"""Playlists: per-user named, ordered collections of videos from the shared catalog. Local CRUD (create / rename / delete, add / remove / reorder items) plus YouTube two-way sync — mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope.""" from typing import TYPE_CHECKING from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app import quota from app.auth import current_user, has_read_scope, has_write_scope, require_write_scope from app.db import get_db from app.feedquery import serialize from app.models import User from app.reader import queries, service from app.sync.playlists import ( PlaylistGone, merge_playlist, plan_merge, pull_user_playlists, repull_playlist, sync_claim, user_sync_claim, ) from app.ratelimit import RateLimiter from app.routes._common import not_found, youtube_refusal from app.youtube.client import YouTubeClient, YouTubeError if TYPE_CHECKING: from app.reader.models import Playlist router = APIRouter(prefix="/api/playlists", tags=["playlists"]) # SEC-14, same reasoning as the sync module's limiter: a playlist pull lists every playlist the # caller owns and then every item in each, against the instance-wide YouTube budget. Its own # limiter rather than a shared one — spending the manual subscription-import allowance must not # also close the playlist button, and vice versa. # # It guards EVERY route that spends under QuotaAction.PLAYLISTS_PULL, not just the bulk one: a # scripted loop over the single-playlist re-pull drains the same shared budget, and limiting one of # two routes with the same quota action is the asymmetric fix this codebase keeps re-learning. # Deliberately NOT on the push side (`push-plan` / `push`): those run on every push attempt, # including the no-op "already up to date" path, so a 3-per-10-minute allowance would refuse # legitimate work. Their exposure is tracked as SEC-18 instead. _playlist_pull_limiter = RateLimiter(max_events=3, window_seconds=600) def _check_pull_cooldown(user: User) -> None: """Shared gate so the two PLAYLISTS_PULL routes cannot drift apart again.""" if not _playlist_pull_limiter.allow(str(user.id)): raise HTTPException(status_code=429, detail={"code": "sync_cooldown"}) def _refused(status: int, reason: str) -> HTTPException: """A playlist action the server refuses, as a reason the UI translates (`playlists.errors.`) — a bare English detail reached the Hungarian UI verbatim.""" return HTTPException(status_code=status, detail={"code": "playlist", "reason": reason}) def _own_playlist(db: Session, user: User, playlist_id: int, *, lock: bool = False) -> "Playlist": pl = queries.own_playlist(db, user, playlist_id, lock=lock) if pl is None: raise not_found("playlist") return pl def _video_or_404(db: Session, video_id: str) -> None: if queries.video(db, video_id) is None: raise not_found("video") def _summary( pl: "Playlist", count: int, total_duration: int = 0, has_video: bool | None = None, cover: str | None = None, ) -> dict: 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, # When this list last CHANGED (rename, add/remove/move/reorder) — the rail's # "recently changed first" sort reads it. See `service.touch` for why it isn't automatic. "updated_at": pl.updated_at, } 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 = queries.playlists_for(db, user) if not pls: return [] ids = [p.id for p in pls] counts = queries.item_counts(db, ids) durations = queries.durations(db, ids) member = queries.containing(db, ids, contains) if contains else set() covers = queries.covers(db, ids) return [ _summary( p, counts.get(p.id, 0), durations.get(p.id, 0), (p.id in member) if contains else None, covers.get(p.id), ) for p in pls ] class CreatePlaylistIn(BaseModel): name: str # Shared by the two "add a video" endpoints (Watch-later shortcut + explicit add-to-playlist). class VideoRefIn(BaseModel): video_id: str @router.post("") def create_playlist( body: CreatePlaylistIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: name = body.name.strip() if not name: raise _refused(400, "name_required") pl = service.create_playlist(db, user, name) db.commit() return _summary(pl, 0) @router.post("/watch-later") def add_watch_later( body: VideoRefIn, 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 = body.video_id _video_or_404(db, video_id) pl = service.watch_later(db, user) service.add_item(db, pl, video_id, mark_dirty=False) db.commit() return {"ok": True, "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 = queries.watch_later(db, user) if pl is not None: service.remove_item(db, pl, video_id, mark_dirty=False) db.commit() return {"ok": True, "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 _refused(403, "youtube_read_scope") with user_sync_claim(db, user.id) as free: if not free: # Another run (the scheduler, another tab) is pulling right now: "0 synced" would lie. raise _refused(409, "sync_all_busy") # After the claim, like the re-pull's: a refused attempt must not spend one of the pulls. _check_pull_cooldown(user) try: with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL): return {"ok": True, **pull_user_playlists(db, user)} except YouTubeError as exc: db.rollback() raise youtube_refusal(exc) from exc @router.post("/{playlist_id}/revert-youtube") def revert_youtube( playlist_id: int, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Reset one YouTube-linked playlist to its current YouTube state, discarding local edits (and clearing the dirty flag). The user-facing 'undo all my changes' for a list once the in-session undo history is gone.""" pl = _own_playlist(db, user, playlist_id) if not pl.yt_playlist_id: raise _refused(400, "not_on_youtube") if not has_read_scope(user): raise _refused(403, "youtube_read_scope") with sync_claim(db, pl.id) as free: if not free: raise _refused(409, "sync_busy") # The cooldown is spent only by a pull that runs: checked after the claim, so clicking # Revert while a push is in flight does not use up the three pulls per ten minutes. _check_pull_cooldown(user) try: with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL): return {"ok": True, **repull_playlist(db, user, pl)} except PlaylistGone: raise not_found("playlist") from None except YouTubeError as exc: db.rollback() raise youtube_refusal(exc, playlist=True) from exc 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 _refused(400, "watch_later_not_pushable") return pl @router.get("/{playlist_id}/push-plan") def push_plan( playlist_id: int, user: User = Depends(require_write_scope), db: Session = Depends(get_db), ) -> dict: """Dry run: what a two-way 'Sync to YouTube' merge would do (what each side gains/loses) + the quota estimate, so the UI can show a confirm. For a linked playlist this reads the live YouTube state (cheap, 1 unit/page). Write scope is required — syncing is a YouTube write.""" pl = _pushable(db, user, playlist_id) try: with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH): plan = plan_merge(db, user, pl) except YouTubeError as exc: raise youtube_refusal(exc, playlist=True) from exc 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(require_write_scope), db: Session = Depends(get_db), ) -> dict: """Two-way sync a local playlist with YouTube (create it on first push, else 3-way merge so neither side loses an item). Refuses if the estimated quota exceeds what's left today, so a big sync can't strand half-applied. Write scope required (require_write_scope).""" pl = _pushable(db, user, playlist_id) # One sync of a list at a time, across tabs, devices and the scheduler (`sync_claim`): the # client's own in-flight flag only covers the menus of one page. with sync_claim(db, pl.id) as free: if not free: raise _refused(409, "sync_busy") try: with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH): # Read the live playlist ONCE (linked playlists only) and share it with both the # plan and the merge, halving read-quota and closing the second read→write TOCTOU # window. A fresh export (no yt_playlist_id) reads nothing. live = None if pl.yt_playlist_id: with YouTubeClient(db, user) as yt: live = ( list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)), yt.get_playlist_snippet(pl.yt_playlist_id), ) plan = plan_merge(db, user, pl, live=live) if plan["units_estimate"] > quota.remaining_today(db): raise _refused(429, "quota_exceeded") return {"ok": True, **merge_playlist(db, user, pl, live=live)} except PlaylistGone: raise not_found("playlist") from None except YouTubeError as exc: db.rollback() raise youtube_refusal(exc, playlist=True) from exc @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) rows = queries.playlist_rows(db, user, pl.id) 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], } class RenamePlaylistIn(BaseModel): name: str | None = None @router.patch("/{playlist_id}") def rename_playlist( playlist_id: int, body: RenamePlaylistIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: # Locked: the new name moves `dirty`, which the scheduled mirror refresh reads under this lock. pl = _own_playlist(db, user, playlist_id, lock=True) if "name" in body.model_fields_set: name = (body.name or "").strip() if not name: raise _refused(400, "name_required") service.rename_playlist(db, pl, name) db.commit() count, total, cover = queries.playlist_totals(db, pl.id) return _summary(pl, count, total, cover=cover) @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 _refused(400, "watch_later_undeletable") # Not in the middle of a sync of it (`sync_claim`): a push that read the list before the delete # and wrote YouTube after it planned from no items at all, so it emptied the YouTube playlist a # local-only delete means to keep. The push checks for a vanished list too (`PlaylistGone`); # this refuses the delete while a sync runs rather than race it. with sync_claim(db, pl.id) as free: if not free: raise _refused(409, "sync_busy") if on_youtube: if not pl.yt_playlist_id: raise _refused(400, "not_on_youtube") if not has_write_scope(user): raise _refused(403, "youtube_editing_off") try: with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE): with YouTubeClient(db, user) as yt: yt.delete_playlist(pl.yt_playlist_id) except YouTubeError as exc: raise youtube_refusal(exc, playlist=True) from exc service.delete_playlist(db, pl) db.commit() return {"deleted": playlist_id} class AddItemIn(VideoRefIn): # Where the video lands among the list's items, in their stored order (0 = first). Omitted, it # is appended. The list page's "Undo" of a removal puts the row back where it was with this — # ONE request, because append-then-reorder leaves it at the end whenever the second call fails. position: int | None = Field(default=None, ge=0) @router.post("/{playlist_id}/items") def add_item( playlist_id: int, body: AddItemIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: pl = _own_playlist(db, user, playlist_id) video_id = body.video_id _video_or_404(db, video_id) service.add_to_playlist(db, pl, video_id, body.position) db.commit() return {"ok": True, "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) service.remove_item(db, pl, video_id, mark_dirty=True) db.commit() return {"ok": True, "playlist_id": pl.id, "video_id": video_id} class MoveItemIn(BaseModel): target_playlist_id: int @router.post("/{playlist_id}/items/{video_id}/move") def move_item( playlist_id: int, video_id: str, body: MoveItemIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Move one video from a playlist into another of the caller's playlists, atomically. ONE transaction on purpose. The obvious client-side shape — add to the target, then remove from the source — leaves the video in BOTH lists whenever the second call fails, which is exactly the kind of half-applied state this codebase treats as a bug rather than an edge case. Idempotent: if the video is already in the target, the source row is simply dropped (moving into a list that already has it is a removal, not a duplicate).""" src = _own_playlist(db, user, playlist_id) # Its own reason: "this playlist doesn't exist any more" about a TARGET that is gone was read as # the list on screen — which is still there. dst = queries.own_playlist(db, user, body.target_playlist_id) if dst is None: raise not_found("move_target") try: service.move_item(db, src, dst, video_id) except service.MoveRefused as e: # A same-list move is a bad request; a video a concurrent edit (another tab, a YouTube # re-pull) already took out is gone. status = 400 if e.reason == "same_playlist" else 404 raise HTTPException( status_code=status, detail={"code": "playlist_move", "reason": e.reason} ) from None db.commit() return {"ok": True, "from_playlist_id": src.id, "to_playlist_id": dst.id, "video_id": video_id} class ReorderItemsIn(BaseModel): video_ids: list[str] = [] @router.put("/{playlist_id}/order") def reorder_items( playlist_id: int, body: ReorderItemsIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: # Locked: a concurrent add between reading the items and writing the order would collide. pl = _own_playlist(db, user, playlist_id, lock=True) pos = service.reorder_items(db, pl, body.video_ids) db.commit() return {"ok": True, "playlist_id": pl.id, "count": pos}