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:
@@ -165,6 +165,20 @@ class UserTagAffinity(Base):
|
|||||||
updated_at = Column(DateTime, default=datetime.utcnow)
|
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):
|
class Collection(Base):
|
||||||
__tablename__ = "collections"
|
__tablename__ = "collections"
|
||||||
|
|
||||||
|
|||||||
@@ -638,6 +638,72 @@ def delete_bookmark(
|
|||||||
db.commit()
|
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])
|
@router.get("/queue", response_model=list[VideoDetail])
|
||||||
def queued_videos(
|
def queued_videos(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@@ -382,6 +382,46 @@ def fetch_channel_links(channel_id: str) -> list[str]:
|
|||||||
return list(channel_ids)
|
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 = {
|
QUALITY_FORMATS = {
|
||||||
"best": "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/22/18/bestvideo+bestaudio/best",
|
"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]",
|
"2160p": "bestvideo[ext=mp4][height<=2160]+bestaudio[ext=m4a]/bestvideo[height<=2160]+bestaudio/best[height<=2160]",
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export const longVideos = () => api.get("/videos/long");
|
|||||||
export const surpriseMe = () => api.get("/videos/surprise");
|
export const surpriseMe = () => api.get("/videos/surprise");
|
||||||
export const getVideo = (id) => api.get(`/videos/${id}`);
|
export const getVideo = (id) => api.get(`/videos/${id}`);
|
||||||
export const getVideoByYtId = (ytId) => api.get(`/videos/by-yt/${ytId}`);
|
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 updateProgress = (id, data) => api.patch(`/videos/${id}/progress`, data);
|
||||||
export const toggleQueue = (id) => api.post(`/videos/${id}/queue`);
|
export const toggleQueue = (id) => api.post(`/videos/${id}/queue`);
|
||||||
export const getQueue = () => api.get("/videos/queue");
|
export const getQueue = () => api.get("/videos/queue");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getSettings, updateSettings, getRelatedVideos, getDownloads, rateVideo,
|
getSettings, updateSettings, getRelatedVideos, getDownloads, rateVideo,
|
||||||
getBookmarks, createBookmark, updateBookmark, deleteBookmark, importChapters, clearChapters,
|
getBookmarks, createBookmark, updateBookmark, deleteBookmark, importChapters, clearChapters,
|
||||||
getCollections, addToCollection, getQueue,
|
getCollections, addToCollection, getQueue,
|
||||||
|
getVideoComments, refreshVideoComments,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import VideoCard from "../components/VideoCard";
|
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 ───────────────────────────────────────────────────────────
|
// ── Main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function Watch() {
|
export default function Watch() {
|
||||||
@@ -1016,6 +1090,11 @@ export default function Watch() {
|
|||||||
<BookmarksSection videoId={video.id} videoRef={videoRef} />
|
<BookmarksSection videoId={video.id} videoRef={videoRef} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
{video?.youtube_video_id && (
|
||||||
|
<CommentsSection youtubeVideoId={video.youtube_video_id} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Keyboard shortcuts hint */}
|
{/* Keyboard shortcuts hint */}
|
||||||
<p className={`text-xs text-zinc-700 text-center ${theater ? "max-w-4xl mx-auto w-full" : ""}`}>
|
<p className={`text-xs text-zinc-700 text-center ${theater ? "max-w-4xl mx-auto w-full" : ""}`}>
|
||||||
Space/K · pause · F · fullscreen · M · mute · ←/→ seek 5s · ↑/↓ volume · ,/. speed · T · theater
|
Space/K · pause · F · fullscreen · M · mute · ←/→ seek 5s · ↑/↓ volume · ,/. speed · T · theater
|
||||||
|
|||||||
Reference in New Issue
Block a user