diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index 7cc3bfb..f4ff7d1 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -6,11 +6,12 @@ from sqlalchemy import func, select, and_ from sqlalchemy.orm import Session, aliased from app import quota -from app.auth import current_user, has_read_scope +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 sync_user_playlists +from app.sync.playlists import plan_push, push_playlist, sync_user_playlists +from app.youtube.client import YouTubeClient, YouTubeError router = APIRouter(prefix="/api/playlists", tags=["playlists"]) @@ -22,6 +23,13 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist: return pl +def _mark_dirty(pl: Playlist) -> None: + """Flag a YouTube-linked local playlist as having unpushed local edits, so the UI can + offer a 'Sync to YouTube'. No-op for un-linked or mirrored playlists.""" + if pl.source == "local" and pl.yt_playlist_id: + pl.dirty = True + + def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = None) -> dict: cover = db.scalar( select(Video.thumbnail_url) @@ -36,6 +44,7 @@ def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = Non "kind": pl.kind, "source": pl.source, "yt_playlist_id": pl.yt_playlist_id, + "dirty": pl.dirty, "item_count": count, "cover_thumbnail": cover, } @@ -205,6 +214,72 @@ def sync_youtube( return sync_user_playlists(db, user) +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.""" + pl = _own_playlist(db, user, playlist_id) + if pl.kind == "watch_later": + raise HTTPException( + status_code=400, detail="The Watch later list can't be sent to YouTube." + ) + if pl.source == "youtube": + raise HTTPException( + status_code=400, detail="This playlist already mirrors a YouTube playlist." + ) + return pl + + +@router.get("/{playlist_id}/push-plan") +def push_plan( + playlist_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Dry run: what 'Sync to YouTube' would do + the quota estimate, so the UI can show a + confirm. For a linked playlist this reads the live YouTube state (cheap, 1 unit/page).""" + pl = _pushable(db, user, playlist_id) + if not has_write_scope(user): + raise HTTPException( + status_code=403, detail="Enable YouTube editing in Settings first." + ) + try: + with quota.attribute(user.id, "playlist_push"): + plan = plan_push(db, user, pl) + except YouTubeError: + raise HTTPException(status_code=502, detail="Couldn't read the playlist on YouTube.") + plan["remaining_today"] = quota.remaining_today(db) + plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"] + return plan + + +@router.post("/{playlist_id}/push") +def push( + playlist_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Push a local playlist to YouTube (create it on first push, else reconcile to match + local — local wins). Refuses if the estimated quota exceeds what's left today, so a + big playlist can't strand half-pushed.""" + pl = _pushable(db, user, playlist_id) + if not has_write_scope(user): + raise HTTPException( + status_code=403, detail="Enable YouTube editing in Settings first." + ) + try: + with quota.attribute(user.id, "playlist_push"): + plan = plan_push(db, user, pl) + if plan["units_estimate"] > quota.remaining_today(db): + raise HTTPException( + status_code=429, + detail="Not enough YouTube quota left today for this sync.", + ) + return push_playlist(db, user, pl) + except YouTubeError: + db.rollback() + raise HTTPException(status_code=502, detail="YouTube playlist sync failed.") + + @router.get("/{playlist_id}") def get_playlist( playlist_id: int, @@ -243,6 +318,7 @@ def get_playlist( "kind": pl.kind, "source": pl.source, "yt_playlist_id": pl.yt_playlist_id, + "dirty": pl.dirty, "items": [_serialize(r) for r in rows], } @@ -270,12 +346,31 @@ def rename_playlist( @router.delete("/{playlist_id}") def delete_playlist( playlist_id: int, + on_youtube: bool = False, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: pl = _own_playlist(db, user, playlist_id) if pl.kind == "watch_later": raise HTTPException(status_code=400, detail="The Watch later list can't be deleted.") + if on_youtube: + if pl.source == "youtube": + raise HTTPException( + status_code=400, + detail="This is a mirror of a YouTube playlist; manage it on YouTube.", + ) + if not pl.yt_playlist_id: + raise HTTPException(status_code=400, detail="This playlist isn't on YouTube.") + if not has_write_scope(user): + raise HTTPException( + status_code=403, detail="Enable YouTube editing in Settings first." + ) + try: + with quota.attribute(user.id, "playlist_delete"): + with YouTubeClient(db, user) as yt: + yt.delete_playlist(pl.yt_playlist_id) + except YouTubeError: + raise HTTPException(status_code=502, detail="YouTube delete failed.") db.delete(pl) # items cascade db.commit() return {"deleted": playlist_id} @@ -309,6 +404,7 @@ def add_item( db.add( PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos) ) + _mark_dirty(pl) db.commit() return {"playlist_id": pl.id, "video_id": video_id} @@ -328,6 +424,7 @@ def remove_item( ) if item is not None: db.delete(item) + _mark_dirty(pl) db.commit() return {"playlist_id": pl.id, "video_id": video_id} @@ -353,5 +450,6 @@ def reorder_items( if it is not None: it.position = pos pos += 1 + _mark_dirty(pl) db.commit() return {"playlist_id": pl.id, "count": pos} diff --git a/backend/app/sync/playlists.py b/backend/app/sync/playlists.py index 1b185e7..6d6c832 100644 --- a/backend/app/sync/playlists.py +++ b/backend/app/sync/playlists.py @@ -12,13 +12,16 @@ from sqlalchemy import delete, select from sqlalchemy.orm import Session from app import quota -from app.auth import has_read_scope +from app.auth import has_read_scope, has_write_scope from app.models import Channel, OAuthToken, Playlist, PlaylistItem, User, Video from app.sync.videos import apply_video_details, parse_dt from app.youtube.client import YouTubeClient, YouTubeError log = logging.getLogger("subfeed.sync") +# Cost of a single YouTube write op (playlists/playlistItems insert/update/delete). +WRITE_UNIT_COST = 50 + def _ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None: """Make sure every given video id exists in the catalog, fetching + ingesting the @@ -81,6 +84,19 @@ def sync_user_playlists(db: Session, user: User) -> dict: ) ).scalars() } + # YouTube playlists that we already own as local (exported) playlists must not be + # re-mirrored, or every exported list would also show up as a read-only duplicate. + owned_yt_ids = set( + db.execute( + select(Playlist.yt_playlist_id).where( + Playlist.user_id == user.id, + Playlist.source == "local", + Playlist.yt_playlist_id.is_not(None), + ) + ) + .scalars() + .all() + ) # Drop mirrors whose YouTube playlist no longer exists. for ytid, pl in existing.items(): if ytid not in yt_ids: @@ -89,7 +105,7 @@ def sync_user_playlists(db: Session, user: User) -> dict: for idx, p in enumerate(yt_playlists): ytid = p.get("id") - if not ytid: + if not ytid or ytid in owned_yt_ids: continue pl = existing.get(ytid) if pl is None: @@ -127,6 +143,154 @@ def sync_user_playlists(db: Session, user: User) -> dict: return {"synced": synced} +def _local_video_ids(db: Session, pl: Playlist) -> list[str]: + return list( + db.execute( + select(PlaylistItem.video_id) + .where(PlaylistItem.playlist_id == pl.id) + .order_by(PlaylistItem.position) + ) + .scalars() + .all() + ) + + +def _reorder_moves(current: list[str], desired: list[str]) -> int: + """Count the playlistItems.update moves an insertion-sort would need to turn + `current` order into `desired` order (both already the same set of video ids). + Items already in place are skipped; this matches what apply_push actually executes.""" + model = list(current) + moves = 0 + for i, want in enumerate(desired): + if i < len(model) and model[i] == want: + continue + j = model.index(want, i) + model.pop(j) + model.insert(i, want) + moves += 1 + return moves + + +def plan_push(db: Session, user: User, pl: Playlist) -> dict: + """Compute (without writing) what pushing this local playlist to YouTube would do: + create vs update, how many inserts/deletes/reorders, the quota estimate, and whether + YouTube has diverged (items present there that local will remove). Costs read units + (1/page) for a linked playlist; nothing for a fresh export.""" + desired = _local_video_ids(db, pl) + if not pl.yt_playlist_id: + ops = 1 + len(desired) # create the playlist + one insert per video + return { + "action": "create", + "to_insert": len(desired), + "to_delete": 0, + "to_reorder": 0, + "yt_extra": 0, + "units_estimate": ops * WRITE_UNIT_COST, + } + with YouTubeClient(db, user) as yt: + current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)) + cur_vids = [it["video_id"] for it in current] + cur_set = set(cur_vids) + desired_set = set(desired) + to_insert = [v for v in desired if v not in cur_set] + to_delete = [v for v in cur_vids if v not in desired_set] + # After membership reconciliation, the YT order is current-minus-deletes plus inserts + # appended; the reorder pass then moves items into the desired order. + after_membership = [v for v in cur_vids if v in desired_set] + to_insert + reorders = _reorder_moves(after_membership, desired) + ops = len(to_insert) + len(to_delete) + reorders + return { + "action": "update", + "to_insert": len(to_insert), + "to_delete": len(to_delete), + "to_reorder": reorders, + "yt_extra": len(to_delete), + "units_estimate": ops * WRITE_UNIT_COST, + } + + +def push_playlist(db: Session, user: User, pl: Playlist) -> dict: + """Push a local playlist to YouTube: create it if needed, then reconcile membership + and order so YouTube matches local (local wins). Marks the playlist clean on success. + Caller must have checked the write scope and the quota budget.""" + desired = _local_video_ids(db, pl) + inserted = deleted = reordered = created = 0 + failures: list[str] = [] + with YouTubeClient(db, user) as yt: + if not pl.yt_playlist_id: + pl.yt_playlist_id = yt.create_playlist(pl.name) + db.commit() + created = 1 + # Brand-new playlist: just append every video in order. + for vid in desired: + try: + yt.add_playlist_item(pl.yt_playlist_id, vid) + inserted += 1 + except YouTubeError: + failures.append(vid) + pl.dirty = False + db.commit() + return { + "created": created, + "inserted": inserted, + "deleted": deleted, + "reordered": reordered, + "failures": failures, + "yt_playlist_id": pl.yt_playlist_id, + } + + # Existing link: diff against the live YouTube state. + current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)) + item_id_by_vid = {it["video_id"]: it["item_id"] for it in current} + cur_vids = [it["video_id"] for it in current] + desired_set = set(desired) + # Delete extras (local is authoritative). + for it in current: + if it["video_id"] not in desired_set: + try: + yt.delete_playlist_item(it["item_id"]) + deleted += 1 + except YouTubeError: + failures.append(it["video_id"]) + # Insert missing (append; the reorder pass below fixes positions). + cur_set = set(cur_vids) + for vid in desired: + if vid not in cur_set: + try: + item_id_by_vid[vid] = yt.add_playlist_item(pl.yt_playlist_id, vid) + inserted += 1 + except YouTubeError: + failures.append(vid) + # Reorder to match `desired` via insertion-sort moves over a local model. + model = [v for v in cur_vids if v in desired_set] + [ + v for v in desired if v not in cur_set and v in item_id_by_vid + ] + for i, want in enumerate(desired): + if want not in item_id_by_vid: + continue # failed insert; skip + if i < len(model) and model[i] == want: + continue + try: + yt.move_playlist_item(item_id_by_vid[want], pl.yt_playlist_id, want, i) + reordered += 1 + except YouTubeError: + failures.append(want) + continue + j = model.index(want) + model.pop(j) + model.insert(i, want) + pl.dirty = False + db.commit() + return { + "created": created, + "inserted": inserted, + "deleted": deleted, + "reordered": reordered, + "failures": failures, + "yt_playlist_id": pl.yt_playlist_id, + } + + def sync_all_playlists(db: Session) -> dict: """Scheduler entry point: mirror playlists for every user with a read scope + token.""" users = ( diff --git a/backend/app/youtube/client.py b/backend/app/youtube/client.py index 9d22961..4bea310 100644 --- a/backend/app/youtube/client.py +++ b/backend/app/youtube/client.py @@ -213,6 +213,93 @@ class YouTubeClient: f"DELETE subscriptions -> {resp.status_code}: {resp.text[:300]}" ) + def iter_playlist_items_with_ids(self, playlist_id: str) -> Iterator[dict]: + """Yield {item_id, video_id} for each item of one of the user's playlists, in + playlist order. `item_id` is the playlistItem resource id, needed to delete or + reposition the item via the write API. OAuth (private playlists work).""" + page_token = None + while True: + params = { + "part": "contentDetails", + "playlistId": playlist_id, + "maxResults": 50, + } + if page_token: + params["pageToken"] = page_token + data = self._get("playlistItems", params, cost=1, allow_key=False) + for item in data.get("items", []): + vid = item.get("contentDetails", {}).get("videoId") + if vid: + yield {"item_id": item.get("id"), "video_id": vid} + page_token = data.get("nextPageToken") + if not page_token: + break + + # --- write endpoints (OAuth only, never the API key; each costs 50 quota units) --- + def _write(self, method: str, path: str, *, params: dict, json: dict | None = None) -> dict: + headers = {"Authorization": f"Bearer {self._access_token()}"} + resp = self._http.request( + method, f"{API_BASE}/{path}", params=params, json=json, headers=headers + ) + quota.record_usage(self.db, 50) + if resp.status_code not in (200, 204): + log.warning("YouTube %s %s -> %s: %s", method, path, resp.status_code, resp.text[:200]) + raise YouTubeError(f"{method} {path} -> {resp.status_code}: {resp.text[:300]}") + return resp.json() if resp.content else {} + + def create_playlist(self, title: str, description: str = "", privacy: str = "private") -> str: + """Create a new YouTube playlist; returns its id. 50 units.""" + data = self._write( + "POST", + "playlists", + params={"part": "snippet,status"}, + json={ + "snippet": {"title": title[:150], "description": description[:5000]}, + "status": {"privacyStatus": privacy}, + }, + ) + return data["id"] + + def add_playlist_item(self, playlist_id: str, video_id: str, position: int | None = None) -> str: + """Append (or insert at `position`) a video into a playlist; returns the new + playlistItem id. 50 units.""" + snippet = { + "playlistId": playlist_id, + "resourceId": {"kind": "youtube#video", "videoId": video_id}, + } + if position is not None: + snippet["position"] = position + data = self._write( + "POST", "playlistItems", params={"part": "snippet"}, json={"snippet": snippet} + ) + return data["id"] + + def move_playlist_item( + self, item_id: str, playlist_id: str, video_id: str, position: int + ) -> None: + """Reposition an existing playlistItem to absolute 0-based `position`. 50 units.""" + self._write( + "PUT", + "playlistItems", + params={"part": "snippet"}, + json={ + "id": item_id, + "snippet": { + "playlistId": playlist_id, + "position": position, + "resourceId": {"kind": "youtube#video", "videoId": video_id}, + }, + }, + ) + + def delete_playlist_item(self, item_id: str) -> None: + """Remove a playlistItem from its playlist. 50 units.""" + self._write("DELETE", "playlistItems", params={"id": item_id}) + + def delete_playlist(self, playlist_id: str) -> None: + """Delete a whole playlist on YouTube. 50 units.""" + self._write("DELETE", "playlists", params={"id": playlist_id}) + def get_videos(self, video_ids: list[str]) -> list[dict]: items: list[dict] = [] for batch in _chunks(video_ids, 50): 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"),