From cdf6520fd81d72e1b1c521782643d3abe0c89e74 Mon Sep 17 00:00:00 2001 From: Mattias Tall Date: Tue, 26 May 2026 11:15:41 +0200 Subject: [PATCH] Add lazy comment fetching to watch page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/models.py | 14 +++++++ backend/routers/videos.py | 66 ++++++++++++++++++++++++++++++ backend/services/ytdlp.py | 40 ++++++++++++++++++ frontend/src/api/index.js | 2 + frontend/src/pages/Watch.jsx | 79 ++++++++++++++++++++++++++++++++++++ 5 files changed, 201 insertions(+) diff --git a/backend/models.py b/backend/models.py index e14b057..e4edd2b 100644 --- a/backend/models.py +++ b/backend/models.py @@ -165,6 +165,20 @@ class UserTagAffinity(Base): updated_at = Column(DateTime, default=datetime.utcnow) +class VideoComment(Base): + __tablename__ = "video_comments" + + id = Column(Integer, primary_key=True, index=True) + video_id = Column(Integer, ForeignKey("videos.id", ondelete="CASCADE"), nullable=False, index=True) + youtube_comment_id = Column(String) + author = Column(String) + text = Column(Text) + likes = Column(Integer, default=0) + is_pinned = Column(Boolean, default=False) + published_at = Column(DateTime) + fetched_at = Column(DateTime, default=datetime.utcnow) + + class Collection(Base): __tablename__ = "collections" diff --git a/backend/routers/videos.py b/backend/routers/videos.py index abe7e67..3e3054b 100644 --- a/backend/routers/videos.py +++ b/backend/routers/videos.py @@ -638,6 +638,72 @@ def delete_bookmark( db.commit() +@router.get("/by-yt/{youtube_video_id}/comments") +def get_comments( + youtube_video_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + from ..models import VideoComment + video = db.query(Video).filter_by(youtube_video_id=youtube_video_id).first() + if not video: + return [] + comments = ( + db.query(VideoComment) + .filter_by(video_id=video.id) + .order_by(VideoComment.is_pinned.desc(), VideoComment.likes.desc()) + .all() + ) + return [ + { + "author": c.author, + "text": c.text, + "likes": c.likes, + "is_pinned": c.is_pinned, + "published_at": c.published_at, + } + for c in comments + ] + + +@router.post("/by-yt/{youtube_video_id}/comments/refresh") +def refresh_comments( + youtube_video_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + from ..models import VideoComment + video = db.query(Video).filter_by(youtube_video_id=youtube_video_id).first() + if not video: + raise HTTPException(status_code=404, detail="Video not found") + + # Clear existing and re-fetch + db.query(VideoComment).filter_by(video_id=video.id).delete() + db.commit() + + fetched = ytdlp.fetch_video_comments(youtube_video_id) + for c in fetched: + db.add(VideoComment(video_id=video.id, **c)) + db.commit() + + comments = ( + db.query(VideoComment) + .filter_by(video_id=video.id) + .order_by(VideoComment.is_pinned.desc(), VideoComment.likes.desc()) + .all() + ) + return [ + { + "author": c.author, + "text": c.text, + "likes": c.likes, + "is_pinned": c.is_pinned, + "published_at": c.published_at, + } + for c in comments + ] + + @router.get("/queue", response_model=list[VideoDetail]) def queued_videos( db: Session = Depends(get_db), diff --git a/backend/services/ytdlp.py b/backend/services/ytdlp.py index 3c7c570..7f6db03 100644 --- a/backend/services/ytdlp.py +++ b/backend/services/ytdlp.py @@ -382,6 +382,46 @@ def fetch_channel_links(channel_id: str) -> list[str]: return list(channel_ids) +def fetch_video_comments(youtube_video_id: str, max_comments: int = 20) -> list[dict]: + """Fetch top comments for a single video. Returns empty list on failure.""" + url = f"https://www.youtube.com/watch?v={youtube_video_id}" + args = [ + "yt-dlp", url, + "--dump-json", + "--write-comments", + "--extractor-args", f"youtube:max_comments={max_comments},max_comment_depth=1", + "--no-download", + "--no-playlist", + "--quiet", + *_cookie_args(), + ] + stdout, _, code = _run(args, timeout=60) + if not stdout.strip(): + return [] + try: + info = json.loads(stdout.strip()) + except json.JSONDecodeError: + return [] + + result = [] + for c in (info.get("comments") or []): + if c.get("parent") not in (None, "root"): + continue # skip replies + ts = c.get("timestamp") + published_at = datetime.utcfromtimestamp(ts) if ts else None + result.append({ + "youtube_comment_id": c.get("id"), + "author": c.get("author"), + "text": c.get("text"), + "likes": c.get("like_count") or 0, + "is_pinned": bool(c.get("is_pinned")), + "published_at": published_at, + }) + # Sort pinned first, then by likes + result.sort(key=lambda c: (not c["is_pinned"], -(c["likes"] or 0))) + return result[:max_comments] + + QUALITY_FORMATS = { "best": "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/22/18/bestvideo+bestaudio/best", "2160p": "bestvideo[ext=mp4][height<=2160]+bestaudio[ext=m4a]/bestvideo[height<=2160]+bestaudio/best[height<=2160]", diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index d0466fa..ab6f507 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -68,6 +68,8 @@ export const longVideos = () => api.get("/videos/long"); export const surpriseMe = () => api.get("/videos/surprise"); export const getVideo = (id) => api.get(`/videos/${id}`); export const getVideoByYtId = (ytId) => api.get(`/videos/by-yt/${ytId}`); +export const getVideoComments = (ytId) => api.get(`/videos/by-yt/${ytId}/comments`); +export const refreshVideoComments = (ytId) => api.post(`/videos/by-yt/${ytId}/comments/refresh`); export const updateProgress = (id, data) => api.patch(`/videos/${id}/progress`, data); export const toggleQueue = (id) => api.post(`/videos/${id}/queue`); export const getQueue = () => api.get("/videos/queue"); diff --git a/frontend/src/pages/Watch.jsx b/frontend/src/pages/Watch.jsx index 658339c..e12d703 100644 --- a/frontend/src/pages/Watch.jsx +++ b/frontend/src/pages/Watch.jsx @@ -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 ( +
+

No comments loaded yet

+ +
+ ); + } + + return ( +
+
+

+ Top comments +

+ +
+
+ {comments.map((c, i) => ( +
+
+ {c.author} + {c.is_pinned && ( + Pinned + )} + {c.likes > 0 && ( + + πŸ‘ {c.likes >= 1000 ? `${(c.likes / 1000).toFixed(c.likes >= 10000 ? 0 : 1)}K` : c.likes} + + )} +
+

{c.text}

+
+ ))} +
+
+ ); +} + + // ── Main component ─────────────────────────────────────────────────────────── export default function Watch() { @@ -1016,6 +1090,11 @@ export default function Watch() { )} + {/* Comments */} + {video?.youtube_video_id && ( + + )} + {/* Keyboard shortcuts hint */}

Space/K Β· pause  Β·  F Β· fullscreen  Β·  M Β· mute  Β·  ←/β†’ seek 5s  Β·  ↑/↓ volume  Β·  ,/. speed  Β·  T Β· theater