Playlist Sync Internals¶
Sync Strategy (src/playlist/sync.py)¶
sync_playlist() uses a pre-check, then replace-or-reorder, then verify approach:
- Pre-check current state - fetch the playlist and compare against the desired list:
- Exact match → return immediately (no writes).
- Same content, different order → call
_reorder_playlist()to align order in place viaedit_playlist(moveItem=...)and return.
- Replace content - otherwise
_replace_playlist_content()removes all tracks, then adds the desired list. - Verify - re-fetch the playlist and compare against desired state.
- Reorder after replace - if the result has the right content but wrong order (YouTube does not always preserve add order for bulk inserts),
_reorder_playlist()fixes it. - Detect substitutions - if mismatches remain, check whether YouTube replaced videos with equivalent ones.
- Retry - up to
verify_attempts(default 2) if verification fails.
In-place Reorder (_reorder_playlist)¶
Walks the desired list and, for each mismatched position, issues ytm.edit_playlist(playlist_id, moveItem=(setVideoId, successor_setVideoId)) to move the right track into place. Uses setVideoId values from get_playlist() and updates its local mirror after every move. Worst case is O(n) moves; typical case after a same-content replace is a handful.
Precondition Retry¶
YouTube's API returns 400 (Precondition) or 409 (Conflict) when the playlist state changed between read and write. _replace_playlist_content() retries these with increasing delays (3s, 6s) by re-fetching the playlist state before each attempt.
YouTube Substitution Detection¶
_are_same_song() detects when YouTube silently replaces a requested video ID with a different upload of the same song.
Comparison logic:
- Fetch metadata for both video IDs via
get_song() - Normalize titles by stripping artist prefixes (
"artist - ") and suffixes:(audio),(official audio),(official video),(lyric video),(lyrics),[official audio],[audio],- audio,- official audio - Check for exact title match and at least one shared artist → substitution
- Check for substring containment (min length 3) with artist match → substitution
Detected substitutions are:
- Logged as info messages
- Recorded in the history DB as
"substitution"actions - Accepted silently - the adjusted desired list is used for further verification
Retry & Backoff¶
_retry_with_backoff() wraps all YTM API calls with exponential backoff:
| Parameter | Value |
|---|---|
| Max retries | 3 |
| Initial delay | 1s |
| Backoff factor | 2× (1s → 2s → 4s) |
| Retryable errors | 403, Forbidden, Expecting value (invalid JSON) |
| Non-retryable | 400 + Precondition (handled by precondition retry), 409 Conflict |
Invalid Video ID Handling¶
When a bulk add_playlist_items call fails with 400/409:
- Each video ID is validated individually via
get_song() - Invalid IDs are collected into an
InvalidVideoIDsError - The caller (
run()) evicts bad IDs from the search cache via_evict_from_cache() - The full track list is re-resolved with fresh API searches for evicted tracks
- Sync is retried with corrected video IDs
_evict_from_cache() scans all cache entries, matches video IDs against the bad set, and deletes the corresponding (artist, title) entries.
Video ID Validation¶
Video IDs must be exactly 11 characters (alphanumeric + underscore/hyphen). Invalid IDs are filtered out during playlist retrieval by _get_playlist_video_ids().
Weekly Playlist Snapshots (src/playlist/weekly.py)¶
Creates a rolling weekly copy of the main playlist.
Naming Convention¶
"{prefix} week of YYYY-MM-DD" where:
- Prefix is either
WEEKLY_PLAYLIST_PREFIXor auto-derived from the main playlist name (strips trailing(auto)) - Date is the start of the current week
Week Calculation¶
- Timezone-aware via
ZoneInfo(falls back to UTC) - Week start day configurable (
MON-SUN) _start_of_week()computes the most recent occurrence of the configured start day at00:00:00
Pruning¶
_prune_old_weeklies() keeps only the N most recent weekly playlists:
- Lists all library playlists matching
"{prefix} week of "pattern - Parses ISO dates from titles
- Sorts by date descending
- Deletes everything beyond
WEEKLY_KEEP_WEEKS(default 2)
When WEEKLY_KEEP_WEEKS > 0, only the N most recent weekly playlists survive on YTM and in the cache (3 entries when =3, etc.); older weeks are deleted from both. When WEEKLY_KEEP_WEEKS = 0, every weekly playlist is kept forever on YTM and cached. In all cases, PlaylistCache.clear_old_weekly_songs() keeps the song template (video_ids) only for the current week - older surviving weeks retain just their ID with no songs.
Discovery & Manual Management¶
The dashboard Playlists tab manages autogenerated playlists outside the normal sync loop (src/playlist/discover.py):
classify_playlist()tags a playlist asmain,weekly,custom, orunknownby exact-name match first, then fuzzy weekly title (week of YYYY-MM-DD) and description markers (so renamed playlists are still recognised).discover_playlists()scans the live library and returns autogenerated candidates with atrackedflag. A candidate is tracked when its ID or its title matches a cached entry. Title matching is essential becausecreate_playlist()andget_library_playlists()return different ID forms for the same playlist, so a freshly-created playlist's cached ID would never match the library ID and would wrongly appear untracked.- During Discover, cached entries are also healed: when a library playlist's title matches a cache entry whose stored ID differs, the cache is updated to the canonical library ID via
PlaylistCache.track_id(). This keeps every stored ID authoritative so later operations can reference playlists directly by ID. - The tab loads tracked playlists offline from the cache (no YTM call) and only hits the API on Discover. Tracking writes IDs via
PlaylistCache.track_id()without disturbing existing templates.
Template Caching¶
Weekly playlists use the same PlaylistCache template system. The change check compares against the weekly's own cached template (not the main playlist's), so a per-week snapshot is detected as stale on its first run of the week even when the main playlist hasn't changed. When the weekly already exists, sync_playlist() is always invoked - its pre-check makes this a cheap no-op when the playlist is already in order, and a moveItem-based reorder when it isn't (e.g. when YTM's bulk add at creation time didn't preserve order).
upsert_playlist() Helper¶
A convenience function that combines create-or-update with template checking:
- Resolve the existing playlist ID via
get_or_rename_playlist()(name lookup, with role-based rename fallback) - If exists and template changed →
sync_playlist() - If exists and template unchanged → skip
- If not found →
create_playlist_with_items()
Used by tag-based custom playlists to avoid duplicating the create/update logic.
Rename Detection (role markers)¶
A managed playlist is looked up by name on every run. When a user changes the
playlist's name (e.g. edits PLAYLIST_NAME), the name lookup misses and a naive
sync would create a duplicate playlist and orphan the old one. To avoid this,
each cached entry carries a stable role marker that survives renames.
Cache support (src/cache/playlist.py)¶
set_template(name, id, video_ids, *, role=None)stores therolealongside the ID and template. Whenroleis omitted, any existing role on the entry is preserved.find_by_role(role)returns(name, id)only when exactly one entry carries that role and has an ID. It returnsNoneon no match or an ambiguous match (multiple entries share the role), so a rename can never target the wrong playlist.rename(old_name, new_name)migrates the cache key in place, preserving the ID, template (video_ids), and role, and refresheslast_updated.
Resolver (get_or_rename_playlist() in src/ytm/operations.py)¶
- Try the normal
get_existing_playlist_by_name()lookup - return the ID on a hit. - On a miss, if a
roleis supplied, callcache.find_by_role(role). - If a match is found under a different name, verify the old playlist still
exists on YTM via
get_playlist(prev_id, limit=0). If it 404s / returnsUnable to find 'contents', the stale entry is dropped from the cache and the caller creates a fresh playlist. - Otherwise retitle it in place with
edit_playlist(prev_id, title=name)and callcache.rename(prev_name, name), returning the existing ID - no duplicate created.
Roles per playlist type¶
| Playlist | Role | Source |
|---|---|---|
| Main | "main" |
src/workflows/main_sync.py |
| Weekly | "weekly:<ISO-date>" (current week's start date) |
src/playlist/weekly.py |
| Custom / tag | "custom:<sha1[:16]>" hashed from the playlist definition (kind, tags, artists, match mode, discovery seeds) |
src/tags/sync.py::_custom_playlist_role() |
Custom playlists have no stable ID in config/custom_playlists.json (entries are
keyed by name), so their role is derived from the definition's content. Two custom
playlists with identical definitions produce the same role - find_by_role()'s
ambiguity guard then declines to rename either, which is the safe outcome.
Dry-run / preview paths deliberately keep using the read-only
get_existing_playlist_by_name() so a preview never mutates playlist titles.
ID reconciliation (reference by ID)¶
Because create_playlist() returns a different ID form than
get_library_playlists(), a cached entry can hold a stale ID for a playlist that
still exists. IDs are kept canonical in three places:
- At creation time:
create_playlist_with_items()immediately callsget_playlist()(wrapped in the standard_retry_with_backoffexponential backoff, since the API is flaky) to read back the playlist's canonicalidand caches that instead of the compact create-time ID. A transient failure falls back to the create-time ID so a sync is never blocked. - On a library scan: whenever
get_existing_playlist_by_name()falls back to a library scan (cache miss, or the cached ID failed to verify), it heals the cache in place viaPlaylistCache.track_id()- updating the stored ID to the canonical library ID while preserving the entry's template and role. - On Discover: the Playlists tab's healing pass reconciles every cached entry whose title matches a library playlist with a differing ID.
Together these keep every stored ID authoritative so all subsequent edit/sync/ delete operations reference playlists directly by ID rather than re-resolving them by name.