mirror of
https://github.com/Horizontal42/Sort_YM.git
synced 2026-11-10 19:11:59 +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/
|
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
|
auth.py — OAuth device-flow, token storage in .token
|
||||||
ymclient.py — Client construction, batching lists, retrying network errors
|
ymclient.py — Client construction, batching lists, retrying network errors
|
||||||
fetch.py — likes -> full Track -> cache/tracks.json; track artists ->
|
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
|
classify.py — (sub-genre | top-level bucket, language) -> playlist name
|
||||||
report.py — two-pass report row building, writes out/report.csv, summaries
|
report.py — two-pass report row building, writes out/report.csv, summaries
|
||||||
apply.py — playlist creation + idempotent track insertion
|
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/
|
tests/
|
||||||
test_genres.py — classify_track, dominant, bucket_for, genre tree
|
test_genres.py — classify_track, dominant, bucket_for, genre tree
|
||||||
test_language.py — language detection
|
test_language.py — language detection
|
||||||
test_report.py — two-pass collapsing of small sub-genre groups
|
test_report.py — two-pass collapsing of small sub-genre groups
|
||||||
test_fetch.py, test_apply.py — track/artist cache, apply idempotency
|
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)
|
cache/ — API cache (in .gitignore)
|
||||||
out/ — reports (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
|
pass 2 — groups < small_group_min collapse into the top-level bucket
|
||||||
→ out/report.csv (dry-run, account unchanged)
|
→ out/report.csv (dry-run, account unchanged)
|
||||||
apply --yes → users_playlists_create / users_playlists_insert_track
|
apply --yes → users_playlists_create / users_playlists_insert_track
|
||||||
|
digest → genres.load_catalog() + language.load_lang_cache() (cache only, no network)
|
||||||
|
→ build_rows() → out/digest.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key decisions
|
## 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
|
each batch — an interrupted run can simply be restarted, and already-fetched data is not
|
||||||
requested again.
|
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
|
## Storage
|
||||||
|
|
||||||
- `.token` — the account's OAuth token (not committed).
|
- `.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
|
- `cache/artist_genres.json` — `{artist_id: {name, genres: [slug, ...]}}`, one or more genres per
|
||||||
artist.
|
artist.
|
||||||
- `cache/genre_catalog.json` — a flat snapshot of Yandex's genre tree (id -> title, root_id).
|
- `cache/genre_catalog.json` — a flat snapshot of Yandex's genre tree (id -> title, root_id).
|
||||||
- `cache/lyrics_lang.json` — lyrics language per track (id -> language code | null).
|
- `cache/lyrics_lang.json` — lyrics language per track (id -> language code | null).
|
||||||
- `out/report.csv` — a preview of the playlist breakdown before `apply` (including the
|
- `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).
|
`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/
|
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
|
auth.py — OAuth device-flow, хранение токена в .token
|
||||||
ymclient.py — создание Client, батч-нарезка списков, ретраи сетевых ошибок
|
ymclient.py — создание Client, батч-нарезка списков, ретраи сетевых ошибок
|
||||||
fetch.py — лайки -> полные Track -> cache/tracks.json; артисты треков ->
|
fetch.py — лайки -> полные Track -> cache/tracks.json; артисты треков ->
|
||||||
|
|
@ -20,13 +21,17 @@ sort_ym/
|
||||||
classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста
|
classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста
|
||||||
report.py — двухпроходная сборка строк отчёта, запись out/report.csv, сводки
|
report.py — двухпроходная сборка строк отчёта, запись out/report.csv, сводки
|
||||||
apply.py — создание плейлистов + идемпотентное добавление треков
|
apply.py — создание плейлистов + идемпотентное добавление треков
|
||||||
cli.py — команды auth / fetch / report / apply
|
digest.py — агрегированная сводка библиотеки (топ-исполнители, жанры, десятилетия,
|
||||||
|
топ-альбомы) -> out/digest.md, полностью офлайн
|
||||||
|
cli.py — команды auth / fetch / report / apply / dedupe / digest
|
||||||
|
|
||||||
tests/
|
tests/
|
||||||
test_genres.py — classify_track, dominant, bucket_for, дерево жанров
|
test_genres.py — classify_track, dominant, bucket_for, дерево жанров
|
||||||
test_language.py — детекция языка
|
test_language.py — детекция языка
|
||||||
test_report.py — двухпроходное схлопывание мелких под-жанровых групп
|
test_report.py — двухпроходное схлопывание мелких под-жанровых групп
|
||||||
test_fetch.py, test_apply.py — кэш треков/артистов, идемпотентность применения
|
test_fetch.py, test_apply.py — кэш треков/артистов, идемпотентность применения
|
||||||
|
test_digest.py — агрегация по исполнителям и альбомам, схлопывание длинного хвоста,
|
||||||
|
границы размера дайджеста
|
||||||
|
|
||||||
cache/ — кэш API (в .gitignore)
|
cache/ — кэш API (в .gitignore)
|
||||||
out/ — отчёты (в .gitignore)
|
out/ — отчёты (в .gitignore)
|
||||||
|
|
@ -44,6 +49,8 @@ report → genres.load_or_fetch_catalog() → cache/genre_catalog.json
|
||||||
проход 2 — группы < small_group_min схлопываются в крупную корзину
|
проход 2 — группы < small_group_min схлопываются в крупную корзину
|
||||||
→ out/report.csv (dry-run, аккаунт не меняется)
|
→ out/report.csv (dry-run, аккаунт не меняется)
|
||||||
apply --yes → users_playlists_create / users_playlists_insert_track
|
apply --yes → users_playlists_create / users_playlists_insert_track
|
||||||
|
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) после каждого
|
запросами (`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-токен аккаунта (не коммитится).
|
- `.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/artist_genres.json` — `{artist_id: {name, genres: [slug, ...]}}`, один и более жанров
|
||||||
на артиста.
|
на артиста.
|
||||||
- `cache/genre_catalog.json` — плоский снимок дерева жанров Яндекса (id -> title, root_id).
|
- `cache/genre_catalog.json` — плоский снимок дерева жанров Яндекса (id -> title, root_id).
|
||||||
- `cache/lyrics_lang.json` — язык текста песни по треку (id -> language code | null).
|
- `cache/lyrics_lang.json` — язык текста песни по треку (id -> language code | null).
|
||||||
- `out/report.csv` — предпросмотр распределения по плейлистам перед `apply` (включая колонку
|
- `out/report.csv` — предпросмотр распределения по плейлистам перед `apply` (включая колонку
|
||||||
`fine_bucket` — под-жанр до возможного схлопывания в крупную корзину).
|
`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 report # compute genre and language per track, save out/report.csv (does not change the account)
|
||||||
.venv\Scripts\python -m sort_ym apply --limit 20 --yes # dry run: create playlists and add the first 20 tracks
|
.venv\Scripts\python -m sort_ym apply --limit 20 --yes # dry run: create playlists and add the first 20 tracks
|
||||||
.venv\Scripts\python -m sort_ym apply --yes # create playlists and add all tracks (changes the account)
|
.venv\Scripts\python -m sort_ym apply --yes # create playlists and add all tracks (changes the account)
|
||||||
|
.venv\Scripts\python -m sort_ym 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.
|
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.
|
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
|
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:
|
**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
|
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.
|
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
|
## Handy things
|
||||||
|
|
||||||
- Run `apply` first with `--limit 20 --yes` and check the result in the app before running it
|
- Run `apply` first with `--limit 20 --yes` and check the result in the app before running it
|
||||||
against all ~2000 tracks.
|
against all ~2000 tracks.
|
||||||
- Delays between requests are configured in `config.toml` (`[throttle]`).
|
- Delays between requests are configured in `config.toml` (`[throttle]`).
|
||||||
- The minimum sub-genre playlist size is `config.toml` (`[classify] small_group_min`).
|
- 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)
|
## 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 fetch
|
||||||
.venv\Scripts\python -m sort_ym report
|
.venv\Scripts\python -m sort_ym report
|
||||||
.venv\Scripts\python -m sort_ym apply --limit 20 --yes
|
.venv\Scripts\python -m sort_ym apply --limit 20 --yes
|
||||||
|
.venv\Scripts\python -m sort_ym digest
|
||||||
```
|
```
|
||||||
|
|
||||||
Code structure and data flow are in [ARCHITECTURE.md](ARCHITECTURE.md).
|
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 report # посчитать жанр и язык каждого трека, сохранить out/report.csv (аккаунт не меняет)
|
||||||
.venv\Scripts\python -m sort_ym apply --limit 20 --yes # пробный запуск: создать плейлисты и добавить в них первые 20 треков
|
.venv\Scripts\python -m sort_ym apply --limit 20 --yes # пробный запуск: создать плейлисты и добавить в них первые 20 треков
|
||||||
.venv\Scripts\python -m sort_ym apply --yes # создать плейлисты и добавить в них все треки (меняет аккаунт)
|
.venv\Scripts\python -m sort_ym apply --yes # создать плейлисты и добавить в них все треки (меняет аккаунт)
|
||||||
|
.venv\Scripts\python -m sort_ym digest # сводка библиотеки (топ-исполнители, жанры) в out/digest.md - чтобы вставить в чат с LLM; сеть не используется
|
||||||
```
|
```
|
||||||
|
|
||||||
При повторных запусках `fetch` докачивает только новые треки, а `apply` не дублирует уже добавленные треки.
|
При повторных запусках `fetch` докачивает только новые треки, а `apply` не дублирует уже добавленные треки.
|
||||||
|
|
@ -59,13 +60,13 @@ Remove-Item -Recurse -Force cache
|
||||||
|
|
||||||
Удаление `cache/` заставит `fetch` и `report` переполучить всё с нуля при следующем запуске.
|
Удаление `cache/` заставит `fetch` и `report` переполучить всё с нуля при следующем запуске.
|
||||||
|
|
||||||
Также можно удалить сгенерированный отчёт:
|
Также можно удалить сгенерированные отчёты (`out/report.csv`, `out/digest.md`):
|
||||||
|
|
||||||
```
|
```
|
||||||
Remove-Item -Recurse -Force out
|
Remove-Item -Recurse -Force out
|
||||||
```
|
```
|
||||||
|
|
||||||
Это просто результаты анализа, их удаление не повлияет на следующий запуск `report`.
|
Это просто результаты анализа, их удаление не повлияет на следующий запуск `report`/`digest`.
|
||||||
|
|
||||||
**Важно:** файл `.token` (OAuth-токен) не входит в кэш и не удаляется при очистке `cache/`. Очистка кэша вас не разлогирует. Если нужна повторная авторизация, удалите `.token` отдельно:
|
**Важно:** файл `.token` (OAuth-токен) не входит в кэш и не удаляется при очистке `cache/`. Очистка кэша вас не разлогирует. Если нужна повторная авторизация, удалите `.token` отдельно:
|
||||||
|
|
||||||
|
|
@ -103,12 +104,66 @@ Remove-Item .token
|
||||||
После `report` в консоли также печатается список нераспознанных жанров (попавших в «Разное»),
|
После `report` в консоли также печатается список нераспознанных жанров (попавших в «Разное»),
|
||||||
чтобы можно было дополнить таблицу в `sort_ym/genres.py`.
|
чтобы можно было дополнить таблицу в `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
|
## Handy things
|
||||||
|
|
||||||
- Первый прогон `apply` делайте с `--limit 20 --yes` и проверьте результат в приложении, прежде
|
- Первый прогон `apply` делайте с `--limit 20 --yes` и проверьте результат в приложении, прежде
|
||||||
чем запускать на все ~2000 треков.
|
чем запускать на все ~2000 треков.
|
||||||
- Настройки задержек между запросами — в `config.toml` (`[throttle]`).
|
- Настройки задержек между запросами — в `config.toml` (`[throttle]`).
|
||||||
- Минимальный размер под-жанрового плейлиста — `config.toml` (`[classify] small_group_min`).
|
- Минимальный размер под-жанрового плейлиста — `config.toml` (`[classify] small_group_min`).
|
||||||
|
- Сколько исполнителей/альбомов показывать поимённо в дайджесте — `config.toml` (`[digest]
|
||||||
|
top_artists`, `top_albums`) или флаги `--top`/`--top-albums` команды `digest`.
|
||||||
|
|
||||||
## Риски (неофициальный API)
|
## Риски (неофициальный 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 fetch
|
||||||
.venv\Scripts\python -m sort_ym report
|
.venv\Scripts\python -m sort_ym report
|
||||||
.venv\Scripts\python -m sort_ym apply --limit 20 --yes
|
.venv\Scripts\python -m sort_ym apply --limit 20 --yes
|
||||||
|
.venv\Scripts\python -m sort_ym digest
|
||||||
```
|
```
|
||||||
|
|
||||||
Структура кода и поток данных — в [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md).
|
Структура кода и поток данных — в [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md).
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,13 @@ batch_size = 100
|
||||||
# Меньшие группы схлопываются в родительскую крупную корзину (rock/indie-alt/...).
|
# Меньшие группы схлопываются в родительскую крупную корзину (rock/indie-alt/...).
|
||||||
small_group_min = 12
|
small_group_min = 12
|
||||||
|
|
||||||
|
[digest]
|
||||||
|
# Сколько исполнителей показывать в дайджесте (out/digest.md) поимённо.
|
||||||
|
# Остальные схлопываются в одну строку "ещё N исполнителей".
|
||||||
|
top_artists = 40
|
||||||
|
# Сколько альбомов показывать поимённо; остальные - в одну строку с метрикой "альбомы vs синглы".
|
||||||
|
top_albums = 15
|
||||||
|
|
||||||
[paths]
|
[paths]
|
||||||
token_file = ".token"
|
token_file = ".token"
|
||||||
cache_dir = "cache"
|
cache_dir = "cache"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
from . import apply as apply_mod
|
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 .config import load_config
|
||||||
from .ymclient import make_client
|
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)
|
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:
|
def cmd_dedupe(args: argparse.Namespace) -> None:
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout)
|
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 = sub.add_parser("dedupe", help="найти и убрать повторные вставки одного трека в плейлистах")
|
||||||
p_dedupe.add_argument("--yes", action="store_true", 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)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
commands = {
|
commands = {
|
||||||
|
|
@ -98,6 +132,7 @@ def main(argv: list[str] | None = None) -> None:
|
||||||
"report": cmd_report,
|
"report": cmd_report,
|
||||||
"apply": cmd_apply,
|
"apply": cmd_apply,
|
||||||
"dedupe": cmd_dedupe,
|
"dedupe": cmd_dedupe,
|
||||||
|
"digest": cmd_digest,
|
||||||
}
|
}
|
||||||
commands[args.command](args)
|
commands[args.command](args)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ class Config:
|
||||||
request_timeout: float
|
request_timeout: float
|
||||||
batch_size: int
|
batch_size: int
|
||||||
small_group_min: int
|
small_group_min: int
|
||||||
|
digest_top_artists: int
|
||||||
|
digest_top_albums: int
|
||||||
token_file: Path
|
token_file: Path
|
||||||
cache_dir: Path
|
cache_dir: Path
|
||||||
out_dir: Path
|
out_dir: Path
|
||||||
|
|
@ -32,6 +34,8 @@ def load_config(path: Path | None = None) -> Config:
|
||||||
request_timeout=data["throttle"]["request_timeout"],
|
request_timeout=data["throttle"]["request_timeout"],
|
||||||
batch_size=data["fetch"]["batch_size"],
|
batch_size=data["fetch"]["batch_size"],
|
||||||
small_group_min=data["classify"]["small_group_min"],
|
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"],
|
token_file=ROOT / data["paths"]["token_file"],
|
||||||
cache_dir=ROOT / data["paths"]["cache_dir"],
|
cache_dir=ROOT / data["paths"]["cache_dir"],
|
||||||
out_dir=ROOT / data["paths"]["out_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:
|
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 {
|
return {
|
||||||
"id": track.id,
|
"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 "",
|
"title": track.title or "",
|
||||||
"artists": [a.name for a in track.artists if a.name],
|
"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],
|
"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
|
return cache
|
||||||
|
|
||||||
all_ids = likes.tracks_ids
|
all_ids = likes.tracks_ids
|
||||||
# "not in cache" - трек ещё не загружен; "artist_ids" not in cache[tid] - трек загружен
|
# "not in cache" - трек ещё не загружен; "artist_ids"/"year" 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]]
|
# наличие ключа, а не правдивость значения - 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)}")
|
print(f"Лайкнутых треков: {len(all_ids)}, новых для загрузки: {len(missing_ids)}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,14 +97,23 @@ def flatten_catalog(roots: list[Genre]) -> dict[str, dict]:
|
||||||
return flat
|
return flat
|
||||||
|
|
||||||
|
|
||||||
def load_or_fetch_catalog(client: Client, cache_dir: Path) -> dict[str, dict]:
|
def load_catalog(cache_dir: Path) -> dict[str, dict]:
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
"""Снимок дерева жанров только из кэша, без сети. {} если снимка ещё нет."""
|
||||||
cache_file = cache_dir / GENRE_CATALOG_FILE
|
cache_file = cache_dir / GENRE_CATALOG_FILE
|
||||||
if cache_file.exists():
|
if cache_file.exists():
|
||||||
return json.loads(cache_file.read_text(encoding="utf-8"))
|
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())
|
roots = with_retries(lambda: client.genres())
|
||||||
flat = flatten_catalog(roots)
|
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")
|
cache_file.write_text(json.dumps(flat, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
return flat
|
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:
|
class FakeAlbum:
|
||||||
def __init__(self, id_, genre=None):
|
def __init__(self, id_, genre=None, title="", year=None):
|
||||||
self.id = id_
|
self.id = id_
|
||||||
self.genre = genre
|
self.genre = genre
|
||||||
|
self.title = title
|
||||||
|
self.year = year
|
||||||
|
|
||||||
|
|
||||||
class FakeTrack:
|
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]
|
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):
|
def test_fetch_artist_genres_batches_and_caches(tmp_path: Path):
|
||||||
cache_dir = tmp_path
|
cache_dir = tmp_path
|
||||||
tracks_cache = {
|
tracks_cache = {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue