Commit graph

15 commits

Author SHA1 Message Date
vrubelroman
fb6e639309 Remove watch/download buttons from feed video cards
Cards stay fully clickable via thumbnail and title; downloads are
managed from the video page. On the Saved page the download button
remains (status/delete), and the dead .button-link styles are removed.
2026-09-22 07:53:19 +00:00
vrubelroman
d4950f6979 Add channels view toggle on the main page
New GET /api/channels/activity returns subscribed channels sorted by
their latest video (no-video channels last) with the 3 newest videos
per channel and cursor pagination. The feed page gains a 'Лента |
Каналы' toggle (?view=channels) with compact video cards per channel
row; category filters and the sidebar preserve the view.
2026-09-18 12:13:32 +00:00
vrubelroman
0dc1336951 Move manual sync to a single 'Update all' button in the header popover
The feed/channels 'Update' buttons triggered a global sync of all channels,
which was misleading inside a specific category or page context. Now one
'Update all' button in the sync indicator popover runs subscriptions then
videos sequentially, tolerates an already-running sync (409), and pages
keep their finished_at-based cache invalidation.
2026-09-18 09:39:18 +00:00
vrubelroman
e10df8dcbd Channel features: subscribers, new-videos badges, activity-driven sync
- Store subscriber count from YouTube statistics and show it on channel page
- Sync 50 videos per channel with playlistItems pagination support
- Show per-channel and per-category new-videos counters (2-day window)
- Replace hourly videos sync with activity trigger (2h idle) and
  incremental backfill (hard cap 200 per channel)
- Clicking the sidebar new-videos count filters the category feed to
  recent videos only (new_only)
- Update agent-team docs: deploy after green checks
2026-09-17 21:07:33 +00:00
vrubelroman
fde9a439df Fix review findings and remove Uncategorized from sidebar
Backend: cache Google access tokens (drop dead access_token_expires_at,
migration 0007), handle MeTube cleared/canceled events by URL, return
email from /auth/status only when authenticated, move Google base URLs
into settings, run container as non-root.

Frontend: include local feed filters in the query key, remove dead
Saved page and unused assets, drop stale CategoryNav props and classes,
send Content-Type only with a body, remove Uncategorized from sidebar.
2026-09-17 17:22:22 +00:00
vrubelroman
c4a772b1e3 Redesign responsive MyTube interface 2026-09-16 21:40:32 +00:00
vrubelroman
90eb012e0d Show saved-video counts (not channel counts) in the Saved page sidebar
GET /api/feed/saved-counts aggregates, per category, how many videos have
a completed download (using each video's latest job only, same rule as
the existing ?downloaded=true feed filter). CategoryNav gains an optional
categoryCounts override so Feed/Channels keep showing channel_count while
Saved shows this instead.

Query key ['feed', 'saved-counts'] deliberately nests under 'feed' so
DownloadButton's existing invalidateQueries({queryKey: ['feed']}) on
download/delete refreshes these counts too, with no extra wiring.

1 new backend test (77 total).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:41:20 +00:00
vrubelroman
44f81f0f0d Show channel counts in the left CategoryNav sidebar (Feed/Channels/Saved)
Same "channels per category" counts as the categories management page,
now next to each button in the sidebar used across all three pages.
Categories already carry channel_count; "Все"/"Без категории" counts are
derived client-side from one extra unfiltered channels query, reused
across the three pages via a shared ['channels', 'all-for-counts'] key.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:38:06 +00:00
vrubelroman
cdb1c2ded2 Show channel count for "Без категории" on the categories management page
Reuses the existing GET /api/channels?uncategorized=true endpoint client-side
rather than changing the /api/categories response shape, which stays a
plain array (kept several existing call sites simple).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:35:39 +00:00
vrubelroman
b29bcfb8d7 Hide unsubscribed channels from the channels list, add a Saved tab
- GET /api/channels called with subscribed=true from the Channels page, so
  a channel disappears from the list as soon as it's unsubscribed instead
  of lingering with a badge
- GET /api/feed gains a `downloaded` filter (this was already anticipated
  in the original TZ's feed query params but left unimplemented until
  download_jobs existed) -- matches on each video's *latest* job only, so
  a redownload/delete history doesn't leave stale matches
- New /saved route: same category filtering, video grid and download
  actions as the main feed, scoped to downloaded videos only

2 new backend tests (76 total).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:23:33 +00:00
vrubelroman
441a3ef9df Verify deletion actually removed the file before reporting success
MeTube's /delete only unlinks the file when it's configured with
DELETE_FILE_ON_TRASHCAN=true; otherwise it just drops the entry from its
own "done" list and returns {"status": "ok"} regardless -- we were trusting
that response and marking the job "deleted" (offering a re-download) while
the file was still sitting on mediaVM's disk the whole time.

Now HEAD-check the media_url right after the delete call. If the file is
still reachable, leave the job's status untouched (still "completed", still
playable) and surface a clear 409 explaining MeTube's own config is why,
rather than lying about local state.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:01:41 +00:00
vrubelroman
3089202316 Add delete-downloaded-video and real YouTube unsubscribe
Delete local copy:
- MeTubeClient.delete_download() -> POST /delete {ids, where: "done"}
- Only ever acts on a job our own app tracked (metube_job_id we stored from
  its own 'completed' event), never a pre-existing MeTube file
- New "deleted" terminal status; DELETE /api/videos/{id}/download
- Frontend: delete button next to "На сервере" badge, confirm dialog

Unsubscribe (deliberate deviation from the original TZ's MVP exclusion of
subscription management, per explicit user request after being shown the
tradeoff):
- OAuth scope widened from youtube.readonly to full youtube (read/write) --
  existing stored tokens only cover the old scope, so unsubscribing needs a
  fresh reconnect; reads keep working unchanged on the old token meanwhile
- channels.youtube_subscription_id (distinct from the channel id; that's
  what subscriptions.delete actually keys on) captured during subscriptions
  sync
- YouTubeInsufficientScope raised on 401/403 "insufficient authentication
  scopes" and surfaced as a clear 403 asking the user to reconnect, rather
  than a generic API error
- POST /api/channels/{id}/unsubscribe calls subscriptions.delete and marks
  the channel unsubscribed locally on success
- Frontend: "Отписаться" button on ChannelCard with confirm dialog

10 new backend tests (73 total).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 19:47:35 +00:00
vrubelroman
10c16ba2cb Fix DownloadButton reverting to "Скачать" after completion
The button disabled its own status polling once a download reached a
terminal state and fell back to the video prop it was first rendered with.
On the video detail page that prop only refreshes on a full page reload
(only ['feed'] was invalidated, not ['video', id]), so the button showed
"Скачать" again right after a real completion until the user refreshed.

Keep the polling query enabled permanently once a download starts tracking
(refetchInterval alone already stops the ticking on a terminal status) and
use its cache as the source of truth instead of the prop, so the button
reflects the fetched status directly rather than depending on an unrelated
query being invalidated and refetched in time.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 19:37:33 +00:00
vrubelroman
fe16c08daa Implement Phase 6: MeTube download integration
- MeTubeClient encapsulates all MeTube HTTP/Socket.IO calls (verified against
  the real MeTube source: /add returns no job id, GET /history gives a queue
  snapshot for reconciliation, filenames arrive already relative, percent is
  a 0-100 float)
- download_jobs table + service: request/dedup active downloads, apply live
  Socket.IO events (added/updated/completed/canceled/cleared) matched by
  canonical YouTube URL, safe relative-path -> public media URL construction
- Reconciliation on startup against MeTube's live queue/done state (section 19):
  non-terminal jobs recovered where possible, else marked "unknown"; already
  completed jobs are left untouched
- POST/GET /api/videos/{id}/download(-status), recheck-local; feed/video
  detail now report real local availability instead of a stub
- Frontend: download button with live status polling (queued/downloading %/
  postprocessing/completed/failed+retry), local <video> playback with
  YouTube fallback on playback error
- health.py now delegates to MeTubeClient (single place for MeTube calls)

26 new backend tests (63 total). Verified live: Socket.IO connects
successfully to the real MeTube instance on deploy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:56:17 +00:00
vrubelroman
0ed20bb838 Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
  pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching

40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00