Commit graph

25 commits

Author SHA1 Message Date
vrubelroman
43adec5224 Fix download status handling and the blank page after download
- Never let a non-authoritative MeTube 'updated' event downgrade a
  terminal job (it refilled the progress bar after completion).
- Self-heal stale active jobs against MeTube history on status polls,
  so a missed event no longer leaves a job stuck in 'queued'.
- Build YT.Player on an imperatively created child div: React keeps
  owning the container, so switching to the local copy after a
  download no longer throws removeChild and blanks the page.
- Autoplay on open and seek controls; subtitle experiment reverted.
2026-09-27 23:36:09 +00:00
vrubelroman
b1faeb3729 Autoplay video when opening the video page
Add autoPlay/playsInline to the local video element, autoplay: 1 to
the YouTube IFrame API playerVars, and ?autoplay=1 to the plain-iframe
fallback. Best-effort: browsers may block unmuted autoplay, so the
play button stays as fallback.
2026-09-25 12:06:11 +00:00
vrubelroman
a89f972afa Add seek controls to the player
- '−10s/+10s' buttons under the player for both modes: local video
  seeks directly, the YouTube embed now uses the official IFrame Player
  API (nocookie host, playsinline) with a plain-iframe fallback if the
  API fails to load.
- Double-tap left/right halves of the local player to seek ±10s with a
  transient indicator (announced via role=status).
2026-09-25 11:06:33 +00:00
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
5fa5a391d7 Remove AGENT_TEAM.md, add project baseline analytics
The agent team workflow lives in .opencode/agent role files; the
human-readable guide is redundant and removed. README and analyst role
updated accordingly. Added analytics baseline documenting the current
project state as a starting point for future tasks.
2026-09-17 21:42:30 +00:00
vrubelroman
f0ca7b97ca Add Analyst subagent: task analytics docs and targeted README updates
Analyst runs before Coder: records each task in analytics/*.md (appending
journal entries and revising only affected sections), updates README where
necessary, and reports ANALYST_DONE. Orchestrator flow updated:
task -> Analyst -> Coder -> Reviewer/Tester -> deploy -> report.
2026-09-17 21:18:36 +00:00
vrubelroman
2e5ef8e528 Expand .env.example with per-field comments and setup hints 2026-09-17 21:11:48 +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
6c704cac97 Switch agent team to opencode, drop Codex/Herdr multi-agent setup 2026-09-16 23:08:21 +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
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
8ddea8762d Fix OAuth token exchange failing on the widened scope
Google echoes back a token response scope string that's a superset/
reordering of what was requested (asking for "youtube" got back
"youtube.readonly youtube ..." too, since the broader scope implies the
narrower one) -- oauthlib does a strict string comparison and raised
"Warning: Scope has changed" on every reconnect attempt after the scope
was widened for the unsubscribe feature, even though Google's consent
screen had already granted access.

OAUTHLIB_RELAX_TOKEN_SCOPE=1 disables that check, matching what Google's
own behavior actually requires for any multi-scope request.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 19:52:23 +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
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