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 [ "## Лирика (RU-подмножество)", f"Разбор текстов {len(entries)} RU-треков локальной моделью; подробнее — 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 = [ "# Разбор текстов (RU-треки)", "", 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