Sort_YM/sort_ym/language.py
Andrew Pimenov 970fd551e5 tighten retry scope, RU-only lyrics text cache, fix theme pattern and dedupe log
with_retries caught bare Exception with a type whitelist, masking real bugs
as network errors under retry/backoff - narrowed back to NetworkError.

fetch_api_languages was writing full lyrics text for every language into
lyrics_text.json via its track_supplement side channel, breaking the
documented RU-only invariant and letting analyze feed foreign-language
lyrics through the Russian-only prompt - now gated on normalize_api_language.

dedupe_playlists logged the found duplicate count as "removed" even when
a mid-loop network/API error cut deletion short.

THEME_PATTERN's relaxed regex allowed 2-char themes, contradicting its own
test; current_themes was hoisted out of the analyze loop so later prompts
never saw themes found earlier in the same run. Both reverted to intended
behavior, docs updated to match.
2026-08-19 04:14:29 +03:00

148 lines
3.9 KiB
Python

from __future__ import annotations
import json
import time
import unicodedata
from pathlib import Path
from yandex_music import Client
from yandex_music.exceptions import NotFoundError
from .ymclient import with_retries
LYRICS_LANG_CACHE_FILE = "lyrics_lang.json"
RU_GENRE_HINTS = {
"rusrap",
"ruspop",
"rusrock",
"shanson",
"bard",
"author",
"avtorskaya",
"estrada",
}
def _script_counts(text: str) -> tuple[int, int]:
cyrillic = 0
latin = 0
for ch in text:
if not ch.isalpha():
continue
try:
name = unicodedata.name(ch)
except ValueError:
continue
if "CYRILLIC" in name:
cyrillic += 1
elif "LATIN" in name:
latin += 1
return cyrillic, latin
def alphabet_heuristic(title: str, artists: list[str]) -> str | None:
text = " ".join([title, *artists])
cyrillic, latin = _script_counts(text)
if cyrillic == 0 and latin == 0:
if any(ch.isalpha() for ch in text):
return "INT"
return None
if cyrillic > latin:
return "RU"
if latin > cyrillic:
return "INT"
return None
def genre_hint(genre_raw: str | None) -> str | None:
if genre_raw and genre_raw.lower() in RU_GENRE_HINTS:
return "RU"
return None
def normalize_api_language(text_language: str | None) -> str | None:
if not text_language:
return None
return "RU" if text_language.strip().lower() == "ru" else "INT"
def detect_language(
title: str,
artists: list[str],
genre_raw: str | None,
api_language: str | None,
) -> str:
lang = normalize_api_language(api_language)
if lang:
return lang
lang = genre_hint(genre_raw)
if lang:
return lang
lang = alphabet_heuristic(title, artists)
if lang:
return lang
return "UNKNOWN"
def _atomic_write_json(path: Path, data: dict) -> None:
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
def load_lang_cache(cache_dir: Path) -> dict[str, str | None]:
cache_file = cache_dir / LYRICS_LANG_CACHE_FILE
if cache_file.exists():
return json.loads(cache_file.read_text(encoding="utf-8"))
return {}
def _fetch_supplement(client: Client, track_id: str) -> tuple[str | None, str | None]:
try:
supplement = with_retries(lambda: client.track_supplement(track_id))
except NotFoundError:
return None, None
if supplement is None or supplement.lyrics is None:
return None, None
return supplement.lyrics.text_language, supplement.lyrics.full_lyrics
def fetch_api_languages(
client: Client,
track_ids: list[str],
cache_dir: Path,
delay: float,
) -> dict[str, str | None]:
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file = cache_dir / LYRICS_LANG_CACHE_FILE
cache = load_lang_cache(cache_dir)
text_cache_file = cache_dir / "lyrics_text.json"
if text_cache_file.exists():
text_cache = json.loads(text_cache_file.read_text(encoding="utf-8"))
else:
text_cache = {}
missing = [tid for tid in track_ids if tid not in cache]
if not missing:
return cache
print(f"Запрос языка текста песни для {len(missing)} треков...")
for i, tid in enumerate(missing, 1):
lang, text = _fetch_supplement(client, tid)
cache[tid] = lang
if text is not None and normalize_api_language(lang) == "RU":
text_cache[tid] = text
if i % 20 == 0:
_atomic_write_json(cache_file, cache)
_atomic_write_json(text_cache_file, text_cache)
print(f" обработано {i}/{len(missing)}")
time.sleep(delay)
_atomic_write_json(cache_file, cache)
_atomic_write_json(text_cache_file, text_cache)
return cache