2026-06-15 14:37:09 +02:00
|
|
|
"""Local playlists: per-user named, ordered collections of videos from the shared
|
|
|
|
|
catalog. Foundation phase = local only (create / rename / delete, add / remove / reorder
|
|
|
|
|
items). YouTube two-way sync (mirror existing playlists, push edits) lands in later phases."""
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
|
from sqlalchemy import func, select, and_
|
|
|
|
|
from sqlalchemy.orm import Session, aliased
|
|
|
|
|
|
2026-06-15 19:37:03 +02:00
|
|
|
from app import quota
|
2026-06-15 21:23:02 +02:00
|
|
|
from app.auth import current_user, has_read_scope, has_write_scope
|
2026-06-15 14:37:09 +02:00
|
|
|
from app.db import get_db
|
|
|
|
|
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
2026-06-15 15:33:53 +02:00
|
|
|
from app.routes.feed import _saved_expr, _serialize
|
2026-06-15 21:23:02 +02:00
|
|
|
from app.sync.playlists import plan_push, push_playlist, sync_user_playlists
|
|
|
|
|
from app.youtube.client import YouTubeClient, YouTubeError
|
2026-06-15 14:37:09 +02:00
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-15 21:23:02 +02:00
|
|
|
def _mark_dirty(pl: Playlist) -> None:
|
|
|
|
|
"""Flag a YouTube-linked local playlist as having unpushed local edits, so the UI can
|
|
|
|
|
offer a 'Sync to YouTube'. No-op for un-linked or mirrored playlists."""
|
|
|
|
|
if pl.source == "local" and pl.yt_playlist_id:
|
|
|
|
|
pl.dirty = True
|
|
|
|
|
|
|
|
|
|
|
2026-06-15 14:37:09 +02:00
|
|
|
def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = None) -> dict:
|
|
|
|
|
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)
|
|
|
|
|
)
|
|
|
|
|
out = {
|
|
|
|
|
"id": pl.id,
|
|
|
|
|
"name": pl.name,
|
|
|
|
|
"kind": pl.kind,
|
|
|
|
|
"source": pl.source,
|
|
|
|
|
"yt_playlist_id": pl.yt_playlist_id,
|
2026-06-15 21:23:02 +02:00
|
|
|
"dirty": pl.dirty,
|
2026-06-15 14:37:09 +02:00
|
|
|
"item_count": count,
|
|
|
|
|
"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()
|
|
|
|
|
)
|
|
|
|
|
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()
|
|
|
|
|
)
|
|
|
|
|
return [
|
|
|
|
|
_summary(db, p, counts.get(p.id, 0), (p.id in member) if contains else None)
|
|
|
|
|
for p in pls
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("")
|
|
|
|
|
def create_playlist(
|
|
|
|
|
payload: dict,
|
|
|
|
|
user: User = Depends(current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> dict:
|
|
|
|
|
name = (payload.get("name") or "").strip()
|
|
|
|
|
if not name:
|
|
|
|
|
raise HTTPException(status_code=400, detail="name is required")
|
|
|
|
|
next_pos = (
|
|
|
|
|
db.scalar(
|
|
|
|
|
select(func.coalesce(func.max(Playlist.position), -1)).where(
|
|
|
|
|
Playlist.user_id == user.id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
+ 1
|
|
|
|
|
)
|
|
|
|
|
pl = Playlist(user_id=user.id, name=name, position=next_pos)
|
|
|
|
|
db.add(pl)
|
|
|
|
|
db.commit()
|
|
|
|
|
return _summary(db, pl, 0)
|
|
|
|
|
|
|
|
|
|
|
2026-06-15 15:33:53 +02:00
|
|
|
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:
|
|
|
|
|
next_pos = (
|
|
|
|
|
db.scalar(
|
|
|
|
|
select(func.coalesce(func.max(Playlist.position), -1)).where(
|
|
|
|
|
Playlist.user_id == user.id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
+ 1
|
|
|
|
|
)
|
|
|
|
|
pl = Playlist(
|
|
|
|
|
user_id=user.id, name="Watch later", kind="watch_later", position=next_pos
|
|
|
|
|
)
|
|
|
|
|
db.add(pl)
|
|
|
|
|
db.commit()
|
|
|
|
|
return pl
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/watch-later")
|
|
|
|
|
def add_watch_later(
|
|
|
|
|
payload: dict,
|
|
|
|
|
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 = payload.get("video_id")
|
|
|
|
|
if not video_id or db.get(Video, video_id) is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Unknown video")
|
|
|
|
|
pl = _watch_later(db, user)
|
|
|
|
|
exists = db.scalar(
|
|
|
|
|
select(PlaylistItem).where(
|
|
|
|
|
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if exists is None:
|
|
|
|
|
next_pos = (
|
|
|
|
|
db.scalar(
|
|
|
|
|
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
|
|
|
|
|
PlaylistItem.playlist_id == pl.id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
+ 1
|
|
|
|
|
)
|
|
|
|
|
db.add(PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos))
|
|
|
|
|
db.commit()
|
|
|
|
|
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:
|
|
|
|
|
item = db.scalar(
|
|
|
|
|
select(PlaylistItem).where(
|
|
|
|
|
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if item is not None:
|
|
|
|
|
db.delete(item)
|
|
|
|
|
db.commit()
|
|
|
|
|
return {"saved": False}
|
|
|
|
|
|
|
|
|
|
|
2026-06-15 19:37:03 +02:00
|
|
|
@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.",
|
|
|
|
|
)
|
|
|
|
|
with quota.attribute(user.id, "playlist_sync"):
|
|
|
|
|
return sync_user_playlists(db, user)
|
|
|
|
|
|
|
|
|
|
|
2026-06-15 21:23:02 +02:00
|
|
|
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."
|
|
|
|
|
)
|
|
|
|
|
if pl.source == "youtube":
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400, detail="This playlist already mirrors a YouTube playlist."
|
|
|
|
|
)
|
|
|
|
|
return pl
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{playlist_id}/push-plan")
|
|
|
|
|
def push_plan(
|
|
|
|
|
playlist_id: int,
|
|
|
|
|
user: User = Depends(current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Dry run: what 'Sync to YouTube' would do + the quota estimate, so the UI can show a
|
|
|
|
|
confirm. For a linked playlist this reads the live YouTube state (cheap, 1 unit/page)."""
|
|
|
|
|
pl = _pushable(db, user, playlist_id)
|
|
|
|
|
if not has_write_scope(user):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=403, detail="Enable YouTube editing in Settings first."
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
with quota.attribute(user.id, "playlist_push"):
|
|
|
|
|
plan = plan_push(db, user, pl)
|
|
|
|
|
except YouTubeError:
|
|
|
|
|
raise HTTPException(status_code=502, 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(current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Push a local playlist to YouTube (create it on first push, else reconcile to match
|
|
|
|
|
local — local wins). Refuses if the estimated quota exceeds what's left today, so a
|
|
|
|
|
big playlist can't strand half-pushed."""
|
|
|
|
|
pl = _pushable(db, user, playlist_id)
|
|
|
|
|
if not has_write_scope(user):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=403, detail="Enable YouTube editing in Settings first."
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
with quota.attribute(user.id, "playlist_push"):
|
|
|
|
|
plan = plan_push(db, user, pl)
|
|
|
|
|
if plan["units_estimate"] > quota.remaining_today(db):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=429,
|
|
|
|
|
detail="Not enough YouTube quota left today for this sync.",
|
|
|
|
|
)
|
|
|
|
|
return push_playlist(db, user, pl)
|
|
|
|
|
except YouTubeError:
|
|
|
|
|
db.rollback()
|
|
|
|
|
raise HTTPException(status_code=502, detail="YouTube playlist sync failed.")
|
|
|
|
|
|
|
|
|
|
|
2026-06-15 14:37:09 +02:00
|
|
|
@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(
|
|
|
|
|
Video.id,
|
|
|
|
|
Video.title,
|
|
|
|
|
Video.channel_id,
|
|
|
|
|
Channel.title.label("channel_title"),
|
|
|
|
|
Channel.thumbnail_url.label("channel_thumbnail"),
|
|
|
|
|
Channel.handle.label("channel_handle"),
|
|
|
|
|
Video.published_at,
|
|
|
|
|
Video.thumbnail_url,
|
|
|
|
|
Video.duration_seconds,
|
|
|
|
|
Video.view_count,
|
|
|
|
|
Video.is_short,
|
|
|
|
|
Video.live_status,
|
|
|
|
|
func.coalesce(state.status, "new").label("status"),
|
|
|
|
|
func.coalesce(state.position_seconds, 0).label("position_seconds"),
|
2026-06-15 15:33:53 +02:00
|
|
|
_saved_expr(user),
|
2026-06-15 14:37:09 +02:00
|
|
|
)
|
|
|
|
|
.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,
|
2026-06-15 21:23:02 +02:00
|
|
|
"dirty": pl.dirty,
|
2026-06-15 14:37:09 +02:00
|
|
|
"items": [_serialize(r) for r in rows],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/{playlist_id}")
|
|
|
|
|
def rename_playlist(
|
|
|
|
|
playlist_id: int,
|
|
|
|
|
payload: dict,
|
|
|
|
|
user: User = Depends(current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> dict:
|
|
|
|
|
pl = _own_playlist(db, user, playlist_id)
|
|
|
|
|
if "name" in payload:
|
|
|
|
|
name = (payload.get("name") or "").strip()
|
|
|
|
|
if not name:
|
|
|
|
|
raise HTTPException(status_code=400, detail="name cannot be empty")
|
|
|
|
|
pl.name = name
|
|
|
|
|
db.commit()
|
|
|
|
|
count = db.scalar(
|
|
|
|
|
select(func.count()).where(PlaylistItem.playlist_id == pl.id)
|
|
|
|
|
)
|
|
|
|
|
return _summary(db, pl, count or 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/{playlist_id}")
|
|
|
|
|
def delete_playlist(
|
|
|
|
|
playlist_id: int,
|
2026-06-15 21:23:02 +02:00
|
|
|
on_youtube: bool = False,
|
2026-06-15 14:37:09 +02:00
|
|
|
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.")
|
2026-06-15 21:23:02 +02:00
|
|
|
if on_youtube:
|
|
|
|
|
if pl.source == "youtube":
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail="This is a mirror of a YouTube playlist; manage it 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, "playlist_delete"):
|
|
|
|
|
with YouTubeClient(db, user) as yt:
|
|
|
|
|
yt.delete_playlist(pl.yt_playlist_id)
|
|
|
|
|
except YouTubeError:
|
|
|
|
|
raise HTTPException(status_code=502, detail="YouTube delete failed.")
|
2026-06-15 14:37:09 +02:00
|
|
|
db.delete(pl) # items cascade
|
|
|
|
|
db.commit()
|
|
|
|
|
return {"deleted": playlist_id}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/{playlist_id}/items")
|
|
|
|
|
def add_item(
|
|
|
|
|
playlist_id: int,
|
|
|
|
|
payload: dict,
|
|
|
|
|
user: User = Depends(current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> dict:
|
|
|
|
|
pl = _own_playlist(db, user, playlist_id)
|
|
|
|
|
video_id = payload.get("video_id")
|
|
|
|
|
if not video_id or db.get(Video, video_id) is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Unknown video")
|
|
|
|
|
exists = db.scalar(
|
|
|
|
|
select(PlaylistItem).where(
|
|
|
|
|
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if exists is None:
|
|
|
|
|
next_pos = (
|
|
|
|
|
db.scalar(
|
|
|
|
|
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
|
|
|
|
|
PlaylistItem.playlist_id == pl.id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
+ 1
|
|
|
|
|
)
|
|
|
|
|
db.add(
|
|
|
|
|
PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos)
|
|
|
|
|
)
|
2026-06-15 21:23:02 +02:00
|
|
|
_mark_dirty(pl)
|
2026-06-15 14:37:09 +02:00
|
|
|
db.commit()
|
|
|
|
|
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)
|
|
|
|
|
item = db.scalar(
|
|
|
|
|
select(PlaylistItem).where(
|
|
|
|
|
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if item is not None:
|
|
|
|
|
db.delete(item)
|
2026-06-15 21:23:02 +02:00
|
|
|
_mark_dirty(pl)
|
2026-06-15 14:37:09 +02:00
|
|
|
db.commit()
|
|
|
|
|
return {"playlist_id": pl.id, "video_id": video_id}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/{playlist_id}/order")
|
|
|
|
|
def reorder_items(
|
|
|
|
|
playlist_id: int,
|
|
|
|
|
payload: dict,
|
|
|
|
|
user: User = Depends(current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> dict:
|
|
|
|
|
pl = _own_playlist(db, user, playlist_id)
|
|
|
|
|
order = payload.get("video_ids") or []
|
|
|
|
|
items = {
|
|
|
|
|
it.video_id: it
|
|
|
|
|
for it in db.execute(
|
|
|
|
|
select(PlaylistItem).where(PlaylistItem.playlist_id == pl.id)
|
|
|
|
|
).scalars()
|
|
|
|
|
}
|
|
|
|
|
pos = 0
|
|
|
|
|
for vid in order:
|
|
|
|
|
it = items.get(vid)
|
|
|
|
|
if it is not None:
|
|
|
|
|
it.position = pos
|
|
|
|
|
pos += 1
|
2026-06-15 21:23:02 +02:00
|
|
|
_mark_dirty(pl)
|
2026-06-15 14:37:09 +02:00
|
|
|
db.commit()
|
|
|
|
|
return {"playlist_id": pl.id, "count": pos}
|