A read from before a write no longer brings a row back: a write calls off the read on its way, what lands while one is out stays off the screen, and the last write out does the refresh. A failure puts back its own rows, an accepted reorder brings back none, and an Undo reads past the cache. A list the cache has dropped is read before a removal, so its Undo has places. undo-move refuses the same list twice, positions cannot repeat, and undo-move and the Plex key bodies refuse a body without their videos. Focus goes to the header's box when the last shown row or Save this order goes.
571 lines
21 KiB
Python
571 lines
21 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, Literal
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, model_validator
|
|
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"})
|
|
|
|
|
|
# Every playlist action the server refuses; the UI names each one (`playlists.errors.<reason>`, held by
|
|
# the frontend's backendIds contract, which reads this Literal).
|
|
PlaylistRefusal = Literal[
|
|
"name_required",
|
|
"not_on_youtube",
|
|
"quota_exceeded",
|
|
"sync_all_busy",
|
|
"sync_busy",
|
|
"watch_later_not_pushable",
|
|
"watch_later_undeletable",
|
|
"youtube_editing_off",
|
|
"youtube_read_scope",
|
|
]
|
|
|
|
|
|
def _refused(status: int, reason: PlaylistRefusal) -> 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}
|
|
|
|
|
|
@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, lock=True)
|
|
video_id = body.video_id
|
|
_video_or_404(db, video_id)
|
|
service.add_item(db, pl, video_id, mark_dirty=True)
|
|
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, lock=True)
|
|
service.remove_item(db, pl, video_id, mark_dirty=True)
|
|
db.commit()
|
|
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
|
|
|
|
|
class VideoIdsIn(BaseModel):
|
|
# Required: with a default, a misnamed key answered 200 having acted on no video.
|
|
video_ids: list[str]
|
|
|
|
|
|
def _paired(video_count: int, positions: list[int]) -> None:
|
|
"""One place per video, none before the start and none twice: both undo bodies come back from
|
|
the client, and the insert's arithmetic counts on places that only rise."""
|
|
if len(positions) != video_count:
|
|
raise ValueError("positions must pair one-to-one with the videos")
|
|
if any(at < 0 for at in positions):
|
|
raise ValueError("positions cannot be negative")
|
|
if len(set(positions)) != len(positions):
|
|
raise ValueError("positions cannot repeat")
|
|
|
|
|
|
class RestoreItemsIn(VideoIdsIn):
|
|
# Where each video goes back: an index over the ordered rows, one per video.
|
|
positions: list[int] = []
|
|
|
|
@model_validator(mode="after")
|
|
def _one_position_per_video(self):
|
|
_paired(len(self.video_ids), self.positions)
|
|
return self
|
|
|
|
|
|
class MoveItemsIn(VideoIdsIn):
|
|
target_playlist_id: int
|
|
|
|
|
|
class UndoMoveIn(BaseModel):
|
|
from_playlist_id: int
|
|
# Required, as `video_ids` is. An empty one is still a real body: the target held them all.
|
|
moved: list[str]
|
|
already_there: list[str] = []
|
|
# One per video of `moved` + `already_there`, in that order.
|
|
positions: list[int]
|
|
|
|
@model_validator(mode="after")
|
|
def _one_position_per_video(self):
|
|
_paired(len(self.moved) + len(self.already_there), self.positions)
|
|
return self
|
|
|
|
|
|
def _list_gone(e: service.ListGone) -> HTTPException:
|
|
"""Each list of a two-list write under the reason the route's own read gives it."""
|
|
return not_found("playlist") if e.source else not_found("move_target")
|
|
|
|
|
|
def _move_refused(e: service.MoveRefused) -> HTTPException:
|
|
return HTTPException(status_code=400, detail={"code": "playlist_move", "reason": e.reason})
|
|
|
|
|
|
def _other_list(db: Session, user: User, playlist_id: int) -> "Playlist":
|
|
"""The other list of a move. Its own reason: "this playlist doesn't exist any more" about a list
|
|
that is gone was read as the one on screen, which is still there."""
|
|
other = queries.own_playlist(db, user, playlist_id)
|
|
if other is None:
|
|
raise not_found("move_target")
|
|
return other
|
|
|
|
|
|
@router.post("/{playlist_id}/items/remove-bulk")
|
|
def remove_items(
|
|
playlist_id: int,
|
|
body: VideoIdsIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Remove many videos from a playlist at once. Returns the ones that went."""
|
|
pl = _own_playlist(db, user, playlist_id, lock=True)
|
|
removed = service.remove_items(db, pl, body.video_ids)
|
|
db.commit()
|
|
return {"ok": True, "playlist_id": pl.id, "removed": removed}
|
|
|
|
|
|
@router.post("/{playlist_id}/items/restore")
|
|
def restore_items(
|
|
playlist_id: int,
|
|
body: RestoreItemsIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""The "Undo" of a removal: the videos back at their places. Returns how many came back."""
|
|
pl = _own_playlist(db, user, playlist_id, lock=True)
|
|
added = service.restore_items(db, pl, body.video_ids, body.positions)
|
|
db.commit()
|
|
return {"ok": True, "playlist_id": pl.id, "added": added}
|
|
|
|
|
|
@router.post("/{playlist_id}/items/move-bulk")
|
|
def move_items(
|
|
playlist_id: int,
|
|
body: MoveItemsIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Move videos 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 a video in BOTH lists whenever the second call fails. A video the
|
|
target already has only leaves the source, and one a concurrent edit (another tab, a YouTube
|
|
re-pull) already took out is skipped. Says which landed there and which it already held, for
|
|
the "Undo"."""
|
|
src = _own_playlist(db, user, playlist_id)
|
|
dst = _other_list(db, user, body.target_playlist_id)
|
|
try:
|
|
moved, already_there = service.move_items(db, src, dst, body.video_ids)
|
|
except service.MoveRefused as e:
|
|
raise _move_refused(e) from None
|
|
except service.ListGone as e:
|
|
raise _list_gone(e) from None
|
|
db.commit()
|
|
return {
|
|
"ok": True,
|
|
"from_playlist_id": src.id,
|
|
"to_playlist_id": dst.id,
|
|
"moved": moved,
|
|
"already_there": already_there,
|
|
}
|
|
|
|
|
|
@router.post("/{playlist_id}/items/undo-move")
|
|
def undo_move(
|
|
playlist_id: int,
|
|
body: UndoMoveIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""The "Undo" of a move, back into this playlist in one transaction. Returns how many came back."""
|
|
src = _own_playlist(db, user, playlist_id)
|
|
dst = _other_list(db, user, body.from_playlist_id)
|
|
try:
|
|
added = service.undo_move(db, src, dst, body.moved, body.already_there, body.positions)
|
|
except service.MoveRefused as e:
|
|
raise _move_refused(e) from None
|
|
except service.ListGone as e:
|
|
raise _list_gone(e) from None
|
|
db.commit()
|
|
return {"ok": True, "playlist_id": src.id, "added": added}
|
|
|
|
|
|
@router.put("/{playlist_id}/order")
|
|
def reorder_items(
|
|
playlist_id: int,
|
|
body: VideoIdsIn,
|
|
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}
|