diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 64f44c6..a4fcffa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 → 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 → 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`. diff --git a/ARCHITECTURE.ru.md b/ARCHITECTURE.ru.md index 33bcfe2..89660c7 100644 --- a/ARCHITECTURE.ru.md +++ b/ARCHITECTURE.ru.md @@ -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 → 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 → 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`. diff --git a/README.md b/README.md index 1456b74..27a5efb 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/README.ru.md b/README.ru.md index 632443c..3352e68 100644 --- a/README.ru.md +++ b/README.ru.md @@ -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 ``` diff --git a/sort_ym/digest.py b/sort_ym/digest.py index bcaa017..33255e5 100644 --- a/sort_ym/digest.py +++ b/sort_ym/digest.py @@ -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"]))