Feed, playlists, saved views, tags and the Home pins do no database work in their routes any more: reads go through reader/queries (and feedquery for a feed page, its counts and facets), writes through reader/service and discover/pins, which flush; the handlers commit. Reader is fenced below its HTTP layer too: its owner filters go through userscope, and the catalog's jobs ask Reader instead of joining its tables (the auto-tagger's system tags, the cleanup jobs' keep rule, the maintenance notice). A playlist sync's catalog ingest moves to sync/videos.
463 lines
17 KiB
Python
463 lines
17 KiB
Python
"""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.<reason>`) — 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=<video_id>`, 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}
|