Sort_YM/ARCHITECTURE.ru.md

235 lines
25 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 текста / жанр-подсказка / алфавит)
lyrics.py — тексты песен RU-треков: track_supplement().lyrics.full_lyrics ->
cache/lyrics_text.json (инкрементально, atomic-write)
analyze.py — разбор текстов локальной Ollama (/api/chat, think + JSON Schema) ->
cache/lyrics_analysis.json (запись после каждого трека)
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_lyrics.py — загрузка текстов песен, структура кэша, резюмируемость
test_analyze.py — интеграция локальной Ollama, разбор JSON Schema, атомарность кэша
test_report.py — двухпроходное схлопывание мелких под-жанровых групп
test_fetch.py, test_apply.py — кэш треков/артистов, идемпотентность применения
test_digest.py — агрегация по исполнителям и альбомам, схлопывание длинного хвоста,
границы размера дайджеста
test_source.py — разбор ссылки на плейлист, сопоставление ответа client.tracks() по id,
обработка приватных/недоступных плейлистов
test_cli.py, test_config.py — валидация флагов --source/--label до обращения к сети;
чтение config.toml включая блок [ollama]
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
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 (агрегат) + 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)
→ словарь в памяти (форма 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` → отдельный плейлист «Не определено».
- **Разбор текстов (`lyrics.py`, `analyze.py`)**: по умолчанию разбираются только русскоязычные
треки — промпт и нарративные поля (`summary`/`resonance`/`key_line`) написаны на русском и
проверены на пилотном прогоне по русской лирике; качество на других языках не проверялось.
`sort_ym lyrics --all-languages` снимает фильтр и загружает/разбирает все треки с доступным
текстом независимо от определённого языка (`lyrics.ru_lyric_track_ids(...,
all_languages=True)`). Загрузка текста и анализ — это **две отдельные команды** намеренно: загрузка привязана к сети и устаревшему `track_supplement`,
анализ — это многочасовая работа локального GPU, а смена prompt или модели должна быть
переиспользуема без нового обращения к Яндексу. `analyze` и `digest` вообще не создают `Client`.
Вызов Ollama (`/api/chat`) комбинирует `think: true` с полной JSON Schema в `format`:
рассуждение идёт в отдельное поле `message.thinking` (никогда не кэшируется), а финальный ответ
ограничивается грамматикой, так что модель рассуждает свободно, а результат всё равно парсится.
`num_predict: -1` — ограничение по токенам обрежет JSON и превратит медленный ответ в невалидный;
ограничение вместо этого — таймаут запроса. `temperature: 0.65`, не 0: при 0 нарративные поля
становятся одноообразными.
`themes` свободного формата, но ограничены паттерном англ. snake_case (`^[a-z][a-z0-9_]{2,29}$`) —
в пилоте неограниченное поле дрейфовало между русским и английским в одном прогоне, что сделало
значения невозможными для агрегации; паттерн делает кириллицу механически невозможной при разборе.
Они англ. по той же причине, что и слаги жанров, и `ROOT_BUCKET`: это ключи агрегации. `mood` —
закрытый enum, намеренно исключающий иронию/сарказм — это ось `stance`, и смешивание двух осей
дало составные значения типа `"aggressive/triumphant"`.
Резюмируемость: кэш пишется атомарно после **каждого трека** (не батчами по 20, как в
`language.py`) — инференс занимает десятки секунд, так что крах должен стоить максимум один трек.
Запись переиспользуется, только если совпадают `(model, prompt_version, lyrics_hash)`, поэтому
исправленный текст или bumped prompt инвалидирует ровно то, что надо. Таймаут или повреждённый
ответ сохраняются как `error` marker, прогон продолжается; error-записи никогда не считаются
свежими, так что следующий прогон повторит точно эти треки.
- **Идентификаторы треков**: у трека есть 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).
- `cache/lyrics_text.json` — текст песни по треку (numeric id -> текст | null, только RU-треки).
- `cache/lyrics_analysis.json` — запись анализа на трек: `{track_id: {model, prompt_version,
lyrics_hash, mood, themes, emotional_arc, pov, tone, description, error?}}`.
- `out/lyrics_digest.md` — полный построчный разбор, сгруппированный по основному настроению.
- `config.toml` — включает блок `[ollama]` с полями `host`, `model`, `prompt_version`, `timeout`,
`keep_alive`.
- `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`;
один перезаписываемый слот "последнего внешнего источника", без истории по чужим плейлистам.