"""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.auth import current_user from app.db import get_db from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState from app.routes.feed import _serialize 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 _summary(db: Session, pl: Playlist, count: int, 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, "item_count": count, "cover_thumbnail": cover, } if has_video is not None: out["has_video"] = has_video return out @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() ) 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), (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) @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"), ) .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, "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") pl.name = name db.commit() count = db.scalar( select(func.count()).where(PlaylistItem.playlist_id == pl.id) ) return _summary(db, pl, count or 0) @router.delete("/{playlist_id}") def delete_playlist( playlist_id: int, 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.") 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) ) 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) 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 db.commit() return {"playlist_id": pl.id, "count": pos}