Sort_YM/sort_ym/genres.py
Andrew Pimenov 0902d86bcd 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 отфильтровываются на записи
и дополнительно на чтении кэша.
2026-07-06 14:26:49 +03:00

171 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import json
from collections import Counter
from pathlib import Path
from yandex_music import Client, Genre
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 актуальных корня на момент
# написания), а не по предположениям об именах слагов — проверено на живом каталоге
# (cache/genre_catalog.json). Если Яндекс добавит новый корневой жанр, он попадёт в "other" —
# смотрите разбивку "other" в выводе команды report, чтобы дополнить таблицу.
ROOT_BUCKET: dict[str, str] = {
# рок
"allrock": "rock",
# инди / альтернатива / панк
"alternative": "indie-alt",
"indie": "indie-alt",
"punk": "indie-alt",
# метал
"metal": "metal",
# рэп
"rap": "rap",
# электроника / танцевальная
"electronics": "electronic",
"dance": "electronic",
# джаз и блюз
"jazz": "jazz-blues",
"blues": "jazz-blues",
# классика
"classicalmusic": "classical",
# поп
"pop": "pop",
"estrada": "pop",
"rnb": "pop",
# фолк и world
"folk": "folk-world",
"folkgenre": "folk-world",
"bard": "folk-world",
"shanson": "folk-world",
"country": "folk-world",
"reggae": "folk-world",
"ska": "folk-world",
"islamicgenre": "folk-world",
# саундтреки
"soundtrack": "soundtrack",
# не музыка / нераспределяемое -> "other"
"all": "other",
"other": "other",
"relax": "other",
"children": "other",
"forchildren": "other",
"poemsforchildren": "other",
"fairytales": "other",
"audiobooks": "other",
"booksnotinrussian": "other",
"fiction": "other",
"nonfictionliterature": "other",
"podcasts": "other",
"naturesounds": "other",
}
def flatten_catalog(roots: list[Genre]) -> dict[str, dict]:
"""id жанра/поджанра -> {title, root_id}."""
flat: dict[str, dict] = {}
def walk(genre: Genre, root_id: str) -> None:
flat[genre.id] = {"title": genre.title, "root_id": root_id}
for sub in genre.sub_genres:
walk(sub, root_id)
for root in roots:
walk(root, root.id)
return flat
def load_or_fetch_catalog(client: Client, cache_dir: Path) -> dict[str, dict]:
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file = cache_dir / GENRE_CATALOG_FILE
if cache_file.exists():
return json.loads(cache_file.read_text(encoding="utf-8"))
roots = with_retries(lambda: client.genres())
flat = flatten_catalog(roots)
cache_file.write_text(json.dumps(flat, ensure_ascii=False, indent=2), encoding="utf-8")
return flat
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"
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