Sort_YM/ARCHITECTURE.ru.md
Andrew Pimenov b991236ffa report: pick individual --extra column groups instead of all-or-nothing
--extra now takes group names (timestamp/duration/version/album/artist/all)
so you can add just what you need, e.g. --extra timestamp. Also split
README command lists into core vs. extra sections for readability.
2026-08-11 01:38:37 +03:00

189 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

[English](ARCHITECTURE.md)
# Архитектура
Консольная утилита на Python, которая читает лайкнутые треки Яндекс.Музыки и раскладывает их
по плейлистам вида `Жанр — RU` / `Жанр — INT`, используя неофициальную библиотеку `yandex-music`.
## Структура
```
sort_ym/
config.py — чтение config.toml (throttle, small_group_min, top_artists/top_albums,
пути к кэшу/токену)
auth.py — OAuth device-flow, хранение токена в .token
ymclient.py — создание Client, батч-нарезка списков, ретраи сетевых ошибок
fetch.py — лайки -> полные Track -> cache/tracks.json (serialize_track); артисты треков ->
client.artists() -> cache/artist_genres.json (оба докачиваются инкрементально);
unique_artist_ids — уникальные id артистов из кэша треков
source.py — эфемерный источник: разбор ссылки на произвольный плейлист + загрузка его
треков/артистов в память, без записи на диск (см. "--source" ниже)
genres.py — реальное дерево жанров Яндекса; classify_track — сведение жанра трека и жанров
артиста в один под-жанр (root_id); ROOT_BUCKET — под-жанр -> 11 крупных корзин
language.py — гибридная детекция языка (API текста / жанр-подсказка / алфавит)
classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста; with_label — суффикс метки
источника для apply --source --label
report.py — двухпроходная сборка строк отчёта, запись out/report.csv, сводки
apply.py — создание плейлистов + идемпотентное добавление треков
digest.py — агрегированная сводка библиотеки (топ-исполнители, жанры, десятилетия,
топ-альбомы) -> out/digest.md, полностью офлайн без --source; Wording/
playlist_wording — тексты дайджеста для лайков vs произвольного плейлиста
cli.py — команды auth / fetch / report / apply / dedupe / digest; --source/--label
на report/apply/digest (см. "Ключевые решения")
tests/
test_genres.py — classify_track, dominant, bucket_for, дерево жанров
test_language.py — детекция языка
test_report.py — двухпроходное схлопывание мелких под-жанровых групп
test_fetch.py, test_apply.py — кэш треков/артистов, идемпотентность применения
test_digest.py — агрегация по исполнителям и альбомам, схлопывание длинного хвоста,
границы размера дайджеста
test_source.py — разбор ссылки на плейлист, сопоставление ответа client.tracks() по id,
обработка приватных/недоступных плейлистов
test_cli.py — валидация флагов --source/--label до обращения к сети
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
digest → genres.load_catalog() + language.load_lang_cache() (только кэш, сеть не используется)
→ 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)
→ словарь в памяти (форма fetch.serialize_track, на диск не пишется)
→ client.artists(batch) → genres.load_or_fetch_catalog() (общий cache/genre_catalog.json)
+ language.load_lang_cache() (только чтение) → build_rows()
→ out/report_source.csv | out/digest_source.md | apply → плейлисты своего аккаунта
(общие "Жанр — RU/INT" или "... (метка)" при --label)
```
## Ключевые решения
- **Жанр трека** доступен только через альбом (`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) после каждого
батча — прерванный прогон можно просто перезапустить, уже полученное не запрашивается повторно.
- **Дайджест библиотеки (`digest.py`)**: вывод команды `digest` рассчитан на вставку в чат с LLM,
поэтому он строится как набор агрегатов с фиксированными границами, а не как построчный дамп —
на библиотеке из ~2000 треков это ~150 строк независимо от того, 500 треков в библиотеке или
10000 (жанровые секции ограничены размером `ROOT_BUCKET`/`BUCKET_LABELS`, топ-исполнители и
топ-альбомы — конфигом `[digest]`, а не всей библиотекой; хвост за пределами топа схлопывается в
одну фразу с суммарными числами). Исполнители считаются **по имени**, а не через
`zip(artists, artist_ids)` — `fetch.serialize_track` кладёт в `artists` всех артистов с именем, а в
`artist_ids` только тех, у кого есть и имя, и id, поэтому списки могут расходиться по длине для
одного трека. По той же причине собственные жанровые теги артиста (`artist_tags`) сопоставляются
только по трекам, где длины списков совпадают — иначе можно присвоить артисту чужие теги.
Склейка строк `report.build_rows()` с `cache/tracks.json` идёт по паре `(id, album_id)` из
**значений** кэша, а не по реконструированному ключу `f"{id}:{album_id}"` — ключ кэша это
исходный id из `likes.tracks_ids`, и для треков без альбома он не совпадает с этим форматом.
Год и название альбома (`year`, `album_title`) берутся из одного и того же `albums[0]`, что и
`album_id` — иначе название могло бы разъехаться с id. Досыпка старых записей кэша идёт тем же
механизмом, что и для `artist_ids`: проверяется **наличие** ключа `year`, а не правдивость
значения, иначе треки с легитимно неизвестным годом перекачивались бы при каждом `fetch`.
Команда полностью офлайн — `Client` не создаётся вообще, в отличие от `report`/`apply`.
- **Произвольный плейлист по ссылке (`source.py`, флаг `--source` на `report`/`apply`/`digest`)**:
внешний источник **эфемерен** — данные загружаются в память при каждом запуске и никогда не
пишутся на диск (`source.py` не импортирует `json` и не трогает `cache_dir` вообще). Единственное
исключение — общий `cache/genre_catalog.json` (дерево жанров Яндекса, не история конкретного
плейлиста) переиспользуется как обычно через `genres.load_or_fetch_catalog`. Запись всегда идёт
в свой аккаунт: foreign `user_id` передаётся только в `client.users_playlists(kind, user_id=...)`
(чтение), ни один вызов `apply.py` (`users_playlists_create`, `users_playlists_insert_track`) его
не принимает. Разбора ссылки в библиотеке `yandex_music` нет вообще — `source.parse_playlist_url`
разбирает `https://music.yandex.ru/users/<логин>/playlists/<kind>` (плюс best-effort форма
`/playlists/<uuid>` через `client.playlist(uuid)`) сам, без внешних зависимостей. `Playlist.tracks`
— это `TrackShort` (без жанра/альбома), полные `Track` докачиваются тем же батч-методом
`client.tracks(...)`, что и лайки, но результат сопоставляется с запрошенным id по значению
(`by_id.get(...)`), а не позиционным `zip` — в чужом плейлисте заметно вероятнее недоступные
треки, и укороченный ответ при `zip` тихо перепутал бы данные между треками (`Playlist.fetch_tracks()`
— ловушка, просто повторяет `users_playlists` и возвращает те же короткие объекты, не используется).
`UnauthorizedError` наследуется от `YandexMusicError`, а не от `NetworkError`, поэтому
`ymclient.with_retries` её не перехватывает и не повторяет — раньше она была необработана нигде в
проекте; теперь ловится в `source._load_playlist` (приватный/недоступный плейлист) и общей сеткой
в `cli.main()` (истёкший токен на любой команде). Язык для внешнего источника не уточняется через
`track_supplement` (сетевой запрос на каждый трек без кэша при каждом прогоне) — используется
только уже существующий `cache/lyrics_lang.json` на чтение плюс эвристики; трек, разошедшийся по
языку с тем, что выбрала бы точная проверка, может попасть не в тот языковой плейлист при
`apply --source` без `--label`.
## Хранилище
- `.token` — OAuth-токен аккаунта (не коммитится).
- `cache/tracks.json` — метаданные лайкнутых треков, включая `artist_ids` (id артистов трека),
`album_title` и `year` (название и год релиза альбома, `Album.title`/`Album.year`; `year` может
быть `null`), а также `added_at` (дата добавления трека в лайки, `TrackShort.timestamp`),
`duration_ms`, `track_version`/`album_version`, `release_date`, `album_likes_count` - сырые поля
API, не используемые в классификации, но доступные в `report --extra` (см. ниже).
- `cache/artist_genres.json` — `{artist_id: {name, genres: [slug, ...], counts, ratings}}`, один и
более жанров на артиста; `counts`/`ratings` - те же поля `Artist.counts`/`Artist.ratings`, что
и `added_at` выше, только для `report --extra`.
- `cache/genre_catalog.json` — плоский снимок дерева жанров Яндекса (id -> title, root_id).
- `cache/lyrics_lang.json` — язык текста песни по треку (id -> language code | null).
- `out/report.csv` — предпросмотр распределения по плейлистам перед `apply` (включая колонку
`fine_bucket` — под-жанр до возможного схлопывания в крупную корзину). `report --order playlist`
сохраняет исходный порядок треков вместо сортировки по целевому плейлисту; `report --extra
<группа...>` добавляет колонки выбранных групп (`timestamp`/`duration`/`version`/`album`/`artist`,
или `all` - сразу все; `report.EXTRA_GROUPS` -> список колонок, `report.resolve_extra_columns()`
разворачивает имена групп в список колонок для CLI).
- `out/digest.md` — сжатая сводка библиотеки для вставки в чат с LLM (топ-исполнители, топ-альбомы,
распределение по жанрам, языку и десятилетиям).
- `out/report_source.csv`, `out/digest_source.md` — те же отчёты, но для запуска с `--source`;
один перезаписываемый слот "последнего внешнего источника", без истории по чужим плейлистам.