Sort_YM/ARCHITECTURE.md
Andrew Pimenov 881e22c528 report: --extra means all extra columns again, add per-group flags
--extra alone now adds every extra column like before. Individual
--timestamp/--duration/--version/--album/--artist flags let you pick just
one group instead, and combine freely.
2026-08-11 01:42:20 +03:00

13 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)
  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_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    — --source/--label flag validation before any network access

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

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 real client.genres() tree (parent → sub-genres), not a guessed slug list — the ROOT_BUCKET table in genres.py relies on confirmed ids, not assumptions. Genres without a match fall into the other bucket and are printed in report'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:

    1. Signals agree (track genre is among the artist's genres) → use it — granular per track.
    2. Track has no genre → use the most frequent (dominant) genre among the track's artists.
    3. Artist(s) have no tags → use the track's genre.
    4. Conflict: the album tag is generic/"umbrella" (GENERIC_SLUGS: rock, alternative, pop, ... — Yandex's low-confidence default) → the artist's genre wins (dominant over 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 in report.build_rows: first the sizes of (fine, lang) groups are counted, then groups smaller than config.small_group_min move to the parent top-level bucket via genres.coarse_of(fine) (a thin wrapper over ROOT_BUCKET). The pass is single (not iterative) — coarse_of is deterministic from fine, so there's no re-counting of sizes or looping.

  • Language is determined hybrid, in priority order:

    1. 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 deprecated track_supplement method, not in the newer tracks_lyrics() — that one returns the text but not the language. Used deliberately despite the library's own deprecation notice.
    2. A genre-slug hint (rusrap, shanson, bard, ...).
    3. An alphabet heuristic on the title/artist (Cyrillic vs Latin, unicodedata).
    4. Otherwise — UNKNOWN → a separate "Undetermined" playlist.
  • 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 separate id/album_id (needed as separate parameters for track_supplement, tracks_lyrics, and users_playlists_insert_track).

  • apply idempotency: before adding tracks to a playlist, its current contents are read (users_playlists(kind)), and tracks already present are skipped. Re-running apply is 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): the digest command'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 of ROOT_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 via zip(artists, artist_ids) — fetch.serialize_track puts every named artist into artists but only those that also have an id into artist_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 from report.build_rows() are joined back to cache/tracks.json by the (id, album_id) pair taken from the cache's values, not by reconstructing the key as f"{id}:{album_id}" — the cache key is the original id from likes.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 same albums[0] as album_id — otherwise the title could end up paired with the wrong id. Old cache entries are backfilled with the same mechanism used for artist_ids: the migration checks whether the year key is present, not whether its value is truthy, otherwise tracks with a legitimately unknown year would be re-fetched on every fetch run. The command is fully offline — it never constructs a Client, unlike report/apply.

  • Arbitrary playlist by link (source.py, --source on report/apply/digest): an external source is ephemeral — its data is fetched into memory on every run and never written to disk (source.py imports no json and never touches cache_dir at all). The one exception is the shared cache/genre_catalog.json (Yandex's genre tree — a reference dataset, not a specific playlist's history), reused as usual via genres.load_or_fetch_catalog. Writes always target your own account: a foreign user_id is only ever passed into client.users_playlists(kind, user_id=...) (a read); no call in apply.py (users_playlists_create, users_playlists_insert_track) accepts one. The yandex_music library has no URL parser at all — source.parse_playlist_url parses https://music.yandex.ru/users/<login>/playlists/<kind> (plus a best-effort /playlists/<uuid> form via client.playlist(uuid)) itself, with no external dependency. Playlist.tracks is a list of TrackShort (no genre/album data); full Track objects 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 positional zip — a foreign playlist is noticeably more likely to contain unavailable tracks, and a shortened response under zip would silently mix up track data (Playlist.fetch_tracks() is a trap — it just re-calls users_playlists and returns the same short objects, so it isn't used). UnauthorizedError extends YandexMusicError directly, not NetworkError, so ymclient.with_retries never catches or retries it — it used to be unhandled anywhere in the project; it's now caught in source._load_playlist (a private or inaccessible playlist) and by a top-level net in cli.main() (an expired token on any command). Language for an external source is not refined via track_supplement (one uncached network request per track on every run) — it falls back to the existing cache/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 under apply --source without --label.

Storage

  • .token — the account's OAuth token (not committed).
  • cache/tracks.json — liked track metadata, including artist_ids (the track's artist ids), album_title and year (the album's title and release year, Album.title/Album.year; year can be null), plus added_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 via report --extra (see below).
  • cache/artist_genres.json — {artist_id: {name, genres: [slug, ...], counts, ratings}}, one or more genres per artist; counts/ratings are Artist.counts/Artist.ratings, same report --extra-only purpose as added_at above.
  • 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).
  • out/report.csv — a preview of the playlist breakdown before apply (including the fine_bucket column — the sub-genre before any collapsing into a top-level bucket). report --order playlist keeps source track order instead of sorting by target playlist; individual --timestamp/--duration/--version/--album/--artist flags (combinable) add that group's columns, --extra adds every group at once. Flag -> CSV columns mapping is report.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 --source run; a single overwritten "last external source" slot, no history of other playlists is kept.