feat(playlists): Sync to YouTube UI + delete-on-YouTube choice

Editable local playlists get an Export/Sync to YouTube button (write-scope gated):
it fetches a dry-run plan, shows a confirm with the change counts, quota estimate
and divergence warning, then pushes. An 'unsynced changes' badge and an accented
YouTube icon mark linked playlists with local edits. Deleting a linked playlist
offers 'delete on YouTube too' vs 'here only'. Trilingual strings (HU/EN/DE).
This commit is contained in:
npeter83 2026-06-15 21:23:13 +02:00
parent b89c00f909
commit 05c2399b94
6 changed files with 190 additions and 9 deletions

View file

@ -232,7 +232,7 @@ export default function App() {
) : page === "stats" && meQuery.data!.role === "admin" ? ( ) : page === "stats" && meQuery.data!.role === "admin" ? (
<Stats /> <Stats />
) : page === "playlists" ? ( ) : page === "playlists" ? (
<Playlists /> <Playlists canWrite={meQuery.data!.can_write} />
) : ( ) : (
<Feed <Feed
filters={filters} filters={filters}

View file

@ -25,6 +25,7 @@ import {
Plus, Plus,
RefreshCw, RefreshCw,
Trash2, Trash2,
Upload,
X, X,
Youtube, Youtube,
} from "lucide-react"; } from "lucide-react";
@ -107,7 +108,7 @@ function Row({
); );
} }
export default function Playlists() { export default function Playlists({ canWrite }: { canWrite: boolean }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
@ -163,7 +164,63 @@ export default function Playlists() {
// YouTube-mirrored playlists are read-only here until the write-back phase. // YouTube-mirrored playlists are read-only here until the write-back phase.
const mirrored = detail?.source === "youtube"; const mirrored = detail?.source === "youtube";
const editable = !builtin && !mirrored; const editable = !builtin && !mirrored;
const linked = editable && !!detail?.yt_playlist_id;
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [pushing, setPushing] = useState(false);
async function pushToYoutube() {
if (selectedId == null || !detail || pushing) return;
setPushing(true);
try {
const plan = await api.playlistPushPlan(selectedId);
if (plan.action === "update" && !plan.to_insert && !plan.to_delete && !plan.to_reorder) {
notify({ level: "info", message: t("playlists.pushUpToDate") });
return;
}
if (!plan.affordable) {
notify({
level: "warning",
message: t("playlists.pushNoQuota", {
left: plan.remaining_today,
units: plan.units_estimate,
}),
});
return;
}
let message =
plan.action === "create"
? t("playlists.pushPlanCreate", {
count: plan.to_insert,
units: plan.units_estimate,
left: plan.remaining_today,
})
: t("playlists.pushPlanUpdate", {
insert: plan.to_insert,
del: plan.to_delete,
reorder: plan.to_reorder,
units: plan.units_estimate,
left: plan.remaining_today,
});
if (plan.yt_extra > 0) message += " " + t("playlists.pushDiverged", { count: plan.yt_extra });
const ok = await confirm({
title: t("playlists.pushTitle"),
message,
confirmLabel: t("playlists.pushConfirm"),
});
if (!ok) return;
const r = await api.pushPlaylist(selectedId);
refreshAll();
if (r.failures.length) {
notify({ level: "warning", message: t("playlists.pushPartial", { count: r.failures.length }) });
} else {
notify({ level: "success", message: t("playlists.pushDone") });
}
} catch {
notify({ level: "warning", message: t("playlists.pushFailed") });
} finally {
setPushing(false);
}
}
async function syncYoutube() { async function syncYoutube() {
if (syncing) return; if (syncing) return;
@ -232,12 +289,27 @@ export default function Playlists() {
danger: true, danger: true,
}); });
if (!ok) return; if (!ok) return;
// For a YouTube-linked playlist, offer to delete it on YouTube too (Cancel = here only).
let onYoutube = false;
if (linked && canWrite) {
onYoutube = await confirm({
title: t("playlists.deleteOnYoutubeTitle"),
message: t("playlists.deleteOnYoutubeMsg", { name: detail.name }),
confirmLabel: t("playlists.deleteOnYoutubeConfirm"),
cancelLabel: t("playlists.deleteHereOnly"),
danger: true,
});
}
const deletedId = selectedId; const deletedId = selectedId;
// Pick the next selection from what's left *now* (don't go via null, or the // Pick the next selection from what's left *now* (don't go via null, or the
// auto-select effect would re-pick the just-deleted id from the stale list). // auto-select effect would re-pick the just-deleted id from the stale list).
const remaining = playlists.filter((p) => p.id !== deletedId); const remaining = playlists.filter((p) => p.id !== deletedId);
setSelectedId(remaining[0]?.id ?? null); setSelectedId(remaining[0]?.id ?? null);
await api.deletePlaylist(deletedId); try {
await api.deletePlaylist(deletedId, onYoutube);
} catch {
notify({ level: "warning", message: t("playlists.deleteYtFailed") });
}
qc.removeQueries({ queryKey: ["playlist", deletedId] }); qc.removeQueries({ queryKey: ["playlist", deletedId] });
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: ["playlists"] });
} }
@ -287,8 +359,10 @@ export default function Playlists() {
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1 text-[13px] text-fg"> <div className="flex items-center gap-1 text-[13px] text-fg">
<span className="truncate">{plName(pl)}</span> <span className="truncate">{plName(pl)}</span>
{pl.source === "youtube" && ( {(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube className="w-3 h-3 shrink-0 text-muted" /> <Youtube
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
/>
)} )}
</div> </div>
<div className="text-[11px] text-muted"> <div className="text-[11px] text-muted">
@ -373,6 +447,27 @@ export default function Playlists() {
> >
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")} <Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</button> </button>
{editable && canWrite && (
<button
onClick={pushToYoutube}
disabled={pushing}
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border transition disabled:opacity-40 ${
detail.dirty
? "border-accent text-accent hover:bg-accent/10"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{pushing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : linked ? (
<Youtube className="w-3.5 h-3.5" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
{linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
</button>
)}
{editable && ( {editable && (
<button <button
onClick={deletePlaylist} onClick={deletePlaylist}
@ -381,6 +476,11 @@ export default function Playlists() {
<Trash2 className="w-3.5 h-3.5" /> {t("playlists.delete")} <Trash2 className="w-3.5 h-3.5" /> {t("playlists.delete")}
</button> </button>
)} )}
{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")}
</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">
<Youtube className="w-3.5 h-3.5" /> {t("playlists.ytReadonly")} <Youtube className="w-3.5 h-3.5" /> {t("playlists.ytReadonly")}

View file

@ -16,5 +16,23 @@
"delete": "Löschen", "delete": "Löschen",
"rename": "Umbenennen", "rename": "Umbenennen",
"confirmDelete": "Die Wiedergabeliste „{{name}}“ löschen? Kann nicht rückgängig gemacht werden.", "confirmDelete": "Die Wiedergabeliste „{{name}}“ löschen? Kann nicht rückgängig gemacht werden.",
"addToPlaylist": "Zur Wiedergabeliste hinzufügen" "addToPlaylist": "Zur Wiedergabeliste hinzufügen",
"exportToYoutube": "Zu YouTube exportieren",
"pushToYoutube": "Mit YouTube synchronisieren",
"unsynced": "Nicht synchronisierte Änderungen",
"pushTitle": "Mit YouTube synchronisieren",
"pushConfirm": "Synchronisieren",
"pushPlanCreate": "Eine neue YouTube-Wiedergabeliste mit {{count}} Videos erstellen? (~{{units}} Kontingenteinheiten; heute noch {{left}} übrig.)",
"pushPlanUpdate": "Auf YouTube aktualisieren — {{insert}} hinzufügen, {{del}} entfernen, {{reorder}} neu anordnen? (~{{units}} Kontingenteinheiten; heute noch {{left}} übrig.)",
"pushDiverged": "{{count}} Video(s), die derzeit auf YouTube sind, werden entfernt.",
"pushNoQuota": "Nicht genug YouTube-Kontingent für heute ({{left}}) für diese Synchronisierung (~{{units}} benötigt). Versuche es morgen erneut.",
"pushUpToDate": "Bereits mit YouTube synchronisiert.",
"pushDone": "Mit YouTube synchronisiert ✓",
"pushPartial": "Synchronisiert, aber {{count}} Element(e) wurden übersprungen.",
"pushFailed": "Synchronisierung mit YouTube fehlgeschlagen.",
"deleteOnYoutubeTitle": "Auch auf YouTube löschen?",
"deleteOnYoutubeMsg": "„{{name}}“ auch aus deinem YouTube-Konto löschen? Wähle „Nur hier“, um sie auf YouTube zu behalten.",
"deleteOnYoutubeConfirm": "Auf YouTube löschen",
"deleteHereOnly": "Nur hier",
"deleteYtFailed": "Löschen auf YouTube fehlgeschlagen; nur hier entfernt."
} }

View file

@ -16,5 +16,23 @@
"delete": "Delete", "delete": "Delete",
"rename": "Rename", "rename": "Rename",
"confirmDelete": "Delete the playlist “{{name}}”? This can't be undone.", "confirmDelete": "Delete the playlist “{{name}}”? This can't be undone.",
"addToPlaylist": "Add to playlist" "addToPlaylist": "Add to playlist",
"exportToYoutube": "Export to YouTube",
"pushToYoutube": "Sync to YouTube",
"unsynced": "Unsynced changes",
"pushTitle": "Sync to YouTube",
"pushConfirm": "Sync",
"pushPlanCreate": "Create a new YouTube playlist with {{count}} videos? (~{{units}} quota units; {{left}} left today.)",
"pushPlanUpdate": "Update on YouTube — add {{insert}}, remove {{del}}, reorder {{reorder}}? (~{{units}} quota units; {{left}} left today.)",
"pushDiverged": "{{count}} video(s) currently on YouTube will be removed.",
"pushNoQuota": "Not enough YouTube quota left today ({{left}}) for this sync (~{{units}} needed). Try again tomorrow.",
"pushUpToDate": "Already in sync with YouTube.",
"pushDone": "Synced to YouTube ✓",
"pushPartial": "Synced, but {{count}} item(s) were skipped.",
"pushFailed": "Couldn't sync to YouTube.",
"deleteOnYoutubeTitle": "Delete on YouTube too?",
"deleteOnYoutubeMsg": "Also delete “{{name}}” from your YouTube account? Choose “Here only” to keep it on YouTube.",
"deleteOnYoutubeConfirm": "Delete on YouTube",
"deleteHereOnly": "Here only",
"deleteYtFailed": "Couldn't delete on YouTube; removed here only."
} }

View file

@ -16,5 +16,23 @@
"delete": "Törlés", "delete": "Törlés",
"rename": "Átnevezés", "rename": "Átnevezés",
"confirmDelete": "Törlöd a(z) „{{name}}” listát? Nem vonható vissza.", "confirmDelete": "Törlöd a(z) „{{name}}” listát? Nem vonható vissza.",
"addToPlaylist": "Hozzáadás listához" "addToPlaylist": "Hozzáadás listához",
"exportToYoutube": "Exportálás YouTube-ra",
"pushToYoutube": "Szinkron YouTube-ra",
"unsynced": "Nem szinkronizált változások",
"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.)",
"pushPlanUpdate": "Frissítés a YouTube-on — {{insert}} hozzáadása, {{del}} eltávolítása, {{reorder}} átrendezése? (~{{units}} kvótaegység; ma még {{left}} maradt.)",
"pushDiverged": "{{count}} videó, amely jelenleg a YouTube-on van, el lesz távolítva.",
"pushNoQuota": "Nincs elég YouTube-kvóta mára ({{left}}) ehhez a szinkronhoz (~{{units}} kell). Próbáld újra holnap.",
"pushUpToDate": "Már szinkronban van a YouTube-bal.",
"pushDone": "Szinkronizálva a YouTube-ra ✓",
"pushPartial": "Szinkronizálva, de {{count}} elem kimaradt.",
"pushFailed": "Nem sikerült a YouTube-ra szinkronizálás.",
"deleteOnYoutubeTitle": "Törlöd a YouTube-on is?",
"deleteOnYoutubeMsg": "Törlöd a(z) „{{name}}” listát a YouTube-fiókodból is? A „Csak itt” megtartja a YouTube-on.",
"deleteOnYoutubeConfirm": "Törlés a YouTube-on",
"deleteHereOnly": "Csak itt",
"deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva."
} }

View file

@ -75,6 +75,7 @@ export interface Playlist {
kind: string; // "user" | "watch_later" kind: string; // "user" | "watch_later"
source: string; // "local" | "youtube" source: string; // "local" | "youtube"
yt_playlist_id: string | null; yt_playlist_id: string | null;
dirty: boolean; // linked local playlist has edits not yet pushed to YouTube
item_count: number; item_count: number;
cover_thumbnail: string | null; cover_thumbnail: string | null;
has_video?: boolean; // only set when listed with ?contains=<video_id> has_video?: boolean; // only set when listed with ?contains=<video_id>
@ -86,9 +87,30 @@ export interface PlaylistDetail {
kind: string; kind: string;
source: string; source: string;
yt_playlist_id: string | null; yt_playlist_id: string | null;
dirty: boolean;
items: Video[]; items: Video[];
} }
export interface PushPlan {
action: "create" | "update";
to_insert: number;
to_delete: number;
to_reorder: number;
yt_extra: number; // items on YouTube that the push would remove (divergence)
units_estimate: number;
remaining_today: number;
affordable: boolean;
}
export interface PushResult {
created: number;
inserted: number;
deleted: number;
reordered: number;
failures: string[];
yt_playlist_id: string | null;
}
export interface FeedFilters { export interface FeedFilters {
tags: number[]; tags: number[];
tagMode: "or" | "and"; tagMode: "or" | "and";
@ -323,7 +345,8 @@ export const api = {
req("/api/playlists", { method: "POST", body: JSON.stringify({ name }) }), req("/api/playlists", { method: "POST", body: JSON.stringify({ name }) }),
renamePlaylist: (id: number, name: string): Promise<Playlist> => renamePlaylist: (id: number, name: string): Promise<Playlist> =>
req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }), req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
deletePlaylist: (id: number) => req(`/api/playlists/${id}`, { method: "DELETE" }), deletePlaylist: (id: number, onYoutube = false) =>
req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }),
addToPlaylist: (id: number, videoId: string) => addToPlaylist: (id: number, videoId: string) =>
req(`/api/playlists/${id}/items`, { req(`/api/playlists/${id}/items`, {
method: "POST", method: "POST",
@ -346,6 +369,10 @@ 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" }),
playlistPushPlan: (id: number): Promise<PushPlan> =>
req(`/api/playlists/${id}/push-plan`),
pushPlaylist: (id: number): Promise<PushResult> =>
req(`/api/playlists/${id}/push`, { method: "POST" }),
// --- quota usage --- // --- quota usage ---
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"), myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),