Язык дорожки из строки «Аудио» (UKR вместо RUS), студия |Дубляж, Имя|, каналы 5.1, оригинальная дорожка, язык в studios, защита от студий «Дубляжная»
docker / build (push) Successful in 1m40s

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-29 17:55:08 +07:00
1 parent 9528113cfd
commit e648415719
6 files changed
+88 -16

No files matched your search

+12 -6
View File
@@ -35,9 +35,10 @@
```json ```json
{ {
"tracks": [ /* дорожки, см. ниже */ ], "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": ["проф. многоголосый"], "translation_types": ["проф. многоголосый"],
"is_18plus": false, "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": []}, "series": {"season": "2", "episodes": "1-9", "total": 10, "by_studio": {"LostFilm": "1-4"}, "full_release": []},
"title": "Название раздачи", "title": "Название раздачи",
"segmenter": "model", "segmenter": "model",
@@ -48,7 +49,8 @@
| Поле | Тип | Описание | | Поле | Тип | Описание |
|---|---|---| |---|---|---|
| `tracks` | array | дорожки релиза без дублей (дубль — одинаковые тип + студия) | | `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 | отсортированный набор типов (значения — из таблицы классов ниже) | | `translation_types` | array of string | отсортированный набор типов (значения — из таблицы классов ниже) |
| `is_18plus` | bool | `true`, если 18+ хотя бы у одной дорожки | | `is_18plus` | bool | `true`, если 18+ хотя бы у одной дорожки |
| `series` | object \| null | сезон, выпущенные серии, «какая студия какие серии» (для Sonarr); `null` — сведений нет | | `series` | object \| null | сезон, выпущенные серии, «какая студия какие серии» (для Sonarr); `null` — сведений нет |
@@ -74,10 +76,10 @@
| `age_confidence` | float 0..1 | вероятность предсказанного класса 18+ | | `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` | 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_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 | `перевод`, `оригинал` (оригинальная дорожка без озвучки) или `комментарии` (комментарии режиссёра и т. п.) | | `role` | string | `перевод`, `оригинал` (оригинальная дорожка без озвучки) или `комментарии` (комментарии режиссёра и т. п.) |
| `official` | bool | в строке «Аудио» есть слова «официальный» / «лицензионный» / «licensed» | | `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"` | только в ответе релиза: дорожка найдена в описании или студия взята из заголовка раздачи | | `source` | `"page"` \| `"title"` | только в ответе релиза: дорожка найдена в описании или студия взята из заголовка раздачи |
| `episodes` | string \| null | серии, которые озвучивает дорожка («1-4», «1,3-5»); `null` — серии не указаны (покрывает весь релиз) | | `episodes` | string \| null | серии, которые озвучивает дорожка («1-4», «1,3-5»); `null` — серии не указаны (покрывает весь релиз) |
| `episode_ranges` | array | то же в виде `[[1, 4]]`; пусто, если серий нет | | `episode_ranges` | array | то же в виде `[[1, 4]]`; пусто, если серий нет |
@@ -158,7 +160,8 @@ type Track = {
type Release = { type Release = {
tracks: Track[]; // может быть пустым массивом 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[]; translation_types: string[];
is_18plus: boolean; is_18plus: boolean;
series: { series: {
@@ -211,7 +214,10 @@ Cloudflare: FlareSolverr нестабилен.
несколько предсказаний: `translation_type` (14 классов), спан автора (разметка BIO по токенам), несколько предсказаний: `translation_type` (14 классов), спан автора (разметка BIO по токенам),
флаг 18+ и язык (`ENG`/`RUS`/`UKR`). флаг 18+ и язык (`ENG`/`RUS`/`UKR`).
6. **Постобработка.** Автор канонизируется словарём алиасов; 18+ ставится также по строке «Аудио»; 6. **Постобработка.** Автор канонизируется словарём алиасов; 18+ ставится также по строке «Аудио»;
язык: нейросеть → строка «Аудио» → слова в тексте; определяются `role`, `official`, `audio`. язык: строка «Аудио» → нейросеть → слова в тексте; строка «Аудио» подбирается к дорожке по имени
студии из `|Дубляж, Le Doyen|` (слова-типы `Дубляж`/`MVO`/… отбрасываются); защита от имён вроде
«Дубляжная»: если дубляж назван только в имени студии, а в тексте указано число голосов, класс берётся
по тексту; отдельно берётся `original`; определяются `role`, `official`, `audio`.
7. **Агрегат релиза.** Дубли (тип + студия) склеиваются; студии из заголовка раздачи, которых нет 7. **Агрегат релиза.** Дубли (тип + студия) склеиваются; студии из заголовка раздачи, которых нет
в описании, добавляются с `source: "title"`; серии и сезон разбираются правилами (`src/episodes.py`). в описании, добавляются с `source: "title"`; серии и сезон разбираются правилами (`src/episodes.py`).
+1 -1
View File
@@ -84,7 +84,7 @@ def apply_audio_18(result: dict, text: str) -> dict:
t["is_18plus"] = True t["is_18plus"] = True
result["tracks"] = out result["tracks"] = out
result["studios"] = [{"studio": t["studio"], "translation_type": t["translation_type"], 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")] for t in out if t.get("studio")]
result["is_18plus"] = any(t["is_18plus"] for t in out) result["is_18plus"] = any(t["is_18plus"] for t in out)
return result return result
+27 -5
View File
@@ -57,6 +57,27 @@ def refine_span(text: str, span: tuple[int, int] | None) -> tuple[int, int] | No
return a, z 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: class DubPredictor:
"""Загруженная модель для предсказания типа озвучки и автора.""" """Загруженная модель для предсказания типа озвучки и автора."""
@@ -114,6 +135,7 @@ class DubPredictor:
span = refine_span(text, decode_span( span = refine_span(text, decode_span(
bio_pred[i], offsets[i], enc["attention_mask"][i].tolist())) bio_pred[i], offsets[i], enc["attention_mask"][i].tolist()))
span_text = text[span[0]:span[1]].strip() if span else None 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 span_conf = None
if span is not None: if span is not None:
toks = [t for t, (a, z) in enumerate(offsets[i]) 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] confs = [float(bio_prob[i, t, bio_pred[i, t]]) for t in toks]
span_conf = float(np.mean(confs)) if confs else None span_conf = float(np.mean(confs)) if confs else None
lang, lang_conf, lang_src = "", None, 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()) j = int(lang_prob[i].argmax())
if lang_prob[i, j] >= 0.5: if lang_prob[i, j] >= 0.5:
lang, lang_conf, lang_src = self.langs[j], round(float(lang_prob[i, j]), 4), "model" 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: if not lang:
lang = lang_of_text(seg) lang = lang_of_text(seg)
lang_src = "text" if lang else None lang_src = "text" if lang else None
results.append({ results.append({
"segment": seg, "segment": seg,
"translation_type": self.types[tid], "translation_type": ttype,
"type_confidence": round(float(type_prob[i, tid]), 4), "type_confidence": round(float(type_prob[i, tid]), 4),
"studio_span": span_text, "studio_span": span_text,
"studio": canonical(span_text) if span_text else None, "studio": canonical(span_text) if span_text else None,
@@ -144,7 +166,7 @@ class DubPredictor:
"lang_source": lang_src, "lang_source": lang_src,
"role": role_of(info, seg), "role": role_of(info, seg),
"official": bool(info and info["official"]), "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} "bitrate_kbps": int(info["kbps"]) if info["kbps"] else None}
if info else None), if info else None),
"_audio18": info["adult"] if info else None, "_audio18": info["adult"] if info else None,
+13 -1
View File
@@ -139,7 +139,7 @@ def aggregate(tracks: list[dict]) -> dict:
typed = {t["translation_type"] for t in uniq if t.get("studio")} 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] 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"], 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")] for t in uniq if t.get("studio")]
return { return {
"tracks": uniq, "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)") 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: if segs:
merge_title_tracks(tracks, predictor._predict_batch(segs)) merge_title_tracks(tracks, predictor._predict_batch(segs))
result = aggregate(tracks) result = aggregate(tracks)
result["original"] = original_info(lines)
apply_audio_18(result, page_text) apply_audio_18(result, page_text)
for t in result["tracks"]: for t in result["tracks"]:
t.pop("_audio18", None) t.pop("_audio18", None)
+13 -3
View File
@@ -34,13 +34,15 @@ RE_ADULT = re.compile(r"(?<!\d)18\s*\+")
RE_CODEC = re.compile(r"(?i)(E-?AC-?3|AC-?3|DTS-HD\s*MA|DTS-HD|DTS:X|DTS|TrueHD|FLAC|AAC|Opus|MP3|L?PCM)") RE_CODEC = re.compile(r"(?i)(E-?AC-?3|AC-?3|DTS-HD\s*MA|DTS-HD|DTS:X|DTS|TrueHD|FLAC|AAC|Opus|MP3|L?PCM)")
RE_CH = re.compile(r"(?i)(\d(?:\.\d)?)\s*(?:ch\b|канал)") RE_CH = re.compile(r"(?i)(\d(?:\.\d)?)\s*(?:ch\b|канал)")
RE_KBPS = re.compile(r"(?i)(\d{2,5})\s*(?:k|к)(?:bps|бит)") RE_KBPS = re.compile(r"(?i)(\d{2,5})\s*(?:k|к)(?:bps|бит)")
RE_LAYOUT = re.compile(r"(?<![\d.])([1-9])\.([0-2])(?![\d.])") # раскладка «5.1» -> 6 каналов
RE_NAMES = re.compile(r"\|\s*([^|]{2,40}?)\s*\||\(([^()]{2,40})\)") RE_NAMES = re.compile(r"\|\s*([^|]{2,40}?)\s*\||\(([^()]{2,40})\)")
RE_ORIGINAL = re.compile(r"(?i)\boriginal\b|оригинал") RE_ORIGINAL = re.compile(r"(?i)\boriginal\b|оригинал")
RE_COMMENT = re.compile(r"(?i)комментар|commentary") RE_COMMENT = re.compile(r"(?i)комментар|commentary")
RE_OFFICIAL = re.compile(r"(?i)официальн|лицензион|licensed") RE_OFFICIAL = re.compile(r"(?i)официальн|лицензион|licensed")
_HOMOGLYPH = str.maketrans("АСЕ", "ACE") # «АС3» кириллицей _HOMOGLYPH = str.maketrans("АСЕ", "ACE") # «АС3» кириллицей
_NORM = re.compile(r"[\W_]+") _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: def norm(s: str) -> str:
@@ -56,6 +58,12 @@ def lang_of_text(text: str) -> str:
return "" 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: def parse_audio_line(line: str) -> dict:
body = re.sub(r"\s+", " ", (line or "").strip()) body = re.sub(r"\s+", " ", (line or "").strip())
lead = RE_LEAD_CODE.match(body) lead = RE_LEAD_CODE.match(body)
@@ -66,11 +74,13 @@ def parse_audio_line(line: str) -> dict:
codec = RE_CODEC.search(fixed) codec = RE_CODEC.search(fixed)
ch = RE_CH.search(fixed) ch = RE_CH.search(fixed)
kbps = RE_KBPS.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)] 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, return {"raw": body, "lang": lang,
"codec": re.sub(r"\s+", "", codec.group(1)).upper() if codec else "", "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)), "adult": bool(RE_ADULT.search(body)), "original": bool(RE_ORIGINAL.search(body)),
"commentary": bool(RE_COMMENT.search(body)), "official": bool(RE_OFFICIAL.search(body)), "commentary": bool(RE_COMMENT.search(body)), "official": bool(RE_OFFICIAL.search(body)),
"names": names} "names": names}
+22
View File
@@ -301,6 +301,28 @@ _agg2 = aggregate([_t("проф. дубляж", "Продубляж"), _t("пр
check("aggregate: дорожка без студии того же типа схлопывается", check("aggregate: дорожка без студии того же типа схлопывается",
[x["translation_type"] for x in _agg2["tracks"]] == ["проф. дубляж", "проф. двухголосый"]) [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("== серии по студиям ==") print("== серии по студиям ==")
from src.episodes import format_ranges as _fr, parse_episodes as _pe # noqa: E402 from src.episodes import format_ranges as _fr, parse_episodes as _pe # noqa: E402
from src.release import series_info as _si # noqa: E402 from src.release import series_info as _si # noqa: E402