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/
__pycache__/
*.pyc
.pytest_cache/
.token
cache/
out/
.claude/
plans/
.aider*
.cursorrules
bugs.md
*_ai_notes*
llm_context*

View file

@ -1,107 +1,109 @@
# Архитектура
[Русский](ARCHITECTURE.ru.md)
Консольная утилита на Python, которая читает лайкнутые треки Яндекс.Музыки и раскладывает их
по плейлистам вида `Жанр — RU` / `Жанр — INT`, используя неофициальную библиотеку `yandex-music`.
# Architecture
## Структура
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/
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
config.py — reads config.toml (throttle, small_group_min, cache/token paths)
auth.py — OAuth device-flow, token storage in .token
ymclient.py — Client construction, batching lists, retrying network errors
fetch.py — likes -> full Track -> cache/tracks.json; track artists ->
client.artists() -> cache/artist_genres.json (both fetched incrementally)
genres.py — the real Yandex genre tree; classify_track — resolves track genre and artist
genres into a single sub-genre (root_id); ROOT_BUCKET — sub-genre -> 11 top-level buckets
language.py — hybrid language detection (lyrics API / genre hint / alphabet)
classify.py — (sub-genre | top-level bucket, language) -> playlist name
report.py — two-pass report row building, writes out/report.csv, summaries
apply.py — playlist creation + idempotent track insertion
cli.py — auth / fetch / report / apply commands
tests/
test_genres.py — classify_track, dominant, bucket_for, дерево жанров
test_language.py — детекция языка
test_report.py — двухпроходное схлопывание мелких под-жанровых групп
test_fetch.py, test_apply.py — кэш треков/артистов, идемпотентность применения
test_genres.py — classify_track, dominant, bucket_for, genre tree
test_language.py — language detection
test_report.py — two-pass collapsing of small sub-genre groups
test_fetch.py, test_apply.py — track/artist cache, apply idempotency
cache/ — кэш API (в .gitignore)
out/ — отчёты (в .gitignore)
cache/ — API cache (in .gitignore)
out/ — reports (in .gitignore)
```
## Поток данных
## Data flow
```
auth → .token
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
→ language.fetch_api_languages() → cache/lyrics_lang.json
→ build_rows(): проход 1 — classify_track() на каждый трек (genre_raw + жанры артиста)
проход 2 — группы < small_group_min схлопываются в крупную корзину
→ out/report.csv (dry-run, аккаунт не меняется)
→ build_rows(): pass 1 — classify_track() per track (genre_raw + artist genres)
pass 2 — groups < small_group_min collapse into the top-level bucket
→ out/report.csv (dry-run, account unchanged)
apply --yes → users_playlists_create / users_playlists_insert_track
```
## Ключевые решения
## Key decisions
- **Жанр трека** доступен только через альбом (`Track.albums[0].genre`), у самого трека поля
жанра нет — это одна строка на трек, часто общая (`rock`, `alternative`). Раскладка по корзинам
сделана через реальное дерево `client.genres()` (родитель → под-жанры), а не через угаданный
список слагов — таблица `ROOT_BUCKET` в `genres.py` опирается на подтверждённые id, а не на
предположения. Жанры без совпадения падают в корзину `other` и печатаются в выводе `report` для
ручного дополнения таблицы.
- **Track genre** is only available through the album (`Track.albums[0].genre`) — the track itself
has no genre field, so it's one string per track, often generic (`rock`, `alternative`). Bucketing
is done through the real `client.genres()` tree (parent → sub-genres), not a guessed slug list —
the `ROOT_BUCKET` table in `genres.py` relies on confirmed ids, not assumptions. Genres without a
match fall into the `other` bucket and are printed in `report`'s output for manual table extension.
- **Под-жанровая классификация (`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`, ...) → побеждает он — вероятный настоящий эксперимент артиста в другом
жанре, а не шум, который стоило бы поправить.
- **Sub-genre classification (`genres.classify_track`)**: track genre is one string per album and
often generic. An artist (`client.artists()`, a batch endpoint) can have several tags, more
precise than a single album genre. `classify_track(genre_raw, artist_genre_lists, catalog)`
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
up in different playlists:
1. Signals agree (track genre is among the artist's genres) → use it — granular per track.
2. Track has no genre → use the most frequent (`dominant`) genre among the track's artists.
3. Artist(s) have no tags → use the track's genre.
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
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`: сначала считаются размеры
групп `(fine, lang)`, затем группы меньше `config.small_group_min` переезжают в родительскую
крупную корзину через `genres.coarse_of(fine)` (тонкая обёртка над `ROOT_BUCKET`). Проход
одиночный (не итеративный) — `coarse_of` детерминирована от `fine`, поэтому пересчёта размеров и
зацикливания нет.
The result (`fine`) is collapsed in a two-pass process in `report.build_rows`: first the sizes of
`(fine, lang)` groups are counted, then groups smaller than `config.small_group_min` move to the
parent top-level bucket via `genres.coarse_of(fine)` (a thin wrapper over `ROOT_BUCKET`). The pass
is single (not iterative) — `coarse_of` is deterministic from `fine`, so there's no re-counting of
sizes or looping.
- **Язык** определяется гибридно, в порядке приоритета:
1. `client.track_supplement(track_id).lyrics.text_language` — единственное поле в библиотеке
с реальным языком текста песни. Оно есть только в устаревшем (deprecated) методе
`track_supplement`, а не в новом `tracks_lyrics()` — тот текст возвращает, но язык не отдаёт.
Используется сознательно, несмотря на пометку deprecated в самой библиотеке.
2. Подсказка по слагу жанра (`rusrap`, `shanson`, `bard`, ...).
3. Эвристика по алфавиту названия/исполнителя (кириллица vs латиница, `unicodedata`).
4. Иначе — `UNKNOWN` → отдельный плейлист «Не определено».
- **Language** is determined hybrid, in priority order:
1. `client.track_supplement(track_id).lyrics.text_language` — the only field in the library with
the actual lyrics language. It's only present in the deprecated `track_supplement` method, not
in the newer `tracks_lyrics()` — that one returns the text but not the language. Used
deliberately despite the library's own deprecation notice.
2. A genre-slug hint (`rusrap`, `shanson`, `bard`, ...).
3. An alphabet heuristic on the title/artist (Cyrillic vs Latin, `unicodedata`).
4. Otherwise — `UNKNOWN` → a separate "Undetermined" playlist.
- **Идентификаторы треков**: у трека есть composite `track_id` (`"id:album_id"`, используется как
ключ кэша и для сверки с уже добавленными треками в плейлисте) и отдельные `id`/`album_id`
(нужны как раздельные параметры для `track_supplement`, `tracks_lyrics` и
`users_playlists_insert_track`).
- **Track identifiers**: a track has a composite `track_id` (`"id:album_id"`, used as the cache key
and to check against tracks already in a playlist) and separate `id`/`album_id` (needed as
separate parameters for `track_supplement`, `tracks_lyrics`, and `users_playlists_insert_track`).
- **Идемпотентность `apply`**: перед добавлением треков в плейлист читается его текущий состав
(`users_playlists(kind)`), уже присутствующие треки пропускаются. Повторный запуск `apply`
безопасен и не создаёт дублей. Лайки не трогаются, ничего не удаляется.
- **`apply` idempotency**: before adding tracks to a playlist, its current contents are read
(`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.
- **Троттлинг и резюмируемость**: все сетевые циклы (загрузка треков, языков) делают паузу между
запросами (`config.toml`, `[throttle]`) и пишут кэш атомарно (temp-файл + rename) после каждого
батча — прерванный прогон можно просто перезапустить, уже полученное не запрашивается повторно.
- **Throttling and resumability**: every network loop (fetching tracks, languages) pauses between
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-токен аккаунта (не коммитится).
- `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` — под-жанр до возможного схлопывания в крупную корзину).
- `.token` — the account's OAuth token (not committed).
- `cache/tracks.json` — liked track metadata, including `artist_ids` (the track's artist ids).
- `cache/artist_genres.json` — `{artist_id: {name, genres: [slug, ...]}}`, one or more genres per
artist.
- `cache/genre_catalog.json` — a flat snapshot of Yandex's genre tree (id -> title, root_id).
- `cache/lyrics_lang.json` — lyrics language per track (id -> language code | null).
- `out/report.csv` — a preview of the playlist breakdown before `apply` (including the
`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
Раскладывает лайкнутые треки Яндекс.Музыки по плейлистам вида `Жанр — RU` / `Жанр — INT`.
Sorts your Yandex Music liked tracks into playlists named `Genre — RU` / `Genre — INT`.
```
$ python -m sort_ym report
Распределение по плейлистам:
Рок — INT: 143
Рок — RU: 287
Рэп — RU: 512
Playlist breakdown:
Rock — INT: 143
Rock — RU: 287
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
.venv\Scripts\pip install -r requirements.txt
```
Для разработки (тесты):
For development (tests):
```
.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 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 # создать плейлисты и добавить в них все треки (меняет аккаунт)
.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 # 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 # 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 # dry run: create playlists and add the first 20 tracks
.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
```
Кэш состоит из четырёх файлов:
- `cache/tracks.json` — список лайкнутых треков и их метаданные (включая id артистов);
- `cache/artist_genres.json` — жанровые теги каждого артиста (может быть несколько на артиста);
- `cache/genre_catalog.json` — снимок дерева жанров Яндекса на момент последнего запуска;
- `cache/lyrics_lang.json` — определённые языки текстов для каждого трека.
The cache consists of four files:
- `cache/tracks.json` — liked tracks and their metadata (including artist ids);
- `cache/artist_genres.json` — genre tags for each artist (an artist can have several);
- `cache/genre_catalog.json` — a snapshot of Yandex's genre tree as of the last run;
- `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
```
Это просто результаты анализа, их удаление не повлияет на следующий запуск `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
```
## Как определяется жанр и язык
## How genre and language are determined
- **Жанр** определяется на **под-жанровом** уровне (Рок, Панк, Инди, Альтернатива, Метал, ...,
не только 11 крупных групп), комбинируя два сигнала:
1. Жанр **альбома** трека (`genre_raw`) — сигнал по конкретному треку.
2. Жанры **артиста** (`Artist.genres`, может быть несколько тегов на артиста, точнее одного
альбомного жанра) — сигнал по исполнителю в целом.
- **Genre** is determined at the **sub-genre** level (Rock, Punk, Indie, Alternative, Metal, ...,
not just the 11 top-level buckets), combining two signals:
1. The track's **album** genre (`genre_raw`) — a per-track signal.
2. The **artist's** genres (`Artist.genres`, an artist can have several tags, more precise than
a single album genre) — a per-artist signal.
Если сигналы совпадают — берётся под-жанр как есть. При конфликте: если альбомный тег общий
("зонтичный", вроде `rock`/`alternative`/`pop` — типичный дефолт Яндекса низкой уверенности) —
побеждает жанр артиста; если альбомный тег точный/специфичный (`numetal`, `dnb`, `shanson`) —
побеждает он, потому что это, скорее всего, реальный эксперимент артиста в другом жанре, а не шум.
Так один и тот же артист с разными по духу треками (например, рОк-трек и панк-трек) раскладывается
по разным плейлистам, а не весь скопом по одному "основному" жанру.
If the signals agree, the sub-genre is used as is. On conflict: if the album tag is generic
("umbrella-like", such as `rock`/`alternative`/`pop` — Yandex's typical low-confidence default),
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`) схлопываются
в родительскую крупную корзину (Поп, Рок, Метал, Рэп, Электроника, Джаз и блюз, Классика, Фолк и
world, Инди и альтернатива, Саундтреки, Разное), чтобы не плодить плейлисты на 1-2 трека.
Реализация — `sort_ym/genres.py` (`classify_track`, `ROOT_BUCKET`) — таблица корней легко
дополняется, если Яндекс вернёт незнакомый корневой жанр.
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,
Folk & World, Indie & Alternative, Soundtracks, Other) to avoid playlists with 1-2 tracks.
Implementation — `sort_ym/genres.py` (`classify_track`, `ROOT_BUCKET`) — the root table is easy
to extend if Yandex returns an unfamiliar root genre.
- **Язык** — гибрид:
1. Если у трека есть текст песни — реальный язык из API (`ru` → RU, иначе → INT).
2. Иначе — подсказка по жанру (`rusrap`, `shanson`, `bard`, ...).
3. Иначе — по алфавиту названия/исполнителя (кириллица/латиница).
4. Если ничего не сработало — трек попадает в `... — Не определено`.
- **Language** is a hybrid:
1. If the track has lyrics — the real language from the API (`ru` → RU, otherwise → INT).
2. Otherwise — a genre-based hint (`rusrap`, `shanson`, `bard`, ...).
3. Otherwise — an alphabet heuristic on the title/artist (Cyrillic/Latin).
4. If nothing matched — the track goes into `... — Undetermined`.
После `report` в консоли также печатается список нераспознанных жанров (попавших в «Разное»),
чтобы можно было дополнить таблицу в `sort_ym/genres.py`.
After `report`, the console also prints a list of unrecognized genres (that fell into "Other") so
the table in `sort_ym/genres.py` can be extended.
## Handy things
- Первый прогон `apply` делайте с `--limit 20 --yes` и проверьте результат в приложении, прежде
чем запускать на все ~2000 треков.
- Настройки задержек между запросами — в `config.toml` (`[throttle]`).
- Минимальный размер под-жанрового плейлиста — `config.toml` (`[classify] small_group_min`).
- Run `apply` first with `--limit 20 --yes` and check the result in the app before running it
against all ~2000 tracks.
- Delays between requests are configured in `config.toml` (`[throttle]`).
- The minimum sub-genre playlist size is `config.toml` (`[classify] small_group_min`).
## Риски (неофициальный API)
## Risks (unofficial API)
Официального API для личной библиотеки Яндекс.Музыки нет — инструмент использует
неофициальную библиотеку `yandex-music`. OAuth-токен в `.token` даёт полный доступ к аккаунту
Яндекса — храните его локально и не публикуйте. Инструмент выполняет только безопасные действия
(чтение лайков, создание плейлистов, добавление треков) и ничего не удаляет.
There is no official API for personal Yandex Music library access — this tool uses the
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
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
@ -127,4 +129,12 @@ Python 3.11+, `yandex-music==3.0.0`, стандартная библиотека
.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)