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>
This commit is contained in:
vrubelroman 2026-09-16 20:23:33 +00:00
parent 62f8fb746b
commit b29bcfb8d7
7 changed files with 127 additions and 3 deletions

View file

@ -2,13 +2,14 @@ import base64
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.auth_dependency import require_session
from app.db import get_db
from app.models.channel import Channel
from app.models.channel_category import channel_categories
from app.models.download_job import DownloadJob
from app.models.video import Video
from app.services.download_jobs import latest_jobs_map
from app.services.video_presentation import channel_categories_map, serialize_video
@ -41,6 +42,7 @@ def get_feed(
category_id: int | None = None,
uncategorized: bool = False,
channel_id: int | None = None,
downloaded: bool = False,
limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT),
cursor: str | None = None,
db: Session = Depends(get_db),
@ -58,6 +60,15 @@ def get_feed(
)
query = query.filter(Video.channel_id.in_(channel_ids_in_category))
if downloaded:
# A video may have several download_jobs over time (retries,
# redownload after delete); only its most recent one counts.
latest_job_ids = select(func.max(DownloadJob.id)).group_by(DownloadJob.video_id)
completed_video_ids = select(DownloadJob.video_id).where(
DownloadJob.id.in_(latest_job_ids), DownloadJob.status == "completed"
)
query = query.filter(Video.id.in_(completed_video_ids))
if cursor:
cursor_published_at, cursor_id = _decode_cursor(cursor)
query = query.filter(