Files
siftlode/backend/app/routes/playlists.py
T
peter 0cc450d1c9 fix(downloads,sync): bound what one user can make the server fetch, download and spend
SEC-12: the download box accepts an arbitrary http(s) URL and yt-dlp fetches
it from the WORKER, so any approved user could aim the server at the compose
network, the LAN or a cloud metadata endpoint — and read the result off
job.error, where "connection refused" vs "HTTP Error 401" vs "timed out" is a
port scanner. downloads/urlguard.py refuses a URL whose host resolves to a
loopback/private/link-local/reserved address, at enqueue AND again in the
worker (DNS can move in between, and the worker is where the socket opens).
A network-class failure on a `url` job now reports one fixed sentence instead
of the raw transport text; the error CODE is still classified from the
original, so the localized explanation is unchanged. DNS rebinding is
explicitly NOT closed — that needs the connection gated inside yt-dlp — and
the module says so rather than implying more than it does.

SEC-13: `noplaylist` only demotes a video-inside-a-playlist URL to that video,
so a URL that is purely a playlist or channel still expanded into every
entry — one enqueue, unbounded downloads. `playlist_items` pins it to one.
Size was unbounded too: `check_enqueue` compares the CURRENT footprint against
the cap, and a download's size is unknown until it lands, so one job could
overshoot by any amount; the owner's remaining budget is now handed to yt-dlp
as `max_filesize`, applied AFTER the adapter merge so no source adapter can
raise a limit the quota layer set. A join's cut-list is capped at 100: an
accurate join emits two filters per segment into one ffmpeg filter_complex.

SEC-14: the YouTube budget is instance-wide, so one user holding down a
manual sync spent the day for everyone. Per-user (not per-IP — the cost
belongs to the account) 3-per-10-minutes limiters on the two bulk pulls,
subscriptions and playlists, with separate budgets so one does not close the
other. The review also listed /rss, /backfill and /enrich; E3 deleted them,
and search stays unlimited on purpose — it is interactive, not a bulk pull.

Gate: pytest 896 -> 924 (none skipped), vitest 671.
2026-09-05 01:03:24 +02:00

547 lines
18 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 fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
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, require_write_scope
from app.db import get_db
from app.feedquery import feed_columns, serialize
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
from app.sync.playlists import (
merge_playlist,
plan_merge,
recompute_dirty,
repull_playlist,
sync_user_playlists,
)
from app.ratelimit import RateLimiter
from app.youtube.client import YouTubeClient, YouTubeError
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.
_playlist_pull_limiter = RateLimiter(max_events=3, window_seconds=600)
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 _next_playlist_position(db: Session, user_id: int) -> int:
"""Append position for a new playlist in this user's rail."""
return (
db.scalar(
select(func.coalesce(func.max(Playlist.position), -1)).where(
Playlist.user_id == user_id
)
)
+ 1
)
def _next_item_position(db: Session, playlist_id: int) -> int:
"""Append position for a new item at the end of a playlist."""
return (
db.scalar(
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
PlaylistItem.playlist_id == playlist_id
)
)
+ 1
)
def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
"""Append a video to a playlist if not already present (idempotent). Returns whether it
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
exists = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
)
)
if exists is not None:
return False
db.add(
PlaylistItem(playlist_id=pl.id, video_id=video_id, position=_next_item_position(db, pl.id))
)
if mark_dirty:
recompute_dirty(db, pl)
db.commit()
return True
def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
"""Remove a video from a playlist if present (idempotent). Returns whether it was removed.
`mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
item = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
)
)
if item is None:
return False
db.delete(item)
if mark_dirty:
recompute_dirty(db, pl)
db.commit()
return True
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,
}
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 = (
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()
)
# Batch the cover thumbnails (first item per playlist by position) in ONE window query
# instead of a per-playlist SELECT inside _summary (the old N+1).
rn = (
func.row_number()
.over(
partition_by=PlaylistItem.playlist_id,
order_by=PlaylistItem.position,
)
.label("rn")
)
first_items = (
select(
PlaylistItem.playlist_id.label("pid"),
Video.thumbnail_url.label("thumb"),
rn,
)
.join(Video, Video.id == PlaylistItem.video_id)
.where(PlaylistItem.playlist_id.in_(ids))
.subquery()
)
covers = dict(
db.execute(
select(first_items.c.pid, first_items.c.thumb).where(first_items.c.rn == 1)
).all()
)
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 HTTPException(status_code=400, detail="name is required")
pl = Playlist(user_id=user.id, name=name, position=_next_playlist_position(db, user.id))
db.add(pl)
db.commit()
return _summary(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:
pl = Playlist(
user_id=user.id,
name="Watch later",
kind="watch_later",
position=_next_playlist_position(db, user.id),
)
db.add(pl)
db.commit()
return pl
@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
if db.get(Video, video_id) is None:
raise HTTPException(status_code=404, detail="Unknown video")
pl = _watch_later(db, user)
_add_item(db, pl, video_id, mark_dirty=False)
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:
_remove_item(db, pl, video_id, mark_dirty=False)
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.",
)
if not _playlist_pull_limiter.allow(str(user.id)):
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return sync_user_playlists(db, user)
@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 HTTPException(status_code=400, detail="This playlist isn't linked to YouTube.")
if not has_read_scope(user):
raise HTTPException(
status_code=403, detail="Connect YouTube (read access) to sync your playlists."
)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return repull_playlist(db, user, pl)
except YouTubeError:
db.rollback()
raise HTTPException(status_code=422, detail="Couldn't refresh from YouTube.")
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(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:
raise HTTPException(status_code=422, 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(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)
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 HTTPException(
status_code=429,
detail="Not enough YouTube quota left today for this sync.",
)
return merge_playlist(db, user, pl, live=live)
except YouTubeError:
db.rollback()
raise HTTPException(status_code=422, 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(*feed_columns(user, state))
.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],
}
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:
pl = _own_playlist(db, user, playlist_id)
if "name" in body.model_fields_set:
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name cannot be empty")
if name != pl.name:
pl.name = name
recompute_dirty(db, pl)
db.commit()
# One aggregate for count + duration (outer join so an empty playlist still returns 0),
# plus a single cover lookup — was 3 separate round-trips.
count, total = db.execute(
select(
func.count(PlaylistItem.id),
func.coalesce(func.sum(Video.duration_seconds), 0),
)
.select_from(PlaylistItem)
.outerjoin(Video, Video.id == PlaylistItem.video_id)
.where(PlaylistItem.playlist_id == pl.id)
).one()
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)
)
return _summary(pl, count or 0, total or 0, 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 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, quota.QuotaAction.PLAYLISTS_DELETE):
with YouTubeClient(db, user) as yt:
yt.delete_playlist(pl.yt_playlist_id)
except YouTubeError:
raise HTTPException(status_code=422, 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,
body: VideoRefIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = _own_playlist(db, user, playlist_id)
video_id = body.video_id
if db.get(Video, video_id) is None:
raise HTTPException(status_code=404, detail="Unknown video")
_add_item(db, pl, video_id, mark_dirty=True)
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)
_remove_item(db, pl, video_id, mark_dirty=True)
return {"playlist_id": pl.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:
pl = _own_playlist(db, user, playlist_id)
order = body.video_ids
items = {
it.video_id: it
for it in db.execute(
select(PlaylistItem).where(PlaylistItem.playlist_id == pl.id)
).scalars()
}
pos = 0
seen: set[str] = set()
for vid in order:
it = items.get(vid)
if it is not None:
it.position = pos
pos += 1
seen.add(vid)
# Any items the payload omitted (e.g. added concurrently in another tab) keep their relative
# order but get fresh trailing positions, so no two items collide on the same position.
for it in sorted(
(it for vid, it in items.items() if vid not in seen), key=lambda i: i.position
):
it.position = pos
pos += 1
recompute_dirty(db, pl)
db.commit()
return {"playlist_id": pl.id, "count": pos}