How It Works¶
This tool has two sync flows: the main playlist (covered below) and, optionally, custom playlists (tag- and artist-based). The Docker dashboard exposes both as separate buttons; on the CLI they are python run.py and python run_tags.py. The rest of this page describes the main flow - custom playlists are documented in Custom Playlists.
Overview¶
- Fetch recent scrobbles from Last.fm (up to
MAX_RAW_SCROBBLES, default 2000) - Process tracks:
- If
USE_RECENCY_WEIGHTING=true, score each track using exponential decay (see Recency Weighting) - Otherwise, pick up to
LIMITmost recent unique tracks - If
DEDUPLICATE=true, ensure the final playlist does not include duplicates
- If
-
Resolve each track to a YouTube Music video ID. Tracks listed in the
_blacklistsection ofconfig/search_overrides.jsonare filtered out first. Surviving tracks are resolved using a three-tier priority:- Manual overrides - check the
_overridessection ofconfig/search_overrides.json(user-specified fixes) - Search cache - check
cache/.search_cache.json(previously successful searches, 30-day TTL) - YouTube Music API - only query the API if both of the above miss, then cache the result
This cache-first approach minimizes API calls and ensures consistent results across runs.
- Manual overrides - check the
-
Backfill - if fewer tracks were resolved than
LIMIT, fetch additional scrobbles and repeat resolution (up toBACKFILL_PASSESiterations, default 3) - Score and select the best match (see Search and Matching)
- Create or update YouTube Music playlist(s) with rate-limit-friendly delays (
SLEEP_BETWEEN_SEARCHES) - If
WEEKLY_ENABLED=true, update the weekly playlist snapshot (see Weekly Playlists)
Playlist lifecycle - manual edits are reverted
On the first run, the tool creates a new YouTube Music playlist named by PLAYLIST_NAME. On every subsequent run it finds the existing playlist by name and updates it in place. The tool manages only the playlist(s) it creates - any manual edits you make to those playlists (adding, removing, or reordering tracks) are overwritten on the next run to match the tool's computed state. To keep a track permanently, use an override or blacklist instead of editing the playlist directly. Other playlists in your library are never touched.
Search and Matching¶
For each track, the engine builds multiple search queries (exact match, artist + title, title only) and runs them against the YouTube Music API using up to SEARCH_MAX_WORKERS parallel threads. Results are scored and the best match is selected.
The matching algorithm aims to select the "right" track:
- Prefers official Song results over user-uploaded Videos
- Scores title, artist(s), uploader, and album similarity
- Handles common artist variations and multi-artist collaborations
- Avoids covers, remixes, and live versions unless they are the closest available match
- Can perform authenticated or anonymous search (
USE_ANON_SEARCH), which may affect results - Supports early termination: if a match scores above
EARLY_TERMINATION_SCORE(default 0.9), searching stops immediately to save API calls
If a track cannot be matched reliably, it is cached as "not found" (retried after CACHE_NOTFOUND_TTL_DAYS, default 7) and skipped for the current run.
Deeper dive
For the full normalization, similarity, and query-building details (with code references), see Search Internals.
Scoring breakdown (advanced)
The base match score is calculated as:
| Component | Weight |
|---|---|
| Title similarity | 56% |
| Artist similarity | 32% |
| Uploader match | 7% |
| Album bonus | 5% |
Additional adjustments:
- Hard rejects: "nightcore", "daycore", "sped", "slowed", "8d", "chipmunk", "reverb", "pitch", "bassboosted" in video results (non-Topic channels) → rejected outright
- Soft penalties: "live", "acoustic", "remix", "cover", "karaoke", "instrumental", etc. → -8% per term (capped at 25%). Hard negative terms that slip through (e.g., in song results) get -35% per term (capped at 60%).
- Result type: Songs get +6%, Videos get -3%, "Topic" channels with good uploader match get +2%
- Style mismatch: If you actually want a nightcore/sped-up version but the candidate lacks it, an additional -12% to -18% penalty is applied
- Minimum thresholds: Artist similarity must be ≥ 0.30, title similarity must be ≥ 0.25, or the candidate is discarded
- Acceptance thresholds: Base threshold of 0.66 (0.68 when album data is available), with an additional +0.05 for video results
Recency Weighting¶
When enabled, the tool combines play count and recency to rank tracks:
- Play score: raw play counts normalized to 0-1 (see Play-count normalization for the strategies)
- Recency score:
0.5 ^ (age_hours / half_life_hours)based on the most recent play- A track played exactly one half-life ago scores 0.5
- More recent = higher score (up to 1.0)
- Default half-life: 48 hours (
RECENCY_HALF_LIFE_HOURS=48.0)
- Final score:
play_weight × play_score + (1 - play_weight) × recency_score- Default: 70% play count, 30% recency (
RECENCY_PLAY_WEIGHT=0.7) - This is a linear blend, so
RECENCY_PLAY_WEIGHTis literally the fraction of the score controlled by play count.0.9→ play count dominates;0.3→ recency dominates.
- Default: 70% play count, 30% recency (
- Minimum play gate: tracks with fewer than
RECENCY_MIN_PLAYSscrobbles inside the fetched window are dropped before scoring (default1= no gate). Useful for surfacing only songs you've revisited. Note that the gate counts plays inside the fetched recency window only - raiseMAX_RAW_SCROBBLESif you need a larger window. - Sorting priority: Higher score → more recent play → higher play count
When USE_RECENCY_WEIGHTING=false, the tool simply takes the most recent unique tracks in chronological order (most recent first), up to LIMIT.
Play-count normalization¶
RECENCY_NORMALIZATION controls how raw play counts become the 0-1 play_score:
| Strategy | Formula | Effect |
|---|---|---|
linear (default) |
plays / max_plays |
A single high-play outlier (e.g. one track at 500 plays) flattens everything below it toward zero. |
log |
log1p(plays) / log1p(max_plays) |
Compresses outliers so heavy hitters don't dominate; mid-tier tracks keep meaningful scores. |
rank |
tie-averaged percentile position | Ignores absolute magnitude entirely - only the order of play counts matters. The most balanced spread. |
All three produce scores in [0, 1], so they plug into the same final-score blend without changing the meaning of RECENCY_PLAY_WEIGHT.
How this affects the final playlist: normalization changes the play_score, which feeds the final ranking, which decides which tracks survive the LIMIT cutoff and in what order. So switching strategies can visibly reshape the playlist even though the underlying scrobbles are identical:
linearlets a few heavy-rotation tracks dominate the top of the list and squeezes everything else toward the bottom, so near theLIMITcutoff selection is driven mostly by recency. Best when you want your top plays to lead.logkeeps mid-tier tracks competitive, so more of your moderately-played songs make the cut and the ordering feels less top-heavy.rankflattens the play-count contribution to pure ordering, which gives recency (viaRECENCY_PLAY_WEIGHT) the most influence over borderline tracks near the cutoff - expect the most varied, least outlier-driven playlist.
The effect is largest when your play counts are skewed (a handful of tracks with far more plays than the rest) and when the playlist is near LIMIT so the cutoff actually excludes tracks. With a flat play-count distribution or a playlist well under LIMIT, the three strategies produce nearly identical results.
Velocity (trending) weight¶
RECENCY_VELOCITY_WEIGHT (default 0.0 = off) blends a trending signal into the final score:
velocity_raw = plays / days_between_first_and_last_play (span floored at 1 day)
velocity_score = velocity_raw / max(velocity_raw) (normalized to 0-1)
final = (1 - vw) * base_score + vw * velocity_score
Because the blend is linear, the weight is the maximum fraction of the final score velocity can swing - and doubling it roughly doubles the effect (there is no threshold or diminishing-returns curve):
RECENCY_VELOCITY_WEIGHT |
Behaviour |
|---|---|
0.0 |
Off - no effect (default). |
0.15-0.3 |
Surfaces recent binges without letting one-off plays dominate. Recommended range if you want the signal. |
0.5 |
Half the ranking is pure trending; the playlist becomes noticeably volatile. |
1.0 |
Ranking is entirely plays-per-day; play count and recency are ignored. |
Key nuances:
- It rewards bursts: 5 plays in one day scores
5.0/day, whereas 5 plays spread over 5 days scores1.0/day. - It uses the raw play count, not the session-weighted count, so it is independent of session weighting.
- A single-play track scores
1/1 = 1.0, so at high weights one-off plays can rank surprisingly high - another reason the default is off and low values are recommended. - Because it feeds the same final score, it does more than reorder: a trending track can be pushed above the
LIMITcutoff and pull an older, higher-total-play track out of the playlist entirely. Larger weights change membership more aggressively.
Session weighting¶
RECENCY_SESSION_WEIGHTING (default false) boosts scrobbles that happened during your preferred listening hours before scoring:
- Plays whose local-time hour falls inside the half-open window
RECENCY_SESSION_HOURS([start, end), e.g.9-23) count as 1.5× a play; plays outside count as 1×. Windows may wrap past midnight (e.g.22-4). - The local hour is computed in
RECENCY_SESSION_TIMEZONE(blank inherits the generalTIMEZONE, thenUTC). - The boosted count feeds the play-count normalization above, so it interacts with
RECENCY_NORMALIZATIONbut not velocity (which uses raw counts). - It requires per-scrobble timestamps, so it is a no-op when
USE_LOCAL_LASTFM_DB=true(the local history DB keeps only aggregated counts). - Because the boost feeds the score, it can change which tracks make the playlist near the
LIMITcutoff, not just their order: a song you mostly play during your listening window is nudged up, while equally-played background/late-night tracks can drop below the cutoff. The effect is mild (1.5× per in-window play), so it mostly nudges borderline tracks rather than reshuffling the whole list.
Weekly Playlists¶
When WEEKLY_ENABLED=true, the tool creates/updates weekly playlists named:
{PLAYLIST_NAME} week of YYYY-MM-DD, or{WEEKLY_PLAYLIST_PREFIX} week of YYYY-MM-DDif a prefix is set
If the main playlist name ends with (auto), the prefix strips it automatically (e.g., Last.fm Recents (auto) → Last.fm Recents week of 2026-04-13).
The date corresponds to the start of the week (determined by WEEKLY_WEEK_START and WEEKLY_TIMEZONE). Over time, you build a library of weekly snapshots. Old weeks are automatically pruned based on WEEKLY_KEEP_WEEKS (default: 2). Set to 0 to keep all weekly playlists indefinitely.
Weekly playlists inherit the main playlist's privacy setting unless overridden with WEEKLY_PLAYLIST_PRIVACY (or the deprecated WEEKLY_MAKE_PUBLIC alias).