Commit graph

9 commits

Author SHA1 Message Date
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
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
62f8fb746b Fix delete not finding the download: wrong id sent to MeTube's /delete
MeTube's queue/pending/done stores are keyed by the download's URL
(PersistentQueue.put: key = value.info.url), not by the id field a Download
reports over Socket.IO (which is what we stored as metube_job_id and were
sending). Sending the wrong key made MeTube's clear()/cancel() silently
no-op ("requested delete for non-existent download" in its own logs) while
still returning {"status": "ok"} regardless -- confirmed live by curling
/history on the real instance and finding the "deleted" entry still
present, unrelated to the DELETE_FILE_ON_TRASHCAN config fix that came
right before this.

delete_download() now takes the video's canonical youtube_url instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:14:43 +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
e333296170 Fix download status flapping: only the 'completed' event is terminal
Production logs showed a job oscillating downloading -> completed ->
downloading -> completed -> postprocessing -> completed. Cause: yt-dlp
reports status='finished' via progress-hook ticks once per stream when
downloading separate video+audio for muxing (video lands, audio is still
in flight), and MeTube forwards that through 'updated' Socket.IO events
too -- we were mapping any status='finished' to our "completed", regardless
of which event carried it.

Only the dedicated 'completed' event (and the 'done' bucket of MeTube's
/history, for startup reconciliation) is now treated as authoritative for
terminal status; a transient 'finished' arriving via 'added'/'updated'
(or history's queue/pending) is a no-op for status, matching the
progress_percent update it also carries.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 19:09:18 +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