From 0902d86bcd01ccf101e31e41467eac8e71b098b3 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Mon, 6 Jul 2026 14:26:49 +0300 Subject: [PATCH] classify tracks by combined track + artist genre signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше жанр брался только с альбома (одна строка, часто общая: rock/alternative/pop) - разные по звучанию артисты сваливались в одну крупную корзину. Теперь classify_track сводит жанр трека и жанры артиста (client.artists(), несколько тегов на артиста) на уровне под-жанра: при согласии сигналов или отсутствии одного из них берём имеющийся; при конфликте - если альбомный тег зонтичный (GENERIC_SLUGS) доверяем артисту, если точный - треку (вероятный осознанный эксперимент артиста в другом жанре). Один и тот же артист с разными по духу треками теперь раскладывается по разным плейлистам. Под-жанровые группы меньше config.small_group_min схлопываются в родительскую крупную корзину (report.build_rows, двухпроходно, без циклов - coarse_of детерминирована от fine). Попутно: artist_ids трека может быть None (артист без привязки к каталогу Яндекса, встречается у стороннего/пиратского аплоада) - раньше это улетало в client.artists() как строка "None" и валилось BadRequestError на батче; теперь такие id отфильтровываются на записи и дополнительно на чтении кэша. --- ARCHITECTURE.md | 62 ++++++++++++++++----- README.md | 31 ++++++++--- config.toml | 7 ++- sort_ym/classify.py | 48 +++++++++++++++- sort_ym/cli.py | 7 ++- sort_ym/config.py | 2 + sort_ym/fetch.py | 58 ++++++++++++++++++- sort_ym/genres.py | 81 +++++++++++++++++++++++++-- sort_ym/report.py | 30 +++++++++- tests/test_fetch.py | 129 ++++++++++++++++++++++++++++++++++++++++++- tests/test_genres.py | 51 ++++++++++++++++- tests/test_report.py | 64 +++++++++++++++++++++ 12 files changed, 530 insertions(+), 40 deletions(-) create mode 100644 tests/test_report.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4d6fbc3..ce4dc76 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -7,19 +7,24 @@ ``` sort_ym/ - config.py — чтение config.toml (throttle, пути к кэшу/токену) + config.py — чтение config.toml (throttle, small_group_min, пути к кэшу/токену) auth.py — OAuth device-flow, хранение токена в .token - ymclient.py — создание Client, батч-нарезка списков - fetch.py — лайки -> полные Track -> cache/tracks.json (докачивается инкрементально) - genres.py — реальное дерево жанров Яндекса -> 11 крупных корзин (ROOT_BUCKET) + ymclient.py — создание Client, батч-нарезка списков, ретраи сетевых ошибок + fetch.py — лайки -> полные Track -> cache/tracks.json; артисты треков -> + client.artists() -> cache/artist_genres.json (оба докачиваются инкрементально) + genres.py — реальное дерево жанров Яндекса; classify_track — сведение жанра трека и жанров + артиста в один под-жанр (root_id); ROOT_BUCKET — под-жанр -> 11 крупных корзин language.py — гибридная детекция языка (API текста / жанр-подсказка / алфавит) - classify.py — (корзина, язык) -> имя плейлиста - report.py — сборка строк отчёта, запись out/report.csv, сводки + classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста + report.py — двухпроходная сборка строк отчёта, запись out/report.csv, сводки apply.py — создание плейлистов + идемпотентное добавление треков cli.py — команды auth / fetch / report / apply tests/ - test_genres.py, test_language.py — тесты чистых функций (маппинг, детекция языка) + test_genres.py — classify_track, dominant, bucket_for, дерево жанров + test_language.py — детекция языка + test_report.py — двухпроходное схлопывание мелких под-жанровых групп + test_fetch.py, test_apply.py — кэш треков/артистов, идемпотентность применения cache/ — кэш API (в .gitignore) out/ — отчёты (в .gitignore) @@ -29,20 +34,44 @@ out/ — отчёты (в .gitignore) ``` auth → .token -fetch → users_likes_tracks() → client.tracks(batch) → cache/tracks.json +fetch → users_likes_tracks() → client.tracks(batch) → cache/tracks.json (+ artist_ids) + → client.artists(batch) по уникальным artist_ids → cache/artist_genres.json report → genres.load_or_fetch_catalog() → cache/genre_catalog.json → language.fetch_api_languages() → cache/lyrics_lang.json - → build_rows() → out/report.csv (dry-run, аккаунт не меняется) + → build_rows(): проход 1 — classify_track() на каждый трек (genre_raw + жанры артиста) + проход 2 — группы < small_group_min схлопываются в крупную корзину + → out/report.csv (dry-run, аккаунт не меняется) apply --yes → users_playlists_create / users_playlists_insert_track ``` ## Ключевые решения - **Жанр трека** доступен только через альбом (`Track.albums[0].genre`), у самого трека поля - жанра нет. Раскладка на 11 крупных групп сделана через реальное дерево `client.genres()` - (родитель → под-жанры), а не через угаданный список слагов — так таблица `ROOT_BUCKET` в - `genres.py` опирается на подтверждённые id, а не на предположения. Жанры без совпадения падают - в корзину `other` и печатаются в выводе `report` для ручного дополнения таблицы. + жанра нет — это одна строка на трек, часто общая (`rock`, `alternative`). Раскладка по корзинам + сделана через реальное дерево `client.genres()` (родитель → под-жанры), а не через угаданный + список слагов — таблица `ROOT_BUCKET` в `genres.py` опирается на подтверждённые id, а не на + предположения. Жанры без совпадения падают в корзину `other` и печатаются в выводе `report` для + ручного дополнения таблицы. + +- **Под-жанровая классификация (`genres.classify_track`)**: жанр трека — это одна строка на альбом + и часто общая. У артиста же (`client.artists()`, батч-эндпоинт) может быть несколько тегов, + точнее одного альбомного. `classify_track(genre_raw, artist_genre_lists, catalog)` сводит оба + сигнала в один под-жанр (`root_id`, например `punk`/`indie`/`allrock`) на уровне **трека**, а не + артиста — так один и тот же артист с разными по духу треками раскладывается по разным плейлистам: + 1. Согласие сигналов (жанр трека есть среди жанров артиста) → берём его — гранулярно по треку. + 2. У трека нет жанра → берём самый частый (`dominant`) жанр среди артистов трека. + 3. У артиста(ов) нет тегов → берём жанр трека. + 4. Конфликт: альбомный тег общий/"зонтичный" (`GENERIC_SLUGS`: `rock`, `alternative`, `pop`, ... — + дефолт Яндекса низкой уверенности) → побеждает жанр артиста (`dominant` по объединённым тегам + всех артистов трека, тай-брейк — по порядку появления). Альбомный тег точный/специфичный + (`numetal`, `dnb`, ...) → побеждает он — вероятный настоящий эксперимент артиста в другом + жанре, а не шум, который стоило бы поправить. + + Результат (`fine`) — двухпроходно схлопывается в `report.build_rows`: сначала считаются размеры + групп `(fine, lang)`, затем группы меньше `config.small_group_min` переезжают в родительскую + крупную корзину через `genres.coarse_of(fine)` (тонкая обёртка над `ROOT_BUCKET`). Проход + одиночный (не итеративный) — `coarse_of` детерминирована от `fine`, поэтому пересчёта размеров и + зацикливания нет. - **Язык** определяется гибридно, в порядке приоритета: 1. `client.track_supplement(track_id).lyrics.text_language` — единственное поле в библиотеке @@ -69,7 +98,10 @@ apply --yes → users_playlists_create / users_playlists_insert_track ## Хранилище - `.token` — OAuth-токен аккаунта (не коммитится). -- `cache/tracks.json` — метаданные лайкнутых треков. +- `cache/tracks.json` — метаданные лайкнутых треков, включая `artist_ids` (id артистов трека). +- `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`. +- `out/report.csv` — предпросмотр распределения по плейлистам перед `apply` (включая колонку + `fine_bucket` — под-жанр до возможного схлопывания в крупную корзину). diff --git a/README.md b/README.md index 7fc399a..9d9f422 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ python -m venv .venv ``` .venv\Scripts\python -m sort_ym auth # войти в аккаунт Яндекса через device-flow и сохранить токен в .token -.venv\Scripts\python -m sort_ym fetch # скачать лайкнутые треки и их метаданные в cache/tracks.json (аккаунт не меняет) +.venv\Scripts\python -m sort_ym fetch # скачать лайкнутые треки (cache/tracks.json) и жанры их артистов (cache/artist_genres.json); аккаунт не меняет .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 # создать плейлисты и добавить в них все треки (меняет аккаунт) @@ -49,8 +49,9 @@ python -m venv .venv Remove-Item -Recurse -Force cache ``` -Кэш состоит из трёх файлов: -- `cache/tracks.json` — список лайкнутых треков и их метаданные; +Кэш состоит из четырёх файлов: +- `cache/tracks.json` — список лайкнутых треков и их метаданные (включая id артистов); +- `cache/artist_genres.json` — жанровые теги каждого артиста (может быть несколько на артиста); - `cache/genre_catalog.json` — снимок дерева жанров Яндекса на момент последнего запуска; - `cache/lyrics_lang.json` — определённые языки текстов для каждого трека. @@ -72,10 +73,25 @@ Remove-Item .token ## Как определяется жанр и язык -- **Жанр**: у трека берётся жанр альбома, затем он сопоставляется с одной из ~11 крупных - групп (Поп, Рок, Метал, Рэп, Электроника, Джаз и блюз, Классика, Фолк и world, Инди и - альтернатива, Саундтреки, Разное) через реальное дерево жанров Яндекса - (`sort_ym/genres.py`, таблица `ROOT_BUCKET` — легко дополняется). +- **Жанр** определяется на **под-жанровом** уровне (Рок, Панк, Инди, Альтернатива, Метал, ..., + не только 11 крупных групп), комбинируя два сигнала: + 1. Жанр **альбома** трека (`genre_raw`) — сигнал по конкретному треку. + 2. Жанры **артиста** (`Artist.genres`, может быть несколько тегов на артиста, точнее одного + альбомного жанра) — сигнал по исполнителю в целом. + + Если сигналы совпадают — берётся под-жанр как есть. При конфликте: если альбомный тег общий + ("зонтичный", вроде `rock`/`alternative`/`pop` — типичный дефолт Яндекса низкой уверенности) — + побеждает жанр артиста; если альбомный тег точный/специфичный (`numetal`, `dnb`, `shanson`) — + побеждает он, потому что это, скорее всего, реальный эксперимент артиста в другом жанре, а не шум. + Так один и тот же артист с разными по духу треками (например, рОк-трек и панк-трек) раскладывается + по разным плейлистам, а не весь скопом по одному "основному" жанру. + + Под-жанровые группы меньше `small_group_min` (по умолчанию 12 треков, `config.toml`) схлопываются + в родительскую крупную корзину (Поп, Рок, Метал, Рэп, Электроника, Джаз и блюз, Классика, Фолк и + world, Инди и альтернатива, Саундтреки, Разное), чтобы не плодить плейлисты на 1-2 трека. + Реализация — `sort_ym/genres.py` (`classify_track`, `ROOT_BUCKET`) — таблица корней легко + дополняется, если Яндекс вернёт незнакомый корневой жанр. + - **Язык** — гибрид: 1. Если у трека есть текст песни — реальный язык из API (`ru` → RU, иначе → INT). 2. Иначе — подсказка по жанру (`rusrap`, `shanson`, `bard`, ...). @@ -90,6 +106,7 @@ Remove-Item .token - Первый прогон `apply` делайте с `--limit 20 --yes` и проверьте результат в приложении, прежде чем запускать на все ~2000 треков. - Настройки задержек между запросами — в `config.toml` (`[throttle]`). +- Минимальный размер под-жанрового плейлиста — `config.toml` (`[classify] small_group_min`). ## Риски (неофициальный API) diff --git a/config.toml b/config.toml index ca8191b..0c50109 100644 --- a/config.toml +++ b/config.toml @@ -7,9 +7,14 @@ apply_request_delay = 0.3 request_timeout = 20 [fetch] -# Сколько id треков в одном батч-запросе client.tracks(...) +# Сколько id треков в одном батч-запросе client.tracks(...) / client.artists(...) batch_size = 100 +[classify] +# Минимальный размер под-жанровой группы (fine, lang), чтобы получить отдельный плейлист. +# Меньшие группы схлопываются в родительскую крупную корзину (rock/indie-alt/...). +small_group_min = 12 + [paths] token_file = ".token" cache_dir = "cache" diff --git a/sort_ym/classify.py b/sort_ym/classify.py index f74a02e..75a264c 100644 --- a/sort_ym/classify.py +++ b/sort_ym/classify.py @@ -1,5 +1,7 @@ from __future__ import annotations +# Крупные корзины (11 штук) - используются, когда под-жанровая группа слишком мала +# и схлопывается в родителя (см. report.build_rows). BUCKET_LABELS: dict[str, str] = { "pop": "Поп", "rock": "Рок", @@ -14,6 +16,49 @@ BUCKET_LABELS: dict[str, str] = { "other": "Разное", } +# Под-жанры (root_id из реального дерева client.genres()) - используются, когда группа +# достаточно большая, чтобы получить отдельный плейлист (см. config.small_group_min). +FINE_LABELS: dict[str, str] = { + "allrock": "Рок", + "alternative": "Альтернатива", + "indie": "Инди", + "punk": "Панк", + "metal": "Метал", + "rap": "Рэп", + "electronics": "Электроника", + "dance": "Танцевальная", + "jazz": "Джаз", + "blues": "Блюз", + "classicalmusic": "Классика", + "pop": "Поп", + "estrada": "Эстрада", + "rnb": "R&B", + "folk": "Музыка мира", + "folkgenre": "Фолк", + "bard": "Авторская песня", + "shanson": "Шансон", + "country": "Кантри", + "reggae": "Регги", + "ska": "Ска", + "islamicgenre": "Исламская музыка", + "soundtrack": "Саундтреки", + "all": "Микс", + "other": "Разное", + "relax": "Лёгкая музыка", + "children": "Детская музыка", + "forchildren": "Детская", + "poemsforchildren": "Стихи для детей", + "fairytales": "Аудиосказки", + "audiobooks": "Разговорное", + "booksnotinrussian": "Аудиокниги на ин. языке", + "fiction": "Художественная литература", + "nonfictionliterature": "Нон-фикшн", + "podcasts": "Подкасты", + "naturesounds": "Звуки природы", +} + +_ALL_LABELS: dict[str, str] = {**FINE_LABELS, **BUCKET_LABELS} + LANG_LABELS: dict[str, str] = { "RU": "RU", "INT": "INT", @@ -22,6 +67,7 @@ LANG_LABELS: dict[str, str] = { def playlist_name(bucket: str, lang: str) -> str: - bucket_label = BUCKET_LABELS.get(bucket, BUCKET_LABELS["other"]) + """bucket - либо под-жанр (root_id), либо крупная корзина (после схлопывания).""" + bucket_label = _ALL_LABELS.get(bucket, BUCKET_LABELS["other"]) lang_label = LANG_LABELS.get(lang, LANG_LABELS["UNKNOWN"]) return f"{bucket_label} — {lang_label}" diff --git a/sort_ym/cli.py b/sort_ym/cli.py index 697bf30..5291904 100644 --- a/sort_ym/cli.py +++ b/sort_ym/cli.py @@ -17,6 +17,7 @@ def cmd_fetch(args: argparse.Namespace) -> None: cfg = load_config() client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout) fetch.fetch_liked_tracks(client, cfg.cache_dir, cfg.batch_size, cfg.fetch_batch_delay) + fetch.fetch_artist_genres(client, cfg.cache_dir, cfg.batch_size, cfg.fetch_batch_delay) def cmd_report(args: argparse.Namespace) -> None: @@ -28,11 +29,12 @@ def cmd_report(args: argparse.Namespace) -> None: raise SystemExit("Кэш треков пуст. Сначала запустите: python -m sort_ym fetch") catalog = genres.load_or_fetch_catalog(client, cfg.cache_dir) + artist_genres = fetch.load_artist_genres(cfg.cache_dir) ids_needing_lang = [str(t["id"]) for t in tracks_cache.values() if t["lyrics_available"]] lang_cache = language.fetch_api_languages(client, ids_needing_lang, cfg.cache_dir, cfg.lyrics_request_delay) - rows = report.build_rows(tracks_cache, lang_cache, catalog) + rows = report.build_rows(tracks_cache, lang_cache, catalog, artist_genres, cfg.small_group_min) out_file = report.write_report(rows, cfg.out_dir) print("\nРаспределение по плейлистам:") @@ -60,8 +62,9 @@ def cmd_apply(args: argparse.Namespace) -> None: raise SystemExit("Кэш треков пуст. Сначала запустите: python -m sort_ym fetch") catalog = genres.load_or_fetch_catalog(client, cfg.cache_dir) + 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) + rows = report.build_rows(tracks_cache, lang_cache, catalog, artist_genres, cfg.small_group_min) apply_mod.apply_classification(client, rows, cfg.apply_request_delay, limit=args.limit) diff --git a/sort_ym/config.py b/sort_ym/config.py index 14a26fc..a91fd3f 100644 --- a/sort_ym/config.py +++ b/sort_ym/config.py @@ -14,6 +14,7 @@ class Config: apply_request_delay: float request_timeout: float batch_size: int + small_group_min: int token_file: Path cache_dir: Path out_dir: Path @@ -30,6 +31,7 @@ def load_config(path: Path | None = None) -> Config: apply_request_delay=data["throttle"]["apply_request_delay"], request_timeout=data["throttle"]["request_timeout"], batch_size=data["fetch"]["batch_size"], + small_group_min=data["classify"]["small_group_min"], 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/fetch.py b/sort_ym/fetch.py index 96b64c4..377119f 100644 --- a/sort_ym/fetch.py +++ b/sort_ym/fetch.py @@ -9,6 +9,7 @@ from yandex_music import Client, Track from .ymclient import chunked, with_retries TRACKS_CACHE_FILE = "tracks.json" +ARTIST_GENRES_CACHE_FILE = "artist_genres.json" def _track_genre(track: Track) -> str | None: @@ -25,6 +26,7 @@ def _serialize(track: Track) -> dict: "album_id": album_id, "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], "genre_raw": _track_genre(track), "lyrics_available": bool(track.lyrics_available), } @@ -59,7 +61,9 @@ def fetch_liked_tracks( return cache all_ids = likes.tracks_ids - missing_ids = [tid for tid in all_ids if tid not in cache] + # "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]] print(f"Лайкнутых треков: {len(all_ids)}, новых для загрузки: {len(missing_ids)}") @@ -88,3 +92,55 @@ def fetch_liked_tracks( print(f" убрано из кэша (больше не лайкнуто): {len(stale)}") return cache + + +def load_artist_genres(cache_dir: Path) -> dict[str, dict]: + cache_file = cache_dir / ARTIST_GENRES_CACHE_FILE + if cache_file.exists(): + return json.loads(cache_file.read_text(encoding="utf-8")) + return {} + + +def fetch_artist_genres( + client: Client, + cache_dir: Path, + batch_size: int, + batch_delay: float, +) -> dict[str, dict]: + """Догружает жанры артистов (Artist.genres - несколько тегов, точнее одного альбомного genre_raw). + + client.artists() - батч-эндпоинт (как client.tracks()), поэтому даже на тысячу с лишним + уникальных артистов уходит около десятка запросов, а не один на артиста. + """ + cache_dir.mkdir(parents=True, exist_ok=True) + cache_file = cache_dir / ARTIST_GENRES_CACHE_FILE + cache = load_artist_genres(cache_dir) + + tracks_cache = load_tracks_cache(cache_dir) + # aid может быть None - у части треков сторонней/пиратской загрузки артист в API Яндекса + # не привязан к id (есть только имя). Такие пропускаем, их жанр возьмём только по треку. + all_artist_ids = { + str(aid) + for t in tracks_cache.values() + for aid in t.get("artist_ids", []) + if aid is not None + } + missing_ids = sorted(aid for aid in all_artist_ids if aid not in cache) + + if not missing_ids: + return cache + + print(f"Уникальных артистов: {len(all_artist_ids)}, новых для загрузки: {len(missing_ids)}") + + for batch in chunked(missing_ids, batch_size): + artists = with_retries(lambda: client.artists(batch)) + for artist in artists: + cache[str(artist.id)] = { + "name": artist.name or "", + "genres": list(artist.genres or []), + } + _atomic_write_json(cache_file, cache) + print(f" загружено {len(cache)}/{len(all_artist_ids)}") + time.sleep(batch_delay) + + return cache diff --git a/sort_ym/genres.py b/sort_ym/genres.py index 001cc8f..4e24a02 100644 --- a/sort_ym/genres.py +++ b/sort_ym/genres.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from collections import Counter from pathlib import Path from yandex_music import Client, Genre @@ -9,6 +10,22 @@ from .ymclient import with_retries GENRE_CATALOG_FILE = "genre_catalog.json" +# Общие "зонтичные" альбомные жанры - сигнал низкой уверенности: Яндекс вешает их на +# альбом по умолчанию, когда не может/не хочет уточнить жанр. Ровно это и создаёт шум, +# который classify_track чинит через жанры артиста. +GENERIC_SLUGS = { + "rock", + "alternative", + "indie", + "pop", + "electronics", + "dance", + "metal", + "rap", + "jazz", + "folk", +} + # Корневые жанры каталога Яндекс.Музыки -> наши крупные корзины. # Таблица построена по реальному дереву client.genres() (все 34 актуальных корня на момент # написания), а не по предположениям об именах слагов — проверено на живом каталоге @@ -92,9 +109,63 @@ def load_or_fetch_catalog(client: Client, cache_dir: Path) -> dict[str, dict]: return flat -def bucket_for(genre_raw: str | None, catalog: dict[str, dict]) -> str: - if not genre_raw: +def fine_of(slug: str | None, catalog: dict[str, dict]) -> str | None: + """Слаг жанра (любой глубины) -> id корневого жанра ("тонкая" под-жанровая классификация).""" + if not slug: + return None + entry = catalog.get(slug) + return entry["root_id"] if entry else slug + + +def coarse_of(fine: str | None) -> str: + """Корневой жанр -> одна из 11 крупных корзин ("грубая" классификация).""" + if not fine: return "other" - entry = catalog.get(genre_raw) - root_id = entry["root_id"] if entry else genre_raw - return ROOT_BUCKET.get(root_id, "other") + return ROOT_BUCKET.get(fine, "other") + + +def bucket_for(genre_raw: str | None, catalog: dict[str, dict]) -> str: + return coarse_of(fine_of(genre_raw, catalog)) + + +def dominant(fines: list[str]) -> str | None: + """Самый частый элемент; при равенстве частот - первый по порядку появления.""" + if not fines: + return None + return Counter(fines).most_common(1)[0][0] + + +def classify_track( + genre_raw: str | None, + artist_genre_lists: list[list[str]], + catalog: dict[str, dict], +) -> str: + """Сводит жанр трека (альбома) и жанры его артиста(ов) в один под-жанр (root_id). + + Args: + genre_raw: жанр альбома трека. + artist_genre_lists: список списков жанровых слагов - один список на артиста трека, + в порядке артистов (первый - главный). + catalog: плоский каталог жанров (см. load_or_fetch_catalog). + + Returns: + root_id под-жанра ("punk", "indie", "allrock", ...) или "other". + """ + track_fine = fine_of(genre_raw, catalog) + + artist_fines: list[str] = [] + for genre_list in artist_genre_lists: + for slug in genre_list: + fine = fine_of(slug, catalog) + if fine: + artist_fines.append(fine) + + if not track_fine: + return dominant(artist_fines) or "other" + if not artist_fines: + return track_fine + if track_fine in artist_fines: + return track_fine + if genre_raw in GENERIC_SLUGS: + return dominant(artist_fines) + return track_fine diff --git a/sort_ym/report.py b/sort_ym/report.py index bf0fd43..180e2e9 100644 --- a/sort_ym/report.py +++ b/sort_ym/report.py @@ -5,25 +5,49 @@ from pathlib import Path from . import classify, genres, language -FIELDNAMES = ["title", "artists", "genre_raw", "bucket", "lang", "target_playlist", "id", "album_id"] +FIELDNAMES = ["title", "artists", "genre_raw", "fine_bucket", "bucket", "lang", "target_playlist", "id", "album_id"] def build_rows( tracks_cache: dict[str, dict], lang_cache: dict[str, str | None], genre_catalog: dict[str, dict], + artist_genres: dict[str, dict], + small_group_min: int, ) -> list[dict]: - rows = [] + # Проход 1: под-жанр (fine) и язык на каждый трек, размеры групп (fine, lang) фиксируем сразу. + prelim = [] + group_sizes: dict[tuple[str, str], int] = {} for t in tracks_cache.values(): - bucket = genres.bucket_for(t["genre_raw"], genre_catalog) + artist_lists = [ + artist_genres[str(aid)]["genres"] + for aid in t.get("artist_ids", []) + if str(aid) in artist_genres + ] + fine = genres.classify_track(t["genre_raw"], artist_lists, genre_catalog) api_lang = lang_cache.get(str(t["id"])) lang = language.detect_language(t["title"], t["artists"], t["genre_raw"], api_lang) + + prelim.append((t, fine, lang)) + key = (fine, lang) + group_sizes[key] = group_sizes.get(key, 0) + 1 + + # Проход 2 (одиночный, не итеративный): мелкие под-жанровые группы схлопываем в + # родительскую крупную корзину. coarse_of детерминирована от fine, поэтому повторного + # пересчёта размеров и зацикливания тут не бывает. + rows = [] + for t, fine, lang in prelim: + if group_sizes[(fine, lang)] < small_group_min: + bucket = genres.coarse_of(fine) + else: + bucket = fine playlist = classify.playlist_name(bucket, lang) rows.append( { "title": t["title"], "artists": ", ".join(t["artists"]), "genre_raw": t["genre_raw"] or "", + "fine_bucket": fine, "bucket": bucket, "lang": lang, "target_playlist": playlist, diff --git a/tests/test_fetch.py b/tests/test_fetch.py index 01c15f9..1e0616c 100644 --- a/tests/test_fetch.py +++ b/tests/test_fetch.py @@ -1,11 +1,14 @@ +import json from pathlib import Path from sort_ym import fetch class FakeArtist: - def __init__(self, name): + def __init__(self, id_, name, genres=None): + self.id = id_ self.name = name + self.genres = genres class FakeAlbum: @@ -19,7 +22,7 @@ class FakeTrack: self.id = id_ self.albums = albums self.title = title - self.artists = artists or [FakeArtist("Artist")] + self.artists = artists or [FakeArtist(1, "Artist")] self.lyrics_available = lyrics_available @@ -33,9 +36,10 @@ class FakeClient: отличается от альбома, под которым трек значится в списке лайков - это реальное поведение API Яндекса для треков на нескольких альбомах.""" - def __init__(self, likes_ids, tracks_by_batch): + def __init__(self, likes_ids, tracks_by_batch, artists_by_batch=None): self._likes_ids = likes_ids self._tracks_by_batch = tracks_by_batch + self._artists_by_batch = artists_by_batch or {} def users_likes_tracks(self): return FakeLikes(self._likes_ids) @@ -43,6 +47,9 @@ class FakeClient: def tracks(self, batch): return self._tracks_by_batch[tuple(batch)] + def artists(self, batch): + return self._artists_by_batch[tuple(sorted(batch))] + def test_fetch_caches_under_original_liked_id_not_recomputed_track_id(tmp_path: Path): # Трек лайкнут как "100:10", но Track.albums[0].id из ответа API - 999 (другой альбом). @@ -72,3 +79,119 @@ def test_fetch_does_not_purge_track_with_mismatched_album_as_stale(tmp_path: Pat cache = fetch.fetch_liked_tracks(client, tmp_path, batch_size=100, batch_delay=0) assert len(cache) == 1, f"трек не должен быть вычищен как stale сразу после загрузки: {cache}" + + +def test_fetch_stores_artist_ids(tmp_path: Path): + track = FakeTrack(id_=100, albums=[FakeAlbum(10)], artists=[FakeArtist(7, "Artist A")]) + client = FakeClient(likes_ids=["100:10"], tracks_by_batch={("100:10",): [track]}) + + cache = fetch.fetch_liked_tracks(client, tmp_path, batch_size=100, batch_delay=0) + + assert cache["100:10"]["artist_ids"] == [7] + + +def test_fetch_skips_artist_with_no_id(tmp_path: Path): + # Регрессия: некоторые треки (сторонняя/пиратская загрузка) имеют артиста с именем, + # но без id в каталоге Яндекса (Artist.id is None). Раньше None попадал в artist_ids, + # затем str(None) = "None" улетал в client.artists() и API отвечал BadRequestError. + track = FakeTrack(id_=100, albums=[FakeAlbum(10)], artists=[FakeArtist(None, "Unlinked Artist")]) + client = FakeClient(likes_ids=["100:10"], tracks_by_batch={("100:10",): [track]}) + + cache = fetch.fetch_liked_tracks(client, tmp_path, batch_size=100, batch_delay=0) + + assert cache["100:10"]["artist_ids"] == [], "артист без id не должен попадать в artist_ids" + + +def test_fetch_artist_genres_ignores_none_ids_in_old_cache(tmp_path: Path): + # Защита на уровне fetch_artist_genres даже если в tracks.json уже есть None + # (например, кэш был написан до фикса выше). + cache_dir = tmp_path + tracks_cache = { + "100:10": {"id": 100, "album_id": 10, "title": "T1", "artists": ["Unlinked"], "artist_ids": [None], "genre_raw": "pop", "lyrics_available": False}, + } + (cache_dir / fetch.TRACKS_CACHE_FILE).write_text( + json.dumps(tracks_cache, ensure_ascii=False), encoding="utf-8" + ) + + def boom(batch): + raise AssertionError(f"не должен запрашивать None как id артиста: {batch}") + + client = FakeClient(likes_ids=[], tracks_by_batch={}) + client.artists = boom + + result = fetch.fetch_artist_genres(client, cache_dir, batch_size=100, batch_delay=0) + + assert result == {} + + +def test_fetch_re_downloads_old_cache_entries_missing_artist_ids(tmp_path: Path): + # Старый формат кэша (до добавления artist_ids) должен быть докачан повторно. + cache_dir = tmp_path + cache_dir.mkdir(exist_ok=True) + old_entry = { + "id": 100, + "album_id": 10, + "title": "Old", + "artists": ["Artist A"], + "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)], 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 "artist_ids" in cache["100:10"], "запись без artist_ids должна быть докачана заново" + assert cache["100:10"]["artist_ids"] == [7] + + +def test_fetch_artist_genres_batches_and_caches(tmp_path: Path): + cache_dir = tmp_path + tracks_cache = { + "100:10": {"id": 100, "album_id": 10, "title": "T1", "artists": ["A"], "artist_ids": [7], "genre_raw": "pop", "lyrics_available": False}, + "200:20": {"id": 200, "album_id": 20, "title": "T2", "artists": ["B"], "artist_ids": [9], "genre_raw": "rock", "lyrics_available": False}, + } + (cache_dir / fetch.TRACKS_CACHE_FILE).write_text( + json.dumps(tracks_cache, ensure_ascii=False), encoding="utf-8" + ) + + client = FakeClient( + likes_ids=[], + tracks_by_batch={}, + artists_by_batch={("7", "9"): [FakeArtist(7, "Artist A", genres=["indie"]), FakeArtist(9, "Artist B", genres=["rock", "punk"])]}, + ) + + result = fetch.fetch_artist_genres(client, cache_dir, batch_size=100, batch_delay=0) + + assert result["7"] == {"name": "Artist A", "genres": ["indie"]} + assert result["9"] == {"name": "Artist B", "genres": ["rock", "punk"]} + + persisted = fetch.load_artist_genres(cache_dir) + assert persisted == result + + +def test_fetch_artist_genres_skips_already_cached(tmp_path: Path): + cache_dir = tmp_path + tracks_cache = { + "100:10": {"id": 100, "album_id": 10, "title": "T1", "artists": ["A"], "artist_ids": [7], "genre_raw": "pop", "lyrics_available": False}, + } + (cache_dir / fetch.TRACKS_CACHE_FILE).write_text( + json.dumps(tracks_cache, ensure_ascii=False), encoding="utf-8" + ) + (cache_dir / fetch.ARTIST_GENRES_CACHE_FILE).write_text( + json.dumps({"7": {"name": "Artist A", "genres": ["indie"]}}, ensure_ascii=False), encoding="utf-8" + ) + + def boom(batch): + raise AssertionError("не должен запрашивать уже закэшированного артиста") + + client = FakeClient(likes_ids=[], tracks_by_batch={}) + client.artists = boom + + result = fetch.fetch_artist_genres(client, cache_dir, batch_size=100, batch_delay=0) + + assert result == {"7": {"name": "Artist A", "genres": ["indie"]}} diff --git a/tests/test_genres.py b/tests/test_genres.py index e4c48eb..30218db 100644 --- a/tests/test_genres.py +++ b/tests/test_genres.py @@ -5,15 +5,23 @@ from sort_ym import genres @pytest.fixture def catalog(): - # Форма реального дерева client.genres(): "rock"/"rusrock" - подгенры, реальный - # корень называется "allrock", а не "rock" (проверено на живом каталоге). + # Форма реального дерева client.genres(): "rock"/"rusrock"/"hardrock"/"dnb" - подгенры, + # "allrock"/"alternative"/"indie"/"punk"/"rap"/"metal" - настоящие корни (root_id == сам слаг). + # Проверено на живом каталоге (cache/genre_catalog.json). return { "allrock": {"title": "Рок", "root_id": "allrock"}, "rock": {"title": "Иностранный рок", "root_id": "allrock"}, "rusrock": {"title": "Русский рок", "root_id": "allrock"}, + "hardrock": {"title": "Хард-рок", "root_id": "allrock"}, "alternative": {"title": "Альтернатива", "root_id": "alternative"}, + "indie": {"title": "Инди", "root_id": "indie"}, + "punk": {"title": "Панк", "root_id": "punk"}, + "metal": {"title": "Метал", "root_id": "metal"}, + "numetal": {"title": "Ню-метал", "root_id": "metal"}, "rap": {"title": "Рэп и хип-хоп", "root_id": "rap"}, "trap": {"title": "Трэп", "root_id": "rap"}, + "electronics": {"title": "Электроника", "root_id": "electronics"}, + "dnb": {"title": "Драм-н-бейс", "root_id": "electronics"}, } @@ -48,3 +56,42 @@ def test_flatten_catalog_walks_sub_genres(): assert flat["rock"] == {"title": "Рок", "root_id": "rock"} assert flat["rusrock"] == {"title": "Русский рок", "root_id": "rock"} + + +@pytest.mark.parametrize( + "fines,expected", + [ + ([], None), + (["indie"], "indie"), + (["indie", "metal", "indie"], "indie"), + (["indie", "metal"], "indie"), # равенство частот -> первый по порядку + ], +) +def test_dominant(fines, expected): + assert genres.dominant(fines) == expected + + +@pytest.mark.parametrize( + "genre_raw,artist_genre_lists,expected", + [ + # согласие трека и артиста -> берём фактический (гранулярно по треку) + ("punk", [["rock", "punk"]], "punk"), + # конфликт: зонтичный альбомный тег ("rock" в GENERIC_SLUGS) -> доверяем артисту + # (кейс New Politics: альбом стоит "rock", у артиста только "indie") + ("rock", [["indie"]], "indie"), + # конфликт: точный альбомный тег (не в GENERIC_SLUGS) -> доверяем треку, даже если + # у артиста другой основной жанр (настоящий эксперимент артиста в ином жанре) + ("dnb", [["indie"]], "electronics"), + # пустой genre_raw -> едем по артисту + (None, [["indie"]], "indie"), + ("", [["indie"]], "indie"), + # у артиста нет тегов -> едем по треку (genre_raw "rock" резолвится в корень "allrock") + ("rock", [[]], "allrock"), + # несколько артистов на треке -> голосование dominant по объединённым тегам + ("rock", [["indie"], ["indie", "metal"]], "indie"), + # ничего не помогло + (None, [[]], "other"), + ], +) +def test_classify_track(catalog, genre_raw, artist_genre_lists, expected): + assert genres.classify_track(genre_raw, artist_genre_lists, catalog) == expected diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..578e9d6 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,64 @@ +from sort_ym import report + +CATALOG: dict = {} # classify_track с пустыми artist_genre_lists использует только genre_raw + + +def _track(id_, title, artists, genre_raw, artist_ids=None): + return { + "id": id_, + "album_id": id_ * 10, + "title": title, + "artists": artists, + "artist_ids": artist_ids or [], + "genre_raw": genre_raw, + "lyrics_available": False, + } + + +def test_small_fine_group_collapses_to_coarse_bucket(): + # 15 "punk"-треков (>= small_group_min=12) остаются отдельным под-жанром, + # 2 "indie"-трека (< 12) схлопываются в родительскую крупную корзину "indie-alt". + tracks_cache = {} + for i in range(15): + tid = f"{i}:{i}" + tracks_cache[tid] = _track(i, f"Punk track {i}", ["Punk Artist"], "punk") + + for i in range(15, 17): + tid = f"{i}:{i}" + tracks_cache[tid] = _track(i, f"Indie track {i}", ["Indie Artist"], "indie") + + rows = report.build_rows( + tracks_cache=tracks_cache, + lang_cache={}, + genre_catalog=CATALOG, + artist_genres={}, + small_group_min=12, + ) + + punk_rows = [r for r in rows if r["genre_raw"] == "punk"] + indie_rows = [r for r in rows if r["genre_raw"] == "indie"] + + assert len(punk_rows) == 15 + assert all(r["bucket"] == "punk" for r in punk_rows), "крупная под-группа - отдельный плейлист" + assert all(r["fine_bucket"] == "punk" for r in punk_rows) + + assert len(indie_rows) == 2 + assert all(r["bucket"] == "indie-alt" for r in indie_rows), "мелкая под-группа схлопнута в крупную корзину" + assert all(r["fine_bucket"] == "indie" for r in indie_rows), "fine_bucket сохраняет исходную классификацию" + + +def test_collapse_is_single_pass_and_terminates(): + # Группа ровно на границе порога (== small_group_min) НЕ схлопывается (строгое <). + tracks_cache = { + f"{i}:{i}": _track(i, f"T{i}", ["Artist"], "punk") for i in range(12) + } + + rows = report.build_rows( + tracks_cache=tracks_cache, + lang_cache={}, + genre_catalog=CATALOG, + artist_genres={}, + small_group_min=12, + ) + + assert all(r["bucket"] == "punk" for r in rows)