mirror of
https://github.com/Horizontal42/Sort_YM.git
synced 2026-11-10 18:02:00 +03:00
--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.
189 lines
20 KiB
Markdown
189 lines
20 KiB
Markdown
[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`
|
||
сохраняет исходный порядок треков вместо сортировки по целевому плейлисту; отдельные флаги
|
||
`--timestamp`/`--duration`/`--version`/`--album`/`--artist` (можно сочетать) добавляют колонки
|
||
своей группы, `--extra` — сразу все группы. Соответствие флаг -> колонки CSV - `report.EXTRA_GROUPS`;
|
||
`report.resolve_extra_columns()` разворачивает выбранные группы в список колонок для CLI.
|
||
- `out/digest.md` — сжатая сводка библиотеки для вставки в чат с LLM (топ-исполнители, топ-альбомы,
|
||
распределение по жанрам, языку и десятилетиям).
|
||
- `out/report_source.csv`, `out/digest_source.md` — те же отчёты, но для запуска с `--source`;
|
||
один перезаписываемый слот "последнего внешнего источника", без истории по чужим плейлистам.
|