mirror of
https://github.com/Horizontal42/Sort_YM.git
synced 2026-11-10 15:42:02 +03:00
496 lines
22 KiB
Python
496 lines
22 KiB
Python
from __future__ import annotations
|
||
|
||
import statistics
|
||
from collections import Counter
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
from . import classify, genres, report
|
||
|
||
DIGEST_FILE = "digest.md"
|
||
DIGEST_SOURCE_FILE = "digest_source.md"
|
||
LYRICS_DIGEST_FILE = "lyrics_digest.md"
|
||
|
||
DECADE_UNKNOWN = "unknown"
|
||
DECADE_MIN_YEAR = 1900
|
||
DECADE_MAX_YEAR = 2100
|
||
DECADE_TOP_GENRES = 3 # константа вёрстки (сколько жанров показывать на десятилетие), не config-knob
|
||
LYRICS_TOP_MOODS = 6
|
||
LYRICS_TOP_THEMES = 8
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Wording:
|
||
"""Тексты дайджеста, зависящие от источника (лайки vs произвольный плейлист по --source).
|
||
|
||
Дефолты - буквально сегодняшние строки для лайков, чтобы render_digest(...) без явного
|
||
wording давал побайтово тот же результат, что и раньше.
|
||
"""
|
||
|
||
heading: str = "Дайджест музыкальной библиотеки"
|
||
subtitle: str | None = None
|
||
tracks_label: str = "Лайкнутых треков"
|
||
no_years_note: str = (
|
||
"> Годы релиза и названия альбомов ещё не загружены - запустите `python -m sort_ym "
|
||
"fetch` (кэш треков обновится один раз). Секции «Десятилетия» и «Топ альбомов» "
|
||
"пока неинформативны."
|
||
)
|
||
album_counter_note: str = "Счётчик — сколько треков с альбома лайкнуто."
|
||
rest_albums_note: str = "Ещё {n} альбомов с {lo}-{hi} лайкнутыми треками."
|
||
deep_albums_note: str = "Треков с альбомов, где лайкнуто 2 и более: {deep} из {total} ({pct}%)."
|
||
|
||
|
||
DEFAULT_WORDING = Wording()
|
||
|
||
|
||
def playlist_wording(title: str, owner: str, url: str) -> Wording:
|
||
"""Wording для дайджеста произвольного плейлиста (cmd_digest --source)."""
|
||
return Wording(
|
||
heading=f"Дайджест плейлиста «{title}»",
|
||
subtitle=f"Источник: плейлист «{title}» (владелец: {owner}), ссылка: {url}",
|
||
tracks_label="Треков в плейлисте",
|
||
no_years_note="> Год выхода и название альбома неизвестны для всех треков этого плейлиста.",
|
||
album_counter_note="Счётчик — сколько треков плейлиста относится к альбому.",
|
||
rest_albums_note="Ещё {n} альбомов с {lo}-{hi} треками в плейлисте.",
|
||
deep_albums_note="Треков с альбомов, где в плейлисте 2 и более трека: {deep} из {total} ({pct}%).",
|
||
)
|
||
|
||
|
||
def _by_track_key(tracks_cache: dict[str, dict]) -> dict[tuple, dict]:
|
||
"""(id, album_id) из ЗНАЧЕНИЙ кэша -> сама запись кэша.
|
||
|
||
Ключ tracks_cache - исходный id из likes.tracks_ids, а не f"{id}:{album_id}" (у треков без
|
||
альбома это разные строки), поэтому склейка со строками report.build_rows идёт по значениям.
|
||
"""
|
||
return {(t["id"], t["album_id"]): t for t in tracks_cache.values()}
|
||
|
||
|
||
def _most_common_deterministic(counter: Counter):
|
||
"""Самое частое значение; при равенстве частот - детерминированный тай-брейк по значению."""
|
||
return sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))[0][0]
|
||
|
||
|
||
def _artist_ids_by_name(tracks_cache: dict[str, dict]) -> dict[str, list[str]]:
|
||
"""Имя артиста -> id (в порядке первого появления, без дублей).
|
||
|
||
fetch.serialize_track кладёт в artists всех артистов с именем, а в artist_ids - только тех, у кого
|
||
есть и имя, и id, поэтому списки могут расходиться по длине для одного трека. Берём только
|
||
треки, где длины совпадают - иначе zip присвоил бы имени чужой id. Имя, встречающееся только
|
||
в рассинхронных треках, остаётся без id (и без тегов) - лучше пусто, чем неверно.
|
||
"""
|
||
ids_by_name: dict[str, list[str]] = {}
|
||
for t in tracks_cache.values():
|
||
artists = t["artists"]
|
||
artist_ids = t["artist_ids"]
|
||
if len(artists) != len(artist_ids):
|
||
continue
|
||
for name, aid in zip(artists, artist_ids):
|
||
ids = ids_by_name.setdefault(name, [])
|
||
sid = str(aid)
|
||
if sid not in ids:
|
||
ids.append(sid)
|
||
return ids_by_name
|
||
|
||
|
||
def artist_tags(
|
||
name: str,
|
||
ids_by_name: dict[str, list[str]],
|
||
artist_genres: dict[str, dict],
|
||
limit: int = 3,
|
||
) -> list[str]:
|
||
"""Собственные жанровые слаги артиста (объединение по всем безопасно найденным id)."""
|
||
tags: list[str] = []
|
||
for aid in ids_by_name.get(name, []):
|
||
entry = artist_genres.get(aid)
|
||
if not entry:
|
||
continue
|
||
for tag in entry.get("genres", []):
|
||
if tag not in tags:
|
||
tags.append(tag)
|
||
return tags[:limit]
|
||
|
||
|
||
def artist_stats(
|
||
rows: list[dict],
|
||
tracks_cache: dict[str, dict],
|
||
artist_genres: dict[str, dict],
|
||
) -> list[dict]:
|
||
"""[{"name", "tracks", "top_fine", "tags"}], сортировка: tracks desc, затем name.
|
||
|
||
Считает по ИМЕНИ артиста, а не через zip(artists, artist_ids) - списки не всегда выровнены.
|
||
top_fine - самый частый fine_bucket среди треков этого исполнителя.
|
||
"""
|
||
by_key = _by_track_key(tracks_cache)
|
||
ids_by_name = _artist_ids_by_name(tracks_cache)
|
||
|
||
counts: dict[str, int] = {}
|
||
fines: dict[str, Counter] = {}
|
||
for row in rows:
|
||
t = by_key.get((row["id"], row["album_id"]))
|
||
if t is None:
|
||
continue
|
||
for name in t["artists"]:
|
||
counts[name] = counts.get(name, 0) + 1
|
||
fines.setdefault(name, Counter())[row["fine_bucket"]] += 1
|
||
|
||
result = [
|
||
{
|
||
"name": name,
|
||
"tracks": tracks,
|
||
"top_fine": fines[name].most_common(1)[0][0],
|
||
"tags": artist_tags(name, ids_by_name, artist_genres),
|
||
}
|
||
for name, tracks in counts.items()
|
||
]
|
||
result.sort(key=lambda a: (-a["tracks"], a["name"]))
|
||
return result
|
||
|
||
|
||
def genre_stats(rows: list[dict]) -> list[dict]:
|
||
"""[{"bucket", "tracks", "langs"}], максимум 11 строк (число крупных корзин).
|
||
|
||
bucket = genres.coarse_of(row["fine_bucket"]), а не row["bucket"] - оно смешивает под-жанр
|
||
и крупную корзину после схлопывания мелких групп в report.build_rows.
|
||
"""
|
||
counts: dict[str, int] = {}
|
||
langs: dict[str, Counter] = {}
|
||
for row in rows:
|
||
bucket = genres.coarse_of(row["fine_bucket"])
|
||
counts[bucket] = counts.get(bucket, 0) + 1
|
||
langs.setdefault(bucket, Counter())[row["lang"]] += 1
|
||
|
||
result = [{"bucket": b, "tracks": n, "langs": dict(langs[b])} for b, n in counts.items()]
|
||
result.sort(key=lambda g: (-g["tracks"], g["bucket"]))
|
||
return result
|
||
|
||
|
||
def fine_stats(rows: list[dict]) -> list[dict]:
|
||
"""[{"fine", "tracks"}], максимум ~34 строки (размер genres.ROOT_BUCKET)."""
|
||
counts = Counter(row["fine_bucket"] for row in rows)
|
||
result = [{"fine": f, "tracks": n} for f, n in counts.items()]
|
||
result.sort(key=lambda g: (-g["tracks"], g["fine"]))
|
||
return result
|
||
|
||
|
||
def lang_stats(rows: list[dict]) -> dict[str, int]:
|
||
return dict(Counter(row["lang"] for row in rows))
|
||
|
||
|
||
def decade_key(year) -> str:
|
||
"""Год -> "1990"/"2010"/... или DECADE_UNKNOWN (None, не int, либо вне разумных границ)."""
|
||
if not isinstance(year, int) or year < DECADE_MIN_YEAR or year > DECADE_MAX_YEAR:
|
||
return DECADE_UNKNOWN
|
||
return str((year // 10) * 10)
|
||
|
||
|
||
def decade_label(key: str) -> str:
|
||
return "Неизвестно" if key == DECADE_UNKNOWN else f"{key}-е"
|
||
|
||
|
||
def decade_stats(rows: list[dict], tracks_cache: dict[str, dict]) -> list[dict]:
|
||
"""[{"key", "tracks", "top_genres"}], сортировка по году desc, "Неизвестно" последним.
|
||
|
||
top_genres - топ-DECADE_TOP_GENRES крупных корзин десятилетия (не полная матрица жанр x
|
||
десятилетие - она была бы до 11 x ~13 строк).
|
||
"""
|
||
by_key = _by_track_key(tracks_cache)
|
||
|
||
counts: dict[str, int] = {}
|
||
genre_counts: dict[str, Counter] = {}
|
||
for row in rows:
|
||
t = by_key.get((row["id"], row["album_id"]))
|
||
year = t.get("year") if t else None
|
||
key = decade_key(year)
|
||
counts[key] = counts.get(key, 0) + 1
|
||
bucket = genres.coarse_of(row["fine_bucket"])
|
||
genre_counts.setdefault(key, Counter())[bucket] += 1
|
||
|
||
result = [
|
||
{
|
||
"key": key,
|
||
"tracks": n,
|
||
"top_genres": [
|
||
{"bucket": b, "tracks": c}
|
||
for b, c in genre_counts[key].most_common(DECADE_TOP_GENRES)
|
||
],
|
||
}
|
||
for key, n in counts.items()
|
||
]
|
||
result.sort(key=lambda d: (d["key"] == DECADE_UNKNOWN, -int(d["key"]) if d["key"] != DECADE_UNKNOWN else 0))
|
||
return result
|
||
|
||
|
||
def album_stats(tracks_cache: dict[str, dict]) -> list[dict]:
|
||
"""[{"album_id", "title", "year", "artists", "tracks"}], сортировка tracks desc, затем title.
|
||
|
||
Треки с album_id is None (сторонние загрузки без привязки к альбому) пропускаются - они не
|
||
участвуют в подсчёте "альбом vs синглы", но остаются во всех остальных срезах.
|
||
title/year/artists представителя альбома - самое частое непустое значение среди его треков
|
||
(детерминированный тай-брейк), а не "первое попавшееся".
|
||
"""
|
||
groups: dict[int, list[dict]] = {}
|
||
for t in tracks_cache.values():
|
||
album_id = t.get("album_id")
|
||
if album_id is None:
|
||
continue
|
||
groups.setdefault(album_id, []).append(t)
|
||
|
||
result = []
|
||
for album_id, tracks in groups.items():
|
||
titles = Counter(t["album_title"] for t in tracks if t.get("album_title"))
|
||
title = _most_common_deterministic(titles) if titles else f"без названия (#{album_id})"
|
||
years = Counter(t["year"] for t in tracks if t.get("year") is not None)
|
||
year = _most_common_deterministic(years) if years else None
|
||
artist_tuples = Counter(tuple(t["artists"]) for t in tracks)
|
||
artists = ", ".join(_most_common_deterministic(artist_tuples)) if artist_tuples else ""
|
||
result.append(
|
||
{
|
||
"album_id": album_id,
|
||
"title": title,
|
||
"year": year,
|
||
"artists": artists,
|
||
"tracks": len(tracks),
|
||
}
|
||
)
|
||
result.sort(key=lambda a: (-a["tracks"], a["title"]))
|
||
return result
|
||
|
||
|
||
def _pct(n: int, total: int) -> int:
|
||
return round(100 * n / total) if total else 0
|
||
|
||
|
||
def analyzed_entries(analysis: dict[str, dict]) -> list[dict]:
|
||
"""Только успешные записи - таймауты и битые ответы хранятся в том же кэше с полем error."""
|
||
return [e for e in analysis.values() if "error" not in e]
|
||
|
||
|
||
def _counted(counter: Counter, key: str, limit: int | None = None) -> list[dict]:
|
||
items = sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))
|
||
if limit is not None:
|
||
items = items[:limit]
|
||
return [{key: name, "tracks": n} for name, n in items]
|
||
|
||
|
||
def mood_stats(analysis: dict[str, dict]) -> list[dict]:
|
||
counter: Counter[str] = Counter()
|
||
for entry in analyzed_entries(analysis):
|
||
counter.update(entry.get("mood", []))
|
||
return _counted(counter, "mood")
|
||
|
||
|
||
def theme_stats(analysis: dict[str, dict], limit: int) -> list[dict]:
|
||
counter: Counter[str] = Counter()
|
||
for entry in analyzed_entries(analysis):
|
||
counter.update(entry.get("themes", []))
|
||
return _counted(counter, "theme", limit)
|
||
|
||
|
||
def lyrics_block(analysis: dict[str, dict]) -> list[str]:
|
||
"""Компактный агрегат разбора лирики для digest.md.
|
||
|
||
Сам разбор (266 нарративных блоков) живёт в out/lyrics_digest.md - digest.md существует
|
||
ради фиксированного потолка ~150 строк, и класть их сюда нельзя.
|
||
"""
|
||
entries = analyzed_entries(analysis)
|
||
if not entries:
|
||
return []
|
||
|
||
moods = ", ".join(f"{m['mood']} {m['tracks']}" for m in mood_stats(analysis)[:LYRICS_TOP_MOODS])
|
||
themes = ", ".join(f"{t['theme']} {t['tracks']}" for t in theme_stats(analysis, LYRICS_TOP_THEMES))
|
||
stances = Counter(e["stance"] for e in entries if "stance" in e)
|
||
povs = Counter(e["pov"] for e in entries if "pov" in e)
|
||
stance_part = " / ".join(f"{name} {n}" for name, n in sorted(stances.items(), key=lambda kv: (-kv[1], kv[0])))
|
||
pov_part = " / ".join(f"{name} {n}" for name, n in sorted(povs.items(), key=lambda kv: (-kv[1], kv[0])))
|
||
|
||
return [
|
||
"## Лирика",
|
||
f"Разбор текстов {len(entries)} треков локальной моделью; подробнее — out/{LYRICS_DIGEST_FILE}.",
|
||
f"- Настроения: {moods}",
|
||
f"- Темы: {themes}",
|
||
f"- Тон: {stance_part}",
|
||
f"- Ракурс: {pov_part}",
|
||
"",
|
||
]
|
||
|
||
|
||
def render_digest(
|
||
rows: list[dict],
|
||
tracks_cache: dict[str, dict],
|
||
artist_genres: dict[str, dict],
|
||
top_artists: int,
|
||
top_albums: int,
|
||
wording: Wording = DEFAULT_WORDING,
|
||
analysis: dict[str, dict] | None = None,
|
||
) -> str:
|
||
total = len(rows)
|
||
unique_artists = {name for t in tracks_cache.values() for name in t["artists"]}
|
||
genre_rows = genre_stats(rows)
|
||
fine_rows = fine_stats(rows)
|
||
lang_rows = lang_stats(rows)
|
||
decade_rows = decade_stats(rows, tracks_cache)
|
||
artists = artist_stats(rows, tracks_cache, artist_genres)
|
||
albums = album_stats(tracks_cache)
|
||
other = report.other_breakdown(rows)
|
||
|
||
known_years = sorted(t["year"] for t in tracks_cache.values() if t.get("year") is not None)
|
||
|
||
lines: list[str] = [f"# {wording.heading}", ""]
|
||
if wording.subtitle:
|
||
lines.append(wording.subtitle)
|
||
lines.append("")
|
||
lines.append("## Итоги")
|
||
lines.append(f"- {wording.tracks_label}: {total}")
|
||
lines.append(f"- Уникальных исполнителей: {len(unique_artists)}")
|
||
lines.append(
|
||
f"- Задействовано крупных корзин: {len(genre_rows)} из {len(classify.BUCKET_LABELS)}, "
|
||
f"под-жанров: {len(fine_rows)}"
|
||
)
|
||
if known_years:
|
||
median_year = round(statistics.median(known_years))
|
||
lines.append(
|
||
f"- Год релиза: медиана {median_year}, известен у {len(known_years)} из {total} "
|
||
f"({_pct(len(known_years), total)}%)"
|
||
)
|
||
else:
|
||
lines.append(wording.no_years_note)
|
||
lines.append("")
|
||
|
||
lines.append("## Жанры (крупные корзины)")
|
||
for g in genre_rows:
|
||
label = classify.BUCKET_LABELS.get(g["bucket"], g["bucket"])
|
||
lang_parts = [
|
||
f"{classify.LANG_LABELS.get(code, code)} {n}"
|
||
for code, n in sorted(g["langs"].items(), key=lambda kv: -kv[1])
|
||
]
|
||
lines.append(f"- {label} — {g['tracks']} ({_pct(g['tracks'], total)}%): {' / '.join(lang_parts)}")
|
||
lines.append("")
|
||
|
||
lines.append("## Под-жанры")
|
||
for f in fine_rows:
|
||
label = classify.FINE_LABELS.get(f["fine"], f["fine"])
|
||
lines.append(f"- {label} — {f['tracks']}")
|
||
lines.append("")
|
||
|
||
lines.append("## Язык")
|
||
for code, n in sorted(lang_rows.items(), key=lambda kv: -kv[1]):
|
||
label = classify.LANG_LABELS.get(code, code)
|
||
lines.append(f"- {label}: {n} ({_pct(n, total)}%)")
|
||
lines.append("")
|
||
|
||
lines.extend(lyrics_block(analysis or {}))
|
||
|
||
lines.append("## Десятилетия")
|
||
lines.append("В скобках — доля от библиотеки; далее — три самые частые крупные корзины десятилетия.")
|
||
for d in decade_rows:
|
||
label = decade_label(d["key"])
|
||
top_genres_str = ", ".join(
|
||
f"{classify.BUCKET_LABELS.get(tg['bucket'], tg['bucket'])} {tg['tracks']}"
|
||
for tg in d["top_genres"]
|
||
)
|
||
lines.append(f"- {label} — {d['tracks']} ({_pct(d['tracks'], total)}%): {top_genres_str}")
|
||
lines.append("")
|
||
|
||
shown_artists = artists[:top_artists]
|
||
rest_artists = artists[top_artists:]
|
||
lines.append(f"## Топ-{len(shown_artists)} исполнителей")
|
||
lines.append("Счётчик — упоминания в треках (у трека может быть несколько исполнителей).")
|
||
lines.append("В скобках — самый частый под-жанр исполнителя, затем его собственные теги из каталога.")
|
||
for i, a in enumerate(shown_artists, 1):
|
||
fine_label = classify.FINE_LABELS.get(a["top_fine"], a["top_fine"])
|
||
tag_part = f"; {', '.join(a['tags'])}" if a["tags"] else ""
|
||
lines.append(f"{i}. {a['name']} — {a['tracks']} ({fine_label}{tag_part})")
|
||
if rest_artists:
|
||
rest_tracks = sum(a["tracks"] for a in rest_artists)
|
||
one_track = sum(1 for a in rest_artists if a["tracks"] == 1)
|
||
lines.append("")
|
||
lines.append(
|
||
f"Ещё {len(rest_artists)} исполнителей с {rest_artists[-1]['tracks']}-"
|
||
f"{rest_artists[0]['tracks']} треками, {rest_tracks} упоминаний суммарно; "
|
||
f"из них {one_track} — по одному треку."
|
||
)
|
||
lines.append("")
|
||
|
||
shown_albums = albums[:top_albums]
|
||
rest_albums = albums[top_albums:]
|
||
lines.append(f"## Топ-{len(shown_albums)} альбомов")
|
||
lines.append(wording.album_counter_note)
|
||
for i, alb in enumerate(shown_albums, 1):
|
||
year_part = f" ({alb['year']})" if alb["year"] else ""
|
||
lines.append(f"{i}. {alb['artists']} — {alb['title']}{year_part} — {alb['tracks']}")
|
||
if rest_albums:
|
||
deep_tracks = sum(a["tracks"] for a in albums if a["tracks"] >= 2)
|
||
lines.append("")
|
||
lines.append(
|
||
wording.rest_albums_note.format(
|
||
n=len(rest_albums), lo=rest_albums[-1]["tracks"], hi=rest_albums[0]["tracks"]
|
||
)
|
||
+ " "
|
||
+ wording.deep_albums_note.format(deep=deep_tracks, total=total, pct=_pct(deep_tracks, total))
|
||
)
|
||
lines.append("")
|
||
|
||
if other:
|
||
lines.append("## Разное (нераспознанные сырые жанры)")
|
||
for genre_raw, n in sorted(other.items(), key=lambda kv: -kv[1])[:5]:
|
||
lines.append(f"- {genre_raw}: {n}")
|
||
lines.append("")
|
||
|
||
return "\n".join(lines).rstrip() + "\n"
|
||
|
||
|
||
def _lyrics_track_names(tracks_cache: dict[str, dict]) -> dict[str, str]:
|
||
names: dict[str, str] = {}
|
||
for t in tracks_cache.values():
|
||
names[str(t["id"])] = f"{', '.join(t['artists'])} — {t['title']}"
|
||
return names
|
||
|
||
|
||
def render_lyrics_digest(tracks_cache: dict[str, dict], analysis: dict[str, dict]) -> str:
|
||
names = _lyrics_track_names(tracks_cache)
|
||
entries = [(tid, e) for tid, e in analysis.items() if "error" not in e]
|
||
failed = len(analysis) - len(analyzed_entries(analysis))
|
||
|
||
lines = [
|
||
"# Разбор текстов",
|
||
"",
|
||
f"Разобрано треков: {len(entries)}. Группировка по основному настроению; "
|
||
"цитата приводится только если сверена с текстом дословно.",
|
||
"",
|
||
]
|
||
|
||
by_mood: dict[str, list[tuple[str, dict]]] = {}
|
||
for tid, entry in entries:
|
||
by_mood.setdefault(entry["mood"][0], []).append((tid, entry))
|
||
|
||
for mood, items in sorted(by_mood.items(), key=lambda kv: (-len(kv[1]), kv[0])):
|
||
lines.append(f"## {mood} ({len(items)})")
|
||
lines.append("")
|
||
for tid, entry in sorted(items, key=lambda kv: names.get(kv[0], kv[0])):
|
||
lines.append(f"### {names.get(tid, f'трек {tid}')}")
|
||
lines.append(
|
||
f"- Настроение: {', '.join(entry['mood'])}; арка: {entry['emotional_arc']}; "
|
||
f"ракурс: {entry['pov']}; тон: {entry['stance']}; речь: {entry['register']}; "
|
||
f"конкретность: {entry['concreteness']}"
|
||
)
|
||
lines.append(f"- Темы: {', '.join(entry['themes'])}")
|
||
if entry.get("key_line_verified"):
|
||
lines.append(f"- Цитата: «{entry['key_line']}»")
|
||
lines.append("")
|
||
lines.append(entry["summary"])
|
||
lines.append("")
|
||
lines.append(f"Чем может цеплять: {entry['resonance']}")
|
||
lines.append("")
|
||
|
||
if failed:
|
||
lines.append(f"Не разобрано: {failed} (таймаут или сбой модели, повторный `analyze` попробует снова).")
|
||
lines.append("")
|
||
|
||
return "\n".join(lines).rstrip() + "\n"
|
||
|
||
|
||
def write_digest(text: str, out_dir: Path, filename: str = DIGEST_FILE) -> Path:
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
out_file = out_dir / filename
|
||
out_file.write_text(text, encoding="utf-8")
|
||
return out_file
|