Files
siftlode/backend/app/sync/playlists.py
T
peter 79704b0001 fix(youtube): "this playlist no longer exists" only where the user picked that list
playlistNotFound was mapped to "gone" everywhere: exploring a channel without uploads (its
uploads list answers not found) and a list deleted mid bulk pull both said a playlist no
longer exists. The reason is now "gone" only on single-playlist routes; explore reads it
as an empty back-catalogue, and the bulk pull skips that list and syncs the rest.
2026-10-04 04:16:52 +02:00

787 lines
35 KiB
Python

"""YouTube playlist sync, both directions. Read: mirror each user's own YouTube playlists
into local `source='youtube'` playlists (kept fresh by the scheduler). Write-back: two-way sync
local edits with YouTube (`plan_merge` / `merge_playlist`), behind the optional write scope; the
pure 3-way merge they run is `sync/playlist_merge.py`.
YouTube's special Watch Later / History playlists are not exposed by the Data API and are
never synced. Videos in a playlist that aren't in our shared catalog yet are fetched and
ingested (with a stub channel) so the mirror is faithful."""
import hashlib
import json
from collections.abc import Callable, Iterator
from contextlib import AbstractContextManager, contextmanager
from datetime import datetime, timezone
from typing import TypeVar
from sqlalchemy import Engine, create_engine, delete, func, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import NullPool
from app import logs, progress, quota
from app.auth import has_read_scope
from app.models import Channel, Playlist, PlaylistItem, User, Video
from app.sync.playlist_merge import merge_plan
from app.sync.runner import token_users
from app.sync.subscriptions import blank_listing, refuse_blank_listing, vanished
from app.sync.videos import apply_video_details, parse_dt
from app.youtube.client import YouTubeClient, YouTubeError, is_playlist_gone
log = logs.get_logger(__name__)
# Cost of a single YouTube write op (playlists/playlistItems insert/update/delete).
WRITE_UNIT_COST = 50
# The advisory-lock namespaces ("PL" per playlist, "PU" per user), so a key cannot meet another
# feature's lock on the same number (Postgres keeps the two-int keys apart from the one-bigint ones).
_SYNC_LOCK_NS = 0x504C
_USER_SYNC_LOCK_NS = 0x5055
# The claims' own connections, outside the app pool: a claim is held for a whole push (seconds of
# YouTube calls) beside the request's pooled session and quota's, so a burst of pushes drawing a
# third pooled connection each ran the pool dry and stalled every other request for pool_timeout.
# NullPool opens one per claim and closes it after, so a lock can never ride back into a pool either.
_claim_engines: dict[str, Engine] = {}
def _claim_engine(db: Session) -> Engine:
url = db.get_bind().engine.url
key = url.render_as_string(hide_password=False)
eng = _claim_engines.get(key)
if eng is None:
eng = _claim_engines[key] = create_engine(url, poolclass=NullPool, hide_parameters=True)
return eng
@contextmanager
def _advisory_claim(db: Session, ns: int, key: int) -> Iterator[bool]:
"""Try a session-level advisory lock for the block on a connection of its own; yields whether it
was free. It survives the block's commits, and a crashed worker's lock dies with its connection
— no claim column that a crash leaves stuck."""
conn = _claim_engine(db).connect()
held = False
try:
held = bool(conn.scalar(select(func.pg_try_advisory_lock(ns, key))))
conn.commit()
yield held
finally:
try:
if held:
conn.scalar(select(func.pg_advisory_unlock(ns, key)))
conn.commit()
finally:
conn.close()
def sync_claim(db: Session, playlist_id: int) -> AbstractContextManager[bool]:
"""Claim one playlist's YouTube sync (push, re-pull, the scheduled mirror refresh) for the block;
yields whether it was free. A push reads YouTube, then writes both sides for seconds and commits
step by step, so a row lock ends at its first commit and two tabs (or a push and the scheduler)
interleave into a doubled YouTube item or a baseline recorded over the other's writes."""
return _advisory_claim(db, _SYNC_LOCK_NS, playlist_id)
class PlaylistGone(Exception):
"""The list a sync was working on was deleted meanwhile (another tab, the mirror drop). Its
items went with it, so a plan made now reads "every item removed here" — and would delete them
all from the YouTube playlist the reader chose to keep. The route answers 404."""
def lock_playlists(db: Session, *playlist_ids: int) -> set[int]:
"""Row-lock playlists before touching their items, several in id order. Every item write takes
the PLAYLIST first — the route edits, a push, a re-pull, the scheduled mirror refresh and a
delete alike: a writer that locked an item row first and the playlist second (a push's item
replace beside a remove) deadlocks against one that went the other way, and the loser is a
500. The rows are re-read, so `dirty`, the name and the sync baseline are the ones a finished
writer committed. Held to the caller's next commit — so no commit may come between this and
the write it guards. Returns the ids it found: a list deleted meanwhile is not among them."""
rows = db.scalars(
select(Playlist)
.where(Playlist.id.in_(sorted(set(playlist_ids))))
.order_by(Playlist.id)
.with_for_update()
.execution_options(populate_existing=True)
).all() # consumed: rows fetched are what refreshes the loaded objects
return {pl.id for pl in rows}
def _lock_or_gone(db: Session, pl: Playlist) -> None:
"""`lock_playlists` for a sync that goes on to write YouTube or the items: a list deleted since
it was read raises `PlaylistGone` (rolled back) instead of being planned as an empty one."""
pl_id = pl.id # read first: the rollback expires `pl`, and its row is gone
if pl_id not in lock_playlists(db, pl_id):
db.rollback()
raise PlaylistGone(pl_id)
def fingerprint(name: str, ids: list[str]) -> str:
"""A stable checksum of a playlist's name + ordered video ids. Stored as the synced
baseline; the current fingerprint is compared to it to derive `dirty`."""
h = hashlib.sha256()
h.update((name or "").encode())
h.update(b"\n")
h.update(",".join(ids).encode())
return h.hexdigest()
def current_fingerprint(db: Session, pl: Playlist) -> str:
return fingerprint(pl.name, _local_video_ids(db, pl))
def _dump_ids(ids: list[str]) -> str:
"""Serialize the ordered id list stored as the 3-way-merge baseline (`synced_video_ids`)."""
return json.dumps(ids)
def _load_baseline(pl: Playlist) -> tuple[list[str] | None, set[str]]:
"""Parse `synced_video_ids` into (ordered id set, un-ingestable id set). Two on-disk forms:
a plain JSON list (legacy / no un-ingestable ids) or `{"ids": [...], "unavailable": [...]}`.
`unavailable` = YouTube-only videos that exist on YouTube but the Data API won't return
(private/deleted/region-locked), so they never became local rows — tracked so the merge can
skip them instead of forever re-classifying them as a YouTube-add (they'd never converge)."""
if not pl.synced_video_ids:
return None, set()
try:
val = json.loads(pl.synced_video_ids)
except (ValueError, TypeError) as exc:
# No baseline means the three-way merge has no "what we last agreed on" to subtract, so
# it degrades to a union: nothing is lost, but a delete made on either side comes back.
log.warning(
"the sync baseline for a playlist could not be read; merging as a union",
extra=logs.event(
"sync.playlist.baseline_unreadable",
playlist_id=pl.id,
reason=type(exc).__name__,
),
)
return None, set()
if isinstance(val, list):
return [str(v) for v in val], set()
if isinstance(val, dict):
raw_ids = val.get("ids")
ids = [str(v) for v in raw_ids] if isinstance(raw_ids, list) else None
raw_un = val.get("unavailable")
un = {str(v) for v in raw_un} if isinstance(raw_un, list) else set()
return ids, un
return None, set()
def parse_baseline(pl: Playlist) -> list[str] | None:
"""The last-synced ordered id set, or None when no baseline has been recorded yet (a fresh
export, or a link created before S7). None makes the first merge union losslessly."""
return _load_baseline(pl)[0]
def parse_unavailable(pl: Playlist) -> set[str]:
"""The last-synced set of un-ingestable-but-live YouTube ids (see `_load_baseline`)."""
return _load_baseline(pl)[1]
def _set_baseline(
pl: Playlist,
ids: list[str],
unavailable: list[str] | None = None,
*,
name: str | None = None,
) -> None:
"""Record `ids` as both the hash (drives `dirty`, over the local ids only) and the raw ordered
set (drives merge). `unavailable` = un-ingestable-but-live YouTube ids to carry forward so the
next merge skips them; stored only when non-empty (keeps the common case a plain list). `name`:
the title YouTube now has, when that may differ from the list's own (see the push)."""
pl.synced_fingerprint = fingerprint(pl.name if name is None else name, ids)
if unavailable:
pl.synced_video_ids = json.dumps({"ids": ids, "unavailable": list(unavailable)})
else:
pl.synced_video_ids = _dump_ids(ids)
pl.dirty = False
def _set_baseline_under_lock(
db: Session,
pl: Playlist,
ids: list[str],
unavailable: list[str] | None = None,
*,
name: str,
) -> None:
"""A push's closing baseline: `ids` and `name` are what YouTube now holds. The push spent
seconds on YouTube writes since it replaced the local items, so an edit may have landed
meanwhile — lock the list and derive `dirty` from what is there, or the edit would read as
synced and never reach YouTube. The name too: the lock re-reads a rename made meanwhile, and a
baseline taken over THAT name matched it, so the list read clean with YouTube's title still the
old one (E28 review round 4)."""
_lock_or_gone(db, pl)
_set_baseline(pl, ids, unavailable, name=name)
recompute_dirty(db, pl)
def recompute_dirty(db: Session, pl: Playlist) -> None:
"""Set `dirty` by comparing the current state to the synced baseline. Unlinked lists
(and Watch later) are never dirty; a missing baseline counts as dirty (unknown YT state)."""
if not pl.yt_playlist_id:
pl.dirty = False
return
# The session has autoflush off, so the caller's pending item/order/name edits aren't
# visible to current_fingerprint's query yet — flush first or dirty would lag one edit.
db.flush()
pl.dirty = pl.synced_fingerprint != current_fingerprint(db, pl)
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
missing ones (and a stub channel for any unknown channel). Deleted/private videos that
the API won't return are simply left out."""
if not video_ids:
return
have = set(db.execute(select(Video.id).where(Video.id.in_(video_ids))).scalars().all())
missing = [v for v in dict.fromkeys(video_ids) if v not in have]
if not missing:
return
items = yt.get_videos(missing)
chan_ids = {
it.get("snippet", {}).get("channelId")
for it in items
if it.get("snippet", {}).get("channelId")
}
existing_ch = set(
db.execute(select(Channel.id).where(Channel.id.in_(chan_ids))).scalars().all()
)
now = datetime.now(timezone.utc)
for it in items:
sn = it.get("snippet", {})
cid = sn.get("channelId")
if cid and cid not in existing_ch:
db.add(Channel(id=cid, title=sn.get("channelTitle")))
existing_ch.add(cid)
db.flush()
seen: set[str] = set()
for it in items:
vid = it.get("id")
sn = it.get("snippet", {})
cid = sn.get("channelId")
if not vid or not cid or vid in seen:
continue
seen.add(vid)
# published_at is NOT NULL (0068); apply_video_details re-fills a missing date, but seed a now()
# fallback here too so the constructor never carries an explicit NULL.
v = Video(id=vid, channel_id=cid, published_at=parse_dt(sn.get("publishedAt")) or now)
apply_video_details(v, it)
v.enriched_at = now
db.add(v)
db.commit()
def _replace_items_from_youtube(db: Session, pl: Playlist, ids: list[str]) -> list[str]:
"""Mirror a YouTube playlist's ordered video ids into `pl`'s local items (authoritative
replace): keep only those present in the catalog in their YT order (de-duplicated), swap the
items out, and reset the synced fingerprint + dirty flag. The caller has ingested `ids`
(`_ensure_videos`, which commits) and THEN taken `lock_playlists`, set `pl.name`, and commits
after: an ingest in here re-fetched every id YouTube will not return (a private or deleted
video) and its commit let go of the lock before the replace. Returns the final ordered ids."""
present = (
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all()) if ids else set()
)
ordered: list[str] = []
seen: set[str] = set()
for v in ids:
if v in present and v not in seen:
seen.add(v)
ordered.append(v)
_set_local_items(db, pl, ordered)
# After a pull/reset local == YouTube, so this ordered set IS the new merge baseline.
_set_baseline(pl, ordered)
return ordered
def _set_local_items(db: Session, pl: Playlist, ordered: list[str]) -> None:
"""Authoritatively replace `pl`'s local items with `ordered` (positions 0..n). The caller
is responsible for having ensured every id exists in the catalog."""
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
for pos, vid in enumerate(ordered):
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
def sync_user_playlists(db: Session, user: User) -> dict:
"""Mirror the user's YouTube playlists into local source='youtube' playlists. Leaves
the user's local playlists and the built-in Watch later untouched."""
if not has_read_scope(user):
return {"synced": 0, "reason": "no read scope"}
with user_sync_claim(db, user.id) as free:
if not free:
return {"synced": 0, "reason": "busy"}
return pull_user_playlists(db, user)
def user_sync_claim(db: Session, user_id: int) -> AbstractContextManager[bool]:
"""Claim a user's bulk mirror pull for the block; yields whether it was free. Two runs at once
(the scheduler beside a manual "sync now", two tabs) both saw a YouTube list unmirrored and
each created its mirror — a duplicate row."""
return _advisory_claim(db, _USER_SYNC_LOCK_NS, user_id)
def _mirror_listing(yt: YouTubeClient, ytid: str, count: object, pl_id: int) -> list[str] | None:
"""A mirror's video ids from YouTube, or None when there is nothing to believe: the list was
deleted between the pull's listing and this read (the next pull drops the mirror as vanished —
not a reason to fail every other list, E32 review round 2), or YouTube listed no items for a
list it counts as non-empty (`blank_listing`)."""
try:
ids = list(yt.iter_my_playlist_video_ids(ytid))
except YouTubeError as exc:
if not is_playlist_gone(exc):
raise
log.info("YouTube playlist of %s vanished mid-pull; skipped", pl_id)
return None
if blank_listing(ids, count):
log.warning("YouTube listed no items for non-empty playlist %s; kept", pl_id)
return None
return ids
def pull_user_playlists(db: Session, user: User) -> dict:
"""The mirror pull itself; the caller holds `user_sync_claim`."""
synced = 0
with YouTubeClient(db, user) as yt:
yt_playlists = list(yt.iter_my_playlists())
yt_ids = {p["id"] for p in yt_playlists if p.get("id")}
existing = {
pl.yt_playlist_id: pl
for pl in db.execute(
select(Playlist).where(Playlist.user_id == user.id, Playlist.source == "youtube")
).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()
)
# Drop vanished mirrors (none on an empty answer) — locked first, as every item write.
gone = vanished(existing, yt_ids)
if gone:
lock_playlists(db, *(pl.id for pl in gone))
for pl in gone:
db.delete(pl)
# Committed now: a run where no list below commits (all dirty, or all claimed) would
# otherwise drop the deletions with the session.
db.commit()
for idx, p in enumerate(yt_playlists):
ytid = p.get("id")
if not ytid or ytid in owned_yt_ids:
continue
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:
pl = Playlist(
user_id=user.id,
name=p.get("title") or "Untitled",
kind="user",
source="youtube",
yt_playlist_id=ytid,
position=1000 + idx, # sort mirrored lists after local ones
)
db.add(pl)
db.flush()
with sync_claim(db, pl.id) as free:
if not free:
# The user is pushing or re-pulling this very list: it writes the same rows.
# The next scheduled run refreshes it.
continue
ids = _mirror_listing(yt, ytid, p.get("item_count"), pl.id)
if ids is None:
db.rollback() # a mirror this run just created is not kept empty either
continue
# Ingest first — it commits — so the row lock below is held from the dirty check
# to the commit that replaces the items.
_ensure_videos(db, yt, ids)
# `dirty` read above is a snapshot from before the YouTube calls (sessions keep
# loaded rows past a commit): a remove or reorder made meanwhile must not be
# replaced by YouTube's order. Item writes lock the playlist row, so this waits for
# one in flight and sees what it committed.
lock_playlists(db, pl.id)
if pl.dirty:
db.rollback()
continue
pl.name = p.get("title") or pl.name
# Mirror is authoritative: replace the items to match YouTube's order.
_replace_items_from_youtube(db, pl, ids)
db.commit()
synced += 1
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 plan_merge(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
"""Compute (without writing) what a two-way sync of this playlist would do: create vs
update, how many items each side gains/loses, reorders, and the quota estimate (YouTube
writes only — local edits are free). Reads live YouTube state (1 unit/page) for a linked
playlist; nothing for a fresh export. `live` = a pre-fetched (current_items, snippet) tuple
so the caller can share one read with merge_playlist."""
local = _local_video_ids(db, pl)
if not pl.yt_playlist_id:
ops = 1 + len(local) # create the playlist + one insert per video
return {
"action": "create",
"add_yt": len(local),
"add_local": 0,
"remove_yt": 0,
"remove_local": 0,
"to_reorder": 0,
"rename": False,
"units_estimate": ops * WRITE_UNIT_COST,
}
if live is not None:
current, snippet = live
else:
with YouTubeClient(db, user) as yt:
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
yt_ids = [it["video_id"] for it in current]
refuse_blank_listing(yt_ids, (snippet or {}).get("item_count"))
mp = merge_plan(parse_baseline(pl), local, yt_ids, parse_unavailable(pl))
ops = len(mp.add_to_yt) + len(mp.remove_from_yt) + mp.reorders + (1 if rename else 0)
return {
"action": "update",
"add_yt": len(mp.add_to_yt),
"add_local": len(mp.add_to_local),
"remove_yt": len(mp.remove_from_yt),
"remove_local": len(mp.remove_from_local),
"to_reorder": mp.reorders,
"rename": rename,
"units_estimate": ops * WRITE_UNIT_COST,
}
# After this many consecutive write failures we stop calling YouTube for the rest of the push.
# The failure modes that matter here are all account-wide, not per-video: quota exhausted, the
# write scope revoked, the token dead. Every remaining write then fails identically — but each
# one still SPENDS 50 units, so a long playlist burns through the whole (already dead) budget,
# and tomorrow's too, to produce a failure list we knew after the fifth item.
_MAX_CONSECUTIVE_FAILURES = 5
class _Failed:
"""Type of the `FAILED` sentinel, so `run()` can stay generic in the op's return type: a call
site gets back `str | _Failed` (not a bare `object`) and keeps its item id typed."""
#: Returned by `_Breaker.run` when the write did not happen (refused or failed).
FAILED = _Failed()
T = TypeVar("T")
class _Breaker:
"""Consecutive-failure circuit breaker for a single push run. Any success re-arms it.
Drive it with `run()` — it owns the whole open-check / call / record-outcome sequence, so a
write site can't accidentally implement two thirds of the protocol (forgetting to record a
failure would silently disable the breaker for that operation)."""
def __init__(
self, limit: int = _MAX_CONSECUTIVE_FAILURES, playlist_id: int | None = None
) -> None:
self.limit = limit
self.playlist_id = playlist_id # for the failure line only
self.streak = 0
self.tripped = False
def open(self) -> bool:
"""True while writes may still be attempted."""
return not self.tripped
def hit(self, ok: bool) -> None:
if ok:
self.streak = 0
return
self.streak += 1
if self.streak >= self.limit and not self.tripped:
self.tripped = True
log.warning(
"playlist push: %d consecutive YouTube write failures — stopping this run "
"(quota, scope or token); the remaining items are reported as failures",
self.limit,
)
def run(
self, op: Callable[[], T], on_fail: Callable[[], None], action: str = "write"
) -> T | _Failed:
"""Attempt one YouTube write. Returns the op's result, or `FAILED` when the breaker is
already tripped (op not called) or the op raised YouTubeError. `on_fail` records the item
in the caller's failure list either way."""
if self.tripped:
on_fail()
return FAILED
try:
out = op()
except YouTubeError as exc:
# The client logged the call, but not which playlist it was for or what kind of write
# — and the caller's failure list only ever reaches the user as a count. Not the video:
# what is in someone's playlist is a row of their history, the same as a download.
log.debug(
"a playlist write to YouTube failed",
extra=logs.event(
"sync.playlist.item_push_failed",
playlist_id=self.playlist_id,
action=action,
reason=exc.reason,
),
)
on_fail()
self.hit(False)
return FAILED
self.hit(True)
return out
def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
"""Two-way sync a local playlist with YouTube: create it on first push, else 3-way MERGE
both sides so an item added (or deleted) on either side survives — no side wins wholesale.
Local edits are applied directly; YouTube edits go through the failure breaker. On full
success the merged set becomes the new baseline and the playlist is marked clean; on any
YouTube failure the baseline is left untouched so the next sync retries and converges.
Caller must have checked the write scope and the quota budget. `live` = a pre-fetched
(current_items, snippet) tuple (from plan_merge's read) to avoid a second page-through."""
local = _local_video_ids(db, pl)
inserted = deleted = reordered = created = pulled = removed_local = 0
failures: list[str] = []
breaker = _Breaker(playlist_id=pl.id)
with YouTubeClient(db, user) as yt:
if not pl.yt_playlist_id:
pushed_name = pl.name
pl.yt_playlist_id = yt.create_playlist(pushed_name)
db.commit()
created = 1
# Brand-new playlist: just append every local video in order.
for vid in local:
added = breaker.run(
lambda v=vid: yt.add_playlist_item(pl.yt_playlist_id, v),
lambda v=vid: failures.append(v),
"add",
)
if not isinstance(added, _Failed):
inserted += 1
if not failures:
_set_baseline_under_lock(db, pl, local, name=pushed_name)
db.commit()
return {
"created": created,
"inserted": inserted,
"deleted": deleted,
"reordered": reordered,
"pulled": pulled,
"removed_local": removed_local,
"failures": failures,
"aborted": breaker.tripped,
"yt_playlist_id": pl.yt_playlist_id,
}
# Existing link: 3-way merge against the live YouTube state (reuse plan_merge's read).
if live is not None:
current, snippet = live
else:
snippet = yt.get_playlist_snippet(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}
yt_ids = [it["video_id"] for it in current]
# A merge would read "YouTube lists none of them" as "YouTube deleted them all".
refuse_blank_listing(yt_ids, (snippet or {}).get("item_count"))
# Re-attempt ingestion of ids previously marked un-ingestable that are still on YouTube: a
# video that has since become available (e.g. private→public) should re-enter the merge, not
# stay skipped forever. Those that still won't ingest remain filtered (`effective_unavailable`).
prev_unavailable = parse_unavailable(pl)
still_listed = [v for v in yt_ids if v in prev_unavailable]
if still_listed:
_ensure_videos(db, yt, still_listed)
healed = set(
db.execute(select(Video.id).where(Video.id.in_(still_listed))).scalars().all()
)
effective_unavailable = prev_unavailable - healed
else:
effective_unavailable = prev_unavailable
mp = merge_plan(parse_baseline(pl), local, yt_ids, effective_unavailable)
# --- Siftlode side (free, direct): pull YouTube-adds in, drop YouTube-deletes, land the
# merged order authoritatively. Do this first so local reflects the merge even if a later
# YouTube write trips the breaker.
_ensure_videos(db, yt, mp.add_to_local)
# `local` was read before the YouTube calls above: an add or remove committed meanwhile
# (another tab) would be undone by the replace below. Lock the list — the ingest's commit is
# behind us — and plan again from what is there now. Nothing new needs ingesting: a video
# the re-read adds is already a local row. A list deleted meanwhile stops here, before a plan
# over its now-empty items removes them all from YouTube.
_lock_or_gone(db, pl)
local = _local_video_ids(db, pl)
mp = merge_plan(parse_baseline(pl), local, yt_ids, effective_unavailable)
# Only the ids that actually exist in the catalog can become local rows (a private/deleted
# YouTube video won't ingest); keep the merged order, dropping any that didn't materialize.
present = (
set(db.execute(select(Video.id).where(Video.id.in_(mp.final))).scalars().all())
if mp.final
else set()
)
final_local = [v for v in mp.final if v in present]
pulled = len([v for v in mp.add_to_local if v in present])
removed_local = len(mp.remove_from_local)
_set_local_items(db, pl, final_local)
db.commit()
# --- YouTube side (metered, breaker-guarded). The name as of the lock above: the closing
# baseline records THIS one, whatever a rename made during the writes below says.
pushed_name = pl.name
if snippet is not None and (snippet.get("title") or "") != pushed_name:
try:
yt.update_playlist_title(pl.yt_playlist_id, pushed_name)
except YouTubeError as exc:
# All the caller passes on is the word "title", which says the rename did not
# happen but not that YouTube refused it, or why.
log.debug(
"a playlist rename did not reach YouTube",
extra=logs.event(
"sync.playlist.title_push_failed",
playlist_id=pl.id,
reason=exc.reason,
),
)
failures.append("title")
# Propagate genuine local deletes.
for vid in mp.remove_from_yt:
item_id = item_id_by_vid.get(vid)
if item_id is None:
continue
dropped = breaker.run(
lambda i=item_id: yt.delete_playlist_item(i),
lambda v=vid: failures.append(v),
"delete",
)
if not isinstance(dropped, _Failed):
deleted += 1
item_id_by_vid.pop(vid, None)
# Insert local-adds (append; the reorder pass below fixes positions).
for vid in mp.add_to_yt:
item_id = breaker.run(
lambda v=vid: yt.add_playlist_item(pl.yt_playlist_id, v),
lambda v=vid: failures.append(v),
"add",
)
if not isinstance(item_id, _Failed):
item_id_by_vid[vid] = item_id
inserted += 1
# Reorder YouTube to match `final` via insertion-sort moves over a local model.
final_set = set(mp.final)
model = [v for v in yt_ids if v in final_set and v in item_id_by_vid] + [
v for v in mp.add_to_yt if v in item_id_by_vid
]
for i, want in enumerate(mp.final):
if want not in item_id_by_vid:
continue # deleted-from-YT or failed insert; skip
if i < len(model) and model[i] == want:
continue
moved = breaker.run(
lambda w=want, pos=i: yt.move_playlist_item(
item_id_by_vid[w], pl.yt_playlist_id, w, pos
),
lambda w=want: failures.append(w),
"move",
)
if isinstance(moved, _Failed):
continue
reordered += 1
j = model.index(want)
model.pop(j)
model.insert(i, want)
if not failures:
# Carry forward un-ingestable-but-live YouTube ids so the next merge skips them
# (see merge_plan): this run's failed pulls, plus prior ones still on YouTube, minus
# any that have since left YouTube. Recomputed each success → self-healing.
yt_id_set = set(yt_ids)
unavailable = sorted(
(effective_unavailable | {v for v in mp.add_to_local if v not in present})
& yt_id_set
)
_set_baseline_under_lock(db, pl, final_local, unavailable, name=pushed_name)
db.commit()
return {
"created": created,
"inserted": inserted,
"deleted": deleted,
"reordered": reordered,
"pulled": pulled,
"removed_local": removed_local,
"failures": failures,
"aborted": breaker.tripped,
"yt_playlist_id": pl.yt_playlist_id,
}
def repull_playlist(db: Session, user: User, pl: Playlist) -> dict:
"""Force-refresh ONE YouTube-linked playlist from YouTube, discarding local edits and
clearing dirty. This is the per-playlist 'reset to YouTube' — unlike the bulk read-sync
it deliberately does NOT skip a dirty playlist (overwriting local changes is the point).
Works for both mirrors (source='youtube') and exported local playlists (yt_playlist_id)."""
if not pl.yt_playlist_id:
raise YouTubeError("Playlist is not linked to YouTube")
with YouTubeClient(db, user) as yt:
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
if snippet is None:
raise YouTubeError(
"Playlist no longer exists on YouTube", reason="playlistNotFound", path="playlists"
)
ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id))
refuse_blank_listing(ids, snippet.get("item_count"))
_ensure_videos(db, yt, ids)
_lock_or_gone(db, pl)
pl.name = snippet.get("title") or pl.name
ordered = _replace_items_from_youtube(db, pl, ids)
db.commit()
return {"items": len(ordered)}
def sync_all_playlists(db: Session) -> dict:
"""Scheduler entry point: mirror playlists for every user with a read scope + token."""
users = token_users(db)
total = 0
for i, user in enumerate(users, 1):
if not has_read_scope(user):
progress.report(i, len(users), "playlist_sync")
continue
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
total += sync_user_playlists(db, user).get("synced", 0)
except YouTubeError as exc:
db.rollback()
log.warning(
"Playlist sync failed for user %s",
user.id,
extra=logs.event("sync.playlists.failed", user_id=user.id, reason=exc.reason),
)
except Exception:
db.rollback()
log.exception(
"Playlist sync crashed for user %s",
user.id,
extra=logs.event("sync.playlists.crashed", user_id=user.id),
)
progress.report(i, len(users), "playlist_sync")
return {"users": len(users), "playlists_synced": total}