Compare commits

..

101 Commits

Author SHA1 Message Date
Mattias Tall
7814fc9718 Add widget API endpoints for backstage dashboard integration
New GET /api/widget/recent — returns recent unwatched videos from followed
channels (title, channel, thumbnail, published_at, duration, direct URL).
New GET /api/widget/stats — unwatched count, new this week, channel count.

Both endpoints auth via X-Widget-Key header (WIDGET_API_KEY env var) so
external services can call without JWT token lifecycle management.
Targets the first admin user's data.

Also: pass WIDGET_API_KEY through docker-compose environment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:48:18 +02:00
Mattias Tall
33e9472f17 Pin all yt-dlp calls to tv_embedded client to stop IP rate limiting
Without a player_client, yt-dlp probes web+mweb+android+tv in sequence
for every request, multiplying API calls ~4x and triggering YouTube IP
blocks. tv_embedded exposes the full format list in one shot and is the
least restricted client for authenticated sessions.

Applies to: search, trending, video/channel metadata, playlists, subs,
comments, and downloads — every yt-dlp invocation site.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:47:02 +02:00
140bf4acf6 revert: remove ios player_client arg from all yt-dlp calls
It caused timeouts and failures on search/flat-playlist/metadata
operations — those extractors don't support the ios client path.
Keeping only the _META_MIN_GAP increase (5s → 12s) which only
affects background polite calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 06:00:41 +02:00
8dcbad6e70 perf: use iOS player client and increase metadata gap to reduce throttling
YouTube has been aggressively throttling the default web client.
The iOS client bypasses PO-token requirements and gets less aggressive
rate limiting from Google's side.

- Add --extractor-args youtube:player_client=ios to all yt-dlp calls:
  fetch_video_metadata, fetch_channel_metadata, search_youtube,
  fetch_available_subs, download_subs_only, fetch_video_comments,
  popular fetch flat-playlist crawl, and start_download
- Increase _META_MIN_GAP from 5s to 12s between metadata calls
- Widen jitter from ±2.5s to ±5s for less predictable request timing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 03:46:54 +02:00
8a5108425b fix: top-bar indicator links to downloads when download/task is active
Previously it always linked to /discovery. Now it links to /downloads
when there are active downloads or tasks, and /discovery only when
discovery is the sole thing running.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 03:35:01 +02:00
09c11da1ce fix: reduce pending deletion window from 7 days to 2 hours
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 03:34:18 +02:00
a3346c6e87 fix: stop discovery from bursting dozens of yt-dlp calls inside one task
Each search/graph/trending task was calling _fetch_and_index_channel
inline for up to 10-15 newly discovered channels, each making up to 4
yt-dlp calls (1 channel metadata + 3 individual video fetches for
dateless entries). This bypassed the 30-90 s worker gap, producing
bursts of 40-60 calls in rapid succession and hammering YouTube.

Changes:
- _fetch_and_index_channel: removed the dateless-video individual
  fetch loop — one call per channel, videos without published_at are
  simply skipped at discovery time
- _search_and_store and _fetch_graph_for_channel: queue channel
  indexing as separate worker tasks (3 and 2 respectively) so the
  30-90 s gap applies between every yt-dlp call, including channel
  indexing
- update_trending_signal and update_graph_signal (old sync path):
  removed inline _fetch_and_index_channel loops (15 and 10 channels)
- _discovery_task in channels.py: replaced run_full_discovery (old
  synchronous path) with schedule_discovery so sync-all and
  follow-by-url go through the queue system

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 03:17:37 +02:00
0c5b236b77 fix: close remaining concurrent yt-dlp session sources
Three more code paths were bypassing the _meta_lock guard and firing
raw yt-dlp processes concurrently with active downloads:

- Popular fetch Phase 1 (flat-playlist channel crawl): changed from
  ytdlp._run to ytdlp._meta_run so it waits for active downloads
- download_subs_only: changed from _run to _meta_run
- fetch_video_comments: returns empty list immediately if a download
  is active (avoids blocking a 90s call indefinitely)
- Diagnostic test endpoint (settings): switched to _meta_run

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 03:12:12 +02:00
c223e57463 fix: prevent concurrent yt-dlp sessions that invalidate cookies
Three code paths could fire yt-dlp immediately (polite=False) while a
download was already running, causing YouTube to see two simultaneous
authenticated sessions and invalidate the cookie:

- search.py: live yt-dlp fallback now skipped while any download is active
- downloads.py: _ensure_video uses polite=True so it waits for active
  downloads to finish before fetching metadata for an unknown video
- channels.py: follow_by_url uses polite=True when fetching metadata
  for a brand-new channel

Added is_download_active() helper to ytdlp.py to expose the active
download state without importing private globals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 03:07:19 +02:00
a0384b2277 Schedule auto-discovery at 4 AM daily instead of every 23 hours
Replaced the rolling 23-hour check with a fixed-time scheduler that sleeps
until the next 4:00 AM, runs discovery for all users, then sleeps until the
following 4 AM. No longer reads last_discovery_run — just runs at the same
time every day.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 02:59:41 +02:00
bcbd552eab Show discovery progress in the top-bar indicator alongside downloads/tasks
The DownloadIndicator in Layout.jsx now queries discovery-status and shows
discovery progress the same way it shows active downloads and channel tasks:
- Spinning icon appears in the navbar when discovery is running
- Hover popover shows "Discovering channels" with X/Y count and a progress bar
- Polls every 10 s while running, 60 s when idle
- Primary label priority: download % > task phase > discovery X/Y

Discovery page header simplified: progress bar and verbose status removed
since the top-bar indicator now handles it. "Find more" button still
disables while running.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 02:58:13 +02:00
1179b53f2e Fix hidden yt-dlp calls on video page causing cookie invalidation
Two background yt-dlp processes were firing every time a video page loaded:

1. importChapters (called unconditionally via useEffect on mount) was calling
   _upsert_video_from_yt with polite=False when chapters=NULL — no rate
   limiter, no download-pause check, runs concurrently with active downloads.
   Fix: return [] immediately when chapters=NULL and let the normal enrichment
   pipeline (already polite=True) fill them in.

2. get_video_by_yt_id schedules a background _enrich task whenever description
   or chapters are NULL. The frontend polls every 3 s while description is
   null, so dozens of enrichment tasks would pile up for the same video.
   Fix: deduplicate with _enriching set — only one background fetch per
   video_id at a time; the set entry is cleared when the task finishes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 02:43:45 +02:00
146a044e69 Fix For You feed: replace broken jitter with proper tier-based sampling
The old approach added ±12 noise to scores that span -365..+100 (recency
uses raw Julian days), so the perturbation could never reorder videos that
differed by more than 24 points — which is almost all of them. Every
reshuffle returned the same ranking.

Fixes:
- Per-channel candidate window: rn <= 15 (was rn <= 5) for a much wider pool
- Candidate pool: up to 600 per page (was limit * 4 = 100)
- Non-overlapping page offsets: page N pulls SQL rows N*600 .. (N+1)*600 so
  pagination actually moves through new material instead of re-reading the same top-100
- Replaced ±12 perturbation with proper tier-based random sampling:
  top 40% → 60% of page, mid 40% → 30%, bottom 20% → 10% wildcards
  Each reshuffle picks a genuinely different mix from the score-ranked pool

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 02:34:05 +02:00
a535e9f22a Add queue-based gradual discovery with shuffled call ordering and progress UI
Each yt-dlp call is now an independent task (one search query, one trending
fetch, one graph channel fetch). Tasks are shuffled together so we don't fire
10 searches in a row, then enqueued with 30-90s random gaps between them —
a full sweep of ~17 tasks completes in roughly 10-25 minutes instead of
hammering YouTube with 21 calls back-to-back.

Fast signals (community, category clusters) still run synchronously at
schedule time since they're pure SQL.

Progress is tracked per-user (total/done/running) and exposed on
GET /api/discovery/status. The Discovery page polls every 10s while
running and shows a progress bar + "Finding channels… X / Y" in the header.
The auto-discovery daemon skips scheduling if a manual sweep is already running.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 02:28:35 +02:00
e6faf8e08e Drastically reduce discovery yt-dlp call count: 64 → ~21
Each yt-dlp call is a separate subprocess that opens a new HTTP session with
YouTube. 64 sessions in a row looks like a bot regardless of rate limiting.

Changes:
- crawl_by_search: 30 queries → 10 (top 5 tags, 4 channel names, 1 serendipity)
- update_liked_signal: 10 queries → 4
- update_watch_signal: removed (tags already included in crawl_by_search)
- update_trending_signal: 2 regions → 1 (first region only)
- update_graph_signal: 12 sampled channels → 6

New total: ~21 yt-dlp calls per run (~105s with 5s gaps) vs ~320s before.
Signal quality is preserved — the removed queries were low-marginal-value
duplicates of content already covered by the remaining ones.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 02:14:25 +02:00
0a4dfb845e Pause discovery metadata calls while a download is active
_meta_run now checks _active_downloads before each background yt-dlp call.
If a download is running it waits (3s poll loop) until the download finishes
before making the next metadata request.

This prevents YouTube from seeing the same session used simultaneously by
a download and a discovery/metadata call, which was causing cookie invalidation
even with private cookie copies.

Downloads still run immediately without waiting for metadata. Background
discovery is the one that yields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 02:12:43 +02:00
c3191aa000 Fix SyntaxError in ytdlp.py start_download: duplicate try block
Two back-to-back try: blocks with only one finally: caused
"expected 'except' or 'finally' block" at startup. Merged into one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 02:04:11 +02:00
395b987644 Add last_discovery_run to UserSettings model
Column was added via SQL migration but missing from the SQLAlchemy model
definition, causing AttributeError when the discovery status endpoint
accesses s.last_discovery_run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 02:03:39 +02:00
12f54ac5b0 Auto-schedule daily discovery + fix Find More UX + expand query diversity
Auto-discovery daemon:
- Runs every hour, triggers full discovery for any user whose last run
  was >23 hours ago. First check is 5 minutes after startup.
- Tracks run time in user_settings.last_discovery_run (new column).
- Manual Find More also stamps last_discovery_run.

Discovery status endpoint (GET /api/discovery/status):
- Returns pending_count (unseen queue size) and last_run timestamp.
- Shown in the Discover page header so users know queue state at a glance.

Find More UX fix:
- Was: kick background task, wait 8 seconds, refetch (task takes minutes).
- Now: button shows "Queued ✓" on success with an explanatory banner
  telling the user it takes a few minutes and also runs daily automatically.

Query diversity:
- Added "best [category] channels" serendipity queries to crawl_by_search.
- Limit raised from 25 to 30 queries per run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 01:58:39 +02:00
4d255647a1 Fix cookie invalidation: give each yt-dlp process a private cookie file copy
Downloads run for minutes via Popen while metadata calls continue in parallel.
Both processes read from AND write back to the same --cookies file, causing
concurrent writes that corrupt the session cookie state.

Fix: _make_private_cookie_copy() intercepts --cookies <file> in any arg list
and swaps it for a NamedTemporaryFile copy. Each yt-dlp process gets its own
snapshot; write-backs go to the throwaway copy and are discarded on cleanup.

- _run() uses this for all subprocess.run calls (metadata, subtitles, comments)
- start_download() uses it for the long-lived Popen download process
- _meta_run() benefits automatically since it calls _run()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 01:48:29 +02:00
592194f2ca Fix discovery scoring: cap trending, prevent score inflation, add freshness
- Cap trending base_score at 18.0 (was unbounded — a viral channel could
  score 240+ vs search's 15, making everything else invisible)
- Cap all discovery scores at 50.0 globally so no single signal dominates
- Fix score accumulation: cap accumulated total at 50.0 (was unbounded
  across repeated runs, cementing high-score channels in top positions forever)
- Expire unseen queue entries older than 14 days at start of each run
- Add ±8 score perturbation to discovery list endpoint (was pure score DESC,
  identical every visit until dismissed)
- Add score perturbation to discovery_videos ORDER BY too
- Fix SQL injection in update_category_clusters (category strings were
  interpolated directly into query; now use parameterized queries per category)
- Raise category signal score from 3.0 → 5.0 to compensate for trending cap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 01:37:09 +02:00
b6a47249d0 Fix search latency: bypass rate limiter for user-triggered searches
search_youtube now takes polite=False (default) for instant user
searches and polite=True for background discovery crawls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 01:27:36 +02:00
86e7648075 Fix _meta_run: hold lock for entire yt-dlp execution, not just scheduling
Previously the lock was released before _run(), so multiple threads could
fire yt-dlp processes simultaneously — completely defeating the rate limiter.
Now the lock is held through the subprocess call and released in finally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 01:21:36 +02:00
1b010d4081 Track video clicks as engagement signals
- stats: started_count now includes any video opened (last_watched_at set)
  not just ones with saved progress seconds
- VideoPlayer: fires updateProgress immediately on open so even a
  click-and-back sets last_watched_at and counts as a started video

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 01:19:47 +02:00
fadb0fffcd Add started_count to stats (videos in progress, not yet finished)
Tracks watch_progress_seconds > 0 AND watched = 0. Shown as
"In progress" card in the engagement row alongside finished/bailed/rewatched.
Total liked moved to engagement row, top row condensed to 3 cards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 01:18:49 +02:00
9d35cc7c68 Increase recommendation density in For You feed to ~25%
1 discovery card per 3 followed videos (was 1 per 5).
Lower-ranked discovery cards also get shuffled so the same
channels don't always appear at fixed positions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 01:17:04 +02:00
bbf7cc939b Overhaul For You feed ranking and freshness
Ranking improvements:
- Wider candidate pool (4x limit) with ±12pt score perturbation so
  same-score videos shuffle differently each load
- Recent channel engagement signal: channels watched in past 30 days
  get a +4pts/watch boost
- Bail penalty: -25pts for videos started but abandoned before 20%
- Impression penalty: -3pts per prior feed appearance (capped at 10),
  so repeatedly-skipped videos sink naturally
- rn cap raised to 5 for more candidates; Python-side sampling picks top limit

Feed UX:
- Reshuffle button now available on For You (ranked) mode, not just Explore
- shuffleKey now always included in query key (not just random mode)
- Ranked mode staleTime reduced from 10min to 90s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 01:14:10 +02:00
c11e1fdaf7 Complete rate-limiter coverage for all background yt-dlp calls
- fetch_playlist_videos now uses _meta_run (background playlist indexing)
- fetch_channel_links now uses _meta_run (dead code, future-proofed)
- _upsert_video_from_yt accepts polite= flag; background enrichment passes polite=True
- Only intentional user-facing one-shot calls (download_subs_only,
  fetch_video_comments, polite=False metadata) bypass the lock

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 01:09:14 +02:00
19dae63385 Route all discovery fetches through global rate limiter
- search_youtube, fetch_trending, fetch_featured_channels now use _meta_run
- Replaced ThreadPoolExecutor(4) parallel searches with sequential loop
- Replaced ThreadPoolExecutor(3) parallel featured-channel fetches with sequential
- _fetch_and_index_channel passes polite=True to fetch_channel/video_metadata

Discovery was firing 4+ simultaneous yt-dlp processes, each with cookies,
which is what invalidated the session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 01:05:56 +02:00
4a7f1f06ac Fix discovery refresh: open fresh DB session in background task
The refresh endpoint was passing the request's db session to the
background task, which is closed before the task runs — silently
doing nothing on every refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 01:01:18 +02:00
1ee6edcb17 Lower finished threshold from 90% to 75%
Applies to frontend player, backend safety net, and stats display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:37:57 +02:00
c26fc3483c Rate limit only background batch fetches, not user requests
fetch_video_metadata and fetch_channel_metadata now take polite=True for
background tasks (enforces 5s+ gap via global lock) while user-facing
calls (watch page, follow channel, download) use polite=False and run
immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:34:59 +02:00
c180c293b0 Add global yt-dlp metadata rate limiter (5s + jitter between calls)
All fetch_video_metadata / fetch_channel_metadata / fetch_channel_playlists
/ fetch_available_subs calls now go through _meta_run which enforces a
minimum 5s gap (+ 0.5-2.5s random jitter) across all concurrent tasks.
Per-task sleep loops removed since the global lock serializes everything.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:31:14 +02:00
15e6b94cbf Expand taste profile: show up to 60 tags with score bars
Top 10 shown as variable-size tag cloud, all tags below as a
two-column bar chart. Backend limit raised from 20 to 60.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:15:57 +02:00
32e55b9042 Hard-cap list view description snippet at 180 chars
line-clamp wasn't clamping reliably; truncate in JS instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:13:24 +02:00
77aebffa49 Set discovery card descriptions to 3 lines
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:10:50 +02:00
ba6dfe321d Trim discovery card descriptions to 1 line
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:08:48 +02:00
31cef555a9 Cap list view description to 3 lines
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:07:55 +02:00
001d2ddcf0 Add Jellyfin NFO sidecar generation for downloaded videos
Writes a Kodi/Jellyfin-compatible .nfo XML file next to each .mp4 on
download completion, deletes it when the download record is removed, and
exposes POST /api/downloads/nfo/generate to backfill NFOs for existing
downloads. Frontend adds a "Generate NFO" button in the Downloads header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:03:58 +02:00
65bc199366 Add browser notification support for new videos
- Settings: Notifications section with toggle that requests browser permission
  and stores preference in localStorage
- Layout: fires a Notification when new_count increases and user isn't on /following
- Works whenever the tab is open (foreground or background)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 23:56:22 +02:00
8029b2517f Slow down popular fetch and enrich to protect cookies
- Popular fetch phase 2: sequential with 2s delay between requests (was 3 parallel workers)
- Reduced from 200 to 100 videos per popular fetch run
- DB writes happen after each video instead of all at end (no data loss on interrupt)
- _enrich_missing_task: delay increased 0.5s → 2s between requests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 23:53:37 +02:00
ff601d3585 Add stats peak hours, RSS feed, channel health view, bulk video download
Stats:
- Peak watching hours chart (24-bar) from last_watched_at timestamps

RSS:
- GET /api/channels/rss — last 100 videos from followed channels as RSS 2.0
- RSS link in Following > Health tab

Channel health:
- New Health tab in Following groups channels into Active / Slow / Dormant / Dead
  based on days since last upload

Bulk video download:
- Select mode on Channel page (Videos tab) with checkboxes
- Sticky bottom bar shows count + Download button
- Queues a download for each selected video

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 23:50:55 +02:00
3652038cf5 Add offline indicator, surprise me button, and continue watching on channel
- Offline banner in nav when backend is unreachable (network error, not 4xx)
- GET /channels/{id}/random — picks random unwatched video, navigates to watch
- GET /channels/{id}/in-progress — videos with >30s progress, not yet watched
- Channel page: 'Surprise me' button (desktop + mobile) navigates to random video
- Channel page: 'Continue watching' row above video list when in-progress videos exist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 23:46:58 +02:00
e02ea12494 Add hover popover to nav download indicator
Shows each active download (title + progress bar) and background task
(label, phase, done/total + bar) on hover. Pure CSS group-hover, no JS state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 23:33:26 +02:00
3a557a1d24 Show popular fetch phases in nav indicator and Downloads page
- Phase 1 (crawling) now creates the task immediately so Downloads shows it
- Phase label updates to 'Enriching view counts' when phase 2 starts
- Nav bar DownloadIndicator also polls /channels/tasks and shows spinning
  indicator + progress % for background tasks (not just file downloads)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 23:32:39 +02:00
be84660e2d Add popular fetch progress to Downloads page
- Track active background tasks in an in-memory dict with a lock
- Expose GET /api/channels/tasks returning running task list
- _fetch_popular_task updates done count as each video fetch completes
- Downloads page polls /tasks every 2s and shows progress bars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 23:17:13 +02:00
c3290d33a7 Reduce parallel YouTube request workers to avoid cookie invalidation
8 simultaneous yt-dlp processes hitting video pages looks like a bot
attack and causes YouTube to nuke the session cookies. Drop to:
- Popular fetch view_count enrichment: 8→3 workers
- Discovery search: 8→4 workers
- Graph signal (featured channels): 8→3 workers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 23:11:07 +02:00
be7319e96c Sample videos randomly for view_count enrichment, not newest-first
Previously ORDER BY published_at DESC meant only the newest 200 videos
ever got view counts. Now ORDER BY RANDOM() spreads the 200 slots across
the full channel history — videos without a count are still prioritised,
but among those they're drawn randomly. Each run of Fetch Popular covers
a different slice, converging toward full coverage over time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 23:06:32 +02:00
6e455ed8ce Fetch popular: flat-playlist crawl then parallel view_count enrichment
Phase 1: crawl the full channel with flat-playlist to store any videos
not yet in DB (fast, no individual requests).
Phase 2: fetch real view_count for up to 200 channel videos in parallel
(8 workers), prioritising those missing a count.
Popular tab sorts all channel videos by view_count DESC NULLS LAST.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 23:05:21 +02:00
ff4d8e4ab4 Popular tab: rank by real view_count, drop broken ?sort=p URL
yt-dlp's own test suite marks channel sort as 'Query for sorting no
longer works' — YouTube blocked it. New approach: fetch view_count for
up to 200 indexed videos in parallel (8 workers, prioritising those
missing counts), then Popular tab sorts by view_count DESC WHERE
view_count IS NOT NULL. Accurate for any channel once enrichment runs.
Frontend refetch wait raised to 60s to cover ~200 parallel fetches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 23:02:03 +02:00
3e699d61b6 Fix popular task failing silently when table doesn't exist
The outer try had no except — any exception (e.g. table missing) killed
the whole background task with no error visible to the user. Now:
- CREATE TABLE IF NOT EXISTS inline so the task works even if the
  startup migration hasn't run (no server restart required)
- Wrap DELETE in its own try/except
- Catch and print outer exceptions so failures appear in server logs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:52:30 +02:00
c3b83ba1d3 Enrich playlist video dates after indexing
flat-playlist mode returns timestamp=null for most playlist entries so
published_at is missing after the initial index. Now kicks off
_enrich_missing_task (scoped to the playlist size) as a daemon thread
immediately after indexing commits, filling in dates and view counts
in the background via individual video fetches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:50:56 +02:00
77cba81ef4 Popular: write Phase 1 immediately, enrich view_count in background
Previously the task waited for all 30 parallel metadata fetches before
writing anything to the DB (~30s). Now Phase 1 (flat-playlist IDs +
basic info) commits to channel_popular_videos immediately (~5s), so the
tab populates fast. Phase 2 (view_count + dates) runs in a daemon thread
while the user is already browsing.

Also: catch table-not-found errors in the sort=popular query so a cold
server returns [] instead of 500. Frontend refetch wait 35s→8s to match
the faster Phase 1 commit time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:47:42 +02:00
be88d70935 Fetch first video thumbnail for playlists with no thumbnail
When yt-dlp returns no thumbnail for a playlist entry, fetch the
playlist's first video (max_videos=1) and derive a stable thumbnail
URL from its video ID. Applied during both the initial fetch and
on index (already done on index in previous commit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:42:58 +02:00
6cfaca382c Fix playlist thumbnails — extract from yt-dlp thumbnails array
_stable_thumbnail expects a video ID but was being passed a playlist ID
(PLxxx), producing a broken URL. Now picks the best thumbnail from
yt-dlp's thumbnails array, falling back to the singular thumbnail field.

Also backfills playlist.thumbnail_url from the first video when indexing
a playlist that still has no thumbnail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:42:30 +02:00
abd7ed7c5a Redesign playlist cards to match VideoCard list style
Replace 2-col grid of small cards with a full-width list layout:
thumbnail on the left, title + status on the right — same proportions
and hover behaviour as VideoCard variant="list". Index/Re-index button
appears on hover, video count shown as a pill overlay on the thumbnail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:41:04 +02:00
112f87e764 Popular tab now shows only flagged popular videos in rank order
Add channel_popular_videos table (channel_id, video_id, rank).
_fetch_popular_task clears and rewrites this table after each fetch.
GET /channels/{id}/videos?sort=popular now JOINs this table and orders
by rank instead of view_count, so the tab shows exactly the videos
YouTube returned in popularity order — nothing more.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:38:53 +02:00
2f37072187 Fix popular fetch and improve date/view_count coverage
Popular fetch now does a two-phase approach: fast flat-playlist to get
IDs in popularity order, then parallel full metadata fetch (8 workers)
to get real view_count and published_at for each video. Previously
flat-playlist mode returned timestamp/view_count as null.

Enrich task now also backfills published_at and view_count (not just
description). Startup limit 3→50, enrichment sleep 2s→0.5s.

Raise all thread pool sizes to match 8-core machine:
- Discovery search: 5→8 workers
- Graph signal: 4→8 workers
- Popular fetch: 5→8 workers
- Download semaphore default 3→6, cap 10→16

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:36:18 +02:00
5b0cf27f07 Add playlists support and fix explore older videos
- New playlists router: fetch channel playlists from YouTube, index
  playlist videos, browse by playlist with pagination
- Playlist model gets video_ids column to store ordered video list
- Register playlists router in main.py with DB migration
- Add Playlists tab to Channel page: grid of playlist cards, click to
  browse videos, index/re-index per playlist
- Fix explore older videos skipping all entries without published_at;
  flat-playlist entries for older videos rarely include timestamp data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:28:35 +02:00
d31fc1ef7f Add Popular tab to channel page
- YouTube sort=p fetch: indexes top 100 most-viewed videos from a channel,
  storing view_count in the DB
- Popular tab on channel page shows videos sorted by view_count DESC
- Videos/Popular tab switcher with context-appropriate fetch buttons
- Expose view_count in VideoOut; add 'popular' sort to channel videos endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:22:10 +02:00
aa91156bbc Add older content exploration: channel page + home feed Rediscover mode
Channel page:
- "Explore older videos" button fetches 100 videos at a time further back
  in the channel history using yt-dlp --playlist-start/--playlist-end
- "Fetch entire history" still available for full crawl
- Backend: /channels/{id}/explore?page=N endpoint + playlist offset support
  in fetch_channel_metadata(start_video=N)

Home feed:
- New "Rediscover" mode: older unwatched videos (90+ days old) from
  followed channels, randomly sampled then re-ranked by tag affinity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:17:20 +02:00
0b482b5d49 Overhaul channel page: search, pagination, fetch all history
- Search bar filters indexed videos server-side; "Search YouTube" button
  triggers a deep channel search and indexes matching results
- Server-side sort (newest/oldest/A-Z/unwatched) + infinite scroll (60/page)
- "Fetch recent" indexes last 30, "Fetch all" indexes full history
- Auto-reindex on page visit if stale (>1h), refetches at 8s
- Add /channels/{id}/index-full endpoint (max_videos=0)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:15:09 +02:00
50d61b5774 Fix crawled_at type error in get_channel
SQLite returns datetime columns as strings via raw text() queries.
Parse crawled_at safely before comparing against utcnow().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:04:35 +02:00
d740fd5224 Auto-reindex channel on page visit if stale
GET /channels/{id} now fires a background _index_channel_task if the
channel hasn't been crawled in the last hour. The frontend refetches
channel + videos 8s after page load to pick up the updated data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:02:59 +02:00
871f668525 Parallelize discovery searches and add graph signal
Run search queries concurrently (5 workers) instead of sequentially —
cuts crawl time dramatically. Add graph signal: fetch featured channels
from followed channels' /channels tab in parallel (4 workers), which
surfaces creator-curated recommendations as a high-signal, diverse pool
that search alone can't reach.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:59:23 +02:00
62c2c73906 Expand discovery pool and remove header logo
Double search results per query (20→40), increase query budget (15→25),
use more tags per signal (6→10-12), index more new channels per refresh
(5→10). Remove the YT logo from the header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:55:52 +02:00
52279752e4 Minimal flat redesign: white accent, remove card backgrounds
Replace yellow accent (#f5a623) with white (#ffffff) across the entire
app. Flatten VideoCard grid variant by removing zinc-900 card background
so content sits directly on the page. Simplify active states, badges,
progress bars, and hover effects throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:50:44 +02:00
366a2ff183 Limit auto-generated subtitle langs to en/sv/ja in CC dropdown
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:40:47 +02:00
8f0ce0756f Activate subtitle track when lang selected from dropdown
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:35:34 +02:00
33e4691619 Fix subtitle size and don't auto-select any track on load
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:33:11 +02:00
6311b90b21 Simplify CC dropdown: downloaded langs at top, YouTube langs below
Single CC chip shows downloaded langs inline. One click opens a dropdown
with optgroups — already-on-disk at top, YouTube-available below loading
async. No re-download needed to select an already-downloaded lang.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:25:41 +02:00
da765ce76e Fix subtitle positioning and show existing langs without re-downloading
- Strip yt-dlp's align:start position:0% cue settings from VTT files
  after both video download and subtitle-only download so CSS ::cue centers them
- CC chip now shows already-downloaded langs (e.g. 'CC: en') directly
  from disk with a '+' button to add more — no YouTube call needed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:22:33 +02:00
75493ed80e Center and style video subtitles via ::cue
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:18:24 +02:00
bbf8365c70 Add subtitle-only download for already-downloaded videos
- download_subs_only(): yt-dlp --skip-download to fetch just .vtt sidecar
- POST /by-yt/{ytId}/download-subs endpoint
- CC chip now visible on downloaded videos; clicking checks YouTube,
  shows lang picker with "Add subtitles" button separate from re-download

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:16:30 +02:00
27f17c16ef Fix subtitle playback: vtt format, track elements, fast disk scan
- Convert subs to .vtt (was .srt which browsers don't support in <track>)
- Add GET /subtitle-files endpoint: instant disk scan for .vtt sidecar files,
  no yt-dlp call needed
- Inject <track> elements into the video player for each .vtt on disk;
  browser CC button appears automatically
- Before download: CC chip triggers YouTube availability check (slow, on demand)
- After download with subs: shows "CC ✓" — subtitles live in the player controls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:11:58 +02:00
97ebcd6c1d Make subtitle availability check lazy (user-triggered, not on page load)
Auto-fetching on every watch page load spawned a yt-dlp process per visit
which could hang and pile up. CC button now triggers the check on demand.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:04:41 +02:00
399c5fcada Add per-video subtitle language picker on Watch page
- fetch_available_subs() queries yt-dlp for manual + auto-generated
  subtitle langs available on YouTube for any given video
- GET /api/videos/by-yt/{ytId}/subs exposes this to the frontend
- DownloadRequest now accepts subtitle_langs to override the global
  setting on a per-download basis
- Watch page fetches available subtitle langs on load (in parallel),
  shows a CC dropdown with manual langs + auto-generated langs labeled
  "(auto)"; selected lang is passed through to the download

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:57:57 +02:00
ea99b74ba8 Add scheduled sync, disk space awareness, and subtitle downloads
- auto-sync daemon: background thread checks every hour and syncs followed
  channels for users with sync_interval_hours set (6/12/24h options)
- disk stats: /api/stats now returns total/used/free/download bytes;
  Stats page shows a disk usage bar
- subtitles: subtitle_langs setting (e.g. "en,sv") passed through all
  download paths; yt-dlp writes .srt files alongside the video
- Settings page: sync interval dropdown + subtitle languages input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:36:50 +02:00
3abbd5749e Remove embed-metadata and embed-thumbnail to speed up post-merge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:26:47 +02:00
2bb5f35db0 Restore embed-metadata/thumbnail for Jellyfin, drop wasteful faststart pass
The Merger+ffmpeg faststart postprocessor arg was overwritten by the
subsequent embed-metadata and embed-thumbnail passes anyway, making it
a pointless extra ffmpeg remux. Dropped it and restored the embeds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:23:19 +02:00
a15123028c Speed up post-download merge by dropping embed-thumbnail and embed-metadata
Both flags trigger extra ffmpeg passes over the entire file after the
stream merge. They're unnecessary — metadata lives in the DB and
thumbnails come from YouTube. Removing them cuts the post-join wait
to just the faststart rewrite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:20:42 +02:00
b41412071a Fix quality: drop unsupported tv_embedded player client override
yt-dlp 2026.03.17 dropped support for tv_embedded — it silently skips it
and falls back to web-only, which only exposes the pre-merged 360p format
(ID 18). The override was added to avoid SABR restrictions but is now the
cause of the low-quality downloads.

Removing --extractor-args restores yt-dlp's default client selection
(android_vr + web fallback) which exposes all formats up to 2160p.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:14:23 +02:00
e9140ab6a1 Fix quality format fallbacks and resolution detection above 1080p
The per-quality format strings fell back to best[height<=NNN] which on
YouTube resolves to pre-merged streams capped at ~360p, causing every
quality selector choice to silently download low-res video. Replace with
bestvideo+bestaudio as the intermediate fallback so adaptive streams are
always preferred over pre-merged ones.

Also fix detect_resolution to correctly label 1440p and 2160p files
instead of capping the display at 1080p.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:07:41 +02:00
Mattias Tall
8da361b087 Fix quality: use tv_embedded,web player client instead of web-only
YouTube's web client gets SABR format restrictions in 2025-2026 yt-dlp,
limiting available streams and causing fallback to 360p. tv_embedded
bypasses SABR and exposes the full format list including 4K.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:48:10 +02:00
Mattias Tall
83e2685c6a Quality selector auto-triggers re-download on change when video is saved
Changing the quality dropdown while a video is already downloaded now
immediately deletes the old file and starts a fresh download at the new
quality — no separate Re-download button needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:41:06 +02:00
Mattias Tall
c24964a1ee Fix quality formats: drop AVC1/MP4 codec restrictions that caused 360p fallback
Most modern YouTube videos use VP9/AV1, so the old bestvideo[ext=mp4][vcodec^=avc1]
filter always failed and fell through to format codes 22/18 (720p/360p).
--merge-output-format mp4 handles the container; no need to restrict codec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:39:55 +02:00
Mattias Tall
744af7337b Add quality indicator and re-download at quality to Watch page
- Quality selector now always visible when idle (not just pre-download)
- Saved chip shows actual downloaded resolution (e.g. "Saved · 1080p")
- Re-download chip deletes existing file and starts new download at selected quality

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:37:03 +02:00
Mattias Tall
623e82fb16 Fix player crash: remove premature pollForFile in handleDownloadAndPlay
The unified effect already gates polling on dlStatus===complete, but
handleDownloadAndPlay was still calling pollForFile immediately on click,
before the download even started.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:30:58 +02:00
Mattias Tall
fe8028c1f9 Fix player crash: gate file polling on confirmed download completion
Never start polling until backend status==="complete" or video.is_downloaded
is true, preventing the player from loading a partially-written file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:29:07 +02:00
Mattias Tall
94f74215e2 Consistency sweep: fix switcher layout, gaps, buttons, empty states
- Home: mode switcher moved to its own row (no longer crammed next to
  title on mobile), hide-watched simplified to text-only toggle
- Home/History/Discovery: pagination buttons text-sm → text-xs, page
  counter text-sm → text-xs
- Liked/Downloads/SearchResults: top-level gap-8 → gap-6
- Liked: refresh button px-4 py-2 text-sm → px-3 py-1.5 text-xs
- Empty states: standardize to text-zinc-500 text-sm across Queue,
  ContinueWatching, History, Following, Discovery, Liked
- Following: "Latest uploads" tab label → "Feed"
- Home: remove -mt-3 hacks from mode description rows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:21:02 +02:00
Mattias Tall
f422d754b9 Compact all mode/tab switchers site-wide
Home feed mode: px-3 py-1.5 text-sm → px-2 py-1 text-xs, tighter container
Hide-watched toggle: same reduction
Following tabs: px-4 py-2 text-sm → px-3 py-1.5 text-xs, "Latest uploads" → "Feed"
Discovery tabs: same reduction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:12:18 +02:00
Mattias Tall
ebd8ddee6e Fix channel page mobile layout
On mobile: move action buttons below the banner into their own row
(flex-1 Follow + Download, compact Re-index) instead of cramming
three full-size buttons inside the banner overlay alongside the avatar
and name. Desktop keeps the original inline layout. Also reduced
banner height, avatar size, and description to 2-line clamp on mobile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:09:17 +02:00
Mattias Tall
ca5196d9f1 Restore dislike signal as icon-only chip next to Like
Removed the label ('Not for me') and kept it icon-only to match the
minimal direction, but the -3.0 affinity signal still fires so the
feed learns to show less of that content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:04:16 +02:00
Mattias Tall
ed55478599 Minimal UX pass: cut action clutter, more breathing room
- VideoCard: actions now hover-only everywhere (was always-visible on mobile)
- Watch: remove Good/Bad rating chips — Like covers positive signal,
  dismiss/skip covers negative. Fewer buttons, same data.
- Watch: PiP and Theater are now icon-only (no label)
- Watch: increase content gap to gap-4/gap-5 for more breathing room

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:01:50 +02:00
Mattias Tall
d6035e6f1f Trim description clutter: strip URLs in cards, tighter preview in Watch
- VideoCard list variant: strip URLs/affiliate links before rendering,
  collapse to 1 line (was 2 lines of raw text including https:// spam)
- DescriptionBox collapsed preview: 2 lines / 200 chars before "Show more"
  (was 4 lines — too much affiliate crap before you can dismiss it)
- Full description still shown when expanded in Watch view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:53:01 +02:00
Mattias Tall
3e3d2c7464 Fix discovery to actually use negative affinity signals
Previously the engine was blind to dislikes/dismissals:
- _build_user_tag_profile only used liked/watched (positive only)
- dismiss_penalty was capped at 80% so hated content still surfaced
- _search_and_store had zero affinity filtering, any YouTube result entered the queue
- user_tag_affinity negative scores (written by dismiss/dislike) were never read

Now:
- _build_user_tag_profile reads directly from user_tag_affinity (positive + negative)
- _tag_relevance_score returns negative values, so disliked-tag channels score below zero and get dropped
- _search_and_store skips channels whose indexed videos match 3+ negatively-rated tags
- list_discovery post-filters channels already in the queue using the same neg-affinity check
- Removed the old _dismissed_channel_tags + dismiss_penalty (superseded)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:48:39 +02:00
Mattias Tall
6f600c9a5c UX: list view everywhere, mobile polish, affinity dismissal fix
- Default list view across all pages (Home, Following, History, Queue,
  ContinueWatching, Liked, Discovery, SearchResults, Channel)
- Watch.jsx mobile: smaller chips/title/avatar/meta, hide tags + keyboard
  hint on mobile, tighter gaps, compact description padding
- Fix mobile bottom nav showing focus outline on tap
- Fix _update_affinity to write negative entries (not just positive) so
  dislikes/dismissals on unseen content actually register
- Dismissing a discovery video now fires -3.0 affinity against its tags,
  matching the dislike weight

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:41:22 +02:00
Mattias Tall
fc05a40f02 Fix affinity scoring, add completion signal, seamless local player switch
Ranked feed — affinity was broken:
- Was looking up user_tag_affinity by v.category (e.g. "Science & Technology")
  but affinity is stored using fine-grained video tags ("linux", "rust", etc.)
- Now uses SUM across all matching affinities: category OR any tag found in the
  video's tags JSON via instr() — up to 5 matches to prevent runaway scores

Ranked feed — completion rate now influences channel scoring:
- Added avg_completion_pct to channel_stats CTE (AVG of completion_percent)
- Channels where you finish videos score higher; channels you bail on score lower
- Defaults to 50% (neutral) for channels with no tracked completions

Progress endpoint — backend auto-watched safety net:
- If completion_percent reaches ≥90% on a video >60s, mark watched automatically
- Catches cases where browser closes before the 10s debounce fires
- Guards against double-calling _update_affinity with not prev_watched check

VideoPlayer — seamless local file switch:
- Removed switchedToLocal state which caused a race condition: video loaded with
  local_file_url already set but flag was still false, requiring a page refresh
- local_file_url from the backend is the single source of truth (backend gates
  it with os.path.exists so it only appears when the file is actually on disk)
- Show spinner while video metadata loads, then immediately show local player
  if file exists — no YouTube flash for already-downloaded videos
- After download completes, single refetchVideo() picks up the new URL and
  React re-renders directly into local player

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:30:29 +02:00
Mattias Tall
c00d5c7595 Optimise Following page: 4 aggregated queries, no correlated subqueries
- Rewrite list_channels to run exactly 4 SQL queries regardless of channel
  count: channel rows, aggregated video stats (GROUP BY), new-video counts,
  and latest video (derived-table JOIN replaces per-row correlated subquery)
- Remove dead _CHANNEL_STATS_SELECT (orphaned after the rewrite)
- Fix upload_frequency_days: use pre-computed date_span_days from vstats
  instead of a broken per-channel db.execute() call
- Restrict new_counts query to id_csv so it uses idx_videos_channel_indexed
- markChannelsSeen: optimistic setQueryData instead of invalidateQueries,
  eliminating a full channel-list re-fetch on every Following page visit
- DownloadIndicator idle poll: 10s → 30s (no need to hit DB when idle)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:18:33 +02:00
Mattias Tall
1405acfaed Revert channel stats to correlated subqueries (CTE had a param binding bug)
The CTE approach returned 0 rows — likely a SQLite/SQLAlchemy interaction
with :user_id appearing in multiple CTEs. Reverted to the original
correlated-subquery form which is proven correct.

The 4 indexes added in the previous commit still apply and will make
the per-channel subqueries faster once the DB is indexed on startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:10:24 +02:00
Mattias Tall
74e9a52096 Fix Following page: replace 9-subquery-per-channel stats with 2 CTEs + indexes
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>
2026-05-26 16:04:41 +02:00
36 changed files with 3819 additions and 753 deletions

View File

@@ -9,6 +9,7 @@ class Settings(BaseSettings):
secret_key: str = "changeme-use-a-real-secret-in-production" secret_key: str = "changeme-use-a-real-secret-in-production"
algorithm: str = "HS256" algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 7 # 1 week access_token_expire_minutes: int = 60 * 24 * 7 # 1 week
widget_api_key: str = "" # set WIDGET_API_KEY in env for backstage integration

View File

@@ -89,6 +89,18 @@ def init_db():
_add_column_if_missing(raw_conn, "videos", "like_count", "INTEGER") _add_column_if_missing(raw_conn, "videos", "like_count", "INTEGER")
_add_column_if_missing(raw_conn, "videos", "dislike_count", "INTEGER") _add_column_if_missing(raw_conn, "videos", "dislike_count", "INTEGER")
raw_conn.commit() raw_conn.commit()
# Indexes that make the channel-stats CTE query fast with many channels
for idx_sql in [
"CREATE INDEX IF NOT EXISTS idx_videos_channel_published ON videos(channel_id, published_at DESC)",
"CREATE INDEX IF NOT EXISTS idx_videos_channel_indexed ON videos(channel_id, indexed_at)",
"CREATE INDEX IF NOT EXISTS idx_user_videos_video_user ON user_videos(video_id, user_id)",
"CREATE INDEX IF NOT EXISTS idx_user_channels_user_status ON user_channels(user_id, status)",
]:
try:
raw_conn.execute(idx_sql)
except Exception:
pass
raw_conn.commit()
# executescript handles multi-statement SQL including trigger BEGIN...END blocks # executescript handles multi-statement SQL including trigger BEGIN...END blocks
raw_conn.executescript(FTS_SETUP_SQL) raw_conn.executescript(FTS_SETUP_SQL)
finally: finally:

View File

@@ -6,7 +6,7 @@ from fastapi.staticfiles import StaticFiles
from .config import settings from .config import settings
from .database import init_db, SessionLocal from .database import init_db, SessionLocal
from .services import ytdlp as ytdlp_service from .services import ytdlp as ytdlp_service
from .routers import auth, channels, videos, search, downloads, discovery, settings as settings_router, stats as stats_router, export as export_router, collections as collections_router, admin as admin_router from .routers import auth, channels, videos, search, downloads, discovery, settings as settings_router, stats as stats_router, export as export_router, collections as collections_router, admin as admin_router, playlists as playlists_router, widget as widget_router
app = FastAPI(title="YouTube Hub", version="0.1.0") app = FastAPI(title="YouTube Hub", version="0.1.0")
@@ -29,6 +29,8 @@ app.include_router(stats_router.router, prefix="/api/stats", tags=["stats"])
app.include_router(export_router.router, prefix="/api/export", tags=["export"]) app.include_router(export_router.router, prefix="/api/export", tags=["export"])
app.include_router(collections_router.router, prefix="/api/collections", tags=["collections"]) app.include_router(collections_router.router, prefix="/api/collections", tags=["collections"])
app.include_router(admin_router.router, prefix="/api/admin", tags=["admin"]) app.include_router(admin_router.router, prefix="/api/admin", tags=["admin"])
app.include_router(playlists_router.router, prefix="/api/playlists", tags=["playlists"])
app.include_router(widget_router.router, prefix="/api/widget", tags=["widget"])
os.makedirs(settings.download_path, exist_ok=True) os.makedirs(settings.download_path, exist_ok=True)
@@ -71,6 +73,29 @@ def on_startup():
"ALTER TABLE user_settings ADD COLUMN feed_weight_affinity REAL DEFAULT 5.0", "ALTER TABLE user_settings ADD COLUMN feed_weight_affinity REAL DEFAULT 5.0",
"ALTER TABLE user_settings ADD COLUMN feed_weight_channel REAL DEFAULT 5.0", "ALTER TABLE user_settings ADD COLUMN feed_weight_channel REAL DEFAULT 5.0",
"ALTER TABLE user_settings ADD COLUMN use_oauth2 INTEGER DEFAULT 0", "ALTER TABLE user_settings ADD COLUMN use_oauth2 INTEGER DEFAULT 0",
"ALTER TABLE user_settings ADD COLUMN sync_interval_hours INTEGER DEFAULT 0",
"ALTER TABLE user_settings ADD COLUMN subtitle_langs TEXT DEFAULT ''",
"""CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
youtube_playlist_id TEXT NOT NULL UNIQUE,
channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
thumbnail_url TEXT,
video_count INTEGER DEFAULT 0,
video_ids TEXT,
indexed_at DATETIME,
crawled_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
"ALTER TABLE playlists ADD COLUMN video_ids TEXT",
"""CREATE TABLE IF NOT EXISTS channel_popular_videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
rank INTEGER NOT NULL,
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(channel_id, video_id)
)""",
"""CREATE TABLE IF NOT EXISTS search_history ( """CREATE TABLE IF NOT EXISTS search_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -106,6 +131,8 @@ def on_startup():
note TEXT DEFAULT '', note TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""", )""",
"ALTER TABLE user_videos ADD COLUMN feed_shown_count INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE user_settings ADD COLUMN last_discovery_run DATETIME DEFAULT NULL",
]: ]:
try: try:
db.execute(text(col_sql)) db.execute(text(col_sql))
@@ -149,10 +176,94 @@ def on_startup():
finally: finally:
db.close() db.close()
# Backfill descriptions for videos that don't have them yet (runs in background) # Start discovery worker and backfill enrichment
import threading import threading
from .routers.channels import _enrich_missing_task from .routers.channels import _enrich_missing_task, _index_channels_batch
threading.Thread(target=_enrich_missing_task, args=(3,), daemon=True).start() from .services.discovery import start_discovery_worker
start_discovery_worker()
threading.Thread(target=_enrich_missing_task, args=(50,), daemon=True).start()
def _auto_sync_daemon():
import time
from datetime import datetime, timedelta
from sqlalchemy import text as _text
while True:
time.sleep(3600)
try:
db = SessionLocal()
try:
users_due = db.execute(
_text("SELECT user_id, sync_interval_hours FROM user_settings WHERE sync_interval_hours > 0")
).mappings().all()
for row in users_due:
uid = row["user_id"]
cutoff = datetime.utcnow() - timedelta(hours=row["sync_interval_hours"])
ch_ids = [
r["id"] for r in db.execute(
_text("""
SELECT c.id FROM channels c
JOIN user_channels uc ON c.id = uc.channel_id
WHERE uc.user_id = :uid AND uc.status = 'followed'
AND (c.crawled_at IS NULL OR c.crawled_at < :cutoff)
ORDER BY COALESCE(c.crawled_at, '1970-01-01') ASC
"""),
{"uid": uid, "cutoff": cutoff},
).mappings().all()
]
if ch_ids:
threading.Thread(
target=_index_channels_batch, args=(ch_ids, uid), daemon=True
).start()
finally:
db.close()
except Exception:
pass
threading.Thread(target=_auto_sync_daemon, daemon=True).start()
def _auto_discovery_daemon():
import time as _time
from datetime import datetime as _dt, timedelta as _td
from sqlalchemy import text as _text
from .services.discovery import schedule_discovery, get_discovery_progress
def _seconds_until_4am():
now = _dt.now()
target = now.replace(hour=4, minute=0, second=0, microsecond=0)
if now >= target:
target += _td(days=1)
return (target - now).total_seconds()
# Sleep until the next 4 AM before doing anything
_time.sleep(_seconds_until_4am())
while True:
try:
db = SessionLocal()
try:
rows = db.execute(_text("""
SELECT u.id AS user_id,
COALESCE(us.discovery_regions, 'US,SE') AS discovery_regions
FROM users u
LEFT JOIN user_settings us ON u.id = us.user_id
""")).mappings().all()
for row in rows:
uid = row["user_id"]
prog = get_discovery_progress(uid)
if prog and prog.get("running"):
continue
regions = [r.strip().upper() for r in (row["discovery_regions"] or "US,SE").split(",") if r.strip()]
schedule_discovery(uid, regions)
finally:
db.close()
except Exception:
pass
# Sleep until the next 4 AM
_time.sleep(_seconds_until_4am())
threading.Thread(target=_auto_discovery_daemon, daemon=True).start()
@app.get("/api/health") @app.get("/api/health")

View File

@@ -122,6 +122,9 @@ class UserSettings(Base):
feed_weight_affinity = Column(Float, default=5.0) # 010 feed_weight_affinity = Column(Float, default=5.0) # 010
feed_weight_channel = Column(Float, default=5.0) # 010 feed_weight_channel = Column(Float, default=5.0) # 010
use_oauth2 = Column(Boolean, default=False) use_oauth2 = Column(Boolean, default=False)
sync_interval_hours = Column(Integer, default=0) # 0 = disabled, 6/12/24 = auto-sync interval
subtitle_langs = Column(String, default="") # "" = disabled, "en", "en,sv", etc.
last_discovery_run = Column(DateTime, nullable=True, default=None)
class DiscoveryQueue(Base): class DiscoveryQueue(Base):
@@ -201,6 +204,21 @@ class CollectionItem(Base):
added_at = Column(DateTime, default=datetime.utcnow) added_at = Column(DateTime, default=datetime.utcnow)
class Playlist(Base):
__tablename__ = "playlists"
id = Column(Integer, primary_key=True, index=True)
youtube_playlist_id = Column(String, unique=True, nullable=False, index=True)
channel_id = Column(Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=True)
title = Column(String, nullable=False)
description = Column(Text)
thumbnail_url = Column(String)
video_count = Column(Integer, default=0)
video_ids = Column(Text) # JSON array of youtube_video_id strings
indexed_at = Column(DateTime)
crawled_at = Column(DateTime, default=datetime.utcnow)
class GraphEdge(Base): class GraphEdge(Base):
__tablename__ = "graph_edges" __tablename__ = "graph_edges"
__table_args__ = (UniqueConstraint("from_channel_id", "to_channel_id"),) __table_args__ = (UniqueConstraint("from_channel_id", "to_channel_id"),)

View File

@@ -1,4 +1,5 @@
import json import json
import threading as _threading
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
@@ -14,6 +15,9 @@ from ..services import ytdlp
router = APIRouter() router = APIRouter()
_tasks: dict = {}
_tasks_lock = _threading.Lock()
class ChannelOut(BaseModel): class ChannelOut(BaseModel):
id: int id: int
@@ -62,41 +66,11 @@ class VideoOut(BaseModel):
is_downloaded: bool = False is_downloaded: bool = False
is_watched: bool = False is_watched: bool = False
queued: bool = False queued: bool = False
view_count: Optional[int] = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
_CHANNEL_STATS_SELECT = """
SELECT c.*, uc.status, uc.auto_download, uc.muted_until, uc.notes,
(SELECT COUNT(*) FROM videos WHERE channel_id = c.id) AS video_count,
(SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_published_at,
(SELECT COUNT(*) FROM videos v
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE v.channel_id = c.id AND COALESCE(uv.watched, 0) = 0) AS unwatched_count,
(SELECT COUNT(*) FROM videos v
JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE v.channel_id = c.id AND uv.watched = 1) AS watched_count,
(SELECT COUNT(*) FROM videos v
JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE v.channel_id = c.id AND uv.downloaded = 1) AS downloaded_count,
(SELECT COUNT(*) FROM videos v
WHERE v.channel_id = c.id
AND (uc.last_seen_at IS NULL OR v.indexed_at > uc.last_seen_at)) AS new_count,
(SELECT v.youtube_video_id FROM videos v
WHERE v.channel_id = c.id ORDER BY v.published_at DESC LIMIT 1) AS latest_video_id,
(SELECT v.title FROM videos v
WHERE v.channel_id = c.id ORDER BY v.published_at DESC LIMIT 1) AS latest_video_title,
(SELECT
CASE WHEN COUNT(*) < 2 THEN NULL
ELSE CAST((julianday(MAX(sub.published_at)) - julianday(MIN(sub.published_at))) AS REAL) / (COUNT(*) - 1)
END
FROM (SELECT published_at FROM videos WHERE channel_id = c.id AND published_at IS NOT NULL ORDER BY published_at DESC LIMIT 15) sub
) AS upload_frequency_days
FROM channels c
JOIN user_channels uc ON c.id = uc.channel_id
WHERE uc.user_id = :user_id AND uc.status = 'followed'
"""
def _get_channel_or_404(db: Session, channel_id: int) -> Channel: def _get_channel_or_404(db: Session, channel_id: int) -> Channel:
c = db.query(Channel).filter(Channel.id == channel_id).first() c = db.query(Channel).filter(Channel.id == channel_id).first()
@@ -106,15 +80,11 @@ def _get_channel_or_404(db: Session, channel_id: int) -> Channel:
def _index_channels_batch(channel_ids: list[int], user_id: int, delay: float = 1.5): def _index_channels_batch(channel_ids: list[int], user_id: int, delay: float = 1.5):
"""Run channel syncs sequentially with a polite delay between requests.""" for cid in channel_ids:
import time
for i, cid in enumerate(channel_ids):
if i > 0:
time.sleep(delay)
_index_channel_task(cid, user_id) _index_channel_task(cid, user_id)
def _index_channel_task(channel_id: int, user_id: int): def _index_channel_task(channel_id: int, user_id: int, max_videos: int = 30):
from ..database import SessionLocal from ..database import SessionLocal
db = SessionLocal() db = SessionLocal()
try: try:
@@ -122,7 +92,7 @@ def _index_channel_task(channel_id: int, user_id: int):
if not channel: if not channel:
return return
result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id) result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id, max_videos=max_videos, polite=True)
if not result: if not result:
return return
@@ -178,6 +148,7 @@ def _index_channel_task(channel_id: int, user_id: int):
if channel_auto: if channel_auto:
quality = user_settings.preferred_quality if user_settings else "best" quality = user_settings.preferred_quality if user_settings else "best"
subtitle_langs = (user_settings.subtitle_langs or "") if user_settings else ""
from ..routers.downloads import _on_progress, _on_complete, _on_error from ..routers.downloads import _on_progress, _on_complete, _on_error
for yt_id, vid_id in new_video_ids: for yt_id, vid_id in new_video_ids:
existing_dl = db.query(Download).filter_by( existing_dl = db.query(Download).filter_by(
@@ -190,7 +161,7 @@ def _index_channel_task(channel_id: int, user_id: int):
import threading import threading
t = threading.Thread( t = threading.Thread(
target=ytdlp.start_download, target=ytdlp.start_download,
args=(yt_id, dl.id, _on_progress, _on_complete, _on_error, quality), args=(yt_id, dl.id, _on_progress, _on_complete, _on_error, quality, subtitle_langs),
daemon=True, daemon=True,
) )
t.start() t.start()
@@ -202,28 +173,24 @@ def _index_channel_task(channel_id: int, user_id: int):
def _discovery_task(user_id: int): def _discovery_task(user_id: int):
from ..database import SessionLocal from ..services.discovery import schedule_discovery
from ..services.discovery import run_full_discovery
db = SessionLocal()
try: try:
run_full_discovery(db, user_id) schedule_discovery(user_id)
except Exception: except Exception:
pass pass
finally:
db.close()
def _enrich_missing_task(limit: int = 20): def _enrich_missing_task(limit: int = 20):
"""Fetch full metadata for videos that are missing a description.""" """Fetch full metadata for videos missing description, published_at, or view_count."""
from ..database import SessionLocal from ..database import SessionLocal
import time
db = SessionLocal() db = SessionLocal()
try: try:
rows = db.execute( rows = db.execute(
text(""" text("""
SELECT v.id, v.youtube_video_id FROM videos v SELECT v.id, v.youtube_video_id FROM videos v
WHERE v.description IS NULL WHERE v.description IS NULL OR v.published_at IS NULL OR v.view_count IS NULL
ORDER BY ORDER BY
-- prioritise: followed-channel videos first, then discovery queue, then rest
(EXISTS (SELECT 1 FROM user_channels uc (EXISTS (SELECT 1 FROM user_channels uc
WHERE uc.channel_id = v.channel_id AND uc.status = 'followed')) DESC, WHERE uc.channel_id = v.channel_id AND uc.status = 'followed')) DESC,
(EXISTS (SELECT 1 FROM discovery_queue dq (EXISTS (SELECT 1 FROM discovery_queue dq
@@ -233,16 +200,18 @@ def _enrich_missing_task(limit: int = 20):
"""), """),
{"limit": limit}, {"limit": limit},
).mappings().all() ).mappings().all()
for i, row in enumerate(rows): for row in rows:
if i > 0:
import time; time.sleep(2)
try: try:
meta = ytdlp.fetch_video_metadata(row["youtube_video_id"]) meta = ytdlp.fetch_video_metadata(row["youtube_video_id"], polite=True)
if meta: if meta:
vid = db.query(Video).filter_by(id=row["id"]).first() vid = db.query(Video).filter_by(id=row["id"]).first()
if vid: if vid:
if meta.get("description") is not None: if meta.get("description") is not None:
vid.description = meta["description"] or "" vid.description = meta["description"] or ""
if not vid.published_at and meta.get("published_at"):
vid.published_at = meta["published_at"]
if vid.view_count is None and meta.get("view_count") is not None:
vid.view_count = meta["view_count"]
if not vid.tags and meta.get("tags"): if not vid.tags and meta.get("tags"):
vid.tags = meta["tags"] vid.tags = meta["tags"]
if not vid.category and meta.get("category"): if not vid.category and meta.get("category"):
@@ -306,11 +275,68 @@ def sync_all_channels(
background_tasks.add_task(_index_channels_batch, ids, current_user.id) background_tasks.add_task(_index_channels_batch, ids, current_user.id)
background_tasks.add_task(_discovery_task, current_user.id) background_tasks.add_task(_discovery_task, current_user.id)
background_tasks.add_task(_enrich_missing_task, 5) background_tasks.add_task(_enrich_missing_task, 30)
return {"indexing": len(channels)} return {"indexing": len(channels)}
@router.get("/rss")
def rss_feed(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from fastapi.responses import Response
rows = db.execute(
text("""
SELECT v.youtube_video_id, v.title, v.description, v.published_at,
c.name AS channel_name, c.youtube_channel_id
FROM videos v
JOIN channels c ON v.channel_id = c.id
JOIN user_channels uc ON c.id = uc.channel_id AND uc.user_id = :uid AND uc.status = 'followed'
WHERE v.published_at IS NOT NULL
ORDER BY v.published_at DESC
LIMIT 100
"""),
{"uid": current_user.id},
).mappings().all()
def esc(s):
if not s:
return ""
return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
items = []
for r in rows:
pub = r["published_at"]
pub_str = pub.strftime("%a, %d %b %Y %H:%M:%S +0000") if pub else ""
yt_id = r["youtube_video_id"]
items.append(f""" <item>
<title>{esc(r['title'])}</title>
<link>https://www.youtube.com/watch?v={yt_id}</link>
<description>{esc(r['description'] or '')}</description>
<author>{esc(r['channel_name'])}</author>
<pubDate>{pub_str}</pubDate>
<guid>https://www.youtube.com/watch?v={yt_id}</guid>
</item>""")
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>YTContinue — Following</title>
<link>https://www.youtube.com/</link>
<description>Latest videos from your followed channels</description>
{chr(10).join(items)}
</channel>
</rss>"""
return Response(content=xml, media_type="application/rss+xml; charset=utf-8")
@router.get("/tasks")
def get_active_tasks(current_user: User = Depends(get_current_user)):
with _tasks_lock:
return list(_tasks.values())
@router.post("/mark-seen", status_code=204) @router.post("/mark-seen", status_code=204)
def mark_channels_seen( def mark_channels_seen(
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -328,11 +354,122 @@ def list_channels(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
rows = db.execute( uid = current_user.id
text(_CHANNEL_STATS_SELECT + "ORDER BY last_published_at DESC"),
{"user_id": current_user.id}, # Step 1 — channel rows + user_channel metadata (fast, no video stats)
ch_rows = db.execute(
text("""
SELECT c.id, c.youtube_channel_id, c.name, c.description,
c.thumbnail_url, c.banner_url, c.subscriber_count, c.crawled_at,
uc.status, uc.auto_download, uc.muted_until, uc.notes, uc.last_seen_at
FROM channels c
JOIN user_channels uc ON c.id = uc.channel_id
WHERE uc.user_id = :uid AND uc.status = 'followed'
"""),
{"uid": uid},
).mappings().all() ).mappings().all()
return [ChannelOut(**dict(r)) for r in rows]
if not ch_rows:
return []
id_csv = ",".join(str(r["id"]) for r in ch_rows)
last_seen = {r["id"]: r["last_seen_at"] for r in ch_rows}
# Step 2 — aggregated video stats for all channels in one query
vstats = {
r["channel_id"]: r
for r in db.execute(
text(f"""
SELECT v.channel_id,
COUNT(*) AS video_count,
MAX(v.published_at) AS last_published_at,
julianday(MAX(v.published_at)) - julianday(MIN(v.published_at)) AS date_span_days,
SUM(CASE WHEN COALESCE(uv.watched, 0) = 0 THEN 1 ELSE 0 END) AS unwatched_count,
SUM(CASE WHEN uv.watched = 1 THEN 1 ELSE 0 END) AS watched_count,
SUM(CASE WHEN uv.downloaded = 1 THEN 1 ELSE 0 END) AS downloaded_count
FROM videos v
LEFT JOIN user_videos uv ON uv.video_id = v.id AND uv.user_id = :uid
WHERE v.channel_id IN ({id_csv})
GROUP BY v.channel_id
"""),
{"uid": uid},
).mappings().all()
}
# Step 3 — new-video count per channel (videos indexed after last_seen_at)
new_counts = {
r["channel_id"]: r["new_count"]
for r in db.execute(
text(f"""
SELECT v.channel_id, COUNT(*) AS new_count
FROM videos v
JOIN user_channels uc
ON uc.channel_id = v.channel_id
AND uc.user_id = :uid
WHERE v.channel_id IN ({id_csv})
AND (uc.last_seen_at IS NULL OR v.indexed_at > uc.last_seen_at)
GROUP BY v.channel_id
"""),
{"uid": uid},
).mappings().all()
}
# Step 4 — latest video id + title per channel (derived-table join, no correlated subquery)
latest = {
r["channel_id"]: r
for r in db.execute(
text(f"""
SELECT v.channel_id,
v.youtube_video_id AS latest_video_id,
v.title AS latest_video_title
FROM videos v
JOIN (
SELECT channel_id, MAX(published_at) AS max_pub
FROM videos
WHERE channel_id IN ({id_csv})
GROUP BY channel_id
) m ON v.channel_id = m.channel_id AND v.published_at = m.max_pub
GROUP BY v.channel_id
"""),
).mappings().all()
}
# Merge and build response
result = []
for r in ch_rows:
cid = r["id"]
vs = vstats.get(cid) or {}
vc = vs.get("video_count") or 0
newest = vs.get("last_published_at")
span = vs.get("date_span_days")
freq = (span / (vc - 1.0)) if (vc >= 2 and span is not None) else None
result.append(ChannelOut(
id=cid,
youtube_channel_id=r["youtube_channel_id"],
name=r["name"],
description=r["description"],
thumbnail_url=r["thumbnail_url"],
banner_url=r.get("banner_url"),
subscriber_count=r.get("subscriber_count"),
crawled_at=r.get("crawled_at"),
status=r["status"],
auto_download=r.get("auto_download"),
muted_until=r.get("muted_until"),
notes=r.get("notes") or "",
video_count=vc,
last_published_at=newest,
unwatched_count=vs.get("unwatched_count") or 0,
watched_count=vs.get("watched_count") or 0,
downloaded_count=vs.get("downloaded_count") or 0,
new_count=new_counts.get(cid, 0),
latest_video_id=latest.get(cid, {}).get("latest_video_id"),
latest_video_title=latest.get(cid, {}).get("latest_video_title"),
upload_frequency_days=freq,
))
result.sort(key=lambda c: c.last_published_at or datetime.min, reverse=True)
return result
# ── Channel Groups (must be before /{channel_id} to avoid route shadowing) ─── # ── Channel Groups (must be before /{channel_id} to avoid route shadowing) ───
@@ -465,6 +602,7 @@ def bulk_channel_action(
@router.get("/{channel_id}", response_model=ChannelOut) @router.get("/{channel_id}", response_model=ChannelOut)
def get_channel( def get_channel(
channel_id: int, channel_id: int,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@@ -495,11 +633,303 @@ def get_channel(
).mappings().first() ).mappings().first()
if not row: if not row:
raise HTTPException(status_code=404, detail="Channel not found") raise HTTPException(status_code=404, detail="Channel not found")
# Re-index in the background if stale (not crawled in the last hour)
stale = True
try:
crawled_at_raw = row.get("crawled_at")
if crawled_at_raw:
crawled_at = (
crawled_at_raw if isinstance(crawled_at_raw, datetime)
else datetime.fromisoformat(str(crawled_at_raw))
)
stale = (datetime.utcnow() - crawled_at).total_seconds() > 3600
except Exception:
pass
if stale:
background_tasks.add_task(_index_channel_task, channel_id, current_user.id)
return ChannelOut(**dict(row)) return ChannelOut(**dict(row))
@router.get("/{channel_id}/videos", response_model=list[VideoOut]) @router.get("/{channel_id}/videos", response_model=list[VideoOut])
def get_channel_videos( def get_channel_videos(
channel_id: int,
sort: str = "newest",
offset: int = 0,
limit: int = 60,
q: str = "",
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_get_channel_or_404(db, channel_id)
params: dict = {"user_id": current_user.id, "channel_id": channel_id, "limit": limit, "offset": offset}
q_clause = ""
if q.strip():
q_clause = "AND (v.title LIKE :q OR v.description LIKE :q)"
params["q"] = f"%{q.strip()}%"
order = {
"newest": "v.published_at DESC NULLS LAST",
"oldest": "v.published_at ASC NULLS LAST",
"title": "v.title ASC",
"unwatched":"COALESCE(uv.watched, 0) ASC, v.published_at DESC NULLS LAST",
"popular": "v.view_count DESC NULLS LAST",
}.get(sort, "v.published_at DESC NULLS LAST")
view_count_clause = "AND v.view_count IS NOT NULL" if sort == "popular" else ""
rows = db.execute(
text(f"""
SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url,
v.duration_seconds, v.published_at, v.view_count,
COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.watched, 0) AS is_watched
FROM videos v
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE v.channel_id = :channel_id {view_count_clause} {q_clause}
ORDER BY {order}
LIMIT :limit OFFSET :offset
"""),
params,
).mappings().all()
return [VideoOut(**dict(r)) for r in rows]
@router.post("/{channel_id}/fetch-popular", status_code=status.HTTP_202_ACCEPTED)
def fetch_popular_videos(
channel_id: int,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Fetch the channel's most popular videos from YouTube and index them."""
channel = _get_channel_or_404(db, channel_id)
background_tasks.add_task(_fetch_popular_task, channel_id, channel.youtube_channel_id, channel.name or "")
return {"detail": "Fetching popular videos"}
def _fetch_popular_task(channel_id: int, youtube_channel_id: str, channel_name: str = ""):
"""Half-and-half popular fetch.
Phase 1 (fast): flat-playlist crawl of the full channel → store any
new videos in DB (title, duration, thumbnail). No individual requests.
Phase 2 (sequential, polite): fetch each video's watch page one at a time
with a 2-second pause between requests to avoid cookie invalidation.
Prioritises videos missing view_count; caps at 100 per run.
"""
import time
from ..database import SessionLocal
task_id = f"popular-{channel_id}"
label = f"Popular fetch — {channel_name}" if channel_name else "Popular fetch"
# Phase 1 — flat-playlist: crawl all channel videos quickly
with _tasks_lock:
_tasks[task_id] = {
"id": task_id,
"label": label,
"phase": "Crawling channel…",
"total": 0,
"done": 0,
"started_at": datetime.utcnow().isoformat(),
}
if youtube_channel_id.startswith("@"):
url = f"https://www.youtube.com/{youtube_channel_id}/videos"
else:
url = f"https://www.youtube.com/channel/{youtube_channel_id}/videos"
stdout, _, _ = ytdlp._meta_run([
"yt-dlp", url,
"--dump-json", "--flat-playlist",
"--quiet",
*ytdlp._cookie_args(),
], timeout=120)
flat_entries = []
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
info = json.loads(line)
yt_id = info.get("id")
if yt_id:
flat_entries.append({
"id": yt_id,
"title": info.get("title", ""),
"duration": info.get("duration"),
})
except json.JSONDecodeError:
continue
# Store any new videos from the flat crawl
if flat_entries:
db = SessionLocal()
try:
channel = db.query(Channel).filter_by(id=channel_id).first()
if channel:
for entry in flat_entries:
if not db.query(Video).filter_by(youtube_video_id=entry["id"]).first():
try:
db.add(Video(
youtube_video_id=entry["id"],
channel_id=channel_id,
title=entry["title"],
thumbnail_url=ytdlp._stable_thumbnail(entry["id"]),
duration_seconds=entry["duration"],
tags="[]",
))
db.commit()
except Exception:
db.rollback()
finally:
db.close()
# Phase 2 — sequential fetches with a polite delay to avoid cookie invalidation
db = SessionLocal()
try:
rows = db.execute(
text("""
SELECT youtube_video_id FROM videos
WHERE channel_id = :cid
ORDER BY (view_count IS NULL) DESC, RANDOM()
LIMIT 100
"""),
{"cid": channel_id},
).mappings().all()
video_ids = [r["youtube_video_id"] for r in rows]
finally:
db.close()
if not video_ids:
with _tasks_lock:
_tasks.pop(task_id, None)
return
with _tasks_lock:
if task_id in _tasks:
_tasks[task_id]["phase"] = "Enriching view counts…"
_tasks[task_id]["total"] = len(video_ids)
_tasks[task_id]["done"] = 0
try:
for yt_id in video_ids:
try:
meta = ytdlp.fetch_video_metadata(yt_id, polite=True)
if meta:
db = SessionLocal()
try:
vid = db.query(Video).filter_by(youtube_video_id=yt_id).first()
if vid:
if meta.get("view_count") is not None:
vid.view_count = meta["view_count"]
if not vid.published_at and meta.get("published_at"):
vid.published_at = meta["published_at"]
db.commit()
except Exception:
db.rollback()
finally:
db.close()
except Exception:
pass
with _tasks_lock:
if task_id in _tasks:
_tasks[task_id]["done"] += 1
finally:
with _tasks_lock:
_tasks.pop(task_id, None)
@router.post("/{channel_id}/search", status_code=status.HTTP_202_ACCEPTED)
def search_channel_youtube(
channel_id: int,
q: str,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Search YouTube within this channel and index matching videos."""
channel = _get_channel_or_404(db, channel_id)
background_tasks.add_task(_search_channel_task, channel_id, channel.youtube_channel_id, q, current_user.id)
return {"detail": "Search started"}
def _search_channel_task(channel_id: int, youtube_channel_id: str, q: str, user_id: int):
"""Fetch videos matching q from YouTube for this channel and index them."""
from ..database import SessionLocal
from urllib.parse import quote
db = SessionLocal()
try:
url = f"https://www.youtube.com/channel/{youtube_channel_id}/search?query={quote(q)}"
result = ytdlp.fetch_channel_metadata(youtube_channel_id, max_videos=100, polite=True)
if not result:
return
# Filter results by query match (yt-dlp fetches all; we filter titles locally)
q_lower = q.lower()
matched = [v for v in result.get("videos", []) if q_lower in (v.get("title") or "").lower()]
if not matched:
matched = result.get("videos", [])[:30]
channel = db.query(Channel).filter_by(id=channel_id).first()
if not channel:
return
for vdata in matched:
yt_id = vdata.get("youtube_video_id")
if not yt_id:
continue
existing = db.query(Video).filter_by(youtube_video_id=yt_id).first()
if not existing:
db.add(Video(
youtube_video_id=yt_id,
channel_id=channel.id,
title=vdata.get("title", ""),
description=vdata.get("description"),
thumbnail_url=vdata.get("thumbnail_url"),
duration_seconds=vdata.get("duration_seconds"),
published_at=vdata.get("published_at"),
tags=vdata.get("tags"),
category=vdata.get("category"),
))
db.commit()
except Exception:
db.rollback()
finally:
db.close()
@router.get("/{channel_id}/random", response_model=VideoOut)
def get_random_channel_video(
channel_id: int,
unwatched_only: bool = True,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_get_channel_or_404(db, channel_id)
unwatched_clause = "AND COALESCE(uv.watched, 0) = 0" if unwatched_only else ""
row = db.execute(
text(f"""
SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url,
v.duration_seconds, v.published_at, v.view_count,
c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id,
COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.watched, 0) AS is_watched,
COALESCE(uv.queued, 0) AS queued
FROM videos v
JOIN channels c ON v.channel_id = c.id
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE v.channel_id = :channel_id {unwatched_clause}
ORDER BY RANDOM()
LIMIT 1
"""),
{"user_id": current_user.id, "channel_id": channel_id},
).mappings().first()
if not row:
raise HTTPException(status_code=404, detail="No videos found")
return VideoOut(**dict(row))
@router.get("/{channel_id}/in-progress", response_model=list[VideoOut])
def get_channel_in_progress(
channel_id: int, channel_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@@ -508,13 +938,19 @@ def get_channel_videos(
rows = db.execute( rows = db.execute(
text(""" text("""
SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url, SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url,
v.duration_seconds, v.published_at, v.duration_seconds, v.published_at, v.view_count,
c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id,
COALESCE(uv.downloaded, 0) AS is_downloaded, COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.watched, 0) AS is_watched COALESCE(uv.watched, 0) AS is_watched,
COALESCE(uv.queued, 0) AS queued
FROM videos v FROM videos v
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id JOIN channels c ON v.channel_id = c.id
JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE v.channel_id = :channel_id WHERE v.channel_id = :channel_id
ORDER BY v.published_at DESC AND uv.watch_progress_seconds > 30
AND COALESCE(uv.watched, 0) = 0
ORDER BY uv.watch_progress_seconds DESC
LIMIT 6
"""), """),
{"user_id": current_user.id, "channel_id": channel_id}, {"user_id": current_user.id, "channel_id": channel_id},
).mappings().all() ).mappings().all()
@@ -572,10 +1008,70 @@ def index_channel(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
_get_channel_or_404(db, channel_id) _get_channel_or_404(db, channel_id)
background_tasks.add_task(_index_channel_task, channel_id, current_user.id) background_tasks.add_task(_index_channel_task, channel_id, current_user.id, 100)
return {"detail": "Indexing started"} return {"detail": "Indexing started"}
@router.post("/{channel_id}/index-full", status_code=status.HTTP_202_ACCEPTED)
def index_channel_full(
channel_id: int,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_get_channel_or_404(db, channel_id)
background_tasks.add_task(_index_channel_task, channel_id, current_user.id, 0)
return {"detail": "Full index started"}
@router.post("/{channel_id}/explore", status_code=status.HTTP_202_ACCEPTED)
def explore_channel_older(
channel_id: int,
page: int = 2,
background_tasks: BackgroundTasks = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Fetch a page of older videos from this channel (page 1 = newest 30, page 2 = next 100, etc.)."""
_get_channel_or_404(db, channel_id)
start = 1 if page <= 1 else (30 + (page - 2) * 100 + 1)
background_tasks.add_task(_index_channel_explore_task, channel_id, current_user.id, start, 100)
return {"detail": f"Fetching older videos (page {page})", "start": start}
def _index_channel_explore_task(channel_id: int, user_id: int, start_video: int, count: int):
from ..database import SessionLocal
db = SessionLocal()
try:
channel = db.query(Channel).filter_by(id=channel_id).first()
if not channel:
return
result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id, max_videos=count, start_video=start_video, polite=True)
if not result:
return
for vdata in result.get("videos", []):
yt_id = vdata.get("youtube_video_id")
if not yt_id:
continue
if not db.query(Video).filter_by(youtube_video_id=yt_id).first():
db.add(Video(
youtube_video_id=yt_id,
channel_id=channel.id,
title=vdata.get("title", ""),
description=vdata.get("description"),
thumbnail_url=vdata.get("thumbnail_url"),
duration_seconds=vdata.get("duration_seconds"),
published_at=vdata.get("published_at"),
tags=vdata.get("tags"),
category=vdata.get("category"),
))
db.commit()
except Exception:
db.rollback()
finally:
db.close()
@router.post("/follow-bulk", status_code=200) @router.post("/follow-bulk", status_code=200)
def follow_bulk( def follow_bulk(
body: dict, body: dict,
@@ -685,7 +1181,7 @@ def follow_by_url(
channel = db.query(Channel).filter_by(youtube_channel_id=yt_channel_id).first() channel = db.query(Channel).filter_by(youtube_channel_id=yt_channel_id).first()
if not channel: if not channel:
meta = ytdlp.fetch_channel_metadata(yt_channel_id, max_videos=30) meta = ytdlp.fetch_channel_metadata(yt_channel_id, max_videos=30, polite=True)
if not meta or not meta.get("channel"): if not meta or not meta.get("channel"):
raise HTTPException(status_code=404, detail="Channel not found on YouTube") raise HTTPException(status_code=404, detail="Channel not found on YouTube")
ch_data = meta["channel"] ch_data = meta["channel"]

View File

@@ -1,7 +1,8 @@
import json import json
import random
from typing import Optional from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import text from sqlalchemy import text
@@ -9,7 +10,7 @@ from sqlalchemy import text
from ..auth_utils import get_current_user from ..auth_utils import get_current_user
from ..database import get_db from ..database import get_db
from ..models import Channel, DiscoveryQueue, User, UserChannel, UserSettings from ..models import Channel, DiscoveryQueue, User, UserChannel, UserSettings
from ..services.discovery import run_full_discovery from ..services.discovery import schedule_discovery, get_discovery_progress
router = APIRouter() router = APIRouter()
@@ -56,9 +57,35 @@ def list_discovery(
ORDER BY dq.score DESC ORDER BY dq.score DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
"""), """),
{"user_id": current_user.id, "limit": limit, "offset": offset}, {"user_id": current_user.id, "limit": limit * 3, "offset": offset},
).mappings().all() ).mappings().all()
# Load negative affinity tags and use them to filter channels already in the queue
neg_affinity = {
r["tag"] for r in db.execute(
text("SELECT tag FROM user_tag_affinity WHERE user_id = :user_id AND score < -2"),
{"user_id": current_user.id},
).mappings().all()
}
if neg_affinity and rows:
channel_ids_csv = ",".join(str(r["channel_id"]) for r in rows)
vtag_rows = db.execute(
text(f"SELECT channel_id, tags FROM videos WHERE channel_id IN ({channel_ids_csv}) AND tags IS NOT NULL LIMIT 1000")
).mappings().all()
neg_hit: dict[int, int] = {}
for vr in vtag_rows:
try:
for tag in json.loads(vr["tags"] or "[]"):
if isinstance(tag, str) and tag.lower().strip() in neg_affinity:
neg_hit[vr["channel_id"]] = neg_hit.get(vr["channel_id"], 0) + 1
except (json.JSONDecodeError, TypeError):
pass
rows = [r for r in rows if neg_hit.get(r["channel_id"], 0) < 3]
# Add score perturbation so the list doesn't look identical every visit.
# ±8 jitter keeps relative ranking meaningful while surfacing different channels.
rows = sorted(rows, key=lambda r: r["score"] + random.uniform(-8, 8), reverse=True)
rows = rows[:limit]
items = [] items = []
for row in rows: for row in rows:
row = dict(row) row = dict(row)
@@ -133,17 +160,14 @@ def dismiss_discovery(
@router.post("/refresh", status_code=202) @router.post("/refresh", status_code=202)
def refresh_discovery( def refresh_discovery(
background_tasks: BackgroundTasks,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
s = db.query(UserSettings).filter_by(user_id=current_user.id).first() s = db.query(UserSettings).filter_by(user_id=current_user.id).first()
regions_str = (s.discovery_regions if s and s.discovery_regions else "US,SE") regions_str = (s.discovery_regions if s and s.discovery_regions else "US,SE")
regions = [r.strip().upper() for r in regions_str.split(",") if r.strip()] regions = [r.strip().upper() for r in regions_str.split(",") if r.strip()]
background_tasks.add_task(run_full_discovery, db, current_user.id, regions) schedule_discovery(current_user.id, regions)
from .channels import _enrich_missing_task return {"detail": "Discovery queued"}
background_tasks.add_task(_enrich_missing_task, 20)
return {"detail": "Discovery refresh started"}
@router.get("/videos", response_model=list[dict]) @router.get("/videos", response_model=list[dict])
@@ -177,7 +201,7 @@ def discovery_videos(
) )
) )
WHERE rn <= 2 WHERE rn <= 2
ORDER BY score DESC, rn ASC, RANDOM() ORDER BY (score + (RANDOM() * 10 - 5)) DESC, rn ASC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
"""), """),
{"user_id": current_user.id, "limit": limit, "offset": offset}, {"user_id": current_user.id, "limit": limit, "offset": offset},
@@ -207,9 +231,30 @@ def dismiss_discovery_video(
dq = db.query(DiscoveryQueue).filter_by(user_id=current_user.id, channel_id=channel_id).first() dq = db.query(DiscoveryQueue).filter_by(user_id=current_user.id, channel_id=channel_id).first()
if dq: if dq:
dq.seen = True dq.seen = True
from ..routers.videos import _update_affinity
_update_affinity(db, current_user.id, video, -3.0)
db.commit() db.commit()
@router.get("/status")
def discovery_status(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
s = db.query(UserSettings).filter_by(user_id=current_user.id).first()
pending = db.execute(
text("SELECT COUNT(*) AS n FROM discovery_queue WHERE user_id = :uid AND seen = 0"),
{"uid": current_user.id},
).mappings().first()
return {
"last_run": s.last_discovery_run.isoformat() if s and s.last_discovery_run else None,
"pending_count": pending["n"] if pending else 0,
"progress": get_discovery_progress(current_user.id),
}
@router.get("/community", response_model=list[dict]) @router.get("/community", response_model=list[dict])
def community_shelf( def community_shelf(
db: Session = Depends(get_db), db: Session = Depends(get_db),

View File

@@ -19,6 +19,7 @@ router = APIRouter()
class DownloadRequest(BaseModel): class DownloadRequest(BaseModel):
youtube_video_id: str youtube_video_id: str
quality: Optional[str] = None quality: Optional[str] = None
subtitle_langs: Optional[str] = None # overrides user setting when provided
TRASH_TTL_DAYS = 7 TRASH_TTL_DAYS = 7
@@ -52,6 +53,32 @@ def _on_progress(download_id: int, pct: float):
db.close() db.close()
def _write_nfo(video: Video, channel: Optional[Channel]) -> None:
"""Write a Jellyfin/Kodi-compatible .nfo sidecar next to the video file."""
from pathlib import Path
try:
nfo_path = Path(settings.download_path) / f"{video.youtube_video_id}.nfo"
title = (video.title or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
plot = (video.description or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
studio = (channel.name if channel else "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
year = video.published_at.year if video.published_at else ""
date = video.published_at.strftime("%Y-%m-%d") if video.published_at else ""
thumb_url = f"https://i.ytimg.com/vi/{video.youtube_video_id}/maxresdefault.jpg"
nfo_path.write_text(f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<movie>
<title>{title}</title>
<plot>{plot}</plot>
<year>{year}</year>
<releasedate>{date}</releasedate>
<studio>{studio}</studio>
<thumb aspect="poster">{thumb_url}</thumb>
<thumb aspect="backdrop">{thumb_url}</thumb>
<uniqueid type="youtube" default="true">{video.youtube_video_id}</uniqueid>
</movie>""", encoding="utf-8")
except Exception:
pass
def _on_complete(download_id: int, file_path: Optional[str], resolution: Optional[str] = None): def _on_complete(download_id: int, file_path: Optional[str], resolution: Optional[str] = None):
db = SessionLocal() db = SessionLocal()
try: try:
@@ -71,6 +98,12 @@ def _on_complete(download_id: int, file_path: Optional[str], resolution: Optiona
uv.downloaded = True uv.downloaded = True
uv.downloaded_at = datetime.utcnow() uv.downloaded_at = datetime.utcnow()
db.commit() db.commit()
# Write Jellyfin sidecar
video = db.query(Video).filter_by(id=dl.video_id).first()
if video:
channel = db.query(Channel).filter_by(id=video.channel_id).first() if video.channel_id else None
_write_nfo(video, channel)
finally: finally:
db.close() db.close()
@@ -92,7 +125,7 @@ def _ensure_video(db: Session, youtube_video_id: str) -> Video:
if video: if video:
return video return video
meta = ytdlp.fetch_video_metadata(youtube_video_id) meta = ytdlp.fetch_video_metadata(youtube_video_id, polite=True)
if not meta: if not meta:
raise HTTPException(status_code=404, detail="Video not found on YouTube") raise HTTPException(status_code=404, detail="Video not found on YouTube")
@@ -127,6 +160,10 @@ def create_download(
user_settings = db.query(UserSettings).filter_by(user_id=current_user.id).first() user_settings = db.query(UserSettings).filter_by(user_id=current_user.id).first()
default_quality = user_settings.preferred_quality if user_settings else "best" default_quality = user_settings.preferred_quality if user_settings else "best"
quality = body.quality if body.quality in ytdlp.QUALITY_FORMATS else default_quality quality = body.quality if body.quality in ytdlp.QUALITY_FORMATS else default_quality
if body.subtitle_langs is not None:
subtitle_langs = body.subtitle_langs.strip()
else:
subtitle_langs = (user_settings.subtitle_langs or "") if user_settings else ""
_DL_SELECT = """ _DL_SELECT = """
SELECT d.id, d.status, d.progress_percent, d.resolution, SELECT d.id, d.status, d.progress_percent, d.resolution,
@@ -155,7 +192,7 @@ def create_download(
ytdlp.start_download, ytdlp.start_download,
video.youtube_video_id, dl.id, video.youtube_video_id, dl.id,
_on_progress, _on_complete, _on_error, _on_progress, _on_complete, _on_error,
quality, quality, subtitle_langs,
) )
row = db.execute(text(_DL_SELECT), {"id": dl.id}).mappings().first() row = db.execute(text(_DL_SELECT), {"id": dl.id}).mappings().first()
@@ -190,6 +227,11 @@ def _get_quality(db, user_id: int) -> str:
return s.preferred_quality if s else "best" return s.preferred_quality if s else "best"
def _get_subtitle_langs(db, user_id: int) -> str:
s = db.query(UserSettings).filter_by(user_id=user_id).first()
return (s.subtitle_langs or "") if s else ""
@router.post("/channel/{channel_id}", status_code=202) @router.post("/channel/{channel_id}", status_code=202)
def download_channel_videos( def download_channel_videos(
channel_id: int, channel_id: int,
@@ -198,6 +240,7 @@ def download_channel_videos(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
quality = _get_quality(db, current_user.id) quality = _get_quality(db, current_user.id)
subtitle_langs = _get_subtitle_langs(db, current_user.id)
rows = db.execute( rows = db.execute(
text(""" text("""
SELECT v.id, v.youtube_video_id SELECT v.id, v.youtube_video_id
@@ -216,7 +259,7 @@ def download_channel_videos(
db.flush() db.flush()
background_tasks.add_task( background_tasks.add_task(
ytdlp.start_download, row["youtube_video_id"], dl.id, ytdlp.start_download, row["youtube_video_id"], dl.id,
_on_progress, _on_complete, _on_error, quality, _on_progress, _on_complete, _on_error, quality, subtitle_langs,
) )
count += 1 count += 1
db.commit() db.commit()
@@ -230,6 +273,7 @@ def download_following_videos(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
quality = _get_quality(db, current_user.id) quality = _get_quality(db, current_user.id)
subtitle_langs = _get_subtitle_langs(db, current_user.id)
rows = db.execute( rows = db.execute(
text(""" text("""
SELECT v.id, v.youtube_video_id SELECT v.id, v.youtube_video_id
@@ -282,6 +326,7 @@ def _purge_expired_trash(db: Session):
def _delete_download_record(db: Session, dl: "Download", user_id: int): def _delete_download_record(db: Session, dl: "Download", user_id: int):
from pathlib import Path
video = db.query(Video).filter_by(id=dl.video_id).first() video = db.query(Video).filter_by(id=dl.video_id).first()
if video: if video:
fp = ytdlp.predicted_file_path(video.youtube_video_id) fp = ytdlp.predicted_file_path(video.youtube_video_id)
@@ -290,6 +335,13 @@ def _delete_download_record(db: Session, dl: "Download", user_id: int):
os.remove(fp) os.remove(fp)
except OSError: except OSError:
pass pass
# Remove NFO sidecar if present
nfo = Path(settings.download_path) / f"{video.youtube_video_id}.nfo"
if nfo.exists():
try:
os.remove(nfo)
except OSError:
pass
uv = db.query(UserVideo).filter_by(user_id=user_id, video_id=dl.video_id).first() uv = db.query(UserVideo).filter_by(user_id=user_id, video_id=dl.video_id).first()
if uv: if uv:
uv.downloaded = False uv.downloaded = False
@@ -297,6 +349,38 @@ def _delete_download_record(db: Session, dl: "Download", user_id: int):
db.delete(dl) db.delete(dl)
@router.post("/nfo/generate", status_code=200)
def generate_nfo_files(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Generate .nfo sidecar files for all completed downloads that have a file on disk."""
from pathlib import Path
rows = db.execute(
text("""
SELECT v.id, v.youtube_video_id, v.channel_id
FROM downloads d
JOIN videos v ON d.video_id = v.id
WHERE d.user_id = :uid AND d.status = 'complete'
"""),
{"uid": current_user.id},
).mappings().all()
written = 0
for row in rows:
fp = Path(settings.download_path) / f"{row['youtube_video_id']}.mp4"
if not fp.exists():
continue
video = db.query(Video).filter_by(id=row["id"]).first()
if not video:
continue
channel = db.query(Channel).filter_by(id=video.channel_id).first() if video.channel_id else None
_write_nfo(video, channel)
written += 1
return {"generated": written}
@router.delete("/all", status_code=204) @router.delete("/all", status_code=204)
def delete_all_downloads( def delete_all_downloads(
db: Session = Depends(get_db), db: Session = Depends(get_db),

View File

@@ -0,0 +1,220 @@
import json
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import text
from ..auth_utils import get_current_user
from ..database import get_db
from ..models import Channel, Playlist, Video, User
from ..services import ytdlp
router = APIRouter()
class PlaylistOut(BaseModel):
id: int
youtube_playlist_id: str
channel_id: Optional[int]
title: str
description: Optional[str]
thumbnail_url: Optional[str]
video_count: int
indexed_at: Optional[datetime]
model_config = {"from_attributes": True}
class PlaylistVideoOut(BaseModel):
id: int
youtube_video_id: str
title: str
thumbnail_url: Optional[str]
duration_seconds: Optional[int]
published_at: Optional[datetime]
view_count: Optional[int]
is_downloaded: bool = False
is_watched: bool = False
channel_id: Optional[int] = None
channel_name: Optional[str] = None
model_config = {"from_attributes": True}
@router.get("/channel/{channel_id}", response_model=list[PlaylistOut])
def get_channel_playlists(
channel_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
rows = db.query(Playlist).filter_by(channel_id=channel_id).order_by(Playlist.video_count.desc()).all()
return rows
@router.post("/channel/{channel_id}/fetch", status_code=status.HTTP_202_ACCEPTED)
def fetch_channel_playlists(
channel_id: int,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
channel = db.query(Channel).filter_by(id=channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
background_tasks.add_task(_fetch_playlists_task, channel_id, channel.youtube_channel_id)
return {"detail": "Fetching playlists"}
def _first_video_thumbnail(youtube_playlist_id: str) -> Optional[str]:
"""Fetch just the first video of a playlist and return a stable thumbnail URL."""
try:
videos = ytdlp.fetch_playlist_videos(youtube_playlist_id, max_videos=1)
if videos:
vid_id = videos[0].get("youtube_video_id")
if vid_id:
from ..services.ytdlp import _stable_thumbnail
return _stable_thumbnail(vid_id)
except Exception:
pass
return None
def _fetch_playlists_task(channel_id: int, youtube_channel_id: str):
from ..database import SessionLocal
db = SessionLocal()
try:
playlists = ytdlp.fetch_channel_playlists(youtube_channel_id)
for pl in playlists:
pl_id = pl["youtube_playlist_id"]
thumb = pl.get("thumbnail_url") or _first_video_thumbnail(pl_id)
existing = db.query(Playlist).filter_by(youtube_playlist_id=pl_id).first()
if existing:
existing.title = pl["title"] or existing.title
if pl.get("video_count"):
existing.video_count = pl["video_count"]
if thumb and not existing.thumbnail_url:
existing.thumbnail_url = thumb
else:
db.add(Playlist(
youtube_playlist_id=pl_id,
channel_id=channel_id,
title=pl["title"],
description=pl.get("description"),
thumbnail_url=thumb,
video_count=pl.get("video_count") or 0,
))
db.commit()
except Exception:
db.rollback()
finally:
db.close()
@router.get("/{playlist_id}/videos", response_model=list[PlaylistVideoOut])
def get_playlist_videos(
playlist_id: int,
offset: int = 0,
limit: int = 60,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
playlist = db.query(Playlist).filter_by(id=playlist_id).first()
if not playlist:
raise HTTPException(status_code=404, detail="Playlist not found")
if not playlist.indexed_at:
return []
rows = db.execute(
text("""
SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url,
v.duration_seconds, v.published_at, v.view_count,
c.id AS channel_id, c.name AS channel_name,
COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.watched, 0) AS is_watched
FROM videos v
LEFT JOIN channels c ON v.channel_id = c.id
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE v.youtube_video_id IN (
SELECT value FROM json_each((
SELECT video_ids FROM playlists WHERE id = :playlist_id
))
)
ORDER BY v.published_at DESC NULLS LAST
LIMIT :limit OFFSET :offset
"""),
{"user_id": current_user.id, "playlist_id": playlist_id, "limit": limit, "offset": offset},
).mappings().all()
return [PlaylistVideoOut(**dict(r)) for r in rows]
@router.post("/{playlist_id}/index", status_code=status.HTTP_202_ACCEPTED)
def index_playlist(
playlist_id: int,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
playlist = db.query(Playlist).filter_by(id=playlist_id).first()
if not playlist:
raise HTTPException(status_code=404, detail="Playlist not found")
background_tasks.add_task(_index_playlist_task, playlist_id, playlist.youtube_playlist_id, playlist.channel_id)
return {"detail": "Indexing playlist"}
def _index_playlist_task(playlist_id: int, youtube_playlist_id: str, channel_id: Optional[int]):
from ..database import SessionLocal
db = SessionLocal()
try:
videos = ytdlp.fetch_playlist_videos(youtube_playlist_id)
playlist = db.query(Playlist).filter_by(id=playlist_id).first()
if not playlist:
return
video_yt_ids = []
for vdata in videos:
yt_id = vdata.get("youtube_video_id")
if not yt_id:
continue
video_yt_ids.append(yt_id)
existing = db.query(Video).filter_by(youtube_video_id=yt_id).first()
if existing:
if vdata.get("view_count") is not None:
existing.view_count = vdata["view_count"]
else:
ch_id = channel_id
if not ch_id and vdata.get("channel", {}).get("youtube_channel_id"):
ch = db.query(Channel).filter_by(
youtube_channel_id=vdata["channel"]["youtube_channel_id"]
).first()
if ch:
ch_id = ch.id
db.add(Video(
youtube_video_id=yt_id,
channel_id=ch_id,
title=vdata.get("title", ""),
thumbnail_url=vdata.get("thumbnail_url"),
duration_seconds=vdata.get("duration_seconds"),
published_at=vdata.get("published_at"),
view_count=vdata.get("view_count"),
tags="[]",
))
playlist.video_count = len(video_yt_ids)
playlist.indexed_at = datetime.utcnow()
playlist.video_ids = json.dumps(video_yt_ids)
if not playlist.thumbnail_url and video_yt_ids:
from ..services.ytdlp import _stable_thumbnail
playlist.thumbnail_url = _stable_thumbnail(video_yt_ids[0])
db.commit()
except Exception:
db.rollback()
return
finally:
db.close()
# Enrich dates and view counts for videos missing them — runs in background
import threading
from ..routers.channels import _enrich_missing_task
threading.Thread(target=_enrich_missing_task, args=(len(video_yt_ids),), daemon=True).start()

View File

@@ -264,8 +264,9 @@ def search(
source = "local" if (video_results or channel_results) else "none" source = "local" if (video_results or channel_results) else "none"
# Fall back to live yt-dlp search if no local results or explicitly requested # Fall back to live yt-dlp search if no local results or explicitly requested.
if not video_results or live: # Skip if a download is active — concurrent yt-dlp sessions invalidate cookies.
if (not video_results or live) and not ytdlp.is_download_active():
try: try:
live_raw = ytdlp.search_youtube(q) live_raw = ytdlp.search_youtube(q)
live_results = _live_search_to_results(db, current_user.id, live_raw) live_results = _live_search_to_results(db, current_user.id, live_raw)

View File

@@ -34,6 +34,8 @@ class SettingsOut(BaseModel):
feed_weight_affinity: float = 5.0 feed_weight_affinity: float = 5.0
feed_weight_channel: float = 5.0 feed_weight_channel: float = 5.0
use_oauth2: bool = False use_oauth2: bool = False
sync_interval_hours: int = 0
subtitle_langs: str = ""
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -55,6 +57,8 @@ class SettingsPatch(BaseModel):
feed_weight_affinity: Optional[float] = Field(None, ge=0.0, le=10.0) feed_weight_affinity: Optional[float] = Field(None, ge=0.0, le=10.0)
feed_weight_channel: Optional[float] = Field(None, ge=0.0, le=10.0) feed_weight_channel: Optional[float] = Field(None, ge=0.0, le=10.0)
use_oauth2: Optional[bool] = None use_oauth2: Optional[bool] = None
sync_interval_hours: Optional[int] = Field(None, ge=0, le=168)
subtitle_langs: Optional[str] = None
def _get_or_create(db: Session, user_id: int) -> UserSettings: def _get_or_create(db: Session, user_id: int) -> UserSettings:
@@ -123,6 +127,10 @@ def update_settings(
if body.use_oauth2 is not None: if body.use_oauth2 is not None:
s.use_oauth2 = body.use_oauth2 s.use_oauth2 = body.use_oauth2
ytdlp.set_oauth2(body.use_oauth2) ytdlp.set_oauth2(body.use_oauth2)
if body.sync_interval_hours is not None:
s.sync_interval_hours = body.sync_interval_hours
if body.subtitle_langs is not None:
s.subtitle_langs = body.subtitle_langs.strip()
db.commit() db.commit()
db.refresh(s) db.refresh(s)
@@ -242,7 +250,7 @@ def ytdlp_test(
except Exception: except Exception:
pass pass
result = subprocess.run( test_stdout, test_stderr, test_code = ytdlp._meta_run(
[ [
"yt-dlp", "yt-dlp",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ", "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
@@ -250,15 +258,15 @@ def ytdlp_test(
"--extractor-args", "youtube:player_client=web", "--extractor-args", "youtube:player_client=web",
*cookie_args, *cookie_args,
], ],
capture_output=True, text=True, timeout=30, timeout=30,
) )
return { return {
"node_path": node_path, "node_path": node_path,
"node_version": node_version, "node_version": node_version,
"yt_dlp_version": yt_version, "yt_dlp_version": yt_version,
"cookie_args": cookie_args, "cookie_args": cookie_args,
"returncode": result.returncode, "returncode": test_code,
"stdout_lines": result.stdout.splitlines()[:5], "stdout_lines": test_stdout.splitlines()[:5],
"stderr_tail": result.stderr.splitlines()[-20:], "stderr_tail": test_stderr.splitlines()[-20:],
"success": result.returncode == 0, "success": test_code == 0,
} }

View File

@@ -1,8 +1,12 @@
import os
import shutil
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import text from sqlalchemy import text
from ..auth_utils import get_current_user from ..auth_utils import get_current_user
from ..config import settings
from ..database import get_db from ..database import get_db
from ..models import User, UserTagAffinity from ..models import User, UserTagAffinity
@@ -81,7 +85,7 @@ def get_stats(
avg_completion = db.execute( avg_completion = db.execute(
text(""" text("""
SELECT AVG(uv.completion_percent) AS avg_pct, SELECT AVG(uv.completion_percent) AS avg_pct,
COUNT(CASE WHEN uv.completion_percent >= 90 THEN 1 END) AS finished_count, COUNT(CASE WHEN uv.completion_percent >= 75 THEN 1 END) AS finished_count,
COUNT(CASE WHEN uv.completion_percent < 20 AND uv.completion_percent IS NOT NULL THEN 1 END) AS bailed_count, COUNT(CASE WHEN uv.completion_percent < 20 AND uv.completion_percent IS NOT NULL THEN 1 END) AS bailed_count,
SUM(uv.rewatch_count) AS total_rewatches, SUM(uv.rewatch_count) AS total_rewatches,
COUNT(CASE WHEN uv.rewatch_count > 0 THEN 1 END) AS rewatched_videos COUNT(CASE WHEN uv.rewatch_count > 0 THEN 1 END) AS rewatched_videos
@@ -110,7 +114,19 @@ def get_stats(
SELECT tag, score FROM user_tag_affinity SELECT tag, score FROM user_tag_affinity
WHERE user_id = :uid AND score > 0 WHERE user_id = :uid AND score > 0
ORDER BY score DESC ORDER BY score DESC
LIMIT 20 LIMIT 60
"""),
{"uid": uid},
).mappings().all()
peak_hours = db.execute(
text("""
SELECT CAST(strftime('%H', uv.last_watched_at) AS INTEGER) AS hour,
COUNT(*) AS count
FROM user_videos uv
WHERE uv.user_id = :uid AND uv.watched = 1 AND uv.last_watched_at IS NOT NULL
GROUP BY hour
ORDER BY hour ASC
"""), """),
{"uid": uid}, {"uid": uid},
).mappings().all() ).mappings().all()
@@ -120,6 +136,24 @@ def get_stats(
{"uid": uid}, {"uid": uid},
).mappings().first() ).mappings().first()
started_count = db.execute(
text("""
SELECT COUNT(*) AS n FROM user_videos
WHERE user_id = :uid AND watched = 0
AND (watch_progress_seconds > 0 OR last_watched_at IS NOT NULL)
"""),
{"uid": uid},
).mappings().first()
try:
disk = shutil.disk_usage(settings.download_path)
download_bytes = sum(
e.stat().st_size for e in os.scandir(settings.download_path) if e.is_file()
)
except Exception:
disk = None
download_bytes = 0
return { return {
"total_watched": totals["total_watched"] or 0, "total_watched": totals["total_watched"] or 0,
"total_watch_seconds": totals["total_watch_seconds"] or 0, "total_watch_seconds": totals["total_watch_seconds"] or 0,
@@ -139,8 +173,16 @@ def get_stats(
"total_rewatches": avg_completion["total_rewatches"] or 0, "total_rewatches": avg_completion["total_rewatches"] or 0,
"rewatched_videos": avg_completion["rewatched_videos"] or 0, "rewatched_videos": avg_completion["rewatched_videos"] or 0,
"total_liked": liked_count["n"] or 0, "total_liked": liked_count["n"] or 0,
"started_count": started_count["n"] or 0,
"top_categories": [dict(r) for r in top_categories], "top_categories": [dict(r) for r in top_categories],
"peak_hours": [dict(r) for r in peak_hours],
"taste_profile": [dict(r) for r in taste_profile], "taste_profile": [dict(r) for r in taste_profile],
"disk": {
"total_bytes": disk.total if disk else None,
"free_bytes": disk.free if disk else None,
"used_bytes": disk.used if disk else None,
"download_bytes": download_bytes,
},
} }

View File

@@ -1,5 +1,6 @@
import os import os
import random import random
import threading
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
@@ -17,6 +18,11 @@ from ..services.scoring import get_surprise_videos, get_discovery_injection
router = APIRouter() router = APIRouter()
# Tracks which video IDs currently have a background enrichment running,
# so repeated polls from the frontend don't spawn duplicate yt-dlp calls.
_enriching: set[str] = set()
_enriching_lock = threading.Lock()
def _update_affinity(db: Session, user_id: int, video: Video, delta: float): def _update_affinity(db: Session, user_id: int, video: Video, delta: float):
"""Adjust tag/category affinity scores for a video. delta > 0 = positive signal.""" """Adjust tag/category affinity scores for a video. delta > 0 = positive signal."""
@@ -37,7 +43,6 @@ def _update_affinity(db: Session, user_id: int, video: Video, delta: float):
existing.score = max(existing.score + delta, -20.0) existing.score = max(existing.score + delta, -20.0)
existing.updated_at = datetime.utcnow() existing.updated_at = datetime.utcnow()
else: else:
if delta > 0:
db.add(UserTagAffinity(user_id=user_id, tag=tag, score=delta)) db.add(UserTagAffinity(user_id=user_id, tag=tag, score=delta))
@@ -209,6 +214,58 @@ def home_feed(
for r in rows for r in rows
] ]
if mode == "rediscover":
# Older unwatched videos from followed channels, ranked by tag affinity
affinity_rows = db.execute(
text("SELECT tag, score FROM user_tag_affinity WHERE user_id = :user_id AND score > 0"),
{"user_id": current_user.id},
).mappings().all()
affinity = {r["tag"]: r["score"] for r in affinity_rows}
rows = db.execute(
text(f"""
SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url,
v.duration_seconds, v.published_at, v.tags, v.category,
c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url,
COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.queued, 0) AS queued,
NULL AS file_path
FROM videos v
JOIN channels c ON v.channel_id = c.id
JOIN user_channels uc
ON c.id = uc.channel_id AND uc.user_id = :user_id AND uc.status = 'followed'
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE COALESCE(uv.watched, 0) = 0
AND v.published_at < datetime('now', '-90 days')
AND (uc.muted_until IS NULL OR datetime(uc.muted_until) < datetime('now'))
{duration_clause}
ORDER BY RANDOM()
LIMIT :limit OFFSET :offset
"""),
{"user_id": current_user.id, "limit": min(limit * 4, 200), "offset": offset},
).mappings().all()
if affinity:
import json as _json
def _affinity_score(row):
try:
tags = _json.loads(row["tags"] or "[]")
return sum(affinity.get(t.lower().strip(), 0) for t in tags if isinstance(t, str))
except Exception:
return 0
rows = sorted(rows, key=_affinity_score, reverse=True)
rows = rows[:limit]
return [
VideoDetail(**{k: v for k, v in dict(r).items() if k not in ("watched",)},
is_watched=bool(r["watched"]))
for r in rows
]
if mode == "inbox": if mode == "inbox":
rows = db.execute( rows = db.execute(
text(f""" text(f"""
@@ -246,6 +303,15 @@ def home_feed(
] ]
# mode == "ranked" (default) # mode == "ranked" (default)
import random as _random
# Pull a large candidate pool per page. Each page draws from a NON-overlapping
# slice of the scored list so pagination actually moves through new material.
# candidate_limit >> limit so tier-sampling has real variety to choose from.
candidate_limit = min(limit * 15, 600)
page_num = offset // limit if limit > 0 else 0
sql_offset = page_num * candidate_limit # non-overlapping pages
rows = db.execute( rows = db.execute(
text(f""" text(f"""
WITH channel_stats AS ( WITH channel_stats AS (
@@ -253,7 +319,9 @@ def home_feed(
v.channel_id, v.channel_id,
COUNT(CASE WHEN uv.watched = 1 THEN 1 END) AS watched_count, COUNT(CASE WHEN uv.watched = 1 THEN 1 END) AS watched_count,
COUNT(CASE WHEN uv.liked = 1 THEN 1 END) AS liked_count, COUNT(CASE WHEN uv.liked = 1 THEN 1 END) AS liked_count,
SUM(CASE WHEN uv.rating IS NOT NULL THEN uv.rating ELSE 0 END) AS rating_sum SUM(CASE WHEN uv.rating IS NOT NULL THEN uv.rating ELSE 0 END) AS rating_sum,
AVG(CASE WHEN uv.completion_percent IS NOT NULL THEN uv.completion_percent END) AS avg_completion_pct,
COUNT(CASE WHEN uv.watched = 1 AND uv.last_watched_at > datetime('now', '-30 days') THEN 1 END) AS recent_watches
FROM videos v FROM videos v
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
GROUP BY v.channel_id GROUP BY v.channel_id
@@ -271,16 +339,25 @@ def home_feed(
COALESCE(uv.queued, 0) AS queued, COALESCE(uv.queued, 0) AS queued,
uv.rating AS rating, uv.rating AS rating,
NULL AS file_path, NULL AS file_path,
(SQRT(CAST(COALESCE(cs.watched_count, 0) AS REAL)) * 6.0 (SQRT(CAST(COALESCE(cs.watched_count, 0) AS REAL)) * 5.0
+ COALESCE(cs.liked_count, 0) * 12.0 + COALESCE(cs.liked_count, 0) * 10.0
+ COALESCE(cs.rating_sum, 0) * 8.0) * :w_channel + COALESCE(cs.rating_sum, 0) * 8.0
+ COALESCE(cs.avg_completion_pct, 50.0) * 0.08
+ COALESCE(cs.recent_watches, 0) * 4.0
) * :w_channel
+ MAX(COALESCE(julianday(v.published_at) - julianday('now'), -90), -365) * :w_recency + MAX(COALESCE(julianday(v.published_at) - julianday('now'), -90), -365) * :w_recency
+ COALESCE(( + COALESCE((
SELECT uta.score FROM user_tag_affinity uta SELECT COALESCE(SUM(uta.score), 0)
FROM user_tag_affinity uta
WHERE uta.user_id = :user_id WHERE uta.user_id = :user_id
AND uta.tag = LOWER(COALESCE(v.category, '')) AND (uta.tag = LOWER(COALESCE(v.category, ''))
LIMIT 1 OR instr(LOWER(COALESCE(v.tags, '')), '"' || uta.tag || '"') > 0)
), 0) * 3.0 * :w_affinity LIMIT 5
), 0) * :w_affinity
- CASE WHEN COALESCE(uv.completion_percent, 100) < 20
AND COALESCE(uv.watch_progress_seconds, 0) > 30
THEN 25 ELSE 0 END
- 3 * MIN(COALESCE(uv.feed_shown_count, 0), 10)
AS score, AS score,
ROW_NUMBER() OVER ( ROW_NUMBER() OVER (
PARTITION BY v.channel_id PARTITION BY v.channel_id
@@ -297,21 +374,71 @@ def home_feed(
{duration_clause} {duration_clause}
) )
SELECT * FROM scored SELECT * FROM scored
WHERE rn <= 3 WHERE rn <= 15
ORDER BY score DESC, RANDOM() ORDER BY score DESC
LIMIT :limit OFFSET :offset LIMIT :candidate_limit OFFSET :sql_offset
"""), """),
{"user_id": current_user.id, "limit": limit, "offset": offset, "hide_watched": 1 if hide_watched else 0, {"user_id": current_user.id, "candidate_limit": candidate_limit, "sql_offset": sql_offset,
"hide_watched": 1 if hide_watched else 0,
"w_recency": w_recency, "w_affinity": w_affinity, "w_channel": w_channel}, "w_recency": w_recency, "w_affinity": w_affinity, "w_channel": w_channel},
).mappings().all() ).mappings().all()
# Tier-based sampling: scores span -365..+100+ so ±N jitter is useless.
# Instead split the ranked pool into thirds and randomly sample from each,
# so every reshuffle genuinely picks a different mix of top/mid/wildcard videos.
candidates = [dict(r) for r in rows]
n = len(candidates)
if n <= limit:
_random.shuffle(candidates)
top = candidates
else:
split1 = max(n * 2 // 5, limit) # top 40 %
split2 = max(n * 4 // 5, split1 + 1) # next 40 %
t1 = candidates[:split1]
t2 = candidates[split1:split2]
t3 = candidates[split2:]
# 60 % from t1, 30 % from t2, 10 % wildcards from t3
n1 = limit * 6 // 10
n2 = limit * 3 // 10
n3 = limit - n1 - n2
picked = (
_random.sample(t1, min(n1, len(t1)))
+ (_random.sample(t2, min(n2, len(t2))) if t2 else [])
+ (_random.sample(t3, min(n3, len(t3))) if t3 else [])
)
# Fill any shortfall when a tier was smaller than requested
if len(picked) < limit:
already = {id(x) for x in picked}
rest = [x for x in candidates if id(x) not in already]
if rest:
picked += _random.sample(rest, min(limit - len(picked), len(rest)))
_random.shuffle(picked)
top = picked[:limit]
# Track impressions — penalises videos shown but not clicked on repeat visits
if page_num == 0 and top:
for item in top:
if not item["watched"]:
db.execute(text("""
INSERT INTO user_videos (user_id, video_id, feed_shown_count)
VALUES (:uid, :vid, 1)
ON CONFLICT (user_id, video_id)
DO UPDATE SET feed_shown_count = feed_shown_count + 1
"""), {"uid": current_user.id, "vid": item["id"]})
db.commit()
followed = [ followed = [
VideoDetail(**{k: v for k, v in dict(r).items() if k not in ("watched", "score", "rn")}, VideoDetail(**{k: v for k, v in item.items() if k not in ("watched", "score", "rn")},
is_watched=bool(r["watched"])) is_watched=bool(item["watched"]))
for r in rows for item in top
] ]
# Inject discovery cards on every page: 1 every 5 followed cards. # Inject discovery cards: 1 every 3 followed cards (~25% recommendations).
disc_per_page = max(limit // 5, 1) disc_per_page = max(limit // 3, 1)
disc_offset = (offset // limit) * disc_per_page if limit > 0 else 0 disc_offset = (offset // limit) * disc_per_page if limit > 0 else 0
disc_rows = db.execute( disc_rows = db.execute(
@@ -341,17 +468,23 @@ def home_feed(
{"user_id": current_user.id, "disc_limit": disc_per_page, "disc_offset": disc_offset}, {"user_id": current_user.id, "disc_limit": disc_per_page, "disc_offset": disc_offset},
).mappings().all() ).mappings().all()
import random as _rand
disc_list = [dict(r) for r in disc_rows]
# Shuffle top-tier recs so the same channel doesn't always appear first
if len(disc_list) > 3:
top, rest = disc_list[:3], disc_list[3:]
_rand.shuffle(rest)
disc_list = top + rest
disc = [ disc = [
VideoDetail(**{k: v for k, v in dict(r).items()}, VideoDetail(**r, is_recommended=True, is_watched=False, is_downloaded=False)
is_recommended=True, is_watched=False, is_downloaded=False) for r in disc_list
for r in disc_rows
] ]
# Interleave: one discovery card every 5 followed cards # Interleave: one discovery card every 3 followed cards
result: list[VideoDetail] = [] result: list[VideoDetail] = []
disc_iter = iter(disc) disc_iter = iter(disc)
for i, v in enumerate(followed): for i, v in enumerate(followed):
if i > 0 and i % 5 == 0: if i > 0 and i % 3 == 0:
rec = next(disc_iter, None) rec = next(disc_iter, None)
if rec: if rec:
result.append(rec) result.append(rec)
@@ -458,9 +591,9 @@ def _row_to_detail(row) -> VideoDetail:
) )
def _upsert_video_from_yt(db: Session, youtube_video_id: str) -> bool: def _upsert_video_from_yt(db: Session, youtube_video_id: str, polite: bool = False) -> bool:
"""Fetch fresh metadata from yt-dlp and upsert video + channel. Returns True if successful.""" """Fetch fresh metadata from yt-dlp and upsert video + channel. Returns True if successful."""
meta = ytdlp.fetch_video_metadata(youtube_video_id) meta = ytdlp.fetch_video_metadata(youtube_video_id, polite=polite)
if not meta: if not meta:
return False return False
@@ -573,18 +706,12 @@ def import_chapters(
import json as _json import json as _json
video = db.query(Video).filter(Video.id == video_id).first() video = db.query(Video).filter(Video.id == video_id).first()
if not video: if not video or video.chapters is None:
# chapters=NULL means enrichment hasn't run yet; the background fetch
# triggered by get_video_by_yt_id will fill this in. Don't call yt-dlp
# here — it runs polite=False and races with active downloads.
return [] return []
# chapters=NULL means never fetched; fetch now and cache the result (even if empty)
if video.chapters is None:
_upsert_video_from_yt(db, video.youtube_video_id)
db.refresh(video)
# Mark as checked even if no chapters found, so we don't re-fetch next time
if video.chapters is None:
video.chapters = "[]"
db.commit()
chapters = _json.loads(video.chapters or "[]") chapters = _json.loads(video.chapters or "[]")
# Skip if trivial (single chapter) or already imported # Skip if trivial (single chapter) or already imported
if len(chapters) < 2: if len(chapters) < 2:
@@ -654,6 +781,52 @@ def delete_bookmark(
db.commit() db.commit()
@router.get("/by-yt/{youtube_video_id}/subs")
def get_available_subs(
youtube_video_id: str,
current_user: User = Depends(get_current_user),
):
"""Return subtitle languages available on YouTube for a video (yt-dlp call, slow)."""
return ytdlp.fetch_available_subs(youtube_video_id)
@router.post("/by-yt/{youtube_video_id}/download-subs")
def download_subs(
youtube_video_id: str,
body: dict,
current_user: User = Depends(get_current_user),
):
"""Download subtitle file(s) only for an already-downloaded video."""
langs = (body.get("subtitle_langs") or "").strip()
if not langs:
raise HTTPException(status_code=400, detail="subtitle_langs required")
ok = ytdlp.download_subs_only(youtube_video_id, langs)
if not ok:
raise HTTPException(status_code=500, detail="Subtitle download failed")
return {"ok": True}
@router.get("/by-yt/{youtube_video_id}/subtitle-files")
def list_subtitle_files(
youtube_video_id: str,
current_user: User = Depends(get_current_user),
):
"""List .vtt subtitle files already on disk for a downloaded video (instant)."""
import re as _re
from pathlib import Path
from ..config import settings as _cfg
pat = _re.compile(rf'^{_re.escape(youtube_video_id)}\.(.+)\.vtt$')
subs = []
try:
for f in Path(_cfg.download_path).iterdir():
m = pat.match(f.name)
if m:
subs.append({"lang": m.group(1), "url": f"/files/{f.name}"})
except Exception:
pass
return sorted(subs, key=lambda s: s["lang"])
@router.get("/by-yt/{youtube_video_id}/comments") @router.get("/by-yt/{youtube_video_id}/comments")
def get_comments( def get_comments(
youtube_video_id: str, youtube_video_id: str,
@@ -762,14 +935,24 @@ def get_video_by_yt_id(
# Video unknown — must block to get at least a title before we can render anything # Video unknown — must block to get at least a title before we can render anything
_upsert_video_from_yt(db, youtube_video_id) _upsert_video_from_yt(db, youtube_video_id)
elif existing.description is None or existing.chapters is None: elif existing.description is None or existing.chapters is None:
# Video known but missing enrichment — fetch in background, return immediately # Video known but missing enrichment — schedule one background fetch.
# The frontend polls every 3 s while description is null; without the
# dedup guard each poll would spawn its own yt-dlp process.
with _enriching_lock:
already = youtube_video_id in _enriching
if not already:
_enriching.add(youtube_video_id)
if not already:
from ..database import SessionLocal from ..database import SessionLocal
def _enrich(yt_id: str): def _enrich(yt_id: str):
bg_db = SessionLocal() bg_db = SessionLocal()
try: try:
_upsert_video_from_yt(bg_db, yt_id) _upsert_video_from_yt(bg_db, yt_id, polite=True)
finally: finally:
bg_db.close() bg_db.close()
with _enriching_lock:
_enriching.discard(yt_id)
background_tasks.add_task(_enrich, youtube_video_id) background_tasks.add_task(_enrich, youtube_video_id)
row = db.execute( row = db.execute(
@@ -884,7 +1067,7 @@ def update_progress(
user_id=current_user.id, video_id=video_id, status="complete" user_id=current_user.id, video_id=video_id, status="complete"
).filter(Download.pending_delete_at.is_(None)).first() ).filter(Download.pending_delete_at.is_(None)).first()
if dl: if dl:
dl.pending_delete_at = datetime.utcnow() + timedelta(days=7) dl.pending_delete_at = datetime.utcnow() + timedelta(hours=2)
elif body.watched and prev_watched: elif body.watched and prev_watched:
# Rewatch — strongest positive signal # Rewatch — strongest positive signal
uv.rewatch_count = (uv.rewatch_count or 0) + 1 uv.rewatch_count = (uv.rewatch_count or 0) + 1
@@ -898,6 +1081,19 @@ def update_progress(
if pct < 0.20: if pct < 0.20:
_update_affinity(db, current_user.id, video, -0.5) _update_affinity(db, current_user.id, video, -0.5)
# Backend safety net: auto-mark watched at ≥75% completion even if the frontend
# didn't send watched=True (e.g. browser closed before debounce fired)
if (not prev_watched and not uv.watched
and uv.completion_percent is not None and uv.completion_percent >= 75
and video.duration_seconds and video.duration_seconds > 60):
uv.watched = True
_update_affinity(db, current_user.id, video, +2.0)
dl = db.query(Download).filter_by(
user_id=current_user.id, video_id=video_id, status="complete"
).filter(Download.pending_delete_at.is_(None)).first()
if dl:
dl.pending_delete_at = datetime.utcnow() + timedelta(hours=2)
db.commit() db.commit()
return {"ok": True} return {"ok": True}

107
backend/routers/widget.py Normal file
View File

@@ -0,0 +1,107 @@
"""
Read-only widget endpoints for external dashboards (e.g. backstage).
Auth: X-Widget-Key header must match WIDGET_API_KEY env var.
No user session required — returns data for the first admin user.
"""
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import text
from ..config import settings
from ..database import get_db
from ..models import User
router = APIRouter()
def _require_widget_key(x_widget_key: Optional[str] = Header(default=None)):
if not settings.widget_api_key:
raise HTTPException(status_code=503, detail="widget API not configured")
if x_widget_key != settings.widget_api_key:
raise HTTPException(status_code=401, detail="invalid widget key")
def _get_widget_user(db: Session) -> User:
user = db.query(User).filter_by(is_admin=True).order_by(User.id).first()
if not user:
user = db.query(User).order_by(User.id).first()
if not user:
raise HTTPException(status_code=503, detail="no users")
return user
@router.get("/recent")
def recent_videos(
limit: int = 12,
db: Session = Depends(get_db),
_: None = Depends(_require_widget_key),
):
"""Recent unwatched videos from followed channels."""
user = _get_widget_user(db)
rows = db.execute(
text("""
SELECT v.youtube_video_id, v.title, v.thumbnail_url,
v.duration_seconds, v.published_at,
c.name AS channel_name, c.youtube_channel_id AS channel_yt_id,
COALESCE(uv.watched, 0) AS watched
FROM videos v
JOIN channels c ON v.channel_id = c.id
JOIN user_channels uc ON c.id = uc.channel_id
AND uc.user_id = :uid AND uc.status = 'followed'
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :uid
WHERE COALESCE(uv.watched, 0) = 0
AND v.published_at IS NOT NULL
AND (uc.muted_until IS NULL OR datetime(uc.muted_until) < datetime('now'))
ORDER BY v.published_at DESC
LIMIT :limit
"""),
{"uid": user.id, "limit": limit},
).mappings().all()
return {
"videos": [
{
"youtube_video_id": r["youtube_video_id"],
"title": r["title"],
"channel_name": r["channel_name"],
"thumbnail_url": r["thumbnail_url"],
"published_at": r["published_at"],
"duration_seconds": r["duration_seconds"],
"url": f"https://yt.nullinput.io/watch/{r['youtube_video_id']}",
}
for r in rows
]
}
@router.get("/stats")
def widget_stats(
db: Session = Depends(get_db),
_: None = Depends(_require_widget_key),
):
"""Quick stats: unwatched count, channel count, recent activity."""
user = _get_widget_user(db)
row = db.execute(
text("""
SELECT
COUNT(*) FILTER (WHERE COALESCE(uv.watched, 0) = 0) AS unwatched,
COUNT(*) FILTER (WHERE v.published_at >= datetime('now', '-7 days')
AND COALESCE(uv.watched, 0) = 0) AS new_this_week,
COUNT(DISTINCT uc.channel_id) AS channel_count
FROM user_channels uc
JOIN channels c ON c.id = uc.channel_id
JOIN videos v ON v.channel_id = c.id
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :uid
WHERE uc.user_id = :uid AND uc.status = 'followed'
"""),
{"uid": user.id},
).mappings().first()
return {
"unwatched": row["unwatched"] if row else 0,
"new_this_week": row["new_this_week"] if row else 0,
"channel_count": row["channel_count"] if row else 0,
}

View File

@@ -1,6 +1,9 @@
"""Discovery engine — search-based crawl, trending, community signal, category clustering.""" """Discovery engine — search-based crawl, trending, community signal, category clustering."""
import json import json
import queue as _queue
import random import random
import threading as _threading
import time as _time
from datetime import datetime from datetime import datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import text from sqlalchemy import text
@@ -8,11 +11,21 @@ from sqlalchemy import text
from ..models import Channel, UserChannel, DiscoveryQueue, Video from ..models import Channel, UserChannel, DiscoveryQueue, Video
from . import ytdlp from . import ytdlp
# ---------------------------------------------------------------------------
# Background task queue — spaces yt-dlp calls 30-90 s apart and shuffles
# call types so we don't fire 10 searches in a row.
# ---------------------------------------------------------------------------
_task_queue: _queue.Queue = _queue.Queue()
_progress: dict[int, dict] = {} # user_id -> {total, done, running}
_progress_lock = _threading.Lock()
_worker_started = False
_worker_lock = _threading.Lock()
def _fetch_and_index_channel(db: Session, channel: Channel): def _fetch_and_index_channel(db: Session, channel: Channel):
"""Fetch full metadata + recent videos for a discovered channel.""" """Fetch metadata + recent videos for a discovered channel (one yt-dlp call only)."""
try: try:
result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id, max_videos=10) result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id, max_videos=10, polite=True)
if not result: if not result:
return return
ch_data = result.get("channel", {}) ch_data = result.get("channel", {})
@@ -21,32 +34,9 @@ def _fetch_and_index_channel(db: Session, channel: Channel):
setattr(channel, k, v) setattr(channel, k, v)
channel.crawled_at = datetime.utcnow() channel.crawled_at = datetime.utcnow()
videos = result.get("videos", []) for vdata in result.get("videos", []):
# For videos missing a date (RSS didn't cover them or flat-playlist had no timestamp),
# do individual fetches — capped at 3 to avoid slow-downs.
dateless = [v for v in videos if not v.get("published_at")]
individual_fetched: dict[str, dict] = {}
for vdata in dateless[:3]:
yt_id = vdata.get("youtube_video_id") yt_id = vdata.get("youtube_video_id")
if not yt_id: if not yt_id or not vdata.get("published_at"):
continue
try:
meta = ytdlp.fetch_video_metadata(yt_id)
if meta and meta.get("published_at"):
individual_fetched[yt_id] = meta
except Exception:
pass
for vdata in videos:
yt_id = vdata.get("youtube_video_id")
if not yt_id:
continue
# Prefer individually-fetched metadata if we retrieved it
if yt_id in individual_fetched:
vdata = individual_fetched[yt_id]
# Skip videos we still can't date — undated videos break feed ordering
if not vdata.get("published_at"):
continue continue
if not db.query(Video).filter_by(youtube_video_id=yt_id).first(): if not db.query(Video).filter_by(youtube_video_id=yt_id).first():
db.add(Video( db.add(Video(
@@ -77,14 +67,18 @@ def _upsert_channel(db: Session, channel_data: dict) -> Channel | None:
return channel return channel
_MAX_DISCOVERY_SCORE = 50.0
def _add_to_discovery( def _add_to_discovery(
db: Session, user_id: int, channel_id: int, score: float, source: str, db: Session, user_id: int, channel_id: int, score: float, source: str,
preview_json: str | None = None, preview_json: str | None = None,
): ):
score = min(score, _MAX_DISCOVERY_SCORE)
existing = db.query(DiscoveryQueue).filter_by(user_id=user_id, channel_id=channel_id).first() existing = db.query(DiscoveryQueue).filter_by(user_id=user_id, channel_id=channel_id).first()
if existing: if existing:
# Accumulate scores across sources but cap to prevent one dominant signal # Accumulate across sources but cap so no single signal dominates forever
existing.score = existing.score + score * 0.5 existing.score = min(existing.score + score * 0.5, _MAX_DISCOVERY_SCORE)
if preview_json and not existing.preview_json: if preview_json and not existing.preview_json:
existing.preview_json = preview_json existing.preview_json = preview_json
return return
@@ -100,12 +94,16 @@ def _add_to_discovery(
def _search_and_store( def _search_and_store(
db: Session, user_id: int, queries: list[str], db: Session, user_id: int, queries: list[str],
followed_yt_ids: set[str], score_multiplier: float, source: str, followed_yt_ids: set[str], score_multiplier: float, source: str,
neg_affinity_tags: frozenset[str] = frozenset(),
): ):
"""Run YouTube searches for the given queries and add results to discovery.""" """Run YouTube searches for the given queries and add results to discovery."""
discovered: dict[str, dict] = {} discovered: dict[str, dict] = {}
for query in queries: for query in queries:
try: try:
results = ytdlp.search_youtube(query, max_results=20) results = ytdlp.search_youtube(query, max_results=40, polite=True)
except Exception:
results = []
for video in results: for video in results:
ch = video.get("channel", {}) ch = video.get("channel", {})
yt_id = ch.get("youtube_channel_id") yt_id = ch.get("youtube_channel_id")
@@ -120,8 +118,6 @@ def _search_and_store(
"thumbnail_url": video["thumbnail_url"], "thumbnail_url": video["thumbnail_url"],
"title": video["title"], "title": video["title"],
}) })
except Exception:
continue
if not discovered: if not discovered:
return return
@@ -145,6 +141,24 @@ def _search_and_store(
uc = db.query(UserChannel).filter_by(user_id=user_id, channel_id=channel.id).first() uc = db.query(UserChannel).filter_by(user_id=user_id, channel_id=channel.id).first()
if uc and uc.status in ("followed", "dismissed"): if uc and uc.status in ("followed", "dismissed"):
continue continue
# Skip channels whose indexed videos heavily overlap with negatively-rated tags
if neg_affinity_tags and not is_new and channel.crawled_at:
neg_hit = 0
vtags = db.execute(
text("SELECT tags FROM videos WHERE channel_id = :cid AND tags IS NOT NULL LIMIT 20"),
{"cid": channel.id},
).scalars().all()
for tags_json in vtags:
try:
for tag in json.loads(tags_json or "[]"):
if isinstance(tag, str) and tag.lower().strip() in neg_affinity_tags:
neg_hit += 1
except (json.JSONDecodeError, TypeError):
pass
if neg_hit >= 3:
continue
preview_json = json.dumps(info["previews"]) if info["previews"] else None preview_json = json.dumps(info["previews"]) if info["previews"] else None
_add_to_discovery( _add_to_discovery(
db, user_id, channel.id, db, user_id, channel.id,
@@ -157,10 +171,9 @@ def _search_and_store(
db.commit() db.commit()
for channel_id in needs_indexing[:5]: # Queue channel indexing as separate worker tasks (30-90 s gaps apply).
channel = db.query(Channel).filter_by(id=channel_id).first() for channel_id in needs_indexing[:3]:
if channel: _task_queue.put((user_id, lambda cid=channel_id: _do_task_index_channel(user_id, cid)))
_fetch_and_index_channel(db, channel)
def crawl_by_search(db: Session, user_id: int): def crawl_by_search(db: Session, user_id: int):
@@ -231,21 +244,32 @@ def crawl_by_search(db: Session, user_id: int):
{"user_id": user_id}, {"user_id": user_id},
).mappings().all() ).mappings().all()
# Build query pool: top tags + random channel names + categories # Keep the query count low — each query is a separate yt-dlp subprocess
top_tags = [t for t, _ in sorted(tag_counts.items(), key=lambda x: -x[1])[:6]] # (its own HTTP session). Too many back-to-back sessions look like a bot.
top_tags = [t for t, _ in sorted(tag_counts.items(), key=lambda x: -x[1])[:5]]
top_cats = [r["category"] for r in cat_rows] top_cats = [r["category"] for r in cat_rows]
# Random sample of followed channel names — diversifies discovery each run # A few randomly-sampled channel names — diversifies results each run
sampled_names: list[str] = [] sampled_names: list[str] = []
if followed_names: if followed_names:
sampled_names = random.sample(followed_names, min(8, len(followed_names))) sampled_names = random.sample(followed_names, min(4, len(followed_names)))
# Combine: tags (most signal) + channel names (broad reach) + categories (fallback) # One serendipity query to surface content outside the user's direct tag space
queries = list(dict.fromkeys(top_tags + sampled_names + top_cats))[:15] serendipity = [f"best {top_cats[0]} channels"] if top_cats else []
# Total target: ≤10 queries
queries = list(dict.fromkeys(top_tags + sampled_names + serendipity + top_cats[:2]))[:10]
if not queries: if not queries:
return return
_search_and_store(db, user_id, queries, followed_yt_ids, score_multiplier=5.0, source="search") neg_tags = frozenset(
r["tag"] for r in db.execute(
text("SELECT tag FROM user_tag_affinity WHERE user_id = :user_id AND score < -2"),
{"user_id": user_id},
).mappings().all()
)
_search_and_store(db, user_id, queries, followed_yt_ids, score_multiplier=5.0, source="search",
neg_affinity_tags=neg_tags)
def update_community_signal(db: Session, user_id: int): def update_community_signal(db: Session, user_id: int):
@@ -295,22 +319,26 @@ def update_category_clusters(db: Session, user_id: int):
if not top_categories: if not top_categories:
return return
placeholders = ",".join(f"'{c}'" for c in top_categories) # Use JSON_EACH / parameterized IN via repeated queries to avoid SQL injection
candidate_rows = db.execute( candidate_channel_ids: set[int] = set()
text(f""" for cat in top_categories:
cat_rows = db.execute(
text("""
SELECT DISTINCT v.channel_id SELECT DISTINCT v.channel_id
FROM videos v FROM videos v
WHERE v.category IN ({placeholders}) WHERE v.category = :cat
AND v.channel_id NOT IN ( AND v.channel_id NOT IN (
SELECT channel_id FROM user_channels WHERE user_id = :user_id SELECT channel_id FROM user_channels WHERE user_id = :user_id
) )
LIMIT 100 LIMIT 50
"""), """),
{"user_id": user_id}, {"cat": cat, "user_id": user_id},
).mappings().all() ).mappings().all()
for row in cat_rows:
candidate_channel_ids.add(row["channel_id"])
for row in candidate_rows: for channel_id in candidate_channel_ids:
_add_to_discovery(db, user_id, row["channel_id"], score=3.0, source="category") _add_to_discovery(db, user_id, channel_id, score=5.0, source="category")
db.commit() db.commit()
@@ -354,8 +382,15 @@ def update_liked_signal(db: Session, user_id: int):
{"user_id": user_id}, {"user_id": user_id},
).scalars().all()) ).scalars().all())
top_tags = [t for t, _ in sorted(tag_counts.items(), key=lambda x: -x[1])[:6]] top_tags = [t for t, _ in sorted(tag_counts.items(), key=lambda x: -x[1])[:4]]
_search_and_store(db, user_id, top_tags, followed_yt_ids, score_multiplier=10.0, source="liked") neg_tags = frozenset(
r["tag"] for r in db.execute(
text("SELECT tag FROM user_tag_affinity WHERE user_id = :user_id AND score < -2"),
{"user_id": user_id},
).mappings().all()
)
_search_and_store(db, user_id, top_tags, followed_yt_ids, score_multiplier=10.0, source="liked",
neg_affinity_tags=neg_tags)
def update_watch_signal(db: Session, user_id: int): def update_watch_signal(db: Session, user_id: int):
@@ -407,41 +442,28 @@ def update_watch_signal(db: Session, user_id: int):
{"user_id": user_id}, {"user_id": user_id},
).scalars().all()) ).scalars().all())
top_tags = [t for t, _ in sorted(qualified.items(), key=lambda x: -x[1])[:6]] top_tags = [t for t, _ in sorted(qualified.items(), key=lambda x: -x[1])[:10]]
neg_tags = frozenset(
_search_and_store(db, user_id, top_tags, followed_yt_ids, score_multiplier=3.0, source="watched") r["tag"] for r in db.execute(
text("SELECT tag FROM user_tag_affinity WHERE user_id = :user_id AND score < -2"),
{"user_id": user_id},
).mappings().all()
)
_search_and_store(db, user_id, top_tags, followed_yt_ids, score_multiplier=3.0, source="watched",
neg_affinity_tags=neg_tags)
def _build_user_tag_profile(db: Session, user_id: int) -> dict[str, float]: def _build_user_tag_profile(db: Session, user_id: int) -> dict[str, float]:
"""Return a weighted tag dict from liked (weight 3) + watched (weight 1) videos.""" """Return tag affinity dict (positive = liked, negative = disliked/dismissed)."""
rows = db.execute( rows = db.execute(
text(""" text("SELECT tag, score FROM user_tag_affinity WHERE user_id = :user_id"),
SELECT v.tags, MAX(uv.liked) AS liked
FROM user_videos uv
JOIN videos v ON uv.video_id = v.id
WHERE uv.user_id = :user_id AND (uv.liked = 1 OR uv.watched = 1)
AND v.tags IS NOT NULL AND v.tags != '' AND v.tags != '[]'
GROUP BY v.id
"""),
{"user_id": user_id}, {"user_id": user_id},
).mappings().all() ).mappings().all()
return {row["tag"]: row["score"] for row in rows}
profile: dict[str, float] = {}
for row in rows:
weight = 3.0 if row["liked"] else 1.0
try:
for tag in json.loads(row["tags"]):
if isinstance(tag, str):
t = tag.lower().strip()
if 3 <= len(t) <= 40:
profile[t] = profile.get(t, 0.0) + weight
except (json.JSONDecodeError, TypeError):
pass
return profile
def _tag_relevance_score(tag_profile: dict[str, float], tags_json: str | None) -> float: def _tag_relevance_score(tag_profile: dict[str, float], tags_json: str | None) -> float:
"""Score a candidate channel's tags against the user's interest profile.""" """Score a channel's tags against user affinity — positive means relevant, negative means disliked."""
if not tag_profile or not tags_json: if not tag_profile or not tags_json:
return 0.0 return 0.0
try: try:
@@ -453,35 +475,7 @@ def _tag_relevance_score(tag_profile: dict[str, float], tags_json: str | None) -
if isinstance(tag, str): if isinstance(tag, str):
t = tag.lower().strip() t = tag.lower().strip()
score += tag_profile.get(t, 0.0) score += tag_profile.get(t, 0.0)
return min(score, 50.0) return max(-100.0, min(score, 50.0))
def _dismissed_channel_tags(db: Session, user_id: int) -> set[str]:
"""Collect tags of channels this user explicitly dismissed — used to avoid similar content."""
rows = db.execute(
text("""
SELECT v.tags
FROM user_channels uc
JOIN videos v ON v.channel_id = uc.channel_id
WHERE uc.user_id = :user_id AND uc.status = 'dismissed'
AND v.tags IS NOT NULL AND v.tags != '' AND v.tags != '[]'
LIMIT 500
"""),
{"user_id": user_id},
).mappings().all()
bad_tags: dict[str, int] = {}
for row in rows:
try:
for tag in json.loads(row["tags"]):
if isinstance(tag, str):
t = tag.lower().strip()
if 3 <= len(t) <= 40:
bad_tags[t] = bad_tags.get(t, 0) + 1
except (json.JSONDecodeError, TypeError):
pass
# Only include tags that appeared in 3+ dismissed-channel videos (strong signal)
return {t for t, c in bad_tags.items() if c >= 3}
def update_trending_signal(db: Session, user_id: int, regions: list[str]): def update_trending_signal(db: Session, user_id: int, regions: list[str]):
@@ -490,7 +484,6 @@ def update_trending_signal(db: Session, user_id: int, regions: list[str]):
return return
tag_profile = _build_user_tag_profile(db, user_id) tag_profile = _build_user_tag_profile(db, user_id)
dismiss_tags = _dismissed_channel_tags(db, user_id)
followed_yt_ids = set(db.execute( followed_yt_ids = set(db.execute(
text(""" text("""
@@ -556,10 +549,12 @@ def update_trending_signal(db: Session, user_id: int, regions: list[str]):
if uc and uc.status in ("followed", "dismissed"): if uc and uc.status in ("followed", "dismissed"):
continue continue
# Score: base ×4 per region × count, boosted by tag relevance, penalised by dismiss-tag overlap # Cap base_score so a viral trending channel can't dominate the whole queue.
base_score = float(info["count"]) * 4.0 * len(info["regions"]) # count × 4.0 × regions can reach 300+ without this cap.
base_score = min(float(info["count"]) * 4.0 * len(info["regions"]), 18.0)
# Tag relevance boost (requires channel to have indexed videos) # Tag relevance: positive for liked content, negative for dismissed/disliked.
# tag_profile comes from user_tag_affinity which tracks both signals.
tag_boost = 0.0 tag_boost = 0.0
if not is_new and channel.crawled_at: if not is_new and channel.crawled_at:
tag_rows = db.execute( tag_rows = db.execute(
@@ -568,25 +563,8 @@ def update_trending_signal(db: Session, user_id: int, regions: list[str]):
).scalars().all() ).scalars().all()
for tags_json in tag_rows: for tags_json in tag_rows:
tag_boost += _tag_relevance_score(tag_profile, tags_json) tag_boost += _tag_relevance_score(tag_profile, tags_json)
tag_boost = min(tag_boost, 30.0)
# Dismiss penalty: if channel's tags overlap heavily with dismissed content, reduce score final_score = min(base_score + tag_boost, 25.0)
dismiss_penalty = 0.0
if dismiss_tags and not is_new:
tag_rows2 = db.execute(
text("SELECT tags FROM videos WHERE channel_id = :cid AND tags IS NOT NULL LIMIT 20"),
{"cid": channel.id},
).scalars().all()
for tags_json in tag_rows2:
try:
for tag in json.loads(tags_json or "[]"):
if isinstance(tag, str) and tag.lower().strip() in dismiss_tags:
dismiss_penalty += 5.0
except (json.JSONDecodeError, TypeError):
pass
dismiss_penalty = min(dismiss_penalty, base_score * 0.8)
final_score = base_score + tag_boost - dismiss_penalty
if final_score <= 0: if final_score <= 0:
continue continue
@@ -597,18 +575,411 @@ def update_trending_signal(db: Session, user_id: int, regions: list[str]):
db.commit() db.commit()
for channel_id in needs_indexing[:5]:
channel = db.query(Channel).filter_by(id=channel_id).first() def update_graph_signal(db: Session, user_id: int):
if channel: """Discover channels featured on followed channels' /channels tab.
_fetch_and_index_channel(db, channel)
Channels that creators explicitly recommend are high-signal — they're
curated by someone whose taste you already follow. Samples up to 12 followed
channels per run and fetches their featured channels list in parallel.
"""
followed_rows = db.execute(
text("""
SELECT c.youtube_channel_id, c.id
FROM channels c
JOIN user_channels uc ON c.id = uc.channel_id
WHERE uc.user_id = :user_id AND uc.status = 'followed'
AND c.youtube_channel_id IS NOT NULL
"""),
{"user_id": user_id},
).mappings().all()
if not followed_rows:
return
followed_yt_ids = {row["youtube_channel_id"] for row in followed_rows}
dismissed_ids = set(db.execute(
text("SELECT channel_id FROM user_channels WHERE user_id = :user_id AND status = 'dismissed'"),
{"user_id": user_id},
).scalars().all())
sample = random.sample(list(followed_rows), min(6, len(followed_rows)))
featured_map: dict[str, list[str]] = {}
for row in sample:
try:
featured_map[row["youtube_channel_id"]] = ytdlp.fetch_featured_channels(row["youtube_channel_id"])
except Exception:
featured_map[row["youtube_channel_id"]] = []
needs_indexing: list[int] = []
for source_yt_id, channel_ids in featured_map.items():
for yt_id in channel_ids:
if yt_id in followed_yt_ids:
continue
channel = db.query(Channel).filter_by(youtube_channel_id=yt_id).first()
is_new = channel is None
if not channel:
channel = Channel(youtube_channel_id=yt_id, name="", description="", thumbnail_url=None)
db.add(channel)
db.flush()
if channel.id in dismissed_ids:
continue
uc = db.query(UserChannel).filter_by(user_id=user_id, channel_id=channel.id).first()
if uc and uc.status in ("followed", "dismissed"):
continue
_add_to_discovery(db, user_id, channel.id, score=8.0, source="graph")
if is_new or not channel.crawled_at:
needs_indexing.append(channel.id)
db.commit()
def run_full_discovery(db: Session, user_id: int, regions: list[str] | None = None): def run_full_discovery(db: Session, user_id: int, regions: list[str] | None = None):
if regions is None: if regions is None:
regions = ["US", "SE"] regions = ["US", "SE"]
crawl_by_search(db, user_id)
# Expire unseen entries older than 14 days so stale high-score channels
# don't block fresh results forever.
db.execute(
text("""
DELETE FROM discovery_queue
WHERE user_id = :user_id AND seen = 0
AND created_at <= datetime('now', '-14 days')
"""),
{"user_id": user_id},
)
db.commit()
crawl_by_search(db, user_id) # ~10 yt-dlp calls
update_community_signal(db, user_id) # no yt-dlp
update_category_clusters(db, user_id) # no yt-dlp
update_liked_signal(db, user_id) # ~4 yt-dlp calls
# update_watch_signal skipped — tags already included in crawl_by_search
update_trending_signal(db, user_id, regions[:1]) # 1 yt-dlp call (first region only)
update_graph_signal(db, user_id) # ~6 yt-dlp calls
# ---------------------------------------------------------------------------
# Queue-based gradual discovery — each yt-dlp call is its own task, shuffled
# so call types are mixed, with 30-90 s gaps between them.
# ---------------------------------------------------------------------------
def _get_followed_yt_ids(db: Session, user_id: int) -> set[str]:
return set(db.execute(
text("""
SELECT c.youtube_channel_id FROM channels c
JOIN user_channels uc ON c.id = uc.channel_id
WHERE uc.user_id = :uid AND uc.status = 'followed'
"""),
{"uid": user_id},
).scalars().all())
def _get_neg_tags(db: Session, user_id: int) -> frozenset[str]:
return frozenset(db.execute(
text("SELECT tag FROM user_tag_affinity WHERE user_id = :uid AND score < -2"),
{"uid": user_id},
).scalars().all())
def _stamp_last_run(user_id: int):
from ..database import SessionLocal
from sqlalchemy import text as _text
db = SessionLocal()
try:
db.execute(
_text("UPDATE user_settings SET last_discovery_run = :now WHERE user_id = :uid"),
{"now": datetime.utcnow(), "uid": user_id},
)
db.commit()
except Exception:
db.rollback()
finally:
db.close()
def _do_task_search(user_id: int, query: str, source: str, score_multiplier: float):
from ..database import SessionLocal
db = SessionLocal()
try:
followed_yt_ids = _get_followed_yt_ids(db, user_id)
neg_tags = _get_neg_tags(db, user_id)
_search_and_store(db, user_id, [query], followed_yt_ids, score_multiplier, source, neg_tags)
finally:
db.close()
def _do_task_trending(user_id: int, region: str):
from ..database import SessionLocal
db = SessionLocal()
try:
update_trending_signal(db, user_id, [region])
finally:
db.close()
def _fetch_graph_for_channel(db: Session, user_id: int, source_yt_id: str):
"""Fetch featured channels for one followed channel and add to discovery queue."""
followed_yt_ids = _get_followed_yt_ids(db, user_id)
dismissed_ids = set(db.execute(
text("SELECT channel_id FROM user_channels WHERE user_id = :uid AND status = 'dismissed'"),
{"uid": user_id},
).scalars().all())
try:
featured = ytdlp.fetch_featured_channels(source_yt_id)
except Exception:
return
needs_indexing: list[int] = []
for yt_id in featured:
if yt_id in followed_yt_ids:
continue
channel = db.query(Channel).filter_by(youtube_channel_id=yt_id).first()
is_new = channel is None
if not channel:
channel = Channel(youtube_channel_id=yt_id, name="", description="", thumbnail_url=None)
db.add(channel)
db.flush()
if channel.id in dismissed_ids:
continue
uc = db.query(UserChannel).filter_by(user_id=user_id, channel_id=channel.id).first()
if uc and uc.status in ("followed", "dismissed"):
continue
_add_to_discovery(db, user_id, channel.id, score=8.0, source="graph")
if is_new or not channel.crawled_at:
needs_indexing.append(channel.id)
db.commit()
for channel_id in needs_indexing[:2]:
_task_queue.put((user_id, lambda cid=channel_id: _do_task_index_channel(user_id, cid)))
def _do_task_graph(user_id: int, source_yt_id: str):
from ..database import SessionLocal
db = SessionLocal()
try:
_fetch_graph_for_channel(db, user_id, source_yt_id)
finally:
db.close()
def _do_task_index_channel(user_id: int, channel_id: int):
"""Index one newly-discovered channel (one yt-dlp call). Queued as a separate
worker task so the 30-90 s gap applies rather than bursting inline."""
from ..database import SessionLocal
db = SessionLocal()
try:
channel = db.query(Channel).filter_by(id=channel_id).first()
if channel:
_fetch_and_index_channel(db, channel)
finally:
db.close()
def _worker_loop():
while True:
try:
user_id, task = _task_queue.get(timeout=10)
except _queue.Empty:
continue
try:
task()
except Exception:
pass
with _progress_lock:
p = _progress.get(user_id)
if p:
p["done"] = min(p["done"] + 1, p["total"])
if p["done"] >= p["total"] and p["running"]:
p["running"] = False
_threading.Thread(target=_stamp_last_run, args=(user_id,), daemon=True).start()
_task_queue.task_done()
# Polite gap — only sleep if more tasks are waiting
if not _task_queue.empty():
_time.sleep(random.uniform(30, 90))
def start_discovery_worker():
"""Start the singleton background worker thread (idempotent)."""
global _worker_started
with _worker_lock:
if not _worker_started:
_threading.Thread(target=_worker_loop, daemon=True, name="discovery-worker").start()
_worker_started = True
def get_discovery_progress(user_id: int) -> dict | None:
with _progress_lock:
p = _progress.get(user_id)
return dict(p) if p is not None else None
def _build_search_task_args(db: Session, user_id: int) -> list[tuple[str, str, float]]:
"""Compute all search/liked query strings without executing any yt-dlp calls."""
result: list[tuple[str, str, float]] = []
followed_rows = db.execute(
text("""
SELECT c.name, c.youtube_channel_id
FROM channels c
JOIN user_channels uc ON c.id = uc.channel_id
WHERE uc.user_id = :user_id AND uc.status = 'followed'
"""),
{"user_id": user_id},
).mappings().all()
followed_names = [row["name"] for row in followed_rows if row["name"]]
tag_rows = db.execute(
text("""
SELECT tags FROM (
SELECT v.tags FROM videos v
JOIN user_channels uc ON v.channel_id = uc.channel_id
WHERE uc.user_id = :user_id AND uc.status = 'followed'
AND v.tags IS NOT NULL AND v.tags != '' AND v.tags != '[]'
LIMIT 300
)
UNION ALL
SELECT tags FROM (
SELECT v.tags FROM user_videos uv
JOIN videos v ON uv.video_id = v.id
WHERE uv.user_id = :user_id AND uv.liked = 1
AND v.tags IS NOT NULL AND v.tags != '' AND v.tags != '[]'
LIMIT 100
)
"""),
{"user_id": user_id},
).mappings().all()
tag_counts: dict[str, int] = {}
liked_tag_counts: dict[str, int] = {}
for row in tag_rows:
try:
for tag in json.loads(row["tags"]):
if isinstance(tag, str):
t = tag.lower().strip()
if 3 <= len(t) <= 40:
tag_counts[t] = tag_counts.get(t, 0) + 1
except (json.JSONDecodeError, TypeError):
continue
cat_rows = db.execute(
text("""
SELECT v.category, COUNT(*) AS cnt
FROM videos v
JOIN user_channels uc ON v.channel_id = uc.channel_id
WHERE uc.user_id = :user_id AND uc.status = 'followed'
AND v.category IS NOT NULL
GROUP BY v.category
ORDER BY cnt DESC
LIMIT 5
"""),
{"user_id": user_id},
).mappings().all()
top_tags = [t for t, _ in sorted(tag_counts.items(), key=lambda x: -x[1])[:5]]
top_cats = [r["category"] for r in cat_rows]
sampled_names = random.sample(followed_names, min(4, len(followed_names))) if followed_names else []
serendipity = [f"best {top_cats[0]} channels"] if top_cats else []
search_queries = list(dict.fromkeys(top_tags + sampled_names + serendipity + top_cats[:2]))[:10]
for q in search_queries:
result.append((q, "search", 5.0))
# Liked signal queries
liked_rows = db.execute(
text("""
SELECT v.tags FROM user_videos uv
JOIN videos v ON uv.video_id = v.id
WHERE uv.user_id = :user_id AND uv.liked = 1
AND v.tags IS NOT NULL AND v.tags != '' AND v.tags != '[]'
"""),
{"user_id": user_id},
).mappings().all()
for row in liked_rows:
try:
for tag in json.loads(row["tags"]):
if isinstance(tag, str):
t = tag.lower().strip()
if 3 <= len(t) <= 40:
liked_tag_counts[t] = liked_tag_counts.get(t, 0) + 2
except (json.JSONDecodeError, TypeError):
pass
for q in [t for t, _ in sorted(liked_tag_counts.items(), key=lambda x: -x[1])[:4]]:
result.append((q, "liked", 10.0))
return result
def _sample_graph_yt_ids(db: Session, user_id: int) -> list[str]:
rows = db.execute(
text("""
SELECT c.youtube_channel_id
FROM channels c
JOIN user_channels uc ON c.id = uc.channel_id
WHERE uc.user_id = :user_id AND uc.status = 'followed'
AND c.youtube_channel_id IS NOT NULL
"""),
{"user_id": user_id},
).scalars().all()
if not rows:
return []
return random.sample(list(rows), min(6, len(rows)))
def schedule_discovery(user_id: int, regions: list[str] | None = None):
"""Schedule a full discovery sweep, spreading yt-dlp calls 30-90 s apart
with call types shuffled so searches, graph fetches, and trending are mixed."""
if regions is None:
regions = ["US", "SE"]
from ..database import SessionLocal
# Fast signals (pure SQL, no yt-dlp) run synchronously right now
db = SessionLocal()
try:
db.execute(
text("""
DELETE FROM discovery_queue
WHERE user_id = :uid AND seen = 0
AND created_at <= datetime('now', '-14 days')
"""),
{"uid": user_id},
)
db.commit()
update_community_signal(db, user_id) update_community_signal(db, user_id)
update_category_clusters(db, user_id) update_category_clusters(db, user_id)
update_liked_signal(db, user_id)
update_watch_signal(db, user_id) search_args = _build_search_task_args(db, user_id)
update_trending_signal(db, user_id, regions) graph_yt_ids = _sample_graph_yt_ids(db, user_id)
finally:
db.close()
# Build one task per yt-dlp call, then shuffle to mix call types
tasks: list[tuple[int, object]] = []
for query, source, mult in search_args:
tasks.append((user_id, lambda q=query, s=source, m=mult: _do_task_search(user_id, q, s, m)))
for region in regions[:1]:
tasks.append((user_id, lambda r=region: _do_task_trending(user_id, r)))
for yt_id in graph_yt_ids:
tasks.append((user_id, lambda y=yt_id: _do_task_graph(user_id, y)))
random.shuffle(tasks)
with _progress_lock:
_progress[user_id] = {"total": len(tasks), "done": 0, "running": bool(tasks)}
for item in tasks:
_task_queue.put(item)
if not tasks:
_stamp_last_run(user_id)

View File

@@ -1,8 +1,13 @@
"""Subprocess wrapper for yt-dlp.""" """Subprocess wrapper for yt-dlp."""
import json import json
import os
import random
import re import re
import shutil
import subprocess import subprocess
import tempfile
import threading import threading
import time
import urllib.request import urllib.request
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -12,9 +17,82 @@ from typing import Any
from ..config import settings from ..config import settings
def _make_private_cookie_copy(args: list[str]) -> tuple[list[str], str | None]:
"""Replace --cookies <file> with a private temp copy so concurrent yt-dlp
processes never write to the same cookie jar simultaneously."""
for i, arg in enumerate(args):
if arg == "--cookies" and i + 1 < len(args):
source = args[i + 1]
if Path(source).exists():
try:
tmp = tempfile.NamedTemporaryFile(suffix=".txt", delete=False)
tmp.close()
shutil.copy2(source, tmp.name)
modified = list(args)
modified[i + 1] = tmp.name
return modified, tmp.name
except Exception:
break
return list(args), None
def _run(args: list[str], timeout: int = 60) -> tuple[str, str, int]: def _run(args: list[str], timeout: int = 60) -> tuple[str, str, int]:
args, tmp_path = _make_private_cookie_copy(args)
try:
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout) result = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
return result.stdout, result.stderr, result.returncode return result.stdout, result.stderr, result.returncode
finally:
if tmp_path:
try:
os.unlink(tmp_path)
except Exception:
pass
# Global rate limiter for all metadata fetches — prevents concurrent tasks from
# hammering YouTube and invalidating cookies.
_meta_lock = threading.Lock()
_meta_last_call: float = 0.0
_META_MIN_GAP = 12.0 # seconds between any two metadata requests
# Pin to tv_embedded client — it exposes full format list without SABR
# restrictions and makes exactly ONE set of API requests per operation.
# Without this yt-dlp probes web + mweb + android + tv in sequence,
# multiplying requests and triggering YouTube IP rate limits.
_YT_CLIENT = ["--extractor-args", "youtube:player_client=tv_embedded"]
# Active download counter — _meta_run pauses while any download is running so
# background discovery and a concurrent download never share the YouTube session
# at the same time. Downloads set/clear this; _meta_run reads it.
_active_downloads: int = 0
_active_downloads_lock = threading.Lock()
def _meta_run(args: list[str], timeout: int = 60) -> tuple[str, str, int]:
global _meta_last_call
with _meta_lock:
# Pause background metadata calls while a download is active.
# Running both concurrently causes YouTube to see two requests from the
# same session simultaneously, which triggers cookie invalidation.
while True:
with _active_downloads_lock:
if _active_downloads == 0:
break
time.sleep(3)
now = time.monotonic()
wait = _META_MIN_GAP - (now - _meta_last_call)
if wait > 0:
time.sleep(wait + random.uniform(1.0, 5.0))
try:
return _run(args, timeout=timeout)
finally:
_meta_last_call = time.monotonic()
def is_download_active() -> bool:
with _active_downloads_lock:
return _active_downloads > 0
def _parse_date(date_str: str | None) -> datetime | None: def _parse_date(date_str: str | None) -> datetime | None:
@@ -130,14 +208,16 @@ def _normalize_channel(info: dict) -> dict:
} }
def search_youtube(query: str, max_results: int = 40) -> list[dict]: def search_youtube(query: str, max_results: int = 40, polite: bool = False) -> list[dict]:
"""Search YouTube via yt-dlp. Uses --flat-playlist for fast results.""" """Search YouTube via yt-dlp. Uses --flat-playlist for fast results."""
stdout, _, code = _run([ runner = _meta_run if polite else _run
stdout, _, code = runner([
"yt-dlp", "yt-dlp",
f"ytsearch{max_results}:{query}", f"ytsearch{max_results}:{query}",
"--dump-json", "--dump-json",
"--flat-playlist", "--flat-playlist",
"--quiet", "--quiet",
*_YT_CLIENT,
*_cookie_args(), *_cookie_args(),
], timeout=60) ], timeout=60)
@@ -179,13 +259,14 @@ def fetch_trending(region: str = "US", max_results: int = 50) -> list[dict]:
region = region.upper() region = region.upper()
# CAI%3D = sort by upload date; gl= sets the region # CAI%3D = sort by upload date; gl= sets the region
url = f"https://www.youtube.com/results?search_query=trending&sp=CAI%253D&gl={region}" url = f"https://www.youtube.com/results?search_query=trending&sp=CAI%253D&gl={region}"
stdout, _, code = _run([ stdout, _, code = _meta_run([
"yt-dlp", "yt-dlp",
url, url,
"--dump-json", "--dump-json",
"--flat-playlist", "--flat-playlist",
"--quiet", "--quiet",
"--playlist-end", str(max_results), "--playlist-end", str(max_results),
*_YT_CLIENT,
*_cookie_args(), *_cookie_args(),
], timeout=60) ], timeout=60)
@@ -224,23 +305,27 @@ def _best_thumbnail(thumbnails: list | None) -> str | None:
return best[0].get("url") if best else None return best[0].get("url") if best else None
def fetch_video_metadata(video_id: str) -> dict | None: def fetch_video_metadata(video_id: str, polite: bool = False) -> dict | None:
"""Fetch metadata for a single video by YouTube ID.""" """Fetch metadata for a single video by YouTube ID.
polite=True applies the global rate limiter (for background batch tasks).
polite=False (default) runs immediately for user-facing requests.
"""
url = f"https://www.youtube.com/watch?v={video_id}" url = f"https://www.youtube.com/watch?v={video_id}"
cookie_args = _cookie_args() cookie_args = _cookie_args()
print(f"[fetch_meta] video={video_id} cookie_args={cookie_args!r}", flush=True) print(f"[fetch_meta] video={video_id} cookie_args={cookie_args!r}", flush=True)
base_cmd = [ base_cmd = [
"yt-dlp", url, "yt-dlp", url,
"--dump-json", "--no-download", "--no-playlist", "--dump-json", "--no-download", "--no-playlist",
"--extractor-args", "youtube:player_client=web", *_YT_CLIENT,
] ]
stdout, stderr, code = _run([*base_cmd, *cookie_args], timeout=30) runner = _meta_run if polite else _run
stdout, stderr, code = runner([*base_cmd, *cookie_args], timeout=30)
if code != 0: if code != 0:
print(f"[fetch_meta] FAILED code={code} stderr={stderr[:500]!r}", flush=True) print(f"[fetch_meta] FAILED code={code} stderr={stderr[:500]!r}", flush=True)
# Retry without auth args — broken cookie config shouldn't block public videos
if cookie_args: if cookie_args:
print(f"[fetch_meta] retrying without cookie args", flush=True) print(f"[fetch_meta] retrying without cookie args", flush=True)
stdout, stderr, code = _run(base_cmd, timeout=30) stdout, stderr, code = runner(base_cmd, timeout=30)
if code != 0: if code != 0:
print(f"[fetch_meta] retry also FAILED code={code}", flush=True) print(f"[fetch_meta] retry also FAILED code={code}", flush=True)
@@ -288,7 +373,7 @@ def _rss_dates(uc_channel_id: str) -> dict[str, datetime]:
return {} return {}
def fetch_channel_metadata(channel_id: str, max_videos: int = 30) -> dict | None: def fetch_channel_metadata(channel_id: str, max_videos: int = 30, start_video: int = 1, polite: bool = False) -> dict | None:
"""Fetch channel info + recent videos. """Fetch channel info + recent videos.
Uses --dump-single-json --flat-playlist for speed, then enriches video dates Uses --dump-single-json --flat-playlist for speed, then enriches video dates
@@ -303,12 +388,17 @@ def fetch_channel_metadata(channel_id: str, max_videos: int = 30) -> dict | None
"--dump-single-json", "--dump-single-json",
"--flat-playlist", "--flat-playlist",
"--quiet", "--quiet",
*_YT_CLIENT,
*_cookie_args(), *_cookie_args(),
] ]
if start_video > 1:
args += ["--playlist-start", str(start_video)]
if max_videos > 0: if max_videos > 0:
args += ["--playlist-end", str(max_videos)] end = (start_video - 1 + max_videos) if start_video > 1 else max_videos
args += ["--playlist-end", str(end)]
stdout, _, code = _run(args, timeout=60) runner = _meta_run if polite else _run
stdout, _, code = runner(args, timeout=60)
if not stdout.strip(): if not stdout.strip():
return None return None
@@ -351,13 +441,135 @@ def fetch_channel_metadata(channel_id: str, max_videos: int = 30) -> dict | None
return {"channel": channel_info, "videos": videos} return {"channel": channel_info, "videos": videos}
def fetch_channel_playlists(channel_id: str, max_results: int = 100) -> list[dict]:
"""Fetch the playlists listed on a channel's /playlists tab."""
if channel_id.startswith("@"):
url = f"https://www.youtube.com/{channel_id}/playlists"
else:
url = f"https://www.youtube.com/channel/{channel_id}/playlists"
stdout, _, code = _meta_run([
"yt-dlp", url,
"--dump-json", "--flat-playlist",
"--playlist-end", str(max_results),
"--quiet",
*_YT_CLIENT,
*_cookie_args(),
], timeout=60)
playlists = []
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
info = json.loads(line)
pl_id = info.get("id") or info.get("playlist_id")
title = info.get("title") or info.get("playlist_title") or ""
if not pl_id or not title or pl_id == channel_id:
continue
# Thumbnail: yt-dlp gives a thumbnails array for playlist entries;
# fall back to singular thumbnail field. Never use _stable_thumbnail
# here because the id is a playlist ID, not a video ID.
thumbs = info.get("thumbnails") or []
thumb_url = info.get("thumbnail")
if thumbs:
best = max(thumbs, key=lambda t: (t.get("width") or 0) * (t.get("height") or 0), default=None)
if best:
thumb_url = best.get("url") or thumb_url
playlists.append({
"youtube_playlist_id": pl_id,
"title": title,
"description": info.get("description"),
"thumbnail_url": thumb_url,
"video_count": info.get("playlist_count") or info.get("n_entries") or 0,
})
except json.JSONDecodeError:
continue
return playlists
def fetch_playlist_videos(playlist_id: str, max_videos: int = 200) -> list[dict]:
"""Fetch videos from a YouTube playlist by playlist ID."""
url = f"https://www.youtube.com/playlist?list={playlist_id}"
args = [
"yt-dlp", url,
"--dump-json", "--flat-playlist",
"--quiet",
*_YT_CLIENT,
*_cookie_args(),
]
if max_videos > 0:
args += ["--playlist-end", str(max_videos)]
stdout, _, code = _meta_run(args, timeout=120)
videos = []
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
info = json.loads(line)
vid_id = info.get("id")
if not vid_id:
continue
videos.append({
"youtube_video_id": vid_id,
"title": info.get("title", ""),
"thumbnail_url": _stable_thumbnail(vid_id),
"duration_seconds": info.get("duration"),
"published_at": _parse_published(info),
"view_count": info.get("view_count"),
"channel": {
"youtube_channel_id": info.get("channel_id"),
"name": info.get("channel") or info.get("uploader") or "",
},
})
except json.JSONDecodeError:
continue
return videos
def fetch_featured_channels(channel_id: str) -> list[str]:
"""Fetch channel IDs from the /channels tab of a YouTube channel.
The /channels tab lists channels the creator explicitly recommends — a very
high-signal source for discovery. Returns UC... channel IDs.
"""
if channel_id.startswith("@"):
url = f"https://www.youtube.com/{channel_id}/channels"
else:
url = f"https://www.youtube.com/channel/{channel_id}/channels"
stdout, _, code = _meta_run([
"yt-dlp", url,
"--dump-json",
"--flat-playlist",
"--quiet",
*_YT_CLIENT,
*_cookie_args(),
], timeout=30)
channel_ids: list[str] = []
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
info = json.loads(line)
ch_id = info.get("channel_id") or info.get("id")
if ch_id and ch_id.startswith("UC"):
channel_ids.append(ch_id)
except json.JSONDecodeError:
continue
return channel_ids
def fetch_channel_links(channel_id: str) -> list[str]: def fetch_channel_links(channel_id: str) -> list[str]:
"""Extract linked channel IDs from a channel's about/description.""" """Extract linked channel IDs from a channel's about/description."""
if channel_id.startswith("@"): if channel_id.startswith("@"):
url = f"https://www.youtube.com/{channel_id}/about" url = f"https://www.youtube.com/{channel_id}/about"
else: else:
url = f"https://www.youtube.com/channel/{channel_id}/about" url = f"https://www.youtube.com/channel/{channel_id}/about"
stdout, _, code = _run([ stdout, _, code = _meta_run([
"yt-dlp", "yt-dlp",
url, url,
"--dump-json", "--dump-json",
@@ -365,6 +577,7 @@ def fetch_channel_links(channel_id: str) -> list[str]:
"--flat-playlist", "--flat-playlist",
"--playlist-end", "1", "--playlist-end", "1",
"--quiet", "--quiet",
*_YT_CLIENT,
*_cookie_args(), *_cookie_args(),
], timeout=30) ], timeout=30)
@@ -385,11 +598,84 @@ def fetch_channel_links(channel_id: str) -> list[str]:
return list(channel_ids) return list(channel_ids)
def _strip_vtt_cue_settings(video_id: str) -> None:
"""Remove position/align/line cue settings from yt-dlp VTT files.
yt-dlp embeds 'align:start position:0%' in every cue header which pins
subtitles to the bottom-left. Stripping them lets CSS ::cue center them.
"""
for vtt in Path(settings.download_path).glob(f"{video_id}.*.vtt"):
try:
text = vtt.read_text(encoding="utf-8", errors="replace")
cleaned = re.sub(
r'(\d{1,2}:\d{2}:\d{2}\.\d{3} --> \d{1,2}:\d{2}:\d{2}\.\d{3})[^\n]*',
r'\1',
text,
)
vtt.write_text(cleaned, encoding="utf-8")
except Exception:
pass
def download_subs_only(video_id: str, subtitle_langs: str) -> bool:
"""Download subtitle files only (no video) for an already-downloaded video."""
url = f"https://www.youtube.com/watch?v={video_id}"
output_template = str(Path(settings.download_path) / f"{video_id}.%(ext)s")
_, _, code = _meta_run([
"yt-dlp", url,
"--skip-download", "--no-playlist",
"--write-subs", "--write-auto-subs",
"--sub-langs", subtitle_langs,
"--convert-subs", "vtt",
"-o", output_template,
*_YT_CLIENT,
*_cookie_args(),
], timeout=60)
if code == 0:
_strip_vtt_cue_settings(video_id)
return code == 0
def fetch_available_subs(video_id: str) -> dict:
"""Return subtitle languages available on YouTube for a video.
Returns {"manual": [...], "auto": [...]} where both are sorted lists of
BCP-47 lang codes. Manual = human-made; auto = auto-generated captions.
"""
url = f"https://www.youtube.com/watch?v={video_id}"
base_cmd = ["yt-dlp", url, "--dump-json", "--no-download", "--no-playlist", *_YT_CLIENT]
cookie_args = _cookie_args()
stdout, _, code = _meta_run([*base_cmd, *cookie_args], timeout=30)
if code != 0 and cookie_args:
stdout, _, code = _meta_run(base_cmd, timeout=30)
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
info = json.loads(line)
manual = sorted(info.get("subtitles") or {})
auto = sorted(set(
lang for lang in (info.get("automatic_captions") or {})
if not lang.endswith("-orig")
))
return {"manual": manual, "auto": auto}
except json.JSONDecodeError:
continue
return {"manual": [], "auto": []}
def fetch_video_comments(youtube_video_id: str, max_comments: int = 20) -> list[dict]: def fetch_video_comments(youtube_video_id: str, max_comments: int = 20) -> list[dict]:
"""Fetch top comments via yt-dlp CLI writing to a temp file. Returns empty list on failure.""" """Fetch top comments via yt-dlp CLI writing to a temp file. Returns empty list on failure."""
import os import os
import tempfile import tempfile
# Don't fire a concurrent yt-dlp session while a download is running — it
# causes YouTube to see two simultaneous authenticated sessions and invalidates cookies.
if is_download_active():
return []
url = f"https://www.youtube.com/watch?v={youtube_video_id}" url = f"https://www.youtube.com/watch?v={youtube_video_id}"
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
@@ -403,6 +689,7 @@ def fetch_video_comments(youtube_video_id: str, max_comments: int = 20) -> list[
"--skip-download", "--skip-download",
"--no-playlist", "--no-playlist",
"--output", out_tmpl, "--output", out_tmpl,
*_YT_CLIENT,
*_cookie_args(), *_cookie_args(),
] ]
_run(args, timeout=90) _run(args, timeout=90)
@@ -449,15 +736,15 @@ def fetch_dislike_count(youtube_video_id: str) -> int | None:
QUALITY_FORMATS = { QUALITY_FORMATS = {
"best": "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/22/18/bestvideo+bestaudio/best", "best": "bestvideo+bestaudio/best",
"2160p": "bestvideo[ext=mp4][height<=2160]+bestaudio[ext=m4a]/bestvideo[height<=2160]+bestaudio/best[height<=2160]", "2160p": "bestvideo[height<=2160]+bestaudio/bestvideo+bestaudio/best",
"1440p": "bestvideo[ext=mp4][height<=1440]+bestaudio[ext=m4a]/bestvideo[height<=1440]+bestaudio/best[height<=1440]", "1440p": "bestvideo[height<=1440]+bestaudio/bestvideo+bestaudio/best",
"1080p": "bestvideo[ext=mp4][vcodec^=avc1][height<=1080]+bestaudio[ext=m4a]/bestvideo[ext=mp4][height<=1080]+bestaudio[ext=m4a]/137+140/22/best[height<=1080]", "1080p": "bestvideo[height<=1080]+bestaudio/bestvideo+bestaudio/best",
"720p": "bestvideo[ext=mp4][vcodec^=avc1][height<=720]+bestaudio[ext=m4a]/bestvideo[ext=mp4][height<=720]+bestaudio[ext=m4a]/22/best[height<=720]", "720p": "bestvideo[height<=720]+bestaudio/bestvideo+bestaudio/best",
"480p": "bestvideo[ext=mp4][vcodec^=avc1][height<=480]+bestaudio[ext=m4a]/bestvideo[ext=mp4][height<=480]+bestaudio[ext=m4a]/18/best[height<=480]", "480p": "bestvideo[height<=480]+bestaudio/bestvideo+bestaudio/best",
"360p": "bestvideo[ext=mp4][height<=360]+bestaudio[ext=m4a]/18/best[height<=360]", "360p": "bestvideo[height<=360]+bestaudio/bestvideo+bestaudio/best",
"240p": "bestvideo[ext=mp4][height<=240]+bestaudio[ext=m4a]/best[height<=240]", "240p": "bestvideo[height<=240]+bestaudio/bestvideo+bestaudio/best",
"144p": "bestvideo[ext=mp4][height<=144]+bestaudio[ext=m4a]/best[height<=144]", "144p": "bestvideo[height<=144]+bestaudio/bestvideo+bestaudio/best",
} }
@@ -470,6 +757,8 @@ def detect_resolution(file_path: str) -> str | None:
capture_output=True, text=True, timeout=15, capture_output=True, text=True, timeout=15,
) )
height = int(result.stdout.strip()) height = int(result.stdout.strip())
if height >= 2160: return "2160p"
if height >= 1440: return "1440p"
if height >= 1080: return "1080p" if height >= 1080: return "1080p"
if height >= 720: return "720p" if height >= 720: return "720p"
if height >= 480: return "480p" if height >= 480: return "480p"
@@ -484,7 +773,7 @@ def predicted_file_path(video_id: str) -> Path:
return Path(settings.download_path) / f"{video_id}.mp4" return Path(settings.download_path) / f"{video_id}.mp4"
_SEMAPHORE = threading.Semaphore(3) _SEMAPHORE = threading.Semaphore(6)
_semaphore_lock = threading.Lock() _semaphore_lock = threading.Lock()
_cookies_browser: str = "" _cookies_browser: str = ""
_cookies_file: str = "" _cookies_file: str = ""
@@ -501,7 +790,7 @@ _oauth2_state_lock = threading.Lock()
def set_max_concurrent(n: int) -> None: def set_max_concurrent(n: int) -> None:
global _SEMAPHORE global _SEMAPHORE
with _semaphore_lock: with _semaphore_lock:
_SEMAPHORE = threading.Semaphore(max(1, min(n, 10))) _SEMAPHORE = threading.Semaphore(max(1, min(n, 16)))
def set_cookies_browser(browser: str) -> None: def set_cookies_browser(browser: str) -> None:
@@ -620,40 +909,52 @@ def start_download(
on_complete: Any, on_complete: Any,
on_error: Any, on_error: Any,
quality: str = "best", quality: str = "best",
subtitle_langs: str = "",
) -> None: ) -> None:
"""Start yt-dlp download in a background thread. """Start yt-dlp download in a background thread.
Uses a single progressive MP4 format so the file is playable as it downloads.
--no-part writes directly to the final filename (no .part rename at the end). --no-part writes directly to the final filename (no .part rename at the end).
""" """
url = f"https://www.youtube.com/watch?v={video_id}" url = f"https://www.youtube.com/watch?v={video_id}"
# Predictable output path — lets the player start before download finishes
output_template = str(Path(settings.download_path) / f"{video_id}.%(ext)s") output_template = str(Path(settings.download_path) / f"{video_id}.%(ext)s")
fmt = QUALITY_FORMATS.get(quality, QUALITY_FORMATS["best"]) fmt = QUALITY_FORMATS.get(quality, QUALITY_FORMATS["best"])
subtitle_args = (
["--write-subs", "--write-auto-subs", "--sub-langs", subtitle_langs, "--convert-subs", "vtt"]
if subtitle_langs else []
)
def _run_download(): def _run_download():
global _active_downloads
# Signal to _meta_run that a download is active so it pauses all
# background discovery/metadata calls for the duration. Running both
# concurrently causes YouTube to see the same session used simultaneously
# and invalidates cookies.
with _active_downloads_lock:
_active_downloads += 1
try:
with _SEMAPHORE: with _SEMAPHORE:
cookie_args = _cookie_args() cookie_args = _cookie_args()
print(f"[ytdlp] cookie_args={cookie_args!r}", flush=True) print(f"[ytdlp] cookie_args={cookie_args!r}", flush=True)
process = subprocess.Popen( cmd = [
[
"yt-dlp", url, "yt-dlp", url,
"-f", fmt, "-f", fmt,
"--merge-output-format", "mp4", "--merge-output-format", "mp4",
"--postprocessor-args", "Merger+ffmpeg:-movflags +faststart",
"--embed-metadata", "--embed-thumbnail",
"--no-part", "--no-mtime", "--no-part", "--no-mtime",
"-o", output_template, "-o", output_template,
"--newline", "--progress", "--no-colors", "--newline", "--progress", "--no-colors",
"--extractor-args", "youtube:player_client=web", *subtitle_args,
*_YT_CLIENT,
*cookie_args, *cookie_args,
], ]
cmd, tmp_cookie_path = _make_private_cookie_copy(cmd)
try:
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, text=True,
) )
file_path = None file_path = None
stream_index = 0 stream_index = 0
output_lines: list[str] = [] output_lines: list[str] = []
@@ -673,6 +974,7 @@ def start_download(
process.wait() process.wait()
if process.returncode == 0: if process.returncode == 0:
_strip_vtt_cue_settings(video_id)
resolution = detect_resolution(file_path) if file_path else None resolution = detect_resolution(file_path) if file_path else None
on_complete(download_id, file_path, resolution) on_complete(download_id, file_path, resolution)
else: else:
@@ -680,6 +982,15 @@ def start_download(
import logging import logging
logging.getLogger(__name__).error("yt-dlp failed (code %d):\n%s", process.returncode, tail) logging.getLogger(__name__).error("yt-dlp failed (code %d):\n%s", process.returncode, tail)
on_error(download_id, f"yt-dlp exited with code {process.returncode}:\n{tail}") on_error(download_id, f"yt-dlp exited with code {process.returncode}:\n{tail}")
finally:
if tmp_cookie_path:
try:
os.unlink(tmp_cookie_path)
except Exception:
pass
finally:
with _active_downloads_lock:
_active_downloads -= 1
thread = threading.Thread(target=_run_download, daemon=True) thread = threading.Thread(target=_run_download, daemon=True)
thread.start() thread.start()

View File

@@ -11,6 +11,7 @@ services:
DATABASE_URL: sqlite:////data/app.db DATABASE_URL: sqlite:////data/app.db
DOWNLOAD_PATH: /downloads DOWNLOAD_PATH: /downloads
SECRET_KEY: ${SECRET_KEY:-changeme} SECRET_KEY: ${SECRET_KEY:-changeme}
WIDGET_API_KEY: ${WIDGET_API_KEY:-}
frontend: frontend:
build: build:
context: ./frontend context: ./frontend

View File

@@ -41,10 +41,15 @@ export const getChannels = () => api.get("/channels");
export const getChannelFeed = (offset = 0) => api.get("/channels/feed", { params: { offset, limit: 24 } }); export const getChannelFeed = (offset = 0) => api.get("/channels/feed", { params: { offset, limit: 24 } });
export const getChannel = (id) => api.get(`/channels/${id}`); export const getChannel = (id) => api.get(`/channels/${id}`);
export const syncAllChannels = () => api.post("/channels/sync-all"); export const syncAllChannels = () => api.post("/channels/sync-all");
export const getChannelVideos = (id) => api.get(`/channels/${id}/videos`); export const getChannelVideos = (id, sort = "newest", offset = 0, limit = 60, q = "") =>
api.get(`/channels/${id}/videos`, { params: { sort, offset, limit, ...(q ? { q } : {}) } });
export const searchChannelYoutube = (id, q) => api.post(`/channels/${id}/search`, null, { params: { q } });
export const followChannel = (id) => api.post(`/channels/${id}/follow`); export const followChannel = (id) => api.post(`/channels/${id}/follow`);
export const unfollowChannel = (id) => api.delete(`/channels/${id}/follow`); export const unfollowChannel = (id) => api.delete(`/channels/${id}/follow`);
export const indexChannel = (id) => api.post(`/channels/${id}/index`); export const indexChannel = (id) => api.post(`/channels/${id}/index`);
export const indexChannelFull = (id) => api.post(`/channels/${id}/index-full`);
export const exploreChannelOlder = (id, page) => api.post(`/channels/${id}/explore`, null, { params: { page } });
export const fetchPopularVideos = (id) => api.post(`/channels/${id}/fetch-popular`);
export const followChannelByUrl = (data) => api.post("/channels/follow-by-url", data); export const followChannelByUrl = (data) => api.post("/channels/follow-by-url", data);
export const setChannelAutoDownload = (id, value) => api.patch(`/channels/${id}/auto-download`, { auto_download: value }); export const setChannelAutoDownload = (id, value) => api.patch(`/channels/${id}/auto-download`, { auto_download: value });
export const markChannelsSeen = () => api.post("/channels/mark-seen"); export const markChannelsSeen = () => api.post("/channels/mark-seen");
@@ -59,6 +64,11 @@ export const renameChannelGroup = (id, name) => api.patch(`/channels/groups/${id
export const addChannelToGroup = (groupId, channelId) => api.post(`/channels/groups/${groupId}/channels/${channelId}`); export const addChannelToGroup = (groupId, channelId) => api.post(`/channels/groups/${groupId}/channels/${channelId}`);
export const removeChannelFromGroup = (groupId, channelId) => api.delete(`/channels/groups/${groupId}/channels/${channelId}`); export const removeChannelFromGroup = (groupId, channelId) => api.delete(`/channels/groups/${groupId}/channels/${channelId}`);
export const bulkChannelAction = (channel_ids, action) => api.post("/channels/bulk-action", { channel_ids, action }); export const bulkChannelAction = (channel_ids, action) => api.post("/channels/bulk-action", { channel_ids, action });
export const getActiveTasks = () => api.get("/channels/tasks");
export const getRssFeedUrl = () => `/api/channels/rss`;
export const getRandomChannelVideo = (id, unwatchedOnly = true) =>
api.get(`/channels/${id}/random`, { params: { unwatched_only: unwatchedOnly } });
export const getChannelInProgress = (id) => api.get(`/channels/${id}/in-progress`);
// Videos // Videos
export const homeFeed = (page = 0, limit = 25, mode = "ranked", duration = "") => export const homeFeed = (page = 0, limit = 25, mode = "ranked", duration = "") =>
@@ -87,9 +97,12 @@ export const importChapters = (videoId) => api.post(`/videos/${videoId}/bookmark
export const clearChapters = (videoId) => api.delete(`/videos/${videoId}/bookmarks/clear-chapters`); export const clearChapters = (videoId) => api.delete(`/videos/${videoId}/bookmarks/clear-chapters`);
// Downloads // Downloads
export const createDownload = (youtube_video_id, quality) => export const createDownload = (youtube_video_id, quality, subtitle_langs) =>
api.post("/downloads", { youtube_video_id, ...(quality ? { quality } : {}) }); api.post("/downloads", { youtube_video_id, ...(quality ? { quality } : {}), ...(subtitle_langs ? { subtitle_langs } : {}) });
export const getDownloads = () => api.get("/downloads"); export const getDownloads = () => api.get("/downloads");
export const getAvailableSubs = (ytId) => api.get(`/videos/by-yt/${ytId}/subs`);
export const getSubtitleFiles = (ytId) => api.get(`/videos/by-yt/${ytId}/subtitle-files`);
export const downloadSubs = (ytId, subtitle_langs) => api.post(`/videos/by-yt/${ytId}/download-subs`, { subtitle_langs });
export const getDownload = (id) => api.get(`/downloads/${id}`); export const getDownload = (id) => api.get(`/downloads/${id}`);
export const deleteDownload = (id) => api.delete(`/downloads/${id}`); export const deleteDownload = (id) => api.delete(`/downloads/${id}`);
export const deleteAllDownloads = () => api.delete("/downloads/all"); export const deleteAllDownloads = () => api.delete("/downloads/all");
@@ -127,6 +140,7 @@ export const followDiscovery = (channelId) =>
export const dismissDiscovery = (channelId) => export const dismissDiscovery = (channelId) =>
api.post(`/discovery/${channelId}/dismiss`); api.post(`/discovery/${channelId}/dismiss`);
export const refreshDiscovery = () => api.post("/discovery/refresh"); export const refreshDiscovery = () => api.post("/discovery/refresh");
export const getDiscoveryStatus = () => api.get("/discovery/status");
export const getCommunityShelf = () => api.get("/discovery/community"); export const getCommunityShelf = () => api.get("/discovery/community");
// Stats // Stats
@@ -147,3 +161,11 @@ export const deleteCollection = (id) => api.delete(`/collections/${id}`);
export const getCollectionVideos = (id) => api.get(`/collections/${id}/videos`); export const getCollectionVideos = (id) => api.get(`/collections/${id}/videos`);
export const addToCollection = (collectionId, videoId) => api.post(`/collections/${collectionId}/videos`, { video_id: videoId }); export const addToCollection = (collectionId, videoId) => api.post(`/collections/${collectionId}/videos`, { video_id: videoId });
export const removeFromCollection = (collectionId, videoId) => api.delete(`/collections/${collectionId}/videos/${videoId}`); export const removeFromCollection = (collectionId, videoId) => api.delete(`/collections/${collectionId}/videos/${videoId}`);
// Playlists
export const getChannelPlaylists = (channelId) => api.get(`/playlists/channel/${channelId}`);
export const fetchChannelPlaylists = (channelId) => api.post(`/playlists/channel/${channelId}/fetch`);
export const getPlaylistVideos = (playlistId, offset = 0, limit = 60) =>
api.get(`/playlists/${playlistId}/videos`, { params: { offset, limit } });
export const indexPlaylist = (playlistId) => api.post(`/playlists/${playlistId}/index`);
export const generateNfoFiles = () => api.post("/downloads/nfo/generate");

View File

@@ -80,7 +80,7 @@ export default function ChannelCard({ channel }) {
className={`text-xs font-medium px-4 py-2 rounded-lg transition-colors ${ className={`text-xs font-medium px-4 py-2 rounded-lg transition-colors ${
isFollowed || followMut.isSuccess isFollowed || followMut.isSuccess
? "bg-zinc-700 text-zinc-300 hover:bg-zinc-600" ? "bg-zinc-700 text-zinc-300 hover:bg-zinc-600"
: "bg-accent text-black hover:bg-yellow-300" : "bg-accent text-black hover:bg-zinc-100"
}`} }`}
> >
{isFollowed || followMut.isSuccess ? "Following" : "Follow"} {isFollowed || followMut.isSuccess ? "Following" : "Follow"}

View File

@@ -1,8 +1,9 @@
import { Outlet, NavLink, useNavigate, Link, useLocation } from "react-router-dom"; import { Outlet, NavLink, Link, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useRef, useEffect } from "react";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import SearchBar from "./SearchBar"; import SearchBar from "./SearchBar";
import { getDownloads, getChannels } from "../api"; import { getDownloads, getChannels, getActiveTasks, getDiscoveryStatus, getMe } from "../api";
function BottomNav({ newCount }) { function BottomNav({ newCount }) {
const tabs = [ const tabs = [
@@ -40,8 +41,8 @@ function BottomNav({ newCount }) {
to={tab.to} to={tab.to}
end={tab.end} end={tab.end}
className={({ isActive }) => className={({ isActive }) =>
`relative flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors ${ `relative flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors outline-none ${
isActive ? "text-accent" : "text-zinc-500" isActive ? "text-zinc-100" : "text-zinc-500"
}` }`
} }
> >
@@ -49,13 +50,13 @@ function BottomNav({ newCount }) {
<> <>
<div className="relative"> <div className="relative">
{isActive && ( {isActive && (
<span className="absolute -inset-2 rounded-xl bg-accent/10" /> <span className="absolute -inset-2 rounded-xl bg-white/10" />
)} )}
<svg className="w-[18px] h-[18px] relative" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-[18px] h-[18px] relative" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{tab.icon} {tab.icon}
</svg> </svg>
{tab.badge > 0 && ( {tab.badge > 0 && (
<span className="absolute -top-1 -right-1.5 min-w-[13px] h-3 bg-accent text-black text-[8px] font-bold rounded-full flex items-center justify-center px-0.5 leading-none"> <span className="absolute -top-1 -right-1.5 min-w-[13px] h-3 bg-zinc-200 text-zinc-900 text-[8px] font-bold rounded-full flex items-center justify-center px-0.5 leading-none">
{tab.badge > 99 ? "99+" : tab.badge} {tab.badge > 99 ? "99+" : tab.badge}
</span> </span>
)} )}
@@ -73,40 +74,128 @@ function BottomNav({ newCount }) {
} }
function DownloadIndicator() { function DownloadIndicator() {
const { data } = useQuery({ const { data: downloads } = useQuery({
queryKey: ["downloads"], queryKey: ["downloads"],
queryFn: () => getDownloads().then((r) => r.data), queryFn: () => getDownloads().then((r) => r.data),
refetchInterval: (query) => { refetchInterval: (query) => {
const active = (query.state.data ?? []).some( const active = (query.state.data ?? []).some(
(d) => d.status === "pending" || d.status === "downloading" (d) => d.status === "pending" || d.status === "downloading"
); );
return active ? 1500 : 10_000; return active ? 1500 : 30_000;
}, },
}); });
const active = (data ?? []).filter( const { data: tasks = [] } = useQuery({
queryKey: ["active-tasks"],
queryFn: () => getActiveTasks().then((r) => r.data),
refetchInterval: (query) => (query.state.data?.length > 0 ? 2000 : 10_000),
});
const { data: discStatus } = useQuery({
queryKey: ["discovery-status"],
queryFn: () => getDiscoveryStatus().then((r) => r.data),
refetchInterval: (query) => (query.state.data?.progress?.running ? 10_000 : 60_000),
staleTime: 10_000,
});
const activeDownloads = (downloads ?? []).filter(
(d) => d.status === "pending" || d.status === "downloading" (d) => d.status === "pending" || d.status === "downloading"
); );
if (!active.length) return null; const discRunning = discStatus?.progress?.running;
const discProgress = discStatus?.progress;
const top = active[0]; if (!activeDownloads.length && !tasks.length && !discRunning) return null;
const pct = top.progress_percent ?? 0;
const totalActive = activeDownloads.length + tasks.length + (discRunning ? 1 : 0);
// Primary label: download % > task phase > discovery
let label;
if (activeDownloads.length) {
const pct = activeDownloads[0].progress_percent ?? 0;
label = <span className="font-mono tabular-nums text-[11px]">{pct.toFixed(0)}%</span>;
} else if (tasks.length) {
const task = tasks[0];
const pct = task.total > 0 ? Math.round((task.done / task.total) * 100) : null;
label = (
<span className="text-[11px] max-w-[80px] truncate hidden sm:inline">
{pct !== null ? `${pct}%` : task.phase || "…"}
</span>
);
} else {
label = (
<span className="text-[11px] hidden sm:inline">
{discProgress ? `${discProgress.done}/${discProgress.total}` : "…"}
</span>
);
}
const indicatorTarget = (activeDownloads.length || tasks.length) ? "/downloads" : "/discovery";
return ( return (
<div className="relative group shrink-0">
<Link <Link
to="/downloads" to={indicatorTarget}
className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors text-xs text-zinc-300 shrink-0" className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors text-xs text-zinc-300"
title={`${active.length} download${active.length > 1 ? "s" : ""} in progress`}
> >
<svg className="w-3 h-3 animate-spin text-accent shrink-0" fill="none" viewBox="0 0 24 24"> <svg className="w-3 h-3 animate-spin text-zinc-400 shrink-0" fill="none" viewBox="0 0 24 24">
<circle className="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" /> <circle className="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /> <path className="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg> </svg>
<span className="font-mono tabular-nums text-[11px]">{pct.toFixed(0)}%</span> {label}
{active.length > 1 && ( {totalActive > 1 && (
<span className="hidden sm:inline text-zinc-500">+{active.length - 1}</span> <span className="hidden sm:inline text-zinc-500">+{totalActive - 1}</span>
)} )}
</Link> </Link>
{/* Hover popover */}
<div className="absolute top-[calc(100%+6px)] right-0 z-50
invisible opacity-0 group-hover:visible group-hover:opacity-100
transition-all duration-100 pointer-events-none">
<div className="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl p-3 min-w-[220px] max-w-[280px] flex flex-col gap-2">
{activeDownloads.map((d) => (
<div key={d.id}>
<div className="flex items-center justify-between gap-2 mb-1">
<p className="text-xs text-zinc-200 truncate">{d.video_title || "Downloading…"}</p>
<span className="text-[10px] text-zinc-400 tabular-nums shrink-0">{(d.progress_percent ?? 0).toFixed(0)}%</span>
</div>
<div className="h-0.5 bg-zinc-800 rounded-full overflow-hidden">
<div className="h-full bg-accent rounded-full transition-all duration-300" style={{ width: `${d.progress_percent ?? 0}%` }} />
</div>
</div>
))}
{tasks.map((task) => {
const pct = task.total > 0 ? (task.done / task.total) * 100 : 0;
return (
<div key={task.id}>
<div className="flex items-center justify-between gap-2 mb-1">
<p className="text-xs text-zinc-200 truncate">{task.label}</p>
{task.total > 0 && (
<span className="text-[10px] text-zinc-400 tabular-nums shrink-0">{task.done}/{task.total}</span>
)}
</div>
<p className="text-[10px] text-zinc-500 mb-1">{task.phase || "Running…"}</p>
<div className="h-0.5 bg-zinc-800 rounded-full overflow-hidden">
<div className="h-full bg-accent rounded-full transition-all duration-300" style={{ width: `${pct}%` }} />
</div>
</div>
);
})}
{discRunning && discProgress && (
<div>
<div className="flex items-center justify-between gap-2 mb-1">
<p className="text-xs text-zinc-200">Discovering channels</p>
<span className="text-[10px] text-zinc-400 tabular-nums shrink-0">{discProgress.done}/{discProgress.total}</span>
</div>
<p className="text-[10px] text-zinc-500 mb-1">Finding channels spaced over ~20 min</p>
<div className="h-0.5 bg-zinc-800 rounded-full overflow-hidden">
<div className="h-full bg-accent rounded-full transition-all duration-300"
style={{ width: `${Math.round((discProgress.done / discProgress.total) * 100)}%` }} />
</div>
</div>
)}
</div>
</div>
</div>
); );
} }
@@ -125,7 +214,7 @@ function NavItem({ to, children, badge }) {
> >
{children} {children}
{badge > 0 && ( {badge > 0 && (
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 bg-accent text-black text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none"> <span className="absolute -top-1 -right-1 min-w-[16px] h-4 bg-zinc-200 text-zinc-900 text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none">
{badge > 99 ? "99+" : badge} {badge > 99 ? "99+" : badge}
</span> </span>
)} )}
@@ -147,7 +236,7 @@ function DropItem({ to, children, badge }) {
> >
<span>{children}</span> <span>{children}</span>
{badge > 0 && ( {badge > 0 && (
<span className="min-w-[18px] h-[18px] bg-accent text-black text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none shrink-0"> <span className="min-w-[18px] h-[18px] bg-zinc-200 text-zinc-900 text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none shrink-0">
{badge > 99 ? "99+" : badge} {badge > 99 ? "99+" : badge}
</span> </span>
)} )}
@@ -201,24 +290,54 @@ function useNewVideosCount() {
return channels.reduce((sum, c) => sum + (c.new_count ?? 0), 0); return channels.reduce((sum, c) => sum + (c.new_count ?? 0), 0);
} }
function useNewVideoNotifications(newCount) {
const location = useLocation();
const prevCount = useRef(newCount);
useEffect(() => {
const enabled = localStorage.getItem("notifications_enabled") === "true";
if (!enabled) return;
if (Notification.permission !== "granted") return;
if (location.pathname === "/following") return;
if (newCount > 0 && newCount > prevCount.current) {
new Notification("New videos", {
body: `${newCount} new video${newCount !== 1 ? "s" : ""} from channels you follow`,
});
}
prevCount.current = newCount;
}, [newCount, location.pathname]);
}
function useOffline() {
const { isError, error } = useQuery({
queryKey: ["health"],
queryFn: () => getMe().then((r) => r.data),
refetchInterval: 30_000,
retry: 1,
staleTime: 20_000,
});
return isError && !error?.response;
}
export default function Layout() { export default function Layout() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate();
const newCount = useNewVideosCount(); const newCount = useNewVideosCount();
const offline = useOffline();
useNewVideoNotifications(newCount);
return ( return (
<div className="flex flex-col overflow-hidden" style={{ height: "100dvh" }}> <div className="flex flex-col overflow-hidden" style={{ height: "100dvh" }}>
{offline && (
<div className="shrink-0 bg-amber-500/10 border-b border-amber-500/30 px-4 py-2 flex items-center gap-2">
<svg className="w-4 h-4 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<p className="text-sm text-amber-300">Server unreachable check that the backend is running.</p>
</div>
)}
{/* Header */} {/* Header */}
<header className="shrink-0 z-40 bg-zinc-950/90 backdrop-blur border-b border-zinc-800"> <header className="shrink-0 z-40 bg-zinc-950/90 backdrop-blur border-b border-zinc-800">
<div className="max-w-screen-xl mx-auto px-3 h-12 sm:h-14 flex items-center gap-2 sm:gap-4"> <div className="max-w-screen-xl mx-auto px-3 h-12 sm:h-14 flex items-center gap-2 sm:gap-4">
{/* Logo */}
<button
onClick={() => navigate("/")}
className="font-display font-bold text-base sm:text-lg text-accent shrink-0"
>
YT
</button>
{/* Search — min-w-0 prevents it from overflowing on narrow screens */} {/* Search — min-w-0 prevents it from overflowing on narrow screens */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<SearchBar /> <SearchBar />

View File

@@ -4,6 +4,16 @@ import { useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createDownload, toggleQueue, toggleLike, dismissDiscovery, getSettings } from "../api"; import { createDownload, toggleQueue, toggleLike, dismissDiscovery, getSettings } from "../api";
function snippetText(desc) {
if (!desc) return "";
const s = desc
.replace(/https?:\/\/\S+/g, "")
.replace(/\n+/g, " ")
.replace(/\s{2,}/g, " ")
.trim();
return s.length > 180 ? s.slice(0, 180).trimEnd() + "…" : s;
}
function formatDuration(secs) { function formatDuration(secs) {
if (!secs) return null; if (!secs) return null;
const h = Math.floor(secs / 3600); const h = Math.floor(secs / 3600);
@@ -33,8 +43,8 @@ function IconBtn({ onClick, title, active, pending, children }) {
onClick={(e) => { e.stopPropagation(); onClick(e); }} onClick={(e) => { e.stopPropagation(); onClick(e); }}
title={title} title={title}
className={clsx( className={clsx(
"flex items-center justify-center w-7 h-7 rounded-full transition-all duration-150", "flex items-center justify-center w-7 h-7 rounded-md transition-all duration-150",
active ? "text-accent" : "text-zinc-600 hover:text-zinc-200", active ? "text-zinc-100" : "text-zinc-600 hover:text-zinc-300",
pending && "opacity-60 cursor-default", pending && "opacity-60 cursor-default",
)} )}
> >
@@ -121,13 +131,13 @@ function ThumbnailBlock({ video, isWatched, duration, calmMode, onDismiss, class
)} )}
{video.download_resolution && ( {video.download_resolution && (
<span className="absolute bottom-1.5 left-1.5 text-[10px] font-medium px-1.5 py-0.5 rounded font-mono text-accent bg-black/75"> <span className="absolute bottom-1.5 left-1.5 text-[10px] font-medium px-1.5 py-0.5 rounded font-mono text-white/80 bg-black/75">
{video.download_resolution} {video.download_resolution}
</span> </span>
)} )}
{isWatched && ( {isWatched && (
<span className="absolute top-2 left-2 w-2 h-2 rounded-full bg-accent shadow-[0_0_6px_rgba(var(--color-accent-rgb),0.8)]" /> <span className="absolute top-2 left-2 w-2 h-2 rounded-full bg-white/60" />
)} )}
<div className="absolute inset-0 bg-black/25 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"> <div className="absolute inset-0 bg-black/25 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
@@ -139,9 +149,9 @@ function ThumbnailBlock({ video, isWatched, duration, calmMode, onDismiss, class
</div> </div>
{video.watch_progress_seconds > 0 && video.duration_seconds > 0 && ( {video.watch_progress_seconds > 0 && video.duration_seconds > 0 && (
<div className="absolute bottom-0 inset-x-0 h-[3px] bg-white/10"> <div className="absolute bottom-0 inset-x-0 h-[2px] bg-white/10">
<div <div
className="h-full bg-accent" className="h-full bg-white/70"
style={{ width: `${Math.min(video.watch_progress_seconds / video.duration_seconds, 1) * 100}%` }} style={{ width: `${Math.min(video.watch_progress_seconds / video.duration_seconds, 1) * 100}%` }}
/> />
</div> </div>
@@ -250,7 +260,7 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
return ( return (
<div <div
onClick={() => navigate(`/watch/${video.youtube_video_id}`)} onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
className="group flex gap-3 sm:gap-4 px-2 sm:px-3 py-2 sm:py-3 rounded-xl cursor-pointer hover:bg-zinc-800/50 transition-colors duration-150" className="group flex gap-3 sm:gap-4 px-2 sm:px-3 py-2 sm:py-3 rounded-lg cursor-pointer hover:bg-zinc-900/70 transition-colors duration-150"
> >
{/* Thumbnail — compact on mobile, wide on desktop */} {/* Thumbnail — compact on mobile, wide on desktop */}
<ThumbnailBlock <ThumbnailBlock
@@ -302,15 +312,15 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
)} )}
</div> </div>
{/* Description — desktop only, 2 lines max */} {/* Description snippet — desktop only, URLs stripped */}
{video.description && ( {video.description && snippetText(video.description) && (
<p className="hidden sm:block text-[11px] leading-relaxed text-zinc-500 line-clamp-2"> <p className="hidden sm:block text-[11px] leading-relaxed text-zinc-500 line-clamp-3">
{video.description.replace(/\n+/g, " ")} {snippetText(video.description)}
</p> </p>
)} )}
{/* Actions — always visible on mobile, fade on desktop */} {/* Actions — hover only everywhere */}
<div className="mt-auto pt-1 sm:opacity-0 sm:pointer-events-none sm:group-hover:opacity-100 sm:group-hover:pointer-events-auto sm:transition-opacity sm:duration-150"> <div className="mt-auto pt-1 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity duration-150">
{actions} {actions}
</div> </div>
</div> </div>
@@ -323,8 +333,7 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
<div <div
onClick={() => navigate(`/watch/${video.youtube_video_id}`)} onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
className={clsx( className={clsx(
"group relative flex flex-col cursor-pointer rounded-2xl", "group relative flex flex-col cursor-pointer",
"bg-zinc-900 hover:bg-zinc-800/80 transition-colors duration-150",
size === "sm" && "text-xs", size === "sm" && "text-xs",
)} )}
> >
@@ -334,10 +343,10 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
duration={duration} duration={duration}
calmMode={calmMode} calmMode={calmMode}
onDismiss={() => dismissMut.mutate()} onDismiss={() => dismissMut.mutate()}
className="aspect-video rounded-t-2xl overflow-hidden" className="aspect-video rounded-lg overflow-hidden"
/> />
<div className="flex gap-2 sm:gap-2.5 p-2 sm:p-3 flex-1"> <div className="flex gap-2 sm:gap-2.5 pt-2 sm:pt-2.5 pb-1 flex-1">
{/* Channel avatar */} {/* Channel avatar */}
<div <div
className="shrink-0 mt-0.5" className="shrink-0 mt-0.5"
@@ -347,10 +356,10 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
<img <img
src={avatarUrl} src={avatarUrl}
alt="" alt=""
className="w-7 h-7 sm:w-8 sm:h-8 rounded-full object-cover hover:ring-2 hover:ring-accent/50 transition-all" className="w-7 h-7 sm:w-8 sm:h-8 rounded-full object-cover hover:opacity-80 transition-opacity"
/> />
) : ( ) : (
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-full bg-zinc-700 flex items-center justify-center text-[11px] font-bold text-zinc-400 hover:ring-2 hover:ring-accent/50 transition-all"> <div className="w-7 h-7 sm:w-8 sm:h-8 rounded-full bg-zinc-800 flex items-center justify-center text-[11px] font-bold text-zinc-400 hover:opacity-80 transition-opacity">
{avatarLetter} {avatarLetter}
</div> </div>
)} )}

View File

@@ -93,9 +93,8 @@ export default function VideoPlayer() {
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [downloadId, setDownloadId] = useState(null); const [downloadId, setDownloadId] = useState(null);
const [switchedToLocal, setSwitchedToLocal] = useState(false);
const saveTimerRef = useRef(null); const saveTimerRef = useRef(null);
const initiatedRef = useRef(null); // track which video we triggered download for const initiatedRef = useRef(null);
// ── Video metadata ──────────────────────────────────────────────────────── // ── Video metadata ────────────────────────────────────────────────────────
const { data: video, refetch: refetchVideo } = useQuery({ const { data: video, refetch: refetchVideo } = useQuery({
@@ -119,14 +118,23 @@ export default function VideoPlayer() {
}, },
}); });
// When download finishes, re-fetch video to get local_file_url and auto-switch // When download finishes, refetch video local_file_url will appear once the
// file exists on disk, which is the single source of truth for switching players
useEffect(() => { useEffect(() => {
if (dlStatus?.status === "complete" && !switchedToLocal) { if (dlStatus?.status === "complete" && !video?.local_file_url) {
refetchVideo().then(({ data }) => { refetchVideo();
if (data?.local_file_url) setSwitchedToLocal(true);
});
} }
}, [dlStatus?.status, switchedToLocal, refetchVideo]); }, [dlStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps
// Record a "clicked" impression as soon as we have the video id — even if the
// user closes immediately before playback starts.
const clickedRef = useRef(false);
useEffect(() => {
if (video?.id && !clickedRef.current && !video.is_watched) {
clickedRef.current = true;
updateProgress(video.id, { watch_progress_seconds: video.watch_progress_seconds ?? 0 });
}
}, [video?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Trigger download on open ────────────────────────────────────────────── // ── Trigger download on open ──────────────────────────────────────────────
const downloadMut = useMutation({ const downloadMut = useMutation({
@@ -134,22 +142,16 @@ export default function VideoPlayer() {
onSuccess: (res) => { onSuccess: (res) => {
const dl = res.data; const dl = res.data;
setDownloadId(dl.id); setDownloadId(dl.id);
// If it came back complete already (was pre-downloaded), just switch now // If already complete (pre-downloaded), refetch to get local_file_url
if (dl.status === "complete") { if (dl.status === "complete") refetchVideo();
refetchVideo().then(({ data }) => {
if (data?.local_file_url) setSwitchedToLocal(true);
});
}
}, },
}); });
useEffect(() => { useEffect(() => {
if (!youtubeId || initiatedRef.current === youtubeId) return; if (!youtubeId || initiatedRef.current === youtubeId) return;
initiatedRef.current = youtubeId; initiatedRef.current = youtubeId;
setSwitchedToLocal(false);
setCurrentTime(0); setCurrentTime(0);
setDownloadId(null); setDownloadId(null);
// Small delay so the modal renders before the fetch starts
const t = setTimeout(() => downloadMut.mutate(youtubeId), 200); const t = setTimeout(() => downloadMut.mutate(youtubeId), 200);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [youtubeId]); // eslint-disable-line react-hooks/exhaustive-deps }, [youtubeId]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -166,7 +168,7 @@ export default function VideoPlayer() {
saveTimerRef.current = setTimeout(() => { saveTimerRef.current = setTimeout(() => {
if (video?.id) { if (video?.id) {
const duration = video.duration_seconds ?? 0; const duration = video.duration_seconds ?? 0;
const watched = duration > 0 && secs >= duration * 0.9; const watched = duration > 0 && secs >= duration * 0.75;
updateProgress(video.id, { watch_progress_seconds: secs, watched }); updateProgress(video.id, { watch_progress_seconds: secs, watched });
} }
}, 10_000); }, 10_000);
@@ -174,7 +176,6 @@ export default function VideoPlayer() {
const close = useCallback(() => { const close = useCallback(() => {
setParams((p) => { p.delete("play"); p.delete("pt"); p.delete("pc"); return p; }); setParams((p) => { p.delete("play"); p.delete("pt"); p.delete("pc"); return p; });
setSwitchedToLocal(false);
clearTimeout(saveTimerRef.current); clearTimeout(saveTimerRef.current);
}, [setParams]); }, [setParams]);
@@ -192,7 +193,9 @@ export default function VideoPlayer() {
const channelName = video?.channel_name ?? urlChannel; const channelName = video?.channel_name ?? urlChannel;
const startAt = video?.watch_progress_seconds ?? 0; const startAt = video?.watch_progress_seconds ?? 0;
const isDownloading = dlStatus && (dlStatus.status === "pending" || dlStatus.status === "downloading"); const isDownloading = dlStatus && (dlStatus.status === "pending" || dlStatus.status === "downloading");
const localUrl = switchedToLocal ? video?.local_file_url : null; // local_file_url is only set by the backend when the file actually exists on disk
const localUrl = video?.local_file_url ?? null;
const videoLoading = !video;
return ( return (
<div <div
@@ -216,8 +219,15 @@ export default function VideoPlayer() {
</button> </button>
</div> </div>
{/* Player — local file once ready, YouTube embed while downloading */} {/* Player — wait for metadata, then show local file or YouTube embed */}
{localUrl ? ( {videoLoading ? (
<div className="w-full aspect-video rounded-lg bg-zinc-900 flex items-center justify-center">
<svg className="w-8 h-8 animate-spin text-zinc-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
</div>
) : localUrl ? (
<LocalVideo src={localUrl} startAt={currentTime || startAt} onTimeUpdate={handleTimeUpdate} /> <LocalVideo src={localUrl} startAt={currentTime || startAt} onTimeUpdate={handleTimeUpdate} />
) : ( ) : (
<YoutubeEmbed youtubeId={youtubeId} startAt={startAt} onTimeUpdate={handleTimeUpdate} /> <YoutubeEmbed youtubeId={youtubeId} startAt={startAt} onTimeUpdate={handleTimeUpdate} />

View File

@@ -17,6 +17,14 @@
} }
} }
video::cue {
text-align: center;
background: rgba(0, 0, 0, 0.75);
color: #ffffff;
font-size: 1.4rem;
line-height: 1.5;
}
@layer utilities { @layer utilities {
.line-clamp-2 { .line-clamp-2 {
display: -webkit-box; display: -webkit-box;

View File

@@ -1,9 +1,28 @@
import { useState, useMemo } from "react"; import { useState, useEffect, useRef } from "react";
import { useParams } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from "@tanstack/react-query";
import { getChannel, getChannelVideos, followChannel, unfollowChannel, indexChannel, downloadChannel } from "../api"; import {
getChannel, getChannelVideos, searchChannelYoutube,
followChannel, unfollowChannel, indexChannel, indexChannelFull, exploreChannelOlder, fetchPopularVideos, downloadChannel,
getChannelPlaylists, fetchChannelPlaylists, getPlaylistVideos, indexPlaylist,
getRandomChannelVideo, getChannelInProgress, createDownload,
} from "../api";
import VideoCard from "../components/VideoCard"; import VideoCard from "../components/VideoCard";
import SortPicker from "../components/SortPicker";
const LIMIT = 60;
const TABS = [
{ value: "videos", label: "Videos" },
{ value: "popular", label: "Popular" },
{ value: "playlists", label: "Playlists" },
];
const SORTS = [
{ value: "newest", label: "Newest" },
{ value: "oldest", label: "Oldest" },
{ value: "title", label: "AZ" },
{ value: "unwatched", label: "Unwatched" },
];
function formatSubs(n) { function formatSubs(n) {
if (!n) return null; if (!n) return null;
@@ -12,35 +31,59 @@ function formatSubs(n) {
return String(n); return String(n);
} }
const VIDEO_SORTS = [
{ value: "newest", label: "Newest" },
{ value: "oldest", label: "Oldest" },
{ value: "title", label: "Title AZ" },
{ value: "unwatched", label: "Unwatched first" },
];
function sortVideos(items, sort) {
const arr = [...items];
if (sort === "oldest") return arr.sort((a, b) => new Date(a.published_at ?? 0) - new Date(b.published_at ?? 0));
if (sort === "title") return arr.sort((a, b) => (a.title ?? "").localeCompare(b.title ?? ""));
if (sort === "unwatched") return arr.sort((a, b) => Number(a.is_watched) - Number(b.is_watched));
return arr.sort((a, b) => new Date(b.published_at ?? 0) - new Date(a.published_at ?? 0));
}
export default function ChannelPage() { export default function ChannelPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const [tab, setTab] = useState("videos");
const [sort, setSort] = useState("newest");
const [search, setSearch] = useState("");
const [activeQ, setActiveQ] = useState("");
const [indexing, setIndexing] = useState(false);
const [explorePage, setExplorePage] = useState(2);
const [selectMode, setSelectMode] = useState(false);
const [selectedVideos, setSelectedVideos] = useState(new Set());
const [bulkDlResult, setBulkDlResult] = useState(null);
const searchInputRef = useRef(null);
const [openPlaylistId, setOpenPlaylistId] = useState(null);
const [playlistOffset, setPlaylistOffset] = useState(0);
const { data: channel, isLoading: loadingChannel } = useQuery({ const { data: channel, isLoading: loadingChannel } = useQuery({
queryKey: ["channel", id], queryKey: ["channel", id],
queryFn: () => getChannel(id).then((r) => r.data), queryFn: () => getChannel(id).then((r) => r.data),
}); });
const { data: videos, isLoading: loadingVideos } = useQuery({ const effectiveSort = tab === "popular" ? "popular" : sort;
queryKey: ["channel-videos", id],
queryFn: () => getChannelVideos(id).then((r) => r.data), const {
data: videosData,
isLoading: loadingVideos,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["channel-videos", id, effectiveSort, activeQ],
queryFn: ({ pageParam = 0 }) =>
getChannelVideos(id, effectiveSort, pageParam, LIMIT, activeQ).then((r) => r.data),
getNextPageParam: (lastPage, pages) =>
lastPage.length === LIMIT ? pages.length * LIMIT : undefined,
enabled: !!id,
}); });
const videos = videosData?.pages.flat() ?? [];
// Refetch after background re-index
const refetchedRef = useRef(false);
useEffect(() => {
if (!id || refetchedRef.current) return;
refetchedRef.current = true;
const t = setTimeout(() => {
qc.invalidateQueries({ queryKey: ["channel", id] });
qc.invalidateQueries({ queryKey: ["channel-videos", id] });
}, 8000);
return () => clearTimeout(t);
}, [id, qc]);
const followMut = useMutation({ const followMut = useMutation({
mutationFn: () => mutationFn: () =>
channel?.status === "followed" ? unfollowChannel(id) : followChannel(id), channel?.status === "followed" ? unfollowChannel(id) : followChannel(id),
@@ -50,13 +93,77 @@ export default function ChannelPage() {
}, },
}); });
const scheduleRefetch = (delayMs) => {
setIndexing(true);
setTimeout(() => {
qc.invalidateQueries({ queryKey: ["channel-videos", id] });
qc.invalidateQueries({ queryKey: ["channel", id] });
setIndexing(false);
}, delayMs);
};
const indexMut = useMutation({ const indexMut = useMutation({
mutationFn: () => indexChannel(id), mutationFn: () => indexChannel(id),
onSuccess: () => setTimeout(() => qc.invalidateQueries({ queryKey: ["channel-videos", id] }), 4000), onSuccess: () => scheduleRefetch(6000),
});
const fullIndexMut = useMutation({
mutationFn: () => indexChannelFull(id),
onSuccess: () => scheduleRefetch(45000),
});
const exploreMut = useMutation({
mutationFn: () => exploreChannelOlder(id, explorePage),
onSuccess: () => {
setExplorePage(p => p + 1);
scheduleRefetch(20000);
},
});
const popularMut = useMutation({
mutationFn: () => fetchPopularVideos(id),
onSuccess: () => scheduleRefetch(60000),
});
const deepSearchMut = useMutation({
mutationFn: () => searchChannelYoutube(id, activeQ || search),
onSuccess: () => scheduleRefetch(20000),
});
const { data: playlists = [], refetch: refetchPlaylists } = useQuery({
queryKey: ["channel-playlists", id],
queryFn: () => getChannelPlaylists(id).then((r) => r.data),
enabled: !!id && tab === "playlists",
});
const fetchPlaylistsMut = useMutation({
mutationFn: () => fetchChannelPlaylists(id),
onSuccess: () => setTimeout(() => refetchPlaylists(), 8000),
});
const { data: playlistVideos, isLoading: loadingPlaylistVideos, refetch: refetchPlaylistVideos } = useQuery({
queryKey: ["playlist-videos", openPlaylistId, playlistOffset],
queryFn: () => getPlaylistVideos(openPlaylistId, playlistOffset, 60).then((r) => r.data),
enabled: !!openPlaylistId,
});
const indexPlaylistMut = useMutation({
mutationFn: (plId) => indexPlaylist(plId),
onSuccess: () => setTimeout(() => refetchPlaylistVideos(), 15000),
});
const { data: inProgress = [] } = useQuery({
queryKey: ["channel-in-progress", id],
queryFn: () => getChannelInProgress(id).then((r) => r.data),
enabled: !!id,
});
const randomMut = useMutation({
mutationFn: () => getRandomChannelVideo(id).then((r) => r.data),
onSuccess: (video) => navigate(`/watch/${video.youtube_video_id}`),
}); });
const [dlResult, setDlResult] = useState(null); const [dlResult, setDlResult] = useState(null);
const [videoSort, setVideoSort] = useState("newest");
const dlMut = useMutation({ const dlMut = useMutation({
mutationFn: () => downloadChannel(id), mutationFn: () => downloadChannel(id),
onSuccess: (res) => { onSuccess: (res) => {
@@ -65,6 +172,40 @@ export default function ChannelPage() {
}, },
}); });
const toggleSelectVideo = (ytId) => {
setSelectedVideos(prev => {
const next = new Set(prev);
if (next.has(ytId)) next.delete(ytId); else next.add(ytId);
return next;
});
};
const bulkDownloadMut = useMutation({
mutationFn: async () => {
const ids = [...selectedVideos];
await Promise.all(ids.map(ytId => createDownload(ytId)));
return ids.length;
},
onSuccess: (count) => {
setBulkDlResult(count);
setSelectedVideos(new Set());
setSelectMode(false);
qc.invalidateQueries({ queryKey: ["downloads"] });
setTimeout(() => setBulkDlResult(null), 4000);
},
});
const handleSearch = (e) => {
e.preventDefault();
setActiveQ(search.trim());
};
const clearSearch = () => {
setSearch("");
setActiveQ("");
searchInputRef.current?.focus();
};
if (loadingChannel) { if (loadingChannel) {
return ( return (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
@@ -76,102 +217,409 @@ export default function ChannelPage() {
if (!channel) return <p className="text-zinc-500">Channel not found.</p>; if (!channel) return <p className="text-zinc-500">Channel not found.</p>;
const isFollowed = channel.status === "followed"; const isFollowed = channel.status === "followed";
const isPending = indexMut.isPending || fullIndexMut.isPending || deepSearchMut.isPending || exploreMut.isPending || popularMut.isPending || indexing;
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-5">
{/* Channel header — banner with overlay, or plain if no banner */} {/* Banner */}
<div className={`-mx-4 -mt-6 relative ${channel.banner_url ? "" : "bg-zinc-900"} rounded-b-2xl overflow-hidden`}> <div className={`-mx-4 -mt-6 relative ${channel.banner_url ? "" : "bg-zinc-900"} rounded-b-2xl overflow-hidden`}>
{channel.banner_url && ( {channel.banner_url && (
<img src={channel.banner_url} alt="" className="w-full h-36 sm:h-52 object-cover" /> <img src={channel.banner_url} alt="" className="w-full h-28 sm:h-48 object-cover" />
)} )}
{/* Gradient overlay */} <div className={channel.banner_url ? "absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" : ""} />
<div className={`${channel.banner_url ? "absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" : ""}`} />
{/* Info row sits at the bottom of the banner */} <div className={`${channel.banner_url ? "absolute bottom-0 inset-x-0" : ""} px-4 pb-4 pt-3 flex items-end gap-3`}>
<div className={`${channel.banner_url ? "absolute bottom-0 inset-x-0" : ""} px-4 pb-4 pt-3 flex items-end gap-4`}>
{/* Avatar */}
{channel.thumbnail_url ? ( {channel.thumbnail_url ? (
<img <img src={channel.thumbnail_url} alt={channel.name}
src={channel.thumbnail_url} className="w-14 h-14 sm:w-20 sm:h-20 rounded-full object-cover shrink-0 ring-2 ring-black/40" />
alt={channel.name}
className="w-16 h-16 sm:w-20 sm:h-20 rounded-full object-cover shrink-0 ring-2 ring-black/40"
/>
) : ( ) : (
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-full bg-zinc-800 flex items-center justify-center text-2xl sm:text-3xl font-display font-bold text-zinc-400 shrink-0"> <div className="w-14 h-14 sm:w-20 sm:h-20 rounded-full bg-zinc-800 flex items-center justify-center text-2xl font-display font-bold text-zinc-400 shrink-0">
{channel.name?.[0]?.toUpperCase()} {channel.name?.[0]?.toUpperCase()}
</div> </div>
)} )}
{/* Name + meta */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h1 className="font-display font-bold text-xl sm:text-2xl text-white drop-shadow">{channel.name}</h1> <h1 className="font-display font-bold text-lg sm:text-2xl text-white drop-shadow leading-tight">{channel.name}</h1>
<p className="text-xs sm:text-sm text-zinc-300 mt-0.5 drop-shadow"> <p className="text-xs text-zinc-400 mt-0.5 drop-shadow">
{[ {[
formatSubs(channel.subscriber_count) && `${formatSubs(channel.subscriber_count)} subscribers`, formatSubs(channel.subscriber_count) && `${formatSubs(channel.subscriber_count)} subs`,
`${channel.video_count} videos indexed`, channel.video_count && `${channel.video_count} indexed`,
].filter(Boolean).join(" · ")} ].filter(Boolean).join(" · ")}
</p> </p>
</div> </div>
{/* Action buttons */} <div className="hidden sm:flex items-center gap-2 shrink-0">
<div className="flex items-center gap-2 shrink-0 flex-wrap justify-end"> <button onClick={() => followMut.mutate()} disabled={followMut.isPending}
{dlResult != null && ( className={`text-sm font-medium px-4 py-1.5 rounded-lg transition-colors ${isFollowed ? "bg-zinc-700/80 text-zinc-300 hover:bg-zinc-600" : "bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700"}`}>
<span className="text-sm text-accent font-mono">
{dlResult === 0 ? "Already up to date" : `${dlResult} queued`}
</span>
)}
<button
onClick={() => dlMut.mutate()}
disabled={dlMut.isPending}
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-black hover:bg-yellow-300 transition-colors disabled:opacity-60 flex items-center gap-2"
>
{dlMut.isPending ? "Queuing…" : "Download all"}
</button>
<button
onClick={() => indexMut.mutate()}
disabled={indexMut.isPending || indexMut.isSuccess}
className="text-sm font-medium px-4 py-2 rounded-lg bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700 transition-colors disabled:opacity-60"
>
{indexMut.isPending ? "Indexing…" : indexMut.isSuccess ? "Done ✓" : "Re-index"}
</button>
<button
onClick={() => followMut.mutate()}
disabled={followMut.isPending}
className={`text-sm font-medium px-4 py-2 rounded-lg transition-colors ${
isFollowed
? "bg-zinc-700/80 text-zinc-300 hover:bg-zinc-600"
: "bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700"
}`}
>
{isFollowed ? "Following" : "Follow"} {isFollowed ? "Following" : "Follow"}
</button> </button>
<button onClick={() => randomMut.mutate()} disabled={randomMut.isPending}
title="Play a random unwatched video"
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700 transition-colors disabled:opacity-60">
{randomMut.isPending ? "…" : "Surprise me"}
</button>
<button onClick={() => dlMut.mutate()} disabled={dlMut.isPending}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-accent text-black hover:bg-zinc-100 transition-colors disabled:opacity-60">
{dlMut.isPending ? "Queuing…" : "Download all"}
</button>
</div> </div>
</div> </div>
</div> </div>
{/* Description below banner */} {/* Mobile actions */}
{channel.description && ( <div className="sm:hidden flex items-center gap-2 -mt-1">
<p className="text-sm text-zinc-400 line-clamp-3 -mt-4">{channel.description}</p> <button onClick={() => followMut.mutate()} disabled={followMut.isPending}
className="flex-1 text-sm font-medium py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors">
{isFollowed ? "Following ✓" : "Follow"}
</button>
<button onClick={() => randomMut.mutate()} disabled={randomMut.isPending}
className="flex-1 text-sm font-medium py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors disabled:opacity-60">
{randomMut.isPending ? "…" : "Surprise me"}
</button>
<button onClick={() => dlMut.mutate()} disabled={dlMut.isPending}
className="flex-1 text-sm font-medium py-2 rounded-lg bg-accent text-black hover:bg-zinc-100 transition-colors disabled:opacity-60">
{dlMut.isPending ? "Queuing…" : "Download all"}
</button>
</div>
{dlResult != null && (
<p className="text-xs text-zinc-400 font-mono -mt-2">
{dlResult === 0 ? "Already up to date" : `${dlResult} downloads queued`}
</p>
)} )}
{/* Video grid */} {channel.description && (
{loadingVideos ? ( <p className="text-xs text-zinc-500 line-clamp-2 -mt-1">{channel.description}</p>
)}
{/* Search bar */}
<form onSubmit={handleSearch} className="flex items-center gap-2">
<div className="relative flex-1">
<input
ref={searchInputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search videos…"
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-1.5 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-600 transition-colors"
/>
{search && (
<button type="button" onClick={clearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
<button type="submit"
className="text-sm px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors shrink-0">
Filter
</button>
{activeQ && (
<button
type="button"
onClick={() => deepSearchMut.mutate()}
disabled={deepSearchMut.isPending || indexing}
className="text-sm px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 transition-colors shrink-0 disabled:opacity-40"
>
Search YouTube
</button>
)}
</form>
{/* Tabs + controls */}
<div className="flex items-center justify-between gap-3 -mt-1 border-b border-zinc-800/60 pb-3">
<div className="flex items-center gap-0.5">
{TABS.map(t => (
<button key={t.value} onClick={() => setTab(t.value)}
className={`text-sm px-3 py-1 rounded-md transition-colors font-medium ${
tab === t.value ? "text-zinc-100" : "text-zinc-500 hover:text-zinc-300"
}`}>
{t.label}
</button>
))}
</div>
<div className="flex items-center gap-3">
{bulkDlResult != null && (
<span className="text-xs text-accent font-mono">{bulkDlResult} queued</span>
)}
{tab === "videos" && (
<button
onClick={() => { setSelectMode(v => !v); setSelectedVideos(new Set()); }}
className={`text-xs px-2.5 py-1 rounded-lg font-medium transition-colors ${selectMode ? "bg-accent text-black" : "text-zinc-500 hover:text-zinc-300"}`}
>
{selectMode ? "Cancel" : "Select"}
</button>
)}
{isPending && (
<span className="text-xs text-zinc-500 flex items-center gap-1.5">
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
Fetching
</span>
)}
{tab === "popular" ? (
<button onClick={() => popularMut.mutate()} disabled={isPending}
className="text-xs text-zinc-400 hover:text-zinc-100 font-medium transition-colors disabled:opacity-40">
Fetch popular
</button>
) : (
<>
<button onClick={() => indexMut.mutate()} disabled={isPending}
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-40">
Fetch recent
</button>
<button onClick={() => fullIndexMut.mutate()} disabled={isPending}
className="text-xs text-zinc-400 hover:text-zinc-100 font-medium transition-colors disabled:opacity-40">
Fetch all
</button>
</>
)}
</div>
</div>
{/* Sort bar — videos tab only */}
{tab === "videos" && (
<div className="flex items-center gap-0.5 -mt-1">
{SORTS.map(s => (
<button key={s.value} onClick={() => setSort(s.value)}
className={`text-xs px-2.5 py-1 rounded-md transition-colors ${
sort === s.value ? "bg-zinc-700 text-zinc-100" : "text-zinc-500 hover:text-zinc-300"
}`}>
{s.label}
</button>
))}
</div>
)}
{/* Playlist browser */}
{tab === "playlists" && (
openPlaylistId ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<button onClick={() => { setOpenPlaylistId(null); setPlaylistOffset(0); }}
className="text-xs text-zinc-400 hover:text-zinc-100 transition-colors">
Back to playlists
</button>
{(() => {
const pl = playlists.find(p => p.id === openPlaylistId);
return pl ? <span className="text-sm font-medium text-zinc-200 truncate">{pl.title}</span> : null;
})()}
<button onClick={() => indexPlaylistMut.mutate(openPlaylistId)}
disabled={indexPlaylistMut.isPending}
className="ml-auto text-xs text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40">
{indexPlaylistMut.isPending ? "Indexing…" : "Re-index"}
</button>
</div>
{loadingPlaylistVideos ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" /> <div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div> </div>
) : videos?.length ? ( ) : playlistVideos?.length ? (
<> <>
<div className="flex justify-end"> <div className="flex flex-col gap-1">
<SortPicker value={videoSort} onChange={setVideoSort} options={VIDEO_SORTS} /> {playlistVideos.map((v) => (
</div> <VideoCard key={v.youtube_video_id} video={v} variant="list" />
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{sortVideos(videos, videoSort).map((v) => (
<VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} />
))} ))}
</div> </div>
<div className="flex items-center justify-center gap-4 mt-2">
{playlistOffset > 0 && (
<button onClick={() => setPlaylistOffset(o => Math.max(0, o - 60))}
className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
Previous
</button>
)}
{playlistVideos.length === 60 && (
<button onClick={() => setPlaylistOffset(o => o + 60)}
className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
Next
</button>
)}
</div>
</> </>
) : ( ) : (
<p className="text-zinc-500 text-sm">No videos indexed yet. Hit Re-index to fetch them.</p> <div className="py-8 flex flex-col items-center gap-3">
<p className="text-zinc-500 text-sm">No videos indexed for this playlist yet.</p>
<button onClick={() => indexPlaylistMut.mutate(openPlaylistId)}
disabled={indexPlaylistMut.isPending}
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
{indexPlaylistMut.isPending ? "Indexing…" : "Index playlist"}
</button>
</div>
)}
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-500">{playlists.length} playlists</span>
<button onClick={() => fetchPlaylistsMut.mutate()}
disabled={fetchPlaylistsMut.isPending}
className="text-xs text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40">
{fetchPlaylistsMut.isPending ? "Fetching…" : "Fetch playlists"}
</button>
</div>
{playlists.length === 0 ? (
<div className="py-8 flex flex-col items-center gap-3">
<p className="text-zinc-500 text-sm">No playlists fetched yet.</p>
<button onClick={() => fetchPlaylistsMut.mutate()}
disabled={fetchPlaylistsMut.isPending}
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
Fetch playlists from YouTube
</button>
</div>
) : (
<div className="flex flex-col gap-1">
{playlists.map(pl => (
<div key={pl.id}
onClick={() => { setOpenPlaylistId(pl.id); setPlaylistOffset(0); }}
className="group flex gap-3 sm:gap-4 px-2 sm:px-3 py-2 sm:py-3 rounded-lg cursor-pointer hover:bg-zinc-900/70 transition-colors">
{/* Thumbnail */}
<div className="relative w-32 sm:w-60 md:w-72 aspect-video rounded-lg overflow-hidden bg-zinc-800 shrink-0">
{pl.thumbnail_url ? (
<img src={pl.thumbnail_url} alt="" className="w-full h-full object-cover group-hover:scale-[1.03] transition-transform duration-300" />
) : (
<div className="w-full h-full flex items-center justify-center text-zinc-600">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h16M4 10h16M4 14h8M4 18h8" />
</svg>
</div>
)}
<span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-[10px] font-medium px-1.5 py-0.5 rounded font-mono">
{pl.video_count} videos
</span>
</div>
{/* Info */}
<div className="flex flex-col gap-1 min-w-0 flex-1 py-px sm:py-0.5">
<h3 className="font-semibold text-[12px] sm:text-[14px] leading-snug text-zinc-50 line-clamp-2">{pl.title}</h3>
<span className="text-[10px] sm:text-[11px] text-zinc-500">
{pl.indexed_at ? "Indexed" : "Not indexed"}
</span>
{pl.description && (
<p className="hidden sm:block text-[11px] leading-relaxed text-zinc-500 line-clamp-1">{pl.description}</p>
)}
<button
onClick={(e) => { e.stopPropagation(); indexPlaylistMut.mutate(pl.id); }}
disabled={indexPlaylistMut.isPending}
className="mt-auto self-start text-[10px] text-zinc-600 hover:text-zinc-300 transition-colors disabled:opacity-40 opacity-0 group-hover:opacity-100">
{indexPlaylistMut.isPending ? "Indexing…" : pl.indexed_at ? "Re-index" : "Index"}
</button>
</div>
</div>
))}
</div>
)}
</div>
)
)}
{/* Continue watching */}
{tab === "videos" && inProgress.length > 0 && (
<div className="flex flex-col gap-2">
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Continue watching</h2>
<div className="flex flex-col gap-1">
{inProgress.map((v) => (
<VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} variant="list" />
))}
</div>
<div className="border-t border-zinc-800/60 mt-1" />
</div>
)}
{/* Video list */}
{tab !== "playlists" && (loadingVideos ? (
<div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
) : videos.length ? (
<div className="flex flex-col gap-1">
{videos.map((v) => (
<div key={v.youtube_video_id} className={`flex items-center gap-2 ${selectMode ? "cursor-pointer" : ""}`}
onClick={selectMode ? () => toggleSelectVideo(v.youtube_video_id) : undefined}>
{selectMode && (
<input
type="checkbox"
readOnly
checked={selectedVideos.has(v.youtube_video_id)}
className="shrink-0 ml-1 accent-accent w-3.5 h-3.5 pointer-events-none"
/>
)}
<div className="flex-1 min-w-0">
<VideoCard video={{ ...v, channel_name: channel.name }} variant="list" />
</div>
</div>
))}
{hasNextPage ? (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}
className="mt-4 self-center text-sm text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-40 py-2 px-4">
{isFetchingNextPage ? "Loading…" : "Load more"}
</button>
) : !activeQ && tab === "videos" && (
<div className="mt-4 flex flex-col items-center gap-3 py-4 border-t border-zinc-800/50">
<p className="text-xs text-zinc-600">{videos.length} videos indexed</p>
<div className="flex items-center gap-4">
<button onClick={() => exploreMut.mutate()} disabled={isPending}
className="text-xs text-zinc-400 hover:text-zinc-100 font-medium transition-colors disabled:opacity-40">
Explore older videos
</button>
<span className="text-zinc-800 text-xs">·</span>
<button onClick={() => fullIndexMut.mutate()} disabled={isPending}
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-40">
Fetch entire history
</button>
</div>
</div>
)}
</div>
) : (
<div className="py-8 flex flex-col items-center gap-3">
<p className="text-zinc-500 text-sm">
{activeQ
? `No indexed videos match "${activeQ}"`
: tab === "popular"
? "No view counts yet — click \"Fetch popular\" to rank indexed videos by views."
: "No videos indexed yet."}
</p>
{activeQ ? (
<button onClick={() => deepSearchMut.mutate()} disabled={isPending}
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
Search YouTube for "{activeQ}"
</button>
) : tab === "popular" ? (
<button onClick={() => popularMut.mutate()} disabled={isPending}
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
Fetch popular videos from YouTube
</button>
) : (
<button onClick={() => fullIndexMut.mutate()} disabled={isPending}
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
Fetch all videos
</button>
)}
</div>
))}
{/* Sticky bulk download bar */}
{selectMode && selectedVideos.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 px-5 py-3 bg-zinc-800 border border-zinc-700 rounded-2xl shadow-2xl">
<span className="text-sm text-zinc-300 font-medium">{selectedVideos.size} selected</span>
<button
onClick={() => bulkDownloadMut.mutate()}
disabled={bulkDownloadMut.isPending}
className="px-4 py-1.5 rounded-lg bg-accent text-black text-sm font-semibold hover:bg-zinc-100 transition-colors disabled:opacity-50"
>
{bulkDownloadMut.isPending ? "Queuing…" : "Download"}
</button>
<button
onClick={() => setSelectedVideos(new Set())}
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
>
Clear
</button>
</div>
)} )}
</div> </div>
); );

View File

@@ -25,7 +25,7 @@ export default function ContinueWatchingPage() {
</svg> </svg>
</div> </div>
<div> <div>
<p className="text-zinc-300 font-medium">Nothing in progress</p> <p className="text-zinc-500 text-sm">Nothing in progress</p>
<p className="text-zinc-500 text-sm mt-1"> <p className="text-zinc-500 text-sm mt-1">
Videos you've started but not finished will appear here. Videos you've started but not finished will appear here.
</p> </p>
@@ -34,14 +34,12 @@ export default function ContinueWatchingPage() {
) : ( ) : (
<> <>
<p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} in progress</p> <p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} in progress</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="flex flex-col gap-2">
{videos.map((v) => ( {videos.map((v) => (
<VideoCard <VideoCard
key={v.youtube_video_id} key={v.youtube_video_id}
video={{ video={{ ...v, is_watched: false }}
...v, variant="list"
is_watched: false,
}}
/> />
))} ))}
</div> </div>

View File

@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
getDiscovery, getDiscoveryVideos, getDiscovery, getDiscoveryVideos,
followDiscovery, dismissDiscovery, dismissDiscoveryVideo, refreshDiscovery, followDiscovery, dismissDiscovery, dismissDiscoveryVideo, refreshDiscovery,
getDiscoveryStatus,
} from "../api"; } from "../api";
import VideoCard from "../components/VideoCard"; import VideoCard from "../components/VideoCard";
import { scrollToTop } from "../utils/scroll"; import { scrollToTop } from "../utils/scroll";
@@ -150,7 +151,7 @@ function ChannelCard({ item }) {
)} )}
{!featured && item.description && ( {!featured && item.description && (
<p className="text-xs text-zinc-500 line-clamp-2">{item.description}</p> <p className="text-xs text-zinc-500 line-clamp-3">{item.description}</p>
)} )}
<div className="mt-auto pt-1"> <div className="mt-auto pt-1">
@@ -160,7 +161,7 @@ function ChannelCard({ item }) {
<button <button
onClick={() => followMut.mutate()} onClick={() => followMut.mutate()}
disabled={busy} disabled={busy}
className="w-full py-1.5 rounded-lg bg-accent text-black text-xs font-semibold hover:bg-yellow-300 transition-colors disabled:opacity-50" className="w-full py-1.5 rounded-lg bg-accent text-black text-xs font-semibold hover:bg-zinc-100 transition-colors disabled:opacity-50"
> >
Follow Follow
</button> </button>
@@ -176,7 +177,7 @@ function Tab({ active, onClick, children, count }) {
<button <button
onClick={onClick} onClick={onClick}
className={[ className={[
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px flex items-center gap-2", "px-3 py-1.5 text-xs font-medium border-b-2 transition-colors -mb-px flex items-center gap-1.5",
active active
? "border-accent text-zinc-100" ? "border-accent text-zinc-100"
: "border-transparent text-zinc-500 hover:text-zinc-300", : "border-transparent text-zinc-500 hover:text-zinc-300",
@@ -213,12 +214,21 @@ export default function DiscoveryPage() {
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
}); });
const { data: discStatus } = useQuery({
queryKey: ["discovery-status"],
queryFn: () => getDiscoveryStatus().then(r => r.data),
staleTime: 10_000,
refetchInterval: (query) => (query.state.data?.progress?.running ? 10_000 : 60_000),
});
const refreshMut = useMutation({ const refreshMut = useMutation({
mutationFn: refreshDiscovery, mutationFn: refreshDiscovery,
onSuccess: () => setTimeout(() => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["discovery"] }); // Discovery runs as a background job and takes several minutes.
qc.invalidateQueries({ queryKey: ["discovery-videos"] }); // Invalidate status immediately so the "queued" state shows, then
}, 8000), // re-check every 2 minutes until results land.
qc.invalidateQueries({ queryKey: ["discovery-status"] });
},
}); });
const handleDismissVideo = (video) => { const handleDismissVideo = (video) => {
@@ -237,12 +247,24 @@ export default function DiscoveryPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h1 className="font-display font-bold text-2xl text-zinc-100">Discover</h1> <h1 className="font-display font-bold text-2xl text-zinc-100">Discover</h1>
{discStatus && (
<p className="text-xs text-zinc-500 mt-0.5">
{discStatus.pending_count > 0
? `${discStatus.pending_count} channel${discStatus.pending_count !== 1 ? "s" : ""} queued`
: "Queue empty"}
{discStatus.last_run
? ` · last refreshed ${new Date(discStatus.last_run + "Z").toLocaleDateString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}`
: " · never refreshed"}
</p>
)}
</div>
<button <button
onClick={() => refreshMut.mutate()} onClick={() => refreshMut.mutate()}
disabled={refreshMut.isPending} disabled={refreshMut.isPending || discStatus?.progress?.running}
className="flex items-center gap-2 text-sm font-medium px-4 py-2 bg-zinc-800 text-zinc-300 rounded-lg hover:bg-zinc-700 transition-colors disabled:opacity-60" className="shrink-0 flex items-center gap-2 text-sm font-medium px-4 py-2 bg-zinc-800 text-zinc-300 rounded-lg hover:bg-zinc-700 transition-colors disabled:opacity-60"
> >
{refreshMut.isPending && ( {refreshMut.isPending && (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"> <svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
@@ -250,13 +272,13 @@ export default function DiscoveryPage() {
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg> </svg>
)} )}
{refreshMut.isPending ? "Searching…" : "Find more"} {discStatus?.progress?.running ? "Running…" : refreshMut.isSuccess ? "Queued ✓" : refreshMut.isPending ? "Queueing…" : "Find more"}
</button> </button>
</div> </div>
{refreshMut.isSuccess && !refreshMut.isPending && ( {refreshMut.isSuccess && !discStatus?.progress?.running && (
<div className="px-4 py-3 rounded-xl bg-zinc-800/60 border border-zinc-700/50 text-sm text-zinc-300"> <div className="px-4 py-3 rounded-xl bg-zinc-800/60 border border-zinc-700/50 text-sm text-zinc-400">
Searching YouTube for new channels results will appear in a few seconds. Discovery is running progress shows in the top bar. Searches are spaced out over ~20 minutes. Runs automatically every day.
</div> </div>
)} )}
@@ -284,7 +306,7 @@ export default function DiscoveryPage() {
</svg> </svg>
</div> </div>
<div> <div>
<p className="text-zinc-300 font-medium">Nothing here yet</p> <p className="text-zinc-500 text-sm">Nothing here yet</p>
<p className="text-zinc-500 text-sm mt-1 max-w-xs"> <p className="text-zinc-500 text-sm mt-1 max-w-xs">
Follow a few channels first, then hit "Find more" to discover similar ones. Follow a few channels first, then hit "Find more" to discover similar ones.
</p> </p>
@@ -292,7 +314,7 @@ export default function DiscoveryPage() {
<button <button
onClick={() => refreshMut.mutate()} onClick={() => refreshMut.mutate()}
disabled={refreshMut.isPending} disabled={refreshMut.isPending}
className="mt-2 px-6 py-2.5 rounded-xl bg-accent text-black font-semibold text-sm hover:bg-yellow-300 transition-colors disabled:opacity-60" className="mt-2 px-6 py-2.5 rounded-xl bg-accent text-black font-semibold text-sm hover:bg-zinc-100 transition-colors disabled:opacity-60"
> >
{refreshMut.isPending ? "Searching…" : "Find channels"} {refreshMut.isPending ? "Searching…" : "Find channels"}
</button> </button>
@@ -308,15 +330,15 @@ export default function DiscoveryPage() {
<button <button
onClick={() => { setChannelPage(p => p - 1); scrollToTop(); }} onClick={() => { setChannelPage(p => p - 1); scrollToTop(); }}
disabled={channelPage === 0} disabled={channelPage === 0}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >
Prev Prev
</button> </button>
<span className="text-zinc-500 text-sm tabular-nums">Page {channelPage + 1}</span> <span className="text-zinc-500 text-xs tabular-nums">Page {channelPage + 1}</span>
<button <button
onClick={() => { setChannelPage(p => p + 1); scrollToTop(); }} onClick={() => { setChannelPage(p => p + 1); scrollToTop(); }}
disabled={!hasNextChannelPage} disabled={!hasNextChannelPage}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >
Next Next
</button> </button>
@@ -324,11 +346,12 @@ export default function DiscoveryPage() {
</> </>
) : ( ) : (
<> <>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="flex flex-col gap-2">
{visibleVideos.map((v) => ( {visibleVideos.map((v) => (
<VideoCard <VideoCard
key={v.youtube_video_id} key={v.youtube_video_id}
video={{ ...v, is_recommended: true }} video={{ ...v, is_recommended: true }}
variant="list"
onDismiss={handleDismissVideo} onDismiss={handleDismissVideo}
/> />
))} ))}
@@ -337,15 +360,15 @@ export default function DiscoveryPage() {
<button <button
onClick={() => { setVideoPage(p => p - 1); scrollToTop(); }} onClick={() => { setVideoPage(p => p - 1); scrollToTop(); }}
disabled={videoPage === 0} disabled={videoPage === 0}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >
Prev Prev
</button> </button>
<span className="text-zinc-500 text-sm tabular-nums">Page {videoPage + 1}</span> <span className="text-zinc-500 text-xs tabular-nums">Page {videoPage + 1}</span>
<button <button
onClick={() => { setVideoPage(p => p + 1); scrollToTop(); }} onClick={() => { setVideoPage(p => p + 1); scrollToTop(); }}
disabled={!hasNextVideoPage} disabled={!hasNextVideoPage}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >
Next Next
</button> </button>

View File

@@ -1,7 +1,7 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getDownloads, deleteDownload, deleteAllDownloads, restoreDownload } from "../api"; import { getDownloads, deleteDownload, deleteAllDownloads, restoreDownload, getActiveTasks, generateNfoFiles } from "../api";
import SortPicker from "../components/SortPicker"; import SortPicker from "../components/SortPicker";
const HISTORY_SORTS = [ const HISTORY_SORTS = [
@@ -66,6 +66,12 @@ export default function DownloadsPage() {
}, },
}); });
const { data: activeTasks = [] } = useQuery({
queryKey: ["active-tasks"],
queryFn: () => getActiveTasks().then((r) => r.data),
refetchInterval: (query) => (query.state.data?.length > 0 ? 2000 : 5000),
});
const clearAllMut = useMutation({ const clearAllMut = useMutation({
mutationFn: deleteAllDownloads, mutationFn: deleteAllDownloads,
onSuccess: () => { onSuccess: () => {
@@ -74,6 +80,12 @@ export default function DownloadsPage() {
}, },
}); });
const [nfoResult, setNfoResult] = useState(null);
const nfoMut = useMutation({
mutationFn: generateNfoFiles,
onSuccess: (r) => setNfoResult(r.data.generated),
});
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
@@ -95,9 +107,18 @@ export default function DownloadsPage() {
const hasRemovable = history.length > 0 || trash.length > 0; const hasRemovable = history.length > 0 || trash.length > 0;
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="font-display font-bold text-2xl text-zinc-100">Downloads</h1> <h1 className="font-display font-bold text-2xl text-zinc-100">Downloads</h1>
<div className="flex items-center gap-3">
<button
onClick={() => { setNfoResult(null); nfoMut.mutate(); }}
disabled={nfoMut.isPending}
title="Generate Jellyfin .nfo sidecar files for all completed downloads"
className="text-sm text-zinc-500 hover:text-zinc-300 disabled:opacity-50 transition-colors"
>
{nfoMut.isPending ? "Generating…" : nfoResult != null ? `Generated ${nfoResult} NFO` : "Generate NFO"}
</button>
{hasRemovable && ( {hasRemovable && (
confirmClear ? ( confirmClear ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -126,6 +147,30 @@ export default function DownloadsPage() {
) )
)} )}
</div> </div>
</div>
{activeTasks.length > 0 && (
<section>
<h2 className="font-display font-semibold text-base text-zinc-400 mb-3">Background Tasks</h2>
<div className="flex flex-col gap-3">
{activeTasks.map((task) => {
const pct = task.total > 0 ? (task.done / task.total) * 100 : 0;
return (
<div key={task.id} className="bg-zinc-900 rounded-xl p-4">
<p className="text-sm font-medium text-zinc-100 mb-0.5">{task.label}</p>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-zinc-400">{task.phase || "Running…"}</span>
{task.total > 0 && (
<span className="text-xs text-zinc-400 tabular-nums">{task.done}/{task.total}</span>
)}
</div>
<ProgressBar pct={pct} />
</div>
);
})}
</div>
</section>
)}
{active.length > 0 && ( {active.length > 0 && (
<section> <section>

View File

@@ -10,6 +10,7 @@ import {
getChannelGroups, createChannelGroup, deleteChannelGroup, renameChannelGroup, getChannelGroups, createChannelGroup, deleteChannelGroup, renameChannelGroup,
addChannelToGroup, removeChannelFromGroup, addChannelToGroup, removeChannelFromGroup,
getSettings, bulkChannelAction, followBulk, updateChannelNotes, getSettings, bulkChannelAction, followBulk, updateChannelNotes,
getRssFeedUrl,
} from "../api"; } from "../api";
import VideoCard from "../components/VideoCard"; import VideoCard from "../components/VideoCard";
import SortPicker from "../components/SortPicker"; import SortPicker from "../components/SortPicker";
@@ -612,7 +613,10 @@ export default function Following() {
useEffect(() => { useEffect(() => {
if (channels.length > 0) { if (channels.length > 0) {
markChannelsSeen().then(() => { markChannelsSeen().then(() => {
qc.invalidateQueries({ queryKey: ["channels"] }); // Zero out new_count optimistically — avoids a full re-fetch just to clear badges
qc.setQueryData(["channels"], (old) =>
old ? old.map((c) => ({ ...c, new_count: 0 })) : old
);
}); });
} }
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
@@ -706,7 +710,7 @@ export default function Following() {
</svg> </svg>
</div> </div>
<div> <div>
<p className="text-zinc-300 font-medium">Not following anyone yet</p> <p className="text-zinc-500 text-sm">Not following anyone yet</p>
<p className="text-zinc-500 text-sm mt-1"> <p className="text-zinc-500 text-sm mt-1">
Hit Follow on a channel while watching a video or searching. Hit Follow on a channel while watching a video or searching.
</p> </p>
@@ -762,7 +766,7 @@ export default function Following() {
<button <button
onClick={() => dlAllMut.mutate()} onClick={() => dlAllMut.mutate()}
disabled={dlAllMut.isPending} disabled={dlAllMut.isPending}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent text-black text-sm font-semibold hover:bg-yellow-300 transition-colors disabled:opacity-60" className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent text-black text-sm font-semibold hover:bg-zinc-100 transition-colors disabled:opacity-60"
> >
{dlAllMut.isPending ? <><Spinner /> Queuing</> : "Download all new"} {dlAllMut.isPending ? <><Spinner /> Queuing</> : "Download all new"}
</button> </button>
@@ -777,13 +781,13 @@ export default function Following() {
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="flex items-center gap-1 border-b border-zinc-800"> <div className="flex items-center gap-0.5 border-b border-zinc-800">
{[["channels", "Channels"], ["feed", "Latest uploads"], ["groups", "Groups"]].map(([key, label]) => ( {[["channels", "Channels"], ["feed", "Feed"], ["health", "Health"], ["groups", "Groups"]].map(([key, label]) => (
<button <button
key={key} key={key}
onClick={() => setTab(key)} onClick={() => setTab(key)}
className={[ className={[
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px", "px-3 py-1.5 text-xs font-medium border-b-2 transition-colors -mb-px",
tab === key tab === key
? "border-accent text-zinc-100" ? "border-accent text-zinc-100"
: "border-transparent text-zinc-500 hover:text-zinc-300", : "border-transparent text-zinc-500 hover:text-zinc-300",
@@ -791,7 +795,7 @@ export default function Following() {
> >
{label} {label}
{key === "groups" && groups.length > 0 && ( {key === "groups" && groups.length > 0 && (
<span className="ml-1.5 text-xs text-zinc-600">{groups.length}</span> <span className="ml-1 text-[10px] text-zinc-600">{groups.length}</span>
)} )}
</button> </button>
))} ))}
@@ -1003,8 +1007,8 @@ export default function Following() {
<p className="text-zinc-500 text-sm">No videos indexed yet hit Sync all to pull the latest from YouTube.</p> <p className="text-zinc-500 text-sm">No videos indexed yet hit Sync all to pull the latest from YouTube.</p>
) : ( ) : (
<> <>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4"> <div className="flex flex-col gap-2">
{sortedFeed.map((v) => <VideoCard key={v.youtube_video_id} video={v} />)} {sortedFeed.map((v) => <VideoCard key={v.youtube_video_id} video={v} variant="list" />)}
</div> </div>
{hasMoreFeed && ( {hasMoreFeed && (
<div className="flex justify-center mt-2"> <div className="flex justify-center mt-2">
@@ -1019,6 +1023,65 @@ export default function Following() {
</div> </div>
)} )}
{/* ── Health tab ── */}
{tab === "health" && (() => {
const now = Date.now();
const bucket = (ch) => {
if (!ch.last_published_at) return "unknown";
const days = (now - new Date(ch.last_published_at)) / 86400000;
if (days < 30) return "active";
if (days < 90) return "slow";
if (days < 365) return "dormant";
return "dead";
};
const buckets = {
active: { label: "Active", sub: "uploaded in the last 30 days", color: "text-green-400", bg: "bg-green-900/20" },
slow: { label: "Slow", sub: "uploaded in the last 90 days", color: "text-yellow-400", bg: "bg-yellow-900/20" },
dormant: { label: "Dormant", sub: "no upload in 90365 days", color: "text-orange-400", bg: "bg-orange-900/20" },
dead: { label: "Dead", sub: "no upload in over a year", color: "text-red-400", bg: "bg-red-900/20" },
unknown: { label: "Unknown", sub: "never indexed", color: "text-zinc-500", bg: "bg-zinc-800/40" },
};
return (
<div className="flex flex-col gap-6">
<a
href={getRssFeedUrl()}
target="_blank"
rel="noopener noreferrer"
className="self-start flex items-center gap-1.5 text-xs text-zinc-500 hover:text-orange-400 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6.18 15.64a2.18 2.18 0 010 4.36 2.18 2.18 0 010-4.36M4 4.44A15.56 15.56 0 0119.56 20h-2.83A12.73 12.73 0 006.18 7.27V4.44M4 10.1a9.9 9.9 0 019.9 9.9h-2.83A7.07 7.07 0 006.18 12.93V10.1H4z"/>
</svg>
RSS feed
</a>
{Object.entries(buckets).map(([key, { label, sub, color, bg }]) => {
const chs = channels.filter(ch => bucket(ch) === key);
if (!chs.length) return null;
return (
<div key={key}>
<div className="flex items-baseline gap-2 mb-2">
<h2 className={`text-sm font-semibold ${color}`}>{label}</h2>
<span className="text-xs text-zinc-600">{sub}</span>
<span className="text-xs text-zinc-600 ml-auto">{chs.length} channel{chs.length !== 1 ? "s" : ""}</span>
</div>
<div className={`rounded-xl ${bg} divide-y divide-zinc-800/40`}>
{chs.map(ch => (
<ChannelRow
key={ch.id}
channel={ch}
groups={groups}
onGroupToggle={handleGroupToggle}
hideSubCount={hideSubCount}
/>
))}
</div>
</div>
);
})}
</div>
);
})()}
{/* ── Groups tab ── */} {/* ── Groups tab ── */}
{tab === "groups" && ( {tab === "groups" && (
<GroupsPanel groups={groups} channels={channels} /> <GroupsPanel groups={groups} channels={channels} />

View File

@@ -30,28 +30,28 @@ export default function History() {
</div> </div>
) : videos.length === 0 ? ( ) : videos.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-20 text-center"> <div className="flex flex-col items-center gap-3 py-20 text-center">
<p className="text-zinc-400 text-sm">No watch history yet. Start watching some videos!</p> <p className="text-zinc-500 text-sm">No watch history yet. Start watching some videos!</p>
</div> </div>
) : ( ) : (
<> <>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="flex flex-col gap-2">
{videos.map((v) => ( {videos.map((v) => (
<VideoCard key={v.youtube_video_id} video={v} /> <VideoCard key={v.youtube_video_id} video={v} variant="list" />
))} ))}
</div> </div>
<div className="flex items-center justify-center gap-3 pt-2"> <div className="flex items-center justify-center gap-3 pt-2">
<button <button
onClick={() => { setPage(p => p - 1); scrollToTop(); }} onClick={() => { setPage(p => p - 1); scrollToTop(); }}
disabled={page === 0} disabled={page === 0}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >
Prev Prev
</button> </button>
<span className="text-zinc-500 text-sm tabular-nums">Page {page + 1}</span> <span className="text-zinc-500 text-xs tabular-nums">Page {page + 1}</span>
<button <button
onClick={() => { setPage(p => p + 1); scrollToTop(); }} onClick={() => { setPage(p => p + 1); scrollToTop(); }}
disabled={!hasNext} disabled={!hasNext}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >
Next Next
</button> </button>

View File

@@ -9,6 +9,7 @@ const PAGE_SIZE = 25;
const FEED_MODES = [ const FEED_MODES = [
{ value: "ranked", label: "For you", hint: "Ranked by your taste" }, { value: "ranked", label: "For you", hint: "Ranked by your taste" },
{ value: "chronological", label: "New", hint: "Everything in date order" }, { value: "chronological", label: "New", hint: "Everything in date order" },
{ value: "rediscover", label: "Rediscover", hint: "Older unwatched videos ranked by your taste" },
{ value: "random", label: "Explore", hint: "Random from discovery pool" }, { value: "random", label: "Explore", hint: "Random from discovery pool" },
{ value: "inbox", label: "Inbox", hint: "New from followed channels since last visit" }, { value: "inbox", label: "Inbox", hint: "New from followed channels since last visit" },
]; ];
@@ -21,7 +22,7 @@ export default function Home() {
const [dismissed, setDismissed] = useState(new Set()); const [dismissed, setDismissed] = useState(new Set());
const [shuffleKey, setShuffleKey] = useState(0); const [shuffleKey, setShuffleKey] = useState(0);
const [duration, setDuration] = useState(""); const [duration, setDuration] = useState("");
const [viewMode, setViewMode] = useState(() => localStorage.getItem("home-view-mode") ?? "grid"); const [viewMode, setViewMode] = useState(() => localStorage.getItem("home-view-mode") ?? "list");
const toggleViewMode = () => { const toggleViewMode = () => {
const next = viewMode === "grid" ? "list" : "grid"; const next = viewMode === "grid" ? "list" : "grid";
@@ -55,9 +56,9 @@ export default function Home() {
}; };
const { data: feedData = [], isLoading: loadingFeed } = useQuery({ const { data: feedData = [], isLoading: loadingFeed } = useQuery({
queryKey: ["home-feed", mode, page, hideWatched, duration, mode === "random" ? shuffleKey : 0], queryKey: ["home-feed", mode, page, hideWatched, duration, shuffleKey],
queryFn: () => homeFeed(page, PAGE_SIZE, mode, duration).then((r) => r.data), queryFn: () => homeFeed(page, PAGE_SIZE, mode, duration).then((r) => r.data),
staleTime: 10 * 60_000, staleTime: mode === "ranked" ? 90_000 : 10 * 60_000,
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
}); });
@@ -107,20 +108,21 @@ export default function Home() {
</div> </div>
) : hasFollowing ? ( ) : hasFollowing ? (
<section className="flex flex-col gap-6"> <section className="flex flex-col gap-6">
<div className="flex items-center justify-between flex-wrap gap-3"> {/* Title row + secondary controls */}
<h2 className="font-display font-semibold text-xl text-zinc-200">Home</h2> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2"> <h2 className="font-display font-semibold text-base sm:text-xl text-zinc-200">Home</h2>
<div className="flex items-center gap-1.5">
<button <button
onClick={toggleViewMode} onClick={toggleViewMode}
title={viewMode === "grid" ? "Switch to list view" : "Switch to grid view"} title={viewMode === "grid" ? "Switch to list view" : "Switch to grid view"}
className="flex items-center justify-center w-8 h-8 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors border border-zinc-800" className="flex items-center justify-center w-7 h-7 rounded-md text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors"
> >
{viewMode === "grid" ? ( {viewMode === "grid" ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg> </svg>
) : ( ) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg> </svg>
)} )}
@@ -129,48 +131,39 @@ export default function Home() {
onClick={handleHideWatchedToggle} onClick={handleHideWatchedToggle}
title={hideWatched ? "Showing unwatched only" : "Showing all videos"} title={hideWatched ? "Showing unwatched only" : "Showing all videos"}
className={[ className={[
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors border", "flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium transition-colors",
hideWatched hideWatched ? "text-accent" : "text-zinc-600 hover:text-zinc-400",
? "bg-accent/10 text-accent border-accent/30"
: "text-zinc-500 border-zinc-800 hover:text-zinc-300 hover:border-zinc-700",
].join(" ")} ].join(" ")}
> >
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{hideWatched ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
)}
</svg>
{hideWatched ? "Unwatched" : "All"} {hideWatched ? "Unwatched" : "All"}
</button> </button>
<div className="flex items-center gap-1 bg-zinc-900 rounded-xl p-1"> </div>
</div>
{/* Mode switcher — own row, full width on mobile */}
<div className="flex items-center gap-0.5 bg-zinc-900 rounded-lg p-0.5 self-start">
{FEED_MODES.map(m => ( {FEED_MODES.map(m => (
<button <button
key={m.value} key={m.value}
onClick={() => handleModeChange(m.value)} onClick={() => handleModeChange(m.value)}
title={m.hint} title={m.hint}
className={[ className={[
"relative px-3 py-1.5 rounded-lg text-sm font-medium transition-colors", "relative px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
mode === m.value mode === m.value ? "bg-zinc-700 text-zinc-100" : "text-zinc-500 hover:text-zinc-300",
? "bg-zinc-700 text-zinc-100"
: "text-zinc-500 hover:text-zinc-300",
].join(" ")} ].join(" ")}
> >
{m.label} {m.label}
{m.value === "inbox" && inboxCount > 0 && ( {m.value === "inbox" && inboxCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 bg-accent text-black text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none"> <span className="absolute -top-1 -right-1 min-w-[13px] h-3 bg-zinc-200 text-zinc-900 text-[8px] font-bold rounded-full flex items-center justify-center px-0.5 leading-none">
{inboxCount > 99 ? "99+" : inboxCount} {inboxCount > 99 ? "99+" : inboxCount}
</span> </span>
)} )}
</button> </button>
))} ))}
</div> </div>
</div>
</div>
{/* Duration filter */} {/* Duration filter */}
<div className="flex items-center gap-1.5 -mt-3"> <div className="flex items-center gap-1.5">
{[["short", "< 10 min"], ["medium", "1030 min"], ["long", "30+ min"]].map(([val, label]) => ( {[["short", "< 10 min"], ["medium", "1030 min"], ["long", "30+ min"]].map(([val, label]) => (
<button <button
key={val} key={val}
@@ -188,7 +181,7 @@ export default function Home() {
</div> </div>
{mode === "inbox" && ( {mode === "inbox" && (
<div className="flex items-center justify-between -mt-3"> <div className="flex items-center justify-between">
<p className="text-xs text-zinc-600">Unwatched videos from followed channels since your last visit.</p> <p className="text-xs text-zinc-600">Unwatched videos from followed channels since your last visit.</p>
<button <button
onClick={() => markSeenMut.mutate()} onClick={() => markSeenMut.mutate()}
@@ -200,11 +193,15 @@ export default function Home() {
</div> </div>
)} )}
{mode === "chronological" && ( {mode === "chronological" && (
<p className="text-xs text-zinc-600 -mt-3">All videos from channels you follow, newest first.</p> <p className="text-xs text-zinc-600">All videos from channels you follow, newest first.</p>
)} )}
{mode === "random" && ( {(mode === "ranked" || mode === "random") && (
<div className="flex items-center justify-between -mt-3"> <div className="flex items-center justify-between">
<p className="text-xs text-zinc-600">Random from your discovery pool no weighting, no ranking.</p> <p className="text-xs text-zinc-600">
{mode === "ranked"
? "Ranked by your taste — reshuffles show a fresh mix."
: "Random from your discovery pool — no weighting, no ranking."}
</p>
<button <button
onClick={handleReshuffle} onClick={handleReshuffle}
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0 ml-4" className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0 ml-4"
@@ -238,15 +235,15 @@ export default function Home() {
<button <button
onClick={() => { setPage(p => p - 1); scrollToTop(); }} onClick={() => { setPage(p => p - 1); scrollToTop(); }}
disabled={page === 0} disabled={page === 0}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >
Prev Prev
</button> </button>
<span className="text-zinc-500 text-sm tabular-nums">Page {page + 1}</span> <span className="text-zinc-500 text-xs tabular-nums">Page {page + 1}</span>
<button <button
onClick={() => { setPage(p => p + 1); scrollToTop(); }} onClick={() => { setPage(p => p + 1); scrollToTop(); }}
disabled={!hasNextPage} disabled={!hasNextPage}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >
Next Next
</button> </button>
@@ -260,7 +257,7 @@ export default function Home() {
<button <button
onClick={() => surpriseMut.mutate()} onClick={() => surpriseMut.mutate()}
disabled={surpriseMut.isPending} disabled={surpriseMut.isPending}
className="bg-accent text-black font-display font-bold text-lg px-8 py-4 rounded-2xl hover:scale-105 active:scale-95 transition-all disabled:opacity-60 shadow-lg shadow-accent/20" className="bg-accent text-black font-display font-bold text-lg px-8 py-4 rounded-2xl hover:scale-105 active:scale-95 transition-all disabled:opacity-60 shadow-lg"
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="text-2xl"></span> <span className="text-2xl"></span>

View File

@@ -42,7 +42,7 @@ export default function LikedPage() {
} }
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-6">
<div className="flex items-start justify-between gap-4 flex-wrap"> <div className="flex items-start justify-between gap-4 flex-wrap">
<div> <div>
<h1 className="font-display font-bold text-2xl text-zinc-100">Liked videos</h1> <h1 className="font-display font-bold text-2xl text-zinc-100">Liked videos</h1>
@@ -54,7 +54,7 @@ export default function LikedPage() {
<button <button
onClick={() => refreshMut.mutate()} onClick={() => refreshMut.mutate()}
disabled={refreshMut.isPending || refreshMut.isSuccess} disabled={refreshMut.isPending || refreshMut.isSuccess}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors disabled:opacity-60" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 transition-colors disabled:opacity-60"
> >
{refreshMut.isPending ? ( {refreshMut.isPending ? (
<> <>
@@ -82,15 +82,15 @@ export default function LikedPage() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" /> d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg> </svg>
<p className="text-zinc-400 font-medium">No liked videos yet</p> <p className="text-zinc-500 text-sm">No liked videos yet</p>
<p className="text-zinc-600 text-sm max-w-xs"> <p className="text-zinc-600 text-sm max-w-xs">
Hit the heart on any video. Liked videos teach the discovery engine what you enjoy. Hit the heart on any video. Liked videos teach the discovery engine what you enjoy.
</p> </p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="flex flex-col gap-2">
{sortLiked(videos, sort).map((v) => ( {sortLiked(videos, sort).map((v) => (
<VideoCard key={v.youtube_video_id} video={v} /> <VideoCard key={v.youtube_video_id} video={v} variant="list" />
))} ))}
</div> </div>
)} )}

View File

@@ -27,7 +27,7 @@ export default function QueuePage() {
</svg> </svg>
</div> </div>
<div> <div>
<p className="text-zinc-300 font-medium">Queue is empty</p> <p className="text-zinc-500 text-sm">Queue is empty</p>
<p className="text-zinc-500 text-sm mt-1"> <p className="text-zinc-500 text-sm mt-1">
Hit the queue icon on any video to save it for later. Hit the queue icon on any video to save it for later.
</p> </p>
@@ -36,11 +36,12 @@ export default function QueuePage() {
) : ( ) : (
<> <>
<p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} saved</p> <p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} saved</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="flex flex-col gap-2">
{videos.map((v) => ( {videos.map((v) => (
<VideoCard <VideoCard
key={v.youtube_video_id} key={v.youtube_video_id}
video={v} video={v}
variant="list"
onRemoveFromQueue={() => { onRemoveFromQueue={() => {
toggleQueue(v.id).then(() => qc.invalidateQueries({ queryKey: ["queue"] })); toggleQueue(v.id).then(() => qc.invalidateQueries({ queryKey: ["queue"] }));
}} }}

View File

@@ -68,7 +68,7 @@ export default function SearchResults() {
const channelsFirst = channels.length > 0 && (videos.length === 0 || source === "local"); const channelsFirst = channels.length > 0 && (videos.length === 0 || source === "local");
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<h1 className="font-display font-semibold text-xl text-zinc-100"> <h1 className="font-display font-semibold text-xl text-zinc-100">
Results for <span className="text-accent">"{q}"</span> Results for <span className="text-accent">"{q}"</span>
@@ -108,9 +108,9 @@ export default function SearchResults() {
{hasMore ? `${visibleCount} of ${videos.length}` : videos.length} {hasMore ? `${visibleCount} of ${videos.length}` : videos.length}
</span> </span>
</h2> </h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="flex flex-col gap-2">
{visibleVideos.map((v) => ( {visibleVideos.map((v) => (
<VideoCard key={v.youtube_video_id} video={v} /> <VideoCard key={v.youtube_video_id} video={v} variant="list" />
))} ))}
</div> </div>
{hasMore && ( {hasMore && (

View File

@@ -413,7 +413,7 @@ function SubscriptionImportSection() {
</div> </div>
<button <button
onClick={() => setShowPaste((v) => !v)} onClick={() => setShowPaste((v) => !v)}
className="shrink-0 px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium hover:bg-yellow-300 transition-colors" className="shrink-0 px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium hover:bg-zinc-100 transition-colors"
> >
{showPaste ? "Cancel" : "Paste list"} {showPaste ? "Cancel" : "Paste list"}
</button> </button>
@@ -430,7 +430,7 @@ function SubscriptionImportSection() {
<button <button
onClick={handlePaste} onClick={handlePaste}
disabled={loading || !pasteText.trim()} disabled={loading || !pasteText.trim()}
className="self-end px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium hover:bg-yellow-300 transition-colors disabled:opacity-50" className="self-end px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium hover:bg-zinc-100 transition-colors disabled:opacity-50"
> >
{loading ? "Importing…" : `Import ${(pasteText.match(/@[\w.-]+(?=•)/g) || []).length} channels`} {loading ? "Importing…" : `Import ${(pasteText.match(/@[\w.-]+(?=•)/g) || []).length} channels`}
</button> </button>
@@ -600,6 +600,62 @@ function OAuth2Section({ s, qc }) {
); );
} }
function NotificationsSection() {
const [permission, setPermission] = useState(() =>
"Notification" in window ? Notification.permission : "unsupported"
);
const [enabled, setEnabled] = useState(() => localStorage.getItem("notifications_enabled") === "true");
const requestPermission = async () => {
if (!("Notification" in window)) return;
const result = await Notification.requestPermission();
setPermission(result);
if (result === "granted") {
setEnabled(true);
localStorage.setItem("notifications_enabled", "true");
new Notification("YTContinue notifications on", {
body: "You'll be notified when new videos arrive from channels you follow.",
});
}
};
const toggle = () => {
if (permission !== "granted") {
requestPermission();
return;
}
const next = !enabled;
setEnabled(next);
localStorage.setItem("notifications_enabled", next ? "true" : "false");
};
if (permission === "unsupported") return null;
return (
<Section title="Notifications">
<Row
label="New video alerts"
hint={
permission === "denied"
? "Blocked by browser allow notifications for this site in browser settings"
: "Get a browser notification when followed channels upload new videos"
}
>
{permission === "denied" ? (
<span className="text-xs text-zinc-600">Blocked</span>
) : (
<button
onClick={toggle}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled && permission === "granted" ? "bg-accent" : "bg-zinc-700"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled && permission === "granted" ? "translate-x-6" : "translate-x-1"}`} />
</button>
)}
</Row>
</Section>
);
}
export default function SettingsPage() { export default function SettingsPage() {
const { user } = useAuth(); const { user } = useAuth();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -650,6 +706,25 @@ export default function SettingsPage() {
<DiagnosticSection /> <DiagnosticSection />
<SubscriptionImportSection /> <SubscriptionImportSection />
{/* Sync */}
<Section title="Sync">
<Row
label="Auto-sync interval"
hint="How often to automatically sync your followed channels in the background."
>
<select
value={s?.sync_interval_hours ?? 0}
onChange={(e) => set({ sync_interval_hours: Number(e.target.value) })}
className="bg-zinc-800 text-zinc-200 text-sm rounded-lg px-3 py-1.5 border border-zinc-700 focus:outline-none focus:border-accent"
>
<option value={0}>Off</option>
<option value={6}>Every 6 hours</option>
<option value={12}>Every 12 hours</option>
<option value={24}>Every 24 hours</option>
</select>
</Row>
</Section>
{/* Download quality */} {/* Download quality */}
<Section title="Download quality"> <Section title="Download quality">
<Row <Row
@@ -691,6 +766,18 @@ export default function SettingsPage() {
onChange={(v) => set({ auto_download_on_sync: v })} onChange={(v) => set({ auto_download_on_sync: v })}
/> />
</Row> </Row>
<Row
label="Subtitle languages"
hint={'Download subtitles for these languages. e.g. "en" or "en,sv". Leave blank to skip.'}
>
<input
type="text"
value={s?.subtitle_langs ?? ""}
onChange={(e) => set({ subtitle_langs: e.target.value })}
placeholder="en, sv, "
className="bg-zinc-800 text-zinc-200 text-sm rounded-lg px-3 py-1.5 border border-zinc-700 focus:outline-none focus:border-accent w-36"
/>
</Row>
</Section> </Section>
{/* Feed */} {/* Feed */}
@@ -792,6 +879,9 @@ export default function SettingsPage() {
</Row> </Row>
</Section> </Section>
{/* Notifications */}
<NotificationsSection />
{/* Data */} {/* Data */}
<Section title="Data"> <Section title="Data">
<div className="px-5 py-4 flex items-center justify-between"> <div className="px-5 py-4 flex items-center justify-between">

View File

@@ -11,6 +11,14 @@ function fmt(seconds) {
return `${h}h ${m}m`; return `${h}h ${m}m`;
} }
function fmtBytes(bytes) {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0, v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function StatCard({ label, value, sub }) { function StatCard({ label, value, sub }) {
return ( return (
<div className="bg-zinc-900 rounded-2xl p-4 flex flex-col gap-1"> <div className="bg-zinc-900 rounded-2xl p-4 flex flex-col gap-1">
@@ -54,23 +62,22 @@ export default function Stats() {
const maxDayCount = Math.max(...days.map(d => dailyMap[d]?.count || 0), 1); const maxDayCount = Math.max(...days.map(d => dailyMap[d]?.count || 0), 1);
const maxCatCount = Math.max(...(data.top_categories || []).map(c => c.watch_count || 0), 1); const maxCatCount = Math.max(...(data.top_categories || []).map(c => c.watch_count || 0), 1);
const topTags = (data.taste_profile || []).slice(0, 12); const allTags = data.taste_profile || [];
const maxTagScore = Math.max(...topTags.map(t => t.score || 0), 1); const maxTagScore = Math.max(...allTags.map(t => t.score || 0), 1);
return ( return (
<div className="flex flex-col gap-8 max-w-4xl mx-auto"> <div className="flex flex-col gap-8 max-w-4xl mx-auto">
<h1 className="font-display font-bold text-2xl text-white">Stats</h1> <h1 className="font-display font-bold text-2xl text-white">Stats</h1>
{/* Top numbers */} {/* Top numbers */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<StatCard label="Total watched" value={data.total_watched.toLocaleString()} sub={fmt(data.total_watch_seconds) + " total"} /> <StatCard label="Total watched" value={data.total_watched.toLocaleString()} sub={fmt(data.total_watch_seconds) + " total"} />
<StatCard label="This week" value={data.this_week.count} sub={fmt(data.this_week.seconds)} /> <StatCard label="This week" value={data.this_week.count} sub={fmt(data.this_week.seconds)} />
<StatCard label="This month" value={data.this_month.count} sub={fmt(data.this_month.seconds)} /> <StatCard label="This month" value={data.this_month.count} sub={fmt(data.this_month.seconds)} />
<StatCard label="Total liked" value={(data.total_liked || 0).toLocaleString()} sub="videos" />
</div> </div>
{/* Engagement row */} {/* Engagement row */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard <StatCard
label="Avg completion" label="Avg completion"
value={`${data.avg_completion_percent ?? 0}%`} value={`${data.avg_completion_percent ?? 0}%`}
@@ -79,7 +86,12 @@ export default function Stats() {
<StatCard <StatCard
label="Finished" label="Finished"
value={(data.finished_count || 0).toLocaleString()} value={(data.finished_count || 0).toLocaleString()}
sub="watched ≥90%" sub="watched ≥75%"
/>
<StatCard
label="In progress"
value={(data.started_count || 0).toLocaleString()}
sub="started, not finished"
/> />
<StatCard <StatCard
label="Bailed early" label="Bailed early"
@@ -91,6 +103,11 @@ export default function Stats() {
value={(data.rewatched_videos || 0).toLocaleString()} value={(data.rewatched_videos || 0).toLocaleString()}
sub={`${data.total_rewatches || 0} total rewatches`} sub={`${data.total_rewatches || 0} total rewatches`}
/> />
<StatCard
label="Total liked"
value={(data.total_liked || 0).toLocaleString()}
sub="videos"
/>
</div> </div>
{/* Activity chart */} {/* Activity chart */}
@@ -170,41 +187,133 @@ export default function Stats() {
)} )}
</div> </div>
{/* Taste profile */} {/* Disk usage */}
{topTags.length > 0 && ( {data.disk?.total_bytes && (
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3"> <div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Disk usage</h2>
<div className="flex flex-col gap-2">
<div className="flex justify-between text-sm">
<span className="text-zinc-400">Downloads</span>
<span className="text-zinc-300 font-mono">{fmtBytes(data.disk.download_bytes)}</span>
</div>
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full bg-accent/70 rounded-full transition-all"
style={{ width: `${Math.min((data.disk.used_bytes / data.disk.total_bytes) * 100, 100)}%` }}
/>
</div>
<div className="flex justify-between text-[11px] text-zinc-600">
<span>{fmtBytes(data.disk.used_bytes)} used</span>
<span>{fmtBytes(data.disk.free_bytes)} free · {fmtBytes(data.disk.total_bytes)} total</span>
</div>
</div>
</div>
)}
{/* Peak hours */}
{data.peak_hours?.length > 0 && (() => {
const hourMap = Object.fromEntries(data.peak_hours.map(h => [h.hour, h.count]));
const maxCount = Math.max(...data.peak_hours.map(h => h.count), 1);
const hours = Array.from({ length: 24 }, (_, i) => i);
return (
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Peak watching hours</h2>
<div className="flex items-end gap-0.5 h-16">
{hours.map(h => {
const count = hourMap[h] || 0;
const pct = count / maxCount;
const label = h === 0 ? "12a" : h < 12 ? `${h}a` : h === 12 ? "12p" : `${h - 12}p`;
return (
<div key={h} className="flex-1 flex flex-col items-center gap-0.5" title={`${label}: ${count} video${count !== 1 ? "s" : ""}`}>
<div
className="w-full rounded-sm transition-all"
style={{
height: count === 0 ? "2px" : `${Math.max(pct * 100, 6)}%`,
backgroundColor: count === 0 ? "#27272a" : `hsl(50,95%,${30 + pct * 30}%)`,
}}
/>
</div>
);
})}
</div>
<div className="flex justify-between text-[10px] text-zinc-600">
<span>12am</span><span>6am</span><span>12pm</span><span>6pm</span><span>11pm</span>
</div>
</div>
);
})()}
{/* Taste profile */}
{allTags.length > 0 && (
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-4">
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Taste profile</h2> <h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Taste profile</h2>
<p className="text-[11px] text-zinc-600">built from your watches, likes and bookmarks</p> <p className="text-[11px] text-zinc-600">{allTags.length} interests · built from watches, likes &amp; bookmarks</p>
</div> </div>
{/* Top tier tag cloud */}
<div>
<p className="text-[10px] text-zinc-600 uppercase tracking-wider mb-2">Top interests</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{topTags.map(t => { {allTags.slice(0, 10).map(t => {
const intensity = t.score / maxTagScore; const intensity = t.score / maxTagScore;
return ( return (
<span <span
key={t.tag} key={t.tag}
title={`score: ${t.score.toFixed(1)}`} title={`score: ${t.score.toFixed(1)}`}
className="flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium" className="flex items-center gap-1 px-3 py-1 rounded-full font-semibold"
style={{ style={{
backgroundColor: `rgba(250,204,21,${0.08 + intensity * 0.18})`, backgroundColor: `rgba(250,204,21,${0.1 + intensity * 0.2})`,
color: `hsl(50,95%,${55 + intensity * 20}%)`, color: `hsl(50,95%,${55 + intensity * 20}%)`,
fontSize: `${11 + intensity * 4}px`, fontSize: `${12 + intensity * 5}px`,
}} }}
> >
{t.tag} {t.tag}
<button <button
onClick={() => deleteTag.mutate(t.tag)} onClick={() => deleteTag.mutate(t.tag)}
disabled={deleteTag.isPending} disabled={deleteTag.isPending}
className="opacity-40 hover:opacity-100 transition-opacity leading-none ml-0.5" className="opacity-30 hover:opacity-100 transition-opacity leading-none ml-0.5"
title="Remove from taste profile" title="Remove"
> >×</button>
×
</button>
</span> </span>
); );
})} })}
</div> </div>
</div> </div>
{/* All tags as score bars */}
{allTags.length > 10 && (
<div>
<p className="text-[10px] text-zinc-600 uppercase tracking-wider mb-3">All interests</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2">
{allTags.map(t => {
const pct = (t.score / maxTagScore) * 100;
const intensity = t.score / maxTagScore;
return (
<div key={t.tag} className="flex items-center gap-2 group/tag">
<span className="text-[12px] text-zinc-300 w-32 shrink-0 truncate">{t.tag}</span>
<div className="flex-1 h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{
width: `${pct}%`,
backgroundColor: `hsl(50,95%,${40 + intensity * 25}%)`,
}}
/>
</div>
<button
onClick={() => deleteTag.mutate(t.tag)}
disabled={deleteTag.isPending}
className="opacity-0 group-hover/tag:opacity-40 hover:!opacity-100 transition-opacity text-zinc-400 text-xs leading-none shrink-0"
title="Remove"
>×</button>
</div>
);
})}
</div>
</div>
)}
</div>
)} )}
</div> </div>
); );

View File

@@ -7,7 +7,7 @@ import {
getSettings, updateSettings, getRelatedVideos, getDownloads, rateVideo, getSettings, updateSettings, getRelatedVideos, getDownloads, rateVideo,
getBookmarks, createBookmark, updateBookmark, deleteBookmark, importChapters, clearChapters, getBookmarks, createBookmark, updateBookmark, deleteBookmark, importChapters, clearChapters,
getCollections, addToCollection, getQueue, getCollections, addToCollection, getQueue,
getVideoComments, refreshVideoComments, getVideoComments, refreshVideoComments, getAvailableSubs, getSubtitleFiles, downloadSubs,
} from "../api"; } from "../api";
import VideoCard from "../components/VideoCard"; import VideoCard from "../components/VideoCard";
@@ -66,15 +66,15 @@ function linkify(text) {
function DescriptionBox({ text }) { function DescriptionBox({ text }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const lines = text.split("\n"); const lines = text.split("\n");
const hasMore = lines.length > 4 || text.length > 300; const hasMore = lines.length > 2 || text.length > 200;
const displayed = expanded ? text : lines.slice(0, 4).join("\n") + (hasMore ? "…" : ""); const displayed = expanded ? text : lines.slice(0, 2).join("\n") + (hasMore ? "…" : "");
return ( return (
<div <div
className="bg-zinc-900 rounded-xl p-4 cursor-pointer select-none" className="bg-zinc-900 rounded-xl p-3 sm:p-4 cursor-pointer select-none"
onClick={() => hasMore && setExpanded(v => !v)} onClick={() => hasMore && setExpanded(v => !v)}
> >
<p className="text-sm text-zinc-300 whitespace-pre-line leading-relaxed"> <p className="text-[13px] text-zinc-300 whitespace-pre-line leading-relaxed">
{linkify(displayed)} {linkify(displayed)}
</p> </p>
{hasMore && ( {hasMore && (
@@ -94,7 +94,7 @@ function Chip({ onClick, active, disabled, children }) {
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className={[ className={[
"flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium transition-colors", "flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
active ? "bg-zinc-100 text-zinc-900 hover:bg-white" : "bg-zinc-800 text-zinc-300 hover:bg-zinc-700", active ? "bg-zinc-100 text-zinc-900 hover:bg-white" : "bg-zinc-800 text-zinc-300 hover:bg-zinc-700",
disabled && "opacity-40 cursor-not-allowed", disabled && "opacity-40 cursor-not-allowed",
].filter(Boolean).join(" ")} ].filter(Boolean).join(" ")}
@@ -230,7 +230,7 @@ function Placeholder({ video, dlStatus, onPlay, onDownloadAndPlay, isDownloading
) : onDownloadAndPlay ? ( ) : onDownloadAndPlay ? (
<button <button
onClick={onDownloadAndPlay} onClick={onDownloadAndPlay}
className="flex items-center gap-2 px-6 py-3 rounded-full bg-accent text-black font-bold text-sm hover:bg-yellow-300 transition-colors" className="flex items-center gap-2 px-6 py-3 rounded-full bg-accent text-black font-bold text-sm hover:bg-zinc-100 transition-colors"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
@@ -561,8 +561,10 @@ export default function Watch() {
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [queued, setQueued] = useState(null); const [queued, setQueued] = useState(null);
const [liked, setLiked] = useState(null); const [liked, setLiked] = useState(null);
const [rating, setRating] = useState(null); const [disliked, setDisliked] = useState(null);
const [isRedownloading, setIsRedownloading] = useState(false);
const [selectedQuality, setSelectedQuality] = useState(null); const [selectedQuality, setSelectedQuality] = useState(null);
const [selectedSubLang, setSelectedSubLang] = useState("");
const [speed, setSpeed] = useState(1); const [speed, setSpeed] = useState(1);
const [autoplay, setAutoplay] = useState(false); const [autoplay, setAutoplay] = useState(false);
const [theater, setTheater] = useState(false); const [theater, setTheater] = useState(false);
@@ -658,6 +660,21 @@ export default function Watch() {
staleTime: sidebarMode === "random" ? 0 : 5 * 60_000, staleTime: sidebarMode === "random" ? 0 : 5 * 60_000,
}); });
const [subsRequested, setSubsRequested] = useState(false);
useEffect(() => { setSubsRequested(false); setSelectedSubLang(""); }, [youtubeVideoId]);
const { data: availableSubs, isLoading: subsLoading } = useQuery({
queryKey: ["available-subs", youtubeVideoId],
queryFn: () => getAvailableSubs(youtubeVideoId).then(r => r.data),
enabled: subsRequested && !!youtubeVideoId,
staleTime: 30 * 60_000,
});
const { data: subtitleFiles = [] } = useQuery({
queryKey: ["subtitle-files", youtubeVideoId],
queryFn: () => getSubtitleFiles(youtubeVideoId).then(r => r.data),
enabled: !!youtubeVideoId,
staleTime: 60_000,
});
const { data: dlStatus } = useQuery({ const { data: dlStatus } = useQuery({
queryKey: ["download-status", downloadId], queryKey: ["download-status", downloadId],
queryFn: () => getDownload(downloadId).then(r => r.data), queryFn: () => getDownload(downloadId).then(r => r.data),
@@ -697,33 +714,47 @@ export default function Watch() {
.catch(() => { pollTimerRef.current = setTimeout(() => pollForFile(url), 1500); }); .catch(() => { pollTimerRef.current = setTimeout(() => pollForFile(url), 1500); });
}, [fileReady]); }, [fileReady]);
// Only poll once the backend confirms the download is fully written.
// Polling before status==="complete" risks playing a partial file.
useEffect(() => { useEffect(() => {
if (!dlStatus?.file_url || fileReady || !playRequested) return; if (fileReady || !playRequested) return;
pollForFile(dlStatus.file_url); const backendDone = dlStatus?.status === "complete" || !!video?.is_downloaded;
const fileUrl = dlStatus?.file_url ?? (video?.is_downloaded ? `/files/${youtubeVideoId}.mp4` : null);
if (!backendDone || !fileUrl) return;
pollForFile(fileUrl);
return () => clearTimeout(pollTimerRef.current); return () => clearTimeout(pollTimerRef.current);
}, [dlStatus?.file_url, fileReady, pollForFile, playRequested]); }, [playRequested, fileReady, dlStatus?.status, dlStatus?.file_url, video?.is_downloaded, youtubeVideoId, pollForFile]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!video?.is_downloaded || fileReady || !playRequested) return;
pollForFile(`/files/${youtubeVideoId}.mp4`);
}, [video?.is_downloaded, playRequested]); // eslint-disable-line react-hooks/exhaustive-deps
const downloadMut = useMutation({ const downloadMut = useMutation({
mutationFn: () => createDownload(youtubeVideoId, selectedQuality), mutationFn: ({ quality, subLang } = {}) =>
createDownload(youtubeVideoId, quality ?? selectedQuality, subLang ?? selectedSubLang),
onSuccess: (res) => { onSuccess: (res) => {
const dl = res.data; setDownloadId(res.data.id);
setDownloadId(dl.id);
refetchVideo(); refetchVideo();
if (dl.status === "complete" && dl.file_url) pollForFile(dl.file_url);
}, },
}); });
const handlePlay = useCallback(() => setPlayRequested(true), []); const handlePlay = useCallback(() => setPlayRequested(true), []);
const handleDownloadAndPlay = useCallback(() => { const handleDownloadAndPlay = useCallback(() => {
setPlayRequested(true); setPlayRequested(true);
downloadMut.mutate(); downloadMut.mutate({});
pollForFile(`/files/${youtubeVideoId}.mp4`); }, [downloadMut]);
}, [downloadMut, pollForFile, youtubeVideoId]); const handleRedownload = useCallback(async (quality) => {
const dlId = downloadId ?? allDownloads.find(
d => d.youtube_video_id === youtubeVideoId && d.status === "complete"
)?.id;
if (!dlId) return;
setIsRedownloading(true);
try { await deleteDownload(dlId); } catch (_) {}
setFileReady(false);
setConfirmedFileUrl(null);
setDownloadId(null);
setPlayRequested(false);
setIsRedownloading(false);
qc.invalidateQueries({ queryKey: ["downloads"] });
refetchVideo();
downloadMut.mutate({ quality });
}, [downloadId, allDownloads, youtubeVideoId, downloadMut, refetchVideo, qc]);
const saveProgress = useCallback((secs) => { const saveProgress = useCallback((secs) => {
if (!video?.id) return; if (!video?.id) return;
@@ -773,9 +804,18 @@ export default function Watch() {
onSuccess: (res) => { setLiked(res.data.liked); qc.invalidateQueries({ queryKey: ["liked-videos"] }); }, onSuccess: (res) => { setLiked(res.data.liked); qc.invalidateQueries({ queryKey: ["liked-videos"] }); },
}); });
const rateMut = useMutation({ const dislikeMut = useMutation({
mutationFn: (r) => rateVideo(video.id, r), mutationFn: () => rateVideo(video.id, isDisliked ? 0 : -1),
onSuccess: (res) => setRating(res.data.rating), onSuccess: (res) => setDisliked(res.data.rating === -1),
});
const addSubsMut = useMutation({
mutationFn: () => downloadSubs(youtubeVideoId, selectedSubLang),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["subtitle-files", youtubeVideoId] });
setSubsRequested(false);
setSelectedSubLang("");
},
}); });
const handlePiP = useCallback(async () => { const handlePiP = useCallback(async () => {
@@ -793,8 +833,9 @@ export default function Watch() {
const startAt = video?.watch_progress_seconds ?? 0; const startAt = video?.watch_progress_seconds ?? 0;
const isQueued = queued ?? video?.queued ?? false; const isQueued = queued ?? video?.queued ?? false;
const isLiked = liked ?? video?.liked ?? false; const isLiked = liked ?? video?.liked ?? false;
const currentRating = rating ?? video?.rating ?? null; const isDisliked = disliked ?? (video?.rating === -1) ?? false;
const dlComplete = dlStatus?.status === "complete" || video?.is_downloaded; const dlComplete = dlStatus?.status === "complete" || video?.is_downloaded;
const downloadedResolution = dlStatus?.resolution ?? video?.download_resolution;
const isFollowed = followMut.isSuccess || video?.channel_followed; const isFollowed = followMut.isSuccess || video?.channel_followed;
const subs = formatSubs(channel?.subscriber_count); const subs = formatSubs(channel?.subscriber_count);
@@ -844,7 +885,7 @@ export default function Watch() {
<div className={theater ? "flex flex-col gap-6" : "flex flex-col xl:flex-row gap-6"}> <div className={theater ? "flex flex-col gap-6" : "flex flex-col xl:flex-row gap-6"}>
{/* ── Left: video + info ───────────────────────────────────────────── */} {/* ── Left: video + info ───────────────────────────────────────────── */}
<div className={theater ? "w-full flex flex-col gap-4" : "flex-1 min-w-0 flex flex-col gap-4"}> <div className={theater ? "w-full flex flex-col gap-4 sm:gap-5" : "flex-1 min-w-0 flex flex-col gap-4 sm:gap-5"}>
{/* Player */} {/* Player */}
<div className={theater ? "w-full aspect-video bg-black overflow-hidden shadow-2xl" : "w-full aspect-video bg-black rounded-2xl overflow-hidden shadow-2xl"}> <div className={theater ? "w-full aspect-video bg-black overflow-hidden shadow-2xl" : "w-full aspect-video bg-black rounded-2xl overflow-hidden shadow-2xl"}>
@@ -864,7 +905,17 @@ export default function Watch() {
if (video?.watch_progress_seconds > 10) v.currentTime = video.watch_progress_seconds; if (video?.watch_progress_seconds > 10) v.currentTime = video.watch_progress_seconds;
v.play().catch(() => {}); v.play().catch(() => {});
}} }}
>
{subtitleFiles.map((s) => (
<track
key={s.lang}
kind="subtitles"
src={s.url}
srcLang={s.lang}
label={s.lang}
/> />
))}
</video>
) : ( ) : (
<Placeholder <Placeholder
video={video} video={video}
@@ -887,11 +938,11 @@ export default function Watch() {
)} )}
{/* Title */} {/* Title */}
<h1 className="text-xl font-bold text-white leading-snug">{title}</h1> <h1 className="text-base sm:text-xl font-bold text-white leading-snug">{title}</h1>
{/* Meta + actions row */} {/* Meta + actions row */}
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2 text-sm text-zinc-500 flex-wrap"> <div className="flex items-center gap-2 text-xs text-zinc-500 flex-wrap">
{date && <span>{date}</span>} {date && <span>{date}</span>}
{video?.view_count > 0 && <><span>·</span><span>{formatViews(video.view_count)}</span></>} {video?.view_count > 0 && <><span>·</span><span>{formatViews(video.view_count)}</span></>}
{video?.like_count > 0 && <><span>·</span><span>{formatViews(video.like_count).replace(" views", "")} likes</span></>} {video?.like_count > 0 && <><span>·</span><span>{formatViews(video.like_count).replace(" views", "")} likes</span></>}
@@ -914,11 +965,16 @@ export default function Watch() {
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{!dlComplete && !isDownloading && !downloadMut.isPending && ( {!isDownloading && !downloadMut.isPending && !isRedownloading && (
<select <select
value={selectedQuality ?? "best"} value={selectedQuality ?? "best"}
onChange={(e) => setSelectedQuality(e.target.value)} onChange={(e) => {
const q = e.target.value;
setSelectedQuality(q);
if (dlComplete) handleRedownload(q);
}}
className="bg-zinc-800 text-zinc-300 text-xs rounded-full px-3 py-2 border border-zinc-700 focus:outline-none focus:border-accent" className="bg-zinc-800 text-zinc-300 text-xs rounded-full px-3 py-2 border border-zinc-700 focus:outline-none focus:border-accent"
> >
{[ {[
@@ -937,6 +993,69 @@ export default function Watch() {
</select> </select>
)} )}
{(() => {
const onDisk = subtitleFiles.map(s => s.lang);
const onDiskSet = new Set(onDisk);
if (!subsRequested) return (
<button
onClick={() => setSubsRequested(true)}
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors"
>
CC{onDisk.length > 0 ? ` · ${onDisk.join(", ")}` : ""}
</button>
);
const AUTO_LANGS = new Set(["en", "sv", "ja"]);
const ytManual = (availableSubs?.manual ?? []).filter(l => !onDiskSet.has(l));
const ytManualSet = new Set(ytManual);
const ytAuto = (availableSubs?.auto ?? []).filter(l => !onDiskSet.has(l) && !ytManualSet.has(l) && AUTO_LANGS.has(l));
const needsDownload = selectedSubLang && !onDiskSet.has(selectedSubLang);
return (
<>
<select
value={selectedSubLang}
onChange={(e) => {
const lang = e.target.value;
setSelectedSubLang(lang);
const v = videoRef.current;
if (v) {
for (let i = 0; i < v.textTracks.length; i++) {
v.textTracks[i].mode =
lang && v.textTracks[i].language === lang ? "showing" : "disabled";
}
}
}}
className="bg-zinc-800 text-zinc-300 text-xs rounded-full px-3 py-2 border border-zinc-700 focus:outline-none focus:border-accent"
>
<option value="">No subtitles</option>
{onDisk.length > 0 && (
<optgroup label="— Downloaded —">
{onDisk.map(l => <option key={l} value={l}>{l}</option>)}
</optgroup>
)}
{(subsLoading || ytManual.length > 0 || ytAuto.length > 0) && (
<optgroup label={subsLoading ? "— Loading YouTube… —" : "— Available on YouTube —"}>
{ytManual.map(l => <option key={l} value={l}>{l}</option>)}
{ytAuto.map(l => <option key={l} value={l}>{l} (auto)</option>)}
</optgroup>
)}
</select>
{dlComplete && needsDownload && (
<button
onClick={() => addSubsMut.mutate()}
disabled={addSubsMut.isPending}
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium bg-accent text-black hover:bg-zinc-100 transition-colors disabled:opacity-50"
>
{addSubsMut.isPending && <span className="w-3 h-3 border-2 border-black/40 border-t-transparent rounded-full animate-spin inline-block" />}
{addSubsMut.isPending ? "Fetching…" : "Add subtitles"}
</button>
)}
</>
);
})()}
{fileReady && ( {fileReady && (
<select <select
value={speed} value={speed}
@@ -952,10 +1071,10 @@ export default function Watch() {
<Chip <Chip
active={dlComplete} active={dlComplete}
disabled={dlComplete || isDownloading || downloadMut.isPending} disabled={dlComplete || isDownloading || downloadMut.isPending}
onClick={() => !dlComplete && !isDownloading && downloadMut.mutate()} onClick={() => !dlComplete && !isDownloading && downloadMut.mutate({})}
> >
{dlComplete ? ( {dlComplete ? (
<><svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/></svg>Saved</> <><svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/></svg>Saved{downloadedResolution ? ` · ${downloadedResolution}` : ""}</>
) : isDownloading || downloadMut.isPending ? ( ) : isDownloading || downloadMut.isPending ? (
<><svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/></svg>Downloading</> <><svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/></svg>Downloading</>
) : ( ) : (
@@ -972,42 +1091,25 @@ export default function Watch() {
</Chip> </Chip>
)} )}
{video?.id && ( {video?.id && (
<>
<Chip active={isLiked} onClick={() => likeMut.mutate()} disabled={likeMut.isPending}> <Chip active={isLiked} onClick={() => likeMut.mutate()} disabled={likeMut.isPending}>
<svg className="w-4 h-4" fill={isLiked ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill={isLiked ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg> </svg>
{isLiked ? "Liked" : "Like"} {isLiked ? "Liked" : "Like"}
</Chip> </Chip>
)} <Chip active={isDisliked} onClick={() => dislikeMut.mutate()} disabled={dislikeMut.isPending} title="Not for me">
<svg className="w-4 h-4" fill={isDisliked ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
{video?.id && (
<>
<Chip
active={currentRating === 1}
onClick={() => rateMut.mutate(currentRating === 1 ? 0 : 1)}
disabled={rateMut.isPending}
>
<svg className="w-4 h-4" fill={currentRating === 1 ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 9V5a3 3 0 00-3-3l-4 9v11h11.28a2 2 0 002-1.7l1.38-9a2 2 0 00-2-2.3H14z"/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 22H4a2 2 0 01-2-2v-7a2 2 0 012-2h3"/>
</svg>
Good
</Chip>
<Chip
active={currentRating === -1}
onClick={() => rateMut.mutate(currentRating === -1 ? 0 : -1)}
disabled={rateMut.isPending}
>
<svg className="w-4 h-4" fill={currentRating === -1 ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 15v4a3 3 0 003 3l4-9V2H5.72a2 2 0 00-2 1.7l-1.38 9a2 2 0 002 2.3H10z"/> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 15v4a3 3 0 003 3l4-9V2H5.72a2 2 0 00-2 1.7l-1.38 9a2 2 0 002 2.3H10z"/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 2h2.67A2.31 2.31 0 0122 4v7a2.31 2.31 0 01-2.33 2H17"/> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 2h2.67A2.31 2.31 0 0122 4v7a2.31 2.31 0 01-2.33 2H17"/>
</svg> </svg>
Not for me
</Chip> </Chip>
</> </>
)} )}
{video?.id && ( {video?.id && (
<Chip active={isQueued} onClick={() => queueMut.mutate()} disabled={queueMut.isPending}> <Chip active={isQueued} onClick={() => queueMut.mutate()} disabled={queueMut.isPending}>
<svg className="w-4 h-4" fill={isQueued ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill={isQueued ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
@@ -1018,16 +1120,15 @@ export default function Watch() {
)} )}
{fileReady && document.pictureInPictureEnabled && ( {fileReady && document.pictureInPictureEnabled && (
<Chip onClick={handlePiP}> <Chip onClick={handlePiP} title="Picture in picture">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="2" y="4" width="20" height="16" rx="2" strokeWidth="2"/> <rect x="2" y="4" width="20" height="16" rx="2" strokeWidth="2"/>
<rect x="12" y="12" width="8" height="6" rx="1.5" strokeWidth="2"/> <rect x="12" y="12" width="8" height="6" rx="1.5" strokeWidth="2"/>
</svg> </svg>
Mini
</Chip> </Chip>
)} )}
<Chip active={theater} onClick={() => setTheater(t => !t)}> <Chip active={theater} onClick={() => setTheater(t => !t)} title={theater ? "Exit theater" : "Theater mode"}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{theater ? ( {theater ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9L4 4m0 5V4h5M15 9l5-5m0 5V4h-5M9 15l-5 5m0-5v5h5M15 15l5 5m0-5v5h-5" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9L4 4m0 5V4h5M15 9l5-5m0 5V4h-5M9 15l-5 5m0-5v5h5M15 15l5 5m0-5v5h-5" />
@@ -1035,7 +1136,6 @@ export default function Watch() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V5a1 1 0 011-1h3M4 16v3a1 1 0 001 1h3m10-4v3a1 1 0 01-1 1h-3M20 8V5a1 1 0 00-1-1h-3" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V5a1 1 0 011-1h3M4 16v3a1 1 0 001 1h3m10-4v3a1 1 0 01-1 1h-3M20 8V5a1 1 0 00-1-1h-3" />
)} )}
</svg> </svg>
Theater
</Chip> </Chip>
</div> </div>
</div> </div>
@@ -1048,9 +1148,9 @@ export default function Watch() {
<Link to={`/channels/${video?.channel_id}`} className="shrink-0"> <Link to={`/channels/${video?.channel_id}`} className="shrink-0">
{channel?.thumbnail_url ? ( {channel?.thumbnail_url ? (
<img src={channel.thumbnail_url} alt={channelName} <img src={channel.thumbnail_url} alt={channelName}
className="w-11 h-11 rounded-full object-cover" /> className="w-9 h-9 sm:w-11 sm:h-11 rounded-full object-cover" />
) : ( ) : (
<div className="w-11 h-11 rounded-full flex items-center justify-center font-bold text-white text-base shrink-0" <div className="w-9 h-9 sm:w-11 sm:h-11 rounded-full flex items-center justify-center font-bold text-white text-base shrink-0"
style={{ backgroundColor: avatarColor(channelName) }}> style={{ backgroundColor: avatarColor(channelName) }}>
{channelName?.[0]?.toUpperCase()} {channelName?.[0]?.toUpperCase()}
</div> </div>
@@ -1084,7 +1184,7 @@ export default function Watch() {
{/* Tags */} {/* Tags */}
{tags.length > 0 && ( {tags.length > 0 && (
<div className="flex flex-wrap gap-1.5"> <div className="hidden sm:flex flex-wrap gap-1.5">
{tags.map(tag => ( {tags.map(tag => (
<span key={tag} className="px-2.5 py-1 rounded-full bg-zinc-800/80 text-zinc-500 text-xs"> <span key={tag} className="px-2.5 py-1 rounded-full bg-zinc-800/80 text-zinc-500 text-xs">
{tag} {tag}
@@ -1107,7 +1207,7 @@ export default function Watch() {
)} )}
{/* Keyboard shortcuts hint */} {/* Keyboard shortcuts hint */}
<p className={`text-xs text-zinc-700 text-center ${theater ? "max-w-4xl mx-auto w-full" : ""}`}> <p className={`hidden sm:block text-xs text-zinc-700 text-center ${theater ? "max-w-4xl mx-auto w-full" : ""}`}>
Space/K · pause &nbsp;·&nbsp; F · fullscreen &nbsp;·&nbsp; M · mute &nbsp;·&nbsp; / seek 5s &nbsp;·&nbsp; / volume &nbsp;·&nbsp; ,/. speed &nbsp;·&nbsp; T · theater Space/K · pause &nbsp;·&nbsp; F · fullscreen &nbsp;·&nbsp; M · mute &nbsp;·&nbsp; / seek 5s &nbsp;·&nbsp; / volume &nbsp;·&nbsp; ,/. speed &nbsp;·&nbsp; T · theater
</p> </p>

View File

@@ -10,9 +10,9 @@ export default {
}, },
colors: { colors: {
accent: { accent: {
DEFAULT: "#f5a623", DEFAULT: "#ffffff",
light: "#fbbf45", light: "#f4f4f5",
dark: "#d4891a", dark: "#d4d4d8",
}, },
}, },
aspectRatio: { aspectRatio: {