Two adds read the same max(position) and a reorder racing an add collided with it. Every add/insert/reorder takes the parent row FOR UPDATE; the YouTube list breaks position ties by id and a Plex reorder renumbers rows it was not sent.
677 lines
25 KiB
Python
677 lines
25 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, Field
|
|
from sqlalchemy import func, select, and_
|
|
from sqlalchemy.exc import IntegrityError
|
|
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.routes._common import get_or_404, owned_or_404, youtube_refusal
|
|
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.
|
|
#
|
|
# 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 _touch(pl: Playlist) -> None:
|
|
"""Bump the playlist's `updated_at` for an edit that only writes `playlist_items`.
|
|
|
|
`UpdatedAtMixin`'s `onupdate` fires on an UPDATE of the PLAYLIST row, and add / remove /
|
|
reorder / move write the item rows instead — so without this the rail's "recently changed"
|
|
sort would only ever see renames. The assignment is what makes the column mean "last time
|
|
this list changed", which is what the rail promises the reader.
|
|
|
|
The DATABASE's clock, like the mixin's own `onupdate`: one sort key written from two clocks
|
|
(this process's and Postgres's) could order a rename and an item edit the wrong way round."""
|
|
pl.updated_at = func.now()
|
|
|
|
|
|
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. Locks the playlist row first: two
|
|
adds (two tabs, the rail and the page) otherwise both read the same max and land on one
|
|
position, and the list's order between them is then arbitrary."""
|
|
db.execute(select(Playlist.id).where(Playlist.id == playlist_id).with_for_update())
|
|
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)
|
|
_touch(pl)
|
|
db.flush() # the handler commits (CLAUDE.md: helpers flush)
|
|
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)
|
|
_touch(pl)
|
|
db.flush() # the handler commits (CLAUDE.md: helpers flush)
|
|
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,
|
|
# When this list last CHANGED (rename, add/remove/move/reorder) — the rail's
|
|
# "recently changed first" sort reads it. See _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 = (
|
|
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 _refused(400, "name_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.flush() # the id, for the item the caller adds next; the handler commits
|
|
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
|
|
get_or_404(db, Video, video_id, what="video")
|
|
pl = _watch_later(db, user)
|
|
_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 = 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)
|
|
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")
|
|
_check_pull_cooldown(user)
|
|
try:
|
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
|
return {"ok": True, **sync_user_playlists(db, user)}
|
|
except YouTubeError as exc:
|
|
db.rollback()
|
|
raise youtube_refusal(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 = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
|
if not pl.yt_playlist_id:
|
|
raise _refused(400, "not_on_youtube")
|
|
if not has_read_scope(user):
|
|
raise _refused(403, "youtube_read_scope")
|
|
_check_pull_cooldown(user)
|
|
try:
|
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
|
return {"ok": True, **repull_playlist(db, user, pl)}
|
|
except YouTubeError as exc:
|
|
db.rollback()
|
|
raise youtube_refusal(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 = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
|
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)
|
|
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 _refused(429, "quota_exceeded")
|
|
return {"ok": True, **merge_playlist(db, user, pl, live=live)}
|
|
except YouTubeError as exc:
|
|
db.rollback()
|
|
raise youtube_refusal(exc)
|
|
|
|
|
|
@router.get("/{playlist_id}")
|
|
def get_playlist(
|
|
playlist_id: int,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
|
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)
|
|
# `id` breaks a position tie the same way on every read (the renumbering add does too).
|
|
.order_by(PlaylistItem.position, PlaylistItem.id)
|
|
).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 = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
|
if "name" in body.model_fields_set:
|
|
name = (body.name or "").strip()
|
|
if not name:
|
|
raise _refused(400, "name_required")
|
|
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 = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
|
if pl.kind == "watch_later":
|
|
raise _refused(400, "watch_later_undeletable")
|
|
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)
|
|
db.delete(pl) # items cascade
|
|
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 = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
|
video_id = body.video_id
|
|
get_or_404(db, Video, video_id, what="video")
|
|
added = _add_item(db, pl, video_id, mark_dirty=False)
|
|
if added and body.position is not None:
|
|
# Renumber the whole list with the new row at `position` (clamped to the end): positions
|
|
# can have gaps after removals, so "index N" is only meaningful over the ordered rows.
|
|
rows = list(
|
|
db.execute(
|
|
select(PlaylistItem)
|
|
.where(PlaylistItem.playlist_id == pl.id)
|
|
.order_by(PlaylistItem.position, PlaylistItem.id)
|
|
).scalars()
|
|
)
|
|
new = next(r for r in rows if r.video_id == video_id)
|
|
rows.remove(new)
|
|
rows.insert(min(body.position, len(rows)), new)
|
|
for pos, row in enumerate(rows):
|
|
row.position = pos
|
|
recompute_dirty(db, pl)
|
|
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 = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
|
_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 = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
|
# 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 = owned_or_404(db, Playlist, body.target_playlist_id, user, what="move_target")
|
|
if src.id == dst.id:
|
|
raise HTTPException(
|
|
status_code=400, detail={"code": "playlist_move", "reason": "same_playlist"}
|
|
)
|
|
item = db.scalar(
|
|
select(PlaylistItem).where(
|
|
PlaylistItem.playlist_id == src.id, PlaylistItem.video_id == video_id
|
|
)
|
|
)
|
|
if item is None:
|
|
# A concurrent edit (another tab, or a YouTube re-pull) already took it out.
|
|
raise HTTPException(
|
|
status_code=404, detail={"code": "playlist_move", "reason": "not_in_playlist"}
|
|
)
|
|
already = db.scalar(
|
|
select(PlaylistItem).where(
|
|
PlaylistItem.playlist_id == dst.id, PlaylistItem.video_id == video_id
|
|
)
|
|
)
|
|
dst_changed = False
|
|
if already is None:
|
|
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
|
|
# survives the move, and there is no window where the video belongs to neither list. The
|
|
# check above is not a lock: a second tab's add or the scheduler's YouTube merge can insert
|
|
# the same (playlist, video) in between, and uq_playlist_video then refuses the re-parent.
|
|
# That is the already-present outcome arriving late, not a failure — answer it the same way
|
|
# instead of a raw 500.
|
|
try:
|
|
with db.begin_nested():
|
|
item.playlist_id = dst.id
|
|
item.position = _next_item_position(db, dst.id)
|
|
db.flush()
|
|
dst_changed = True
|
|
# silent-ok: the conflict IS the answer — the target already holds the video, which is the
|
|
# idempotent outcome; the savepoint put the row back in the source list, dropped below.
|
|
except IntegrityError:
|
|
pass
|
|
if not dst_changed:
|
|
db.delete(item)
|
|
# The source always changed; the target only when the row landed in it. `_touch` on a target
|
|
# that already had the video promoted an untouched list in the rail's "recently changed" sort.
|
|
recompute_dirty(db, src)
|
|
_touch(src)
|
|
if dst_changed:
|
|
recompute_dirty(db, dst)
|
|
_touch(dst)
|
|
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 = owned_or_404(db, Playlist, playlist_id, user, what="playlist", lock=True)
|
|
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)
|
|
_touch(pl)
|
|
db.commit()
|
|
return {"ok": True, "playlist_id": pl.id, "count": pos}
|