mirror of
https://github.com/Horizontal42/Sort_YM.git
synced 2026-11-10 18:02:00 +03:00
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:
parent
b93ac0d04a
commit
0902d86bcd
12 changed files with 530 additions and 40 deletions
|
|
@ -7,19 +7,24 @@
|
||||||
|
|
||||||
```
|
```
|
||||||
sort_ym/
|
sort_ym/
|
||||||
config.py — чтение config.toml (throttle, пути к кэшу/токену)
|
config.py — чтение config.toml (throttle, small_group_min, пути к кэшу/токену)
|
||||||
auth.py — OAuth device-flow, хранение токена в .token
|
auth.py — OAuth device-flow, хранение токена в .token
|
||||||
ymclient.py — создание Client, батч-нарезка списков
|
ymclient.py — создание Client, батч-нарезка списков, ретраи сетевых ошибок
|
||||||
fetch.py — лайки -> полные Track -> cache/tracks.json (докачивается инкрементально)
|
fetch.py — лайки -> полные Track -> cache/tracks.json; артисты треков ->
|
||||||
genres.py — реальное дерево жанров Яндекса -> 11 крупных корзин (ROOT_BUCKET)
|
client.artists() -> cache/artist_genres.json (оба докачиваются инкрементально)
|
||||||
|
genres.py — реальное дерево жанров Яндекса; classify_track — сведение жанра трека и жанров
|
||||||
|
артиста в один под-жанр (root_id); ROOT_BUCKET — под-жанр -> 11 крупных корзин
|
||||||
language.py — гибридная детекция языка (API текста / жанр-подсказка / алфавит)
|
language.py — гибридная детекция языка (API текста / жанр-подсказка / алфавит)
|
||||||
classify.py — (корзина, язык) -> имя плейлиста
|
classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста
|
||||||
report.py — сборка строк отчёта, запись out/report.csv, сводки
|
report.py — двухпроходная сборка строк отчёта, запись out/report.csv, сводки
|
||||||
apply.py — создание плейлистов + идемпотентное добавление треков
|
apply.py — создание плейлистов + идемпотентное добавление треков
|
||||||
cli.py — команды auth / fetch / report / apply
|
cli.py — команды auth / fetch / report / apply
|
||||||
|
|
||||||
tests/
|
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)
|
cache/ — кэш API (в .gitignore)
|
||||||
out/ — отчёты (в .gitignore)
|
out/ — отчёты (в .gitignore)
|
||||||
|
|
@ -29,20 +34,44 @@ out/ — отчёты (в .gitignore)
|
||||||
|
|
||||||
```
|
```
|
||||||
auth → .token
|
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
|
report → genres.load_or_fetch_catalog() → cache/genre_catalog.json
|
||||||
→ language.fetch_api_languages() → cache/lyrics_lang.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
|
apply --yes → users_playlists_create / users_playlists_insert_track
|
||||||
```
|
```
|
||||||
|
|
||||||
## Ключевые решения
|
## Ключевые решения
|
||||||
|
|
||||||
- **Жанр трека** доступен только через альбом (`Track.albums[0].genre`), у самого трека поля
|
- **Жанр трека** доступен только через альбом (`Track.albums[0].genre`), у самого трека поля
|
||||||
жанра нет. Раскладка на 11 крупных групп сделана через реальное дерево `client.genres()`
|
жанра нет — это одна строка на трек, часто общая (`rock`, `alternative`). Раскладка по корзинам
|
||||||
(родитель → под-жанры), а не через угаданный список слагов — так таблица `ROOT_BUCKET` в
|
сделана через реальное дерево `client.genres()` (родитель → под-жанры), а не через угаданный
|
||||||
`genres.py` опирается на подтверждённые id, а не на предположения. Жанры без совпадения падают
|
список слагов — таблица `ROOT_BUCKET` в `genres.py` опирается на подтверждённые id, а не на
|
||||||
в корзину `other` и печатаются в выводе `report` для ручного дополнения таблицы.
|
предположения. Жанры без совпадения падают в корзину `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` — единственное поле в библиотеке
|
1. `client.track_supplement(track_id).lyrics.text_language` — единственное поле в библиотеке
|
||||||
|
|
@ -69,7 +98,10 @@ apply --yes → users_playlists_create / users_playlists_insert_track
|
||||||
## Хранилище
|
## Хранилище
|
||||||
|
|
||||||
- `.token` — OAuth-токен аккаунта (не коммитится).
|
- `.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/genre_catalog.json` — плоский снимок дерева жанров Яндекса (id -> title, root_id).
|
||||||
- `cache/lyrics_lang.json` — язык текста песни по треку (id -> language code | null).
|
- `cache/lyrics_lang.json` — язык текста песни по треку (id -> language code | null).
|
||||||
- `out/report.csv` — предпросмотр распределения по плейлистам перед `apply`.
|
- `out/report.csv` — предпросмотр распределения по плейлистам перед `apply` (включая колонку
|
||||||
|
`fine_bucket` — под-жанр до возможного схлопывания в крупную корзину).
|
||||||
|
|
|
||||||
31
README.md
31
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 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 report # посчитать жанр и язык каждого трека, сохранить out/report.csv (аккаунт не меняет)
|
||||||
.venv\Scripts\python -m sort_ym apply --limit 20 --yes # пробный запуск: создать плейлисты и добавить в них первые 20 треков
|
.venv\Scripts\python -m sort_ym apply --limit 20 --yes # пробный запуск: создать плейлисты и добавить в них первые 20 треков
|
||||||
.venv\Scripts\python -m sort_ym apply --yes # создать плейлисты и добавить в них все треки (меняет аккаунт)
|
.venv\Scripts\python -m sort_ym apply --yes # создать плейлисты и добавить в них все треки (меняет аккаунт)
|
||||||
|
|
@ -49,8 +49,9 @@ python -m venv .venv
|
||||||
Remove-Item -Recurse -Force cache
|
Remove-Item -Recurse -Force cache
|
||||||
```
|
```
|
||||||
|
|
||||||
Кэш состоит из трёх файлов:
|
Кэш состоит из четырёх файлов:
|
||||||
- `cache/tracks.json` — список лайкнутых треков и их метаданные;
|
- `cache/tracks.json` — список лайкнутых треков и их метаданные (включая id артистов);
|
||||||
|
- `cache/artist_genres.json` — жанровые теги каждого артиста (может быть несколько на артиста);
|
||||||
- `cache/genre_catalog.json` — снимок дерева жанров Яндекса на момент последнего запуска;
|
- `cache/genre_catalog.json` — снимок дерева жанров Яндекса на момент последнего запуска;
|
||||||
- `cache/lyrics_lang.json` — определённые языки текстов для каждого трека.
|
- `cache/lyrics_lang.json` — определённые языки текстов для каждого трека.
|
||||||
|
|
||||||
|
|
@ -72,10 +73,25 @@ Remove-Item .token
|
||||||
|
|
||||||
## Как определяется жанр и язык
|
## Как определяется жанр и язык
|
||||||
|
|
||||||
- **Жанр**: у трека берётся жанр альбома, затем он сопоставляется с одной из ~11 крупных
|
- **Жанр** определяется на **под-жанровом** уровне (Рок, Панк, Инди, Альтернатива, Метал, ...,
|
||||||
групп (Поп, Рок, Метал, Рэп, Электроника, Джаз и блюз, Классика, Фолк и world, Инди и
|
не только 11 крупных групп), комбинируя два сигнала:
|
||||||
альтернатива, Саундтреки, Разное) через реальное дерево жанров Яндекса
|
1. Жанр **альбома** трека (`genre_raw`) — сигнал по конкретному треку.
|
||||||
(`sort_ym/genres.py`, таблица `ROOT_BUCKET` — легко дополняется).
|
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).
|
1. Если у трека есть текст песни — реальный язык из API (`ru` → RU, иначе → INT).
|
||||||
2. Иначе — подсказка по жанру (`rusrap`, `shanson`, `bard`, ...).
|
2. Иначе — подсказка по жанру (`rusrap`, `shanson`, `bard`, ...).
|
||||||
|
|
@ -90,6 +106,7 @@ Remove-Item .token
|
||||||
- Первый прогон `apply` делайте с `--limit 20 --yes` и проверьте результат в приложении, прежде
|
- Первый прогон `apply` делайте с `--limit 20 --yes` и проверьте результат в приложении, прежде
|
||||||
чем запускать на все ~2000 треков.
|
чем запускать на все ~2000 треков.
|
||||||
- Настройки задержек между запросами — в `config.toml` (`[throttle]`).
|
- Настройки задержек между запросами — в `config.toml` (`[throttle]`).
|
||||||
|
- Минимальный размер под-жанрового плейлиста — `config.toml` (`[classify] small_group_min`).
|
||||||
|
|
||||||
## Риски (неофициальный API)
|
## Риски (неофициальный API)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,14 @@ apply_request_delay = 0.3
|
||||||
request_timeout = 20
|
request_timeout = 20
|
||||||
|
|
||||||
[fetch]
|
[fetch]
|
||||||
# Сколько id треков в одном батч-запросе client.tracks(...)
|
# Сколько id треков в одном батч-запросе client.tracks(...) / client.artists(...)
|
||||||
batch_size = 100
|
batch_size = 100
|
||||||
|
|
||||||
|
[classify]
|
||||||
|
# Минимальный размер под-жанровой группы (fine, lang), чтобы получить отдельный плейлист.
|
||||||
|
# Меньшие группы схлопываются в родительскую крупную корзину (rock/indie-alt/...).
|
||||||
|
small_group_min = 12
|
||||||
|
|
||||||
[paths]
|
[paths]
|
||||||
token_file = ".token"
|
token_file = ".token"
|
||||||
cache_dir = "cache"
|
cache_dir = "cache"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Крупные корзины (11 штук) - используются, когда под-жанровая группа слишком мала
|
||||||
|
# и схлопывается в родителя (см. report.build_rows).
|
||||||
BUCKET_LABELS: dict[str, str] = {
|
BUCKET_LABELS: dict[str, str] = {
|
||||||
"pop": "Поп",
|
"pop": "Поп",
|
||||||
"rock": "Рок",
|
"rock": "Рок",
|
||||||
|
|
@ -14,6 +16,49 @@ BUCKET_LABELS: dict[str, str] = {
|
||||||
"other": "Разное",
|
"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] = {
|
LANG_LABELS: dict[str, str] = {
|
||||||
"RU": "RU",
|
"RU": "RU",
|
||||||
"INT": "INT",
|
"INT": "INT",
|
||||||
|
|
@ -22,6 +67,7 @@ LANG_LABELS: dict[str, str] = {
|
||||||
|
|
||||||
|
|
||||||
def playlist_name(bucket: str, lang: 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"])
|
lang_label = LANG_LABELS.get(lang, LANG_LABELS["UNKNOWN"])
|
||||||
return f"{bucket_label} — {lang_label}"
|
return f"{bucket_label} — {lang_label}"
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ def cmd_fetch(args: argparse.Namespace) -> None:
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout)
|
client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout)
|
||||||
fetch.fetch_liked_tracks(client, cfg.cache_dir, cfg.batch_size, cfg.fetch_batch_delay)
|
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:
|
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")
|
raise SystemExit("Кэш треков пуст. Сначала запустите: python -m sort_ym fetch")
|
||||||
|
|
||||||
catalog = genres.load_or_fetch_catalog(client, cfg.cache_dir)
|
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"]]
|
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)
|
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)
|
out_file = report.write_report(rows, cfg.out_dir)
|
||||||
|
|
||||||
print("\nРаспределение по плейлистам:")
|
print("\nРаспределение по плейлистам:")
|
||||||
|
|
@ -60,8 +62,9 @@ def cmd_apply(args: argparse.Namespace) -> None:
|
||||||
raise SystemExit("Кэш треков пуст. Сначала запустите: python -m sort_ym fetch")
|
raise SystemExit("Кэш треков пуст. Сначала запустите: python -m sort_ym fetch")
|
||||||
|
|
||||||
catalog = genres.load_or_fetch_catalog(client, cfg.cache_dir)
|
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)
|
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)
|
apply_mod.apply_classification(client, rows, cfg.apply_request_delay, limit=args.limit)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ class Config:
|
||||||
apply_request_delay: float
|
apply_request_delay: float
|
||||||
request_timeout: float
|
request_timeout: float
|
||||||
batch_size: int
|
batch_size: int
|
||||||
|
small_group_min: int
|
||||||
token_file: Path
|
token_file: Path
|
||||||
cache_dir: Path
|
cache_dir: Path
|
||||||
out_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"],
|
apply_request_delay=data["throttle"]["apply_request_delay"],
|
||||||
request_timeout=data["throttle"]["request_timeout"],
|
request_timeout=data["throttle"]["request_timeout"],
|
||||||
batch_size=data["fetch"]["batch_size"],
|
batch_size=data["fetch"]["batch_size"],
|
||||||
|
small_group_min=data["classify"]["small_group_min"],
|
||||||
token_file=ROOT / data["paths"]["token_file"],
|
token_file=ROOT / data["paths"]["token_file"],
|
||||||
cache_dir=ROOT / data["paths"]["cache_dir"],
|
cache_dir=ROOT / data["paths"]["cache_dir"],
|
||||||
out_dir=ROOT / data["paths"]["out_dir"],
|
out_dir=ROOT / data["paths"]["out_dir"],
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from yandex_music import Client, Track
|
||||||
from .ymclient import chunked, with_retries
|
from .ymclient import chunked, with_retries
|
||||||
|
|
||||||
TRACKS_CACHE_FILE = "tracks.json"
|
TRACKS_CACHE_FILE = "tracks.json"
|
||||||
|
ARTIST_GENRES_CACHE_FILE = "artist_genres.json"
|
||||||
|
|
||||||
|
|
||||||
def _track_genre(track: Track) -> str | None:
|
def _track_genre(track: Track) -> str | None:
|
||||||
|
|
@ -25,6 +26,7 @@ def _serialize(track: Track) -> dict:
|
||||||
"album_id": album_id,
|
"album_id": album_id,
|
||||||
"title": track.title or "",
|
"title": track.title or "",
|
||||||
"artists": [a.name for a in track.artists if a.name],
|
"artists": [a.name for a in track.artists if a.name],
|
||||||
|
"artist_ids": [a.id for a in track.artists if a.name and a.id is not None],
|
||||||
"genre_raw": _track_genre(track),
|
"genre_raw": _track_genre(track),
|
||||||
"lyrics_available": bool(track.lyrics_available),
|
"lyrics_available": bool(track.lyrics_available),
|
||||||
}
|
}
|
||||||
|
|
@ -59,7 +61,9 @@ def fetch_liked_tracks(
|
||||||
return cache
|
return cache
|
||||||
|
|
||||||
all_ids = likes.tracks_ids
|
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)}")
|
print(f"Лайкнутых треков: {len(all_ids)}, новых для загрузки: {len(missing_ids)}")
|
||||||
|
|
||||||
|
|
@ -88,3 +92,55 @@ def fetch_liked_tracks(
|
||||||
print(f" убрано из кэша (больше не лайкнуто): {len(stale)}")
|
print(f" убрано из кэша (больше не лайкнуто): {len(stale)}")
|
||||||
|
|
||||||
return cache
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from collections import Counter
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from yandex_music import Client, Genre
|
from yandex_music import Client, Genre
|
||||||
|
|
@ -9,6 +10,22 @@ from .ymclient import with_retries
|
||||||
|
|
||||||
GENRE_CATALOG_FILE = "genre_catalog.json"
|
GENRE_CATALOG_FILE = "genre_catalog.json"
|
||||||
|
|
||||||
|
# Общие "зонтичные" альбомные жанры - сигнал низкой уверенности: Яндекс вешает их на
|
||||||
|
# альбом по умолчанию, когда не может/не хочет уточнить жанр. Ровно это и создаёт шум,
|
||||||
|
# который classify_track чинит через жанры артиста.
|
||||||
|
GENERIC_SLUGS = {
|
||||||
|
"rock",
|
||||||
|
"alternative",
|
||||||
|
"indie",
|
||||||
|
"pop",
|
||||||
|
"electronics",
|
||||||
|
"dance",
|
||||||
|
"metal",
|
||||||
|
"rap",
|
||||||
|
"jazz",
|
||||||
|
"folk",
|
||||||
|
}
|
||||||
|
|
||||||
# Корневые жанры каталога Яндекс.Музыки -> наши крупные корзины.
|
# Корневые жанры каталога Яндекс.Музыки -> наши крупные корзины.
|
||||||
# Таблица построена по реальному дереву client.genres() (все 34 актуальных корня на момент
|
# Таблица построена по реальному дереву client.genres() (все 34 актуальных корня на момент
|
||||||
# написания), а не по предположениям об именах слагов — проверено на живом каталоге
|
# написания), а не по предположениям об именах слагов — проверено на живом каталоге
|
||||||
|
|
@ -92,9 +109,63 @@ def load_or_fetch_catalog(client: Client, cache_dir: Path) -> dict[str, dict]:
|
||||||
return flat
|
return flat
|
||||||
|
|
||||||
|
|
||||||
def bucket_for(genre_raw: str | None, catalog: dict[str, dict]) -> str:
|
def fine_of(slug: str | None, catalog: dict[str, dict]) -> str | None:
|
||||||
if not genre_raw:
|
"""Слаг жанра (любой глубины) -> 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"
|
return "other"
|
||||||
entry = catalog.get(genre_raw)
|
return ROOT_BUCKET.get(fine, "other")
|
||||||
root_id = entry["root_id"] if entry else genre_raw
|
|
||||||
return ROOT_BUCKET.get(root_id, "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
|
||||||
|
|
|
||||||
|
|
@ -5,25 +5,49 @@ from pathlib import Path
|
||||||
|
|
||||||
from . import classify, genres, language
|
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(
|
def build_rows(
|
||||||
tracks_cache: dict[str, dict],
|
tracks_cache: dict[str, dict],
|
||||||
lang_cache: dict[str, str | None],
|
lang_cache: dict[str, str | None],
|
||||||
genre_catalog: dict[str, dict],
|
genre_catalog: dict[str, dict],
|
||||||
|
artist_genres: dict[str, dict],
|
||||||
|
small_group_min: int,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
rows = []
|
# Проход 1: под-жанр (fine) и язык на каждый трек, размеры групп (fine, lang) фиксируем сразу.
|
||||||
|
prelim = []
|
||||||
|
group_sizes: dict[tuple[str, str], int] = {}
|
||||||
for t in tracks_cache.values():
|
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"]))
|
api_lang = lang_cache.get(str(t["id"]))
|
||||||
lang = language.detect_language(t["title"], t["artists"], t["genre_raw"], api_lang)
|
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)
|
playlist = classify.playlist_name(bucket, lang)
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
"title": t["title"],
|
"title": t["title"],
|
||||||
"artists": ", ".join(t["artists"]),
|
"artists": ", ".join(t["artists"]),
|
||||||
"genre_raw": t["genre_raw"] or "",
|
"genre_raw": t["genre_raw"] or "",
|
||||||
|
"fine_bucket": fine,
|
||||||
"bucket": bucket,
|
"bucket": bucket,
|
||||||
"lang": lang,
|
"lang": lang,
|
||||||
"target_playlist": playlist,
|
"target_playlist": playlist,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sort_ym import fetch
|
from sort_ym import fetch
|
||||||
|
|
||||||
|
|
||||||
class FakeArtist:
|
class FakeArtist:
|
||||||
def __init__(self, name):
|
def __init__(self, id_, name, genres=None):
|
||||||
|
self.id = id_
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.genres = genres
|
||||||
|
|
||||||
|
|
||||||
class FakeAlbum:
|
class FakeAlbum:
|
||||||
|
|
@ -19,7 +22,7 @@ class FakeTrack:
|
||||||
self.id = id_
|
self.id = id_
|
||||||
self.albums = albums
|
self.albums = albums
|
||||||
self.title = title
|
self.title = title
|
||||||
self.artists = artists or [FakeArtist("Artist")]
|
self.artists = artists or [FakeArtist(1, "Artist")]
|
||||||
self.lyrics_available = lyrics_available
|
self.lyrics_available = lyrics_available
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -33,9 +36,10 @@ class FakeClient:
|
||||||
отличается от альбома, под которым трек значится в списке лайков -
|
отличается от альбома, под которым трек значится в списке лайков -
|
||||||
это реальное поведение API Яндекса для треков на нескольких альбомах."""
|
это реальное поведение 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._likes_ids = likes_ids
|
||||||
self._tracks_by_batch = tracks_by_batch
|
self._tracks_by_batch = tracks_by_batch
|
||||||
|
self._artists_by_batch = artists_by_batch or {}
|
||||||
|
|
||||||
def users_likes_tracks(self):
|
def users_likes_tracks(self):
|
||||||
return FakeLikes(self._likes_ids)
|
return FakeLikes(self._likes_ids)
|
||||||
|
|
@ -43,6 +47,9 @@ class FakeClient:
|
||||||
def tracks(self, batch):
|
def tracks(self, batch):
|
||||||
return self._tracks_by_batch[tuple(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):
|
def test_fetch_caches_under_original_liked_id_not_recomputed_track_id(tmp_path: Path):
|
||||||
# Трек лайкнут как "100:10", но Track.albums[0].id из ответа API - 999 (другой альбом).
|
# Трек лайкнут как "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)
|
cache = fetch.fetch_liked_tracks(client, tmp_path, batch_size=100, batch_delay=0)
|
||||||
|
|
||||||
assert len(cache) == 1, f"трек не должен быть вычищен как stale сразу после загрузки: {cache}"
|
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"]}}
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,23 @@ from sort_ym import genres
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def catalog():
|
def catalog():
|
||||||
# Форма реального дерева client.genres(): "rock"/"rusrock" - подгенры, реальный
|
# Форма реального дерева client.genres(): "rock"/"rusrock"/"hardrock"/"dnb" - подгенры,
|
||||||
# корень называется "allrock", а не "rock" (проверено на живом каталоге).
|
# "allrock"/"alternative"/"indie"/"punk"/"rap"/"metal" - настоящие корни (root_id == сам слаг).
|
||||||
|
# Проверено на живом каталоге (cache/genre_catalog.json).
|
||||||
return {
|
return {
|
||||||
"allrock": {"title": "Рок", "root_id": "allrock"},
|
"allrock": {"title": "Рок", "root_id": "allrock"},
|
||||||
"rock": {"title": "Иностранный рок", "root_id": "allrock"},
|
"rock": {"title": "Иностранный рок", "root_id": "allrock"},
|
||||||
"rusrock": {"title": "Русский рок", "root_id": "allrock"},
|
"rusrock": {"title": "Русский рок", "root_id": "allrock"},
|
||||||
|
"hardrock": {"title": "Хард-рок", "root_id": "allrock"},
|
||||||
"alternative": {"title": "Альтернатива", "root_id": "alternative"},
|
"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"},
|
"rap": {"title": "Рэп и хип-хоп", "root_id": "rap"},
|
||||||
"trap": {"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["rock"] == {"title": "Рок", "root_id": "rock"}
|
||||||
assert flat["rusrock"] == {"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
64
tests/test_report.py
Normal 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)
|
||||||
Loading…
Add table
Reference in a new issue