diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f45520e..21b27e7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -232,7 +232,7 @@ export default function App() { ) : page === "stats" && meQuery.data!.role === "admin" ? ( ) : page === "playlists" ? ( - + ) : ( 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() { if (syncing) return; @@ -232,12 +289,27 @@ export default function Playlists() { danger: true, }); 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; // 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). const remaining = playlists.filter((p) => p.id !== deletedId); 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.invalidateQueries({ queryKey: ["playlists"] }); } @@ -287,8 +359,10 @@ export default function Playlists() {
{plName(pl)} - {pl.source === "youtube" && ( - + {(pl.source === "youtube" || pl.yt_playlist_id) && ( + )}
@@ -373,6 +447,27 @@ export default function Playlists() { > {t("playlists.playAll")} + {editable && canWrite && ( + + )} {editable && ( )} + {linked && detail.dirty && ( + + {t("playlists.unsynced")} + + )} {mirrored && ( {t("playlists.ytReadonly")} diff --git a/frontend/src/i18n/locales/de/playlists.json b/frontend/src/i18n/locales/de/playlists.json index 4b8728d..0bb51a5 100644 --- a/frontend/src/i18n/locales/de/playlists.json +++ b/frontend/src/i18n/locales/de/playlists.json @@ -16,5 +16,23 @@ "delete": "Löschen", "rename": "Umbenennen", "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." } diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index 85ed5d9..bf798db 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -16,5 +16,23 @@ "delete": "Delete", "rename": "Rename", "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." } diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index 0f30811..16ed77f 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -16,5 +16,23 @@ "delete": "Törlés", "rename": "Átnevezés", "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." } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3472d9b..96c3b42 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -75,6 +75,7 @@ export interface Playlist { kind: string; // "user" | "watch_later" source: string; // "local" | "youtube" yt_playlist_id: string | null; + dirty: boolean; // linked local playlist has edits not yet pushed to YouTube item_count: number; cover_thumbnail: string | null; has_video?: boolean; // only set when listed with ?contains= @@ -86,9 +87,30 @@ export interface PlaylistDetail { kind: string; source: string; yt_playlist_id: string | null; + dirty: boolean; 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 { tags: number[]; tagMode: "or" | "and"; @@ -323,7 +345,8 @@ export const api = { req("/api/playlists", { method: "POST", body: JSON.stringify({ name }) }), renamePlaylist: (id: number, name: string): Promise => 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) => req(`/api/playlists/${id}/items`, { method: "POST", @@ -346,6 +369,10 @@ export const api = { req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }), syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> => req("/api/playlists/sync-youtube", { method: "POST" }), + playlistPushPlan: (id: number): Promise => + req(`/api/playlists/${id}/push-plan`), + pushPlaylist: (id: number): Promise => + req(`/api/playlists/${id}/push`, { method: "POST" }), // --- quota usage --- myUsage: (): Promise => req("/api/quota/my-usage"),