with_retries caught bare Exception with a type whitelist, masking real bugs as network errors under retry/backoff - narrowed back to NetworkError. fetch_api_languages was writing full lyrics text for every language into lyrics_text.json via its track_supplement side channel, breaking the documented RU-only invariant and letting analyze feed foreign-language lyrics through the Russian-only prompt - now gated on normalize_api_language. dedupe_playlists logged the found duplicate count as "removed" even when a mid-loop network/API error cut deletion short. THEME_PATTERN's relaxed regex allowed 2-char themes, contradicting its own test; current_themes was hoisted out of the analyze loop so later prompts never saw themes found earlier in the same run. Both reverted to intended behavior, docs updated to match.
25 KiB
Архитектура
Консольная утилита на 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) на уровне трека, а не артиста — так один и тот же артист с разными по духу треками раскладывается по разным плейлистам:- Согласие сигналов (жанр трека есть среди жанров артиста) → берём его — гранулярно по треку.
- У трека нет жанра → берём самый частый (
dominant) жанр среди артистов трека. - У артиста(ов) нет тегов → берём жанр трека.
- Конфликт: альбомный тег общий/"зонтичный" (
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, поэтому пересчёта размеров и зацикливания нет. -
Язык определяется гибридно, в порядке приоритета:
client.track_supplement(track_id).lyrics.text_language— единственное поле в библиотеке с реальным языком текста песни. Оно есть только в устаревшем (deprecated) методеtrack_supplement, а не в новомtracks_lyrics()— тот текст возвращает, но язык не отдаёт. Используется сознательно, несмотря на пометку deprecated в самой библиотеке.- Подсказка по слагу жанра (
rusrap,shanson,bard, ...). - Эвристика по алфавиту названия/исполнителя (кириллица vs латиница,
unicodedata). - Иначе —
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 инвалидирует ровно то, что надо. Таймаут или повреждённый ответ сохраняются какerrormarker, прогон продолжается; 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. Запись всегда идёт в свой аккаунт: foreignuser_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; один перезаписываемый слот "последнего внешнего источника", без истории по чужим плейлистам.