feat(playlists): reset a playlist to its YouTube state (discard local edits)
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.
This commit is contained in:
parent
4efeb2fd43
commit
a8a3734496
7 changed files with 120 additions and 4 deletions
|
|
@ -10,7 +10,12 @@ from app.auth import current_user, has_read_scope, has_write_scope
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
||||||
from app.routes.feed import _saved_expr, _serialize
|
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
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
||||||
|
|
@ -251,6 +256,30 @@ def sync_youtube(
|
||||||
return sync_user_playlists(db, user)
|
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:
|
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
|
"""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."""
|
Watch later and read-only YouTube mirrors are never pushable."""
|
||||||
|
|
|
||||||
|
|
@ -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:
|
def sync_all_playlists(db: Session) -> dict:
|
||||||
"""Scheduler entry point: mirror playlists for every user with a read scope + token."""
|
"""Scheduler entry point: mirror playlists for every user with a read scope + token."""
|
||||||
users = (
|
users = (
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
|
|
@ -326,6 +327,28 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
const linked = editable && !!detail?.yt_playlist_id;
|
const linked = editable && !!detail?.yt_playlist_id;
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [pushing, setPushing] = 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() {
|
async function pushToYoutube() {
|
||||||
if (selectedId == null || !detail || pushing) return;
|
if (selectedId == null || !detail || pushing) return;
|
||||||
|
|
@ -685,9 +708,20 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{linked && detail.dirty && (
|
{linked && detail.dirty && (
|
||||||
<span className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg text-accent">
|
<>
|
||||||
{t("playlists.unsynced")}
|
<button
|
||||||
</span>
|
onClick={revertToYoutube}
|
||||||
|
disabled={reverting}
|
||||||
|
title={t("playlists.revert")}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||||||
|
{t("playlists.revert")}
|
||||||
|
</button>
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg text-accent">
|
||||||
|
{t("playlists.unsynced")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{mirrored && (
|
{mirrored && (
|
||||||
<span className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg text-muted">
|
<span className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg text-muted">
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@
|
||||||
"exportToYoutube": "Zu YouTube exportieren",
|
"exportToYoutube": "Zu YouTube exportieren",
|
||||||
"pushToYoutube": "Mit YouTube synchronisieren",
|
"pushToYoutube": "Mit YouTube synchronisieren",
|
||||||
"unsynced": "Nicht synchronisierte Änderungen",
|
"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",
|
"pushTitle": "Mit YouTube synchronisieren",
|
||||||
"pushConfirm": "Synchronisieren",
|
"pushConfirm": "Synchronisieren",
|
||||||
"pushPlanCreate": "Eine neue YouTube-Wiedergabeliste mit {{count}} Videos erstellen? (~{{units}} Kontingenteinheiten; heute noch {{left}} übrig.)",
|
"pushPlanCreate": "Eine neue YouTube-Wiedergabeliste mit {{count}} Videos erstellen? (~{{units}} Kontingenteinheiten; heute noch {{left}} übrig.)",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@
|
||||||
"exportToYoutube": "Export to YouTube",
|
"exportToYoutube": "Export to YouTube",
|
||||||
"pushToYoutube": "Sync to YouTube",
|
"pushToYoutube": "Sync to YouTube",
|
||||||
"unsynced": "Unsynced changes",
|
"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",
|
"pushTitle": "Sync to YouTube",
|
||||||
"pushConfirm": "Sync",
|
"pushConfirm": "Sync",
|
||||||
"pushPlanCreate": "Create a new YouTube playlist with {{count}} videos? (~{{units}} quota units; {{left}} left today.)",
|
"pushPlanCreate": "Create a new YouTube playlist with {{count}} videos? (~{{units}} quota units; {{left}} left today.)",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@
|
||||||
"exportToYoutube": "Exportálás YouTube-ra",
|
"exportToYoutube": "Exportálás YouTube-ra",
|
||||||
"pushToYoutube": "Szinkron YouTube-ra",
|
"pushToYoutube": "Szinkron YouTube-ra",
|
||||||
"unsynced": "Nem szinkronizált változások",
|
"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",
|
"pushTitle": "Szinkron YouTube-ra",
|
||||||
"pushConfirm": "Szinkron",
|
"pushConfirm": "Szinkron",
|
||||||
"pushPlanCreate": "Létrehozol egy új YouTube-listát {{count}} videóval? (~{{units}} kvótaegység; ma még {{left}} maradt.)",
|
"pushPlanCreate": "Létrehozol egy új YouTube-listát {{count}} videóval? (~{{units}} kvótaegység; ma még {{left}} maradt.)",
|
||||||
|
|
|
||||||
|
|
@ -370,6 +370,8 @@ export const api = {
|
||||||
req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }),
|
req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }),
|
||||||
syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> =>
|
syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> =>
|
||||||
req("/api/playlists/sync-youtube", { method: "POST" }),
|
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<PushPlan> =>
|
playlistPushPlan: (id: number): Promise<PushPlan> =>
|
||||||
req(`/api/playlists/${id}/push-plan`),
|
req(`/api/playlists/${id}/push-plan`),
|
||||||
pushPlaylist: (id: number): Promise<PushResult> =>
|
pushPlaylist: (id: number): Promise<PushResult> =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue