Merge improvement/s4d-editable-mirrors: editable YouTube playlist mirrors

This commit is contained in:
npeter83 2026-06-15 21:40:20 +02:00
commit 87c5c2ab6a
8 changed files with 58 additions and 26 deletions

View file

@ -24,9 +24,10 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
def _mark_dirty(pl: Playlist) -> None: def _mark_dirty(pl: Playlist) -> None:
"""Flag a YouTube-linked local playlist as having unpushed local edits, so the UI can """Flag a YouTube-linked playlist as having unpushed local edits, so the UI can offer
offer a 'Sync to YouTube'. No-op for un-linked or mirrored playlists.""" a 'Sync to YouTube' and the read-sync won't clobber them. Covers both exported local
if pl.source == "local" and pl.yt_playlist_id: playlists and edited YouTube mirrors; no-op for un-linked lists and Watch later."""
if pl.kind != "watch_later" and pl.yt_playlist_id:
pl.dirty = True pl.dirty = True
@ -222,10 +223,6 @@ def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
raise HTTPException( raise HTTPException(
status_code=400, detail="The Watch later list can't be sent to YouTube." 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 return pl
@ -335,7 +332,9 @@ def rename_playlist(
name = (payload.get("name") or "").strip() name = (payload.get("name") or "").strip()
if not name: if not name:
raise HTTPException(status_code=400, detail="name cannot be empty") raise HTTPException(status_code=400, detail="name cannot be empty")
pl.name = name if name != pl.name:
pl.name = name
_mark_dirty(pl)
db.commit() db.commit()
count = db.scalar( count = db.scalar(
select(func.count()).where(PlaylistItem.playlist_id == pl.id) select(func.count()).where(PlaylistItem.playlist_id == pl.id)
@ -354,11 +353,6 @@ def delete_playlist(
if pl.kind == "watch_later": if pl.kind == "watch_later":
raise HTTPException(status_code=400, detail="The Watch later list can't be deleted.") raise HTTPException(status_code=400, detail="The Watch later list can't be deleted.")
if on_youtube: 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: if not pl.yt_playlist_id:
raise HTTPException(status_code=400, detail="This playlist isn't on YouTube.") raise HTTPException(status_code=400, detail="This playlist isn't on YouTube.")
if not has_write_scope(user): if not has_write_scope(user):

View file

@ -108,6 +108,10 @@ def sync_user_playlists(db: Session, user: User) -> dict:
if not ytid or ytid in owned_yt_ids: if not ytid or ytid in owned_yt_ids:
continue continue
pl = existing.get(ytid) pl = existing.get(ytid)
if pl is not None and pl.dirty:
# Local edits not yet pushed: don't clobber them with YouTube's state.
# A successful 'Sync to YouTube' clears dirty and lets the mirror refresh.
continue
if pl is None: if pl is None:
pl = Playlist( pl = Playlist(
user_id=user.id, user_id=user.id,
@ -184,11 +188,14 @@ def plan_push(db: Session, user: User, pl: Playlist) -> dict:
"to_insert": len(desired), "to_insert": len(desired),
"to_delete": 0, "to_delete": 0,
"to_reorder": 0, "to_reorder": 0,
"rename": False,
"yt_extra": 0, "yt_extra": 0,
"units_estimate": ops * WRITE_UNIT_COST, "units_estimate": ops * WRITE_UNIT_COST,
} }
with YouTubeClient(db, user) as yt: with YouTubeClient(db, user) as yt:
current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)) current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id))
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
rename = bool(snippet) and (snippet.get("title") or "") != pl.name
cur_vids = [it["video_id"] for it in current] cur_vids = [it["video_id"] for it in current]
cur_set = set(cur_vids) cur_set = set(cur_vids)
desired_set = set(desired) desired_set = set(desired)
@ -198,12 +205,13 @@ def plan_push(db: Session, user: User, pl: Playlist) -> dict:
# appended; the reorder pass then moves items into the desired order. # 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 after_membership = [v for v in cur_vids if v in desired_set] + to_insert
reorders = _reorder_moves(after_membership, desired) reorders = _reorder_moves(after_membership, desired)
ops = len(to_insert) + len(to_delete) + reorders ops = len(to_insert) + len(to_delete) + reorders + (1 if rename else 0)
return { return {
"action": "update", "action": "update",
"to_insert": len(to_insert), "to_insert": len(to_insert),
"to_delete": len(to_delete), "to_delete": len(to_delete),
"to_reorder": reorders, "to_reorder": reorders,
"rename": rename,
"yt_extra": len(to_delete), "yt_extra": len(to_delete),
"units_estimate": ops * WRITE_UNIT_COST, "units_estimate": ops * WRITE_UNIT_COST,
} }
@ -240,6 +248,12 @@ def push_playlist(db: Session, user: User, pl: Playlist) -> dict:
} }
# Existing link: diff against the live YouTube state. # Existing link: diff against the live YouTube state.
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
if snippet is not None and (snippet.get("title") or "") != pl.name:
try:
yt.update_playlist_title(pl.yt_playlist_id, pl.name)
except YouTubeError:
failures.append("title")
current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)) 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} item_id_by_vid = {it["video_id"]: it["item_id"] for it in current}
cur_vids = [it["video_id"] for it in current] cur_vids = [it["video_id"] for it in current]

View file

@ -247,6 +247,27 @@ class YouTubeClient:
raise YouTubeError(f"{method} {path} -> {resp.status_code}: {resp.text[:300]}") raise YouTubeError(f"{method} {path} -> {resp.status_code}: {resp.text[:300]}")
return resp.json() if resp.content else {} return resp.json() if resp.content else {}
def get_playlist_snippet(self, playlist_id: str) -> dict | None:
"""The snippet (title/description) of one playlist, or None if it no longer
exists. 1 unit (OAuth, so private playlists work)."""
data = self._get(
"playlists",
{"part": "snippet", "id": playlist_id, "maxResults": 1},
cost=1,
allow_key=False,
)
items = data.get("items", [])
return items[0].get("snippet") if items else None
def update_playlist_title(self, playlist_id: str, title: str) -> None:
"""Rename a playlist on YouTube. 50 units."""
self._write(
"PUT",
"playlists",
params={"part": "snippet"},
json={"id": playlist_id, "snippet": {"title": title[:150]}},
)
def create_playlist(self, title: str, description: str = "", privacy: str = "private") -> str: def create_playlist(self, title: str, description: str = "", privacy: str = "private") -> str:
"""Create a new YouTube playlist; returns its id. 50 units.""" """Create a new YouTube playlist; returns its id. 50 units."""
data = self._write( data = self._write(

View file

@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ListPlus, Plus } from "lucide-react"; import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api"; import { api, type Playlist } from "../lib/api";
// A small popover (portaled to body, so the feed grid can't clip it) for toggling a // A small popover (portaled to body, so the feed grid can't clip it) for toggling a
@ -118,9 +118,9 @@ export default function AddToPlaylist({
} }
} }
// YouTube-mirrored playlists are read-only until the write-back phase, so they can't be // All of the user's playlists are add targets, including YouTube-linked ones — adding
// added to here yet. // marks them dirty, and a later "Sync to YouTube" pushes the change back.
const lists = (membership.data ?? []).filter((pl) => pl.source !== "youtube"); const lists = membership.data ?? [];
return ( return (
<> <>
@ -167,9 +167,12 @@ export default function AddToPlaylist({
> >
{pl.has_video && <Check className="w-3 h-3" />} {pl.has_video && <Check className="w-3 h-3" />}
</span> </span>
<span className="truncate"> <span className="truncate flex-1 text-left">
{pl.kind === "watch_later" ? t("playlists.watchLater") : pl.name} {pl.kind === "watch_later" ? t("playlists.watchLater") : pl.name}
</span> </span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube className="w-3 h-3 shrink-0 text-muted" />
)}
</button> </button>
))} ))}
</div> </div>

View file

@ -161,9 +161,10 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
const plName = (p: { kind: string; name: string }) => const plName = (p: { kind: string; name: string }) =>
p.kind === "watch_later" ? t("playlists.watchLater") : p.name; p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
const builtin = detail?.kind === "watch_later"; const builtin = detail?.kind === "watch_later";
// YouTube-mirrored playlists are read-only here until the write-back phase. // YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips
// a dirty mirror so it won't clobber unpushed edits); the chip just marks their origin.
const mirrored = detail?.source === "youtube"; const mirrored = detail?.source === "youtube";
const editable = !builtin && !mirrored; const editable = !builtin;
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);
@ -253,7 +254,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
async function onDragEnd(e: DragEndEvent) { async function onDragEnd(e: DragEndEvent) {
const { active: a, over } = e; const { active: a, over } = e;
if (!over || a.id === over.id || selectedId == null || mirrored) return; if (!over || a.id === over.id || selectedId == null) return;
const oldIndex = items.findIndex((v) => v.id === a.id); const oldIndex = items.findIndex((v) => v.id === a.id);
const newIndex = items.findIndex((v) => v.id === over.id); const newIndex = items.findIndex((v) => v.id === over.id);
if (oldIndex < 0 || newIndex < 0) return; if (oldIndex < 0 || newIndex < 0) return;
@ -513,7 +514,6 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
key={v.id} key={v.id}
video={v} video={v}
index={i} index={i}
readOnly={mirrored}
onPlay={() => setPlayingIndex(i)} onPlay={() => setPlayingIndex(i)}
onRemove={() => removeItem(v.id)} onRemove={() => removeItem(v.id)}
/> />

View file

@ -4,7 +4,7 @@
"syncYoutube": "Von YouTube synchronisieren", "syncYoutube": "Von YouTube synchronisieren",
"syncedToast": "{{count}} Wiedergabelisten von YouTube synchronisiert", "syncedToast": "{{count}} Wiedergabelisten von YouTube synchronisiert",
"syncFailed": "Synchronisierung von YouTube fehlgeschlagen", "syncFailed": "Synchronisierung von YouTube fehlgeschlagen",
"ytReadonly": "Von YouTube synchronisiert — vorerst schreibgeschützt", "ytReadonly": "Von YouTube synchronisiert — Änderungen werden zurücksynchronisiert",
"noneYet": "Noch keine Wiedergabelisten", "noneYet": "Noch keine Wiedergabelisten",
"newPlaylist": "Neue Liste…", "newPlaylist": "Neue Liste…",
"itemCount_one": "{{count}} Video", "itemCount_one": "{{count}} Video",

View file

@ -4,7 +4,7 @@
"syncYoutube": "Sync from YouTube", "syncYoutube": "Sync from YouTube",
"syncedToast": "Synced {{count}} playlists from YouTube", "syncedToast": "Synced {{count}} playlists from YouTube",
"syncFailed": "Couldn't sync from YouTube", "syncFailed": "Couldn't sync from YouTube",
"ytReadonly": "Synced from YouTube — read-only for now", "ytReadonly": "Synced from YouTube — edits sync back",
"noneYet": "No playlists yet", "noneYet": "No playlists yet",
"newPlaylist": "New playlist…", "newPlaylist": "New playlist…",
"itemCount_one": "{{count}} video", "itemCount_one": "{{count}} video",

View file

@ -4,7 +4,7 @@
"syncYoutube": "Szinkron YouTube-ról", "syncYoutube": "Szinkron YouTube-ról",
"syncedToast": "{{count}} lista szinkronizálva a YouTube-ról", "syncedToast": "{{count}} lista szinkronizálva a YouTube-ról",
"syncFailed": "Nem sikerült a YouTube-szinkron", "syncFailed": "Nem sikerült a YouTube-szinkron",
"ytReadonly": "YouTube-ról szinkronizálva — egyelőre csak olvasható", "ytReadonly": "YouTube-ról szinkronizálva — a módosítások visszaszinkronizálhatók",
"noneYet": "Még nincs lejátszási lista", "noneYet": "Még nincs lejátszási lista",
"newPlaylist": "Új lista…", "newPlaylist": "Új lista…",
"itemCount_one": "{{count}} videó", "itemCount_one": "{{count}} videó",