diff --git a/.gitignore b/.gitignore index 0267313..3f0cb0c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,14 @@ .venv/ __pycache__/ *.pyc +.pytest_cache/ .token cache/ out/ .claude/ plans/ +.aider* +.cursorrules +bugs.md +*_ai_notes* +llm_context* diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ce4dc76..c3f9c0a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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). diff --git a/ARCHITECTURE.ru.md b/ARCHITECTURE.ru.md new file mode 100644 index 0000000..f343bfc --- /dev/null +++ b/ARCHITECTURE.ru.md @@ -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` — под-жанр до возможного схлопывания в крупную корзину). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a429eb6 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 9d9f422..b82b2df 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..87e0e59 --- /dev/null +++ b/README.ru.md @@ -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)