mirror of
https://github.com/Horizontal42/Sort_YM.git
synced 2026-11-11 01:02:02 +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/
|
||||
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` — под-жанр до возможного схлопывания в крупную корзину).
|
||||
|
|
|
|||
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 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]}}
|
||||
|
|
|
|||
|
|
@ -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
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