Sort_YM/ARCHITECTURE.ru.md
Andrew Pimenov f36526a9c9 fix docs to match code: dedupe command, deletion claim, analysis field names
README and ARCHITECTURE were missing the dedupe command and claimed the tool
never deletes anything, but dedupe --yes removes duplicate playlist entries.
lyrics_analysis.json field list (tone/description) didn't match the actual
schema (register/concreteness/stance/summary/resonance/key_line/confidence).
lyrics_text.json is also written by report/apply as a side effect of language
detection, not only by the lyrics command. Sub-genre count corrected 34->36.
2026-08-23 19:05:23 +03:00

26 KiB
Raw Blame History

English

Архитектура

Консольная утилита на 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); тот же файл попутно
                  пополняет language.fetch_api_languages на report/apply
  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 / lyrics / analyze;
                  --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
dedupe --yes → users_playlists(kind) → find_duplicate_ranges() → users_playlists_delete_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-z0-9][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 безопасен и не создаёт дублей. Лайки не трогаются, и сама apply_classification ничего не удаляет — единственное удаление в apply.py делает dedupe_playlists (см. ниже), отдельный, явно вызываемый путь.

  • dedupe: отдельный проход очистки, не часть apply. Читает текущий состав плейлиста (users_playlists(kind)), находит повторные вставки одного трека через find_duplicate_ranges и — только при dedupe --yes — удаляет лишние записи через users_playlists_delete_track. Без --yes это dry-run, который только сообщает, что было бы удалено. Это единственное место в коде, которое вообще что-то удаляет из плейлиста.

  • Троттлинг и резюмируемость: все сетевые циклы (загрузка треков, языков) делают паузу между запросами (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-треки, если не загружено через --all-languages. Пишется инкрементально не только lyrics.fetch_lyrics_text, но и попутно language.fetch_api_languages на каждом report/ apply — она кэширует RU-текст, который уже получила, определяя язык текста песни.
  • cache/lyrics_analysis.json — запись анализа на трек: {track_id: {model, prompt_version, lyrics_hash, analyzed_at, mood, themes, emotional_arc, pov, register, concreteness, stance, confidence, summary, resonance, key_line, key_line_verified, 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; один перезаписываемый слот "последнего внешнего источника", без истории по чужим плейлистам.