From b29bcfb8d7bc6f8c33dfc17d4ffd743132442006 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Wed, 16 Sep 2026 20:23:33 +0000 Subject: [PATCH] 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 --- backend/app/api/feed.py | 13 +++++- frontend/src/App.tsx | 2 + frontend/src/api/client.ts | 12 +++++- frontend/src/pages/Channels.tsx | 2 + frontend/src/pages/Feed.tsx | 1 + frontend/src/pages/Saved.tsx | 70 +++++++++++++++++++++++++++++++++ tests/test_feed.py | 30 ++++++++++++++ 7 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 frontend/src/pages/Saved.tsx diff --git a/backend/app/api/feed.py b/backend/app/api/feed.py index 73600b7..9391d79 100644 --- a/backend/app/api/feed.py +++ b/backend/app/api/feed.py @@ -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( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 391ffb8..beccbf6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom' import { getAuthStatus } from './api/client' import Connect from './pages/Connect' import Feed from './pages/Feed' +import Saved from './pages/Saved' import Channels from './pages/Channels' import ChannelVideos from './pages/ChannelVideos' import VideoPage from './pages/VideoPage' @@ -43,6 +44,7 @@ function App() {
} /> + } /> } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5e04728..176e4bd 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -64,12 +64,13 @@ export function getChannel(channelId: number) { } export function listChannels( - params: { search?: string; categoryId?: number; uncategorized?: boolean } = {}, + params: { search?: string; categoryId?: number; uncategorized?: boolean; subscribed?: boolean } = {}, ) { const qs = new URLSearchParams() if (params.search) qs.set('search', params.search) if (params.categoryId != null) qs.set('category_id', String(params.categoryId)) if (params.uncategorized) qs.set('uncategorized', 'true') + if (params.subscribed != null) qs.set('subscribed', String(params.subscribed)) const suffix = qs.toString() ? `?${qs.toString()}` : '' return request(`/api/channels${suffix}`) } @@ -199,12 +200,19 @@ export function recheckLocal(youtubeVideoId: string) { } export function getFeed( - params: { categoryId?: number; uncategorized?: boolean; channelId?: number; cursor?: string } = {}, + params: { + categoryId?: number + uncategorized?: boolean + channelId?: number + downloaded?: boolean + cursor?: string + } = {}, ) { const qs = new URLSearchParams() if (params.categoryId != null) qs.set('category_id', String(params.categoryId)) if (params.uncategorized) qs.set('uncategorized', 'true') if (params.channelId != null) qs.set('channel_id', String(params.channelId)) + if (params.downloaded) qs.set('downloaded', 'true') if (params.cursor) qs.set('cursor', params.cursor) const suffix = qs.toString() ? `?${qs.toString()}` : '' return request<{ items: FeedVideoDto[]; next_cursor: string | null }>(`/api/feed${suffix}`) diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index 8d4420b..babc3be 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -16,6 +16,7 @@ function Channels() { queryKey: ['channels', filter], queryFn: () => listChannels({ + subscribed: true, categoryId: typeof filter === 'number' ? filter : undefined, uncategorized: filter === 'uncategorized', }), @@ -58,6 +59,7 @@ function Channels() {

Каналы

Лента + Сохранённые Категории + )} +
+
+ + ) +} + +export default Saved diff --git a/tests/test_feed.py b/tests/test_feed.py index 1e539a5..a451491 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -9,6 +9,7 @@ from app.main import app from app.models.category import Category 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 @@ -109,6 +110,35 @@ def test_feed_filters_uncategorized(client, db_session): assert ids == {"vid1", "vid3"} +def test_feed_filters_downloaded(client, db_session): + _, _, _, videos = _seed(db_session) + + completed = DownloadJob(video_id=videos[0].id, status="completed", media_url="http://x/f0.mp4") + failed = DownloadJob(video_id=videos[1].id, status="failed") + db_session.add_all([completed, failed]) + db_session.commit() + + resp = client.get("/api/feed?downloaded=true").json() + + ids = {i["youtube_video_id"] for i in resp["items"]} + assert ids == {"vid0"} + + +def test_feed_filters_downloaded_uses_latest_job_per_video(client, db_session): + _, _, _, videos = _seed(db_session) + + older = DownloadJob(video_id=videos[0].id, status="completed", media_url="http://x/f0.mp4") + db_session.add(older) + db_session.commit() + newer = DownloadJob(video_id=videos[0].id, status="deleted") + db_session.add(newer) + db_session.commit() + + resp = client.get("/api/feed?downloaded=true").json() + + assert resp["items"] == [] + + def test_feed_item_shape(client, db_session): channel_a, _, category, _ = _seed(db_session)