import os import random from datetime import datetime from typing import Optional from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session from sqlalchemy import text from ..auth_utils import get_current_user from ..config import settings from ..database import get_db from ..models import Channel, User, UserSettings, UserTagAffinity, UserVideo, Video from ..services import ytdlp from ..services.scoring import get_surprise_videos, get_discovery_injection router = APIRouter() def _update_affinity(db: Session, user_id: int, video: Video, delta: float): """Adjust tag/category affinity scores for a video. delta > 0 = positive signal.""" import json as _json tags = [] if video.category: tags.append(video.category.lower().strip()) if video.tags: try: for t in _json.loads(video.tags)[:8]: if t and t.strip(): tags.append(t.lower().strip()) except Exception: pass for tag in set(tags): existing = db.query(UserTagAffinity).filter_by(user_id=user_id, tag=tag).first() if existing: existing.score = max(existing.score + delta, -20.0) existing.updated_at = datetime.utcnow() else: db.add(UserTagAffinity(user_id=user_id, tag=tag, score=delta)) class VideoDetail(BaseModel): id: int youtube_video_id: str title: str description: Optional[str] thumbnail_url: Optional[str] duration_seconds: Optional[int] published_at: Optional[datetime] channel_id: Optional[int] = None channel_name: Optional[str] channel_youtube_id: Optional[str] tags: Optional[str] category: Optional[str] is_downloaded: bool = False is_watched: bool = False liked: bool = False watch_progress_seconds: int = 0 queued: bool = False rating: Optional[int] = None channel_followed: bool = False download_resolution: Optional[str] = None local_file_url: Optional[str] = None is_recommended: bool = False view_count: Optional[int] = None like_count: Optional[int] = None dislike_count: Optional[int] = None channel_thumbnail_url: Optional[str] = None model_config = {"from_attributes": True} def _local_file_url(file_path: Optional[str]) -> Optional[str]: if not file_path or not os.path.exists(file_path): return None try: rel = os.path.relpath(file_path, settings.download_path) return f"/files/{rel}" except ValueError: return None class ProgressUpdate(BaseModel): watch_progress_seconds: int watched: Optional[bool] = None def _get_uv(db: Session, user_id: int, video_id: int) -> UserVideo: uv = db.query(UserVideo).filter_by(user_id=user_id, video_id=video_id).first() if not uv: uv = UserVideo(user_id=user_id, video_id=video_id) db.add(uv) db.flush() return uv @router.get("/history", response_model=list[VideoDetail]) def watch_history( limit: int = 25, offset: int = 0, channel_id: Optional[int] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): where_extra = "AND v.channel_id = :channel_id" if channel_id else "" params: dict = {"user_id": current_user.id, "limit": limit, "offset": offset} if channel_id: params["channel_id"] = channel_id rows = db.execute( text(_VIDEO_SELECT + f""" WHERE uv.user_id = :user_id AND uv.watched = 1 {where_extra} ORDER BY uv.last_watched_at DESC LIMIT :limit OFFSET :offset """), params, ).mappings().all() return [_row_to_detail(r) for r in rows] @router.get("/home-feed", response_model=list[VideoDetail]) def home_feed( limit: int = 25, offset: int = 0, mode: str = "ranked", # ranked | chronological | random | inbox duration: str = "", # "" | short | medium | long db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): duration_clause = { "short": "AND v.duration_seconds <= 600", "medium": "AND v.duration_seconds > 600 AND v.duration_seconds <= 1800", "long": "AND v.duration_seconds > 1800", }.get(duration, "") user_settings = db.query(UserSettings).filter_by(user_id=current_user.id).first() hide_watched = user_settings.hide_watched_from_feed if user_settings else False w_recency = (user_settings.feed_weight_recency if user_settings and user_settings.feed_weight_recency is not None else 5.0) / 5.0 w_affinity = (user_settings.feed_weight_affinity if user_settings and user_settings.feed_weight_affinity is not None else 5.0) / 5.0 w_channel = (user_settings.feed_weight_channel if user_settings and user_settings.feed_weight_channel is not None else 5.0) / 5.0 if mode == "chronological": rows = db.execute( text(f""" SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url, v.duration_seconds, v.published_at, v.tags, v.category, c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id, c.thumbnail_url AS channel_thumbnail_url, COALESCE(uv.watched, 0) AS watched, COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds, COALESCE(uv.downloaded, 0) AS is_downloaded, COALESCE(uv.queued, 0) AS queued, NULL AS file_path FROM videos v JOIN channels c ON v.channel_id = c.id JOIN user_channels uc ON c.id = uc.channel_id AND uc.user_id = :user_id AND uc.status = 'followed' LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id WHERE (:hide_watched = 0 OR COALESCE(uv.watched, 0) = 0) AND (uc.muted_until IS NULL OR datetime(uc.muted_until) < datetime('now')) {duration_clause} ORDER BY v.published_at DESC NULLS LAST LIMIT :limit OFFSET :offset """), {"user_id": current_user.id, "limit": limit, "offset": offset, "hide_watched": 1 if hide_watched else 0}, ).mappings().all() return [ VideoDetail(**{k: v for k, v in dict(r).items() if k not in ("watched",)}, is_watched=bool(r["watched"])) for r in rows ] if mode == "random": # Random videos from the discovery pool — unweighted, no score ordering rows = db.execute( text(f""" SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url, v.duration_seconds, v.published_at, v.tags, v.category, c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id, c.thumbnail_url AS channel_thumbnail_url, COALESCE(uv.watched, 0) AS watched, COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds, COALESCE(uv.downloaded, 0) AS is_downloaded, COALESCE(uv.queued, 0) AS queued, NULL AS file_path FROM videos v JOIN channels c ON v.channel_id = c.id JOIN discovery_queue dq ON c.id = dq.channel_id LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id WHERE dq.user_id = :user_id AND dq.seen = 0 AND v.published_at IS NOT NULL AND dq.channel_id NOT IN ( SELECT channel_id FROM user_channels WHERE user_id = :user_id AND status IN ('followed', 'dismissed') ) {duration_clause} ORDER BY RANDOM() LIMIT :limit OFFSET :offset """), {"user_id": current_user.id, "limit": limit, "offset": offset}, ).mappings().all() return [ VideoDetail(**{k: v for k, v in dict(r).items() if k not in ("watched",)}, is_watched=bool(r["watched"]), is_recommended=True) for r in rows ] if mode == "inbox": rows = db.execute( text(f""" SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url, v.duration_seconds, v.published_at, v.tags, v.category, c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id, c.thumbnail_url AS channel_thumbnail_url, COALESCE(uv.watched, 0) AS watched, COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds, COALESCE(uv.downloaded, 0) AS is_downloaded, COALESCE(uv.queued, 0) AS queued, NULL AS file_path FROM videos v JOIN channels c ON v.channel_id = c.id JOIN user_channels uc ON c.id = uc.channel_id AND uc.user_id = :user_id AND uc.status = 'followed' LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id WHERE COALESCE(uv.watched, 0) = 0 AND ( (uc.last_seen_at IS NULL AND v.indexed_at >= datetime('now', '-7 days')) OR (uc.last_seen_at IS NOT NULL AND v.indexed_at > uc.last_seen_at) ) {duration_clause} ORDER BY v.indexed_at DESC LIMIT :limit OFFSET :offset """), {"user_id": current_user.id, "limit": limit, "offset": offset}, ).mappings().all() return [ VideoDetail(**{k: v for k, v in dict(r).items() if k not in ("watched",)}, is_watched=bool(r["watched"])) for r in rows ] # mode == "ranked" (default) rows = db.execute( text(f""" WITH channel_stats AS ( SELECT v.channel_id, COUNT(CASE WHEN uv.watched = 1 THEN 1 END) AS watched_count, COUNT(CASE WHEN uv.liked = 1 THEN 1 END) AS liked_count, SUM(CASE WHEN uv.rating IS NOT NULL THEN uv.rating ELSE 0 END) AS rating_sum, AVG(CASE WHEN uv.completion_percent IS NOT NULL THEN uv.completion_percent END) AS avg_completion_pct FROM videos v LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id GROUP BY v.channel_id ), scored AS ( SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url, v.duration_seconds, v.published_at, v.tags, v.category, c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id, c.thumbnail_url AS channel_thumbnail_url, COALESCE(uv.watched, 0) AS watched, COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds, COALESCE(uv.downloaded, 0) AS is_downloaded, COALESCE(uv.queued, 0) AS queued, uv.rating AS rating, NULL AS file_path, (SQRT(CAST(COALESCE(cs.watched_count, 0) AS REAL)) * 5.0 + COALESCE(cs.liked_count, 0) * 10.0 + COALESCE(cs.rating_sum, 0) * 8.0 + COALESCE(cs.avg_completion_pct, 50.0) * 0.08) * :w_channel + MAX(COALESCE(julianday(v.published_at) - julianday('now'), -90), -365) * :w_recency + COALESCE(( SELECT COALESCE(SUM(uta.score), 0) FROM user_tag_affinity uta WHERE uta.user_id = :user_id AND (uta.tag = LOWER(COALESCE(v.category, '')) OR instr(LOWER(COALESCE(v.tags, '')), '"' || uta.tag || '"') > 0) LIMIT 5 ), 0) * :w_affinity AS score, ROW_NUMBER() OVER ( PARTITION BY v.channel_id ORDER BY v.published_at DESC NULLS LAST, v.id DESC ) AS rn FROM videos v JOIN channels c ON v.channel_id = c.id JOIN user_channels uc ON c.id = uc.channel_id AND uc.user_id = :user_id AND uc.status = 'followed' LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id LEFT JOIN channel_stats cs ON v.channel_id = cs.channel_id WHERE (:hide_watched = 0 OR COALESCE(uv.watched, 0) = 0) AND (uc.muted_until IS NULL OR datetime(uc.muted_until) < datetime('now')) {duration_clause} ) SELECT * FROM scored WHERE rn <= 3 ORDER BY score DESC, RANDOM() LIMIT :limit OFFSET :offset """), {"user_id": current_user.id, "limit": limit, "offset": offset, "hide_watched": 1 if hide_watched else 0, "w_recency": w_recency, "w_affinity": w_affinity, "w_channel": w_channel}, ).mappings().all() followed = [ VideoDetail(**{k: v for k, v in dict(r).items() if k not in ("watched", "score", "rn")}, is_watched=bool(r["watched"])) for r in rows ] # Inject discovery cards on every page: 1 every 5 followed cards. disc_per_page = max(limit // 5, 1) disc_offset = (offset // limit) * disc_per_page if limit > 0 else 0 disc_rows = db.execute( text(""" SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url, v.duration_seconds, v.published_at, v.tags, v.category, c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id, c.thumbnail_url AS channel_thumbnail_url FROM discovery_queue dq JOIN channels c ON dq.channel_id = c.id JOIN videos v ON v.channel_id = c.id WHERE dq.user_id = :user_id AND dq.seen = 0 AND v.published_at IS NOT NULL AND dq.channel_id NOT IN ( SELECT channel_id FROM user_channels WHERE user_id = :user_id AND status IN ('followed', 'dismissed') ) AND v.id = ( SELECT id FROM videos WHERE channel_id = c.id AND published_at IS NOT NULL ORDER BY published_at DESC LIMIT 1 ) ORDER BY dq.score DESC LIMIT :disc_limit OFFSET :disc_offset """), {"user_id": current_user.id, "disc_limit": disc_per_page, "disc_offset": disc_offset}, ).mappings().all() disc = [ VideoDetail(**{k: v for k, v in dict(r).items()}, is_recommended=True, is_watched=False, is_downloaded=False) for r in disc_rows ] # Interleave: one discovery card every 5 followed cards result: list[VideoDetail] = [] disc_iter = iter(disc) for i, v in enumerate(followed): if i > 0 and i % 5 == 0: rec = next(disc_iter, None) if rec: result.append(rec) result.append(v) result.extend(disc_iter) return result @router.get("/continue-watching", response_model=list[VideoDetail]) def continue_watching( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): rows = db.execute( text(""" SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url, v.duration_seconds, v.published_at, v.tags, v.category, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id, uv.watched, uv.watch_progress_seconds, uv.downloaded AS is_downloaded, uv.queued FROM user_videos uv JOIN videos v ON uv.video_id = v.id LEFT JOIN channels c ON v.channel_id = c.id WHERE uv.user_id = :user_id AND uv.watch_progress_seconds > 0 AND (uv.watched IS NULL OR uv.watched = 0) ORDER BY uv.last_watched_at DESC LIMIT 20 """), {"user_id": current_user.id}, ).mappings().all() return [VideoDetail(**{k: v for k, v in dict(r).items() if k != "watched"}, is_watched=bool(r["watched"])) for r in rows] @router.get("/long", response_model=list[VideoDetail]) def long_videos( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): rows = db.execute( text(""" SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url, v.duration_seconds, v.published_at, v.tags, v.category, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id, COALESCE(uv.watched, 0) AS watched, COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds, COALESCE(uv.downloaded, 0) AS is_downloaded, COALESCE(uv.queued, 0) AS queued FROM videos v LEFT JOIN channels c ON v.channel_id = c.id LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id WHERE uv.downloaded = 1 AND v.duration_seconds > 2700 ORDER BY RANDOM() LIMIT 20 """), {"user_id": current_user.id}, ).mappings().all() return [VideoDetail(**dict(r), is_watched=bool(r["watched"])) for r in rows] @router.get("/surprise", response_model=list[dict]) def surprise_me( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): results = get_surprise_videos(db, current_user.id, limit=10) # 1 in 5 chance: inject a discovery item if random.random() < 0.2: injection = get_discovery_injection(db, current_user.id) if injection and results: results.insert(random.randint(0, min(4, len(results))), {**injection, "is_discovery": True}) return results _VIDEO_SELECT = """ SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url, v.duration_seconds, v.published_at, v.tags, v.category, v.view_count, v.like_count, v.dislike_count, c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id, c.thumbnail_url AS channel_thumbnail_url, COALESCE(uv.watched, 0) AS watched, COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds, COALESCE(uv.downloaded, 0) AS is_downloaded, COALESCE(uv.liked, 0) AS liked, COALESCE(uv.queued, 0) AS queued, uv.rating AS rating, CASE WHEN uc.id IS NOT NULL THEN 1 ELSE 0 END AS channel_followed, d.file_path, d.resolution AS download_resolution FROM videos v LEFT JOIN channels c ON v.channel_id = c.id LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id LEFT JOIN downloads d ON v.id = d.video_id AND d.user_id = :user_id AND d.status = 'complete' LEFT JOIN user_channels uc ON c.id = uc.channel_id AND uc.user_id = :user_id AND uc.status = 'followed' """ def _row_to_detail(row) -> VideoDetail: r = dict(row) return VideoDetail( **{k: v for k, v in r.items() if k not in ("watched", "file_path", "score")}, is_watched=bool(r["watched"]), local_file_url=_local_file_url(r.get("file_path")), ) def _upsert_video_from_yt(db: Session, youtube_video_id: str) -> bool: """Fetch fresh metadata from yt-dlp and upsert video + channel. Returns True if successful.""" meta = ytdlp.fetch_video_metadata(youtube_video_id) if not meta: return False ch_data = meta.pop("channel", {}) or {} yt_channel_id = ch_data.get("youtube_channel_id") channel = None if yt_channel_id: channel = db.query(Channel).filter_by(youtube_channel_id=yt_channel_id).first() if not channel: channel = Channel(**{k: v for k, v in ch_data.items() if hasattr(Channel, k)}) db.add(channel) db.flush() else: for k, v in ch_data.items(): if hasattr(channel, k) and v is not None and k != "thumbnail_url": setattr(channel, k, v) video = db.query(Video).filter_by(youtube_video_id=youtube_video_id).first() if not video: video = Video( channel_id=channel.id if channel else None, **{k: v for k, v in meta.items() if hasattr(Video, k)}, ) db.add(video) else: for k, v in meta.items(): if not hasattr(video, k) or v is None: continue # Don't overwrite already-set description/tags with empty strings from yt-dlp if k in ("description", "tags") and v == "" and getattr(video, k) is not None: continue setattr(video, k, v) if channel: video.channel_id = channel.id # Fetch crowdsourced dislike count if not already known if not video.dislike_count: dislikes = ytdlp.fetch_dislike_count(youtube_video_id) if dislikes is not None: video.dislike_count = dislikes db.commit() return True class BookmarkOut(BaseModel): id: int video_id: int timestamp_seconds: int note: Optional[str] source: str = "manual" created_at: datetime model_config = {"from_attributes": True} class BookmarkCreate(BaseModel): timestamp_seconds: int note: Optional[str] = "" class BookmarkPatch(BaseModel): note: str @router.get("/{video_id}/bookmarks", response_model=list[BookmarkOut]) def get_bookmarks( video_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): from ..models import VideoBookmark items = db.query(VideoBookmark).filter_by(user_id=current_user.id, video_id=video_id).order_by(VideoBookmark.timestamp_seconds).all() return [BookmarkOut.model_validate(b) for b in items] @router.post("/{video_id}/bookmarks", response_model=BookmarkOut, status_code=201) def create_bookmark( video_id: int, body: BookmarkCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): from ..models import VideoBookmark video = db.query(Video).filter(Video.id == video_id).first() if not video: raise HTTPException(status_code=404, detail="Video not found") bm = VideoBookmark( user_id=current_user.id, video_id=video_id, timestamp_seconds=body.timestamp_seconds, note=body.note or "", ) db.add(bm) _update_affinity(db, current_user.id, video, +2.0) db.commit() db.refresh(bm) return BookmarkOut.model_validate(bm) @router.post("/{video_id}/bookmarks/import-chapters", response_model=list[BookmarkOut], status_code=200) def import_chapters( video_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Create auto bookmarks from stored chapter data. Idempotent — skips if already imported. If chapters have never been fetched (NULL), refreshes metadata from yt-dlp first.""" from ..models import VideoBookmark import json as _json video = db.query(Video).filter(Video.id == video_id).first() if not video: return [] # chapters=NULL means never fetched; fetch now and cache the result (even if empty) if video.chapters is None: _upsert_video_from_yt(db, video.youtube_video_id) db.refresh(video) # Mark as checked even if no chapters found, so we don't re-fetch next time if video.chapters is None: video.chapters = "[]" db.commit() chapters = _json.loads(video.chapters or "[]") # Skip if trivial (single chapter) or already imported if len(chapters) < 2: return [] existing = db.query(VideoBookmark).filter_by(user_id=current_user.id, video_id=video_id, source="auto").first() if existing: return [] created = [] for ch in chapters: bm = VideoBookmark( user_id=current_user.id, video_id=video_id, timestamp_seconds=ch["start_time"], note=ch["title"], source="auto", ) db.add(bm) created.append(bm) db.commit() for bm in created: db.refresh(bm) return [BookmarkOut.model_validate(bm) for bm in created] @router.delete("/{video_id}/bookmarks/clear-chapters", status_code=204) def clear_chapters( video_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Delete all auto-imported chapter bookmarks for this video.""" from ..models import VideoBookmark db.query(VideoBookmark).filter_by(user_id=current_user.id, video_id=video_id, source="auto").delete() db.commit() @router.patch("/{video_id}/bookmarks/{bookmark_id}", response_model=BookmarkOut) def update_bookmark( video_id: int, bookmark_id: int, body: BookmarkPatch, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): from ..models import VideoBookmark bm = db.query(VideoBookmark).filter_by(id=bookmark_id, user_id=current_user.id, video_id=video_id).first() if not bm: raise HTTPException(status_code=404, detail="Bookmark not found") bm.note = body.note db.commit() db.refresh(bm) return BookmarkOut.model_validate(bm) @router.delete("/{video_id}/bookmarks/{bookmark_id}", status_code=204) def delete_bookmark( video_id: int, bookmark_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): from ..models import VideoBookmark bm = db.query(VideoBookmark).filter_by(id=bookmark_id, user_id=current_user.id, video_id=video_id).first() if bm: db.delete(bm) db.commit() @router.get("/by-yt/{youtube_video_id}/subs") def get_available_subs( youtube_video_id: str, current_user: User = Depends(get_current_user), ): """Return subtitle languages available on YouTube for a video (yt-dlp call, slow).""" return ytdlp.fetch_available_subs(youtube_video_id) @router.post("/by-yt/{youtube_video_id}/download-subs") def download_subs( youtube_video_id: str, body: dict, current_user: User = Depends(get_current_user), ): """Download subtitle file(s) only for an already-downloaded video.""" langs = (body.get("subtitle_langs") or "").strip() if not langs: raise HTTPException(status_code=400, detail="subtitle_langs required") ok = ytdlp.download_subs_only(youtube_video_id, langs) if not ok: raise HTTPException(status_code=500, detail="Subtitle download failed") return {"ok": True} @router.get("/by-yt/{youtube_video_id}/subtitle-files") def list_subtitle_files( youtube_video_id: str, current_user: User = Depends(get_current_user), ): """List .vtt subtitle files already on disk for a downloaded video (instant).""" import re as _re from pathlib import Path from ..config import settings as _cfg pat = _re.compile(rf'^{_re.escape(youtube_video_id)}\.(.+)\.vtt$') subs = [] try: for f in Path(_cfg.download_path).iterdir(): m = pat.match(f.name) if m: subs.append({"lang": m.group(1), "url": f"/files/{f.name}"}) except Exception: pass return sorted(subs, key=lambda s: s["lang"]) @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), current_user: User = Depends(get_current_user), ): rows = db.execute( text(_VIDEO_SELECT + """ WHERE uv.user_id = :user_id AND uv.queued = 1 ORDER BY uv.id DESC """), {"user_id": current_user.id}, ).mappings().all() return [_row_to_detail(r) for r in rows] @router.get("/liked", response_model=list[VideoDetail]) def liked_videos( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): rows = db.execute( text(_VIDEO_SELECT + """ WHERE uv.user_id = :user_id AND uv.liked = 1 ORDER BY uv.liked_at DESC """), {"user_id": current_user.id}, ).mappings().all() return [_row_to_detail(r) for r in rows] @router.get("/by-yt/{youtube_video_id}", response_model=VideoDetail) def get_video_by_yt_id( youtube_video_id: str, background_tasks: BackgroundTasks, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): existing = db.query(Video).filter_by(youtube_video_id=youtube_video_id).first() if not existing or not existing.title: # Video unknown — must block to get at least a title before we can render anything _upsert_video_from_yt(db, youtube_video_id) elif existing.description is None or existing.chapters is None: # Video known but missing enrichment — fetch in background, return immediately from ..database import SessionLocal def _enrich(yt_id: str): bg_db = SessionLocal() try: _upsert_video_from_yt(bg_db, yt_id) finally: bg_db.close() background_tasks.add_task(_enrich, youtube_video_id) row = db.execute( text(_VIDEO_SELECT + "WHERE v.youtube_video_id = :yt_id"), {"user_id": current_user.id, "yt_id": youtube_video_id}, ).mappings().first() if not row: raise HTTPException(status_code=404, detail="Video not found") return _row_to_detail(row) @router.get("/{video_id}/related", response_model=list[VideoDetail]) def related_videos( video_id: int, mode: str = "weighted", # weighted | random db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Videos from discovery-queue channels, ordered by discovery score or randomly.""" video = db.query(Video).filter(Video.id == video_id).first() if not video: raise HTTPException(status_code=404, detail="Video not found") if mode == "random": order_clause = "ORDER BY RANDOM()" else: order_clause = "ORDER BY rn ASC, score DESC, RANDOM()" rows = db.execute( text(f""" SELECT * FROM ( SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url, v.duration_seconds, v.published_at, v.tags, v.category, c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id, COALESCE(uv.watched, 0) AS watched, COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds, COALESCE(uv.downloaded, 0) AS is_downloaded, COALESCE(uv.liked, 0) AS liked, COALESCE(uv.queued, 0) AS queued, 0 AS channel_followed, NULL AS file_path, NULL AS download_resolution, dq.score, ROW_NUMBER() OVER ( PARTITION BY v.channel_id ORDER BY v.published_at DESC NULLS LAST ) AS rn FROM videos v JOIN channels c ON v.channel_id = c.id JOIN discovery_queue dq ON c.id = dq.channel_id LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id WHERE dq.user_id = :user_id AND dq.seen = 0 AND dq.channel_id NOT IN ( SELECT channel_id FROM user_channels WHERE user_id = :user_id AND status IN ('followed', 'dismissed') ) AND v.channel_id != :channel_id ) WHERE rn <= 2 {order_clause} LIMIT 14 """), {"user_id": current_user.id, "channel_id": video.channel_id or 0}, ).mappings().all() return [_row_to_detail(r) for r in rows] @router.get("/{video_id}", response_model=VideoDetail) def get_video( video_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = db.execute( text(_VIDEO_SELECT + "WHERE v.id = :video_id"), {"user_id": current_user.id, "video_id": video_id}, ).mappings().first() if not row: raise HTTPException(status_code=404, detail="Video not found") return _row_to_detail(row) @router.patch("/{video_id}/progress") def update_progress( video_id: int, body: ProgressUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): video = db.query(Video).filter(Video.id == video_id).first() if not video: raise HTTPException(status_code=404, detail="Video not found") uv = _get_uv(db, current_user.id, video_id) from ..models import Download from datetime import timedelta prev_watched = bool(uv.watched) uv.watch_progress_seconds = body.watch_progress_seconds uv.last_watched_at = datetime.utcnow() # Compute completion percent whenever we have duration if video.duration_seconds and video.duration_seconds > 0: uv.completion_percent = round( min(body.watch_progress_seconds / video.duration_seconds * 100, 100), 1 ) if body.watched is not None: if body.watched and not prev_watched: # First completion — positive affinity signal uv.watched = True _update_affinity(db, current_user.id, video, +2.0) dl = db.query(Download).filter_by( user_id=current_user.id, video_id=video_id, status="complete" ).filter(Download.pending_delete_at.is_(None)).first() if dl: dl.pending_delete_at = datetime.utcnow() + timedelta(days=7) elif body.watched and prev_watched: # Rewatch — strongest positive signal uv.rewatch_count = (uv.rewatch_count or 0) + 1 _update_affinity(db, current_user.id, video, +3.0) elif not body.watched: uv.watched = False # Early bail signal: navigating away before 20% without marking watched elif not prev_watched and video.duration_seconds and video.duration_seconds > 60: pct = body.watch_progress_seconds / video.duration_seconds if pct < 0.20: _update_affinity(db, current_user.id, video, -0.5) # Backend safety net: auto-mark watched at ≥90% completion even if the frontend # didn't send watched=True (e.g. browser closed before debounce fired) if (not prev_watched and not uv.watched and uv.completion_percent is not None and uv.completion_percent >= 90 and video.duration_seconds and video.duration_seconds > 60): uv.watched = True _update_affinity(db, current_user.id, video, +2.0) dl = db.query(Download).filter_by( user_id=current_user.id, video_id=video_id, status="complete" ).filter(Download.pending_delete_at.is_(None)).first() if dl: dl.pending_delete_at = datetime.utcnow() + timedelta(days=7) db.commit() return {"ok": True} @router.post("/{video_id}/like") def toggle_like( video_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): from ..models import DiscoveryQueue, UserChannel video = db.query(Video).filter(Video.id == video_id).first() if not video: raise HTTPException(status_code=404, detail="Video not found") uv = _get_uv(db, current_user.id, video_id) uv.liked = not uv.liked uv.liked_at = datetime.utcnow() if uv.liked else None # When liking a video from a channel not yet followed, boost that channel's # discovery score directly so it rises to the top of recommendations. if uv.liked and video.channel_id: uc = db.query(UserChannel).filter_by( user_id=current_user.id, channel_id=video.channel_id, ).first() not_followed = not uc or uc.status not in ("followed", "dismissed") if not_followed: dq = db.query(DiscoveryQueue).filter_by( user_id=current_user.id, channel_id=video.channel_id, ).first() if dq: dq.score += 30.0 else: db.add(DiscoveryQueue( user_id=current_user.id, channel_id=video.channel_id, score=30.0, source="liked", )) # Affinity: like = strong positive, unlike = remove that boost _update_affinity(db, current_user.id, video, +3.0 if uv.liked else -3.0) db.commit() return {"liked": uv.liked} class RateBody(BaseModel): rating: int # 1 = thumbs up, -1 = thumbs down, 0 = clear @router.post("/{video_id}/rate") def rate_video( video_id: int, body: RateBody, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): from ..models import DiscoveryQueue, UserChannel video = db.query(Video).filter(Video.id == video_id).first() if not video: raise HTTPException(status_code=404, detail="Video not found") uv = _get_uv(db, current_user.id, video_id) old_rating = uv.rating or 0 new_rating = body.rating if body.rating in (1, -1) else None uv.rating = new_rating # Adjust discovery score for unfolowed channels if video.channel_id: uc = db.query(UserChannel).filter_by( user_id=current_user.id, channel_id=video.channel_id, ).first() if not uc or uc.status not in ("followed", "dismissed"): delta = (body.rating if body.rating in (1, -1) else 0) * 15 - old_rating * 15 if delta != 0: dq = db.query(DiscoveryQueue).filter_by( user_id=current_user.id, channel_id=video.channel_id, ).first() if dq: dq.score = max(dq.score + delta, -50) if dq.score < 0: dq.seen = True elif delta > 0: db.add(DiscoveryQueue( user_id=current_user.id, channel_id=video.channel_id, score=float(delta), source="rated", )) db.commit() return {"rating": uv.rating} @router.post("/{video_id}/queue") def toggle_queue( video_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): video = db.query(Video).filter(Video.id == video_id).first() if not video: raise HTTPException(status_code=404, detail="Video not found") uv = _get_uv(db, current_user.id, video_id) uv.queued = not uv.queued db.commit() return {"queued": uv.queued}