mirror of
https://github.com/Horizontal42/Sort_YM.git
synced 2026-11-10 22:41:59 +03:00
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:
parent
ac955ea35a
commit
544765db3f
6 changed files with 429 additions and 141 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -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*
|
||||||
|
|
|
||||||
160
ARCHITECTURE.md
160
ARCHITECTURE.md
|
|
@ -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
109
ARCHITECTURE.ru.md
Normal 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
21
LICENSE
Normal 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
134
README.md
|
|
@ -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
140
README.ru.md
Normal 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)
|
||||||
Loading…
Add table
Reference in a new issue