split docs into EN/RU, add MIT license

README/ARCHITECTURE now bilingual (EN default, .ru.md cross-linked) per public-repo convention; added MIT license and Credits/License sections; tightened .gitignore against stray AI-tool files.
This commit is contained in:
Andrew Pimenov 2026-08-03 17:18:50 +03:00
parent ac955ea35a
commit 544765db3f
6 changed files with 429 additions and 141 deletions

6
.gitignore vendored
View file

@ -1,8 +1,14 @@
.venv/ .venv/
__pycache__/ __pycache__/
*.pyc *.pyc
.pytest_cache/
.token .token
cache/ cache/
out/ out/
.claude/ .claude/
plans/ plans/
.aider*
.cursorrules
bugs.md
*_ai_notes*
llm_context*

View file

@ -1,107 +1,109 @@
# Архитектура [Русский](ARCHITECTURE.ru.md)
Консольная утилита на Python, которая читает лайкнутые треки Яндекс.Музыки и раскладывает их # Architecture
по плейлистам вида `Жанр — RU` / `Жанр — INT`, используя неофициальную библиотеку `yandex-music`.
## Структура A Python console utility that reads Yandex Music liked tracks and sorts them into playlists named
`Genre — RU` / `Genre — INT`, using the unofficial `yandex-music` library.
## Structure
``` ```
sort_ym/ sort_ym/
config.py — чтение config.toml (throttle, small_group_min, пути к кэшу/токену) config.py — reads config.toml (throttle, small_group_min, cache/token paths)
auth.py — OAuth device-flow, хранение токена в .token auth.py — OAuth device-flow, token storage in .token
ymclient.py — создание Client, батч-нарезка списков, ретраи сетевых ошибок ymclient.py — Client construction, batching lists, retrying network errors
fetch.py — лайки -> полные Track -> cache/tracks.json; артисты треков -> fetch.py — likes -> full Track -> cache/tracks.json; track artists ->
client.artists() -> cache/artist_genres.json (оба докачиваются инкрементально) client.artists() -> cache/artist_genres.json (both fetched incrementally)
genres.py — реальное дерево жанров Яндекса; classify_track — сведение жанра трека и жанров genres.py — the real Yandex genre tree; classify_track — resolves track genre and artist
артиста в один под-жанр (root_id); ROOT_BUCKET — под-жанр -> 11 крупных корзин genres into a single sub-genre (root_id); ROOT_BUCKET — sub-genre -> 11 top-level buckets
language.py — гибридная детекция языка (API текста / жанр-подсказка / алфавит) language.py — hybrid language detection (lyrics API / genre hint / alphabet)
classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста classify.py — (sub-genre | top-level bucket, language) -> playlist name
report.py — двухпроходная сборка строк отчёта, запись out/report.csv, сводки report.py — two-pass report row building, writes out/report.csv, summaries
apply.py — создание плейлистов + идемпотентное добавление треков apply.py — playlist creation + idempotent track insertion
cli.py — команды auth / fetch / report / apply cli.py — auth / fetch / report / apply commands
tests/ tests/
test_genres.py — classify_track, dominant, bucket_for, дерево жанров test_genres.py — classify_track, dominant, bucket_for, genre tree
test_language.py — детекция языка test_language.py — language detection
test_report.py — двухпроходное схлопывание мелких под-жанровых групп test_report.py — two-pass collapsing of small sub-genre groups
test_fetch.py, test_apply.py — кэш треков/артистов, идемпотентность применения test_fetch.py, test_apply.py — track/artist cache, apply idempotency
cache/ — кэш API (в .gitignore) cache/ — API cache (in .gitignore)
out/ — отчёты (в .gitignore) out/ — reports (in .gitignore)
``` ```
## Поток данных ## Data flow
``` ```
auth → .token auth → .token
fetch → users_likes_tracks() → client.tracks(batch) → cache/tracks.json (+ artist_ids) fetch → users_likes_tracks() → client.tracks(batch) → cache/tracks.json (+ artist_ids)
→ client.artists(batch) по уникальным artist_ids → cache/artist_genres.json → client.artists(batch) over unique artist_ids → cache/artist_genres.json
report → genres.load_or_fetch_catalog() → cache/genre_catalog.json report → genres.load_or_fetch_catalog() → cache/genre_catalog.json
→ language.fetch_api_languages() → cache/lyrics_lang.json → language.fetch_api_languages() → cache/lyrics_lang.json
→ build_rows(): проход 1 — classify_track() на каждый трек (genre_raw + жанры артиста) → build_rows(): pass 1 — classify_track() per track (genre_raw + artist genres)
проход 2 — группы < small_group_min схлопываются в крупную корзину pass 2 — groups < small_group_min collapse into the top-level bucket
→ out/report.csv (dry-run, аккаунт не меняется) → out/report.csv (dry-run, account unchanged)
apply --yes → users_playlists_create / users_playlists_insert_track apply --yes → users_playlists_create / users_playlists_insert_track
``` ```
## Ключевые решения ## Key decisions
- **Жанр трека** доступен только через альбом (`Track.albums[0].genre`), у самого трека поля - **Track genre** is only available through the album (`Track.albums[0].genre`) — the track itself
жанра нет — это одна строка на трек, часто общая (`rock`, `alternative`). Раскладка по корзинам has no genre field, so it's one string per track, often generic (`rock`, `alternative`). Bucketing
сделана через реальное дерево `client.genres()` (родитель → под-жанры), а не через угаданный is done through the real `client.genres()` tree (parent → sub-genres), not a guessed slug list —
список слагов — таблица `ROOT_BUCKET` в `genres.py` опирается на подтверждённые id, а не на the `ROOT_BUCKET` table in `genres.py` relies on confirmed ids, not assumptions. Genres without a
предположения. Жанры без совпадения падают в корзину `other` и печатаются в выводе `report` для match fall into the `other` bucket and are printed in `report`'s output for manual table extension.
ручного дополнения таблицы.
- **Под-жанровая классификация (`genres.classify_track`)**: жанр трека — это одна строка на альбом - **Sub-genre classification (`genres.classify_track`)**: track genre is one string per album and
и часто общая. У артиста же (`client.artists()`, батч-эндпоинт) может быть несколько тегов, often generic. An artist (`client.artists()`, a batch endpoint) can have several tags, more
точнее одного альбомного. `classify_track(genre_raw, artist_genre_lists, catalog)` сводит оба precise than a single album genre. `classify_track(genre_raw, artist_genre_lists, catalog)`
сигнала в один под-жанр (`root_id`, например `punk`/`indie`/`allrock`) на уровне **трека**, а не resolves both signals into a single sub-genre (`root_id`, e.g. `punk`/`indie`/`allrock`) at the
артиста — так один и тот же артист с разными по духу треками раскладывается по разным плейлистам: **track** level, not the artist level — so the same artist with tracks of different character ends
1. Согласие сигналов (жанр трека есть среди жанров артиста) → берём его — гранулярно по треку. up in different playlists:
2. У трека нет жанра → берём самый частый (`dominant`) жанр среди артистов трека. 1. Signals agree (track genre is among the artist's genres) → use it — granular per track.
3. У артиста(ов) нет тегов → берём жанр трека. 2. Track has no genre → use the most frequent (`dominant`) genre among the track's artists.
4. Конфликт: альбомный тег общий/"зонтичный" (`GENERIC_SLUGS`: `rock`, `alternative`, `pop`, ... — 3. Artist(s) have no tags → use the track's genre.
дефолт Яндекса низкой уверенности) → побеждает жанр артиста (`dominant` по объединённым тегам 4. Conflict: the album tag is generic/"umbrella" (`GENERIC_SLUGS`: `rock`, `alternative`, `pop`,
всех артистов трека, тай-брейк — по порядку появления). Альбомный тег точный/специфичный ... — Yandex's low-confidence default) → the artist's genre wins (`dominant` over the combined
(`numetal`, `dnb`, ...) → побеждает он — вероятный настоящий эксперимент артиста в другом tags of all the track's artists, tie-break by order of appearance). The album tag is
жанре, а не шум, который стоило бы поправить. precise/specific (`numetal`, `dnb`, ...) → it wins — likely a genuine genre experiment by the
artist rather than noise worth correcting.
Результат (`fine`) — двухпроходно схлопывается в `report.build_rows`: сначала считаются размеры The result (`fine`) is collapsed in a two-pass process in `report.build_rows`: first the sizes of
групп `(fine, lang)`, затем группы меньше `config.small_group_min` переезжают в родительскую `(fine, lang)` groups are counted, then groups smaller than `config.small_group_min` move to the
крупную корзину через `genres.coarse_of(fine)` (тонкая обёртка над `ROOT_BUCKET`). Проход parent top-level bucket via `genres.coarse_of(fine)` (a thin wrapper over `ROOT_BUCKET`). The pass
одиночный (не итеративный) — `coarse_of` детерминирована от `fine`, поэтому пересчёта размеров и is single (not iterative) — `coarse_of` is deterministic from `fine`, so there's no re-counting of
зацикливания нет. sizes or looping.
- **Язык** определяется гибридно, в порядке приоритета: - **Language** is determined hybrid, in priority order:
1. `client.track_supplement(track_id).lyrics.text_language` — единственное поле в библиотеке 1. `client.track_supplement(track_id).lyrics.text_language` — the only field in the library with
с реальным языком текста песни. Оно есть только в устаревшем (deprecated) методе the actual lyrics language. It's only present in the deprecated `track_supplement` method, not
`track_supplement`, а не в новом `tracks_lyrics()` — тот текст возвращает, но язык не отдаёт. in the newer `tracks_lyrics()` — that one returns the text but not the language. Used
Используется сознательно, несмотря на пометку deprecated в самой библиотеке. deliberately despite the library's own deprecation notice.
2. Подсказка по слагу жанра (`rusrap`, `shanson`, `bard`, ...). 2. A genre-slug hint (`rusrap`, `shanson`, `bard`, ...).
3. Эвристика по алфавиту названия/исполнителя (кириллица vs латиница, `unicodedata`). 3. An alphabet heuristic on the title/artist (Cyrillic vs Latin, `unicodedata`).
4. Иначе — `UNKNOWN` → отдельный плейлист «Не определено». 4. Otherwise — `UNKNOWN` → a separate "Undetermined" playlist.
- **Идентификаторы треков**: у трека есть composite `track_id` (`"id:album_id"`, используется как - **Track identifiers**: a track has a composite `track_id` (`"id:album_id"`, used as the cache key
ключ кэша и для сверки с уже добавленными треками в плейлисте) и отдельные `id`/`album_id` and to check against tracks already in a playlist) and separate `id`/`album_id` (needed as
(нужны как раздельные параметры для `track_supplement`, `tracks_lyrics` и separate parameters for `track_supplement`, `tracks_lyrics`, and `users_playlists_insert_track`).
`users_playlists_insert_track`).
- **Идемпотентность `apply`**: перед добавлением треков в плейлист читается его текущий состав - **`apply` idempotency**: before adding tracks to a playlist, its current contents are read
(`users_playlists(kind)`), уже присутствующие треки пропускаются. Повторный запуск `apply` (`users_playlists(kind)`), and tracks already present are skipped. Re-running `apply` is safe and
безопасен и не создаёт дублей. Лайки не трогаются, ничего не удаляется. creates no duplicates. Likes are never touched, nothing is ever deleted.
- **Троттлинг и резюмируемость**: все сетевые циклы (загрузка треков, языков) делают паузу между - **Throttling and resumability**: every network loop (fetching tracks, languages) pauses between
запросами (`config.toml`, `[throttle]`) и пишут кэш атомарно (temp-файл + rename) после каждого requests (`config.toml`, `[throttle]`) and writes the cache atomically (temp file + rename) after
батча — прерванный прогон можно просто перезапустить, уже полученное не запрашивается повторно. each batch — an interrupted run can simply be restarted, and already-fetched data is not
requested again.
## Хранилище ## Storage
- `.token` — OAuth-токен аккаунта (не коммитится). - `.token` — the account's OAuth token (not committed).
- `cache/tracks.json` — метаданные лайкнутых треков, включая `artist_ids` (id артистов трека). - `cache/tracks.json` — liked track metadata, including `artist_ids` (the track's artist ids).
- `cache/artist_genres.json` — `{artist_id: {name, genres: [slug, ...]}}`, один и более жанров - `cache/artist_genres.json` — `{artist_id: {name, genres: [slug, ...]}}`, one or more genres per
на артиста. artist.
- `cache/genre_catalog.json` — плоский снимок дерева жанров Яндекса (id -> title, root_id). - `cache/genre_catalog.json` — a flat snapshot of Yandex's genre tree (id -> title, root_id).
- `cache/lyrics_lang.json` — язык текста песни по треку (id -> language code | null). - `cache/lyrics_lang.json` — lyrics language per track (id -> language code | null).
- `out/report.csv` — предпросмотр распределения по плейлистам перед `apply` (включая колонку - `out/report.csv` — a preview of the playlist breakdown before `apply` (including the
`fine_bucket` — под-жанр до возможного схлопывания в крупную корзину). `fine_bucket` column — the sub-genre before any collapsing into a top-level bucket).

109
ARCHITECTURE.ru.md Normal file
View file

@ -0,0 +1,109 @@
[English](ARCHITECTURE.md)
# Архитектура
Консольная утилита на Python, которая читает лайкнутые треки Яндекс.Музыки и раскладывает их
по плейлистам вида `Жанр — RU` / `Жанр — INT`, используя неофициальную библиотеку `yandex-music`.
## Структура
```
sort_ym/
config.py — чтение config.toml (throttle, small_group_min, пути к кэшу/токену)
auth.py — OAuth device-flow, хранение токена в .token
ymclient.py — создание Client, батч-нарезка списков, ретраи сетевых ошибок
fetch.py — лайки -> полные Track -> cache/tracks.json; артисты треков ->
client.artists() -> cache/artist_genres.json (оба докачиваются инкрементально)
genres.py — реальное дерево жанров Яндекса; classify_track — сведение жанра трека и жанров
артиста в один под-жанр (root_id); ROOT_BUCKET — под-жанр -> 11 крупных корзин
language.py — гибридная детекция языка (API текста / жанр-подсказка / алфавит)
classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста
report.py — двухпроходная сборка строк отчёта, запись out/report.csv, сводки
apply.py — создание плейлистов + идемпотентное добавление треков
cli.py — команды auth / fetch / report / apply
tests/
test_genres.py — classify_track, dominant, bucket_for, дерево жанров
test_language.py — детекция языка
test_report.py — двухпроходное схлопывание мелких под-жанровых групп
test_fetch.py, test_apply.py — кэш треков/артистов, идемпотентность применения
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
```
## Ключевые решения
- **Жанр трека** доступен только через альбом (`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) после каждого
батча — прерванный прогон можно просто перезапустить, уже полученное не запрашивается повторно.
## Хранилище
- `.token` — OAuth-токен аккаунта (не коммитится).
- `cache/tracks.json` — метаданные лайкнутых треков, включая `artist_ids` (id артистов трека).
- `cache/artist_genres.json` — `{artist_id: {name, genres: [slug, ...]}}`, один и более жанров
на артиста.
- `cache/genre_catalog.json` — плоский снимок дерева жанров Яндекса (id -> title, root_id).
- `cache/lyrics_lang.json` — язык текста песни по треку (id -> language code | null).
- `out/report.csv` — предпросмотр распределения по плейлистам перед `apply` (включая колонку
`fine_bucket` — под-жанр до возможного схлопывания в крупную корзину).

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Andrew Pimenov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

134
README.md
View file

@ -1,123 +1,125 @@
[Русский](README.ru.md)
# Sort YM # Sort YM
Раскладывает лайкнутые треки Яндекс.Музыки по плейлистам вида `Жанр — RU` / `Жанр — INT`. Sorts your Yandex Music liked tracks into playlists named `Genre — RU` / `Genre — INT`.
``` ```
$ python -m sort_ym report $ python -m sort_ym report
Распределение по плейлистам: Playlist breakdown:
Рок — INT: 143 Rock — INT: 143
Рок — RU: 287 Rock — RU: 287
Рэп — RU: 512 Rap — RU: 512
... ...
Отчёт сохранён: out/report.csv Report saved: out/report.csv
``` ```
## Установка ## Install
Требования: Python 3.11+ (используется `tomllib` из стандартной библиотеки), Windows/Linux/macOS, доступ в интернет. Requirements: Python 3.11+ (uses `tomllib` from the standard library), Windows/Linux/macOS, internet access.
``` ```
python -m venv .venv python -m venv .venv
.venv\Scripts\pip install -r requirements.txt .venv\Scripts\pip install -r requirements.txt
``` ```
Для разработки (тесты): For development (tests):
``` ```
.venv\Scripts\pip install -r requirements-dev.txt .venv\Scripts\pip install -r requirements-dev.txt
``` ```
## Что делает ## What it does
``` ```
.venv\Scripts\python -m sort_ym auth # войти в аккаунт Яндекса через device-flow и сохранить токен в .token .venv\Scripts\python -m sort_ym auth # log in to a Yandex account via device-flow and save the token to .token
.venv\Scripts\python -m sort_ym fetch # скачать лайкнутые треки (cache/tracks.json) и жанры их артистов (cache/artist_genres.json); аккаунт не меняет .venv\Scripts\python -m sort_ym fetch # download liked tracks (cache/tracks.json) and their artists' genres (cache/artist_genres.json); does not change the account
.venv\Scripts\python -m sort_ym report # посчитать жанр и язык каждого трека, сохранить out/report.csv (аккаунт не меняет) .venv\Scripts\python -m sort_ym report # compute genre and language per track, save out/report.csv (does not change the account)
.venv\Scripts\python -m sort_ym apply --limit 20 --yes # пробный запуск: создать плейлисты и добавить в них первые 20 треков .venv\Scripts\python -m sort_ym apply --limit 20 --yes # dry run: create playlists and add the first 20 tracks
.venv\Scripts\python -m sort_ym apply --yes # создать плейлисты и добавить в них все треки (меняет аккаунт) .venv\Scripts\python -m sort_ym apply --yes # create playlists and add all tracks (changes the account)
``` ```
При повторных запусках `fetch` докачивает только новые треки, а `apply` не дублирует уже добавленные треки. On repeated runs `fetch` only downloads new tracks, and `apply` does not duplicate tracks already added.
## Полная очистка кэша ## Full cache cleanup
Если требуется начать с чистого листа (переполучить все данные с нуля), удалите папку `cache/`: To start over from scratch (re-fetch everything), delete the `cache/` folder:
``` ```
Remove-Item -Recurse -Force cache Remove-Item -Recurse -Force cache
``` ```
Кэш состоит из четырёх файлов: The cache consists of four files:
- `cache/tracks.json` — список лайкнутых треков и их метаданные (включая id артистов); - `cache/tracks.json` — liked tracks and their metadata (including artist ids);
- `cache/artist_genres.json` — жанровые теги каждого артиста (может быть несколько на артиста); - `cache/artist_genres.json` — genre tags for each artist (an artist can have several);
- `cache/genre_catalog.json` — снимок дерева жанров Яндекса на момент последнего запуска; - `cache/genre_catalog.json` — a snapshot of Yandex's genre tree as of the last run;
- `cache/lyrics_lang.json` — определённые языки текстов для каждого трека. - `cache/lyrics_lang.json` — detected lyrics language per track.
Удаление `cache/` заставит `fetch` и `report` переполучить всё с нуля при следующем запуске. Deleting `cache/` makes `fetch` and `report` re-fetch everything from scratch on the next run.
Также можно удалить сгенерированный отчёт: You can also delete the generated report:
``` ```
Remove-Item -Recurse -Force out Remove-Item -Recurse -Force out
``` ```
Это просто результаты анализа, их удаление не повлияет на следующий запуск `report`. It's just analysis output — deleting it doesn't affect the next `report` run.
**Важно:** файл `.token` (OAuth-токен) не входит в кэш и не удаляется при очистке `cache/`. Очистка кэша вас не разлогирует. Если нужна повторная авторизация, удалите `.token` отдельно: **Important:** the `.token` file (OAuth token) is not part of the cache and is not removed by clearing `cache/`. Clearing the cache does not log you out. To force re-authentication, delete `.token` separately:
``` ```
Remove-Item .token Remove-Item .token
``` ```
## Как определяется жанр и язык ## How genre and language are determined
- **Жанр** определяется на **под-жанровом** уровне (Рок, Панк, Инди, Альтернатива, Метал, ..., - **Genre** is determined at the **sub-genre** level (Rock, Punk, Indie, Alternative, Metal, ...,
не только 11 крупных групп), комбинируя два сигнала: not just the 11 top-level buckets), combining two signals:
1. Жанр **альбома** трека (`genre_raw`) — сигнал по конкретному треку. 1. The track's **album** genre (`genre_raw`) — a per-track signal.
2. Жанры **артиста** (`Artist.genres`, может быть несколько тегов на артиста, точнее одного 2. The **artist's** genres (`Artist.genres`, an artist can have several tags, more precise than
альбомного жанра) — сигнал по исполнителю в целом. a single album genre) — a per-artist signal.
Если сигналы совпадают — берётся под-жанр как есть. При конфликте: если альбомный тег общий If the signals agree, the sub-genre is used as is. On conflict: if the album tag is generic
("зонтичный", вроде `rock`/`alternative`/`pop` — типичный дефолт Яндекса низкой уверенности) — ("umbrella-like", such as `rock`/`alternative`/`pop` — Yandex's typical low-confidence default),
побеждает жанр артиста; если альбомный тег точный/специфичный (`numetal`, `dnb`, `shanson`) — the artist's genre wins; if the album tag is precise/specific (`numetal`, `dnb`, `shanson`), it
побеждает он, потому что это, скорее всего, реальный эксперимент артиста в другом жанре, а не шум. wins, because it's more likely a genuine genre experiment by the artist rather than noise. This
Так один и тот же артист с разными по духу треками (например, рОк-трек и панк-трек) раскладывается way the same artist with tracks of different character (say, a rock track and a punk track) ends
по разным плейлистам, а не весь скопом по одному "основному" жанру. up in different playlists instead of everything being lumped into one "main" genre.
Под-жанровые группы меньше `small_group_min` (по умолчанию 12 треков, `config.toml`) схлопываются Sub-genre groups smaller than `small_group_min` (12 tracks by default, `config.toml`) collapse
в родительскую крупную корзину (Поп, Рок, Метал, Рэп, Электроника, Джаз и блюз, Классика, Фолк и into the parent top-level bucket (Pop, Rock, Metal, Rap, Electronic, Jazz & Blues, Classical,
world, Инди и альтернатива, Саундтреки, Разное), чтобы не плодить плейлисты на 1-2 трека. Folk & World, Indie & Alternative, Soundtracks, Other) to avoid playlists with 1-2 tracks.
Реализация — `sort_ym/genres.py` (`classify_track`, `ROOT_BUCKET`) — таблица корней легко Implementation — `sort_ym/genres.py` (`classify_track`, `ROOT_BUCKET`) — the root table is easy
дополняется, если Яндекс вернёт незнакомый корневой жанр. to extend if Yandex returns an unfamiliar root genre.
- **Язык** — гибрид: - **Language** is a hybrid:
1. Если у трека есть текст песни — реальный язык из API (`ru` → RU, иначе → INT). 1. If the track has lyrics — the real language from the API (`ru` → RU, otherwise → INT).
2. Иначе — подсказка по жанру (`rusrap`, `shanson`, `bard`, ...). 2. Otherwise — a genre-based hint (`rusrap`, `shanson`, `bard`, ...).
3. Иначе — по алфавиту названия/исполнителя (кириллица/латиница). 3. Otherwise — an alphabet heuristic on the title/artist (Cyrillic/Latin).
4. Если ничего не сработало — трек попадает в `... — Не определено`. 4. If nothing matched — the track goes into `... — Undetermined`.
После `report` в консоли также печатается список нераспознанных жанров (попавших в «Разное»), After `report`, the console also prints a list of unrecognized genres (that fell into "Other") so
чтобы можно было дополнить таблицу в `sort_ym/genres.py`. the table in `sort_ym/genres.py` can be extended.
## Handy things ## Handy things
- Первый прогон `apply` делайте с `--limit 20 --yes` и проверьте результат в приложении, прежде - Run `apply` first with `--limit 20 --yes` and check the result in the app before running it
чем запускать на все ~2000 треков. against all ~2000 tracks.
- Настройки задержек между запросами — в `config.toml` (`[throttle]`). - Delays between requests are configured in `config.toml` (`[throttle]`).
- Минимальный размер под-жанрового плейлиста — `config.toml` (`[classify] small_group_min`). - The minimum sub-genre playlist size is `config.toml` (`[classify] small_group_min`).
## Риски (неофициальный API) ## Risks (unofficial API)
Официального API для личной библиотеки Яндекс.Музыки нет — инструмент использует There is no official API for personal Yandex Music library access — this tool uses the
неофициальную библиотеку `yandex-music`. OAuth-токен в `.token` даёт полный доступ к аккаунту unofficial `yandex-music` library. The OAuth token in `.token` grants full access to your Yandex
Яндекса — храните его локально и не публикуйте. Инструмент выполняет только безопасные действия account — keep it local and never publish it. The tool only performs safe actions (reading likes,
(чтение лайков, создание плейлистов, добавление треков) и ничего не удаляет. creating playlists, adding tracks) and never deletes anything.
## For developers ## For developers
Python 3.11+, `yandex-music==3.0.0`, стандартная библиотека (`tomllib`, `csv`, `unicodedata`). Python 3.11+, `yandex-music==3.0.0`, standard library (`tomllib`, `csv`, `unicodedata`).
``` ```
.venv\Scripts\python -m pytest tests/ -v .venv\Scripts\python -m pytest tests/ -v
@ -127,4 +129,12 @@ Python 3.11+, `yandex-music==3.0.0`, стандартная библиотека
.venv\Scripts\python -m sort_ym apply --limit 20 --yes .venv\Scripts\python -m sort_ym apply --limit 20 --yes
``` ```
Структура кода и поток данных — в [ARCHITECTURE.md](ARCHITECTURE.md). Code structure and data flow are in [ARCHITECTURE.md](ARCHITECTURE.md).
## Credits
Andrew Pimenov
## License
[MIT License](LICENSE)

140
README.ru.md Normal file
View file

@ -0,0 +1,140 @@
[English](README.md)
# Sort YM
Раскладывает лайкнутые треки Яндекс.Музыки по плейлистам вида `Жанр — RU` / `Жанр — INT`.
```
$ python -m sort_ym report
Распределение по плейлистам:
Рок — INT: 143
Рок — RU: 287
Рэп — RU: 512
...
Отчёт сохранён: out/report.csv
```
## Установка
Требования: Python 3.11+ (используется `tomllib` из стандартной библиотеки), Windows/Linux/macOS, доступ в интернет.
```
python -m venv .venv
.venv\Scripts\pip install -r requirements.txt
```
Для разработки (тесты):
```
.venv\Scripts\pip install -r requirements-dev.txt
```
## Что делает
```
.venv\Scripts\python -m sort_ym auth # войти в аккаунт Яндекса через device-flow и сохранить токен в .token
.venv\Scripts\python -m sort_ym fetch # скачать лайкнутые треки (cache/tracks.json) и жанры их артистов (cache/artist_genres.json); аккаунт не меняет
.venv\Scripts\python -m sort_ym report # посчитать жанр и язык каждого трека, сохранить out/report.csv (аккаунт не меняет)
.venv\Scripts\python -m sort_ym apply --limit 20 --yes # пробный запуск: создать плейлисты и добавить в них первые 20 треков
.venv\Scripts\python -m sort_ym apply --yes # создать плейлисты и добавить в них все треки (меняет аккаунт)
```
При повторных запусках `fetch` докачивает только новые треки, а `apply` не дублирует уже добавленные треки.
## Полная очистка кэша
Если требуется начать с чистого листа (переполучить все данные с нуля), удалите папку `cache/`:
```
Remove-Item -Recurse -Force cache
```
Кэш состоит из четырёх файлов:
- `cache/tracks.json` — список лайкнутых треков и их метаданные (включая id артистов);
- `cache/artist_genres.json` — жанровые теги каждого артиста (может быть несколько на артиста);
- `cache/genre_catalog.json` — снимок дерева жанров Яндекса на момент последнего запуска;
- `cache/lyrics_lang.json` — определённые языки текстов для каждого трека.
Удаление `cache/` заставит `fetch` и `report` переполучить всё с нуля при следующем запуске.
Также можно удалить сгенерированный отчёт:
```
Remove-Item -Recurse -Force out
```
Это просто результаты анализа, их удаление не повлияет на следующий запуск `report`.
**Важно:** файл `.token` (OAuth-токен) не входит в кэш и не удаляется при очистке `cache/`. Очистка кэша вас не разлогирует. Если нужна повторная авторизация, удалите `.token` отдельно:
```
Remove-Item .token
```
## Как определяется жанр и язык
- **Жанр** определяется на **под-жанровом** уровне (Рок, Панк, Инди, Альтернатива, Метал, ...,
не только 11 крупных групп), комбинируя два сигнала:
1. Жанр **альбома** трека (`genre_raw`) — сигнал по конкретному треку.
2. Жанры **артиста** (`Artist.genres`, может быть несколько тегов на артиста, точнее одного
альбомного жанра) — сигнал по исполнителю в целом.
Если сигналы совпадают — берётся под-жанр как есть. При конфликте: если альбомный тег общий
("зонтичный", вроде `rock`/`alternative`/`pop` — типичный дефолт Яндекса низкой уверенности) —
побеждает жанр артиста; если альбомный тег точный/специфичный (`numetal`, `dnb`, `shanson`) —
побеждает он, потому что это, скорее всего, реальный эксперимент артиста в другом жанре, а не шум.
Так один и тот же артист с разными по духу треками (например, рОк-трек и панк-трек) раскладывается
по разным плейлистам, а не весь скопом по одному "основному" жанру.
Под-жанровые группы меньше `small_group_min` (по умолчанию 12 треков, `config.toml`) схлопываются
в родительскую крупную корзину (Поп, Рок, Метал, Рэп, Электроника, Джаз и блюз, Классика, Фолк и
world, Инди и альтернатива, Саундтреки, Разное), чтобы не плодить плейлисты на 1-2 трека.
Реализация — `sort_ym/genres.py` (`classify_track`, `ROOT_BUCKET`) — таблица корней легко
дополняется, если Яндекс вернёт незнакомый корневой жанр.
- **Язык** — гибрид:
1. Если у трека есть текст песни — реальный язык из API (`ru` → RU, иначе → INT).
2. Иначе — подсказка по жанру (`rusrap`, `shanson`, `bard`, ...).
3. Иначе — по алфавиту названия/исполнителя (кириллица/латиница).
4. Если ничего не сработало — трек попадает в `... — Не определено`.
После `report` в консоли также печатается список нераспознанных жанров (попавших в «Разное»),
чтобы можно было дополнить таблицу в `sort_ym/genres.py`.
## Handy things
- Первый прогон `apply` делайте с `--limit 20 --yes` и проверьте результат в приложении, прежде
чем запускать на все ~2000 треков.
- Настройки задержек между запросами — в `config.toml` (`[throttle]`).
- Минимальный размер под-жанрового плейлиста — `config.toml` (`[classify] small_group_min`).
## Риски (неофициальный API)
Официального API для личной библиотеки Яндекс.Музыки нет — инструмент использует
неофициальную библиотеку `yandex-music`. OAuth-токен в `.token` даёт полный доступ к аккаунту
Яндекса — храните его локально и не публикуйте. Инструмент выполняет только безопасные действия
(чтение лайков, создание плейлистов, добавление треков) и ничего не удаляет.
## For developers
Python 3.11+, `yandex-music==3.0.0`, стандартная библиотека (`tomllib`, `csv`, `unicodedata`).
```
.venv\Scripts\python -m pytest tests/ -v
.venv\Scripts\python -m sort_ym auth
.venv\Scripts\python -m sort_ym fetch
.venv\Scripts\python -m sort_ym report
.venv\Scripts\python -m sort_ym apply --limit 20 --yes
```
Структура кода и поток данных — в [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md).
## Credits
Andrew Pimenov
## License
[MIT License](LICENSE)