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() {
Каналы
Лента
+
Сохранённые
Категории