classify tracks by combined track + artist genre signals

Раньше жанр брался только с альбома (одна строка, часто общая:
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 отфильтровываются на записи
и дополнительно на чтении кэша.
This commit is contained in:
Andrew Pimenov 2026-07-06 14:26:49 +03:00
parent b93ac0d04a
commit 0902d86bcd
12 changed files with 530 additions and 40 deletions

View file

@ -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` — под-жанр до возможного схлопывания в крупную корзину).

View file

@ -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)

View file

@ -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"

View file

@ -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}"

View file

@ -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)

View file

@ -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"],

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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"]}}

View file

@ -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

64
tests/test_report.py Normal file
View file

@ -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)