Commit Graph

40 Commits

Author SHA1 Message Date
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
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
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
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
1cd8645957 Fix YouTube hammering, sync rate limiting, and Following load time
Sync throttling:
- sync-all now skips channels crawled within the last 6 hours (prevents
  re-scraping 1266 channels on every button press)
- Channels are queued into a single _index_channels_batch task that runs
  with 1.5s delay between each yt-dlp call instead of firing 1266
  background tasks simultaneously
- Startup enrich task reduced from 10 to 3 videos (3 yt-dlp calls on
  each container restart)
- Enrich task adds 2s sleep between metadata fetches

SQLite stability:
- busy_timeout=5000 prevents SQLITE_BUSY errors under concurrent load
- synchronous=NORMAL speeds up writes without data loss risk (safe with WAL)

Following page:
- staleTime: 60s on channels query so cached data is reused immediately
  on revisit; gcTime keeps it in memory for 5 min

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:00:37 +02:00
Mattias Tall
0d6dd94029 VideoCard: redesigned list view, removed grid descriptions, smaller mobile fonts
List variant:
- Mobile thumbnail w-32 (128px) instead of w-56 (224px) — much less dominant
- Title 12px mobile / 14px desktop (was 15px everywhere)
- Channel/meta 10px mobile / 11px desktop
- Channel avatar shown next to channel name on desktop
- Description hidden on mobile, line-clamp-2 on desktop (was line-clamp-3)
- Actions always visible on mobile (hover:opacity on desktop only)
- Tighter spacing (gap-3, px-2, py-2)

Grid variant:
- Removed description entirely — too cluttered on narrow columns
- Title 12px mobile / 13px desktop
- Channel/meta 10px everywhere
- Avatar 28px mobile / 32px desktop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:59:15 +02:00
Mattias Tall
cb05b739a8 Mobile UX: fixed bottom nav, compact header, less cramped cards
- App-shell layout (height:100dvh, only main scrolls) so the bottom nav
  is a natural flex child and never disappears regardless of browser
  chrome show/hide behaviour
- Bottom nav reduced from h-16 to h-14, icons from 20px to 18px, labels
  from 10px to 9px — slimmer bar, still readable
- Header: min-w-0 on search prevents horizontal overflow; user/sign-out
  hidden on mobile (accessible via Settings); logo shortened to "YT" on
  mobile; px-3 / h-12 on mobile instead of px-4 / h-14
- Grid card descriptions hidden on mobile (hidden sm:block) — reduces
  height cramping in the 2-column feed
- scrollToTop() utility replaces window.scrollTo so pagination still
  scrolls to top within the new scroll container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 12:30:23 +02:00
Mattias Tall
b3c288a590 Fix channel avatar: include channel_thumbnail_url in video response
Reading from the channels query cache was unreliable (cache might not be
loaded, or channel not followed). Add c.thumbnail_url AS channel_thumbnail_url
to _VIDEO_SELECT so every video response carries its channel avatar directly.
VideoCard uses it with cache as fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 12:16:43 +02:00
Mattias Tall
aaa9d0145e Fix description text clipping in video cards
Card wrapper overflow-hidden was clipping description text at the card
boundary. Move overflow-hidden + rounding to the thumbnail only so the
card body text has room to render fully.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 12:03:56 +02:00
Mattias Tall
f5a35cd1f2 Add view count and description snippet to grid video cards
Date and views share a meta line; description shows up to 2 lines below —
gives enough context to judge a video during discovery without opening it.
Both fields are optional so cards without them stay compact.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 12:02:50 +02:00
Mattias Tall
1df396590f Redesign video cards with channel avatar; fix mobile bottom nav
VideoCard grid: YouTube-style layout — channel avatar circle (falls back
to letter) left of title/meta, avatar is clickable to go to channel page,
date on its own line for breathing room, badges inline with channel name

Bottom nav: env(safe-area-inset-bottom) so it clears iOS home indicator,
active tab gets a soft accent pill behind the icon, slightly bolder label

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 12:01:43 +02:00
Mattias Tall
c7ec8c21f2 Add paste-from-YouTube import to subscription section
Alongside the CSV option, add a text area that accepts copy-pasted text
from the YouTube subscriptions page. Extracts @handle from lines like
'@handle•N subscribers' using regex, deduplicates, then calls follow-bulk.
Button live-counts how many handles are detected as you paste.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:47:16 +02:00
Mattias Tall
18b3c572f1 Simplify likes display: plain text in meta row, no bar
Bar depends on reliable dislike data which YouTube doesn't expose.
Show likes inline as "X.XM likes" alongside views and date instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:34:40 +02:00
Mattias Tall
74e2b4cd73 Fix comments (Python API), add dislike bar
Comments: switch from CLI --write-comments to yt-dlp Python API with
getcomments=True — more reliable, proper extractor_args dict format

Dislikes: add dislike_count column, fetch from returnyoutubedislike.com
after each video metadata upsert (5s timeout, non-fatal)

UI: replace emoji like count with a like/dislike ratio bar — blue fill
showing like proportion, labels on each end; views stay in meta row

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:33:00 +02:00
Mattias Tall
3f225e7647 Add like count to videos
Same pattern as view_count: model column, yt-dlp extraction, SQL select,
VideoDetail field, startup migration, and display in Watch meta row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:25:44 +02:00
Mattias Tall
8221177615 Add view count to videos
- Video model: view_count column (Integer, nullable)
- ytdlp._normalize_video: extract view_count from yt-dlp info
- _VIDEO_SELECT: include v.view_count in all queries
- VideoDetail schema: view_count field
- Watch page: formatViews() helper, show "X.XM views" in meta row
  alongside date and category

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:18:53 +02:00
Mattias Tall
cdf6520fd8 Add lazy comment fetching to watch page
- VideoComment model (video_id, author, text, likes, is_pinned, published_at)
- fetch_video_comments() in ytdlp.py: top 20 comments, no reply threads,
  sorted pinned-first then by likes
- GET /videos/by-yt/{id}/comments — returns cached comments instantly
- POST /videos/by-yt/{id}/comments/refresh — fetches from YouTube, stores, returns
- Watch page: CommentsSection shows "Load comments" button when uncached,
  renders comments with author/likes once loaded; Refresh link to re-fetch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:15:41 +02:00
Mattias Tall
d6dd07e0bd Add delete button to taste profile tags in Stats
- Backend: DELETE /stats/taste/{tag} removes the row from user_tag_affinity
- API: deleteTasteTag(tag) helper
- Stats UI: × button on each tag chip, faint by default, full opacity on hover;
  invalidates stats query so the tag disappears immediately

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:07:32 +02:00
Mattias Tall
a4cd32da4a Add PWA support and Google Takeout subscription import
- manifest.json + icon.svg for installability on mobile (standalone mode)
- index.html: theme-color, apple-mobile-web-app meta tags, manifest link
- Settings: Import CSV section reads Google Takeout subscriptions.csv,
  extracts UC... channel IDs, calls follow-bulk to follow them all at once

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:04:42 +02:00
Mattias Tall
219a388d72 Channel page: overlay avatar/name/buttons on banner
Name, subscriber count, and action buttons sit at the bottom of the
banner with a gradient overlay. Falls back to a plain dark header when
no banner is available. Description moves below the header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 10:58:31 +02:00
Mattias Tall
426e85c2c9 Mobile nav, channel banners, search channel linking
- Add bottom tab bar (Home/Following/Discover/Downloads/Settings) for mobile
- Fetch and display channel banner images on channel pages
- Fix ChannelCard: channels without a local DB id now follow+navigate on click

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 10:55:25 +02:00
Mattias Tall
ecb246ed99 Add yt-dlp diagnostic button to Settings page
Shows node version, yt-dlp version, cookie args, and raw stderr tail
to diagnose download/metadata failures without needing shell access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 10:45:28 +02:00
Mattias Tall
a33491fee0 Fix login: don't intercept 401 on auth/login itself
A failed login attempt was triggering the global 401 interceptor which
silently redirected back to /login, making the form appear broken.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 10:40:33 +02:00
Mattias Tall
fb2e42051e Fix Watch page crash: import getQueue
getQueue was called on the watch page but missing from the import list,
causing a ReferenceError that broke the entire /watch/:id route.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 10:23:24 +02:00
Mattias Tall
98d986cd95 Fix cookie fallback breaking yt-dlp in Docker; add OAuth2 auth flow
- _cookie_args() no longer falls through to --cookies-from-browser when
  cookies_file is configured but missing. Firefox isn't installed in the
  Docker image, so that fallback caused yt-dlp to exit with empty stdout
  and every metadata fetch to return "Video not found on YouTube".
- fetch_video_metadata() now retries without auth args if the first call
  fails, so a broken cookie config can't block public video fetches.
- Add use_oauth2 setting + full device-auth flow (POST /settings/oauth2-init,
  GET /settings/oauth2-status) with OAuth2Section UI in Settings page.
- Add GET /settings/ytdlp-test diagnostics endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 09:53:02 +02:00
inputnoise
5f5ca52b95 Add cookies.txt upload UI — drag/drop or click to upload, stored in data volume 2026-05-25 21:01:02 +02:00
inputnoise
56dd5f8360 Add cookies file support for Docker; auto-detect /data/cookies.txt 2026-05-25 20:57:04 +02:00
inputnoise
2a2ba0f41c Proxy /files/ through nginx to backend for video playback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 20:32:16 +02:00
inputnoise
1827dd6c4e Initial commit — YT Hub
Self-hosted personal YouTube management app.
FastAPI + SQLite backend, React + Vite + Tailwind frontend.
Dockerfiles and compose included for Portainer deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 20:09:04 +02:00