An Esc during a pointer drag is spent on the drag and leaves the selection. A wheel over the rail re-reads the target each frame, so a drop lands on the row under the pointer. After a bulk removal the focus goes to the next row left, and to the list's menu when the list is emptied. remove-bulk names the videos that went, and its Undo brings back only those. An Undo of a move keeps each row (and when it was saved), leaves out a video that moved on from the target, closes up its place, and says how many came back. Delete with every tick hidden removes nothing and says why. A list gone before the lock answers 404, and a body without its videos 422. One removal path now: the single row goes through the bulk one, the same toast and undo, which also retires the add-at-position route. railDrop reuses the store and scrollParent, the drag image the overlay and the z-index token, and the sensors keep their number. The count is read out from a region that outlives the band. Tests fill the gaps the review named, each seen failing.
563 lines
20 KiB
Python
563 lines
20 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: both undo bodies come back from the client."""
|
|
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")
|
|
|
|
|
|
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
|
|
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 _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 HTTPException(
|
|
status_code=400, detail={"code": "playlist_move", "reason": e.reason}
|
|
) 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.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}
|