diff --git a/backend/routers/channels.py b/backend/routers/channels.py index 3453c51..d8b3014 100644 --- a/backend/routers/channels.py +++ b/backend/routers/channels.py @@ -758,6 +758,54 @@ def index_channel_full( return {"detail": "Full index started"} +@router.post("/{channel_id}/explore", status_code=status.HTTP_202_ACCEPTED) +def explore_channel_older( + channel_id: int, + page: int = 2, + background_tasks: BackgroundTasks = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Fetch a page of older videos from this channel (page 1 = newest 30, page 2 = next 100, etc.).""" + _get_channel_or_404(db, channel_id) + start = 1 if page <= 1 else (30 + (page - 2) * 100 + 1) + background_tasks.add_task(_index_channel_explore_task, channel_id, current_user.id, start, 100) + return {"detail": f"Fetching older videos (page {page})", "start": start} + + +def _index_channel_explore_task(channel_id: int, user_id: int, start_video: int, count: int): + from ..database import SessionLocal + db = SessionLocal() + try: + channel = db.query(Channel).filter_by(id=channel_id).first() + if not channel: + return + result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id, max_videos=count, start_video=start_video) + if not result: + return + for vdata in result.get("videos", []): + yt_id = vdata.get("youtube_video_id") + if not yt_id or not vdata.get("published_at"): + continue + if not db.query(Video).filter_by(youtube_video_id=yt_id).first(): + db.add(Video( + youtube_video_id=yt_id, + channel_id=channel.id, + title=vdata.get("title", ""), + description=vdata.get("description"), + thumbnail_url=vdata.get("thumbnail_url"), + duration_seconds=vdata.get("duration_seconds"), + published_at=vdata.get("published_at"), + tags=vdata.get("tags"), + category=vdata.get("category"), + )) + db.commit() + except Exception: + db.rollback() + finally: + db.close() + + @router.post("/follow-bulk", status_code=200) def follow_bulk( body: dict, diff --git a/backend/routers/videos.py b/backend/routers/videos.py index b94f841..1dc34fc 100644 --- a/backend/routers/videos.py +++ b/backend/routers/videos.py @@ -208,6 +208,58 @@ def home_feed( for r in rows ] + if mode == "rediscover": + # Older unwatched videos from followed channels, ranked by tag affinity + affinity_rows = db.execute( + text("SELECT tag, score FROM user_tag_affinity WHERE user_id = :user_id AND score > 0"), + {"user_id": current_user.id}, + ).mappings().all() + affinity = {r["tag"]: r["score"] for r in affinity_rows} + + rows = db.execute( + text(f""" + SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url, + v.duration_seconds, v.published_at, v.tags, v.category, + c.id AS channel_id, c.name AS channel_name, + c.youtube_channel_id AS channel_youtube_id, + c.thumbnail_url AS channel_thumbnail_url, + COALESCE(uv.watched, 0) AS watched, + COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds, + COALESCE(uv.downloaded, 0) AS is_downloaded, + COALESCE(uv.queued, 0) AS queued, + NULL AS file_path + FROM videos v + JOIN channels c ON v.channel_id = c.id + JOIN user_channels uc + ON c.id = uc.channel_id AND uc.user_id = :user_id AND uc.status = 'followed' + LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id + WHERE COALESCE(uv.watched, 0) = 0 + AND v.published_at < datetime('now', '-90 days') + AND (uc.muted_until IS NULL OR datetime(uc.muted_until) < datetime('now')) + {duration_clause} + ORDER BY RANDOM() + LIMIT :limit OFFSET :offset + """), + {"user_id": current_user.id, "limit": min(limit * 4, 200), "offset": offset}, + ).mappings().all() + + if affinity: + import json as _json + def _affinity_score(row): + try: + tags = _json.loads(row["tags"] or "[]") + return sum(affinity.get(t.lower().strip(), 0) for t in tags if isinstance(t, str)) + except Exception: + return 0 + rows = sorted(rows, key=_affinity_score, reverse=True) + + rows = rows[:limit] + return [ + VideoDetail(**{k: v for k, v in dict(r).items() if k not in ("watched",)}, + is_watched=bool(r["watched"])) + for r in rows + ] + if mode == "inbox": rows = db.execute( text(f""" diff --git a/backend/services/ytdlp.py b/backend/services/ytdlp.py index 4f1d550..679aaa5 100644 --- a/backend/services/ytdlp.py +++ b/backend/services/ytdlp.py @@ -287,7 +287,7 @@ def _rss_dates(uc_channel_id: str) -> dict[str, datetime]: return {} -def fetch_channel_metadata(channel_id: str, max_videos: int = 30) -> dict | None: +def fetch_channel_metadata(channel_id: str, max_videos: int = 30, start_video: int = 1) -> dict | None: """Fetch channel info + recent videos. Uses --dump-single-json --flat-playlist for speed, then enriches video dates @@ -304,8 +304,11 @@ def fetch_channel_metadata(channel_id: str, max_videos: int = 30) -> dict | None "--quiet", *_cookie_args(), ] + if start_video > 1: + args += ["--playlist-start", str(start_video)] if max_videos > 0: - args += ["--playlist-end", str(max_videos)] + end = (start_video - 1 + max_videos) if start_video > 1 else max_videos + args += ["--playlist-end", str(end)] stdout, _, code = _run(args, timeout=60) if not stdout.strip(): diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index b0e917d..1251d14 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -48,6 +48,7 @@ export const followChannel = (id) => api.post(`/channels/${id}/follow`); export const unfollowChannel = (id) => api.delete(`/channels/${id}/follow`); export const indexChannel = (id) => api.post(`/channels/${id}/index`); export const indexChannelFull = (id) => api.post(`/channels/${id}/index-full`); +export const exploreChannelOlder = (id, page) => api.post(`/channels/${id}/explore`, null, { params: { page } }); export const followChannelByUrl = (data) => api.post("/channels/follow-by-url", data); export const setChannelAutoDownload = (id, value) => api.patch(`/channels/${id}/auto-download`, { auto_download: value }); export const markChannelsSeen = () => api.post("/channels/mark-seen"); diff --git a/frontend/src/pages/Channel.jsx b/frontend/src/pages/Channel.jsx index 92e8501..923d8c6 100644 --- a/frontend/src/pages/Channel.jsx +++ b/frontend/src/pages/Channel.jsx @@ -3,7 +3,7 @@ import { useParams } from "react-router-dom"; import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from "@tanstack/react-query"; import { getChannel, getChannelVideos, searchChannelYoutube, - followChannel, unfollowChannel, indexChannel, indexChannelFull, downloadChannel, + followChannel, unfollowChannel, indexChannel, indexChannelFull, exploreChannelOlder, downloadChannel, } from "../api"; import VideoCard from "../components/VideoCard"; @@ -30,6 +30,7 @@ export default function ChannelPage() { const [search, setSearch] = useState(""); const [activeQ, setActiveQ] = useState(""); const [indexing, setIndexing] = useState(false); + const [explorePage, setExplorePage] = useState(2); const searchInputRef = useRef(null); const { data: channel, isLoading: loadingChannel } = useQuery({ @@ -94,6 +95,14 @@ export default function ChannelPage() { onSuccess: () => scheduleRefetch(45000), }); + const exploreMut = useMutation({ + mutationFn: () => exploreChannelOlder(id, explorePage), + onSuccess: () => { + setExplorePage(p => p + 1); + scheduleRefetch(20000); + }, + }); + const deepSearchMut = useMutation({ mutationFn: () => searchChannelYoutube(id, activeQ || search), onSuccess: () => scheduleRefetch(20000), @@ -130,7 +139,7 @@ export default function ChannelPage() { if (!channel) return
Channel not found.
; const isFollowed = channel.status === "followed"; - const isPending = indexMut.isPending || fullIndexMut.isPending || deepSearchMut.isPending || indexing; + const isPending = indexMut.isPending || fullIndexMut.isPending || deepSearchMut.isPending || exploreMut.isPending || indexing; return (All {videos.length} indexed videos shown
- +{videos.length} videos indexed
+