--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.
20 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 текста / жанр-подсказка / алфавит)
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) на уровне трека, а не артиста — так один и тот же артист с разными по духу треками раскладывается по разным плейлистам:- Согласие сигналов (жанр трека есть среди жанров артиста) → берём его — гранулярно по треку.
- У трека нет жанра → берём самый частый (
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→ отдельный плейлист «Не определено».
-
Идентификаторы треков: у трека есть 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).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; один перезаписываемый слот "последнего внешнего источника", без истории по чужим плейлистам.