@@ -373,6 +447,27 @@ export default function Playlists() {
>
{t("playlists.playAll")}
+ {editable && canWrite && (
+
+ )}
{editable && (
{t("playlists.delete")}
)}
+ {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"),