diff --git a/backend/routers/channels.py b/backend/routers/channels.py index 44e0749..ec69e08 100644 --- a/backend/routers/channels.py +++ b/backend/routers/channels.py @@ -865,6 +865,66 @@ def _search_channel_task(channel_id: int, youtube_channel_id: str, q: str, user_ db.close() +@router.get("/{channel_id}/random", response_model=VideoOut) +def get_random_channel_video( + channel_id: int, + unwatched_only: bool = True, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _get_channel_or_404(db, channel_id) + unwatched_clause = "AND COALESCE(uv.watched, 0) = 0" if unwatched_only else "" + row = db.execute( + text(f""" + SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url, + v.duration_seconds, v.published_at, v.view_count, + c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id, + COALESCE(uv.downloaded, 0) AS is_downloaded, + COALESCE(uv.watched, 0) AS is_watched, + COALESCE(uv.queued, 0) AS queued + FROM videos v + JOIN channels c ON v.channel_id = c.id + LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id + WHERE v.channel_id = :channel_id {unwatched_clause} + ORDER BY RANDOM() + LIMIT 1 + """), + {"user_id": current_user.id, "channel_id": channel_id}, + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="No videos found") + return VideoOut(**dict(row)) + + +@router.get("/{channel_id}/in-progress", response_model=list[VideoOut]) +def get_channel_in_progress( + channel_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _get_channel_or_404(db, channel_id) + rows = db.execute( + text(""" + SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url, + v.duration_seconds, v.published_at, v.view_count, + c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id, + COALESCE(uv.downloaded, 0) AS is_downloaded, + COALESCE(uv.watched, 0) AS is_watched, + COALESCE(uv.queued, 0) AS queued + FROM videos v + JOIN channels c ON v.channel_id = c.id + JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id + WHERE v.channel_id = :channel_id + AND uv.watch_progress_seconds > 30 + AND COALESCE(uv.watched, 0) = 0 + ORDER BY uv.watch_progress_seconds DESC + LIMIT 6 + """), + {"user_id": current_user.id, "channel_id": channel_id}, + ).mappings().all() + return [VideoOut(**dict(r)) for r in rows] + + @router.post("/{channel_id}/follow", status_code=status.HTTP_204_NO_CONTENT) def follow_channel( channel_id: int, diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 0d74a2e..d1f65b5 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -65,6 +65,9 @@ export const addChannelToGroup = (groupId, channelId) => api.post(`/channels/gro export const removeChannelFromGroup = (groupId, channelId) => api.delete(`/channels/groups/${groupId}/channels/${channelId}`); export const bulkChannelAction = (channel_ids, action) => api.post("/channels/bulk-action", { channel_ids, action }); export const getActiveTasks = () => api.get("/channels/tasks"); +export const getRandomChannelVideo = (id, unwatchedOnly = true) => + api.get(`/channels/${id}/random`, { params: { unwatched_only: unwatchedOnly } }); +export const getChannelInProgress = (id) => api.get(`/channels/${id}/in-progress`); // Videos export const homeFeed = (page = 0, limit = 25, mode = "ranked", duration = "") => diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index db2f002..8dbe916 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -2,7 +2,7 @@ import { Outlet, NavLink, Link, useLocation } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { useAuth } from "../hooks/useAuth"; import SearchBar from "./SearchBar"; -import { getDownloads, getChannels, getActiveTasks } from "../api"; +import { getDownloads, getChannels, getActiveTasks, getMe } from "../api"; function BottomNav({ newCount }) { const tabs = [ @@ -259,12 +259,32 @@ function useNewVideosCount() { return channels.reduce((sum, c) => sum + (c.new_count ?? 0), 0); } +function useOffline() { + const { isError, error } = useQuery({ + queryKey: ["health"], + queryFn: () => getMe().then((r) => r.data), + refetchInterval: 30_000, + retry: 1, + staleTime: 20_000, + }); + return isError && !error?.response; +} + export default function Layout() { const { user, logout } = useAuth(); const newCount = useNewVideosCount(); + const offline = useOffline(); return (
Server unreachable — check that the backend is running.
+