The old _CHANNEL_STATS_SELECT ran 9 correlated subqueries for each
channel row. With 1266 channels that was ~11000 sub-executions per
GET /channels request, causing multi-second (or timeout) delays.
New approach: 2 CTEs (vinfo for counts/sums, nc for new_count) each do
a single aggregated pass over all followed-channel videos, joined back
to channels. Only 2 correlated LIMIT-1 subqueries remain for
latest_video_id/title (fast with the new index).
Also adds 4 indexes on startup (IF NOT EXISTS — safe to deploy):
- videos(channel_id, published_at DESC) — latest video lookups
- videos(channel_id, indexed_at) — new_count filter
- user_videos(video_id, user_id) — watch/download aggregation
- user_channels(user_id, status) — followed channel filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sync throttling:
- sync-all now skips channels crawled within the last 6 hours (prevents
re-scraping 1266 channels on every button press)
- Channels are queued into a single _index_channels_batch task that runs
with 1.5s delay between each yt-dlp call instead of firing 1266
background tasks simultaneously
- Startup enrich task reduced from 10 to 3 videos (3 yt-dlp calls on
each container restart)
- Enrich task adds 2s sleep between metadata fetches
SQLite stability:
- busy_timeout=5000 prevents SQLITE_BUSY errors under concurrent load
- synchronous=NORMAL speeds up writes without data loss risk (safe with WAL)
Following page:
- staleTime: 60s on channels query so cached data is reused immediately
on revisit; gcTime keeps it in memory for 5 min
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
List variant:
- Mobile thumbnail w-32 (128px) instead of w-56 (224px) — much less dominant
- Title 12px mobile / 14px desktop (was 15px everywhere)
- Channel/meta 10px mobile / 11px desktop
- Channel avatar shown next to channel name on desktop
- Description hidden on mobile, line-clamp-2 on desktop (was line-clamp-3)
- Actions always visible on mobile (hover:opacity on desktop only)
- Tighter spacing (gap-3, px-2, py-2)
Grid variant:
- Removed description entirely — too cluttered on narrow columns
- Title 12px mobile / 13px desktop
- Channel/meta 10px everywhere
- Avatar 28px mobile / 32px desktop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- App-shell layout (height:100dvh, only main scrolls) so the bottom nav
is a natural flex child and never disappears regardless of browser
chrome show/hide behaviour
- Bottom nav reduced from h-16 to h-14, icons from 20px to 18px, labels
from 10px to 9px — slimmer bar, still readable
- Header: min-w-0 on search prevents horizontal overflow; user/sign-out
hidden on mobile (accessible via Settings); logo shortened to "YT" on
mobile; px-3 / h-12 on mobile instead of px-4 / h-14
- Grid card descriptions hidden on mobile (hidden sm:block) — reduces
height cramping in the 2-column feed
- scrollToTop() utility replaces window.scrollTo so pagination still
scrolls to top within the new scroll container
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All inline SQL queries in the feed endpoint (chronological, random,
inbox, ranked scored CTE, and discovery injection) were missing
c.thumbnail_url AS channel_thumbnail_url — only _VIDEO_SELECT had it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reading from the channels query cache was unreliable (cache might not be
loaded, or channel not followed). Add c.thumbnail_url AS channel_thumbnail_url
to _VIDEO_SELECT so every video response carries its channel avatar directly.
VideoCard uses it with cache as fallback.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Card wrapper overflow-hidden was clipping description text at the card
boundary. Move overflow-hidden + rounding to the thumbnail only so the
card body text has room to render fully.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Date and views share a meta line; description shows up to 2 lines below —
gives enough context to judge a video during discovery without opening it.
Both fields are optional so cards without them stay compact.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
VideoCard grid: YouTube-style layout — channel avatar circle (falls back
to letter) left of title/meta, avatar is clickable to go to channel page,
date on its own line for breathing room, badges inline with channel name
Bottom nav: env(safe-area-inset-bottom) so it clears iOS home indicator,
active tab gets a soft accent pill behind the icon, slightly bolder label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Alongside the CSV option, add a text area that accepts copy-pasted text
from the YouTube subscriptions page. Extracts @handle from lines like
'@handle•N subscribers' using regex, deduplicates, then calls follow-bulk.
Button live-counts how many handles are detected as you paste.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
max_comments takes thread_count,total,replies_per_thread,reply_pages.
Passing just one value left the rest unset which caused yt-dlp to fetch
only 1 comment. Now passes 20,20,0,0 to fetch 20 top-level comments
with no replies. Also switch --no-download to --skip-download.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
--write-comments writes to .info.json reliably; parsing stdout with
--dump-json was never guaranteed to include comments. Use a TemporaryDirectory,
write the info.json there, read it, then let the context manager clean up.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bar depends on reliable dislike data which YouTube doesn't expose.
Show likes inline as "X.XM likes" alongside views and date instead.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comments: switch from CLI --write-comments to yt-dlp Python API with
getcomments=True — more reliable, proper extractor_args dict format
Dislikes: add dislike_count column, fetch from returnyoutubedislike.com
after each video metadata upsert (5s timeout, non-fatal)
UI: replace emoji like count with a like/dislike ratio bar — blue fill
showing like proportion, labels on each end; views stay in meta row
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
yt-dlp separates extractor args with ; not ,. The malformed arg was
causing max_comments to parse as a garbage string, fetching ~1 comment.
Also swap max_comment_depth (not a real YouTube extractor arg) for
comment_sort=top to get highest-engagement comments first.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Same pattern as view_count: model column, yt-dlp extraction, SQL select,
VideoDetail field, startup migration, and display in Watch meta row.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
create_all doesn't add columns to existing tables. Add _add_column_if_missing
helper that checks PRAGMA table_info and runs ALTER TABLE if needed, called
on every startup before FTS setup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Video model: view_count column (Integer, nullable)
- ytdlp._normalize_video: extract view_count from yt-dlp info
- _VIDEO_SELECT: include v.view_count in all queries
- VideoDetail schema: view_count field
- Watch page: formatViews() helper, show "X.XM views" in meta row
alongside date and category
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- VideoComment model (video_id, author, text, likes, is_pinned, published_at)
- fetch_video_comments() in ytdlp.py: top 20 comments, no reply threads,
sorted pinned-first then by likes
- GET /videos/by-yt/{id}/comments — returns cached comments instantly
- POST /videos/by-yt/{id}/comments/refresh — fetches from YouTube, stores, returns
- Watch page: CommentsSection shows "Load comments" button when uncached,
renders comments with author/likes once loaded; Refresh link to re-fetch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Backend: DELETE /stats/taste/{tag} removes the row from user_tag_affinity
- API: deleteTasteTag(tag) helper
- Stats UI: × button on each tag chip, faint by default, full opacity on hover;
invalidates stats query so the tag disappears immediately
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- manifest.json + icon.svg for installability on mobile (standalone mode)
- index.html: theme-color, apple-mobile-web-app meta tags, manifest link
- Settings: Import CSV section reads Google Takeout subscriptions.csv,
extracts UC... channel IDs, calls follow-bulk to follow them all at once
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Name, subscriber count, and action buttons sit at the bottom of the
banner with a gradient overlay. Falls back to a plain dark header when
no banner is available. Description moves below the header.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add bottom tab bar (Home/Following/Discover/Downloads/Settings) for mobile
- Fetch and display channel banner images on channel pages
- Fix ChannelCard: channels without a local DB id now follow+navigate on click
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows node version, yt-dlp version, cookie args, and raw stderr tail
to diagnose download/metadata failures without needing shell access.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A failed login attempt was triggering the global 401 interceptor which
silently redirected back to /login, making the form appear broken.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getQueue was called on the watch page but missing from the import list,
causing a ReferenceError that broke the entire /watch/:id route.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
yt-dlp's EJS (External JavaScript Solver) needs two things:
1. The solver scripts — only bundled with yt-dlp[default], not bare yt-dlp
2. An explicit --js-runtimes flag — Node.js is not the default (Deno is)
Both are now set: pip installs the [default] extras, and /etc/yt-dlp.conf
sets --js-runtimes node globally so every yt-dlp call uses it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
nodeenv is a Python package that downloads a pre-built Node.js binary —
no apt repos, no compilation, guaranteed to work in python:3.12-slim.
The 'node' binary is linked into /usr/local/bin so yt-dlp can find it.
With Node.js available the web client works fully (37 formats) and can
solve YouTube's n-challenge that every other approach was failing on.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
web_embedded: supports cookies, no Node.js/JS runtime needed, 23 video
formats available. android_vr was skipped by yt-dlp when cookies are
present since that client doesn't support cookie auth.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
android_vr provides pre-signed format URLs that bypass YouTube's
n-challenge and signature JS requirements entirely. Tested: 23 video
formats available without any JavaScript runtime installed.
Reverts Node.js Dockerfile addition (which failed to build anyway).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Debian installs nodejs as /usr/bin/nodejs but yt-dlp looks for 'node'.
The symlink ensures yt-dlp can find the runtime.
Diagnostics now report node path/version and yt-dlp version so we can
verify the environment without shelling into the container.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Node.js is required by yt-dlp to solve YouTube's n-challenge (format URL
deobfuscation). Without it the web client returns no video formats.
The tv and ios player clients were removed — both require GVS PO tokens
that we don't have, so they only produce warnings and block every request.
The web client with Node.js installed gives 30+ formats and works cleanly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- _cookie_args() no longer falls through to --cookies-from-browser when
cookies_file is configured but missing. Firefox isn't installed in the
Docker image, so that fallback caused yt-dlp to exit with empty stdout
and every metadata fetch to return "Video not found on YouTube".
- fetch_video_metadata() now retries without auth args if the first call
fails, so a broken cookie config can't block public video fetches.
- Add use_oauth2 setting + full device-auth flow (POST /settings/oauth2-init,
GET /settings/oauth2-status) with OAuth2Section UI in Settings page.
- Add GET /settings/ytdlp-test diagnostics endpoint.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>