From 55833d8f72d833059fc8c5aefed78373ff2539fc Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 22:28:16 +0200 Subject: [PATCH] feat(playlists): reset a playlist to its YouTube state (discard local edits) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-session undo/redo is lost when switching playlists, leaving no way to undo local edits to a YouTube-linked list afterwards. Add a per-playlist 'Reset to YouTube' action (shown when a linked playlist is dirty) that force-repulls that single playlist from YouTube, replacing its items/order/name and clearing dirty — even though the bulk read-sync skips dirty playlists. Backend: repull_playlist() + POST /api/playlists/ {id}/revert-youtube (read-scope gated). Confirm dialog (destructive). Trilingual. --- backend/app/routes/playlists.py | 31 +++++++++++++++- backend/app/sync/playlists.py | 33 +++++++++++++++++ frontend/src/components/Playlists.tsx | 40 +++++++++++++++++++-- frontend/src/i18n/locales/de/playlists.json | 6 ++++ frontend/src/i18n/locales/en/playlists.json | 6 ++++ frontend/src/i18n/locales/hu/playlists.json | 6 ++++ frontend/src/lib/api.ts | 2 ++ 7 files changed, 120 insertions(+), 4 deletions(-) diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index 375a2fe..22b03a5 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -10,7 +10,12 @@ 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 plan_push, push_playlist, sync_user_playlists +from app.sync.playlists import ( + plan_push, + push_playlist, + repull_playlist, + sync_user_playlists, +) from app.youtube.client import YouTubeClient, YouTubeError router = APIRouter(prefix="/api/playlists", tags=["playlists"]) @@ -251,6 +256,30 @@ def sync_youtube( return sync_user_playlists(db, user) +@router.post("/{playlist_id}/revert-youtube") +def revert_youtube( + playlist_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Reset one YouTube-linked playlist to its current YouTube state, discarding local + edits (and clearing the dirty flag). The user-facing 'undo all my changes' for a list + once the in-session undo history is gone.""" + pl = _own_playlist(db, user, playlist_id) + if not pl.yt_playlist_id: + raise HTTPException(status_code=400, detail="This playlist isn't linked to YouTube.") + if not has_read_scope(user): + raise HTTPException( + status_code=403, detail="Connect YouTube (read access) to sync your playlists." + ) + try: + with quota.attribute(user.id, "playlist_sync"): + return repull_playlist(db, user, pl) + except YouTubeError: + db.rollback() + raise HTTPException(status_code=502, detail="Couldn't refresh from YouTube.") + + 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.""" diff --git a/backend/app/sync/playlists.py b/backend/app/sync/playlists.py index 5dfc85b..9051920 100644 --- a/backend/app/sync/playlists.py +++ b/backend/app/sync/playlists.py @@ -305,6 +305,39 @@ def push_playlist(db: Session, user: User, pl: Playlist) -> dict: } +def repull_playlist(db: Session, user: User, pl: Playlist) -> dict: + """Force-refresh ONE YouTube-linked playlist from YouTube, discarding local edits and + clearing dirty. This is the per-playlist 'reset to YouTube' — unlike the bulk read-sync + it deliberately does NOT skip a dirty playlist (overwriting local changes is the point). + Works for both mirrors (source='youtube') and exported local playlists (yt_playlist_id).""" + if not pl.yt_playlist_id: + raise YouTubeError("Playlist is not linked to YouTube") + with YouTubeClient(db, user) as yt: + snippet = yt.get_playlist_snippet(pl.yt_playlist_id) + if snippet is None: + raise YouTubeError("Playlist no longer exists on YouTube") + ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id)) + _ensure_videos(db, yt, ids) + present = ( + set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all()) + if ids + else set() + ) + ordered: list[str] = [] + seen: set[str] = set() + for v in ids: + if v in present and v not in seen: + seen.add(v) + ordered.append(v) + pl.name = snippet.get("title") or pl.name + db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id)) + for pos, vid in enumerate(ordered): + db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos)) + pl.dirty = False + db.commit() + return {"items": len(ordered)} + + def sync_all_playlists(db: Session) -> dict: """Scheduler entry point: mirror playlists for every user with a read scope + token.""" users = ( diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index f0b77eb..1228904 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -27,6 +27,7 @@ import { Play, Plus, RefreshCw, + RotateCcw, Trash2, Upload, X, @@ -326,6 +327,28 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { const linked = editable && !!detail?.yt_playlist_id; const [syncing, setSyncing] = useState(false); const [pushing, setPushing] = useState(false); + const [reverting, setReverting] = useState(false); + + async function revertToYoutube() { + if (selectedId == null || !detail || reverting) return; + const ok = await confirm({ + title: t("playlists.revertTitle"), + message: t("playlists.revertMsg"), + confirmLabel: t("playlists.revertConfirm"), + danger: true, + }); + if (!ok) return; + setReverting(true); + try { + await api.revertPlaylist(selectedId); + refreshAll(); + notify({ level: "success", message: t("playlists.revertDone") }); + } catch { + notify({ level: "warning", message: t("playlists.revertFailed") }); + } finally { + setReverting(false); + } + } async function pushToYoutube() { if (selectedId == null || !detail || pushing) return; @@ -685,9 +708,20 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { )} {linked && detail.dirty && ( - - {t("playlists.unsynced")} - + <> + + + {t("playlists.unsynced")} + + )} {mirrored && ( diff --git a/frontend/src/i18n/locales/de/playlists.json b/frontend/src/i18n/locales/de/playlists.json index 2b1f16a..4494e93 100644 --- a/frontend/src/i18n/locales/de/playlists.json +++ b/frontend/src/i18n/locales/de/playlists.json @@ -20,6 +20,12 @@ "exportToYoutube": "Zu YouTube exportieren", "pushToYoutube": "Mit YouTube synchronisieren", "unsynced": "Nicht synchronisierte Änderungen", + "revert": "Von YouTube zurücksetzen", + "revertTitle": "Lokale Änderungen verwerfen?", + "revertMsg": "Dies lädt die Wiedergabeliste von YouTube neu und verwirft deine nicht synchronisierten lokalen Änderungen (Reihenfolge, Hinzufügungen, Entfernungen, Umbenennung). Fortfahren?", + "revertConfirm": "Verwerfen & neu laden", + "revertDone": "Von YouTube neu geladen ✓", + "revertFailed": "Neuladen von YouTube fehlgeschlagen.", "pushTitle": "Mit YouTube synchronisieren", "pushConfirm": "Synchronisieren", "pushPlanCreate": "Eine neue YouTube-Wiedergabeliste mit {{count}} Videos erstellen? (~{{units}} Kontingenteinheiten; heute noch {{left}} übrig.)", diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index 3e5b1cd..8e5eb44 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -20,6 +20,12 @@ "exportToYoutube": "Export to YouTube", "pushToYoutube": "Sync to YouTube", "unsynced": "Unsynced changes", + "revert": "Reset to YouTube", + "revertTitle": "Discard local changes?", + "revertMsg": "This reloads the playlist from YouTube and discards your unsynced local changes (order, additions, removals, rename). Continue?", + "revertConfirm": "Discard & reload", + "revertDone": "Reloaded from YouTube ✓", + "revertFailed": "Couldn't reload from YouTube.", "pushTitle": "Sync to YouTube", "pushConfirm": "Sync", "pushPlanCreate": "Create a new YouTube playlist with {{count}} videos? (~{{units}} quota units; {{left}} left today.)", diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index c9d1b8b..29240e9 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -20,6 +20,12 @@ "exportToYoutube": "Exportálás YouTube-ra", "pushToYoutube": "Szinkron YouTube-ra", "unsynced": "Nem szinkronizált változások", + "revert": "Visszaállítás YouTube-ról", + "revertTitle": "Eldobod a helyi módosításokat?", + "revertMsg": "Ez újratölti a listát a YouTube-ról, és eldobja a nem szinkronizált helyi módosításaidat (sorrend, hozzáadás, eltávolítás, átnevezés). Folytatod?", + "revertConfirm": "Eldobás és újratöltés", + "revertDone": "Újratöltve a YouTube-ról ✓", + "revertFailed": "Nem sikerült újratölteni a YouTube-ról.", "pushTitle": "Szinkron YouTube-ra", "pushConfirm": "Szinkron", "pushPlanCreate": "Létrehozol egy új YouTube-listát {{count}} videóval? (~{{units}} kvótaegység; ma még {{left}} maradt.)", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e35a988..350c270 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -370,6 +370,8 @@ export const api = { req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }), syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> => req("/api/playlists/sync-youtube", { method: "POST" }), + revertPlaylist: (id: number): Promise<{ items: number }> => + req(`/api/playlists/${id}/revert-youtube`, { method: "POST" }), playlistPushPlan: (id: number): Promise => req(`/api/playlists/${id}/push-plan`), pushPlaylist: (id: number): Promise =>