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>
This commit is contained in:
parent
44f81f0f0d
commit
90eb012e0d
5 changed files with 85 additions and 18 deletions
|
|
@ -37,6 +37,42 @@ def _decode_cursor(cursor: str) -> tuple[datetime, int]:
|
||||||
raise HTTPException(status_code=400, detail="Invalid cursor")
|
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")
|
@router.get("/feed")
|
||||||
def get_feed(
|
def get_feed(
|
||||||
category_id: int | None = None,
|
category_id: int | None = None,
|
||||||
|
|
@ -61,13 +97,7 @@ def get_feed(
|
||||||
query = query.filter(Video.channel_id.in_(channel_ids_in_category))
|
query = query.filter(Video.channel_id.in_(channel_ids_in_category))
|
||||||
|
|
||||||
if downloaded:
|
if downloaded:
|
||||||
# A video may have several download_jobs over time (retries,
|
query = query.filter(Video.id.in_(_completed_video_ids()))
|
||||||
# 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:
|
if cursor:
|
||||||
cursor_published_at, cursor_id = _decode_cursor(cursor)
|
cursor_published_at, cursor_id = _decode_cursor(cursor)
|
||||||
|
|
|
||||||
|
|
@ -217,3 +217,13 @@ export function getFeed(
|
||||||
const suffix = qs.toString() ? `?${qs.toString()}` : ''
|
const suffix = qs.toString() ? `?${qs.toString()}` : ''
|
||||||
return request<{ items: FeedVideoDto[]; next_cursor: string | null }>(`/api/feed${suffix}`)
|
return request<{ items: FeedVideoDto[]; next_cursor: string | null }>(`/api/feed${suffix}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SavedCounts {
|
||||||
|
all: number
|
||||||
|
uncategorized: number
|
||||||
|
categories: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSavedCounts() {
|
||||||
|
return request<SavedCounts>('/api/feed/saved-counts')
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,12 @@ interface Props {
|
||||||
onChange: (filter: CategoryFilter) => void
|
onChange: (filter: CategoryFilter) => void
|
||||||
allCount?: number
|
allCount?: number
|
||||||
uncategorizedCount?: 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<number, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount }: Props) {
|
function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount, categoryCounts }: Props) {
|
||||||
return (
|
return (
|
||||||
<nav className="category-nav">
|
<nav className="category-nav">
|
||||||
<button className={value === 'all' ? 'active' : ''} onClick={() => onChange('all')}>
|
<button className={value === 'all' ? 'active' : ''} onClick={() => onChange('all')}>
|
||||||
|
|
@ -28,7 +31,9 @@ function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount
|
||||||
onClick={() => onChange(category.id)}
|
onClick={() => onChange(category.id)}
|
||||||
>
|
>
|
||||||
{category.name}
|
{category.name}
|
||||||
<span className="category-nav-count">{category.channel_count}</span>
|
<span className="category-nav-count">
|
||||||
|
{categoryCounts ? categoryCounts[category.id] ?? 0 : category.channel_count}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||||
import { Link } from 'react-router-dom'
|
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 CategoryNav, { type CategoryFilter } from '../components/CategoryNav'
|
||||||
import VideoCard from '../components/VideoCard'
|
import VideoCard from '../components/VideoCard'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
@ -11,12 +11,13 @@ function Saved() {
|
||||||
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
|
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
|
||||||
const categories = categoriesQuery.data ?? []
|
const categories = categoriesQuery.data ?? []
|
||||||
|
|
||||||
const allChannelsQuery = useQuery({
|
// Keyed under 'feed' so DownloadButton's existing invalidateQueries({
|
||||||
queryKey: ['channels', 'all-for-counts'],
|
// queryKey: ['feed'] }) on download/delete also refreshes these counts.
|
||||||
queryFn: () => listChannels({ subscribed: true }),
|
const savedCountsQuery = useQuery({ queryKey: ['feed', 'saved-counts'], queryFn: getSavedCounts })
|
||||||
})
|
const savedCounts = savedCountsQuery.data
|
||||||
const allChannels = allChannelsQuery.data ?? []
|
const categoryCounts = Object.fromEntries(
|
||||||
const uncategorizedCount = allChannels.filter((c) => c.category_ids.length === 0).length
|
Object.entries(savedCounts?.categories ?? {}).map(([id, count]) => [Number(id), count]),
|
||||||
|
)
|
||||||
|
|
||||||
const feedQuery = useInfiniteQuery({
|
const feedQuery = useInfiniteQuery({
|
||||||
queryKey: ['feed', 'saved', filter],
|
queryKey: ['feed', 'saved', filter],
|
||||||
|
|
@ -49,8 +50,9 @@ function Saved() {
|
||||||
categories={categories}
|
categories={categories}
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={setFilter}
|
onChange={setFilter}
|
||||||
allCount={allChannels.length}
|
allCount={savedCounts?.all}
|
||||||
uncategorizedCount={uncategorizedCount}
|
uncategorizedCount={savedCounts?.uncategorized}
|
||||||
|
categoryCounts={categoryCounts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="content">
|
<div className="content">
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,26 @@ def test_feed_filters_downloaded_uses_latest_job_per_video(client, db_session):
|
||||||
assert resp["items"] == []
|
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):
|
def test_feed_item_shape(client, db_session):
|
||||||
channel_a, _, category, _ = _seed(db_session)
|
channel_a, _, category, _ = _seed(db_session)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue