Язык дорожки из строки «Аудио» (UKR вместо RUS), студия |Дубляж, Имя|, каналы 5.1, оригинальная дорожка, язык в studios, защита от студий «Дубляжная»
docker / build (push) Successful in 1m40s
docker / build (push) Successful in 1m40s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9528113cfd
commit
e648415719
6 files changed
+88
-16
No files matched your search
+12
-6
@@ -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`).
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
+27
-5
@@ -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,
|
||||
|
||||
+13
-1
@@ -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)
|
||||
|
||||
+13
-3
@@ -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_CH = re.compile(r"(?i)(\d(?:\.\d)?)\s*(?:ch\b|канал)")
|
||||
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_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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user