Merge feature/s4d-youtube-playlist-push: YouTube playlist write-back

This commit is contained in:
npeter83 2026-06-15 21:23:31 +02:00
commit 49d226126c
9 changed files with 543 additions and 13 deletions

View file

@ -6,11 +6,12 @@ from sqlalchemy import func, select, and_
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
from app import quota 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.db import get_db
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
from app.routes.feed import _saved_expr, _serialize 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"]) router = APIRouter(prefix="/api/playlists", tags=["playlists"])
@ -22,6 +23,13 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
return pl 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: def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = None) -> dict:
cover = db.scalar( cover = db.scalar(
select(Video.thumbnail_url) select(Video.thumbnail_url)
@ -36,6 +44,7 @@ def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = Non
"kind": pl.kind, "kind": pl.kind,
"source": pl.source, "source": pl.source,
"yt_playlist_id": pl.yt_playlist_id, "yt_playlist_id": pl.yt_playlist_id,
"dirty": pl.dirty,
"item_count": count, "item_count": count,
"cover_thumbnail": cover, "cover_thumbnail": cover,
} }
@ -205,6 +214,72 @@ def sync_youtube(
return sync_user_playlists(db, user) 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}") @router.get("/{playlist_id}")
def get_playlist( def get_playlist(
playlist_id: int, playlist_id: int,
@ -243,6 +318,7 @@ def get_playlist(
"kind": pl.kind, "kind": pl.kind,
"source": pl.source, "source": pl.source,
"yt_playlist_id": pl.yt_playlist_id, "yt_playlist_id": pl.yt_playlist_id,
"dirty": pl.dirty,
"items": [_serialize(r) for r in rows], "items": [_serialize(r) for r in rows],
} }
@ -270,12 +346,31 @@ def rename_playlist(
@router.delete("/{playlist_id}") @router.delete("/{playlist_id}")
def delete_playlist( def delete_playlist(
playlist_id: int, playlist_id: int,
on_youtube: bool = False,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
pl = _own_playlist(db, user, playlist_id) pl = _own_playlist(db, user, playlist_id)
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 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.delete(pl) # items cascade
db.commit() db.commit()
return {"deleted": playlist_id} return {"deleted": playlist_id}
@ -309,6 +404,7 @@ def add_item(
db.add( db.add(
PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos) PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos)
) )
_mark_dirty(pl)
db.commit() db.commit()
return {"playlist_id": pl.id, "video_id": video_id} return {"playlist_id": pl.id, "video_id": video_id}
@ -328,6 +424,7 @@ def remove_item(
) )
if item is not None: if item is not None:
db.delete(item) db.delete(item)
_mark_dirty(pl)
db.commit() db.commit()
return {"playlist_id": pl.id, "video_id": video_id} return {"playlist_id": pl.id, "video_id": video_id}
@ -353,5 +450,6 @@ def reorder_items(
if it is not None: if it is not None:
it.position = pos it.position = pos
pos += 1 pos += 1
_mark_dirty(pl)
db.commit() db.commit()
return {"playlist_id": pl.id, "count": pos} return {"playlist_id": pl.id, "count": pos}

View file

@ -12,13 +12,16 @@ from sqlalchemy import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import quota 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.models import Channel, OAuthToken, Playlist, PlaylistItem, User, Video
from app.sync.videos import apply_video_details, parse_dt from app.sync.videos import apply_video_details, parse_dt
from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.client import YouTubeClient, YouTubeError
log = logging.getLogger("subfeed.sync") 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: 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 """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() ).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. # Drop mirrors whose YouTube playlist no longer exists.
for ytid, pl in existing.items(): for ytid, pl in existing.items():
if ytid not in yt_ids: 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): for idx, p in enumerate(yt_playlists):
ytid = p.get("id") ytid = p.get("id")
if not ytid: if not ytid or ytid in owned_yt_ids:
continue continue
pl = existing.get(ytid) pl = existing.get(ytid)
if pl is None: if pl is None:
@ -127,6 +143,154 @@ def sync_user_playlists(db: Session, user: User) -> dict:
return {"synced": synced} 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: def sync_all_playlists(db: Session) -> dict:
"""Scheduler entry point: mirror playlists for every user with a read scope + token.""" """Scheduler entry point: mirror playlists for every user with a read scope + token."""
users = ( users = (

View file

@ -213,6 +213,93 @@ class YouTubeClient:
f"DELETE subscriptions -> {resp.status_code}: {resp.text[:300]}" 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]: def get_videos(self, video_ids: list[str]) -> list[dict]:
items: list[dict] = [] items: list[dict] = []
for batch in _chunks(video_ids, 50): for batch in _chunks(video_ids, 50):

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"),