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
language.py — hybrid language detection (lyrics API / genre hint / alphabet)
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) ->
cache/lyrics_analysis.json (written after each track)
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)
-> out/digest.md, fully offline without --source; Wording/playlist_wording —
digest text for likes vs an arbitrary playlist
cli.py — auth / fetch / report / apply / dedupe / digest commands; --source/--label
on report/apply/digest (see "Key decisions")
cli.py — auth / fetch / report / apply / dedupe / digest / lyrics / analyze commands;
--source/--label on report/apply/digest (see "Key decisions")
tests/
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
→ out/report.csv (dry-run, account unchanged)
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)
→ build_rows() → out/digest.md
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
(`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
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.
- `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_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,
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.
- `config.toml` — includes `[ollama]` block with `host`, `model`, `prompt_version`, `timeout`,
`keep_alive`.

View file

@ -22,7 +22,8 @@ sort_ym/
артиста в один под-жанр (root_id); ROOT_BUCKET — под-жанр -> 11 крупных корзин
language.py — гибридная детекция языка (API текста / жанр-подсказка / алфавит)
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) ->
cache/lyrics_analysis.json (запись после каждого трека)
classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста; with_label — суффикс метки
@ -32,8 +33,8 @@ sort_ym/
digest.py — агрегированная сводка библиотеки (топ-исполнители, жанры, десятилетия,
топ-альбомы) -> out/digest.md, полностью офлайн без --source; Wording/
playlist_wording — тексты дайджеста для лайков vs произвольного плейлиста
cli.py — команды auth / fetch / report / apply / dedupe / digest; --source/--label
на report/apply/digest (см. "Ключевые решения")
cli.py — команды auth / fetch / report / apply / dedupe / digest / lyrics / analyze;
--source/--label на report/apply/digest (см. "Ключевые решения")
tests/
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 схлопываются в крупную корзину
→ out/report.csv (dry-run, аккаунт не меняется)
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() (только кэш, сеть не используется)
→ build_rows() → out/digest.md
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`**: перед добавлением треков в плейлист читается его текущий состав
(`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) после каждого
@ -217,9 +227,13 @@ report/apply/digest --source <url> → source.parse_playlist_url() → (user_id,
и `added_at` выше, только для `report --extra`.
- `cache/genre_catalog.json` — плоский снимок дерева жанров Яндекса (id -> title, root_id).
- `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,
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` — полный построчный разбор, сгруппированный по основному настроению.
- `config.toml` — включает блок `[ollama]` с полями `host`, `model`, `prompt_version`, `timeout`,
`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 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 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 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.
@ -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/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_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.
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
`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.
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.
@ -173,8 +175,9 @@ Example `out/digest.md` (abridged):
## Lyric analysis (Russian tracks)
`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
song is about and why it might resonate.
profile: mood, themes, emotional arc, point of view, register, concreteness, stance, a confidence
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
@ -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
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.
account — keep it local and never publish it. The tool only performs safe actions on your library
(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
@ -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 report
.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 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 apply --limit 20 --yes # пробный запуск: создать плейлисты и добавить в них первые 20 треков
.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 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` не дублирует уже добавленные треки.
@ -92,7 +94,10 @@ rm -rf cache # Linux/macOS
- `cache/artist_genres.json` — жанровые теги каждого артиста (может быть несколько на артиста);
- `cache/genre_catalog.json` — снимок дерева жанров Яндекса на момент последнего запуска;
- `cache/lyrics_lang.json` — определённые языки текстов для каждого трека;
- `cache/lyrics_text.json` — тексты песен русскоязычных треков;
- `cache/lyrics_text.json` — тексты песен русскоязычных треков (либо всех треков с текстом, если
загружены через `--all-languages`); пополняется не только командой `lyrics`, но и попутно
командами `report`/`apply` — они запрашивают тот же язык текста песни на трек и кэшируют RU-текст,
если он нашёлся;
- `cache/lyrics_analysis.json` — разбор текста каждого трека локальной моделью.
Удаление `cache/` заставит `fetch` и `report` переполучить всё с нуля при следующем запуске.
@ -147,7 +152,7 @@ rm .token # Linux/macOS
Полный построчный список из ~2000 треков LLM переварить не может так же качественно, как
компактную сводку — поэтому `digest` не выгружает треки построчно, а агрегирует библиотеку в
несколько разделов с жёстко ограниченным размером: не более 11 строк по крупным жанрам, ~34 по
несколько разделов с жёстко ограниченным размером: не более 11 строк по крупным жанрам, ~36 по
под-жанрам, 3 по языкам, ~13 по десятилетиям, а топ-исполнителей и топ-альбомов ограничен
`config.toml`, а не размером библиотеки. Всё, что не попало в топ, схлопывается в одну фразу с
суммарными числами. Итоговый файл — не больше пары сотен строк независимо от того, 500 треков в
@ -174,8 +179,9 @@ $ python -m sort_ym digest
## Разбор текстов (русскоязычные треки)
`lyrics` загружает текст песни, `analyze` читает его локальной моделью и пишет профиль каждого
трека: настроение, темы, эмоциональную арку, ракурс, тон, плюс короткое описание того, о чём
песня и чем она может цеплять.
трека: настроение, темы, эмоциональную арку, ракурс, регистр речи, конкретность, отношение к
предмету (`stance`), оценку уверенности, плюс короткое summary/resonance о том, о чём песня и чем
она может цеплять, и дословную ключевую строчку из текста.
```
.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 для личной библиотеки Яндекс.Музыки нет — инструмент использует
неофициальную библиотеку `yandex-music`. OAuth-токен в `.token` даёт полный доступ к аккаунту
Яндекса — храните его локально и не публикуйте. Инструмент выполняет только безопасные действия
(чтение лайков, создание плейлистов, добавление треков) и ничего не удаляет.
с библиотекой (чтение лайков, создание плейлистов, добавление треков) и никогда не удаляет
лайкнутые треки. Единственное удаление, которое он вообще выполняет — `dedupe --yes`, убирающая
повторные вставки одного и того же трека внутри созданного им плейлиста.
## 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 report
.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 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]:
"""[{"fine", "tracks"}], максимум ~34 строки (размер genres.ROOT_BUCKET)."""
"""[{"fine", "tracks"}], максимум ~36 строк (размер genres.ROOT_BUCKET)."""
counts = Counter(row["fine_bucket"] for row in rows)
result = [{"fine": f, "tracks": n} for f, n in counts.items()]
result.sort(key=lambda g: (-g["tracks"], g["fine"]))