move_item checked "already in the target?" and then re-parented without a lock, so a second tab's add or the scheduler's YouTube merge committing the same video in between hit uq_playlist_video and answered a raw 500. The re-parent now runs in a savepoint and a conflict falls back to the already-present outcome. The target is touched only when the row landed in it (an untouched list was promoted in the rail's "recently changed" sort), and _touch stamps the database clock like the mixin's own onupdate. The canonical-patterns table names owned_or_404 against ad-hoc ownership helpers; six were left (_own_playlist, _own_playlist_or_404, _own_view, _own_tag, notifications._owned, downloads._own_job), two of them hand-rolled with a bare English 404. All call sites now use owned_or_404, and the download/link/notification 404s are structured like the rest. The contract test reads every "what is gone" the routes name, and the YouTube refusal reasons, and requires both languages to name each.
643 lines
23 KiB
Python
643 lines
23 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.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."""
|
|
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.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)
|
|
_touch(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,
|
|
# 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.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
|
|
get_or_404(db, Video, video_id, what="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 _refused(403, "youtube_read_scope")
|
|
_check_pull_cooldown(user)
|
|
try:
|
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
|
return 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 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 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)
|
|
.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 = 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}
|
|
|
|
|
|
@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 = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
|
video_id = body.video_id
|
|
get_or_404(db, Video, video_id, what="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 = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
|
_remove_item(db, pl, video_id, mark_dirty=True)
|
|
return {"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
|
|
except IntegrityError:
|
|
pass # the savepoint put the row back in the source list; drop it below
|
|
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:
|
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
|
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 {"playlist_id": pl.id, "count": pos}
|