diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 2fc5044..2917244 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -4,9 +4,12 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import Select, and_, false, func, or_, select from sqlalchemy.orm import Session, aliased +from app import quota from app.auth import current_user from app.db import get_db from app.models import Channel, ChannelTag, Subscription, Tag, User, Video, VideoState +from app.sync.videos import parse_iso8601_duration +from app.youtube.client import YouTubeClient, YouTubeError router = APIRouter(prefix="/api", tags=["feed"]) @@ -287,3 +290,57 @@ def set_video_state( row.watched_at = datetime.now(timezone.utc) if status == "watched" else row.watched_at db.commit() return {"video_id": video_id, "status": status} + + +@router.get("/videos/{video_id}") +def get_video_detail( + video_id: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """On-demand detail (description, like count) — kept out of the feed list payload + so the feed stays lean; fetched lazily, e.g. for the title hover popover. + + Videos we already store are served from the DB for free. A video that isn't in + our DB (e.g. a YouTube link inside another video's description that the in-app + player navigated to) is resolved via the YouTube API (videos.list, 1 unit, + attributed to the requesting user).""" + v = db.get(Video, video_id) + if v is not None: + return { + "id": v.id, + "description": v.description, + "like_count": v.like_count, + "in_db": True, + "channel_id": v.channel_id, + "channel_title": v.channel.title if v.channel else None, + "published_at": v.published_at.isoformat() if v.published_at else None, + "view_count": v.view_count, + "duration_seconds": v.duration_seconds, + } + + try: + with quota.attribute(user.id, "video_lookup"), YouTubeClient(db, user) as yt: + items = yt.get_videos([video_id]) + except YouTubeError as exc: + raise HTTPException(status_code=502, detail=f"YouTube lookup failed: {exc}") + if not items: + raise HTTPException(status_code=404, detail="Unknown video") + + snippet = items[0].get("snippet", {}) + stats = items[0].get("statistics", {}) + likes = stats.get("likeCount") + views = stats.get("viewCount") + return { + "id": video_id, + "description": snippet.get("description"), + "like_count": int(likes) if likes is not None else None, + "in_db": False, + "channel_id": snippet.get("channelId"), + "channel_title": snippet.get("channelTitle"), + "published_at": snippet.get("publishedAt"), + "view_count": int(views) if views is not None else None, + "duration_seconds": parse_iso8601_duration( + items[0].get("contentDetails", {}).get("duration") + ), + } diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 747149b..2652be3 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -3,6 +3,7 @@ import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-quer import { api, type FeedFilters, type Video } from "../lib/api"; import { notify } from "../lib/notifications"; import VideoCard from "./VideoCard"; +import PlayerModal from "./PlayerModal"; const PAGE = 60; @@ -31,6 +32,7 @@ export default function Feed({ view: "grid" | "list"; }) { const [overrides, setOverrides] = useState>({}); + const [activeVideo, setActiveVideo] = useState