"""Local playlists: per-user named, ordered collections of videos from the shared catalog. Foundation phase = local only (create / rename / delete, add / remove / reorder items). YouTube two-way sync (mirror existing playlists, push edits) lands in later phases.""" from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func, select, and_ from sqlalchemy.orm import Session, aliased from app import quota from app.auth import current_user, has_read_scope, has_write_scope from app.db import get_db from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState from app.routes.feed import _saved_expr, _serialize from app.sync.playlists import plan_push, push_playlist, sync_user_playlists from app.youtube.client import YouTubeClient, YouTubeError router = APIRouter(prefix="/api/playlists", tags=["playlists"]) def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist: pl = db.get(Playlist, playlist_id) if pl is None or pl.user_id != user.id: raise HTTPException(status_code=404, detail="Unknown playlist") return pl def _mark_dirty(pl: Playlist) -> None: """Flag a YouTube-linked playlist as having unpushed local edits, so the UI can offer a 'Sync to YouTube' and the read-sync won't clobber them. Covers both exported local playlists and edited YouTube mirrors; no-op for un-linked lists and Watch later.""" if pl.kind != "watch_later" and pl.yt_playlist_id: pl.dirty = True def _summary( db: Session, pl: Playlist, count: int, total_duration: int = 0, has_video: bool | None = None, ) -> dict: cover = db.scalar( select(Video.thumbnail_url) .join(PlaylistItem, PlaylistItem.video_id == Video.id) .where(PlaylistItem.playlist_id == pl.id) .order_by(PlaylistItem.position) .limit(1) ) out = { "id": pl.id, "name": pl.name, "kind": pl.kind, "source": pl.source, "yt_playlist_id": pl.yt_playlist_id, "dirty": pl.dirty, "item_count": count, "total_duration_seconds": total_duration, "cover_thumbnail": cover, } if has_video is not None: out["has_video"] = has_video return out def _total_duration(db: Session, playlist_id: int) -> int: return ( db.scalar( select(func.coalesce(func.sum(Video.duration_seconds), 0)) .select_from(PlaylistItem) .join(Video, Video.id == PlaylistItem.video_id) .where(PlaylistItem.playlist_id == playlist_id) ) or 0 ) @router.get("") def list_playlists( contains: str | None = None, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> list[dict]: """The user's playlists (newest custom order). With `contains=`, each entry also reports whether that video is already in it — used by the add-to-playlist popover.""" pls = ( db.execute( select(Playlist) .where(Playlist.user_id == user.id) .order_by(Playlist.position, Playlist.created_at) ) .scalars() .all() ) if not pls: return [] ids = [p.id for p in pls] counts = dict( db.execute( select(PlaylistItem.playlist_id, func.count()) .where(PlaylistItem.playlist_id.in_(ids)) .group_by(PlaylistItem.playlist_id) ).all() ) durations = dict( db.execute( select( PlaylistItem.playlist_id, func.coalesce(func.sum(Video.duration_seconds), 0), ) .join(Video, Video.id == PlaylistItem.video_id) .where(PlaylistItem.playlist_id.in_(ids)) .group_by(PlaylistItem.playlist_id) ).all() ) member: set[int] = set() if contains: member = set( db.execute( select(PlaylistItem.playlist_id).where( PlaylistItem.playlist_id.in_(ids), PlaylistItem.video_id == contains, ) ) .scalars() .all() ) return [ _summary( db, p, counts.get(p.id, 0), durations.get(p.id, 0), (p.id in member) if contains else None, ) for p in pls ] @router.post("") def create_playlist( payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: name = (payload.get("name") or "").strip() if not name: raise HTTPException(status_code=400, detail="name is required") next_pos = ( db.scalar( select(func.coalesce(func.max(Playlist.position), -1)).where( Playlist.user_id == user.id ) ) + 1 ) pl = Playlist(user_id=user.id, name=name, position=next_pos) db.add(pl) db.commit() return _summary(db, pl, 0) def _watch_later(db: Session, user: User) -> Playlist: """The user's built-in Watch later playlist, created on first use.""" pl = db.scalar( select(Playlist).where( Playlist.user_id == user.id, Playlist.kind == "watch_later" ) ) if pl is None: next_pos = ( db.scalar( select(func.coalesce(func.max(Playlist.position), -1)).where( Playlist.user_id == user.id ) ) + 1 ) pl = Playlist( user_id=user.id, name="Watch later", kind="watch_later", position=next_pos ) db.add(pl) db.commit() return pl @router.post("/watch-later") def add_watch_later( payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Bookmark shortcut: add a video to the built-in Watch later playlist.""" video_id = payload.get("video_id") if not video_id or db.get(Video, video_id) is None: raise HTTPException(status_code=404, detail="Unknown video") pl = _watch_later(db, user) exists = db.scalar( select(PlaylistItem).where( PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id ) ) if exists is None: next_pos = ( db.scalar( select(func.coalesce(func.max(PlaylistItem.position), -1)).where( PlaylistItem.playlist_id == pl.id ) ) + 1 ) db.add(PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos)) db.commit() return {"saved": True, "playlist_id": pl.id} @router.delete("/watch-later/{video_id}") def remove_watch_later( video_id: str, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: pl = db.scalar( select(Playlist).where( Playlist.user_id == user.id, Playlist.kind == "watch_later" ) ) if pl is not None: item = db.scalar( select(PlaylistItem).where( PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id ) ) if item is not None: db.delete(item) db.commit() return {"saved": False} @router.post("/sync-youtube") def sync_youtube( user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Pull the user's YouTube playlists into local mirrors now (read-only direction). Also runs automatically on the scheduler; this is the manual 'sync now'.""" if not has_read_scope(user): raise HTTPException( status_code=403, detail="Connect YouTube (read access) to sync your playlists.", ) with quota.attribute(user.id, "playlist_sync"): return sync_user_playlists(db, user) def _pushable(db: Session, user: User, playlist_id: int) -> Playlist: """A local playlist the user may push to YouTube (create or update). The built-in Watch later and read-only YouTube mirrors are never pushable.""" pl = _own_playlist(db, user, playlist_id) if pl.kind == "watch_later": raise HTTPException( status_code=400, detail="The Watch later list can't be sent to YouTube." ) return pl @router.get("/{playlist_id}/push-plan") def push_plan( playlist_id: int, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Dry run: what 'Sync to YouTube' would do + the quota estimate, so the UI can show a confirm. For a linked playlist this reads the live YouTube state (cheap, 1 unit/page).""" pl = _pushable(db, user, playlist_id) if not has_write_scope(user): raise HTTPException( status_code=403, detail="Enable YouTube editing in Settings first." ) try: with quota.attribute(user.id, "playlist_push"): plan = plan_push(db, user, pl) except YouTubeError: raise HTTPException(status_code=502, detail="Couldn't read the playlist on YouTube.") plan["remaining_today"] = quota.remaining_today(db) plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"] return plan @router.post("/{playlist_id}/push") def push( playlist_id: int, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Push a local playlist to YouTube (create it on first push, else reconcile to match local — local wins). Refuses if the estimated quota exceeds what's left today, so a big playlist can't strand half-pushed.""" pl = _pushable(db, user, playlist_id) if not has_write_scope(user): raise HTTPException( status_code=403, detail="Enable YouTube editing in Settings first." ) try: with quota.attribute(user.id, "playlist_push"): plan = plan_push(db, user, pl) if plan["units_estimate"] > quota.remaining_today(db): raise HTTPException( status_code=429, detail="Not enough YouTube quota left today for this sync.", ) return push_playlist(db, user, pl) except YouTubeError: db.rollback() raise HTTPException(status_code=502, detail="YouTube playlist sync failed.") @router.get("/{playlist_id}") def get_playlist( playlist_id: int, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: pl = _own_playlist(db, user, playlist_id) state = aliased(VideoState) rows = db.execute( select( Video.id, Video.title, Video.channel_id, Channel.title.label("channel_title"), Channel.thumbnail_url.label("channel_thumbnail"), Channel.handle.label("channel_handle"), Video.published_at, Video.thumbnail_url, Video.duration_seconds, Video.view_count, Video.is_short, Video.live_status, func.coalesce(state.status, "new").label("status"), func.coalesce(state.position_seconds, 0).label("position_seconds"), _saved_expr(user), ) .join(PlaylistItem, PlaylistItem.video_id == Video.id) .join(Channel, Channel.id == Video.channel_id) .outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id)) .where(PlaylistItem.playlist_id == pl.id) .order_by(PlaylistItem.position) ).all() return { "id": pl.id, "name": pl.name, "kind": pl.kind, "source": pl.source, "yt_playlist_id": pl.yt_playlist_id, "dirty": pl.dirty, "items": [_serialize(r) for r in rows], } @router.patch("/{playlist_id}") def rename_playlist( playlist_id: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: pl = _own_playlist(db, user, playlist_id) if "name" in payload: name = (payload.get("name") or "").strip() if not name: raise HTTPException(status_code=400, detail="name cannot be empty") if name != pl.name: pl.name = name _mark_dirty(pl) db.commit() count = db.scalar( select(func.count()).where(PlaylistItem.playlist_id == pl.id) ) return _summary(db, pl, count or 0, _total_duration(db, pl.id)) @router.delete("/{playlist_id}") def delete_playlist( playlist_id: int, on_youtube: bool = False, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: pl = _own_playlist(db, user, playlist_id) if pl.kind == "watch_later": raise HTTPException(status_code=400, detail="The Watch later list can't be deleted.") if on_youtube: if not pl.yt_playlist_id: raise HTTPException(status_code=400, detail="This playlist isn't on YouTube.") if not has_write_scope(user): raise HTTPException( status_code=403, detail="Enable YouTube editing in Settings first." ) try: with quota.attribute(user.id, "playlist_delete"): with YouTubeClient(db, user) as yt: yt.delete_playlist(pl.yt_playlist_id) except YouTubeError: raise HTTPException(status_code=502, detail="YouTube delete failed.") db.delete(pl) # items cascade db.commit() return {"deleted": playlist_id} @router.post("/{playlist_id}/items") def add_item( playlist_id: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: pl = _own_playlist(db, user, playlist_id) video_id = payload.get("video_id") if not video_id or db.get(Video, video_id) is None: raise HTTPException(status_code=404, detail="Unknown video") exists = db.scalar( select(PlaylistItem).where( PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id ) ) if exists is None: next_pos = ( db.scalar( select(func.coalesce(func.max(PlaylistItem.position), -1)).where( PlaylistItem.playlist_id == pl.id ) ) + 1 ) db.add( PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos) ) _mark_dirty(pl) db.commit() return {"playlist_id": pl.id, "video_id": video_id} @router.delete("/{playlist_id}/items/{video_id}") def remove_item( playlist_id: int, video_id: str, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: pl = _own_playlist(db, user, playlist_id) item = db.scalar( select(PlaylistItem).where( PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id ) ) if item is not None: db.delete(item) _mark_dirty(pl) db.commit() return {"playlist_id": pl.id, "video_id": video_id} @router.put("/{playlist_id}/order") def reorder_items( playlist_id: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: pl = _own_playlist(db, user, playlist_id) order = payload.get("video_ids") or [] items = { it.video_id: it for it in db.execute( select(PlaylistItem).where(PlaylistItem.playlist_id == pl.id) ).scalars() } pos = 0 for vid in order: it = items.get(vid) if it is not None: it.position = pos pos += 1 _mark_dirty(pl) db.commit() return {"playlist_id": pl.id, "count": pos}