feat(player): linkify descriptions and play YouTube links inline

- Narrow the title hover target to the actual text, not the whole row.
- Linkify descriptions: timestamps (mm:ss / hh:mm:ss) seek the player; emails
  become mailto:; hashtags link to YouTube's hashtag feed; other URLs open in a
  new tab. Blank lines are stripped so the popover isn't mostly whitespace.
- YouTube links play in the inline player: a link to the current video seeks
  (honoring t=), a link to another video navigates the player to it, with a Back
  button to the original. While on a linked video the title/author come from the
  player and its views/date/duration + a clickable channel come from the detail
  endpoint, which falls back to the YouTube API (videos.list, attributed to the
  user) for videos not in our DB.
This commit is contained in:
npeter83 2026-06-12 17:39:20 +02:00
parent 37a7723c8d
commit ea2e1fb5a7
3 changed files with 335 additions and 63 deletions

View file

@ -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"])
@ -296,12 +299,48 @@ def get_video_detail(
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."""
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 None:
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": v.id,
"description": v.description,
"like_count": v.like_count,
"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")
),
}