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:
parent
62f8fb746b
commit
b29bcfb8d7
7 changed files with 127 additions and 3 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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}`)
|
||||||
|
|
|
||||||
|
|
@ -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 ? 'Синхронизация...' : 'Обновить сейчас'}
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
70
frontend/src/pages/Saved.tsx
Normal file
70
frontend/src/pages/Saved.tsx
Normal 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
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue