Add stats peak hours, RSS feed, channel health view, bulk video download
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 <noreply@anthropic.com>
This commit is contained in:
@@ -290,6 +290,57 @@ def sync_all_channels(
|
|||||||
return {"indexing": len(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""" <item>
|
||||||
|
<title>{esc(r['title'])}</title>
|
||||||
|
<link>https://www.youtube.com/watch?v={yt_id}</link>
|
||||||
|
<description>{esc(r['description'] or '')}</description>
|
||||||
|
<author>{esc(r['channel_name'])}</author>
|
||||||
|
<pubDate>{pub_str}</pubDate>
|
||||||
|
<guid>https://www.youtube.com/watch?v={yt_id}</guid>
|
||||||
|
</item>""")
|
||||||
|
|
||||||
|
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>YTContinue — Following</title>
|
||||||
|
<link>https://www.youtube.com/</link>
|
||||||
|
<description>Latest videos from your followed channels</description>
|
||||||
|
{chr(10).join(items)}
|
||||||
|
</channel>
|
||||||
|
</rss>"""
|
||||||
|
return Response(content=xml, media_type="application/rss+xml; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks")
|
@router.get("/tasks")
|
||||||
def get_active_tasks(current_user: User = Depends(get_current_user)):
|
def get_active_tasks(current_user: User = Depends(get_current_user)):
|
||||||
with _tasks_lock:
|
with _tasks_lock:
|
||||||
|
|||||||
@@ -119,6 +119,18 @@ def get_stats(
|
|||||||
{"uid": uid},
|
{"uid": uid},
|
||||||
).mappings().all()
|
).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(
|
liked_count = db.execute(
|
||||||
text("SELECT COUNT(*) AS n FROM user_videos WHERE user_id = :uid AND liked = 1"),
|
text("SELECT COUNT(*) AS n FROM user_videos WHERE user_id = :uid AND liked = 1"),
|
||||||
{"uid": uid},
|
{"uid": uid},
|
||||||
@@ -153,6 +165,7 @@ def get_stats(
|
|||||||
"rewatched_videos": avg_completion["rewatched_videos"] or 0,
|
"rewatched_videos": avg_completion["rewatched_videos"] or 0,
|
||||||
"total_liked": liked_count["n"] or 0,
|
"total_liked": liked_count["n"] or 0,
|
||||||
"top_categories": [dict(r) for r in top_categories],
|
"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],
|
"taste_profile": [dict(r) for r in taste_profile],
|
||||||
"disk": {
|
"disk": {
|
||||||
"total_bytes": disk.total if disk else None,
|
"total_bytes": disk.total if disk else None,
|
||||||
|
|||||||
@@ -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 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 bulkChannelAction = (channel_ids, action) => api.post("/channels/bulk-action", { channel_ids, action });
|
||||||
export const getActiveTasks = () => api.get("/channels/tasks");
|
export const getActiveTasks = () => api.get("/channels/tasks");
|
||||||
|
export const getRssFeedUrl = () => `/api/channels/rss`;
|
||||||
export const getRandomChannelVideo = (id, unwatchedOnly = true) =>
|
export const getRandomChannelVideo = (id, unwatchedOnly = true) =>
|
||||||
api.get(`/channels/${id}/random`, { params: { unwatched_only: unwatchedOnly } });
|
api.get(`/channels/${id}/random`, { params: { unwatched_only: unwatchedOnly } });
|
||||||
export const getChannelInProgress = (id) => api.get(`/channels/${id}/in-progress`);
|
export const getChannelInProgress = (id) => api.get(`/channels/${id}/in-progress`);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
getChannel, getChannelVideos, searchChannelYoutube,
|
getChannel, getChannelVideos, searchChannelYoutube,
|
||||||
followChannel, unfollowChannel, indexChannel, indexChannelFull, exploreChannelOlder, fetchPopularVideos, downloadChannel,
|
followChannel, unfollowChannel, indexChannel, indexChannelFull, exploreChannelOlder, fetchPopularVideos, downloadChannel,
|
||||||
getChannelPlaylists, fetchChannelPlaylists, getPlaylistVideos, indexPlaylist,
|
getChannelPlaylists, fetchChannelPlaylists, getPlaylistVideos, indexPlaylist,
|
||||||
getRandomChannelVideo, getChannelInProgress,
|
getRandomChannelVideo, getChannelInProgress, createDownload,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import VideoCard from "../components/VideoCard";
|
import VideoCard from "../components/VideoCard";
|
||||||
|
|
||||||
@@ -41,6 +41,9 @@ export default function ChannelPage() {
|
|||||||
const [activeQ, setActiveQ] = useState("");
|
const [activeQ, setActiveQ] = useState("");
|
||||||
const [indexing, setIndexing] = useState(false);
|
const [indexing, setIndexing] = useState(false);
|
||||||
const [explorePage, setExplorePage] = useState(2);
|
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 searchInputRef = useRef(null);
|
||||||
const [openPlaylistId, setOpenPlaylistId] = useState(null);
|
const [openPlaylistId, setOpenPlaylistId] = useState(null);
|
||||||
const [playlistOffset, setPlaylistOffset] = useState(0);
|
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) => {
|
const handleSearch = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveQ(search.trim());
|
setActiveQ(search.trim());
|
||||||
@@ -315,6 +341,17 @@ export default function ChannelPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{bulkDlResult != null && (
|
||||||
|
<span className="text-xs text-accent font-mono">{bulkDlResult} queued</span>
|
||||||
|
)}
|
||||||
|
{tab === "videos" && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectMode(v => !v); setSelectedVideos(new Set()); }}
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-lg font-medium transition-colors ${selectMode ? "bg-accent text-black" : "text-zinc-500 hover:text-zinc-300"}`}
|
||||||
|
>
|
||||||
|
{selectMode ? "Cancel" : "Select"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{isPending && (
|
{isPending && (
|
||||||
<span className="text-xs text-zinc-500 flex items-center gap-1.5">
|
<span className="text-xs text-zinc-500 flex items-center gap-1.5">
|
||||||
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
@@ -499,7 +536,20 @@ export default function ChannelPage() {
|
|||||||
) : videos.length ? (
|
) : videos.length ? (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{videos.map((v) => (
|
{videos.map((v) => (
|
||||||
<VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} variant="list" />
|
<div key={v.youtube_video_id} className={`flex items-center gap-2 ${selectMode ? "cursor-pointer" : ""}`}
|
||||||
|
onClick={selectMode ? () => toggleSelectVideo(v.youtube_video_id) : undefined}>
|
||||||
|
{selectMode && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
readOnly
|
||||||
|
checked={selectedVideos.has(v.youtube_video_id)}
|
||||||
|
className="shrink-0 ml-1 accent-accent w-3.5 h-3.5 pointer-events-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<VideoCard video={{ ...v, channel_name: channel.name }} variant="list" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{hasNextPage ? (
|
{hasNextPage ? (
|
||||||
@@ -551,6 +601,26 @@ export default function ChannelPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Sticky bulk download bar */}
|
||||||
|
{selectMode && selectedVideos.size > 0 && (
|
||||||
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 px-5 py-3 bg-zinc-800 border border-zinc-700 rounded-2xl shadow-2xl">
|
||||||
|
<span className="text-sm text-zinc-300 font-medium">{selectedVideos.size} selected</span>
|
||||||
|
<button
|
||||||
|
onClick={() => bulkDownloadMut.mutate()}
|
||||||
|
disabled={bulkDownloadMut.isPending}
|
||||||
|
className="px-4 py-1.5 rounded-lg bg-accent text-black text-sm font-semibold hover:bg-zinc-100 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{bulkDownloadMut.isPending ? "Queuing…" : "Download"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedVideos(new Set())}
|
||||||
|
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getChannelGroups, createChannelGroup, deleteChannelGroup, renameChannelGroup,
|
getChannelGroups, createChannelGroup, deleteChannelGroup, renameChannelGroup,
|
||||||
addChannelToGroup, removeChannelFromGroup,
|
addChannelToGroup, removeChannelFromGroup,
|
||||||
getSettings, bulkChannelAction, followBulk, updateChannelNotes,
|
getSettings, bulkChannelAction, followBulk, updateChannelNotes,
|
||||||
|
getRssFeedUrl,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import VideoCard from "../components/VideoCard";
|
import VideoCard from "../components/VideoCard";
|
||||||
import SortPicker from "../components/SortPicker";
|
import SortPicker from "../components/SortPicker";
|
||||||
@@ -781,7 +782,7 @@ export default function Following() {
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex items-center gap-0.5 border-b border-zinc-800">
|
<div className="flex items-center gap-0.5 border-b border-zinc-800">
|
||||||
{[["channels", "Channels"], ["feed", "Feed"], ["groups", "Groups"]].map(([key, label]) => (
|
{[["channels", "Channels"], ["feed", "Feed"], ["health", "Health"], ["groups", "Groups"]].map(([key, label]) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => setTab(key)}
|
onClick={() => setTab(key)}
|
||||||
@@ -1022,6 +1023,65 @@ export default function Following() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── 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 (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<a
|
||||||
|
href={getRssFeedUrl()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="self-start flex items-center gap-1.5 text-xs text-zinc-500 hover:text-orange-400 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M6.18 15.64a2.18 2.18 0 010 4.36 2.18 2.18 0 010-4.36M4 4.44A15.56 15.56 0 0119.56 20h-2.83A12.73 12.73 0 006.18 7.27V4.44M4 10.1a9.9 9.9 0 019.9 9.9h-2.83A7.07 7.07 0 006.18 12.93V10.1H4z"/>
|
||||||
|
</svg>
|
||||||
|
RSS feed
|
||||||
|
</a>
|
||||||
|
{Object.entries(buckets).map(([key, { label, sub, color, bg }]) => {
|
||||||
|
const chs = channels.filter(ch => bucket(ch) === key);
|
||||||
|
if (!chs.length) return null;
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<div className="flex items-baseline gap-2 mb-2">
|
||||||
|
<h2 className={`text-sm font-semibold ${color}`}>{label}</h2>
|
||||||
|
<span className="text-xs text-zinc-600">{sub}</span>
|
||||||
|
<span className="text-xs text-zinc-600 ml-auto">{chs.length} channel{chs.length !== 1 ? "s" : ""}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`rounded-xl ${bg} divide-y divide-zinc-800/40`}>
|
||||||
|
{chs.map(ch => (
|
||||||
|
<ChannelRow
|
||||||
|
key={ch.id}
|
||||||
|
channel={ch}
|
||||||
|
groups={groups}
|
||||||
|
onGroupToggle={handleGroupToggle}
|
||||||
|
hideSubCount={hideSubCount}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* ── Groups tab ── */}
|
{/* ── Groups tab ── */}
|
||||||
{tab === "groups" && (
|
{tab === "groups" && (
|
||||||
<GroupsPanel groups={groups} channels={channels} />
|
<GroupsPanel groups={groups} channels={channels} />
|
||||||
|
|||||||
@@ -201,6 +201,39 @@ export default function Stats() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
|
||||||
|
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Peak watching hours</h2>
|
||||||
|
<div className="flex items-end gap-0.5 h-16">
|
||||||
|
{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 (
|
||||||
|
<div key={h} className="flex-1 flex flex-col items-center gap-0.5" title={`${label}: ${count} video${count !== 1 ? "s" : ""}`}>
|
||||||
|
<div
|
||||||
|
className="w-full rounded-sm transition-all"
|
||||||
|
style={{
|
||||||
|
height: count === 0 ? "2px" : `${Math.max(pct * 100, 6)}%`,
|
||||||
|
backgroundColor: count === 0 ? "#27272a" : `hsl(50,95%,${30 + pct * 30}%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[10px] text-zinc-600">
|
||||||
|
<span>12am</span><span>6am</span><span>12pm</span><span>6pm</span><span>11pm</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Taste profile */}
|
{/* Taste profile */}
|
||||||
{topTags.length > 0 && (
|
{topTags.length > 0 && (
|
||||||
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
|
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user