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 && (
);
}
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 && (