From ff601d358557b22a229b6b9ddea493dc78f21288 Mon Sep 17 00:00:00 2001 From: Mattias Thall Date: Tue, 26 May 2026 23:50:55 +0200 Subject: [PATCH] Add stats peak hours, RSS feed, channel health view, bulk video download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stats: - Peak watching hours chart (24-bar) from last_watched_at timestamps RSS: - GET /api/channels/rss — last 100 videos from followed channels as RSS 2.0 - RSS link in Following > Health tab Channel health: - New Health tab in Following groups channels into Active / Slow / Dormant / Dead based on days since last upload Bulk video download: - Select mode on Channel page (Videos tab) with checkboxes - Sticky bottom bar shows count + Download button - Queues a download for each selected video Co-Authored-By: Claude Sonnet 4.6 --- backend/routers/channels.py | 51 ++++++++++++++++++++++ backend/routers/stats.py | 13 ++++++ frontend/src/api/index.js | 1 + frontend/src/pages/Channel.jsx | 74 +++++++++++++++++++++++++++++++- frontend/src/pages/Following.jsx | 62 +++++++++++++++++++++++++- frontend/src/pages/Stats.jsx | 33 ++++++++++++++ 6 files changed, 231 insertions(+), 3 deletions(-) diff --git a/backend/routers/channels.py b/backend/routers/channels.py index ec69e08..158054b 100644 --- a/backend/routers/channels.py +++ b/backend/routers/channels.py @@ -290,6 +290,57 @@ def sync_all_channels( return {"indexing": len(channels)} +@router.get("/rss") +def rss_feed( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + from fastapi.responses import Response + rows = db.execute( + text(""" + SELECT v.youtube_video_id, v.title, v.description, v.published_at, + c.name AS channel_name, c.youtube_channel_id + 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 = :uid AND uc.status = 'followed' + WHERE v.published_at IS NOT NULL + ORDER BY v.published_at DESC + LIMIT 100 + """), + {"uid": current_user.id}, + ).mappings().all() + + def esc(s): + if not s: + return "" + return str(s).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) + + items = [] + for r in rows: + pub = r["published_at"] + pub_str = pub.strftime("%a, %d %b %Y %H:%M:%S +0000") if pub else "" + yt_id = r["youtube_video_id"] + items.append(f""" + {esc(r['title'])} + https://www.youtube.com/watch?v={yt_id} + {esc(r['description'] or '')} + {esc(r['channel_name'])} + {pub_str} + https://www.youtube.com/watch?v={yt_id} + """) + + xml = f""" + + + YTContinue — Following + https://www.youtube.com/ + Latest videos from your followed channels +{chr(10).join(items)} + +""" + return Response(content=xml, media_type="application/rss+xml; charset=utf-8") + + @router.get("/tasks") def get_active_tasks(current_user: User = Depends(get_current_user)): with _tasks_lock: diff --git a/backend/routers/stats.py b/backend/routers/stats.py index 10e733a..a6c24ab 100644 --- a/backend/routers/stats.py +++ b/backend/routers/stats.py @@ -119,6 +119,18 @@ def get_stats( {"uid": uid}, ).mappings().all() + peak_hours = db.execute( + text(""" + SELECT CAST(strftime('%H', uv.last_watched_at) AS INTEGER) AS hour, + COUNT(*) AS count + FROM user_videos uv + WHERE uv.user_id = :uid AND uv.watched = 1 AND uv.last_watched_at IS NOT NULL + GROUP BY hour + ORDER BY hour ASC + """), + {"uid": uid}, + ).mappings().all() + liked_count = db.execute( text("SELECT COUNT(*) AS n FROM user_videos WHERE user_id = :uid AND liked = 1"), {"uid": uid}, @@ -153,6 +165,7 @@ def get_stats( "rewatched_videos": avg_completion["rewatched_videos"] or 0, "total_liked": liked_count["n"] or 0, "top_categories": [dict(r) for r in top_categories], + "peak_hours": [dict(r) for r in peak_hours], "taste_profile": [dict(r) for r in taste_profile], "disk": { "total_bytes": disk.total if disk else None, diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index d1f65b5..8109d44 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -65,6 +65,7 @@ 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 getRssFeedUrl = () => `/api/channels/rss`; 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`); diff --git a/frontend/src/pages/Channel.jsx b/frontend/src/pages/Channel.jsx index 24514d9..e94d816 100644 --- a/frontend/src/pages/Channel.jsx +++ b/frontend/src/pages/Channel.jsx @@ -5,7 +5,7 @@ import { getChannel, getChannelVideos, searchChannelYoutube, followChannel, unfollowChannel, indexChannel, indexChannelFull, exploreChannelOlder, fetchPopularVideos, downloadChannel, getChannelPlaylists, fetchChannelPlaylists, getPlaylistVideos, indexPlaylist, - getRandomChannelVideo, getChannelInProgress, + getRandomChannelVideo, getChannelInProgress, createDownload, } from "../api"; import VideoCard from "../components/VideoCard"; @@ -41,6 +41,9 @@ export default function ChannelPage() { const [activeQ, setActiveQ] = useState(""); const [indexing, setIndexing] = useState(false); const [explorePage, setExplorePage] = useState(2); + const [selectMode, setSelectMode] = useState(false); + const [selectedVideos, setSelectedVideos] = useState(new Set()); + const [bulkDlResult, setBulkDlResult] = useState(null); const searchInputRef = useRef(null); const [openPlaylistId, setOpenPlaylistId] = useState(null); const [playlistOffset, setPlaylistOffset] = useState(0); @@ -169,6 +172,29 @@ export default function ChannelPage() { }, }); + const toggleSelectVideo = (ytId) => { + setSelectedVideos(prev => { + const next = new Set(prev); + if (next.has(ytId)) next.delete(ytId); else next.add(ytId); + return next; + }); + }; + + const bulkDownloadMut = useMutation({ + mutationFn: async () => { + const ids = [...selectedVideos]; + await Promise.all(ids.map(ytId => createDownload(ytId))); + return ids.length; + }, + onSuccess: (count) => { + setBulkDlResult(count); + setSelectedVideos(new Set()); + setSelectMode(false); + qc.invalidateQueries({ queryKey: ["downloads"] }); + setTimeout(() => setBulkDlResult(null), 4000); + }, + }); + const handleSearch = (e) => { e.preventDefault(); setActiveQ(search.trim()); @@ -315,6 +341,17 @@ export default function ChannelPage() {
+ {bulkDlResult != null && ( + {bulkDlResult} queued + )} + {tab === "videos" && ( + + )} {isPending && ( @@ -499,7 +536,20 @@ export default function ChannelPage() { ) : videos.length ? (
{videos.map((v) => ( - +
toggleSelectVideo(v.youtube_video_id) : undefined}> + {selectMode && ( + + )} +
+ +
+
))} {hasNextPage ? ( @@ -551,6 +601,26 @@ export default function ChannelPage() { )}
))} + + {/* Sticky bulk download bar */} + {selectMode && selectedVideos.size > 0 && ( +
+ {selectedVideos.size} selected + + +
+ )}
); } diff --git a/frontend/src/pages/Following.jsx b/frontend/src/pages/Following.jsx index 8c59dae..cb4d9b6 100644 --- a/frontend/src/pages/Following.jsx +++ b/frontend/src/pages/Following.jsx @@ -10,6 +10,7 @@ import { getChannelGroups, createChannelGroup, deleteChannelGroup, renameChannelGroup, addChannelToGroup, removeChannelFromGroup, getSettings, bulkChannelAction, followBulk, updateChannelNotes, + getRssFeedUrl, } from "../api"; import VideoCard from "../components/VideoCard"; import SortPicker from "../components/SortPicker"; @@ -781,7 +782,7 @@ export default function Following() { {/* Tabs */}
- {[["channels", "Channels"], ["feed", "Feed"], ["groups", "Groups"]].map(([key, label]) => ( + {[["channels", "Channels"], ["feed", "Feed"], ["health", "Health"], ["groups", "Groups"]].map(([key, label]) => (
)} + {/* ── Health tab ── */} + {tab === "health" && (() => { + const now = Date.now(); + const bucket = (ch) => { + if (!ch.last_published_at) return "unknown"; + const days = (now - new Date(ch.last_published_at)) / 86400000; + if (days < 30) return "active"; + if (days < 90) return "slow"; + if (days < 365) return "dormant"; + return "dead"; + }; + const buckets = { + active: { label: "Active", sub: "uploaded in the last 30 days", color: "text-green-400", bg: "bg-green-900/20" }, + slow: { label: "Slow", sub: "uploaded in the last 90 days", color: "text-yellow-400", bg: "bg-yellow-900/20" }, + dormant: { label: "Dormant", sub: "no upload in 90–365 days", color: "text-orange-400", bg: "bg-orange-900/20" }, + dead: { label: "Dead", sub: "no upload in over a year", color: "text-red-400", bg: "bg-red-900/20" }, + unknown: { label: "Unknown", sub: "never indexed", color: "text-zinc-500", bg: "bg-zinc-800/40" }, + }; + return ( +
+ + + + + RSS feed + + {Object.entries(buckets).map(([key, { label, sub, color, bg }]) => { + const chs = channels.filter(ch => bucket(ch) === key); + if (!chs.length) return null; + return ( +
+
+

{label}

+ {sub} + {chs.length} channel{chs.length !== 1 ? "s" : ""} +
+
+ {chs.map(ch => ( + + ))} +
+
+ ); + })} +
+ ); + })()} + {/* ── Groups tab ── */} {tab === "groups" && ( diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index f177864..51cc9b1 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -201,6 +201,39 @@ export default function Stats() { )} + {/* Peak hours */} + {data.peak_hours?.length > 0 && (() => { + const hourMap = Object.fromEntries(data.peak_hours.map(h => [h.hour, h.count])); + const maxCount = Math.max(...data.peak_hours.map(h => h.count), 1); + const hours = Array.from({ length: 24 }, (_, i) => i); + return ( +
+

Peak watching hours

+
+ {hours.map(h => { + const count = hourMap[h] || 0; + const pct = count / maxCount; + const label = h === 0 ? "12a" : h < 12 ? `${h}a` : h === 12 ? "12p" : `${h - 12}p`; + return ( +
+
+
+ ); + })} +
+
+ 12am6am12pm6pm11pm +
+
+ ); + })()} + {/* Taste profile */} {topTags.length > 0 && (