From 3652038cf5d8d91272ec882369b9ce595f7dbf67 Mon Sep 17 00:00:00 2001 From: Mattias Thall Date: Tue, 26 May 2026 23:46:58 +0200 Subject: [PATCH] Add offline indicator, surprise me button, and continue watching on channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Offline banner in nav when backend is unreachable (network error, not 4xx) - GET /channels/{id}/random — picks random unwatched video, navigates to watch - GET /channels/{id}/in-progress — videos with >30s progress, not yet watched - Channel page: 'Surprise me' button (desktop + mobile) navigates to random video - Channel page: 'Continue watching' row above video list when in-progress videos exist Co-Authored-By: Claude Sonnet 4.6 --- backend/routers/channels.py | 60 ++++++++++++++++++++++++++++++ frontend/src/api/index.js | 3 ++ frontend/src/components/Layout.jsx | 22 ++++++++++- frontend/src/pages/Channel.jsx | 37 +++++++++++++++++- 4 files changed, 120 insertions(+), 2 deletions(-) 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 (
+ {offline && ( +
+ + + +

Server unreachable — check that the backend is running.

+
+ )} {/* Header */}
diff --git a/frontend/src/pages/Channel.jsx b/frontend/src/pages/Channel.jsx index 01d6c84..24514d9 100644 --- a/frontend/src/pages/Channel.jsx +++ b/frontend/src/pages/Channel.jsx @@ -1,10 +1,11 @@ import { useState, useEffect, useRef } from "react"; -import { useParams } from "react-router-dom"; +import { useParams, useNavigate } from "react-router-dom"; import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from "@tanstack/react-query"; import { getChannel, getChannelVideos, searchChannelYoutube, followChannel, unfollowChannel, indexChannel, indexChannelFull, exploreChannelOlder, fetchPopularVideos, downloadChannel, getChannelPlaylists, fetchChannelPlaylists, getPlaylistVideos, indexPlaylist, + getRandomChannelVideo, getChannelInProgress, } from "../api"; import VideoCard from "../components/VideoCard"; @@ -32,6 +33,7 @@ function formatSubs(n) { export default function ChannelPage() { const { id } = useParams(); + const navigate = useNavigate(); const qc = useQueryClient(); const [tab, setTab] = useState("videos"); const [sort, setSort] = useState("newest"); @@ -147,6 +149,17 @@ export default function ChannelPage() { onSuccess: () => setTimeout(() => refetchPlaylistVideos(), 15000), }); + const { data: inProgress = [] } = useQuery({ + queryKey: ["channel-in-progress", id], + queryFn: () => getChannelInProgress(id).then((r) => r.data), + enabled: !!id, + }); + + const randomMut = useMutation({ + mutationFn: () => getRandomChannelVideo(id).then((r) => r.data), + onSuccess: (video) => navigate(`/watch/${video.youtube_video_id}`), + }); + const [dlResult, setDlResult] = useState(null); const dlMut = useMutation({ mutationFn: () => downloadChannel(id), @@ -213,6 +226,11 @@ export default function ChannelPage() { className={`text-sm font-medium px-4 py-1.5 rounded-lg transition-colors ${isFollowed ? "bg-zinc-700/80 text-zinc-300 hover:bg-zinc-600" : "bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700"}`}> {isFollowed ? "Following" : "Follow"} + +