Sort_YM/ARCHITECTURE.md
Andrew Pimenov 0902d86bcd classify tracks by combined track + artist genre signals
Раньше жанр брался только с альбома (одна строка, часто общая:
rock/alternative/pop) - разные по звучанию артисты сваливались в одну
крупную корзину. Теперь classify_track сводит жанр трека и жанры
артиста (client.artists(), несколько тегов на артиста) на уровне
под-жанра: при согласии сигналов или отсутствии одного из них берём
имеющийся; при конфликте - если альбомный тег зонтичный (GENERIC_SLUGS)
доверяем артисту, если точный - треку (вероятный осознанный эксперимент
артиста в другом жанре). Один и тот же артист с разными по духу треками
теперь раскладывается по разным плейлистам.

Под-жанровые группы меньше config.small_group_min схлопываются в
родительскую крупную корзину (report.build_rows, двухпроходно,
без циклов - coarse_of детерминирована от fine).

Попутно: artist_ids трека может быть None (артист без привязки к
каталогу Яндекса, встречается у стороннего/пиратского аплоада) - раньше
это улетало в client.artists() как строка "None" и валилось
BadRequestError на батче; теперь такие id отфильтровываются на записи
и дополнительно на чтении кэша.
2026-07-06 14:26:49 +03:00

9.4 KiB
Raw Blame History

Архитектура

Консольная утилита на Python, которая читает лайкнутые треки Яндекс.Музыки и раскладывает их по плейлистам вида Жанр — RU / Жанр — INT, используя неофициальную библиотеку yandex-music.

Структура

sort_ym/
  config.py     — чтение config.toml (throttle, small_group_min, пути к кэшу/токену)
  auth.py       — OAuth device-flow, хранение токена в .token
  ymclient.py   — создание Client, батч-нарезка списков, ретраи сетевых ошибок
  fetch.py      — лайки -> полные Track -> cache/tracks.json; артисты треков ->
                  client.artists() -> cache/artist_genres.json (оба докачиваются инкрементально)
  genres.py     — реальное дерево жанров Яндекса; classify_track — сведение жанра трека и жанров
                  артиста в один под-жанр (root_id); ROOT_BUCKET — под-жанр -> 11 крупных корзин
  language.py   — гибридная детекция языка (API текста / жанр-подсказка / алфавит)
  classify.py   — (под-жанр | крупная корзина, язык) -> имя плейлиста
  report.py     — двухпроходная сборка строк отчёта, запись out/report.csv, сводки
  apply.py      — создание плейлистов + идемпотентное добавление треков
  cli.py        — команды auth / fetch / report / apply

tests/
  test_genres.py — classify_track, dominant, bucket_for, дерево жанров
  test_language.py — детекция языка
  test_report.py — двухпроходное схлопывание мелких под-жанровых групп
  test_fetch.py, test_apply.py — кэш треков/артистов, идемпотентность применения

cache/  — кэш API (в .gitignore)
out/    — отчёты (в .gitignore)

Поток данных

auth  → .token
fetch → users_likes_tracks() → client.tracks(batch) → cache/tracks.json (+ artist_ids)
      → client.artists(batch) по уникальным 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(): проход 1 — classify_track() на каждый трек (genre_raw + жанры артиста)
                        проход 2 — группы < small_group_min схлопываются в крупную корзину
       → out/report.csv (dry-run, аккаунт не меняется)
apply --yes → users_playlists_create / users_playlists_insert_track

Ключевые решения

  • Жанр трека доступен только через альбом (Track.albums[0].genre), у самого трека поля жанра нет — это одна строка на трек, часто общая (rock, alternative). Раскладка по корзинам сделана через реальное дерево client.genres() (родитель → под-жанры), а не через угаданный список слагов — таблица ROOT_BUCKET в genres.py опирается на подтверждённые id, а не на предположения. Жанры без совпадения падают в корзину other и печатаются в выводе report для ручного дополнения таблицы.

  • Под-жанровая классификация (genres.classify_track): жанр трека — это одна строка на альбом и часто общая. У артиста же (client.artists(), батч-эндпоинт) может быть несколько тегов, точнее одного альбомного. classify_track(genre_raw, artist_genre_lists, catalog) сводит оба сигнала в один под-жанр (root_id, например punk/indie/allrock) на уровне трека, а не артиста — так один и тот же артист с разными по духу треками раскладывается по разным плейлистам:

    1. Согласие сигналов (жанр трека есть среди жанров артиста) → берём его — гранулярно по треку.
    2. У трека нет жанра → берём самый частый (dominant) жанр среди артистов трека.
    3. У артиста(ов) нет тегов → берём жанр трека.
    4. Конфликт: альбомный тег общий/"зонтичный" (GENERIC_SLUGS: rock, alternative, pop, ... — дефолт Яндекса низкой уверенности) → побеждает жанр артиста (dominant по объединённым тегам всех артистов трека, тай-брейк — по порядку появления). Альбомный тег точный/специфичный (numetal, dnb, ...) → побеждает он — вероятный настоящий эксперимент артиста в другом жанре, а не шум, который стоило бы поправить.

    Результат (fine) — двухпроходно схлопывается в report.build_rows: сначала считаются размеры групп (fine, lang), затем группы меньше config.small_group_min переезжают в родительскую крупную корзину через genres.coarse_of(fine) (тонкая обёртка над ROOT_BUCKET). Проход одиночный (не итеративный) — coarse_of детерминирована от fine, поэтому пересчёта размеров и зацикливания нет.

  • Язык определяется гибридно, в порядке приоритета:

    1. client.track_supplement(track_id).lyrics.text_language — единственное поле в библиотеке с реальным языком текста песни. Оно есть только в устаревшем (deprecated) методе track_supplement, а не в новом tracks_lyrics() — тот текст возвращает, но язык не отдаёт. Используется сознательно, несмотря на пометку deprecated в самой библиотеке.
    2. Подсказка по слагу жанра (rusrap, shanson, bard, ...).
    3. Эвристика по алфавиту названия/исполнителя (кириллица vs латиница, unicodedata).
    4. Иначе — UNKNOWN → отдельный плейлист «Не определено».
  • Идентификаторы треков: у трека есть composite track_id ("id:album_id", используется как ключ кэша и для сверки с уже добавленными треками в плейлисте) и отдельные id/album_id (нужны как раздельные параметры для track_supplement, tracks_lyrics и users_playlists_insert_track).

  • Идемпотентность apply: перед добавлением треков в плейлист читается его текущий состав (users_playlists(kind)), уже присутствующие треки пропускаются. Повторный запуск apply безопасен и не создаёт дублей. Лайки не трогаются, ничего не удаляется.

  • Троттлинг и резюмируемость: все сетевые циклы (загрузка треков, языков) делают паузу между запросами (config.toml, [throttle]) и пишут кэш атомарно (temp-файл + rename) после каждого батча — прерванный прогон можно просто перезапустить, уже полученное не запрашивается повторно.

Хранилище

  • .token — OAuth-токен аккаунта (не коммитится).
  • cache/tracks.json — метаданные лайкнутых треков, включая artist_ids (id артистов трека).
  • cache/artist_genres.json — {artist_id: {name, genres: [slug, ...]}}, один и более жанров на артиста.
  • cache/genre_catalog.json — плоский снимок дерева жанров Яндекса (id -> title, root_id).
  • cache/lyrics_lang.json — язык текста песни по треку (id -> language code | null).
  • out/report.csv — предпросмотр распределения по плейлистам перед apply (включая колонку fine_bucket — под-жанр до возможного схлопывания в крупную корзину).