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 datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.auth_dependency import require_session from app.core.auth_dependency import require_session
from app.db import get_db from app.db import get_db
from app.models.channel import Channel from app.models.channel import Channel
from app.models.channel_category import channel_categories from app.models.channel_category import channel_categories
from app.models.download_job import DownloadJob
from app.models.video import Video from app.models.video import Video
from app.services.download_jobs import latest_jobs_map from app.services.download_jobs import latest_jobs_map
from app.services.video_presentation import channel_categories_map, serialize_video from app.services.video_presentation import channel_categories_map, serialize_video
@ -41,6 +42,7 @@ def get_feed(
category_id: int | None = None, category_id: int | None = None,
uncategorized: bool = False, uncategorized: bool = False,
channel_id: int | None = None, channel_id: int | None = None,
downloaded: bool = False,
limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT),
cursor: str | None = None, cursor: str | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@ -58,6 +60,15 @@ 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:
# 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: if cursor:
cursor_published_at, cursor_id = _decode_cursor(cursor) cursor_published_at, cursor_id = _decode_cursor(cursor)
query = query.filter( query = query.filter(

View file

@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom'
import { getAuthStatus } from './api/client' import { getAuthStatus } from './api/client'
import Connect from './pages/Connect' import Connect from './pages/Connect'
import Feed from './pages/Feed' import Feed from './pages/Feed'
import Saved from './pages/Saved'
import Channels from './pages/Channels' import Channels from './pages/Channels'
import ChannelVideos from './pages/ChannelVideos' import ChannelVideos from './pages/ChannelVideos'
import VideoPage from './pages/VideoPage' import VideoPage from './pages/VideoPage'
@ -43,6 +44,7 @@ function App() {
<div className="app"> <div className="app">
<Routes> <Routes>
<Route path="/" element={<Feed />} /> <Route path="/" element={<Feed />} />
<Route path="/saved" element={<Saved />} />
<Route path="/channels" element={<Channels />} /> <Route path="/channels" element={<Channels />} />
<Route path="/channels/:channelId/videos" element={<ChannelVideos />} /> <Route path="/channels/:channelId/videos" element={<ChannelVideos />} />
<Route path="/video/:youtubeVideoId" element={<VideoPage />} /> <Route path="/video/:youtubeVideoId" element={<VideoPage />} />

View file

@ -64,12 +64,13 @@ export function getChannel(channelId: number) {
} }
export function listChannels( export function listChannels(
params: { search?: string; categoryId?: number; uncategorized?: boolean } = {}, params: { search?: string; categoryId?: number; uncategorized?: boolean; subscribed?: boolean } = {},
) { ) {
const qs = new URLSearchParams() const qs = new URLSearchParams()
if (params.search) qs.set('search', params.search) if (params.search) qs.set('search', params.search)
if (params.categoryId != null) qs.set('category_id', String(params.categoryId)) if (params.categoryId != null) qs.set('category_id', String(params.categoryId))
if (params.uncategorized) qs.set('uncategorized', 'true') if (params.uncategorized) qs.set('uncategorized', 'true')
if (params.subscribed != null) qs.set('subscribed', String(params.subscribed))
const suffix = qs.toString() ? `?${qs.toString()}` : '' const suffix = qs.toString() ? `?${qs.toString()}` : ''
return request<ChannelDto[]>(`/api/channels${suffix}`) return request<ChannelDto[]>(`/api/channels${suffix}`)
} }
@ -199,12 +200,19 @@ export function recheckLocal(youtubeVideoId: string) {
} }
export function getFeed( 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() const qs = new URLSearchParams()
if (params.categoryId != null) qs.set('category_id', String(params.categoryId)) if (params.categoryId != null) qs.set('category_id', String(params.categoryId))
if (params.uncategorized) qs.set('uncategorized', 'true') if (params.uncategorized) qs.set('uncategorized', 'true')
if (params.channelId != null) qs.set('channel_id', String(params.channelId)) 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) if (params.cursor) qs.set('cursor', params.cursor)
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}`)

View file

@ -16,6 +16,7 @@ function Channels() {
queryKey: ['channels', filter], queryKey: ['channels', filter],
queryFn: () => queryFn: () =>
listChannels({ listChannels({
subscribed: true,
categoryId: typeof filter === 'number' ? filter : undefined, categoryId: typeof filter === 'number' ? filter : undefined,
uncategorized: filter === 'uncategorized', uncategorized: filter === 'uncategorized',
}), }),
@ -58,6 +59,7 @@ function Channels() {
<h1>Каналы</h1> <h1>Каналы</h1>
<div className="actions"> <div className="actions">
<Link to="/">Лента</Link> <Link to="/">Лента</Link>
<Link to="/saved">Сохранённые</Link>
<Link to="/categories">Категории</Link> <Link to="/categories">Категории</Link>
<button onClick={() => syncMutation.mutate()} disabled={running}> <button onClick={() => syncMutation.mutate()} disabled={running}>
{running ? 'Синхронизация...' : 'Обновить сейчас'} {running ? 'Синхронизация...' : 'Обновить сейчас'}

View file

@ -53,6 +53,7 @@ function Feed() {
<header> <header>
<h1>Лента</h1> <h1>Лента</h1>
<div className="actions"> <div className="actions">
<Link to="/saved">Сохранённые</Link>
<Link to="/channels">Каналы</Link> <Link to="/channels">Каналы</Link>
<Link to="/categories">Категории</Link> <Link to="/categories">Категории</Link>
<button onClick={() => syncMutation.mutate()} disabled={running}> <button onClick={() => syncMutation.mutate()} disabled={running}>

View file

@ -0,0 +1,70 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { getFeed, listCategories } from '../api/client'
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav'
import VideoCard from '../components/VideoCard'
import { useState } from 'react'
function Saved() {
const [filter, setFilter] = useState<CategoryFilter>('all')
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const categories = categoriesQuery.data ?? []
const feedQuery = useInfiniteQuery({
queryKey: ['feed', 'saved', filter],
queryFn: ({ pageParam }) =>
getFeed({
categoryId: typeof filter === 'number' ? filter : undefined,
uncategorized: filter === 'uncategorized',
downloaded: true,
cursor: pageParam,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
})
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
return (
<div className="feed-screen">
<header>
<h1>Сохранённые</h1>
<div className="actions">
<Link to="/">Лента</Link>
<Link to="/channels">Каналы</Link>
<Link to="/categories">Категории</Link>
</div>
</header>
<div className="layout">
<CategoryNav categories={categories} value={filter} onChange={setFilter} />
<div className="content">
{feedQuery.isLoading && <p>Загрузка...</p>}
{feedQuery.isError && <p className="error">Не удалось загрузить сохранённые видео</p>}
<ul className="video-grid">
{items.map((video) => (
<VideoCard key={video.youtube_video_id} video={video} />
))}
</ul>
{!feedQuery.isLoading && items.length === 0 && <p>Здесь пока пусто. Скачай видео из ленты.</p>}
{feedQuery.hasNextPage && (
<button
className="load-more"
onClick={() => feedQuery.fetchNextPage()}
disabled={feedQuery.isFetchingNextPage}
>
{feedQuery.isFetchingNextPage ? 'Загрузка...' : 'Показать ещё'}
</button>
)}
</div>
</div>
</div>
)
}
export default Saved

View file

@ -9,6 +9,7 @@ from app.main import app
from app.models.category import Category from app.models.category import Category
from app.models.channel import Channel from app.models.channel import Channel
from app.models.channel_category import channel_categories from app.models.channel_category import channel_categories
from app.models.download_job import DownloadJob
from app.models.video import Video from app.models.video import Video
@ -109,6 +110,35 @@ def test_feed_filters_uncategorized(client, db_session):
assert ids == {"vid1", "vid3"} 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): def test_feed_item_shape(client, db_session):
channel_a, _, category, _ = _seed(db_session) channel_a, _, category, _ = _seed(db_session)