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 =>