Студии из кодек-строк: codec_line_rows, доразметка, аудит дорожек

tools/audit_tracks.py и reports/tracks_audit.md — аудит всех дорожек
техданных по data/raw с планом. Пункт 1 плана: harvest_core.codec_line_rows
добавляет студии из строк «Аудио 2: AC3 … (MVO, LostFilm)», «Аудио Rus: … |
TVShows» (studio_source=codec), которых нет среди строк релиза; тот же шаг
в tools/relabel_studios.py (повторный запуск ничего не добавляет).
labels.csv: +6 428 строк в 2 862 релизах (kinozal 3 444, nnm 1 609,
rutracker 1 375), со студией 29 331 -> 35 759. tests/e2e_segmenter.py:
модель через E2E_MODEL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-02 20:30:52 +07:00
1 parent 63878f7874
commit e3af778068
10 files changed
+7937 -29

No files matched your search

+120
View File
@@ -1784,6 +1784,125 @@ def kz_title_rows(rec: dict, text: str, rows: list[dict]) -> list[dict]:
return out
# кодек-строка одной дорожки: «Аудио 2: …», «Аудио Rus: …», «Аудио pic RUS: …», «Звук #1: …»,
# «• №3: Русский (АС3 …)», «Russian (Dolby AC3, 6 ch) …»
RE_CODEC_LINE = re.compile(
r"(?i)^\s*[•#№*\-\s]*(?:(?:аудио\w*|audio|звук\w*)\b[^:\n]{0,24}:|№\s*\d+\s*:|(?:russian|русск\w*|украин\w*)\b)")
RE_CODEC_PARAM = re.compile(r"(?i)\b(?:e-?ac-?3|ac-?3|ас3|dts|aac|truehd|flac|mp3|opus|l?pcm|mp2)\b|\d\s*(?:kbps|кбит|kb/s)")
RE_LANG_WORD = re.compile(
r"(?i)русск|russian|\brus\b|украин|ukrain|\bukr\b|україн|англ|english|\beng\b|япон|japan|казах|kazakh"
r"|француз|french|немецк|german|итальян|испан|корей|китайск")
# не студия: язык, тех. параметры, тип, источник, пометки
CODEC_NOT_STUDIO = re.compile(
r"(?i)^(?:rus|ukr|eng|ru|ua|int|ext|pic|dub|mvo|dvo|avo|vo|лицензи\w*|ненормативн\w*.*|.*лексик\w*"
r"|присутствует.*|внешн\w*|в составе.*|серии.*|эп\w*\.?\s*\d.*|[\d\s.,:/\-]+|.*\b(?:kbps|ch|khz|кбит)\b.*"
r"|.*(?:русск|английск|украинск|russian|english|ukrainian|оригинал|original)\w*.*"
r"|.*(?:голос|дублир|закадр|перевод).*|.*(?<![а-яё])дубляж(?![а-яё]).*|звук.*"
r"|.*(?:blu-?ray|itunes|netflix|amazon|\bbd\b|\bdvd\b|web-?dl).*)$")
# имя из скобок кодек-строки, которого нет в словаре: «(L,C,R,l,r)», «(АС3 …)», «(1-8 серии)», «(поздний)»
CODEC_NAME_NOISE = re.compile(
r"(?i)^(?:[lrc]|sl|sr|ls|rs|lfe)\b|\b(?:[lrc]|lfe)\s*[,;]|^(?:cee|eur|vhs|cbr|vbr|lfe|stereo|custom"
r"|line|production|россия|сек|[aа]{1,2}[сc]\w?|мр\w?|front|side)\b|\bmono\b|dolby|поздн|ранн|отдельн|тифло|комментар"
r"|сезон|сери[ия]|эпизод|файл|контейнер|bits?\b|sound\s*track|sdh|forced|вставк|сэмпл|sample|неизвестн|unknown")
def _lang_key(word: str) -> str:
w = word.lower()
return "RUS" if w.startswith(("русс", "russ", "rus")) else "UKR" if w.startswith(("укр", "ukr")) else w[:4]
def _known_studios() -> re.Pattern | None:
"""Словарь студий из data/labels.csv (≥ 5 строк): имена, которые kinozal пишет без скобок
(«Аудио Rus: … | LostFilm», «… - Русский, LostFilm», «… MVO DniproFilm / HDRezka Studio»)."""
global _KNOWN_RX
if _KNOWN_RX is None:
path = _REPO / "data" / "labels.csv"
cnt: Counter = Counter()
if path.exists():
with path.open(encoding="utf-8-sig", newline="") as f:
for r in csv.DictReader(f):
if r.get("studio_source") == "codec": # словарь — без своих же строк: повторный прогон стабилен
continue
st = (r.get("studio") or "").strip()
if len(st) >= 3 and st != "(не указана)" and not CODEC_NOT_STUDIO.match(st) \
and st.lower() not in SOURCE_LIKE and not CODEC_NAME_NOISE.search(st):
cnt[st] += 1
names = sorted((s for s, c in cnt.items() if c >= 5), key=len, reverse=True)
_KNOWN_RX = re.compile(r"(?i)(?<![\w])(" + "|".join(map(re.escape, names)) + r")(?![\w])") \
if names else re.compile(r"(?!x)x")
return _KNOWN_RX
_KNOWN_RX: re.Pattern | None = None
def codec_line_rows(rec: dict, text: str, rows: list[dict], known: re.Pattern | None = None) -> list[dict]:
"""Студии из кодек-строк русских/украинских дорожек, которых нет среди строк разметки.
kinozal: «Перевод: Профессиональный (многоголосый закадровый)» без студий + «Аудио 2: AC3 …
(MVO, LostFilm)», «Аудио Rus: … | TVShows»; nnm/rutracker — те же кодек-строки рядом с
«Перевод N:» со списком, где этой студии нет. Строка-вход — сегмент кодек-строки, как его
режет сервис (`src.release._entries`); тип — из самой строки (MVO, «дубляж»), иначе
единственный тип релиза, иначе «неизвестно». Сводка из нескольких языков
(«Аудио: Русский (AC3…), украинский (AC3…)»), оригинал и комментарии — пропускаются.
"""
from src.release import _entries, same_studio
from src.trackinfo import parse_audio_line
known = known if known is not None else _known_studios()
have = [r["studio"] for r in rows if r.get("studio") and r["studio"] != "(не указана)"]
types = {r["translation_type"] for r in rows} - {"неизвестно", ""}
base = rows[0] if rows else {}
out: list[dict] = []
for line in (text or "").splitlines():
line = re.sub(r"\s+", " ", line).strip()
if len(line) > 300 or not RE_CODEC_LINE.match(line) or not RE_CODEC_PARAM.search(line):
continue
if len({_lang_key(m.group(0)) for m in RE_LANG_WORD.finditer(line)}) > 1:
continue
info = parse_audio_line(re.sub(r"(?i)^\s*[^:]{0,30}:\s*", "", line, count=1))
if info["original"] or info["commentary"] or info["lang"] not in ("RUS", "UKR", ""):
continue
if not info["lang"] and not re.search(r"(?i)\brus\b|\bukr\b", line):
continue
tag = NNM_TAG_RE.search(line) or NNM_TAG_RE.search(line.translate(CYR_TO_LAT)) # «АVO» кириллицей
kind, pro = nnm_tag_type(tag.group(1)) if tag else ("", "")
if not kind:
kind, pro = translation_type(line)
if kind.endswith("неизвестно"):
kind, pro = "", ""
if not kind:
kind = next(iter(types)) if len(types) == 1 else "неизвестно"
pro = kind.split()[0] if kind.split()[0] in ("проф.", "люб.", "авт.") else ""
cands = [(m.group(1), True) for m in known.finditer(line)] + [(n, False) for n in info["names"]]
for name, in_vocab in cands:
studio, note = clean_studio(name)
studio, extra = canonical_studio(studio)
if len(studio) < 3 or studio.lower() in SOURCE_LIKE or CODEC_NOT_STUDIO.match(studio):
continue
if CODEC_NAME_NOISE.search(studio) or not in_vocab and re.search(r"\d", studio):
continue
if any(same_studio(studio, x) for x in have):
continue
entries = _entries(line)
seg = next((e for e in entries if name.lower() in e.lower()), entries[0] if entries else line)
out.append({
"source": base.get("source") or rec.get("source", ""), "release_id": rec.get("id", ""),
"ru_title": base.get("ru_title", ""), "year": base.get("year", ""),
"quality": base.get("quality", ""), "is_series": base.get("is_series", False),
"studio": studio, "translation_type": kind, "professional": pro,
"role_raw": "", "actors": "",
"note": mark_18(" ".join(x for x in (note, extra) if x).strip(), info["adult"]),
"age_18": int(info["adult"]), "codec": info["codec"], "channels": info["channels"],
"khz": "", "kbps": info["kbps"], "subtitles": "", "studio_source": "codec",
"raw": seg, "lang": info["lang"] or ("UKR" if re.search(r"(?i)\bukr\b", line) else "RUS"),
"audio_raw": line})
have.append(studio)
return out
def _with_audio(core, texts):
"""Обёртка парсера: lang/audio_raw у строк; число переводов в rel — по итоговым строкам."""
def parse(rec: dict) -> tuple[list[dict], dict]:
@@ -1797,6 +1916,7 @@ def _with_audio(core, texts):
if core is _kz_parse_core:
rows += kz_title_rows(rec, text, rows)
if rows:
rows += codec_line_rows(rec, text, rows)
rel["n_translations"], rel["ok"] = len(rows), True
rel["studios"] = " | ".join(sorted({r["studio"] for r in rows}))
return rows, rel