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

@@ -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"

View File

@@ -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),

View File

@@ -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]",