feat(playlists): push local playlists to YouTube (export + diff sync)

Local 'user' playlists (not Watch later, not YouTube mirrors) can now be pushed
to YouTube: create a new playlist on first push, else reconcile membership + order
so YouTube matches local (local wins). plan_push computes a dry-run estimate
(inserts/deletes/reorders + quota units + divergence) read from the live YouTube
state; push refuses if the estimate exceeds the remaining daily quota so a big
playlist can't strand half-pushed. Reorder uses an insertion-sort move count that
matches what executes. Edits to a linked playlist set dirty; a successful push
clears it. DELETE accepts on_youtube to also delete the playlist on YouTube. The
read-sync now skips YouTube playlists already owned by a local export, avoiding
duplicate read-only mirrors.
This commit is contained in:
npeter83 2026-06-15 21:23:02 +02:00
parent b7bf6cc6f9
commit b150337c87
2 changed files with 266 additions and 4 deletions

View file

@ -6,11 +6,12 @@ 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
from app.auth import current_user, has_read_scope, has_write_scope
from app.db import get_db
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
from app.routes.feed import _saved_expr, _serialize
from app.sync.playlists import sync_user_playlists
from app.sync.playlists import plan_push, push_playlist, sync_user_playlists
from app.youtube.client import YouTubeClient, YouTubeError
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
@ -22,6 +23,13 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
return pl
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
def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = None) -> dict:
cover = db.scalar(
select(Video.thumbnail_url)
@ -36,6 +44,7 @@ def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = Non
"kind": pl.kind,
"source": pl.source,
"yt_playlist_id": pl.yt_playlist_id,
"dirty": pl.dirty,
"item_count": count,
"cover_thumbnail": cover,
}
@ -205,6 +214,72 @@ def sync_youtube(
return sync_user_playlists(db, user)
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.")
@router.get("/{playlist_id}")
def get_playlist(
playlist_id: int,
@ -243,6 +318,7 @@ def get_playlist(
"kind": pl.kind,
"source": pl.source,
"yt_playlist_id": pl.yt_playlist_id,
"dirty": pl.dirty,
"items": [_serialize(r) for r in rows],
}
@ -270,12 +346,31 @@ def rename_playlist(
@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 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.")
db.delete(pl) # items cascade
db.commit()
return {"deleted": playlist_id}
@ -309,6 +404,7 @@ def add_item(
db.add(
PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos)
)
_mark_dirty(pl)
db.commit()
return {"playlist_id": pl.id, "video_id": video_id}
@ -328,6 +424,7 @@ def remove_item(
)
if item is not None:
db.delete(item)
_mark_dirty(pl)
db.commit()
return {"playlist_id": pl.id, "video_id": video_id}
@ -353,5 +450,6 @@ def reorder_items(
if it is not None:
it.position = pos
pos += 1
_mark_dirty(pl)
db.commit()
return {"playlist_id": pl.id, "count": pos}