diff --git a/backend/routers/channels.py b/backend/routers/channels.py index d8b3014..5c1f6bc 100644 --- a/backend/routers/channels.py +++ b/backend/routers/channels.py @@ -62,6 +62,7 @@ class VideoOut(BaseModel): is_downloaded: bool = False is_watched: bool = False queued: bool = False + view_count: Optional[int] = None model_config = {"from_attributes": True} @@ -608,10 +609,11 @@ def get_channel_videos( ): _get_channel_or_404(db, channel_id) order = { - "newest": "v.published_at DESC NULLS LAST", - "oldest": "v.published_at ASC NULLS LAST", - "title": "v.title ASC", - "unwatched": "COALESCE(uv.watched, 0) ASC, v.published_at DESC NULLS LAST", + "newest": "v.published_at DESC NULLS LAST", + "oldest": "v.published_at ASC NULLS LAST", + "title": "v.title ASC", + "unwatched":"COALESCE(uv.watched, 0) ASC, v.published_at DESC NULLS LAST", + "popular": "v.view_count DESC NULLS LAST", }.get(sort, "v.published_at DESC NULLS LAST") params: dict = {"user_id": current_user.id, "channel_id": channel_id, "limit": limit, "offset": offset} q_clause = "" @@ -621,7 +623,7 @@ def get_channel_videos( rows = db.execute( text(f""" SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url, - v.duration_seconds, v.published_at, + v.duration_seconds, v.published_at, v.view_count, COALESCE(uv.downloaded, 0) AS is_downloaded, COALESCE(uv.watched, 0) AS is_watched FROM videos v @@ -635,6 +637,77 @@ def get_channel_videos( return [VideoOut(**dict(r)) for r in rows] +@router.post("/{channel_id}/fetch-popular", status_code=status.HTTP_202_ACCEPTED) +def fetch_popular_videos( + channel_id: int, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Fetch the channel's most popular videos from YouTube and index them.""" + channel = _get_channel_or_404(db, channel_id) + background_tasks.add_task(_fetch_popular_task, channel_id, channel.youtube_channel_id) + return {"detail": "Fetching popular videos"} + + +def _fetch_popular_task(channel_id: int, youtube_channel_id: str): + from ..database import SessionLocal + db = SessionLocal() + try: + if youtube_channel_id.startswith("@"): + url = f"https://www.youtube.com/{youtube_channel_id}/videos?sort=p" + else: + url = f"https://www.youtube.com/channel/{youtube_channel_id}/videos?sort=p" + + stdout, _, code = ytdlp._run([ + "yt-dlp", url, + "--dump-json", "--flat-playlist", + "--playlist-end", "100", + "--quiet", + *ytdlp._cookie_args(), + ], timeout=120) + + channel = db.query(Channel).filter_by(id=channel_id).first() + if not channel: + return + + for line in stdout.splitlines(): + line = line.strip() + if not line: + continue + try: + info = json.loads(line) + except json.JSONDecodeError: + continue + yt_id = info.get("id") + if not yt_id: + continue + existing = db.query(Video).filter_by(youtube_video_id=yt_id).first() + view_count = info.get("view_count") + published_at = ytdlp._parse_published(info) + if existing: + if view_count is not None: + existing.view_count = view_count + if published_at and not existing.published_at: + existing.published_at = published_at + else: + db.add(Video( + youtube_video_id=yt_id, + channel_id=channel.id, + title=info.get("title", ""), + thumbnail_url=ytdlp._stable_thumbnail(yt_id), + duration_seconds=info.get("duration"), + published_at=published_at, + tags=json.dumps(info.get("tags") or []), + view_count=view_count, + )) + db.commit() + except Exception: + db.rollback() + finally: + db.close() + + @router.post("/{channel_id}/search", status_code=status.HTTP_202_ACCEPTED) def search_channel_youtube( channel_id: int, diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 1251d14..720221f 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -49,6 +49,7 @@ 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 fetchPopularVideos = (id) => api.post(`/channels/${id}/fetch-popular`); 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 923d8c6..418dce5 100644 --- a/frontend/src/pages/Channel.jsx +++ b/frontend/src/pages/Channel.jsx @@ -3,12 +3,17 @@ import { useParams } from "react-router-dom"; import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from "@tanstack/react-query"; import { getChannel, getChannelVideos, searchChannelYoutube, - followChannel, unfollowChannel, indexChannel, indexChannelFull, exploreChannelOlder, downloadChannel, + followChannel, unfollowChannel, indexChannel, indexChannelFull, exploreChannelOlder, fetchPopularVideos, downloadChannel, } from "../api"; import VideoCard from "../components/VideoCard"; const LIMIT = 60; +const TABS = [ + { value: "videos", label: "Videos" }, + { value: "popular", label: "Popular" }, +]; + const SORTS = [ { value: "newest", label: "Newest" }, { value: "oldest", label: "Oldest" }, @@ -26,6 +31,7 @@ function formatSubs(n) { export default function ChannelPage() { const { id } = useParams(); const qc = useQueryClient(); + const [tab, setTab] = useState("videos"); const [sort, setSort] = useState("newest"); const [search, setSearch] = useState(""); const [activeQ, setActiveQ] = useState(""); @@ -38,6 +44,8 @@ export default function ChannelPage() { queryFn: () => getChannel(id).then((r) => r.data), }); + const effectiveSort = tab === "popular" ? "popular" : sort; + const { data: videosData, isLoading: loadingVideos, @@ -45,9 +53,9 @@ export default function ChannelPage() { hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ - queryKey: ["channel-videos", id, sort, activeQ], + queryKey: ["channel-videos", id, effectiveSort, activeQ], queryFn: ({ pageParam = 0 }) => - getChannelVideos(id, sort, pageParam, LIMIT, activeQ).then((r) => r.data), + getChannelVideos(id, effectiveSort, pageParam, LIMIT, activeQ).then((r) => r.data), getNextPageParam: (lastPage, pages) => lastPage.length === LIMIT ? pages.length * LIMIT : undefined, enabled: !!id, @@ -103,6 +111,11 @@ export default function ChannelPage() { }, }); + const popularMut = useMutation({ + mutationFn: () => fetchPopularVideos(id), + onSuccess: () => scheduleRefetch(20000), + }); + const deepSearchMut = useMutation({ mutationFn: () => searchChannelYoutube(id, activeQ || search), onSuccess: () => scheduleRefetch(20000), @@ -139,7 +152,7 @@ export default function ChannelPage() { if (!channel) return

Channel not found.

; const isFollowed = channel.status === "followed"; - const isPending = indexMut.isPending || fullIndexMut.isPending || deepSearchMut.isPending || exploreMut.isPending || indexing; + const isPending = indexMut.isPending || fullIndexMut.isPending || deepSearchMut.isPending || exploreMut.isPending || popularMut.isPending || indexing; return (
@@ -240,15 +253,15 @@ export default function ChannelPage() { )} - {/* Sort + index controls */} -
+ {/* Tabs + controls */} +
- {SORTS.map(s => ( - ))}
@@ -260,20 +273,43 @@ export default function ChannelPage() { - Indexing… + Fetching… )} - - + {tab === "popular" ? ( + + ) : ( + <> + + + + )}
+ {/* Sort bar — videos tab only */} + {tab === "videos" && ( +
+ {SORTS.map(s => ( + + ))} +
+ )} + {/* Video list */} {loadingVideos ? (
@@ -290,7 +326,7 @@ export default function ChannelPage() { className="mt-4 self-center text-sm text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-40 py-2 px-4"> {isFetchingNextPage ? "Loading…" : "Load more"} - ) : !activeQ && ( + ) : !activeQ && tab === "videos" && (

{videos.length} videos indexed

@@ -310,13 +346,22 @@ export default function ChannelPage() { ) : (

- {activeQ ? `No indexed videos match "${activeQ}"` : "No videos indexed yet."} + {activeQ + ? `No indexed videos match "${activeQ}"` + : tab === "popular" + ? "No popular videos fetched yet." + : "No videos indexed yet."}

{activeQ ? ( + ) : tab === "popular" ? ( + ) : (