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:
parent
b7bf6cc6f9
commit
b150337c87
2 changed files with 266 additions and 4 deletions
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue