diff --git a/backend/app/api/feed.py b/backend/app/api/feed.py index 9391d79..2c9c596 100644 --- a/backend/app/api/feed.py +++ b/backend/app/api/feed.py @@ -37,6 +37,42 @@ def _decode_cursor(cursor: str) -> tuple[datetime, int]: raise HTTPException(status_code=400, detail="Invalid cursor") +def _completed_video_ids(): + # 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) + return select(DownloadJob.video_id).where( + DownloadJob.id.in_(latest_job_ids), DownloadJob.status == "completed" + ) + + +@router.get("/feed/saved-counts") +def get_saved_counts(db: Session = Depends(get_db)) -> dict: + """Per-category counts of saved (downloaded) videos, for the Saved + page's sidebar -- distinct from /api/categories' channel_count.""" + completed_video_ids = _completed_video_ids() + + all_count = db.query(Video).filter(Video.id.in_(completed_video_ids)).count() + + categorized_channel_ids = select(channel_categories.c.channel_id) + uncategorized_count = ( + db.query(Video) + .filter(Video.id.in_(completed_video_ids), ~Video.channel_id.in_(categorized_channel_ids)) + .count() + ) + + rows = ( + db.query(channel_categories.c.category_id, func.count(func.distinct(Video.id))) + .join(Video, Video.channel_id == channel_categories.c.channel_id) + .filter(Video.id.in_(completed_video_ids)) + .group_by(channel_categories.c.category_id) + .all() + ) + categories = {str(category_id): count for category_id, count in rows} + + return {"all": all_count, "uncategorized": uncategorized_count, "categories": categories} + + @router.get("/feed") def get_feed( category_id: int | None = None, @@ -61,13 +97,7 @@ 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)) + query = query.filter(Video.id.in_(_completed_video_ids())) if cursor: cursor_published_at, cursor_id = _decode_cursor(cursor) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 176e4bd..ce49d90 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -217,3 +217,13 @@ export function getFeed( const suffix = qs.toString() ? `?${qs.toString()}` : '' return request<{ items: FeedVideoDto[]; next_cursor: string | null }>(`/api/feed${suffix}`) } + +export interface SavedCounts { + all: number + uncategorized: number + categories: Record +} + +export function getSavedCounts() { + return request('/api/feed/saved-counts') +} diff --git a/frontend/src/components/CategoryNav.tsx b/frontend/src/components/CategoryNav.tsx index d808533..de3bfb6 100644 --- a/frontend/src/components/CategoryNav.tsx +++ b/frontend/src/components/CategoryNav.tsx @@ -8,9 +8,12 @@ interface Props { onChange: (filter: CategoryFilter) => void allCount?: number uncategorizedCount?: number + // Overrides each category's displayed count (channel_count by default) -- + // e.g. the Saved page shows saved-video counts instead. + categoryCounts?: Record } -function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount }: Props) { +function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount, categoryCounts }: Props) { return ( diff --git a/frontend/src/pages/Saved.tsx b/frontend/src/pages/Saved.tsx index b823bbf..85c251b 100644 --- a/frontend/src/pages/Saved.tsx +++ b/frontend/src/pages/Saved.tsx @@ -1,6 +1,6 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { Link } from 'react-router-dom' -import { getFeed, listCategories, listChannels } from '../api/client' +import { getFeed, getSavedCounts, listCategories } from '../api/client' import CategoryNav, { type CategoryFilter } from '../components/CategoryNav' import VideoCard from '../components/VideoCard' import { useState } from 'react' @@ -11,12 +11,13 @@ function Saved() { const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories }) const categories = categoriesQuery.data ?? [] - const allChannelsQuery = useQuery({ - queryKey: ['channels', 'all-for-counts'], - queryFn: () => listChannels({ subscribed: true }), - }) - const allChannels = allChannelsQuery.data ?? [] - const uncategorizedCount = allChannels.filter((c) => c.category_ids.length === 0).length + // Keyed under 'feed' so DownloadButton's existing invalidateQueries({ + // queryKey: ['feed'] }) on download/delete also refreshes these counts. + const savedCountsQuery = useQuery({ queryKey: ['feed', 'saved-counts'], queryFn: getSavedCounts }) + const savedCounts = savedCountsQuery.data + const categoryCounts = Object.fromEntries( + Object.entries(savedCounts?.categories ?? {}).map(([id, count]) => [Number(id), count]), + ) const feedQuery = useInfiniteQuery({ queryKey: ['feed', 'saved', filter], @@ -49,8 +50,9 @@ function Saved() { categories={categories} value={filter} onChange={setFilter} - allCount={allChannels.length} - uncategorizedCount={uncategorizedCount} + allCount={savedCounts?.all} + uncategorizedCount={savedCounts?.uncategorized} + categoryCounts={categoryCounts} />
diff --git a/tests/test_feed.py b/tests/test_feed.py index a451491..bace6fc 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -139,6 +139,26 @@ def test_feed_filters_downloaded_uses_latest_job_per_video(client, db_session): assert resp["items"] == [] +def test_saved_counts(client, db_session): + channel_a, channel_b, category, videos = _seed(db_session) + + # vid0 is on channel_a (in "Linux"), vid1 is on channel_b (uncategorized). + db_session.add_all( + [ + DownloadJob(video_id=videos[0].id, status="completed", media_url="http://x/0.mp4"), + DownloadJob(video_id=videos[1].id, status="completed", media_url="http://x/1.mp4"), + DownloadJob(video_id=videos[2].id, status="failed"), + ] + ) + db_session.commit() + + resp = client.get("/api/feed/saved-counts").json() + + assert resp["all"] == 2 + assert resp["uncategorized"] == 1 + assert resp["categories"] == {str(category.id): 1} + + def test_feed_item_shape(client, db_session): channel_a, _, category, _ = _seed(db_session)