with_retries caught bare Exception with a type whitelist, masking real bugs as network errors under retry/backoff - narrowed back to NetworkError. fetch_api_languages was writing full lyrics text for every language into lyrics_text.json via its track_supplement side channel, breaking the documented RU-only invariant and letting analyze feed foreign-language lyrics through the Russian-only prompt - now gated on normalize_api_language. dedupe_playlists logged the found duplicate count as "removed" even when a mid-loop network/API error cut deletion short. THEME_PATTERN's relaxed regex allowed 2-char themes, contradicting its own test; current_themes was hoisted out of the analyze loop so later prompts never saw themes found earlier in the same run. Both reverted to intended behavior, docs updated to match.
17 KiB
Architecture
A Python console utility that reads Yandex Music liked tracks and sorts them into playlists named
Genre — RU / Genre — INT, using the unofficial yandex-music library.
Structure
sort_ym/
config.py — reads config.toml (throttle, small_group_min, top_artists/top_albums,
cache/token paths)
auth.py — OAuth device-flow, token storage in .token
ymclient.py — Client construction, batching lists, retrying network errors
fetch.py — likes -> full Track -> cache/tracks.json (serialize_track); track artists ->
client.artists() -> cache/artist_genres.json (both fetched incrementally);
unique_artist_ids — unique artist ids from the track cache
source.py — ephemeral source: parses a link to an arbitrary playlist + loads its tracks/
artists into memory, never touches disk (see "--source" below)
genres.py — the real Yandex genre tree; classify_track — resolves track genre and artist
genres into a single sub-genre (root_id); ROOT_BUCKET — sub-genre -> 11 top-level buckets
language.py — hybrid language detection (lyrics API / genre hint / alphabet)
lyrics.py — text lyrics of RU-tracks: track_supplement().lyrics.full_lyrics ->
cache/lyrics_text.json (incrementally, atomic-write)
analyze.py — lyric analysis via local Ollama (/api/chat, think + JSON Schema) ->
cache/lyrics_analysis.json (written after each track)
classify.py — (sub-genre | top-level bucket, language) -> playlist name; with_label — source
label suffix for apply --source --label
report.py — two-pass report row building, writes out/report.csv, summaries
apply.py — playlist creation + idempotent track insertion
digest.py — aggregated library summary (top artists, genres, decades, top albums)
-> out/digest.md, fully offline without --source; Wording/playlist_wording —
digest text for likes vs an arbitrary playlist
cli.py — auth / fetch / report / apply / dedupe / digest commands; --source/--label
on report/apply/digest (see "Key decisions")
tests/
test_genres.py — classify_track, dominant, bucket_for, genre tree
test_language.py — language detection
test_lyrics.py — lyrics text download, cache structure, resumability
test_analyze.py — local Ollama integration, JSON Schema parsing, cache atomicity
test_report.py — two-pass collapsing of small sub-genre groups
test_fetch.py, test_apply.py — track/artist cache, apply idempotency
test_digest.py — artist/album aggregation, long-tail collapsing, digest size bounds
test_source.py — playlist URL parsing, matching client.tracks() response by id, handling
private/inaccessible playlists
test_cli.py, test_config.py — --source/--label flag validation before any network access;
config.toml reading including [ollama] block
cache/ — API cache (in .gitignore)
out/ — reports (in .gitignore)
Data flow
auth → .token
fetch → users_likes_tracks() → client.tracks(batch) → cache/tracks.json (+ artist_ids)
→ client.artists(batch) over unique artist_ids → cache/artist_genres.json
report → genres.load_or_fetch_catalog() → cache/genre_catalog.json
→ language.fetch_api_languages() → cache/lyrics_lang.json
→ build_rows(): pass 1 — classify_track() per track (genre_raw + artist genres)
pass 2 — groups < small_group_min collapse into the top-level bucket
→ out/report.csv (dry-run, account unchanged)
apply --yes → users_playlists_create / users_playlists_insert_track
digest → genres.load_catalog() + language.load_lang_cache() (cache only, no network)
→ build_rows() → out/digest.md
lyrics → ru_lyric_track_ids() → client.track_supplement() → cache/lyrics_text.json
analyze → cache/lyrics_text.json → Ollama /api/chat → cache/lyrics_analysis.json
digest → + cache/lyrics_analysis.json → out/digest.md (aggregate block) + out/lyrics_digest.md
report/apply/digest --source <url> → source.parse_playlist_url() → (user_id, kind)
→ client.users_playlists(kind, user_id=...) → TrackShort[] → client.tracks(batch)
→ an in-memory dict (fetch.serialize_track shape, never written to disk)
→ client.artists(batch) → genres.load_or_fetch_catalog() (shared cache/genre_catalog.json)
+ language.load_lang_cache() (read-only) → build_rows()
→ out/report_source.csv | out/digest_source.md | apply → playlists on your own account
(shared "Genre — RU/INT" or "... (label)" with --label)
Key decisions
-
Track genre is only available through the album (
Track.albums[0].genre) — the track itself has no genre field, so it's one string per track, often generic (rock,alternative). Bucketing is done through the realclient.genres()tree (parent → sub-genres), not a guessed slug list — theROOT_BUCKETtable ingenres.pyrelies on confirmed ids, not assumptions. Genres without a match fall into theotherbucket and are printed inreport's output for manual table extension. -
Sub-genre classification (
genres.classify_track): track genre is one string per album and often generic. An artist (client.artists(), a batch endpoint) can have several tags, more precise than a single album genre.classify_track(genre_raw, artist_genre_lists, catalog)resolves both signals into a single sub-genre (root_id, e.g.punk/indie/allrock) at the track level, not the artist level — so the same artist with tracks of different character ends up in different playlists:- Signals agree (track genre is among the artist's genres) → use it — granular per track.
- Track has no genre → use the most frequent (
dominant) genre among the track's artists. - Artist(s) have no tags → use the track's genre.
- Conflict: the album tag is generic/"umbrella" (
GENERIC_SLUGS:rock,alternative,pop, ... — Yandex's low-confidence default) → the artist's genre wins (dominantover the combined tags of all the track's artists, tie-break by order of appearance). The album tag is precise/specific (numetal,dnb, ...) → it wins — likely a genuine genre experiment by the artist rather than noise worth correcting.
The result (
fine) is collapsed in a two-pass process inreport.build_rows: first the sizes of(fine, lang)groups are counted, then groups smaller thanconfig.small_group_minmove to the parent top-level bucket viagenres.coarse_of(fine)(a thin wrapper overROOT_BUCKET). The pass is single (not iterative) —coarse_ofis deterministic fromfine, so there's no re-counting of sizes or looping. -
Language is determined hybrid, in priority order:
client.track_supplement(track_id).lyrics.text_language— the only field in the library with the actual lyrics language. It's only present in the deprecatedtrack_supplementmethod, not in the newertracks_lyrics()— that one returns the text but not the language. Used deliberately despite the library's own deprecation notice.- A genre-slug hint (
rusrap,shanson,bard, ...). - An alphabet heuristic on the title/artist (Cyrillic vs Latin,
unicodedata). - Otherwise —
UNKNOWN→ a separate "Undetermined" playlist.
-
Lyric analysis (
lyrics.py,analyze.py): by default only Russian-language tracks are analyzed — the prompt and the narrative fields (summary/resonance/key_line) are written in Russian and were validated against a Russian-lyrics pilot run; quality on other languages is unverified.sort_ym lyrics --all-languageslifts the filter and fetches/analyzes every track with available lyrics regardless of detected language (lyrics.ru_lyric_track_ids(..., all_languages=True)). Text fetching and analysis are two separate commands on purpose: the fetch is network-bound against the deprecatedtrack_supplement, the analysis is a multi-hour local GPU job, and a prompt or model change must be re-runnable without touching Yandex again.analyzeanddigestconstruct noClientat all.The Ollama call (
/api/chat) combinesthink: truewith a full JSON Schema informat: reasoning goes to a separatemessage.thinkingfield (never cached) while only the final answer is grammar-constrained, so the model reasons freely and the result still parses.num_predict: -1— a token cap would truncate mid-JSON and turn a slow answer into an invalid one; the bound is the request timeout instead.temperature: 0.65, not 0: at 0 the narrative fields flatten out.themesare free-form but pattern-constrained to English snake_case (^[a-z0-9][a-z0-9_-]{2,29}$) — in the pilot, an unconstrained field drifted between Russian and English inside a single run, making the values impossible to aggregate; the pattern makes Cyrillic mechanically unrepresentable at decode time. They are English for the same reason genre slugs andROOT_BUCKETkeys are: they are aggregation keys.moodis a closed enum that deliberately excludes irony/sarcasm — that axis isstance, and mixing the two produced compound values like"aggressive/triumphant".Resumability: the cache is written atomically after every single track (not batched by 20 as in
language.py) — inference costs tens of seconds, so a crash must cost at most one track. An entry is reused only when(model, prompt_version, lyrics_hash)all match, so a corrected lyrics text or a bumped prompt invalidates exactly what it should. A timeout or a broken response is stored as anerrormarker and the batch continues; error entries are never considered fresh, so the next run retries precisely those tracks. -
Track identifiers: a track has a composite
track_id("id:album_id", used as the cache key and to check against tracks already in a playlist) and separateid/album_id(needed as separate parameters fortrack_supplement,tracks_lyrics, andusers_playlists_insert_track). -
applyidempotency: before adding tracks to a playlist, its current contents are read (users_playlists(kind)), and tracks already present are skipped. Re-runningapplyis safe and creates no duplicates. Likes are never touched, nothing is ever deleted. -
Throttling and resumability: every network loop (fetching tracks, languages) pauses between requests (
config.toml,[throttle]) and writes the cache atomically (temp file + rename) after each batch — an interrupted run can simply be restarted, and already-fetched data is not requested again. -
Library digest (
digest.py): thedigestcommand's output is meant to be pasted into an LLM chat, so it's built as a set of aggregates with fixed ceilings rather than a per-track dump — on a ~2000-track library that's ~150 lines, regardless of whether the library has 500 tracks or 10000 (genre sections are bounded by the size ofROOT_BUCKET/BUCKET_LABELS; top artists and top albums are bounded by the[digest]config, not the library size; anything past the top is collapsed into a single sentence with aggregate numbers). Artists are counted by name, not viazip(artists, artist_ids)—fetch.serialize_trackputs every named artist intoartistsbut only those that also have an id intoartist_ids, so the two lists can differ in length for a given track. For the same reason, an artist's own genre tags (artist_tags) are only looked up from tracks where the two lists' lengths match — otherwise an artist could end up with someone else's tags. Rows fromreport.build_rows()are joined back tocache/tracks.jsonby the(id, album_id)pair taken from the cache's values, not by reconstructing the key asf"{id}:{album_id}"— the cache key is the original id fromlikes.tracks_ids, and for album-less tracks it doesn't match that format. The album's year and title (year,album_title) are read from the samealbums[0]asalbum_id— otherwise the title could end up paired with the wrong id. Old cache entries are backfilled with the same mechanism used forartist_ids: the migration checks whether theyearkey is present, not whether its value is truthy, otherwise tracks with a legitimately unknown year would be re-fetched on everyfetchrun. The command is fully offline — it never constructs aClient, unlikereport/apply. -
Arbitrary playlist by link (
source.py,--sourceonreport/apply/digest): an external source is ephemeral — its data is fetched into memory on every run and never written to disk (source.pyimports nojsonand never touchescache_dirat all). The one exception is the sharedcache/genre_catalog.json(Yandex's genre tree — a reference dataset, not a specific playlist's history), reused as usual viagenres.load_or_fetch_catalog. Writes always target your own account: a foreignuser_idis only ever passed intoclient.users_playlists(kind, user_id=...)(a read); no call inapply.py(users_playlists_create,users_playlists_insert_track) accepts one. Theyandex_musiclibrary has no URL parser at all —source.parse_playlist_urlparseshttps://music.yandex.ru/users/<login>/playlists/<kind>(plus a best-effort/playlists/<uuid>form viaclient.playlist(uuid)) itself, with no external dependency.Playlist.tracksis a list ofTrackShort(no genre/album data); fullTrackobjects are fetched with the same batch method used for likes,client.tracks(...), but the response is matched back to the requested id by value (by_id.get(...)), not a positionalzip— a foreign playlist is noticeably more likely to contain unavailable tracks, and a shortened response underzipwould silently mix up track data (Playlist.fetch_tracks()is a trap — it just re-callsusers_playlistsand returns the same short objects, so it isn't used).UnauthorizedErrorextendsYandexMusicErrordirectly, notNetworkError, soymclient.with_retriesnever catches or retries it — it used to be unhandled anywhere in the project; it's now caught insource._load_playlist(a private or inaccessible playlist) and by a top-level net incli.main()(an expired token on any command). Language for an external source is not refined viatrack_supplement(one uncached network request per track on every run) — it falls back to the existingcache/lyrics_lang.json(read only) plus the heuristics; a track whose language disagrees with what the precise check would have picked can land in the wrong language playlist underapply --sourcewithout--label.
Storage
.token— the account's OAuth token (not committed).cache/tracks.json— liked track metadata, includingartist_ids(the track's artist ids),album_titleandyear(the album's title and release year,Album.title/Album.year;yearcan benull), plusadded_at(when the track was liked,TrackShort.timestamp),duration_ms,track_version/album_version,release_date,album_likes_count- raw API fields not used for classification but available viareport --extra(see below).cache/artist_genres.json—{artist_id: {name, genres: [slug, ...], counts, ratings}}, one or more genres per artist;counts/ratingsareArtist.counts/Artist.ratings, samereport --extra-only purpose asadded_atabove.cache/genre_catalog.json— a flat snapshot of Yandex's genre tree (id -> title, root_id).cache/lyrics_lang.json— lyrics language per track (id -> language code | null).cache/lyrics_text.json— lyrics text per track (numeric id -> text | null, RU tracks only).cache/lyrics_analysis.json— per-track analysis record:{track_id: {model, prompt_version, lyrics_hash, mood, themes, emotional_arc, pov, tone, description, error?}}.out/lyrics_digest.md— full per-track reading grouped by primary mood.config.toml— includes[ollama]block withhost,model,prompt_version,timeout,keep_alive.out/report.csv— a preview of the playlist breakdown beforeapply(including thefine_bucketcolumn — the sub-genre before any collapsing into a top-level bucket).report --order playlistkeeps source track order instead of sorting by target playlist; individual--timestamp/--duration/--version/--album/--artistflags (combinable) add that group's columns,--extraadds every group at once. Flag -> CSV columns mapping isreport.EXTRA_GROUPS;report.resolve_extra_columns()expands the selected groups into a column list for the CLI.out/digest.md— a compact library summary for pasting into an LLM chat (top artists, top albums, genre/language/decade breakdowns).out/report_source.csv,out/digest_source.md— the same reports for a--sourcerun; a single overwritten "last external source" slot, no history of other playlists is kept.