feat(playlists): make YouTube-mirrored playlists editable + syncable

Mirrors (source='youtube') were read-only; per the original two-way design they can
now be edited locally (add/remove/reorder/rename) and pushed back. The read-sync
skips a dirty mirror so it won't clobber unpushed edits; a successful push clears
dirty and lets the mirror refresh. _mark_dirty now covers any YouTube-linked list
(exported local or mirror), and rename marks dirty too. push/delete no longer reject
mirrors. Push reconciles the title as well (cheap playlists.list compare, then
playlists.update only if changed) so a local rename doesn't revert on the next pull.
Frontend: editable = not-built-in (was also excluding mirrors); rows/reorder enabled
for all owned lists; AddToPlaylist popover lists mirrors too (with a YT marker); the
origin chip now reads 'edits sync back'. Trilingual.
This commit is contained in:
npeter83 2026-06-15 21:40:13 +02:00
parent 49d226126c
commit 9acea11010
8 changed files with 58 additions and 26 deletions

View file

@ -24,9 +24,10 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
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:
"""Flag a YouTube-linked playlist as having unpushed local edits, so the UI can offer
a 'Sync to YouTube' and the read-sync won't clobber them. Covers both exported local
playlists and edited YouTube mirrors; no-op for un-linked lists and Watch later."""
if pl.kind != "watch_later" and pl.yt_playlist_id:
pl.dirty = True
@ -222,10 +223,6 @@ def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
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
@ -335,7 +332,9 @@ def rename_playlist(
name = (payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name cannot be empty")
pl.name = name
if name != pl.name:
pl.name = name
_mark_dirty(pl)
db.commit()
count = db.scalar(
select(func.count()).where(PlaylistItem.playlist_id == pl.id)
@ -354,11 +353,6 @@ def delete_playlist(
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):