mirror of
https://github.com/Horizontal42/Sort_YM.git
synced 2026-11-11 01:02:02 +03:00
add digest command for a compact library summary
Aggregates liked tracks into top artists, genres, decades and top albums with fixed size ceilings instead of a per-track dump, so it can be pasted into an LLM chat regardless of library size. Fully offline - reads only the existing cache, no Client/network needed. Adds album_title/year to the track cache (one-time re-fetch) to support the decade and album views.
This commit is contained in:
parent
544765db3f
commit
187c30b4ab
12 changed files with 916 additions and 19 deletions
|
|
@ -9,7 +9,8 @@ A Python console utility that reads Yandex Music liked tracks and sorts them int
|
|||
|
||||
```
|
||||
sort_ym/
|
||||
config.py — reads config.toml (throttle, small_group_min, cache/token paths)
|
||||
config.py — reads config.toml (throttle, small_group_min, top_artists/top_albums,
|
||||
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 ->
|
||||
|
|
@ -20,13 +21,16 @@ sort_ym/
|
|||
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
|
||||
digest.py — aggregated library summary (top artists, genres, decades, top albums)
|
||||
-> out/digest.md, fully offline
|
||||
cli.py — auth / fetch / report / apply / dedupe / digest commands
|
||||
|
||||
tests/
|
||||
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
|
||||
test_digest.py — artist/album aggregation, long-tail collapsing, digest size bounds
|
||||
|
||||
cache/ — API cache (in .gitignore)
|
||||
out/ — reports (in .gitignore)
|
||||
|
|
@ -44,6 +48,8 @@ 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
|
||||
digest → genres.load_catalog() + language.load_lang_cache() (cache only, no network)
|
||||
→ build_rows() → out/digest.md
|
||||
```
|
||||
|
||||
## Key decisions
|
||||
|
|
@ -97,13 +103,37 @@ apply --yes → users_playlists_create / users_playlists_insert_track
|
|||
each batch — an interrupted run can simply be restarted, and already-fetched data is not
|
||||
requested again.
|
||||
|
||||
- **Library digest (`digest.py`)**: the `digest` command's output is meant to be pasted into an LLM
|
||||
chat, so it's built as a set of aggregates with fixed ceilings rather than a per-track dump — on a
|
||||
~2000-track library that's ~150 lines, regardless of whether the library has 500 tracks or 10000
|
||||
(genre sections are bounded by the size of `ROOT_BUCKET`/`BUCKET_LABELS`; top artists and top
|
||||
albums are bounded by the `[digest]` config, not the library size; anything past the top is
|
||||
collapsed into a single sentence with aggregate numbers). Artists are counted **by name**, not via
|
||||
`zip(artists, artist_ids)` — `fetch._serialize` puts every named artist into `artists` but only
|
||||
those that also have an id into `artist_ids`, so the two lists can differ in length for a given
|
||||
track. For the same reason, an artist's own genre tags (`artist_tags`) are only looked up from
|
||||
tracks where the two lists' lengths match — otherwise an artist could end up with someone else's
|
||||
tags. Rows from `report.build_rows()` are joined back to `cache/tracks.json` by the `(id,
|
||||
album_id)` pair taken from the cache's **values**, not by reconstructing the key as
|
||||
`f"{id}:{album_id}"` — the cache key is the original id from `likes.tracks_ids`, and for
|
||||
album-less tracks it doesn't match that format. The album's year and title (`year`,
|
||||
`album_title`) are read from the same `albums[0]` as `album_id` — otherwise the title could end up
|
||||
paired with the wrong id. Old cache entries are backfilled with the same mechanism used for
|
||||
`artist_ids`: the migration checks whether the `year` key is **present**, not whether its value is
|
||||
truthy, otherwise tracks with a legitimately unknown year would be re-fetched on every `fetch` run.
|
||||
The command is fully offline — it never constructs a `Client`, unlike `report`/`apply`.
|
||||
|
||||
## Storage
|
||||
|
||||
- `.token` — the account's OAuth token (not committed).
|
||||
- `cache/tracks.json` — liked track metadata, including `artist_ids` (the track's artist ids).
|
||||
- `cache/tracks.json` — liked track metadata, including `artist_ids` (the track's artist ids),
|
||||
`album_title` and `year` (the album's title and release year, `Album.title`/`Album.year`; `year`
|
||||
can be `null`).
|
||||
- `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).
|
||||
- `out/digest.md` — a compact library summary for pasting into an LLM chat (top artists, top
|
||||
albums, genre/language/decade breakdowns).
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
|
||||
```
|
||||
sort_ym/
|
||||
config.py — чтение config.toml (throttle, small_group_min, пути к кэшу/токену)
|
||||
config.py — чтение config.toml (throttle, small_group_min, top_artists/top_albums,
|
||||
пути к кэшу/токену)
|
||||
auth.py — OAuth device-flow, хранение токена в .token
|
||||
ymclient.py — создание Client, батч-нарезка списков, ретраи сетевых ошибок
|
||||
fetch.py — лайки -> полные Track -> cache/tracks.json; артисты треков ->
|
||||
|
|
@ -20,13 +21,17 @@ sort_ym/
|
|||
classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста
|
||||
report.py — двухпроходная сборка строк отчёта, запись out/report.csv, сводки
|
||||
apply.py — создание плейлистов + идемпотентное добавление треков
|
||||
cli.py — команды auth / fetch / report / apply
|
||||
digest.py — агрегированная сводка библиотеки (топ-исполнители, жанры, десятилетия,
|
||||
топ-альбомы) -> out/digest.md, полностью офлайн
|
||||
cli.py — команды auth / fetch / report / apply / dedupe / digest
|
||||
|
||||
tests/
|
||||
test_genres.py — classify_track, dominant, bucket_for, дерево жанров
|
||||
test_language.py — детекция языка
|
||||
test_report.py — двухпроходное схлопывание мелких под-жанровых групп
|
||||
test_fetch.py, test_apply.py — кэш треков/артистов, идемпотентность применения
|
||||
test_digest.py — агрегация по исполнителям и альбомам, схлопывание длинного хвоста,
|
||||
границы размера дайджеста
|
||||
|
||||
cache/ — кэш API (в .gitignore)
|
||||
out/ — отчёты (в .gitignore)
|
||||
|
|
@ -44,6 +49,8 @@ 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
|
||||
digest → genres.load_catalog() + language.load_lang_cache() (только кэш, сеть не используется)
|
||||
→ build_rows() → out/digest.md
|
||||
```
|
||||
|
||||
## Ключевые решения
|
||||
|
|
@ -97,13 +104,36 @@ apply --yes → users_playlists_create / users_playlists_insert_track
|
|||
запросами (`config.toml`, `[throttle]`) и пишут кэш атомарно (temp-файл + rename) после каждого
|
||||
батча — прерванный прогон можно просто перезапустить, уже полученное не запрашивается повторно.
|
||||
|
||||
- **Дайджест библиотеки (`digest.py`)**: вывод команды `digest` рассчитан на вставку в чат с LLM,
|
||||
поэтому он строится как набор агрегатов с фиксированными границами, а не как построчный дамп —
|
||||
на библиотеке из ~2000 треков это ~150 строк независимо от того, 500 треков в библиотеке или
|
||||
10000 (жанровые секции ограничены размером `ROOT_BUCKET`/`BUCKET_LABELS`, топ-исполнители и
|
||||
топ-альбомы — конфигом `[digest]`, а не всей библиотекой; хвост за пределами топа схлопывается в
|
||||
одну фразу с суммарными числами). Исполнители считаются **по имени**, а не через
|
||||
`zip(artists, artist_ids)` — `fetch._serialize` кладёт в `artists` всех артистов с именем, а в
|
||||
`artist_ids` только тех, у кого есть и имя, и id, поэтому списки могут расходиться по длине для
|
||||
одного трека. По той же причине собственные жанровые теги артиста (`artist_tags`) сопоставляются
|
||||
только по трекам, где длины списков совпадают — иначе можно присвоить артисту чужие теги.
|
||||
Склейка строк `report.build_rows()` с `cache/tracks.json` идёт по паре `(id, album_id)` из
|
||||
**значений** кэша, а не по реконструированному ключу `f"{id}:{album_id}"` — ключ кэша это
|
||||
исходный id из `likes.tracks_ids`, и для треков без альбома он не совпадает с этим форматом.
|
||||
Год и название альбома (`year`, `album_title`) берутся из одного и того же `albums[0]`, что и
|
||||
`album_id` — иначе название могло бы разъехаться с id. Досыпка старых записей кэша идёт тем же
|
||||
механизмом, что и для `artist_ids`: проверяется **наличие** ключа `year`, а не правдивость
|
||||
значения, иначе треки с легитимно неизвестным годом перекачивались бы при каждом `fetch`.
|
||||
Команда полностью офлайн — `Client` не создаётся вообще, в отличие от `report`/`apply`.
|
||||
|
||||
## Хранилище
|
||||
|
||||
- `.token` — OAuth-токен аккаунта (не коммитится).
|
||||
- `cache/tracks.json` — метаданные лайкнутых треков, включая `artist_ids` (id артистов трека).
|
||||
- `cache/tracks.json` — метаданные лайкнутых треков, включая `artist_ids` (id артистов трека),
|
||||
`album_title` и `year` (название и год релиза альбома, `Album.title`/`Album.year`; `year` может
|
||||
быть `null`).
|
||||
- `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` — под-жанр до возможного схлопывания в крупную корзину).
|
||||
- `out/digest.md` — сжатая сводка библиотеки для вставки в чат с LLM (топ-исполнители, топ-альбомы,
|
||||
распределение по жанрам, языку и десятилетиям).
|
||||
|
|
|
|||
59
README.md
59
README.md
|
|
@ -39,6 +39,7 @@ For development (tests):
|
|||
.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 digest # library summary (top artists, genres) to out/digest.md - to paste into an LLM chat; no network used
|
||||
```
|
||||
|
||||
On repeated runs `fetch` only downloads new tracks, and `apply` does not duplicate tracks already added.
|
||||
|
|
@ -59,13 +60,13 @@ The cache consists of four files:
|
|||
|
||||
Deleting `cache/` makes `fetch` and `report` re-fetch everything from scratch on the next run.
|
||||
|
||||
You can also delete the generated report:
|
||||
You can also delete the generated reports (`out/report.csv`, `out/digest.md`):
|
||||
|
||||
```
|
||||
Remove-Item -Recurse -Force out
|
||||
```
|
||||
|
||||
It's just analysis output — deleting it doesn't affect the next `report` run.
|
||||
It's just analysis output — deleting it doesn't affect the next `report`/`digest` run.
|
||||
|
||||
**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:
|
||||
|
||||
|
|
@ -103,12 +104,65 @@ Remove-Item .token
|
|||
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.
|
||||
|
||||
## Digest for an LLM
|
||||
|
||||
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,
|
||||
~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.
|
||||
|
||||
The command is fully offline: it makes no network calls and needs no `.token`, only the already
|
||||
downloaded `cache/`. `report` must have run at least once first (for `cache/genre_catalog.json`).
|
||||
|
||||
```
|
||||
$ python -m sort_ym digest
|
||||
|
||||
Genres:
|
||||
Indie & Alternative: 680
|
||||
Rock: 590
|
||||
...
|
||||
|
||||
Digest saved: out/digest.md
|
||||
```
|
||||
|
||||
Example `out/digest.md` (abridged):
|
||||
|
||||
```
|
||||
## Genres (top-level buckets)
|
||||
- Indie & Alternative — 680 (34%): INT 571 / RU 108
|
||||
- Rock — 590 (29%): INT 518 / RU 72
|
||||
...
|
||||
|
||||
## Decades
|
||||
- 2010s — 620 (31%): Rock 280, Indie & Alternative 200, Metal 90
|
||||
...
|
||||
|
||||
## Top 40 artists
|
||||
1. Billy Talent — 66 (Rock; rock, punk)
|
||||
...
|
||||
1140 more artists with 1-4 tracks, 1737 mentions total; 835 of them with a single track.
|
||||
|
||||
## Top 15 albums
|
||||
1. Slipknot — Iowa (2001) — 12
|
||||
...
|
||||
1401 more albums with 1-3 liked tracks. Tracks from albums with 2+ liked tracks: 732 of 2005 (37%).
|
||||
```
|
||||
|
||||
**Important:** the `year` and `album title` fields weren't in the track cache from the start — if
|
||||
`cache/tracks.json` was created by an older version of `fetch`, the "Decades" and "Top albums"
|
||||
sections will show "Unknown" until the next `python -m sort_ym fetch` run (it re-downloads the
|
||||
whole cache once, then goes back to only fetching new tracks as usual).
|
||||
|
||||
## Handy things
|
||||
|
||||
- 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`).
|
||||
- How many artists/albums the digest names individually — `config.toml` (`[digest] top_artists`,
|
||||
`top_albums`) or the `digest` command's `--top`/`--top-albums` flags.
|
||||
|
||||
## Risks (unofficial API)
|
||||
|
||||
|
|
@ -127,6 +181,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 digest
|
||||
```
|
||||
|
||||
Code structure and data flow are in [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
|
|
|||
60
README.ru.md
60
README.ru.md
|
|
@ -39,6 +39,7 @@ 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 digest # сводка библиотеки (топ-исполнители, жанры) в out/digest.md - чтобы вставить в чат с LLM; сеть не используется
|
||||
```
|
||||
|
||||
При повторных запусках `fetch` докачивает только новые треки, а `apply` не дублирует уже добавленные треки.
|
||||
|
|
@ -59,13 +60,13 @@ Remove-Item -Recurse -Force cache
|
|||
|
||||
Удаление `cache/` заставит `fetch` и `report` переполучить всё с нуля при следующем запуске.
|
||||
|
||||
Также можно удалить сгенерированный отчёт:
|
||||
Также можно удалить сгенерированные отчёты (`out/report.csv`, `out/digest.md`):
|
||||
|
||||
```
|
||||
Remove-Item -Recurse -Force out
|
||||
```
|
||||
|
||||
Это просто результаты анализа, их удаление не повлияет на следующий запуск `report`.
|
||||
Это просто результаты анализа, их удаление не повлияет на следующий запуск `report`/`digest`.
|
||||
|
||||
**Важно:** файл `.token` (OAuth-токен) не входит в кэш и не удаляется при очистке `cache/`. Очистка кэша вас не разлогирует. Если нужна повторная авторизация, удалите `.token` отдельно:
|
||||
|
||||
|
|
@ -103,12 +104,66 @@ Remove-Item .token
|
|||
После `report` в консоли также печатается список нераспознанных жанров (попавших в «Разное»),
|
||||
чтобы можно было дополнить таблицу в `sort_ym/genres.py`.
|
||||
|
||||
## Дайджест для LLM
|
||||
|
||||
Полный построчный список из ~2000 треков LLM переварить не может так же качественно, как
|
||||
компактную сводку — поэтому `digest` не выгружает треки построчно, а агрегирует библиотеку в
|
||||
несколько разделов с жёстко ограниченным размером: не более 11 строк по крупным жанрам, ~34 по
|
||||
под-жанрам, 3 по языкам, ~13 по десятилетиям, а топ-исполнителей и топ-альбомов ограничен
|
||||
`config.toml`, а не размером библиотеки. Всё, что не попало в топ, схлопывается в одну фразу с
|
||||
суммарными числами. Итоговый файл — не больше пары сотен строк независимо от того, 500 треков в
|
||||
библиотеке или 10000.
|
||||
|
||||
Команда полностью офлайн: она не обращается к сети и не требует `.token`, читает только уже
|
||||
загруженный `cache/`. Нужно, чтобы `report` был запущен хотя бы раз (для `cache/genre_catalog.json`).
|
||||
|
||||
```
|
||||
$ python -m sort_ym digest
|
||||
|
||||
Жанры:
|
||||
Инди и альтернатива: 680
|
||||
Рок: 590
|
||||
...
|
||||
|
||||
Дайджест сохранён: out/digest.md
|
||||
```
|
||||
|
||||
Пример `out/digest.md` (сокращённо):
|
||||
|
||||
```
|
||||
## Жанры (крупные корзины)
|
||||
- Инди и альтернатива — 680 (34%): INT 571 / RU 108
|
||||
- Рок — 590 (29%): INT 518 / RU 72
|
||||
...
|
||||
|
||||
## Десятилетия
|
||||
- 2010-е — 620 (31%): Рок 280, Инди и альтернатива 200, Метал 90
|
||||
...
|
||||
|
||||
## Топ-40 исполнителей
|
||||
1. Billy Talent — 66 (Рок; rock, punk)
|
||||
...
|
||||
Ещё 1140 исполнителей с 1-4 треками, 1737 упоминаний суммарно; из них 835 — по одному треку.
|
||||
|
||||
## Топ-15 альбомов
|
||||
1. Slipknot — Iowa (2001) — 12
|
||||
...
|
||||
Ещё 1401 альбом с 1-3 лайкнутыми треками. Треков с альбомов, где лайкнуто 2 и более: 732 из 2005 (37%).
|
||||
```
|
||||
|
||||
**Важно:** поля `год выхода` и `название альбома` появились в кэше треков не с самого начала —
|
||||
если `cache/tracks.json` был создан старой версией `fetch`, разделы «Десятилетия» и «Топ альбомов»
|
||||
покажут «Неизвестно» до первого перезапуска `python -m sort_ym fetch` (он один раз доперекачает
|
||||
весь кэш, дальше — как обычно, только новые треки).
|
||||
|
||||
## Handy things
|
||||
|
||||
- Первый прогон `apply` делайте с `--limit 20 --yes` и проверьте результат в приложении, прежде
|
||||
чем запускать на все ~2000 треков.
|
||||
- Настройки задержек между запросами — в `config.toml` (`[throttle]`).
|
||||
- Минимальный размер под-жанрового плейлиста — `config.toml` (`[classify] small_group_min`).
|
||||
- Сколько исполнителей/альбомов показывать поимённо в дайджесте — `config.toml` (`[digest]
|
||||
top_artists`, `top_albums`) или флаги `--top`/`--top-albums` команды `digest`.
|
||||
|
||||
## Риски (неофициальный API)
|
||||
|
||||
|
|
@ -127,6 +182,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 digest
|
||||
```
|
||||
|
||||
Структура кода и поток данных — в [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md).
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ batch_size = 100
|
|||
# Меньшие группы схлопываются в родительскую крупную корзину (rock/indie-alt/...).
|
||||
small_group_min = 12
|
||||
|
||||
[digest]
|
||||
# Сколько исполнителей показывать в дайджесте (out/digest.md) поимённо.
|
||||
# Остальные схлопываются в одну строку "ещё N исполнителей".
|
||||
top_artists = 40
|
||||
# Сколько альбомов показывать поимённо; остальные - в одну строку с метрикой "альбомы vs синглы".
|
||||
top_albums = 15
|
||||
|
||||
[paths]
|
||||
token_file = ".token"
|
||||
cache_dir = "cache"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
import argparse
|
||||
|
||||
from . import apply as apply_mod
|
||||
from . import auth, fetch, genres, language, report
|
||||
from . import auth, classify, digest, fetch, genres, language, report
|
||||
from .config import load_config
|
||||
from .ymclient import make_client
|
||||
|
||||
|
|
@ -69,6 +69,33 @@ def cmd_apply(args: argparse.Namespace) -> None:
|
|||
apply_mod.apply_classification(client, rows, cfg.apply_request_delay, limit=args.limit)
|
||||
|
||||
|
||||
def cmd_digest(args: argparse.Namespace) -> None:
|
||||
cfg = load_config()
|
||||
|
||||
tracks_cache = fetch.load_tracks_cache(cfg.cache_dir)
|
||||
if not tracks_cache:
|
||||
raise SystemExit("Кэш треков пуст. Сначала запустите: python -m sort_ym fetch")
|
||||
|
||||
catalog = genres.load_catalog(cfg.cache_dir)
|
||||
if not catalog:
|
||||
raise SystemExit("Нет снимка дерева жанров. Сначала запустите: python -m sort_ym report")
|
||||
|
||||
artist_genres = fetch.load_artist_genres(cfg.cache_dir)
|
||||
lang_cache = language.load_lang_cache(cfg.cache_dir) # только кэш, без сети
|
||||
rows = report.build_rows(tracks_cache, lang_cache, catalog, artist_genres, cfg.small_group_min)
|
||||
|
||||
top = args.top if args.top is not None else cfg.digest_top_artists
|
||||
top_albums = args.top_albums if args.top_albums is not None else cfg.digest_top_albums
|
||||
text = digest.render_digest(rows, tracks_cache, artist_genres, top, top_albums)
|
||||
out_file = digest.write_digest(text, cfg.out_dir)
|
||||
|
||||
print("\nЖанры:")
|
||||
for g in digest.genre_stats(rows):
|
||||
print(f" {classify.BUCKET_LABELS.get(g['bucket'], g['bucket'])}: {g['tracks']}")
|
||||
|
||||
print(f"\nДайджест сохранён: {out_file}")
|
||||
|
||||
|
||||
def cmd_dedupe(args: argparse.Namespace) -> None:
|
||||
cfg = load_config()
|
||||
client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout)
|
||||
|
|
@ -90,6 +117,13 @@ def main(argv: list[str] | None = None) -> None:
|
|||
p_dedupe = sub.add_parser("dedupe", help="найти и убрать повторные вставки одного трека в плейлистах")
|
||||
p_dedupe.add_argument("--yes", action="store_true", help="подтвердить удаление дублей (без флага - только отчёт)")
|
||||
|
||||
p_digest = sub.add_parser(
|
||||
"digest",
|
||||
help="сводка библиотеки (топ-исполнители, жанры, десятилетия, топ-альбомы) в out/digest.md - для вставки в чат с LLM",
|
||||
)
|
||||
p_digest.add_argument("--top", type=int, default=None, help="сколько исполнителей показать поимённо (по умолчанию из config.toml)")
|
||||
p_digest.add_argument("--top-albums", type=int, default=None, help="сколько альбомов показать поимённо (по умолчанию из config.toml)")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
commands = {
|
||||
|
|
@ -98,6 +132,7 @@ def main(argv: list[str] | None = None) -> None:
|
|||
"report": cmd_report,
|
||||
"apply": cmd_apply,
|
||||
"dedupe": cmd_dedupe,
|
||||
"digest": cmd_digest,
|
||||
}
|
||||
commands[args.command](args)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ class Config:
|
|||
request_timeout: float
|
||||
batch_size: int
|
||||
small_group_min: int
|
||||
digest_top_artists: int
|
||||
digest_top_albums: int
|
||||
token_file: Path
|
||||
cache_dir: Path
|
||||
out_dir: Path
|
||||
|
|
@ -32,6 +34,8 @@ def load_config(path: Path | None = None) -> Config:
|
|||
request_timeout=data["throttle"]["request_timeout"],
|
||||
batch_size=data["fetch"]["batch_size"],
|
||||
small_group_min=data["classify"]["small_group_min"],
|
||||
digest_top_artists=data["digest"]["top_artists"],
|
||||
digest_top_albums=data["digest"]["top_albums"],
|
||||
token_file=ROOT / data["paths"]["token_file"],
|
||||
cache_dir=ROOT / data["paths"]["cache_dir"],
|
||||
out_dir=ROOT / data["paths"]["out_dir"],
|
||||
|
|
|
|||
344
sort_ym/digest.py
Normal file
344
sort_ym/digest.py
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
from . import classify, genres, report
|
||||
|
||||
DIGEST_FILE = "digest.md"
|
||||
|
||||
DECADE_UNKNOWN = "unknown"
|
||||
DECADE_MIN_YEAR = 1900
|
||||
DECADE_MAX_YEAR = 2100
|
||||
DECADE_TOP_GENRES = 3 # константа вёрстки (сколько жанров показывать на десятилетие), не config-knob
|
||||
|
||||
|
||||
def _by_track_key(tracks_cache: dict[str, dict]) -> dict[tuple, dict]:
|
||||
"""(id, album_id) из ЗНАЧЕНИЙ кэша -> сама запись кэша.
|
||||
|
||||
Ключ tracks_cache - исходный id из likes.tracks_ids, а не f"{id}:{album_id}" (у треков без
|
||||
альбома это разные строки), поэтому склейка со строками report.build_rows идёт по значениям.
|
||||
"""
|
||||
return {(t["id"], t["album_id"]): t for t in tracks_cache.values()}
|
||||
|
||||
|
||||
def _most_common_deterministic(counter: Counter):
|
||||
"""Самое частое значение; при равенстве частот - детерминированный тай-брейк по значению."""
|
||||
return sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))[0][0]
|
||||
|
||||
|
||||
def _artist_ids_by_name(tracks_cache: dict[str, dict]) -> dict[str, list[str]]:
|
||||
"""Имя артиста -> id (в порядке первого появления, без дублей).
|
||||
|
||||
fetch._serialize кладёт в artists всех артистов с именем, а в artist_ids - только тех, у кого
|
||||
есть и имя, и id, поэтому списки могут расходиться по длине для одного трека. Берём только
|
||||
треки, где длины совпадают - иначе zip присвоил бы имени чужой id. Имя, встречающееся только
|
||||
в рассинхронных треках, остаётся без id (и без тегов) - лучше пусто, чем неверно.
|
||||
"""
|
||||
ids_by_name: dict[str, list[str]] = {}
|
||||
for t in tracks_cache.values():
|
||||
artists = t["artists"]
|
||||
artist_ids = t["artist_ids"]
|
||||
if len(artists) != len(artist_ids):
|
||||
continue
|
||||
for name, aid in zip(artists, artist_ids):
|
||||
ids = ids_by_name.setdefault(name, [])
|
||||
sid = str(aid)
|
||||
if sid not in ids:
|
||||
ids.append(sid)
|
||||
return ids_by_name
|
||||
|
||||
|
||||
def artist_tags(
|
||||
name: str,
|
||||
ids_by_name: dict[str, list[str]],
|
||||
artist_genres: dict[str, dict],
|
||||
limit: int = 3,
|
||||
) -> list[str]:
|
||||
"""Собственные жанровые слаги артиста (объединение по всем безопасно найденным id)."""
|
||||
tags: list[str] = []
|
||||
for aid in ids_by_name.get(name, []):
|
||||
entry = artist_genres.get(aid)
|
||||
if not entry:
|
||||
continue
|
||||
for tag in entry.get("genres", []):
|
||||
if tag not in tags:
|
||||
tags.append(tag)
|
||||
return tags[:limit]
|
||||
|
||||
|
||||
def artist_stats(
|
||||
rows: list[dict],
|
||||
tracks_cache: dict[str, dict],
|
||||
artist_genres: dict[str, dict],
|
||||
) -> list[dict]:
|
||||
"""[{"name", "tracks", "top_fine", "tags"}], сортировка: tracks desc, затем name.
|
||||
|
||||
Считает по ИМЕНИ артиста, а не через zip(artists, artist_ids) - списки не всегда выровнены.
|
||||
top_fine - самый частый fine_bucket среди треков этого исполнителя.
|
||||
"""
|
||||
by_key = _by_track_key(tracks_cache)
|
||||
ids_by_name = _artist_ids_by_name(tracks_cache)
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
fines: dict[str, Counter] = {}
|
||||
for row in rows:
|
||||
t = by_key.get((row["id"], row["album_id"]))
|
||||
if t is None:
|
||||
continue
|
||||
for name in t["artists"]:
|
||||
counts[name] = counts.get(name, 0) + 1
|
||||
fines.setdefault(name, Counter())[row["fine_bucket"]] += 1
|
||||
|
||||
result = [
|
||||
{
|
||||
"name": name,
|
||||
"tracks": tracks,
|
||||
"top_fine": fines[name].most_common(1)[0][0],
|
||||
"tags": artist_tags(name, ids_by_name, artist_genres),
|
||||
}
|
||||
for name, tracks in counts.items()
|
||||
]
|
||||
result.sort(key=lambda a: (-a["tracks"], a["name"]))
|
||||
return result
|
||||
|
||||
|
||||
def genre_stats(rows: list[dict]) -> list[dict]:
|
||||
"""[{"bucket", "tracks", "langs"}], максимум 11 строк (число крупных корзин).
|
||||
|
||||
bucket = genres.coarse_of(row["fine_bucket"]), а не row["bucket"] - оно смешивает под-жанр
|
||||
и крупную корзину после схлопывания мелких групп в report.build_rows.
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
langs: dict[str, Counter] = {}
|
||||
for row in rows:
|
||||
bucket = genres.coarse_of(row["fine_bucket"])
|
||||
counts[bucket] = counts.get(bucket, 0) + 1
|
||||
langs.setdefault(bucket, Counter())[row["lang"]] += 1
|
||||
|
||||
result = [{"bucket": b, "tracks": n, "langs": dict(langs[b])} for b, n in counts.items()]
|
||||
result.sort(key=lambda g: (-g["tracks"], g["bucket"]))
|
||||
return result
|
||||
|
||||
|
||||
def fine_stats(rows: list[dict]) -> list[dict]:
|
||||
"""[{"fine", "tracks"}], максимум ~34 строки (размер 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"]))
|
||||
return result
|
||||
|
||||
|
||||
def lang_stats(rows: list[dict]) -> dict[str, int]:
|
||||
return dict(Counter(row["lang"] for row in rows))
|
||||
|
||||
|
||||
def decade_key(year) -> str:
|
||||
"""Год -> "1990"/"2010"/... или DECADE_UNKNOWN (None, не int, либо вне разумных границ)."""
|
||||
if not isinstance(year, int) or year < DECADE_MIN_YEAR or year > DECADE_MAX_YEAR:
|
||||
return DECADE_UNKNOWN
|
||||
return str((year // 10) * 10)
|
||||
|
||||
|
||||
def decade_label(key: str) -> str:
|
||||
return "Неизвестно" if key == DECADE_UNKNOWN else f"{key}-е"
|
||||
|
||||
|
||||
def decade_stats(rows: list[dict], tracks_cache: dict[str, dict]) -> list[dict]:
|
||||
"""[{"key", "tracks", "top_genres"}], сортировка по году desc, "Неизвестно" последним.
|
||||
|
||||
top_genres - топ-DECADE_TOP_GENRES крупных корзин десятилетия (не полная матрица жанр x
|
||||
десятилетие - она была бы до 11 x ~13 строк).
|
||||
"""
|
||||
by_key = _by_track_key(tracks_cache)
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
genre_counts: dict[str, Counter] = {}
|
||||
for row in rows:
|
||||
t = by_key.get((row["id"], row["album_id"]))
|
||||
year = t.get("year") if t else None
|
||||
key = decade_key(year)
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
bucket = genres.coarse_of(row["fine_bucket"])
|
||||
genre_counts.setdefault(key, Counter())[bucket] += 1
|
||||
|
||||
result = [
|
||||
{
|
||||
"key": key,
|
||||
"tracks": n,
|
||||
"top_genres": [
|
||||
{"bucket": b, "tracks": c}
|
||||
for b, c in genre_counts[key].most_common(DECADE_TOP_GENRES)
|
||||
],
|
||||
}
|
||||
for key, n in counts.items()
|
||||
]
|
||||
result.sort(key=lambda d: (d["key"] == DECADE_UNKNOWN, -int(d["key"]) if d["key"] != DECADE_UNKNOWN else 0))
|
||||
return result
|
||||
|
||||
|
||||
def album_stats(tracks_cache: dict[str, dict]) -> list[dict]:
|
||||
"""[{"album_id", "title", "year", "artists", "tracks"}], сортировка tracks desc, затем title.
|
||||
|
||||
Треки с album_id is None (сторонние загрузки без привязки к альбому) пропускаются - они не
|
||||
участвуют в подсчёте "альбом vs синглы", но остаются во всех остальных срезах.
|
||||
title/year/artists представителя альбома - самое частое непустое значение среди его треков
|
||||
(детерминированный тай-брейк), а не "первое попавшееся".
|
||||
"""
|
||||
groups: dict[int, list[dict]] = {}
|
||||
for t in tracks_cache.values():
|
||||
album_id = t.get("album_id")
|
||||
if album_id is None:
|
||||
continue
|
||||
groups.setdefault(album_id, []).append(t)
|
||||
|
||||
result = []
|
||||
for album_id, tracks in groups.items():
|
||||
titles = Counter(t["album_title"] for t in tracks if t.get("album_title"))
|
||||
title = _most_common_deterministic(titles) if titles else f"без названия (#{album_id})"
|
||||
years = Counter(t["year"] for t in tracks if t.get("year") is not None)
|
||||
year = _most_common_deterministic(years) if years else None
|
||||
artist_tuples = Counter(tuple(t["artists"]) for t in tracks)
|
||||
artists = ", ".join(_most_common_deterministic(artist_tuples)) if artist_tuples else ""
|
||||
result.append(
|
||||
{
|
||||
"album_id": album_id,
|
||||
"title": title,
|
||||
"year": year,
|
||||
"artists": artists,
|
||||
"tracks": len(tracks),
|
||||
}
|
||||
)
|
||||
result.sort(key=lambda a: (-a["tracks"], a["title"]))
|
||||
return result
|
||||
|
||||
|
||||
def _pct(n: int, total: int) -> int:
|
||||
return round(100 * n / total) if total else 0
|
||||
|
||||
|
||||
def render_digest(
|
||||
rows: list[dict],
|
||||
tracks_cache: dict[str, dict],
|
||||
artist_genres: dict[str, dict],
|
||||
top_artists: int,
|
||||
top_albums: int,
|
||||
) -> str:
|
||||
total = len(rows)
|
||||
unique_artists = {name for t in tracks_cache.values() for name in t["artists"]}
|
||||
genre_rows = genre_stats(rows)
|
||||
fine_rows = fine_stats(rows)
|
||||
lang_rows = lang_stats(rows)
|
||||
decade_rows = decade_stats(rows, tracks_cache)
|
||||
artists = artist_stats(rows, tracks_cache, artist_genres)
|
||||
albums = album_stats(tracks_cache)
|
||||
other = report.other_breakdown(rows)
|
||||
|
||||
known_years = sorted(t["year"] for t in tracks_cache.values() if t.get("year") is not None)
|
||||
|
||||
lines: list[str] = ["# Дайджест музыкальной библиотеки", "", "## Итоги"]
|
||||
lines.append(f"- Лайкнутых треков: {total}")
|
||||
lines.append(f"- Уникальных исполнителей: {len(unique_artists)}")
|
||||
lines.append(
|
||||
f"- Задействовано крупных корзин: {len(genre_rows)} из {len(classify.BUCKET_LABELS)}, "
|
||||
f"под-жанров: {len(fine_rows)}"
|
||||
)
|
||||
if known_years:
|
||||
median_year = round(statistics.median(known_years))
|
||||
lines.append(
|
||||
f"- Год релиза: медиана {median_year}, известен у {len(known_years)} из {total} "
|
||||
f"({_pct(len(known_years), total)}%)"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
"> Годы релиза и названия альбомов ещё не загружены - запустите `python -m sort_ym "
|
||||
"fetch` (кэш треков обновится один раз). Секции «Десятилетия» и «Топ альбомов» "
|
||||
"пока неинформативны."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Жанры (крупные корзины)")
|
||||
for g in genre_rows:
|
||||
label = classify.BUCKET_LABELS.get(g["bucket"], g["bucket"])
|
||||
lang_parts = [
|
||||
f"{classify.LANG_LABELS.get(code, code)} {n}"
|
||||
for code, n in sorted(g["langs"].items(), key=lambda kv: -kv[1])
|
||||
]
|
||||
lines.append(f"- {label} — {g['tracks']} ({_pct(g['tracks'], total)}%): {' / '.join(lang_parts)}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Под-жанры")
|
||||
for f in fine_rows:
|
||||
label = classify.FINE_LABELS.get(f["fine"], f["fine"])
|
||||
lines.append(f"- {label} — {f['tracks']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Язык")
|
||||
for code, n in sorted(lang_rows.items(), key=lambda kv: -kv[1]):
|
||||
label = classify.LANG_LABELS.get(code, code)
|
||||
lines.append(f"- {label}: {n} ({_pct(n, total)}%)")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Десятилетия")
|
||||
lines.append("В скобках — доля от библиотеки; далее — три самые частые крупные корзины десятилетия.")
|
||||
for d in decade_rows:
|
||||
label = decade_label(d["key"])
|
||||
top_genres_str = ", ".join(
|
||||
f"{classify.BUCKET_LABELS.get(tg['bucket'], tg['bucket'])} {tg['tracks']}"
|
||||
for tg in d["top_genres"]
|
||||
)
|
||||
lines.append(f"- {label} — {d['tracks']} ({_pct(d['tracks'], total)}%): {top_genres_str}")
|
||||
lines.append("")
|
||||
|
||||
shown_artists = artists[:top_artists]
|
||||
rest_artists = artists[top_artists:]
|
||||
lines.append(f"## Топ-{len(shown_artists)} исполнителей")
|
||||
lines.append("Счётчик — упоминания в треках (у трека может быть несколько исполнителей).")
|
||||
lines.append("В скобках — самый частый под-жанр исполнителя, затем его собственные теги из каталога.")
|
||||
for i, a in enumerate(shown_artists, 1):
|
||||
fine_label = classify.FINE_LABELS.get(a["top_fine"], a["top_fine"])
|
||||
tag_part = f"; {', '.join(a['tags'])}" if a["tags"] else ""
|
||||
lines.append(f"{i}. {a['name']} — {a['tracks']} ({fine_label}{tag_part})")
|
||||
if rest_artists:
|
||||
rest_tracks = sum(a["tracks"] for a in rest_artists)
|
||||
one_track = sum(1 for a in rest_artists if a["tracks"] == 1)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"Ещё {len(rest_artists)} исполнителей с {rest_artists[-1]['tracks']}-"
|
||||
f"{rest_artists[0]['tracks']} треками, {rest_tracks} упоминаний суммарно; "
|
||||
f"из них {one_track} — по одному треку."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
shown_albums = albums[:top_albums]
|
||||
rest_albums = albums[top_albums:]
|
||||
lines.append(f"## Топ-{len(shown_albums)} альбомов")
|
||||
lines.append("Счётчик — сколько треков с альбома лайкнуто.")
|
||||
for i, alb in enumerate(shown_albums, 1):
|
||||
year_part = f" ({alb['year']})" if alb["year"] else ""
|
||||
lines.append(f"{i}. {alb['artists']} — {alb['title']}{year_part} — {alb['tracks']}")
|
||||
if rest_albums:
|
||||
deep_tracks = sum(a["tracks"] for a in albums if a["tracks"] >= 2)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"Ещё {len(rest_albums)} альбомов с {rest_albums[-1]['tracks']}-"
|
||||
f"{rest_albums[0]['tracks']} лайкнутыми треками. Треков с альбомов, где лайкнуто "
|
||||
f"2 и более: {deep_tracks} из {total} ({_pct(deep_tracks, total)}%)."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
if other:
|
||||
lines.append("## Разное (нераспознанные сырые жанры)")
|
||||
for genre_raw, n in sorted(other.items(), key=lambda kv: -kv[1])[:5]:
|
||||
lines.append(f"- {genre_raw}: {n}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def write_digest(text: str, out_dir: Path) -> Path:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_file = out_dir / DIGEST_FILE
|
||||
out_file.write_text(text, encoding="utf-8")
|
||||
return out_file
|
||||
|
|
@ -20,10 +20,14 @@ def _track_genre(track: Track) -> str | None:
|
|||
|
||||
|
||||
def _serialize(track: Track) -> dict:
|
||||
album_id = track.albums[0].id if track.albums else None
|
||||
# album_id, album_title и year берутся из одного и того же albums[0] - иначе название
|
||||
# альбома может разъехаться с его id (у трека может быть несколько альбомов).
|
||||
album = track.albums[0] if track.albums else None
|
||||
return {
|
||||
"id": track.id,
|
||||
"album_id": album_id,
|
||||
"album_id": album.id if album else None,
|
||||
"album_title": (album.title or "") if album else "",
|
||||
"year": album.year if album else None,
|
||||
"title": track.title or "",
|
||||
"artists": [a.name for a in track.artists if a.name],
|
||||
"artist_ids": [a.id for a in track.artists if a.name and a.id is not None],
|
||||
|
|
@ -61,9 +65,15 @@ def fetch_liked_tracks(
|
|||
return cache
|
||||
|
||||
all_ids = likes.tracks_ids
|
||||
# "not in cache" - трек ещё не загружен; "artist_ids" not in cache[tid] - трек загружен
|
||||
# старой версией кэша (до добавления artist_ids) и должен быть докачан повторно.
|
||||
missing_ids = [tid for tid in all_ids if tid not in cache or "artist_ids" not in cache[tid]]
|
||||
# "not in cache" - трек ещё не загружен; "artist_ids"/"year" not in cache[tid] - трек загружен
|
||||
# старой версией кэша (до добавления этих полей) и должен быть докачан повторно. Проверяем
|
||||
# наличие ключа, а не правдивость значения - year легитимно может быть None у части альбомов,
|
||||
# и тогда такой трек перекачивался бы при каждом fetch.
|
||||
missing_ids = [
|
||||
tid
|
||||
for tid in all_ids
|
||||
if tid not in cache or "artist_ids" not in cache[tid] or "year" not in cache[tid]
|
||||
]
|
||||
|
||||
print(f"Лайкнутых треков: {len(all_ids)}, новых для загрузки: {len(missing_ids)}")
|
||||
|
||||
|
|
|
|||
|
|
@ -97,14 +97,23 @@ def flatten_catalog(roots: list[Genre]) -> dict[str, dict]:
|
|||
return flat
|
||||
|
||||
|
||||
def load_or_fetch_catalog(client: Client, cache_dir: Path) -> dict[str, dict]:
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
def load_catalog(cache_dir: Path) -> dict[str, dict]:
|
||||
"""Снимок дерева жанров только из кэша, без сети. {} если снимка ещё нет."""
|
||||
cache_file = cache_dir / GENRE_CATALOG_FILE
|
||||
if cache_file.exists():
|
||||
return json.loads(cache_file.read_text(encoding="utf-8"))
|
||||
return {}
|
||||
|
||||
|
||||
def load_or_fetch_catalog(client: Client, cache_dir: Path) -> dict[str, dict]:
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
flat = load_catalog(cache_dir)
|
||||
if flat:
|
||||
return flat
|
||||
|
||||
roots = with_retries(lambda: client.genres())
|
||||
flat = flatten_catalog(roots)
|
||||
cache_file = cache_dir / GENRE_CATALOG_FILE
|
||||
cache_file.write_text(json.dumps(flat, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return flat
|
||||
|
||||
|
|
|
|||
253
tests/test_digest.py
Normal file
253
tests/test_digest.py
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
from sort_ym import digest, report
|
||||
|
||||
CATALOG: dict = {} # classify_track с пустым catalog использует genre_raw как есть
|
||||
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _track(id_, title, artists, genre_raw, artist_ids=None, album_id=_UNSET, album_title="", year=None):
|
||||
return {
|
||||
"id": id_,
|
||||
"album_id": id_ * 10 if album_id is _UNSET else album_id,
|
||||
"album_title": album_title,
|
||||
"year": year,
|
||||
"title": title,
|
||||
"artists": artists,
|
||||
"artist_ids": artist_ids if artist_ids is not None else [],
|
||||
"genre_raw": genre_raw,
|
||||
"lyrics_available": False,
|
||||
}
|
||||
|
||||
|
||||
def _rows(tracks_cache, small_group_min=1):
|
||||
return report.build_rows(tracks_cache, {}, CATALOG, {}, small_group_min)
|
||||
|
||||
|
||||
def test_artist_stats_counts_by_name_even_without_ids():
|
||||
tracks_cache = {
|
||||
"1:10": _track(1, "T1", ["Artist A"], "punk", artist_ids=[]),
|
||||
"2:10": _track(2, "T2", ["Artist A"], "punk", artist_ids=[7]),
|
||||
}
|
||||
stats = digest.artist_stats(_rows(tracks_cache), tracks_cache, {})
|
||||
|
||||
assert len(stats) == 1
|
||||
assert stats[0]["name"] == "Artist A"
|
||||
assert stats[0]["tracks"] == 2
|
||||
|
||||
|
||||
def test_artist_stats_joins_tracks_without_album_id():
|
||||
# Ключ кэша не совпадает с "id:album_id" (реалистично для треков без альбома) -
|
||||
# склейка идёт по значениям, а не по ключу.
|
||||
tracks_cache = {"some-uuid": _track(1, "T1", ["Artist A"], "punk", artist_ids=[7], album_id=None)}
|
||||
stats = digest.artist_stats(_rows(tracks_cache), tracks_cache, {})
|
||||
|
||||
assert stats == [{"name": "Artist A", "tracks": 1, "top_fine": "punk", "tags": []}]
|
||||
|
||||
|
||||
def test_artist_stats_counts_collaboration_for_each_artist():
|
||||
tracks_cache = {"1:10": _track(1, "T1", ["Artist A", "Artist B"], "punk", artist_ids=[7, 8])}
|
||||
stats = digest.artist_stats(_rows(tracks_cache), tracks_cache, {})
|
||||
|
||||
names = {s["name"]: s["tracks"] for s in stats}
|
||||
assert names == {"Artist A": 1, "Artist B": 1}
|
||||
|
||||
|
||||
def test_long_tail_collapses_past_top_artists():
|
||||
tracks_cache = {}
|
||||
tid = 0
|
||||
for artist_idx in range(50):
|
||||
count = 50 - artist_idx # Artist0 - 50 треков, ... Artist49 - 1 трек
|
||||
for _ in range(count):
|
||||
tid += 1
|
||||
tracks_cache[f"{tid}:{tid * 10}"] = _track(tid, f"T{tid}", [f"Artist{artist_idx}"], "punk")
|
||||
|
||||
rows = _rows(tracks_cache)
|
||||
stats = digest.artist_stats(rows, tracks_cache, {})
|
||||
rest = stats[10:]
|
||||
text = digest.render_digest(rows, tracks_cache, {}, top_artists=10, top_albums=0)
|
||||
|
||||
assert "Artist0" in text
|
||||
assert "Artist9" in text
|
||||
assert "Artist10" not in text
|
||||
assert f"Ещё {len(rest)} исполнителей" in text
|
||||
assert str(sum(a["tracks"] for a in rest)) in text
|
||||
|
||||
|
||||
def test_genre_stats_uses_coarse_of_fine_not_row_bucket():
|
||||
# punk (15 треков, >= порога) остаётся отдельным под-жанром в row["bucket"], indie (2, < порога)
|
||||
# схлопывается в indie-alt - при наивном подсчёте по row["bucket"] это были бы ДВЕ разные
|
||||
# строки, хотя coarse_of("punk") тоже "indie-alt" - это одна и та же крупная корзина.
|
||||
tracks_cache = {}
|
||||
for i in range(15):
|
||||
tracks_cache[f"{i}:{i * 10}"] = _track(i, f"P{i}", ["Punk Artist"], "punk")
|
||||
for i in range(15, 17):
|
||||
tracks_cache[f"{i}:{i * 10}"] = _track(i, f"I{i}", ["Indie Artist"], "indie")
|
||||
|
||||
rows = _rows(tracks_cache, small_group_min=12)
|
||||
assert any(r["bucket"] == "punk" for r in rows), "sanity: punk остался отдельным под-жанром"
|
||||
assert any(r["bucket"] == "indie-alt" for r in rows), "sanity: indie схлопнулся"
|
||||
|
||||
stats = digest.genre_stats(rows)
|
||||
assert {g["bucket"]: g["tracks"] for g in stats} == {"indie-alt": 17}
|
||||
|
||||
|
||||
def test_fine_stats_unmapped_bucket_renders_as_raw_slug():
|
||||
tracks_cache = {"1:10": _track(1, "T1", ["Artist"], "totally-unknown-genre")}
|
||||
rows = _rows(tracks_cache)
|
||||
|
||||
text = digest.render_digest(rows, tracks_cache, {}, top_artists=10, top_albums=10)
|
||||
|
||||
assert "totally-unknown-genre" in text
|
||||
|
||||
|
||||
def test_decade_key_and_label():
|
||||
assert digest.decade_key(2011) == "2010"
|
||||
assert digest.decade_key(None) == digest.DECADE_UNKNOWN
|
||||
assert digest.decade_key(0) == digest.DECADE_UNKNOWN
|
||||
assert digest.decade_key(1) == digest.DECADE_UNKNOWN
|
||||
assert digest.decade_key(2150) == digest.DECADE_UNKNOWN
|
||||
assert digest.decade_label("2010") == "2010-е"
|
||||
assert digest.decade_label(digest.DECADE_UNKNOWN) == "Неизвестно"
|
||||
|
||||
|
||||
def test_decade_stats_handles_missing_year_key_without_crashing():
|
||||
old_format_track = _track(1, "T1", ["Artist"], "punk")
|
||||
del old_format_track["year"]
|
||||
tracks_cache = {"1:10": old_format_track}
|
||||
|
||||
stats = digest.decade_stats(_rows(tracks_cache), tracks_cache)
|
||||
|
||||
assert stats == [{"key": digest.DECADE_UNKNOWN, "tracks": 1, "top_genres": [{"bucket": "indie-alt", "tracks": 1}]}]
|
||||
|
||||
|
||||
def test_decade_stats_sorted_desc_with_unknown_last():
|
||||
tracks_cache = {
|
||||
"1:10": _track(1, "T1", ["A"], "punk", year=2015),
|
||||
"2:20": _track(2, "T2", ["B"], "punk", year=1995),
|
||||
"3:30": _track(3, "T3", ["C"], "punk", year=None),
|
||||
}
|
||||
stats = digest.decade_stats(_rows(tracks_cache), tracks_cache)
|
||||
|
||||
assert [s["key"] for s in stats] == ["2010", "1990", digest.DECADE_UNKNOWN]
|
||||
|
||||
|
||||
def test_decade_top_genres_truncated_to_three_largest():
|
||||
tracks_cache = {}
|
||||
tid = 0
|
||||
for genre_raw, n in {"punk": 10, "metal": 8, "rap": 6, "jazz": 4, "pop": 2}.items():
|
||||
for _ in range(n):
|
||||
tid += 1
|
||||
tracks_cache[f"{tid}:{tid * 10}"] = _track(tid, f"T{tid}", ["Artist"], genre_raw, year=2015)
|
||||
|
||||
stats = digest.decade_stats(_rows(tracks_cache), tracks_cache)
|
||||
|
||||
assert len(stats) == 1
|
||||
top = stats[0]["top_genres"]
|
||||
assert len(top) == 3
|
||||
assert sum(g["tracks"] for g in top) < stats[0]["tracks"]
|
||||
|
||||
|
||||
def test_album_stats_skips_none_album_id_but_tracks_counted_elsewhere():
|
||||
tracks_cache = {
|
||||
"abc": _track(1, "T1", ["Artist"], "punk", album_id=None),
|
||||
"2:20": _track(2, "T2", ["Artist"], "punk", album_id=20, album_title="Album X"),
|
||||
}
|
||||
albums = digest.album_stats(tracks_cache)
|
||||
assert len(albums) == 1
|
||||
assert albums[0]["album_id"] == 20
|
||||
|
||||
genre_total = sum(g["tracks"] for g in digest.genre_stats(_rows(tracks_cache)))
|
||||
assert genre_total == 2
|
||||
|
||||
|
||||
def test_album_stats_title_tie_break_is_deterministic():
|
||||
# Оба названия встречаются по разу - при равенстве частот побеждает одно и то же
|
||||
# значение независимо от порядка треков на входе.
|
||||
a = {
|
||||
"1:10": _track(1, "T1", ["Artist"], "punk", album_id=10, album_title="Zeta"),
|
||||
"2:10": _track(2, "T2", ["Artist"], "punk", album_id=10, album_title="Alpha"),
|
||||
}
|
||||
b = {
|
||||
"2:10": a["2:10"],
|
||||
"1:10": a["1:10"],
|
||||
}
|
||||
|
||||
assert digest.album_stats(a)[0]["title"] == "Alpha"
|
||||
assert digest.album_stats(b)[0]["title"] == "Alpha"
|
||||
|
||||
|
||||
def test_album_stats_untitled_fallback():
|
||||
tracks_cache = {"1:10": _track(1, "T1", ["Artist"], "punk", album_id=42, album_title="")}
|
||||
|
||||
albums = digest.album_stats(tracks_cache)
|
||||
|
||||
assert albums[0]["title"] == "без названия (#42)"
|
||||
|
||||
|
||||
def test_artist_tags_empty_when_only_misaligned_tracks():
|
||||
# 2 имени, 1 id - длины не совпадают, трек целиком пропускается для сопоставления тегов.
|
||||
tracks_cache = {"1:10": _track(1, "T1", ["Artist A", "Artist B"], "punk", artist_ids=[7])}
|
||||
ids_by_name = digest._artist_ids_by_name(tracks_cache)
|
||||
|
||||
assert digest.artist_tags("Artist A", ids_by_name, {"7": {"name": "Artist B", "genres": ["rock"]}}) == []
|
||||
|
||||
|
||||
def test_artist_tags_no_misattribution_from_misaligned_track():
|
||||
tracks_cache = {
|
||||
"1:10": _track(1, "T1", ["Artist A", "Artist B"], "punk", artist_ids=[9]), # рассинхрон
|
||||
"2:20": _track(2, "T2", ["Artist B"], "punk", artist_ids=[9]), # выровнен
|
||||
}
|
||||
ids_by_name = digest._artist_ids_by_name(tracks_cache)
|
||||
artist_genres = {"9": {"name": "Artist B", "genres": ["rock"]}}
|
||||
|
||||
assert digest.artist_tags("Artist A", ids_by_name, artist_genres) == []
|
||||
assert digest.artist_tags("Artist B", ids_by_name, artist_genres) == ["rock"]
|
||||
|
||||
|
||||
def test_artist_tags_unions_across_multiple_ids_for_same_name():
|
||||
tracks_cache = {
|
||||
"1:10": _track(1, "T1", ["Artist X"], "punk", artist_ids=[1]),
|
||||
"2:20": _track(2, "T2", ["Artist X"], "punk", artist_ids=[2]),
|
||||
}
|
||||
ids_by_name = digest._artist_ids_by_name(tracks_cache)
|
||||
artist_genres = {
|
||||
"1": {"name": "Artist X", "genres": ["punk", "rock"]},
|
||||
"2": {"name": "Artist X", "genres": ["rock", "ska"]},
|
||||
}
|
||||
|
||||
assert digest.artist_tags("Artist X", ids_by_name, artist_genres) == ["punk", "rock", "ska"]
|
||||
|
||||
|
||||
def test_artist_tags_truncated_to_limit():
|
||||
tracks_cache = {"1:10": _track(1, "T1", ["Artist X"], "punk", artist_ids=[1])}
|
||||
ids_by_name = digest._artist_ids_by_name(tracks_cache)
|
||||
artist_genres = {"1": {"name": "Artist X", "genres": ["a", "b", "c", "d"]}}
|
||||
|
||||
assert digest.artist_tags("Artist X", ids_by_name, artist_genres, limit=3) == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_digest_line_count_stays_bounded_on_large_library():
|
||||
tracks_cache = {}
|
||||
tid = 0
|
||||
genre_pool = [
|
||||
"punk", "indie", "metal", "rap", "electronics", "jazz",
|
||||
"classicalmusic", "pop", "folk", "soundtrack", "allrock",
|
||||
]
|
||||
for artist_idx in range(1500):
|
||||
for _ in range(1 + artist_idx % 5):
|
||||
tid += 1
|
||||
tracks_cache[f"t{tid}"] = _track(
|
||||
tid,
|
||||
f"Track{tid}",
|
||||
[f"Artist{artist_idx}"],
|
||||
genre_pool[tid % len(genre_pool)],
|
||||
album_id=tid % 1000,
|
||||
album_title=f"Album{tid % 1000}",
|
||||
year=1960 + (tid % 60),
|
||||
)
|
||||
|
||||
rows = _rows(tracks_cache)
|
||||
text = digest.render_digest(rows, tracks_cache, {}, top_artists=40, top_albums=15)
|
||||
|
||||
assert len(text.splitlines()) < 250
|
||||
|
|
@ -12,9 +12,11 @@ class FakeArtist:
|
|||
|
||||
|
||||
class FakeAlbum:
|
||||
def __init__(self, id_, genre=None):
|
||||
def __init__(self, id_, genre=None, title="", year=None):
|
||||
self.id = id_
|
||||
self.genre = genre
|
||||
self.title = title
|
||||
self.year = year
|
||||
|
||||
|
||||
class FakeTrack:
|
||||
|
|
@ -149,6 +151,68 @@ def test_fetch_re_downloads_old_cache_entries_missing_artist_ids(tmp_path: Path)
|
|||
assert cache["100:10"]["artist_ids"] == [7]
|
||||
|
||||
|
||||
def test_fetch_re_downloads_old_cache_entries_missing_year(tmp_path: Path):
|
||||
# Старый формат кэша (до добавления album_title/year, но уже с artist_ids) должен
|
||||
# быть докачан повторно.
|
||||
cache_dir = tmp_path
|
||||
cache_dir.mkdir(exist_ok=True)
|
||||
old_entry = {
|
||||
"id": 100,
|
||||
"album_id": 10,
|
||||
"title": "Old",
|
||||
"artists": ["Artist A"],
|
||||
"artist_ids": [7],
|
||||
"genre_raw": "pop",
|
||||
"lyrics_available": False,
|
||||
}
|
||||
(cache_dir / fetch.TRACKS_CACHE_FILE).write_text(
|
||||
json.dumps({"100:10": old_entry}, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
track = FakeTrack(
|
||||
id_=100,
|
||||
albums=[FakeAlbum(10, title="Iowa", year=2001)],
|
||||
artists=[FakeArtist(7, "Artist A")],
|
||||
)
|
||||
client = FakeClient(likes_ids=["100:10"], tracks_by_batch={("100:10",): [track]})
|
||||
|
||||
cache = fetch.fetch_liked_tracks(client, cache_dir, batch_size=100, batch_delay=0)
|
||||
|
||||
assert cache["100:10"]["year"] == 2001
|
||||
assert cache["100:10"]["album_title"] == "Iowa"
|
||||
|
||||
|
||||
def test_fetch_does_not_redownload_entries_with_legitimately_unknown_year(tmp_path: Path):
|
||||
# Регрессия: year=None легитимен (у альбома действительно нет года), и такая запись
|
||||
# не должна перекачиваться при каждом fetch - проверяем наличие ключа, а не значение.
|
||||
cache_dir = tmp_path
|
||||
cache_dir.mkdir(exist_ok=True)
|
||||
entry = {
|
||||
"id": 100,
|
||||
"album_id": 10,
|
||||
"album_title": "Unknown Year Album",
|
||||
"year": None,
|
||||
"title": "T1",
|
||||
"artists": ["Artist A"],
|
||||
"artist_ids": [7],
|
||||
"genre_raw": "pop",
|
||||
"lyrics_available": False,
|
||||
}
|
||||
(cache_dir / fetch.TRACKS_CACHE_FILE).write_text(
|
||||
json.dumps({"100:10": entry}, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
def boom(batch):
|
||||
raise AssertionError(f"не должен перекачивать трек с легитимно неизвестным годом: {batch}")
|
||||
|
||||
client = FakeClient(likes_ids=["100:10"], tracks_by_batch={})
|
||||
client.tracks = boom
|
||||
|
||||
cache = fetch.fetch_liked_tracks(client, cache_dir, batch_size=100, batch_delay=0)
|
||||
|
||||
assert cache["100:10"]["year"] is None
|
||||
|
||||
|
||||
def test_fetch_artist_genres_batches_and_caches(tmp_path: Path):
|
||||
cache_dir = tmp_path
|
||||
tracks_cache = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue