The deep review of the branch raised 47 findings, 18 of which survived adversarial verification. Grouped by what they actually were: **The size ceiling inverted at its boundary.** `remaining_bytes` clamped to 0 for a user already at their storage cap, and `build_ydl_opts` reads 0 as "no limit" — so the one user over quota was the only one downloading with no ceiling at all. `check_enqueue` could not catch it: it ran while the job was queued and the user was still under the cap. The arithmetic moves into `quota.remaining_bytes` (where every other max_bytes decision lives, and where it can be tested), and the worker now fails the job outright rather than proceeding unbounded — as a non-retryable `quota_exceeded`, since retrying cannot help until space is freed. **Silent failures in the new account panel.** All three actions went through `quiet: true` endpoints, which suppress the global error dialog on the promise that the caller reports inline — with no `onError`, that promise was broken and a refusal rendered as nothing. On the broken-SMTP instance this panel exists for, an admin would read silence as "the mail went out". The co-invalidation set and the email-off predicate move to `lib/adminUsers`, shared with the pending-access queue, so the two copies of these mutations cannot drift apart again — which is how half of this was lost in the first place. **A rollback recipe that destroyed data without working.** `alembic downgrade -1` steps back one migration, but a release routinely ships several (0.80.0 shipped four): it drops the newest one's columns and leaves the schema at a revision the older image also cannot resolve, i.e. the crash loop it was meant to escape, minus the data. The dump restore is now the recommended path, and the alembic route asks the target version for its own head revision instead of counting backwards. Also: redirects named in the urlguard docstring beside the DNS-rebinding residual they share a fix with (SEC-17); a malformed port refused as `bad_port` instead of escaping as a 500; the pull cooldown extended to the sibling route spending the same QuotaAction; restore stops the app before dropping its tables; the PowerShell cleanup can no longer decide the script's verdict on 5.1, nor report the wrong exit code; the documented one-liners hardened to match the scripts; the registry's retention window stated where pinning is advertised; the two banners made mutually exclusive so neither can shadow the other; `hover:bg-hover` (a utility this project never generated) replaced by the `selectable` recipe; the clipboard helper reused; and the sync cooldown's copy swept to the two callers that discarded it. Verified: gate green (vitest 678, pytest 949), and the panel, the row hover and the reset banner UAT'd on localdev.
560 lines
19 KiB
Python
560 lines
19 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.
|
|
#
|
|
# 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 _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.",
|
|
)
|
|
_check_pull_cooldown(user)
|
|
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."
|
|
)
|
|
_check_pull_cooldown(user)
|
|
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}
|