From 187c30b4abc1c1bbf8da7051a2e148aa143b15e9 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Thu, 6 Aug 2026 16:13:11 +0300 Subject: [PATCH] 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. --- ARCHITECTURE.md | 36 ++++- ARCHITECTURE.ru.md | 36 ++++- README.md | 59 +++++++- README.ru.md | 60 +++++++- config.toml | 7 + sort_ym/cli.py | 37 ++++- sort_ym/config.py | 4 + sort_ym/digest.py | 344 +++++++++++++++++++++++++++++++++++++++++++ sort_ym/fetch.py | 20 ++- sort_ym/genres.py | 13 +- tests/test_digest.py | 253 +++++++++++++++++++++++++++++++ tests/test_fetch.py | 66 ++++++++- 12 files changed, 916 insertions(+), 19 deletions(-) create mode 100644 sort_ym/digest.py create mode 100644 tests/test_digest.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c3f9c0a..c726628 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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). diff --git a/ARCHITECTURE.ru.md b/ARCHITECTURE.ru.md index f343bfc..22e5ba2 100644 --- a/ARCHITECTURE.ru.md +++ b/ARCHITECTURE.ru.md @@ -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 (топ-исполнители, топ-альбомы, + распределение по жанрам, языку и десятилетиям). diff --git a/README.md b/README.md index b82b2df..3bb8a24 100644 --- a/README.md +++ b/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). diff --git a/README.ru.md b/README.ru.md index 87e0e59..4b2f7f7 100644 --- a/README.ru.md +++ b/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). diff --git a/config.toml b/config.toml index 0c50109..a5096f1 100644 --- a/config.toml +++ b/config.toml @@ -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" diff --git a/sort_ym/cli.py b/sort_ym/cli.py index 372baf2..aa775ce 100644 --- a/sort_ym/cli.py +++ b/sort_ym/cli.py @@ -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) diff --git a/sort_ym/config.py b/sort_ym/config.py index a91fd3f..0730f12 100644 --- a/sort_ym/config.py +++ b/sort_ym/config.py @@ -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"], diff --git a/sort_ym/digest.py b/sort_ym/digest.py new file mode 100644 index 0000000..8756b01 --- /dev/null +++ b/sort_ym/digest.py @@ -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 diff --git a/sort_ym/fetch.py b/sort_ym/fetch.py index 377119f..f226cc6 100644 --- a/sort_ym/fetch.py +++ b/sort_ym/fetch.py @@ -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)}") diff --git a/sort_ym/genres.py b/sort_ym/genres.py index 4e24a02..c2eae38 100644 --- a/sort_ym/genres.py +++ b/sort_ym/genres.py @@ -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 diff --git a/tests/test_digest.py b/tests/test_digest.py new file mode 100644 index 0000000..b5b9587 --- /dev/null +++ b/tests/test_digest.py @@ -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 diff --git a/tests/test_fetch.py b/tests/test_fetch.py index 1e0616c..040ec9b 100644 --- a/tests/test_fetch.py +++ b/tests/test_fetch.py @@ -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 = {