Add lazy comment fetching to watch page

- VideoComment model (video_id, author, text, likes, is_pinned, published_at)
- fetch_video_comments() in ytdlp.py: top 20 comments, no reply threads,
  sorted pinned-first then by likes
- GET /videos/by-yt/{id}/comments — returns cached comments instantly
- POST /videos/by-yt/{id}/comments/refresh — fetches from YouTube, stores, returns
- Watch page: CommentsSection shows "Load comments" button when uncached,
  renders comments with author/likes once loaded; Refresh link to re-fetch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mattias Tall
2026-05-26 11:15:41 +02:00
parent d6dd07e0bd
commit cdf6520fd8
5 changed files with 201 additions and 0 deletions

View File

@@ -7,6 +7,7 @@ import {
getSettings, updateSettings, getRelatedVideos, getDownloads, rateVideo,
getBookmarks, createBookmark, updateBookmark, deleteBookmark, importChapters, clearChapters,
getCollections, addToCollection, getQueue,
getVideoComments, refreshVideoComments,
} from "../api";
import VideoCard from "../components/VideoCard";
@@ -465,6 +466,79 @@ function BookmarksSection({ videoId, videoRef }) {
}
function CommentsSection({ youtubeVideoId }) {
const qc = useQueryClient();
const { data: comments = [], isLoading } = useQuery({
queryKey: ["comments", youtubeVideoId],
queryFn: () => getVideoComments(youtubeVideoId).then(r => r.data),
staleTime: Infinity,
});
const refresh = useMutation({
mutationFn: () => refreshVideoComments(youtubeVideoId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["comments", youtubeVideoId] }),
});
if (isLoading) return null;
if (!comments.length) {
return (
<div className="flex items-center justify-between py-2">
<p className="text-sm text-zinc-500">No comments loaded yet</p>
<button
onClick={() => refresh.mutate()}
disabled={refresh.isPending}
className="text-xs px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors disabled:opacity-50 flex items-center gap-1.5"
>
{refresh.isPending ? (
<>
<span className="w-3 h-3 border border-zinc-500 border-t-transparent rounded-full animate-spin inline-block" />
Fetching
</>
) : "Load comments"}
</button>
</div>
);
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">
Top comments
</p>
<button
onClick={() => refresh.mutate()}
disabled={refresh.isPending}
className="text-xs text-zinc-600 hover:text-zinc-400 transition-colors"
>
{refresh.isPending ? "Refreshing…" : "Refresh"}
</button>
</div>
<div className="flex flex-col gap-3">
{comments.map((c, i) => (
<div key={i} className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-zinc-300">{c.author}</span>
{c.is_pinned && (
<span className="text-[10px] text-accent font-medium">Pinned</span>
)}
{c.likes > 0 && (
<span className="text-[10px] text-zinc-600 ml-auto">
👍 {c.likes >= 1000 ? `${(c.likes / 1000).toFixed(c.likes >= 10000 ? 0 : 1)}K` : c.likes}
</span>
)}
</div>
<p className="text-xs text-zinc-400 leading-relaxed">{c.text}</p>
</div>
))}
</div>
</div>
);
}
// ── Main component ───────────────────────────────────────────────────────────
export default function Watch() {
@@ -1016,6 +1090,11 @@ export default function Watch() {
<BookmarksSection videoId={video.id} videoRef={videoRef} />
)}
{/* Comments */}
{video?.youtube_video_id && (
<CommentsSection youtubeVideoId={video.youtube_video_id} />
)}
{/* Keyboard shortcuts hint */}
<p className={`text-xs text-zinc-700 text-center ${theater ? "max-w-4xl mx-auto w-full" : ""}`}>
Space/K · pause &nbsp;·&nbsp; F · fullscreen &nbsp;·&nbsp; M · mute &nbsp;·&nbsp; / seek 5s &nbsp;·&nbsp; / volume &nbsp;·&nbsp; ,/. speed &nbsp;·&nbsp; T · theater