diff --git a/backend/routers/channels.py b/backend/routers/channels.py index c522388..3453c51 100644 --- a/backend/routers/channels.py +++ b/backend/routers/channels.py @@ -83,7 +83,7 @@ def _index_channels_batch(channel_ids: list[int], user_id: int, delay: float = 1 _index_channel_task(cid, user_id) -def _index_channel_task(channel_id: int, user_id: int): +def _index_channel_task(channel_id: int, user_id: int, max_videos: int = 30): from ..database import SessionLocal db = SessionLocal() try: @@ -91,7 +91,7 @@ def _index_channel_task(channel_id: int, user_id: int): if not channel: return - result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id) + result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id, max_videos=max_videos) if not result: return @@ -599,26 +599,98 @@ def get_channel( @router.get("/{channel_id}/videos", response_model=list[VideoOut]) def get_channel_videos( channel_id: int, + sort: str = "newest", + offset: int = 0, + limit: int = 60, + q: str = "", db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): _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", + }.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 = "" + if q.strip(): + q_clause = "AND (v.title LIKE :q OR v.description LIKE :q)" + params["q"] = f"%{q.strip()}%" rows = db.execute( - text(""" + text(f""" SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url, v.duration_seconds, v.published_at, COALESCE(uv.downloaded, 0) AS is_downloaded, COALESCE(uv.watched, 0) AS is_watched FROM videos v LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id - WHERE v.channel_id = :channel_id - ORDER BY v.published_at DESC + WHERE v.channel_id = :channel_id {q_clause} + ORDER BY {order} + LIMIT :limit OFFSET :offset """), - {"user_id": current_user.id, "channel_id": channel_id}, + params, ).mappings().all() return [VideoOut(**dict(r)) for r in rows] +@router.post("/{channel_id}/search", status_code=status.HTTP_202_ACCEPTED) +def search_channel_youtube( + channel_id: int, + q: str, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Search YouTube within this channel and index matching videos.""" + channel = _get_channel_or_404(db, channel_id) + background_tasks.add_task(_search_channel_task, channel_id, channel.youtube_channel_id, q, current_user.id) + return {"detail": "Search started"} + + +def _search_channel_task(channel_id: int, youtube_channel_id: str, q: str, user_id: int): + """Fetch videos matching q from YouTube for this channel and index them.""" + from ..database import SessionLocal + from urllib.parse import quote + db = SessionLocal() + try: + url = f"https://www.youtube.com/channel/{youtube_channel_id}/search?query={quote(q)}" + result = ytdlp.fetch_channel_metadata(youtube_channel_id, max_videos=100) + if not result: + return + # Filter results by query match (yt-dlp fetches all; we filter titles locally) + q_lower = q.lower() + matched = [v for v in result.get("videos", []) if q_lower in (v.get("title") or "").lower()] + if not matched: + matched = result.get("videos", [])[:30] + channel = db.query(Channel).filter_by(id=channel_id).first() + if not channel: + return + for vdata in matched: + yt_id = vdata.get("youtube_video_id") + if not yt_id: + continue + existing = db.query(Video).filter_by(youtube_video_id=yt_id).first() + if not existing: + 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("/{channel_id}/follow", status_code=status.HTTP_204_NO_CONTENT) def follow_channel( channel_id: int, @@ -670,10 +742,22 @@ def index_channel( current_user: User = Depends(get_current_user), ): _get_channel_or_404(db, channel_id) - background_tasks.add_task(_index_channel_task, channel_id, current_user.id) + background_tasks.add_task(_index_channel_task, channel_id, current_user.id, 100) return {"detail": "Indexing started"} +@router.post("/{channel_id}/index-full", status_code=status.HTTP_202_ACCEPTED) +def index_channel_full( + channel_id: int, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _get_channel_or_404(db, channel_id) + background_tasks.add_task(_index_channel_task, channel_id, current_user.id, 0) + return {"detail": "Full index started"} + + @router.post("/follow-bulk", status_code=200) def follow_bulk( body: dict, diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index ee185f3..b0e917d 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -41,10 +41,13 @@ export const getChannels = () => api.get("/channels"); export const getChannelFeed = (offset = 0) => api.get("/channels/feed", { params: { offset, limit: 24 } }); export const getChannel = (id) => api.get(`/channels/${id}`); export const syncAllChannels = () => api.post("/channels/sync-all"); -export const getChannelVideos = (id) => api.get(`/channels/${id}/videos`); +export const getChannelVideos = (id, sort = "newest", offset = 0, limit = 60, q = "") => + api.get(`/channels/${id}/videos`, { params: { sort, offset, limit, ...(q ? { q } : {}) } }); +export const searchChannelYoutube = (id, q) => api.post(`/channels/${id}/search`, null, { params: { q } }); 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 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 78d9555..92e8501 100644 --- a/frontend/src/pages/Channel.jsx +++ b/frontend/src/pages/Channel.jsx @@ -1,9 +1,20 @@ -import { useState, useMemo, useEffect, useRef } from "react"; +import { useState, useEffect, useRef } from "react"; import { useParams } from "react-router-dom"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { getChannel, getChannelVideos, followChannel, unfollowChannel, indexChannel, downloadChannel } from "../api"; +import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from "@tanstack/react-query"; +import { + getChannel, getChannelVideos, searchChannelYoutube, + followChannel, unfollowChannel, indexChannel, indexChannelFull, downloadChannel, +} from "../api"; import VideoCard from "../components/VideoCard"; -import SortPicker from "../components/SortPicker"; + +const LIMIT = 60; + +const SORTS = [ + { value: "newest", label: "Newest" }, + { value: "oldest", label: "Oldest" }, + { value: "title", label: "A–Z" }, + { value: "unwatched", label: "Unwatched" }, +]; function formatSubs(n) { if (!n) return null; @@ -12,36 +23,38 @@ function formatSubs(n) { return String(n); } -const VIDEO_SORTS = [ - { value: "newest", label: "Newest" }, - { value: "oldest", label: "Oldest" }, - { value: "title", label: "Title A–Z" }, - { value: "unwatched", label: "Unwatched first" }, -]; - -function sortVideos(items, sort) { - const arr = [...items]; - if (sort === "oldest") return arr.sort((a, b) => new Date(a.published_at ?? 0) - new Date(b.published_at ?? 0)); - if (sort === "title") return arr.sort((a, b) => (a.title ?? "").localeCompare(b.title ?? "")); - if (sort === "unwatched") return arr.sort((a, b) => Number(a.is_watched) - Number(b.is_watched)); - return arr.sort((a, b) => new Date(b.published_at ?? 0) - new Date(a.published_at ?? 0)); -} - export default function ChannelPage() { const { id } = useParams(); const qc = useQueryClient(); + const [sort, setSort] = useState("newest"); + const [search, setSearch] = useState(""); + const [activeQ, setActiveQ] = useState(""); + const [indexing, setIndexing] = useState(false); + const searchInputRef = useRef(null); const { data: channel, isLoading: loadingChannel } = useQuery({ queryKey: ["channel", id], queryFn: () => getChannel(id).then((r) => r.data), }); - const { data: videos, isLoading: loadingVideos } = useQuery({ - queryKey: ["channel-videos", id], - queryFn: () => getChannelVideos(id).then((r) => r.data), + const { + data: videosData, + isLoading: loadingVideos, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + queryKey: ["channel-videos", id, sort, activeQ], + queryFn: ({ pageParam = 0 }) => + getChannelVideos(id, sort, pageParam, LIMIT, activeQ).then((r) => r.data), + getNextPageParam: (lastPage, pages) => + lastPage.length === LIMIT ? pages.length * LIMIT : undefined, + enabled: !!id, }); - // Refetch after the background re-index has had time to run + const videos = videosData?.pages.flat() ?? []; + + // Refetch after background re-index const refetchedRef = useRef(false); useEffect(() => { if (!id || refetchedRef.current) return; @@ -62,13 +75,31 @@ export default function ChannelPage() { }, }); + const scheduleRefetch = (delayMs) => { + setIndexing(true); + setTimeout(() => { + qc.invalidateQueries({ queryKey: ["channel-videos", id] }); + qc.invalidateQueries({ queryKey: ["channel", id] }); + setIndexing(false); + }, delayMs); + }; + const indexMut = useMutation({ mutationFn: () => indexChannel(id), - onSuccess: () => setTimeout(() => qc.invalidateQueries({ queryKey: ["channel-videos", id] }), 4000), + onSuccess: () => scheduleRefetch(6000), + }); + + const fullIndexMut = useMutation({ + mutationFn: () => indexChannelFull(id), + onSuccess: () => scheduleRefetch(45000), + }); + + const deepSearchMut = useMutation({ + mutationFn: () => searchChannelYoutube(id, activeQ || search), + onSuccess: () => scheduleRefetch(20000), }); const [dlResult, setDlResult] = useState(null); - const [videoSort, setVideoSort] = useState("newest"); const dlMut = useMutation({ mutationFn: () => downloadChannel(id), onSuccess: (res) => { @@ -77,6 +108,17 @@ export default function ChannelPage() { }, }); + const handleSearch = (e) => { + e.preventDefault(); + setActiveQ(search.trim()); + }; + + const clearSearch = () => { + setSearch(""); + setActiveQ(""); + searchInputRef.current?.focus(); + }; + if (loadingChannel) { return (
@@ -88,9 +130,10 @@ export default function ChannelPage() { if (!channel) return

Channel not found.

; const isFollowed = channel.status === "followed"; + const isPending = indexMut.isPending || fullIndexMut.isPending || deepSearchMut.isPending || indexing; return ( -
+
{/* Banner */}
{channel.banner_url && ( @@ -98,7 +141,6 @@ export default function ChannelPage() { )}
- {/* Avatar + name — always in the banner overlay */}
{channel.thumbnail_url ? ( {channel.name} {[ formatSubs(channel.subscriber_count) && `${formatSubs(channel.subscriber_count)} subs`, - channel.video_count && `${channel.video_count} videos`, + channel.video_count && `${channel.video_count} indexed`, ].filter(Boolean).join(" · ")}

- {/* Desktop action buttons inline */} +
-
- {/* Mobile action row — below banner */} -
+ {/* Mobile actions */} +
-
{dlResult != null && ( -

+

{dlResult === 0 ? "Already up to date" : `${dlResult} downloads queued`}

)} - {/* Description */} {channel.description && ( -

{channel.description}

+

{channel.description}

)} - {/* Video grid */} + {/* Search bar */} +
+
+ setSearch(e.target.value)} + placeholder="Search videos…" + className="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-1.5 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-600 transition-colors" + /> + {search && ( + + )} +
+ + {activeQ && ( + + )} +
+ + {/* Sort + index controls */} +
+
+ {SORTS.map(s => ( + + ))} +
+ +
+ {isPending && ( + + + + + + Indexing… + + )} + + +
+
+ + {/* Video list */} {loadingVideos ? (
- ) : videos?.length ? ( - <> -
- -
-
- {sortVideos(videos, videoSort).map((v) => ( + ) : videos.length ? ( +
+ {videos.map((v) => ( ))} -
- + + {hasNextPage ? ( + + ) : !activeQ && ( +
+

All {videos.length} indexed videos shown

+ +
+ )} +
) : ( -

No videos indexed yet. Hit Re-index to fetch them.

+
+

+ {activeQ ? `No indexed videos match "${activeQ}"` : "No videos indexed yet."} +

+ {activeQ ? ( + + ) : ( + + )} +
)}
);