From 9acea11010838138773b7fbdaa2d24575cd24c1d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 21:40:13 +0200 Subject: [PATCH] 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. --- backend/app/routes/playlists.py | 20 +++++++------------- backend/app/sync/playlists.py | 16 +++++++++++++++- backend/app/youtube/client.py | 21 +++++++++++++++++++++ frontend/src/components/AddToPlaylist.tsx | 13 ++++++++----- frontend/src/components/Playlists.tsx | 8 ++++---- frontend/src/i18n/locales/de/playlists.json | 2 +- frontend/src/i18n/locales/en/playlists.json | 2 +- frontend/src/i18n/locales/hu/playlists.json | 2 +- 8 files changed, 58 insertions(+), 26 deletions(-) diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index f4ff7d1..e7942f6 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -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): diff --git a/backend/app/sync/playlists.py b/backend/app/sync/playlists.py index 6d6c832..5dfc85b 100644 --- a/backend/app/sync/playlists.py +++ b/backend/app/sync/playlists.py @@ -108,6 +108,10 @@ def sync_user_playlists(db: Session, user: User) -> dict: if not ytid or ytid in owned_yt_ids: continue pl = existing.get(ytid) + if pl is not None and pl.dirty: + # Local edits not yet pushed: don't clobber them with YouTube's state. + # A successful 'Sync to YouTube' clears dirty and lets the mirror refresh. + continue if pl is None: pl = Playlist( user_id=user.id, @@ -184,11 +188,14 @@ def plan_push(db: Session, user: User, pl: Playlist) -> dict: "to_insert": len(desired), "to_delete": 0, "to_reorder": 0, + "rename": False, "yt_extra": 0, "units_estimate": ops * WRITE_UNIT_COST, } with YouTubeClient(db, user) as yt: current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)) + snippet = yt.get_playlist_snippet(pl.yt_playlist_id) + rename = bool(snippet) and (snippet.get("title") or "") != pl.name cur_vids = [it["video_id"] for it in current] cur_set = set(cur_vids) desired_set = set(desired) @@ -198,12 +205,13 @@ def plan_push(db: Session, user: User, pl: Playlist) -> dict: # appended; the reorder pass then moves items into the desired order. after_membership = [v for v in cur_vids if v in desired_set] + to_insert reorders = _reorder_moves(after_membership, desired) - ops = len(to_insert) + len(to_delete) + reorders + ops = len(to_insert) + len(to_delete) + reorders + (1 if rename else 0) return { "action": "update", "to_insert": len(to_insert), "to_delete": len(to_delete), "to_reorder": reorders, + "rename": rename, "yt_extra": len(to_delete), "units_estimate": ops * WRITE_UNIT_COST, } @@ -240,6 +248,12 @@ def push_playlist(db: Session, user: User, pl: Playlist) -> dict: } # Existing link: diff against the live YouTube state. + snippet = yt.get_playlist_snippet(pl.yt_playlist_id) + if snippet is not None and (snippet.get("title") or "") != pl.name: + try: + yt.update_playlist_title(pl.yt_playlist_id, pl.name) + except YouTubeError: + failures.append("title") current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)) item_id_by_vid = {it["video_id"]: it["item_id"] for it in current} cur_vids = [it["video_id"] for it in current] diff --git a/backend/app/youtube/client.py b/backend/app/youtube/client.py index 4bea310..f0d0da4 100644 --- a/backend/app/youtube/client.py +++ b/backend/app/youtube/client.py @@ -247,6 +247,27 @@ class YouTubeClient: raise YouTubeError(f"{method} {path} -> {resp.status_code}: {resp.text[:300]}") return resp.json() if resp.content else {} + def get_playlist_snippet(self, playlist_id: str) -> dict | None: + """The snippet (title/description) of one playlist, or None if it no longer + exists. 1 unit (OAuth, so private playlists work).""" + data = self._get( + "playlists", + {"part": "snippet", "id": playlist_id, "maxResults": 1}, + cost=1, + allow_key=False, + ) + items = data.get("items", []) + return items[0].get("snippet") if items else None + + def update_playlist_title(self, playlist_id: str, title: str) -> None: + """Rename a playlist on YouTube. 50 units.""" + self._write( + "PUT", + "playlists", + params={"part": "snippet"}, + json={"id": playlist_id, "snippet": {"title": title[:150]}}, + ) + def create_playlist(self, title: str, description: str = "", privacy: str = "private") -> str: """Create a new YouTube playlist; returns its id. 50 units.""" data = self._write( diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index e46d447..3124c97 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, ListPlus, Plus } from "lucide-react"; +import { Check, ListPlus, Plus, Youtube } from "lucide-react"; import { api, type Playlist } from "../lib/api"; // A small popover (portaled to body, so the feed grid can't clip it) for toggling a @@ -118,9 +118,9 @@ export default function AddToPlaylist({ } } - // YouTube-mirrored playlists are read-only until the write-back phase, so they can't be - // added to here yet. - const lists = (membership.data ?? []).filter((pl) => pl.source !== "youtube"); + // All of the user's playlists are add targets, including YouTube-linked ones — adding + // marks them dirty, and a later "Sync to YouTube" pushes the change back. + const lists = membership.data ?? []; return ( <> @@ -167,9 +167,12 @@ export default function AddToPlaylist({ > {pl.has_video && } - + {pl.kind === "watch_later" ? t("playlists.watchLater") : pl.name} + {(pl.source === "youtube" || pl.yt_playlist_id) && ( + + )} ))} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index d6a08bd..6817e78 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -161,9 +161,10 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { const plName = (p: { kind: string; name: string }) => p.kind === "watch_later" ? t("playlists.watchLater") : p.name; const builtin = detail?.kind === "watch_later"; - // YouTube-mirrored playlists are read-only here until the write-back phase. + // YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips + // a dirty mirror so it won't clobber unpushed edits); the chip just marks their origin. const mirrored = detail?.source === "youtube"; - const editable = !builtin && !mirrored; + const editable = !builtin; const linked = editable && !!detail?.yt_playlist_id; const [syncing, setSyncing] = useState(false); const [pushing, setPushing] = useState(false); @@ -253,7 +254,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { async function onDragEnd(e: DragEndEvent) { const { active: a, over } = e; - if (!over || a.id === over.id || selectedId == null || mirrored) return; + if (!over || a.id === over.id || selectedId == null) return; const oldIndex = items.findIndex((v) => v.id === a.id); const newIndex = items.findIndex((v) => v.id === over.id); if (oldIndex < 0 || newIndex < 0) return; @@ -513,7 +514,6 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { key={v.id} video={v} index={i} - readOnly={mirrored} onPlay={() => setPlayingIndex(i)} onRemove={() => removeItem(v.id)} /> diff --git a/frontend/src/i18n/locales/de/playlists.json b/frontend/src/i18n/locales/de/playlists.json index 0bb51a5..3e4ca7f 100644 --- a/frontend/src/i18n/locales/de/playlists.json +++ b/frontend/src/i18n/locales/de/playlists.json @@ -4,7 +4,7 @@ "syncYoutube": "Von YouTube synchronisieren", "syncedToast": "{{count}} Wiedergabelisten von YouTube synchronisiert", "syncFailed": "Synchronisierung von YouTube fehlgeschlagen", - "ytReadonly": "Von YouTube synchronisiert — vorerst schreibgeschützt", + "ytReadonly": "Von YouTube synchronisiert — Änderungen werden zurücksynchronisiert", "noneYet": "Noch keine Wiedergabelisten", "newPlaylist": "Neue Liste…", "itemCount_one": "{{count}} Video", diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index bf798db..2f61a01 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -4,7 +4,7 @@ "syncYoutube": "Sync from YouTube", "syncedToast": "Synced {{count}} playlists from YouTube", "syncFailed": "Couldn't sync from YouTube", - "ytReadonly": "Synced from YouTube — read-only for now", + "ytReadonly": "Synced from YouTube — edits sync back", "noneYet": "No playlists yet", "newPlaylist": "New playlist…", "itemCount_one": "{{count}} video", diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index 16ed77f..233cb49 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -4,7 +4,7 @@ "syncYoutube": "Szinkron YouTube-ról", "syncedToast": "{{count}} lista szinkronizálva a YouTube-ról", "syncFailed": "Nem sikerült a YouTube-szinkron", - "ytReadonly": "YouTube-ról szinkronizálva — egyelőre csak olvasható", + "ytReadonly": "YouTube-ról szinkronizálva — a módosítások visszaszinkronizálhatók", "noneYet": "Még nincs lejátszási lista", "newPlaylist": "Új lista…", "itemCount_one": "{{count}} videó",