From e6484157198712e4572a847bf5da5d53f44ec509 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 29 Oct 2026 17:55:08 +0700 Subject: [PATCH] =?UTF-8?q?=D0=AF=D0=B7=D1=8B=D0=BA=20=D0=B4=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B6=D0=BA=D0=B8=20=D0=B8=D0=B7=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B8=20=C2=AB=D0=90=D1=83=D0=B4=D0=B8=D0=BE=C2=BB?= =?UTF-8?q?=20(UKR=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20RUS),=20=D1=81?= =?UTF-8?q?=D1=82=D1=83=D0=B4=D0=B8=D1=8F=20|=D0=94=D1=83=D0=B1=D0=BB?= =?UTF-8?q?=D1=8F=D0=B6,=20=D0=98=D0=BC=D1=8F|,=20=D0=BA=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D1=8B=205.1,=20=D0=BE=D1=80=D0=B8=D0=B3=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=B4=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B6=D0=BA=D0=B0,=20=D1=8F=D0=B7=D1=8B=D0=BA=20=D0=B2=20studi?= =?UTF-8?q?os,=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B0=20=D0=BE=D1=82=20?= =?UTF-8?q?=D1=81=D1=82=D1=83=D0=B4=D0=B8=D0=B9=20=C2=AB=D0=94=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D1=8F=D0=B6=D0=BD=D0=B0=D1=8F=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/api-reference.md | 18 ++++++++++++------ src/audio18.py | 2 +- src/predict.py | 32 +++++++++++++++++++++++++++----- src/release.py | 14 +++++++++++++- src/trackinfo.py | 16 +++++++++++++--- tests/run_tests.py | 22 ++++++++++++++++++++++ 6 files changed, 88 insertions(+), 16 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 5d84071..1aab666 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -35,9 +35,10 @@ ```json { "tracks": [ /* дорожки, см. ниже */ ], - "studios": [{"studio": "LostFilm", "translation_type": "проф. многоголосый", "is_18plus": false, "episodes": "1-4"}], + "studios": [{"studio": "LostFilm", "translation_type": "проф. многоголосый", "lang": "RUS", "is_18plus": false, "episodes": "1-4"}], "translation_types": ["проф. многоголосый"], "is_18plus": false, + "original": {"lang": "ENG", "codec": "E-AC3", "channels": 6, "bitrate_kbps": 768, "raw": "English E-AC3+Atmos / 5.1 / 48 kHz / 768 kbps |Original|"}, "series": {"season": "2", "episodes": "1-9", "total": 10, "by_studio": {"LostFilm": "1-4"}, "full_release": []}, "title": "Название раздачи", "segmenter": "model", @@ -48,7 +49,8 @@ | Поле | Тип | Описание | |---|---|---| | `tracks` | array | дорожки релиза без дублей (дубль — одинаковые тип + студия) | -| `studios` | array | уникальные `{studio, translation_type, is_18plus, episodes}` | +| `studios` | array | уникальные `{studio, translation_type, lang, is_18plus, episodes}`; `lang` — язык дорожки студии (как в `tracks[].lang`, может быть `null`). Одна студия с русским и украинским дубляжом даёт две записи | +| `original` | object \| null | оригинальная дорожка из строки «Аудио … \|Original\|»: `{lang, codec, channels, bitrate_kbps, raw}` (поля могут быть `null`); `null` — такой строки на странице нет | | `translation_types` | array of string | отсортированный набор типов (значения — из таблицы классов ниже) | | `is_18plus` | bool | `true`, если 18+ хотя бы у одной дорожки | | `series` | object \| null | сезон, выпущенные серии, «какая студия какие серии» (для Sonarr); `null` — сведений нет | @@ -74,10 +76,10 @@ | `age_confidence` | float 0..1 | вероятность предсказанного класса 18+ | | `lang` | string \| null | язык дорожки, ISO-подобный 3-буквенный код: `RUS`, `UKR`, `ENG`, также `BEL`, `KAZ`, `POL`, `FRA`, `DEU`, `ITA`, `SPA`, `JPN`, `KOR`, `CHI`, `CZE`, `LAV`, `LIT`, `EST`, `GEO`, `ARM`, `AZE`, `UZB`, `TUR`, `POR`, `HIN`; `null` — не определён | | `lang_confidence` | float \| null | уверенность модели; заполнено только при `lang_source = "model"` | -| `lang_source` | `"model"` \| `"audio"` \| `"text"` \| null | откуда язык: нейросеть (классы `ENG`/`RUS`/`UKR`, порог 0.5), строка «Аудио» страницы или слова в тексте («Украинский дубляж») | +| `lang_source` | `"audio"` \| `"model"` \| `"text"` \| null | откуда язык, в порядке приоритета: строка «Аудио» страницы (`Ukrainian E-AC3 …` → `UKR`, самый надёжный источник), нейросеть (классы `ENG`/`RUS`/`UKR`, порог 0.5; без строки «Аудио» ей приходится угадывать по тексту), слова в тексте («Украинский дубляж») | | `role` | string | `перевод`, `оригинал` (оригинальная дорожка без озвучки) или `комментарии` (комментарии режиссёра и т. п.) | | `official` | bool | в строке «Аудио» есть слова «официальный» / «лицензионный» / «licensed» | -| `audio` | object \| null | `{codec, channels, bitrate_kbps}` из строки «Аудио» страницы (`ac3`, `6`, `448`); каждое поле может быть `null`; весь объект `null`, если строка не найдена | +| `audio` | object \| null | `{codec, channels, bitrate_kbps}` из строки «Аудио» страницы (`"E-AC3"`, `6`, `768`; `channels` — число каналов, «5.1» → 6); каждое поле может быть `null`; весь объект `null`, если строка не найдена | | `source` | `"page"` \| `"title"` | только в ответе релиза: дорожка найдена в описании или студия взята из заголовка раздачи | | `episodes` | string \| null | серии, которые озвучивает дорожка («1-4», «1,3-5»); `null` — серии не указаны (покрывает весь релиз) | | `episode_ranges` | array | то же в виде `[[1, 4]]`; пусто, если серий нет | @@ -158,7 +160,8 @@ type Track = { type Release = { tracks: Track[]; // может быть пустым массивом - studios: { studio: string | null; translation_type: string; is_18plus: boolean; episodes: string | null }[]; + studios: { studio: string | null; translation_type: string; lang: string | null; is_18plus: boolean; episodes: string | null }[]; + original: { lang: string | null; codec: string | null; channels: number | null; bitrate_kbps: number | null; raw: string } | null; translation_types: string[]; is_18plus: boolean; series: { @@ -211,7 +214,10 @@ Cloudflare: FlareSolverr нестабилен. несколько предсказаний: `translation_type` (14 классов), спан автора (разметка BIO по токенам), флаг 18+ и язык (`ENG`/`RUS`/`UKR`). 6. **Постобработка.** Автор канонизируется словарём алиасов; 18+ ставится также по строке «Аудио»; - язык: нейросеть → строка «Аудио» → слова в тексте; определяются `role`, `official`, `audio`. + язык: строка «Аудио» → нейросеть → слова в тексте; строка «Аудио» подбирается к дорожке по имени + студии из `|Дубляж, Le Doyen|` (слова-типы `Дубляж`/`MVO`/… отбрасываются); защита от имён вроде + «Дубляжная»: если дубляж назван только в имени студии, а в тексте указано число голосов, класс берётся + по тексту; отдельно берётся `original`; определяются `role`, `official`, `audio`. 7. **Агрегат релиза.** Дубли (тип + студия) склеиваются; студии из заголовка раздачи, которых нет в описании, добавляются с `source: "title"`; серии и сезон разбираются правилами (`src/episodes.py`). diff --git a/src/audio18.py b/src/audio18.py index abd616c..ce37339 100644 --- a/src/audio18.py +++ b/src/audio18.py @@ -84,7 +84,7 @@ def apply_audio_18(result: dict, text: str) -> dict: t["is_18plus"] = True result["tracks"] = out result["studios"] = [{"studio": t["studio"], "translation_type": t["translation_type"], - "is_18plus": t["is_18plus"], "episodes": t.get("episodes")} + "lang": t.get("lang"), "is_18plus": t["is_18plus"], "episodes": t.get("episodes")} for t in out if t.get("studio")] result["is_18plus"] = any(t["is_18plus"] for t in out) return result diff --git a/src/predict.py b/src/predict.py index 44305b7..f7927d6 100644 --- a/src/predict.py +++ b/src/predict.py @@ -57,6 +57,27 @@ def refine_span(text: str, span: tuple[int, int] | None) -> tuple[int, int] | No return a, z + +_RE_VOICES = re.compile(r"(?i)(много|двух|одно)голос") +_RE_DUB_WORD = re.compile(r"(?i)дубл") +_PREFIXES = (("проф.", re.compile(r"(?i)профессиональн")), ("люб.", re.compile(r"(?i)любительск")), + ("авт.", re.compile(r"(?i)авторск"))) + + +def guard_type(ttype: str, text: str, span_text: str | None, types: list[str]) -> str: + """Дубляж из-за имени студии («Дубляжная», «Продубляж»): если вне имени о дубляже ничего нет, + а число голосов названо прямо («Любительский (многоголосый закадровый) …») — берём класс по тексту.""" + if "дубляж" not in ttype: + return ttype + rest = text.replace(span_text, " ") if span_text else text + voices = _RE_VOICES.search(rest) + if _RE_DUB_WORD.search(rest) or not voices: + return ttype + prefix = next((p for p, rx in _PREFIXES if rx.search(rest)), "") + cand = f"{prefix} {voices.group(1).lower()}голосый".strip() + return cand if cand in types else ttype + + class DubPredictor: """Загруженная модель для предсказания типа озвучки и автора.""" @@ -114,6 +135,7 @@ class DubPredictor: span = refine_span(text, decode_span( bio_pred[i], offsets[i], enc["attention_mask"][i].tolist())) span_text = text[span[0]:span[1]].strip() if span else None + ttype = guard_type(self.types[tid], text, span_text, self.types) span_conf = None if span is not None: toks = [t for t, (a, z) in enumerate(offsets[i]) @@ -121,18 +143,18 @@ class DubPredictor: confs = [float(bio_prob[i, t, bio_pred[i, t]]) for t in toks] span_conf = float(np.mean(confs)) if confs else None lang, lang_conf, lang_src = "", None, None - if lang_prob is not None: + if info and info["lang"]: # язык, прямо названный в строке «Аудио», надёжнее нейросети + lang, lang_src = info["lang"], "audio" + elif lang_prob is not None: j = int(lang_prob[i].argmax()) if lang_prob[i, j] >= 0.5: lang, lang_conf, lang_src = self.langs[j], round(float(lang_prob[i, j]), 4), "model" - if not lang and info and info["lang"]: - lang, lang_src = info["lang"], "audio" if not lang: lang = lang_of_text(seg) lang_src = "text" if lang else None results.append({ "segment": seg, - "translation_type": self.types[tid], + "translation_type": ttype, "type_confidence": round(float(type_prob[i, tid]), 4), "studio_span": span_text, "studio": canonical(span_text) if span_text else None, @@ -144,7 +166,7 @@ class DubPredictor: "lang_source": lang_src, "role": role_of(info, seg), "official": bool(info and info["official"]), - "audio": ({"codec": info["codec"] or None, "channels": info["channels"] or None, + "audio": ({"codec": info["codec"] or None, "channels": int(info["channels"]) if info["channels"] else None, "bitrate_kbps": int(info["kbps"]) if info["kbps"] else None} if info else None), "_audio18": info["adult"] if info else None, diff --git a/src/release.py b/src/release.py index 94e7de7..48a4ac4 100644 --- a/src/release.py +++ b/src/release.py @@ -139,7 +139,7 @@ def aggregate(tracks: list[dict]) -> dict: typed = {t["translation_type"] for t in uniq if t.get("studio")} uniq = [t for t in uniq if t.get("studio") or t["translation_type"] not in typed] studios = [{"studio": t["studio"], "translation_type": t["translation_type"], - "is_18plus": t["is_18plus"], "episodes": t.get("episodes")} + "lang": t.get("lang"), "is_18plus": t["is_18plus"], "episodes": t.get("episodes")} for t in uniq if t.get("studio")] return { "tracks": uniq, @@ -149,6 +149,17 @@ def aggregate(tracks: list[dict]) -> dict: } + +def original_info(lines: list[dict]) -> dict | None: + """Оригинальная дорожка из строки «Аудио … |Original|» (без студии и комментариев); нет — None.""" + for ln in lines: + if ln["original"] and not ln["names"] and not ln["commentary"]: + return {"lang": ln["lang"] or None, "codec": ln["codec"] or None, + "channels": int(ln["channels"]) if ln["channels"] else None, + "bitrate_kbps": int(ln["kbps"]) if ln["kbps"] else None, "raw": ln["raw"]} + return None + + RE_SEASON = re.compile(r"(?i)сезон[ыа]?\s*(\d+(?:\s*[-–]\s*\d+)?)|\bS(\d{1,2})\b(?!E)") @@ -253,6 +264,7 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No if segs: merge_title_tracks(tracks, predictor._predict_batch(segs)) result = aggregate(tracks) + result["original"] = original_info(lines) apply_audio_18(result, page_text) for t in result["tracks"]: t.pop("_audio18", None) diff --git a/src/trackinfo.py b/src/trackinfo.py index 6f1514c..28ac5b4 100644 --- a/src/trackinfo.py +++ b/src/trackinfo.py @@ -34,13 +34,15 @@ RE_ADULT = re.compile(r"(? 6 каналов RE_NAMES = re.compile(r"\|\s*([^|]{2,40}?)\s*\||\(([^()]{2,40})\)") RE_ORIGINAL = re.compile(r"(?i)\boriginal\b|оригинал") RE_COMMENT = re.compile(r"(?i)комментар|commentary") RE_OFFICIAL = re.compile(r"(?i)официальн|лицензион|licensed") _HOMOGLYPH = str.maketrans("АСЕ", "ACE") # «АС3» кириллицей _NORM = re.compile(r"[\W_]+") -_NOT_NAME = re.compile(r"(?i)^(?:original|оригинал\w*|18\s*\+|forced|full|sdh|\d+\s*ch|\d+(?:\.\d)?)$") +_NOT_NAME = re.compile(r"(?i)^(?:dub|vo|mvo|dvo|avo|дубляж|дублированн(?:ый|ая|ое)|закадров(?:ый|ая|ое)|" + r"(?:много|двух|одно)голос\w*|original|оригинал\w*|18\s*\+|forced|full|sdh|\d+\s*ch|\d+(?:\.\d)?)$") def norm(s: str) -> str: @@ -56,6 +58,12 @@ def lang_of_text(text: str) -> str: return "" +def _channels(v: str) -> str: + """«6» -> «6»; «5.1» -> «6» (число каналов, а не раскладка).""" + a, _, b = v.partition(".") + return str(int(a) + int(b or 0)) + + def parse_audio_line(line: str) -> dict: body = re.sub(r"\s+", " ", (line or "").strip()) lead = RE_LEAD_CODE.match(body) @@ -66,11 +74,13 @@ def parse_audio_line(line: str) -> dict: codec = RE_CODEC.search(fixed) ch = RE_CH.search(fixed) kbps = RE_KBPS.search(fixed) - names = [n.strip() for a, b in RE_NAMES.findall(body) for n in [a or b] + names = [n.strip() for a, b in RE_NAMES.findall(body) for chunk in [a or b] + for n in re.split(r"\s*[,;/]\s*", chunk) # «|Дубляж, Le Doyen|» -> тип и студия отдельно if n.strip() and not _NOT_NAME.match(n.strip()) and not RE_CODEC.search(n)] + layout = RE_LAYOUT.search(fixed) return {"raw": body, "lang": lang, "codec": re.sub(r"\s+", "", codec.group(1)).upper() if codec else "", - "channels": ch.group(1) if ch else "", "kbps": kbps.group(1) if kbps else "", + "channels": _channels(ch.group(1)) if ch else (_channels(layout.group(0)) if layout else ""), "kbps": kbps.group(1) if kbps else "", "adult": bool(RE_ADULT.search(body)), "original": bool(RE_ORIGINAL.search(body)), "commentary": bool(RE_COMMENT.search(body)), "official": bool(RE_OFFICIAL.search(body)), "names": names} diff --git a/tests/run_tests.py b/tests/run_tests.py index 5c1dc1d..172328f 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -301,6 +301,28 @@ _agg2 = aggregate([_t("проф. дубляж", "Продубляж"), _t("пр check("aggregate: дорожка без студии того же типа схлопывается", [x["translation_type"] for x in _agg2["tracks"]] == ["проф. дубляж", "проф. двухголосый"]) +print("== строки «Аудио»: язык, студии, оригинал ==") +from src.predict import guard_type +from src.release import original_info +from src.trackinfo import audio_lines_all, match_audio +_au = audio_lines_all( + "Аудио 1: Russian E-AC3 / 5.1 / 48 kHz / 768 kbps |Дубляж, Продубляж|\n" + "Аудио 4: Ukrainian E-AC3 / 5.1 / 48 kHz / 256 kbps |Дубляж, Le Doyen|\n" + "Аудио 5: English E-AC3+Atmos / 5.1 / 48 kHz / 768 kbps |Original|\n") +check("аудио: «|Дубляж, Le Doyen|» -> студия Le Doyen без слова-типа", _au[1]["names"] == ["Le Doyen"], str(_au[1]["names"])) +check("аудио: язык Ukrainian -> UKR", _au[1]["lang"] == "UKR") +check("аудио: раскладка 5.1 -> 6 каналов", _au[1]["channels"] == "6") +check("аудио: дорожка студии находит свою строку", + match_audio("Профессиональный (дублированный) Le Doyen", _au) == [_au[1]]) +_orig = original_info(_au) +check("оригинал: ENG, 768 kbps, 6 каналов", _orig and _orig["lang"] == "ENG" and _orig["bitrate_kbps"] == 768 and _orig["channels"] == 6, str(_orig)) +check("оригинал: нет строки Original -> None", original_info(_au[:2]) is None) +_T = ["проф. дубляж", "проф. многоголосый", "люб. многоголосый", "многоголосый"] +check("тип: «Любительский (многоголосый …) Дубляжная» не дубляж", + guard_type("проф. дубляж", "Любительский (многоголосый закадровый) Дубляжная", "Дубляжная", _T) == "люб. многоголосый") +check("тип: настоящий дубляж не трогается", + guard_type("проф. дубляж", "Профессиональный (дублированный) Продубляж", "Продубляж", _T) == "проф. дубляж") + print("== серии по студиям ==") from src.episodes import format_ranges as _fr, parse_episodes as _pe # noqa: E402 from src.release import series_info as _si # noqa: E402