fix docs to match code: dedupe command, deletion claim, analysis field names

README and ARCHITECTURE were missing the dedupe command and claimed the tool
never deletes anything, but dedupe --yes removes duplicate playlist entries.
lyrics_analysis.json field list (tone/description) didn't match the actual
schema (register/concreteness/stance/summary/resonance/key_line/confidence).
lyrics_text.json is also written by report/apply as a side effect of language
detection, not only by the lyrics command. Sub-genre count corrected 34->36.
This commit is contained in:
Andrew Pimenov 2026-08-23 19:05:23 +03:00
parent b19eadd744
commit f36526a9c9
5 changed files with 69 additions and 26 deletions

View file

@ -22,7 +22,8 @@ sort_ym/
genres into a single sub-genre (root_id); ROOT_BUCKET — sub-genre -> 11 top-level buckets 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) language.py — hybrid language detection (lyrics API / genre hint / alphabet)
lyrics.py — text lyrics of RU-tracks: track_supplement().lyrics.full_lyrics -> lyrics.py — text lyrics of RU-tracks: track_supplement().lyrics.full_lyrics ->
cache/lyrics_text.json (incrementally, atomic-write) cache/lyrics_text.json (incrementally, atomic-write); the same file is also
populated as a side effect of language.fetch_api_languages on report/apply
analyze.py — lyric analysis via local Ollama (/api/chat, think + JSON Schema) -> analyze.py — lyric analysis via local Ollama (/api/chat, think + JSON Schema) ->
cache/lyrics_analysis.json (written after each track) cache/lyrics_analysis.json (written after each track)
classify.py — (sub-genre | top-level bucket, language) -> playlist name; with_label — source classify.py — (sub-genre | top-level bucket, language) -> playlist name; with_label — source
@ -32,8 +33,8 @@ sort_ym/
digest.py — aggregated library summary (top artists, genres, decades, top albums) digest.py — aggregated library summary (top artists, genres, decades, top albums)
-> out/digest.md, fully offline without --source; Wording/playlist_wording — -> out/digest.md, fully offline without --source; Wording/playlist_wording —
digest text for likes vs an arbitrary playlist digest text for likes vs an arbitrary playlist
cli.py — auth / fetch / report / apply / dedupe / digest commands; --source/--label cli.py — auth / fetch / report / apply / dedupe / digest / lyrics / analyze commands;
on report/apply/digest (see "Key decisions") --source/--label on report/apply/digest (see "Key decisions")
tests/ tests/
test_genres.py — classify_track, dominant, bucket_for, genre tree test_genres.py — classify_track, dominant, bucket_for, genre tree
@ -64,6 +65,7 @@ report → genres.load_or_fetch_catalog() → cache/genre_catalog.json
pass 2 — groups < small_group_min collapse into the top-level bucket pass 2 — groups < small_group_min collapse into the top-level bucket
→ out/report.csv (dry-run, account unchanged) → 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
dedupe --yes → users_playlists(kind) → find_duplicate_ranges() → users_playlists_delete_track
digest → genres.load_catalog() + language.load_lang_cache() (cache only, no network) digest → genres.load_catalog() + language.load_lang_cache() (cache only, no network)
→ build_rows() → out/digest.md → build_rows() → out/digest.md
lyrics → ru_lyric_track_ids() → client.track_supplement() → cache/lyrics_text.json lyrics → ru_lyric_track_ids() → client.track_supplement() → cache/lyrics_text.json
@ -154,7 +156,15 @@ report/apply/digest --source <url> → source.parse_playlist_url() → (user_id,
- **`apply` idempotency**: before adding tracks to a playlist, its current contents are read - **`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 (`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. creates no duplicates. Likes are never touched, and `apply_classification` itself never deletes
anything — the only deletion in `apply.py` is `dedupe_playlists` (see below), a separate,
explicitly-invoked path.
- **`dedupe`**: a separate cleanup pass, not run as part of `apply`. Reads a playlist's current
contents (`users_playlists(kind)`), finds consecutive/duplicate insertions of the same track via
`find_duplicate_ranges`, and — only with `dedupe --yes` — removes the extras with
`users_playlists_delete_track`. Without `--yes` it's a dry run that only reports what it would
remove. This is the one place in the codebase that deletes anything from a playlist.
- **Throttling and resumability**: every network loop (fetching tracks, languages) pauses between - **Throttling and resumability**: every network loop (fetching tracks, languages) pauses between
requests (`config.toml`, `[throttle]`) and writes the cache atomically (temp file + rename) after requests (`config.toml`, `[throttle]`) and writes the cache atomically (temp file + rename) after
@ -219,9 +229,13 @@ report/apply/digest --source <url> → source.parse_playlist_url() → (user_id,
`report --extra`-only purpose as `added_at` above. `report --extra`-only purpose as `added_at` above.
- `cache/genre_catalog.json` — a flat snapshot of Yandex's genre tree (id -> title, root_id). - `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). - `cache/lyrics_lang.json` — lyrics language per track (id -> language code | null).
- `cache/lyrics_text.json` — lyrics text per track (numeric id -> text | null, RU tracks only). - `cache/lyrics_text.json` — lyrics text per track (numeric id -> text | null). RU tracks only
unless fetched with `--all-languages`. Written incrementally not just by `lyrics.fetch_lyrics_text`
but also, as a side effect, by `language.fetch_api_languages` on every `report`/`apply` run — it
caches the RU lyrics text it already fetched while resolving the lyrics language.
- `cache/lyrics_analysis.json` — per-track analysis record: `{track_id: {model, prompt_version, - `cache/lyrics_analysis.json` — per-track analysis record: `{track_id: {model, prompt_version,
lyrics_hash, mood, themes, emotional_arc, pov, tone, description, error?}}`. lyrics_hash, analyzed_at, mood, themes, emotional_arc, pov, register, concreteness, stance,
confidence, summary, resonance, key_line, key_line_verified, error?}}`.
- `out/lyrics_digest.md` — full per-track reading grouped by primary mood. - `out/lyrics_digest.md` — full per-track reading grouped by primary mood.
- `config.toml` — includes `[ollama]` block with `host`, `model`, `prompt_version`, `timeout`, - `config.toml` — includes `[ollama]` block with `host`, `model`, `prompt_version`, `timeout`,
`keep_alive`. `keep_alive`.

View file

@ -22,7 +22,8 @@ sort_ym/
артиста в один под-жанр (root_id); ROOT_BUCKET — под-жанр -> 11 крупных корзин артиста в один под-жанр (root_id); ROOT_BUCKET — под-жанр -> 11 крупных корзин
language.py — гибридная детекция языка (API текста / жанр-подсказка / алфавит) language.py — гибридная детекция языка (API текста / жанр-подсказка / алфавит)
lyrics.py — тексты песен RU-треков: track_supplement().lyrics.full_lyrics -> lyrics.py — тексты песен RU-треков: track_supplement().lyrics.full_lyrics ->
cache/lyrics_text.json (инкрементально, atomic-write) cache/lyrics_text.json (инкрементально, atomic-write); тот же файл попутно
пополняет language.fetch_api_languages на report/apply
analyze.py — разбор текстов локальной Ollama (/api/chat, think + JSON Schema) -> analyze.py — разбор текстов локальной Ollama (/api/chat, think + JSON Schema) ->
cache/lyrics_analysis.json (запись после каждого трека) cache/lyrics_analysis.json (запись после каждого трека)
classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста; with_label — суффикс метки classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста; with_label — суффикс метки
@ -32,8 +33,8 @@ sort_ym/
digest.py — агрегированная сводка библиотеки (топ-исполнители, жанры, десятилетия, digest.py — агрегированная сводка библиотеки (топ-исполнители, жанры, десятилетия,
топ-альбомы) -> out/digest.md, полностью офлайн без --source; Wording/ топ-альбомы) -> out/digest.md, полностью офлайн без --source; Wording/
playlist_wording — тексты дайджеста для лайков vs произвольного плейлиста playlist_wording — тексты дайджеста для лайков vs произвольного плейлиста
cli.py — команды auth / fetch / report / apply / dedupe / digest; --source/--label cli.py — команды auth / fetch / report / apply / dedupe / digest / lyrics / analyze;
на report/apply/digest (см. "Ключевые решения") --source/--label на report/apply/digest (см. "Ключевые решения")
tests/ tests/
test_genres.py — classify_track, dominant, bucket_for, дерево жанров test_genres.py — classify_track, dominant, bucket_for, дерево жанров
@ -65,6 +66,7 @@ report → genres.load_or_fetch_catalog() → cache/genre_catalog.json
проход 2 — группы < small_group_min схлопываются в крупную корзину проход 2 — группы < small_group_min схлопываются в крупную корзину
→ out/report.csv (dry-run, аккаунт не меняется) → out/report.csv (dry-run, аккаунт не меняется)
apply --yes → users_playlists_create / users_playlists_insert_track apply --yes → users_playlists_create / users_playlists_insert_track
dedupe --yes → users_playlists(kind) → find_duplicate_ranges() → users_playlists_delete_track
digest → genres.load_catalog() + language.load_lang_cache() (только кэш, сеть не используется) digest → genres.load_catalog() + language.load_lang_cache() (только кэш, сеть не используется)
→ build_rows() → out/digest.md → build_rows() → out/digest.md
lyrics → ru_lyric_track_ids() → client.track_supplement() → cache/lyrics_text.json lyrics → ru_lyric_track_ids() → client.track_supplement() → cache/lyrics_text.json
@ -155,7 +157,15 @@ report/apply/digest --source <url> → source.parse_playlist_url() → (user_id,
- **Идемпотентность `apply`**: перед добавлением треков в плейлист читается его текущий состав - **Идемпотентность `apply`**: перед добавлением треков в плейлист читается его текущий состав
(`users_playlists(kind)`), уже присутствующие треки пропускаются. Повторный запуск `apply` (`users_playlists(kind)`), уже присутствующие треки пропускаются. Повторный запуск `apply`
безопасен и не создаёт дублей. Лайки не трогаются, ничего не удаляется. безопасен и не создаёт дублей. Лайки не трогаются, и сама `apply_classification` ничего не
удаляет — единственное удаление в `apply.py` делает `dedupe_playlists` (см. ниже), отдельный,
явно вызываемый путь.
- **`dedupe`**: отдельный проход очистки, не часть `apply`. Читает текущий состав плейлиста
(`users_playlists(kind)`), находит повторные вставки одного трека через `find_duplicate_ranges`
и — только при `dedupe --yes` — удаляет лишние записи через `users_playlists_delete_track`. Без
`--yes` это dry-run, который только сообщает, что было бы удалено. Это единственное место в
коде, которое вообще что-то удаляет из плейлиста.
- **Троттлинг и резюмируемость**: все сетевые циклы (загрузка треков, языков) делают паузу между - **Троттлинг и резюмируемость**: все сетевые циклы (загрузка треков, языков) делают паузу между
запросами (`config.toml`, `[throttle]`) и пишут кэш атомарно (temp-файл + rename) после каждого запросами (`config.toml`, `[throttle]`) и пишут кэш атомарно (temp-файл + rename) после каждого
@ -217,9 +227,13 @@ report/apply/digest --source <url> → source.parse_playlist_url() → (user_id,
и `added_at` выше, только для `report --extra`. и `added_at` выше, только для `report --extra`.
- `cache/genre_catalog.json` — плоский снимок дерева жанров Яндекса (id -> title, root_id). - `cache/genre_catalog.json` — плоский снимок дерева жанров Яндекса (id -> title, root_id).
- `cache/lyrics_lang.json` — язык текста песни по треку (id -> language code | null). - `cache/lyrics_lang.json` — язык текста песни по треку (id -> language code | null).
- `cache/lyrics_text.json` — текст песни по треку (numeric id -> текст | null, только RU-треки). - `cache/lyrics_text.json` — текст песни по треку (numeric id -> текст | null). Только RU-треки,
если не загружено через `--all-languages`. Пишется инкрементально не только
`lyrics.fetch_lyrics_text`, но и попутно `language.fetch_api_languages` на каждом `report`/
`apply` — она кэширует RU-текст, который уже получила, определяя язык текста песни.
- `cache/lyrics_analysis.json` — запись анализа на трек: `{track_id: {model, prompt_version, - `cache/lyrics_analysis.json` — запись анализа на трек: `{track_id: {model, prompt_version,
lyrics_hash, mood, themes, emotional_arc, pov, tone, description, error?}}`. lyrics_hash, analyzed_at, mood, themes, emotional_arc, pov, register, concreteness, stance,
confidence, summary, resonance, key_line, key_line_verified, error?}}`.
- `out/lyrics_digest.md` — полный построчный разбор, сгруппированный по основному настроению. - `out/lyrics_digest.md` — полный построчный разбор, сгруппированный по основному настроению.
- `config.toml` — включает блок `[ollama]` с полями `host`, `model`, `prompt_version`, `timeout`, - `config.toml` — включает блок `[ollama]` с полями `host`, `model`, `prompt_version`, `timeout`,
`keep_alive`. `keep_alive`.

View file

@ -45,9 +45,11 @@ All commands below use the Windows `.venv\Scripts\...` path — on Linux/macOS s
.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 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 --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) .venv\Scripts\python -m sort_ym apply --yes # create playlists and add all tracks (changes the account)
.venv\Scripts\python -m sort_ym dedupe # dry run: report duplicate track insertions within playlists
.venv\Scripts\python -m sort_ym dedupe --yes # remove those duplicates (changes the account)
.venv\Scripts\python -m sort_ym digest # library summary (top artists, genres) to out/digest.md - to paste into an LLM chat; no network used .venv\Scripts\python -m sort_ym digest # library summary (top artists, genres) to out/digest.md - to paste into an LLM chat; no network used
.venv\Scripts\python -m sort_ym lyrics # download the lyrics text of Russian-language tracks (cache/lyrics_text.json); does not change the account .venv\Scripts\python -m sort_ym lyrics # download the lyrics text of Russian-language tracks (cache/lyrics_text.json); does not change the account
.venv\Scripts\python -m sort_ym analyze # analyze those lyrics with a local Ollama model (cache/lyrics_analysis.json); no internet, runs for hours, resumable .venv\Scripts\python -m sort_ym analyze # analyze those lyrics with a local Ollama model (cache/lyrics_analysis.json); no internet, runs for hours, resumable; --model overrides the Ollama model from config.toml
``` ```
On repeated runs `fetch` only downloads new tracks, and `apply` does not duplicate tracks already added. On repeated runs `fetch` only downloads new tracks, and `apply` does not duplicate tracks already added.
@ -92,7 +94,7 @@ The cache consists of six files:
- `cache/artist_genres.json` — genre tags for each artist (an artist can have several); - `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/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/lyrics_lang.json` — detected lyrics language per track;
- `cache/lyrics_text.json` — lyrics text of Russian-language tracks; - `cache/lyrics_text.json` — lyrics text of Russian-language tracks (or of every track with lyrics if fetched via `--all-languages`); populated not only by `lyrics` but also, as a side effect, by `report`/`apply`, which look up the same per-track lyrics language and cache the RU text if found;
- `cache/lyrics_analysis.json` — per-track lyric analysis from the local model. - `cache/lyrics_analysis.json` — per-track lyric analysis from the local model.
Deleting `cache/` makes `fetch` and `report` re-fetch everything from scratch on the next run. Deleting `cache/` makes `fetch` and `report` re-fetch everything from scratch on the next run.
@ -147,7 +149,7 @@ the table in `sort_ym/genres.py` can be extended.
An LLM can't digest a full per-track dump of ~2000 tracks as well as a compact summary — so An LLM can't digest a full per-track dump of ~2000 tracks as well as a compact summary — so
`digest` doesn't export tracks line by line, it aggregates the library into a few sections with `digest` doesn't export tracks line by line, it aggregates the library into a few sections with
hard size ceilings: at most 11 lines for top-level genres, ~34 for sub-genres, 3 for languages, hard size ceilings: at most 11 lines for top-level genres, ~36 for sub-genres, 3 for languages,
~13 for decades, and top artists/top albums are bounded by `config.toml`, not by library size. ~13 for decades, and top artists/top albums are bounded by `config.toml`, not by library size.
Anything past the top collapses into a single sentence with aggregate numbers. The resulting file Anything past the top collapses into a single sentence with aggregate numbers. The resulting file
stays a couple hundred lines at most, regardless of whether the library has 500 tracks or 10000. stays a couple hundred lines at most, regardless of whether the library has 500 tracks or 10000.
@ -173,8 +175,9 @@ Example `out/digest.md` (abridged):
## Lyric analysis (Russian tracks) ## Lyric analysis (Russian tracks)
`lyrics` downloads the lyrics text, `analyze` reads it with a local model and writes a per-track `lyrics` downloads the lyrics text, `analyze` reads it with a local model and writes a per-track
profile: mood, themes, emotional arc, point of view, tone, plus a short description of what the profile: mood, themes, emotional arc, point of view, register, concreteness, stance, a confidence
song is about and why it might resonate. score, plus a short summary/resonance of what the song is about and why it might resonate, and a
verbatim key line quoted from the lyrics.
``` ```
.venv\Scripts\python -m sort_ym lyrics # lyrics text of Russian-language tracks .venv\Scripts\python -m sort_ym lyrics # lyrics text of Russian-language tracks
@ -272,8 +275,10 @@ python -m sort_ym apply --source https://music.yandex.ru/users/vasya/playlists/1
There is no official API for personal Yandex Music library access — this tool uses the 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 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, account — keep it local and never publish it. The tool only performs safe actions on your library
creating playlists, adding tracks) and never deletes anything. (reading likes, creating playlists, adding tracks) and never deletes liked tracks. The only
deletion it performs at all is `dedupe --yes`, which removes duplicate insertions of the same
track within a playlist it created.
## For developers ## For developers
@ -285,6 +290,7 @@ Python 3.11+, `yandex-music==3.0.0`, standard library (`tomllib`, `csv`, `unicod
.venv\Scripts\python -m sort_ym fetch .venv\Scripts\python -m sort_ym fetch
.venv\Scripts\python -m sort_ym report .venv\Scripts\python -m sort_ym report
.venv\Scripts\python -m sort_ym apply --limit 20 --yes .venv\Scripts\python -m sort_ym apply --limit 20 --yes
.venv\Scripts\python -m sort_ym dedupe
.venv\Scripts\python -m sort_ym digest .venv\Scripts\python -m sort_ym digest
.venv\Scripts\python -m sort_ym report --source https://music.yandex.ru/users/vasya/playlists/1001 .venv\Scripts\python -m sort_ym report --source https://music.yandex.ru/users/vasya/playlists/1001
``` ```

View file

@ -45,9 +45,11 @@ python -m venv .venv
.venv\Scripts\python -m sort_ym report # посчитать жанр и язык каждого трека, сохранить out/report.csv (аккаунт не меняет) .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 --limit 20 --yes # пробный запуск: создать плейлисты и добавить в них первые 20 треков
.venv\Scripts\python -m sort_ym apply --yes # создать плейлисты и добавить в них все треки (меняет аккаунт) .venv\Scripts\python -m sort_ym apply --yes # создать плейлисты и добавить в них все треки (меняет аккаунт)
.venv\Scripts\python -m sort_ym dedupe # пробный запуск: показать повторные вставки одного трека в плейлистах
.venv\Scripts\python -m sort_ym dedupe --yes # убрать эти дубли (меняет аккаунт)
.venv\Scripts\python -m sort_ym digest # сводка библиотеки (топ-исполнители, жанры) в out/digest.md - чтобы вставить в чат с LLM; сеть не используется .venv\Scripts\python -m sort_ym digest # сводка библиотеки (топ-исполнители, жанры) в out/digest.md - чтобы вставить в чат с LLM; сеть не используется
.venv\Scripts\python -m sort_ym lyrics # загрузить тексты песен русскоязычных треков (cache/lyrics_text.json), аккаунт не меняется .venv\Scripts\python -m sort_ym lyrics # загрузить тексты песен русскоязычных треков (cache/lyrics_text.json), аккаунт не меняется
.venv\Scripts\python -m sort_ym analyze # разобрать эти тексты локальной моделью Ollama (cache/lyrics_analysis.json), без интернета, идёт часами, продолжается после обрыва .venv\Scripts\python -m sort_ym analyze # разобрать эти тексты локальной моделью Ollama (cache/lyrics_analysis.json), без интернета, идёт часами, продолжается после обрыва; --model переопределяет модель Ollama из config.toml
``` ```
При повторных запусках `fetch` докачивает только новые треки, а `apply` не дублирует уже добавленные треки. При повторных запусках `fetch` докачивает только новые треки, а `apply` не дублирует уже добавленные треки.
@ -92,7 +94,10 @@ rm -rf cache # Linux/macOS
- `cache/artist_genres.json` — жанровые теги каждого артиста (может быть несколько на артиста); - `cache/artist_genres.json` — жанровые теги каждого артиста (может быть несколько на артиста);
- `cache/genre_catalog.json` — снимок дерева жанров Яндекса на момент последнего запуска; - `cache/genre_catalog.json` — снимок дерева жанров Яндекса на момент последнего запуска;
- `cache/lyrics_lang.json` — определённые языки текстов для каждого трека; - `cache/lyrics_lang.json` — определённые языки текстов для каждого трека;
- `cache/lyrics_text.json` — тексты песен русскоязычных треков; - `cache/lyrics_text.json` — тексты песен русскоязычных треков (либо всех треков с текстом, если
загружены через `--all-languages`); пополняется не только командой `lyrics`, но и попутно
командами `report`/`apply` — они запрашивают тот же язык текста песни на трек и кэшируют RU-текст,
если он нашёлся;
- `cache/lyrics_analysis.json` — разбор текста каждого трека локальной моделью. - `cache/lyrics_analysis.json` — разбор текста каждого трека локальной моделью.
Удаление `cache/` заставит `fetch` и `report` переполучить всё с нуля при следующем запуске. Удаление `cache/` заставит `fetch` и `report` переполучить всё с нуля при следующем запуске.
@ -147,7 +152,7 @@ rm .token # Linux/macOS
Полный построчный список из ~2000 треков LLM переварить не может так же качественно, как Полный построчный список из ~2000 треков LLM переварить не может так же качественно, как
компактную сводку — поэтому `digest` не выгружает треки построчно, а агрегирует библиотеку в компактную сводку — поэтому `digest` не выгружает треки построчно, а агрегирует библиотеку в
несколько разделов с жёстко ограниченным размером: не более 11 строк по крупным жанрам, ~34 по несколько разделов с жёстко ограниченным размером: не более 11 строк по крупным жанрам, ~36 по
под-жанрам, 3 по языкам, ~13 по десятилетиям, а топ-исполнителей и топ-альбомов ограничен под-жанрам, 3 по языкам, ~13 по десятилетиям, а топ-исполнителей и топ-альбомов ограничен
`config.toml`, а не размером библиотеки. Всё, что не попало в топ, схлопывается в одну фразу с `config.toml`, а не размером библиотеки. Всё, что не попало в топ, схлопывается в одну фразу с
суммарными числами. Итоговый файл — не больше пары сотен строк независимо от того, 500 треков в суммарными числами. Итоговый файл — не больше пары сотен строк независимо от того, 500 треков в
@ -174,8 +179,9 @@ $ python -m sort_ym digest
## Разбор текстов (русскоязычные треки) ## Разбор текстов (русскоязычные треки)
`lyrics` загружает текст песни, `analyze` читает его локальной моделью и пишет профиль каждого `lyrics` загружает текст песни, `analyze` читает его локальной моделью и пишет профиль каждого
трека: настроение, темы, эмоциональную арку, ракурс, тон, плюс короткое описание того, о чём трека: настроение, темы, эмоциональную арку, ракурс, регистр речи, конкретность, отношение к
песня и чем она может цеплять. предмету (`stance`), оценку уверенности, плюс короткое summary/resonance о том, о чём песня и чем
она может цеплять, и дословную ключевую строчку из текста.
``` ```
.venv\Scripts\python -m sort_ym lyrics # тексты песен русскоязычных треков .venv\Scripts\python -m sort_ym lyrics # тексты песен русскоязычных треков
@ -274,7 +280,9 @@ python -m sort_ym apply --source https://music.yandex.ru/users/vasya/playlists/1
Официального API для личной библиотеки Яндекс.Музыки нет — инструмент использует Официального API для личной библиотеки Яндекс.Музыки нет — инструмент использует
неофициальную библиотеку `yandex-music`. OAuth-токен в `.token` даёт полный доступ к аккаунту неофициальную библиотеку `yandex-music`. OAuth-токен в `.token` даёт полный доступ к аккаунту
Яндекса — храните его локально и не публикуйте. Инструмент выполняет только безопасные действия Яндекса — храните его локально и не публикуйте. Инструмент выполняет только безопасные действия
(чтение лайков, создание плейлистов, добавление треков) и ничего не удаляет. с библиотекой (чтение лайков, создание плейлистов, добавление треков) и никогда не удаляет
лайкнутые треки. Единственное удаление, которое он вообще выполняет — `dedupe --yes`, убирающая
повторные вставки одного и того же трека внутри созданного им плейлиста.
## For developers ## For developers
@ -286,6 +294,7 @@ Python 3.11+, `yandex-music==3.0.0`, стандартная библиотека
.venv\Scripts\python -m sort_ym fetch .venv\Scripts\python -m sort_ym fetch
.venv\Scripts\python -m sort_ym report .venv\Scripts\python -m sort_ym report
.venv\Scripts\python -m sort_ym apply --limit 20 --yes .venv\Scripts\python -m sort_ym apply --limit 20 --yes
.venv\Scripts\python -m sort_ym dedupe
.venv\Scripts\python -m sort_ym digest .venv\Scripts\python -m sort_ym digest
.venv\Scripts\python -m sort_ym report --source https://music.yandex.ru/users/vasya/playlists/1001 .venv\Scripts\python -m sort_ym report --source https://music.yandex.ru/users/vasya/playlists/1001
``` ```

View file

@ -165,7 +165,7 @@ def genre_stats(rows: list[dict]) -> list[dict]:
def fine_stats(rows: list[dict]) -> list[dict]: def fine_stats(rows: list[dict]) -> list[dict]:
"""[{"fine", "tracks"}], максимум ~34 строки (размер genres.ROOT_BUCKET).""" """[{"fine", "tracks"}], максимум ~36 строк (размер genres.ROOT_BUCKET)."""
counts = Counter(row["fine_bucket"] for row in rows) counts = Counter(row["fine_bucket"] for row in rows)
result = [{"fine": f, "tracks": n} for f, n in counts.items()] result = [{"fine": f, "tracks": n} for f, n in counts.items()]
result.sort(key=lambda g: (-g["tracks"], g["fine"])) result.sort(key=lambda g: (-g["tracks"], g["fine"]))