API: видео, тип релиза и субтитры в ответе релиза; Atmos и kHz у дорожек
docker / build (push) Successful in 1m18s
docker / build (push) Successful in 1m18s
src/techinfo.py — правила по строкам «Видео:», «Тип релиза:»/«Качество», «Контейнер:», «Продолжительность:», «Субтитры:» и заголовку: поля video (кодек, размер кадра, класс разрешения, fps, битрейт, битность, HDR, профиль DV), release (тип, нормализованный источник, контейнер, продолжительность), subtitles (языки с forced/full/SDH, формат, вшитые). audio дорожек и original: khz и atmos (вход модели не меняется). title_from_html больше не рвёт заголовок rutracker пробелами (<span>/<wbr> на каждом слове). Покрытие на 600 страницах каждого трекера: видео 90/93/100%, e2e озвучки без изменений. Тесты 275 ok. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
89ffe4375b
commit
dc19fe6255
9 files changed
+409
-16
No files matched your search
+54
-5
@@ -38,8 +38,16 @@
|
||||
"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|"},
|
||||
"original": {"lang": "ENG", "codec": "E-AC3", "channels": 6, "bitrate_kbps": 768, "khz": 48, "atmos": true, "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": []},
|
||||
"video": {"codec": "HEVC", "width": 3840, "height": 2160, "resolution": "2160p", "fps": 23.976, "bitrate_kbps": 24800,
|
||||
"bit_depth": 10, "hdr": ["Dolby Vision", "HDR10+", "HDR10"], "dv_profile": "8", "raw": "MPEG-H HEVC Video / 24,8 Mbps / 3840x2160 / …"},
|
||||
"release": {"quality": "WEB-DL 2160p Dolby Vision", "source": "WEB-DL", "container": "MKV", "duration": "01:38:12", "duration_sec": 5892},
|
||||
"subtitles": {"languages": ["RUS", "UKR", "ENG"],
|
||||
"tracks": [{"lang": "RUS", "forced": true, "full": false, "sdh": false},
|
||||
{"lang": "UKR", "forced": true, "full": true, "sdh": true},
|
||||
{"lang": "ENG", "forced": false, "full": true, "sdh": true}],
|
||||
"format": "softsub (SRT)", "hardsub": false, "raw": "Русские (Forced), Украинские (Forced, 2x Full, SDH), Английские (Full, SDH)"},
|
||||
"title": "Название раздачи",
|
||||
"segmenter": "model",
|
||||
"tracker": "rutracker"
|
||||
@@ -50,14 +58,39 @@
|
||||
|---|---|---|
|
||||
| `tracks` | array | дорожки релиза без дублей (дубль — одинаковые тип + студия) |
|
||||
| `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` — такой строки на странице нет |
|
||||
| `original` | object \| null | оригинальная дорожка из строки «Аудио … \|Original\|»: `{lang, codec, channels, bitrate_kbps, khz, atmos, raw}` (поля могут быть `null`, `atmos` — bool); `null` — такой строки на странице нет |
|
||||
| `translation_types` | array of string | отсортированный набор типов (значения — из таблицы классов ниже) |
|
||||
| `is_18plus` | bool | `true`, если 18+ хотя бы у одной дорожки |
|
||||
| `series` | object \| null | сезон, выпущенные серии, «какая студия какие серии» (для Sonarr); `null` — сведений нет |
|
||||
| `video` | object \| null | видео из строки «Видео:» / «Video:» (у nnm — также «Видеокодек», «Размер кадра», «Битрейт видео»), см. ниже; `null` — строки нет |
|
||||
| `release` | object \| null | тип релиза, контейнер, продолжительность, см. ниже; `null` — ничего из этого на странице нет |
|
||||
| `subtitles` | object \| null | субтитры из строк «Субтитры:», «Субтитры 2:», «Язык субтитров:» (нет строк — языки из «Sub (Rus, Eng)» заголовка), см. ниже; `null` — о субтитрах ничего не сказано |
|
||||
| `title` | string \| null | заголовок раздачи, если найден |
|
||||
| `segmenter` | `"model"` \| `"rules"` | чем страница разбита на строки дорожек: обученным сегментатором или запасными правилами |
|
||||
| `tracker` | string | только для `url`: `rutracker`, `nnm`, `kinozal` или `lostfilm` |
|
||||
|
||||
`video` (все поля, кроме `hdr` и `raw`, могут быть `null`):
|
||||
`codec` — `HEVC`, `AVC`, `AV1`, `VP9`, `VC-1`, `MPEG-2`, `XviD`, `DivX`, `MPEG-4`;
|
||||
`width`/`height` — размер кадра; `resolution` — класс по ширине (`2160p`, `1440p`, `1080p`, `720p`; кадр с
|
||||
полосами 1920x800 — тоже `1080p`), для SD — высота кадра (`576p`, `400p`); `fps`; `bitrate_kbps`;
|
||||
`bit_depth` — 8/10/12; `hdr` — массив из `Dolby Vision`, `HDR10+`, `HDR10`, `HLG` или просто `HDR`, если вид
|
||||
не назван (ищется в строке видео, «HDR format», типе релиза и заголовке), пустой — HDR нет; `dv_profile` —
|
||||
профиль Dolby Vision строкой («8», «5»); `raw` — строка видео целиком.
|
||||
|
||||
`release`: `quality` — значение «Тип релиза» / «Качество видео» / «Качество» как на странице (без
|
||||
«\| Исходник: …»); `source` — нормализованный источник: `UHD BDRemux`, `BDRemux`, `WEB-DLRip`, `WEB-DL`, `WEBRip`,
|
||||
`BDRip`, `HDRip`, `Blu-ray`, `DVDRip`, `DVD9`, `DVD5`, `HDTVRip`, `HDTV`, `SATRip`, `TVRip`, `DVB`, `VHSRip`,
|
||||
`DCPRip`, `IPTV`, `CAMRip`, `DVD` (из «Качества», иначе из заголовка); `container` — `MKV`, `MP4`, `AVI`, `M2TS`,
|
||||
`ISO`, `DVD-Video`, `TS`, `WEBM`, `MOV`, `WMV` (kinozal строку контейнера обычно не пишет — тогда `null`);
|
||||
`duration` — «Продолжительность» как на странице («01:38:12», «18 x ~ 00:20:00»); `duration_sec` — секунды,
|
||||
только если это одно значение ЧЧ:ММ:СС фильма (не серии и не список файлов). Любое поле может быть `null`.
|
||||
|
||||
`subtitles`: `languages` — коды языков по порядку (те же коды, что у `lang` дорожки); `tracks` — по записи
|
||||
на язык: `{lang, forced, full, sdh}` (флаги «Forced/форсированные/надписи», «Full/полные», «SDH/для
|
||||
слабослышащих» объединяются по всем строкам); `format` — «Формат субтитров» как на странице или `null`;
|
||||
`hardsub` — субтитры вшиты в видео; `raw` — исходные строки через «; ». «Субтитры: нет/отсутствуют» даёт
|
||||
объект с пустым `languages` — субтитров точно нет; `null` — страница о субтитрах молчит.
|
||||
|
||||
`series`: `season` — номер сезона строкой или `null`; `episodes` — выпущенные серии («1-9»);
|
||||
`total` — всего серий; `by_studio` — `{студия: серии}`; `full_release` — студии без указанных серий
|
||||
(для сериала это значит «озвучены все вышедшие серии»). Подробности — в [integration.md](integration.md).
|
||||
@@ -79,7 +112,7 @@
|
||||
| `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}` из строки «Аудио» страницы (`"E-AC3"`, `6`, `768`; `channels` — число каналов, «5.1» → 6); каждое поле может быть `null`; весь объект `null`, если строка не найдена |
|
||||
| `audio` | object \| null | `{codec, channels, bitrate_kbps, khz, atmos}` из строки «Аудио» страницы (`"E-AC3"`, `6`, `768`, `48`, `true`; `channels` — число каналов, «5.1» → 6; `khz` — частота дискретизации, 44.1 / 48; `atmos` — в строке есть «Atmos», кодек при этом остаётся `E-AC3`/`TrueHD`); поля, кроме `atmos`, могут быть `null`; весь объект `null`, если строка не найдена |
|
||||
| `source` | `"page"` \| `"title"` | только в ответе релиза: дорожка найдена в описании или студия взята из заголовка раздачи |
|
||||
| `episodes` | string \| null | серии, которые озвучивает дорожка («1-4», «1,3-5»); `null` — серии не указаны (покрывает весь релиз) |
|
||||
| `episode_ranges` | array | то же в виде `[[1, 4]]`; пусто, если серий нет |
|
||||
@@ -157,7 +190,7 @@ type Track = {
|
||||
lang_source: "model" | "audio" | "text" | "url" | null; // "url" — ссылка lostfilm.tv; null вместе с lang
|
||||
role: "перевод" | "оригинал" | "комментарии"; // никогда null
|
||||
official: boolean;
|
||||
audio: { codec: string | null; channels: number | null; bitrate_kbps: number | null } | null;
|
||||
audio: { codec: string | null; channels: number | null; bitrate_kbps: number | null; khz: number | null; atmos: boolean } | null;
|
||||
// только в ответе релиза (html / url):
|
||||
source: "page" | "title";
|
||||
episodes: string | null; // "1-4", "1,3-5"; null — серии не указаны
|
||||
@@ -167,7 +200,8 @@ type Track = {
|
||||
type Release = {
|
||||
tracks: Track[]; // может быть пустым массивом
|
||||
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;
|
||||
original: { lang: string | null; codec: string | null; channels: number | null; bitrate_kbps: number | null;
|
||||
khz: number | null; atmos: boolean; raw: string } | null;
|
||||
translation_types: string[];
|
||||
is_18plus: boolean;
|
||||
series: {
|
||||
@@ -177,6 +211,19 @@ type Release = {
|
||||
by_studio: Record<string, string>; // { "LostFilm": "1-4" }, может быть {}
|
||||
full_release: string[]; // студии без указанных серий
|
||||
} | null; // null — ни сезона, ни серий в раздаче нет
|
||||
video: {
|
||||
codec: string | null; width: number | null; height: number | null; resolution: string | null;
|
||||
fps: number | null; bitrate_kbps: number | null; bit_depth: number | null;
|
||||
hdr: string[]; // [] — HDR нет
|
||||
dv_profile: string | null; raw: string;
|
||||
} | null;
|
||||
release: { quality: string | null; source: string | null; container: string | null;
|
||||
duration: string | null; duration_sec: number | null } | null;
|
||||
subtitles: {
|
||||
languages: string[]; // [] — «Субтитры: нет»
|
||||
tracks: { lang: string; forced: boolean; full: boolean; sdh: boolean }[];
|
||||
format: string | null; hardsub: boolean; raw: string | null;
|
||||
} | null; // null — о субтитрах на странице ничего нет
|
||||
title: string | null;
|
||||
segmenter: "model" | "rules";
|
||||
tracker?: "rutracker" | "nnm" | "kinozal" | "lostfilm"; // только для url
|
||||
@@ -226,6 +273,8 @@ Cloudflare: FlareSolverr нестабилен.
|
||||
по тексту; отдельно берётся `original`; определяются `role`, `official`, `audio`.
|
||||
7. **Агрегат релиза.** Дубли (тип + студия) склеиваются; студии из заголовка раздачи, которых нет
|
||||
в описании, добавляются с `source: "title"`; серии и сезон разбираются правилами (`src/episodes.py`).
|
||||
8. **Техданные** (`video`, `release`, `subtitles`) — правилами по строкам «Видео:», «Тип релиза:»,
|
||||
«Контейнер:», «Продолжительность:», «Субтитры:» и заголовку (`src/techinfo.py`), без нейросети.
|
||||
|
||||
Размеченные данные (`data/labels.csv`) используются только при обучении: на них учатся метки
|
||||
`translation_type`, спан студии, 18+ и язык. Схема файла и происхождение меток — в
|
||||
|
||||
@@ -37,12 +37,18 @@ python -m src.predict --url https://kinozal.tv/details.php?id=1 --cookie "uid=..
|
||||
| `studios` | уникальные `{studio, translation_type, is_18plus}` |
|
||||
| `translation_types` | отсортированный список типов релиза |
|
||||
| `is_18plus` | `true`, если 18+ хотя бы у одной дорожки |
|
||||
| `original`, `series` | оригинальная дорожка; сезон и серии |
|
||||
| `video` | кодек, разрешение, fps, битрейт, битность, HDR (Dolby Vision / HDR10+ / HDR10 / HLG) |
|
||||
| `release` | тип релиза («WEB-DL», «BDRemux»…), контейнер, продолжительность |
|
||||
| `subtitles` | языки субтитров с пометками forced / full / SDH, формат, вшитые ли |
|
||||
| `title` | заголовок раздачи (если найден в HTML) |
|
||||
| `tracker` | только для `--url` |
|
||||
|
||||
Дорожки дедуплицируются по (тип, студия). Студии из заголовка раздачи вида
|
||||
`ПМ (LostFilm)`, которых нет в описании, добавляются с `source: "title"`
|
||||
(у остальных `source: "page"`). `--text` по-прежнему возвращает только `tracks`.
|
||||
Все поля и их значения — в [api-reference.md](api-reference.md); `video`, `release`, `subtitles`
|
||||
считаются правилами (`src/techinfo.py`), без нейросети.
|
||||
|
||||
## Python API
|
||||
|
||||
|
||||
+6
-1
@@ -41,7 +41,12 @@ API:
|
||||
| `lang`, `lang_confidence`, `lang_source` | язык дорожки (`RUS`, `UKR`, `ENG`, …); источник: `model` (языковая голова), `audio` (строка «Аудио»), `text` (слова в строке дорожки) или `null` |
|
||||
| `role` | `перевод` / `оригинал` / `комментарии` (по строке «Аудио» и тексту, правилами) |
|
||||
| `official` | в строке «Аудио» сказано «официальный» / «лицензионный» |
|
||||
| `audio` | `{codec, channels, bitrate_kbps}` из строки «Аудио» (или `null`) |
|
||||
| `audio` | `{codec, channels, bitrate_kbps, khz, atmos}` из строки «Аудио» (или `null`) |
|
||||
|
||||
Ответ релиза (`html`/`url`) кроме дорожек несёт `video`, `release` и `subtitles` — разрешение, HDR,
|
||||
тип релиза, контейнер, продолжительность, языки субтитров; поля и значения — в
|
||||
[api-reference.md](api-reference.md). ONNX-путь (Б) их не считает: это правила `src/techinfo.py`,
|
||||
их нужно повторить на стороне клиента или ходить в HTTP-сервис.
|
||||
|
||||
Русский и украинский дубляж одной студии — две отдельные дорожки; так же раздельны обычная
|
||||
дорожка студии и её дорожка 18+ (если на странице две строки «Аудио» с одной студией).
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
| 7 тесты | `run_tests.py`: 57 ok (URL allowlist/SSRF, декодирование, заглушки Cloudflare/логина, title, слияние, агрегат) |
|
||||
| 8 `target/` | сделано |
|
||||
| 10 аудит дорожек | сделано 02.11.2026: `tools/audit_tracks.py` → `reports/tracks_audit.md` (план из 5 пунктов). Пункт 1 — студии из кодек-строк в разметке (`codec_line_rows`, +6 428 строк); модель переобучена: test joint exact 0.891 → 0.906, e2e P/R kinozal 0.745/0.850 → 0.766/0.879, rutracker 0.866/0.859 → 0.877/0.870, nnm 0.845/0.829 → 0.838/0.825. Пункты 2–5 и сегментатор на новой разметке — открыты |
|
||||
| 11 техданные релиза | сделано 02.11.2026: в ответе релиза `video` (кодек, разрешение, fps, битрейт, битность, HDR, профиль DV), `release` (тип, контейнер, продолжительность), `subtitles` (языки, forced/full/SDH, формат, вшитые); у дорожек `audio.khz` и `audio.atmos` — правила `src/techinfo.py`. Покрытие на 600 сохранённых страницах каждого трекера: видео найдено на 90% rutracker / 93% nnm / 100% kinozal, кодек — у 91% / 92% / 97% из них. Заголовок rutracker больше не рвётся пробелами («Галактическо е»). В ONNX-клиентах этих полей нет |
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
|
||||
+2
-4
@@ -33,7 +33,7 @@ from .html_extract import (RE_LINE_PREFIX, extract_segments, html_to_text,
|
||||
from .model import load_model, predict_with_lang
|
||||
from .studio_norm import canonical
|
||||
from .spans import MAX_LEN, decode_span
|
||||
from .trackinfo import lang_of_text, model_text, role_of
|
||||
from .trackinfo import audio_summary, lang_of_text, model_text, role_of
|
||||
|
||||
|
||||
# мусор, который модель иногда принимает за автора: раскладка каналов, кодеки
|
||||
@@ -166,9 +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": int(info["channels"]) if info["channels"] else None,
|
||||
"bitrate_kbps": int(info["kbps"]) if info["kbps"] else None}
|
||||
if info else None),
|
||||
"audio": audio_summary(info),
|
||||
"_audio18": info["adult"] if info else None,
|
||||
})
|
||||
return results
|
||||
|
||||
+8
-6
@@ -16,7 +16,8 @@ import re
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from .audio18 import apply_audio_18
|
||||
from .trackinfo import audio_lines_all, match_audio
|
||||
from .techinfo import tech_info
|
||||
from .trackinfo import audio_lines_all, audio_summary, match_audio
|
||||
from .episodes import format_ranges, merge_ranges, parse_episodes
|
||||
from .html_extract import (RE_LINE_PREFIX, _split_entries, extract_segments,
|
||||
html_to_text)
|
||||
@@ -43,7 +44,9 @@ def title_from_html(html: str) -> str:
|
||||
for sel in ("h1", "title"):
|
||||
node = soup.select_one(sel)
|
||||
if node and node.get_text(strip=True):
|
||||
return re.sub(r"\s+", " ", node.get_text(" ", strip=True))
|
||||
# без разделителя: rutracker оборачивает каждое русское слово в <span> и режет <wbr>
|
||||
# («Галактическо<wbr>е»), пробелы между словами — отдельные текстовые узлы
|
||||
return re.sub(r"\s+", " ", node.get_text()).strip()
|
||||
return ""
|
||||
|
||||
|
||||
@@ -153,9 +156,7 @@ 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 {"lang": ln["lang"] or None, **audio_summary(ln), "raw": ln["raw"]}
|
||||
return None
|
||||
|
||||
|
||||
@@ -169,7 +170,7 @@ def lostfilm_release(title: str | None = None) -> dict:
|
||||
"source": "url", "episode_ranges": [], "episodes": None}
|
||||
result = aggregate([track])
|
||||
result.update(original=None, series=series_info(title or "", result["tracks"]),
|
||||
title=title or None, segmenter="rules")
|
||||
**tech_info("", title or ""), title=title or None, segmenter="rules")
|
||||
return result
|
||||
|
||||
|
||||
@@ -374,6 +375,7 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
|
||||
for t in result["tracks"]:
|
||||
t.pop("_audio18", None)
|
||||
result["series"] = series_info(title or "", result["tracks"])
|
||||
result.update(tech_info(page_text, title or ""))
|
||||
result["title"] = title or None
|
||||
result["segmenter"] = used
|
||||
return result
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
"""Техданные релиза помимо озвучки: видео, тип релиза, контейнер, продолжительность, субтитры.
|
||||
|
||||
Только правила по строкам страницы («Видео: …», «Тип релиза: …», «Субтитры: …») и по
|
||||
заголовку раздачи — нейросеть здесь не нужна, форматы у трекеров устойчивые.
|
||||
|
||||
video_info(text, title) -> {codec, width, height, resolution, fps, bitrate_kbps, bit_depth, hdr, dv_profile, raw} | None
|
||||
release_info(text, title) -> {quality, source, container, duration, duration_sec} | None
|
||||
subtitles_info(text, title) -> {languages, tracks, format, hardsub, raw} | None
|
||||
tech_info(text, title) -> {"video": …, "release": …, "subtitles": …}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .trackinfo import LANG_CODES, lang_of_text
|
||||
|
||||
# строка техданных «Ключ: значение»; ключ — в начале строки, точки-заполнители MediaInfo допустимы
|
||||
def _field(text: str, key: str) -> list[str]:
|
||||
rx = re.compile(rf"(?im)^[^\S\n]*(?:{key})[^\S\n.]*\.*[^\S\n]*:[^\S\n]*(?P<v>[^\n]*)$")
|
||||
# nnm пишет несколько полей в одну строку через табуляцию или длинный пробел:
|
||||
# «Отсутствуют Дата мировой премьеры: …» — значение до такого разрыва
|
||||
return [re.split(r"\t| {3,}", m.group("v").strip(), maxsplit=1)[0].strip()
|
||||
for m in rx.finditer(text or "")]
|
||||
|
||||
|
||||
def _first(text: str, *keys: str) -> str:
|
||||
for key in keys:
|
||||
for v in _field(text, key):
|
||||
if v:
|
||||
return v
|
||||
return ""
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ видео
|
||||
VIDEO_CODECS = [ # (шаблон, каноническое имя); порядок важен: HEVC раньше MPEG-4
|
||||
(r"HEVC|H\.?265|x265", "HEVC"),
|
||||
(r"\bAV1\b", "AV1"),
|
||||
(r"\bVP9\b", "VP9"),
|
||||
(r"\bAVC\b|H\.?264|x264", "AVC"),
|
||||
(r"VC-?1", "VC-1"),
|
||||
(r"MPEG-?2|MPEG\s*Video\s*2", "MPEG-2"),
|
||||
(r"XviD", "XviD"),
|
||||
(r"DivX", "DivX"),
|
||||
(r"MPEG-?4", "MPEG-4"),
|
||||
]
|
||||
RE_SIZE = re.compile(r"(?<!\d)(\d{3,4})\s*[xх×*]\s*(\d{3,4})(?!\d)")
|
||||
RE_PLINE = re.compile(r"(?i)(?<!\d)(2160|1440|1080|720|576|480)[pi]\b")
|
||||
RE_FPS = re.compile(r"(?i)(?<![\d.,])(\d{2,3}(?:[.,]\d{1,6})?)\s*(?:fps|кадр)")
|
||||
# «8 200 Кбит/с» — пробел как разделитель тысяч, но не «640x360 1494 kb/s» (высота кадра перед числом)
|
||||
RE_RATE = re.compile(r"(?i)(?<![\d.,xх×*])(?<![xх×*] )(\d{1,3}(?: \d{3})+|\d+)((?:[.,]\d+)?)\s*([kкmм])\s*(?:bps|bit|бит|b/[sсc])")
|
||||
RE_DEPTH = re.compile(r"(?i)(?<![\d.,])(8|10|12)\s*-?\s*(?:bits?|бит)(?!\s*/)")
|
||||
RE_DV_PROFILE = re.compile(r"(?i)dolby\s*vision[^/\n]{0,15}?profile\s*(\d+(?:\.\d+)?)|dvhe\.0?(\d+)")
|
||||
HDR_FLAGS = [ # (шаблон, флаг) — что нашли, в этом порядке и отдаём
|
||||
(r"dolby\s*vision|\bDoVi\b", "Dolby Vision"),
|
||||
(r"HDR10\+|HDR10\s*plus", "HDR10+"),
|
||||
(r"HDR10(?!\+|\s*plus)|SMPTE\s*ST\s*2086", "HDR10"),
|
||||
(r"\bHLG\b", "HLG"),
|
||||
]
|
||||
RE_HDR_ANY = re.compile(r"(?i)\bHDR\b")
|
||||
|
||||
|
||||
def _rate_kbps(s: str) -> int | None:
|
||||
m = RE_RATE.search(s or "")
|
||||
if not m:
|
||||
return None
|
||||
whole, frac, unit = m.group(1).replace(" ", ""), m.group(2), m.group(3).lower()
|
||||
# «24,8 Mbps» — десятичная запятая; «1.050 kbps» — разделитель тысяч
|
||||
if unit in "kк" and re.fullmatch(r"[.,]\d{3}", frac) and len(whole) <= 3:
|
||||
whole, frac = whole + frac[1:], ""
|
||||
val = float(whole + frac.replace(",", "."))
|
||||
# «5763 Mбит/с», «16000 Mbps» — на деле килобиты: видео > 1 Гбит/с не бывает
|
||||
return round(val * 1000) if unit in "mм" and val < 1000 else round(val)
|
||||
|
||||
|
||||
def _resolution(width: int | None, height: int | None, line: str) -> str | None:
|
||||
"""Класс разрешения: по ширине (кадр с полосами 1920x800 — всё равно 1080p), иначе «1080p» из текста."""
|
||||
if width:
|
||||
for w, name in ((3800, "2160p"), (2500, "1440p"), (1900, "1080p"), (1200, "720p")):
|
||||
if width >= w:
|
||||
return name
|
||||
return f"{height}p" if height else None
|
||||
m = RE_PLINE.search(line)
|
||||
return f"{m.group(1)}p" if m else None
|
||||
|
||||
|
||||
def hdr_flags(*texts: str) -> list[str]:
|
||||
joined = "\n".join(t for t in texts if t)
|
||||
out = [flag for rx, flag in HDR_FLAGS if re.search(rx, joined, re.I)]
|
||||
if not out and RE_HDR_ANY.search(joined):
|
||||
out.append("HDR")
|
||||
return out
|
||||
|
||||
|
||||
def video_info(text: str, title: str = "") -> dict | None:
|
||||
"""Первая строка «Видео:»/«Video:» (MediaInfo-ключи nnm — запасной путь); нет — None."""
|
||||
line = _first(text, r"Видео(?:\s*\d+)?", r"Video(?:\s*\d+)?")
|
||||
extra = " ".join(x for x in (_first(text, r"Видео\s*кодек", r"Видеокодек"),
|
||||
_first(text, r"Размер\s+кадра"),
|
||||
_first(text, r"Битрейт\s+видео")) if x)
|
||||
if not line and not extra:
|
||||
return None
|
||||
src = line or extra
|
||||
codec = next((name for rx, name in VIDEO_CODECS if re.search(rx, src, re.I)), None)
|
||||
if codec is None and line and extra:
|
||||
codec = next((name for rx, name in VIDEO_CODECS if re.search(rx, extra, re.I)), None)
|
||||
size = RE_SIZE.search(src) or RE_SIZE.search(extra)
|
||||
width, height = (int(size.group(1)), int(size.group(2))) if size else (None, None)
|
||||
fps = RE_FPS.search(src)
|
||||
depth = RE_DEPTH.search(src)
|
||||
dv = RE_DV_PROFILE.search(src) or RE_DV_PROFILE.search("\n".join(_field(text, r"HDR\s*format")))
|
||||
hdr = hdr_flags(src, *_field(text, r"HDR\s*format"), release_quality(text), title)
|
||||
return {
|
||||
"codec": codec,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"resolution": _resolution(width, height, src + " " + release_quality(text) + " " + (title or "")),
|
||||
"fps": float(fps.group(1).replace(",", ".")) if fps else None,
|
||||
"bitrate_kbps": _rate_kbps(src) or _rate_kbps(_first(text, r"Битрейт\s+видео")),
|
||||
"bit_depth": int(depth.group(1)) if depth else None,
|
||||
"hdr": hdr,
|
||||
"dv_profile": (dv.group(1) or dv.group(2)) if dv else None,
|
||||
"raw": src,
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ тип релиза
|
||||
SOURCES = [ # (шаблон, тег); порядок важен: Remux раньше Rip, Rip раньше DL
|
||||
(r"UHD\s*BD\s*-?Remux|UHD\s*Blu-?ray\s*Remux", "UHD BDRemux"),
|
||||
(r"BD-?Remux|Blu-?ray\s*Remux|REMUX", "BDRemux"),
|
||||
(r"WEB-?DL\s*-?Rip", "WEB-DLRip"),
|
||||
(r"WEB-?DL", "WEB-DL"),
|
||||
(r"WEB-?Rip", "WEBRip"),
|
||||
(r"BD-?Rip|Blu-?ray\s*Rip", "BDRip"),
|
||||
(r"HD-?Rip", "HDRip"),
|
||||
(r"UHD\s*Blu-?ray|Blu-?ray\s*Disc|\bBD(?:25|50|66|100)\b|\bBlu-?ray\b", "Blu-ray"),
|
||||
(r"DVD-?Rip", "DVDRip"),
|
||||
(r"\bDVD-?9\b", "DVD9"),
|
||||
(r"\bDVD-?5\b", "DVD5"),
|
||||
(r"HDTV-?Rip", "HDTVRip"),
|
||||
(r"HDTV", "HDTV"),
|
||||
(r"SAT-?Rip", "SATRip"),
|
||||
(r"\bTV-?Rip\b", "TVRip"),
|
||||
(r"DVB", "DVB"),
|
||||
(r"VHS-?Rip|VHS", "VHSRip"),
|
||||
(r"DCP-?Rip", "DCPRip"),
|
||||
(r"\bIPTV\b", "IPTV"),
|
||||
(r"CAM-?Rip|\bCAM\b|\bTS\b|Telesync", "CAMRip"),
|
||||
(r"\bDVD\b", "DVD"),
|
||||
]
|
||||
CONTAINERS = [
|
||||
(r"\bMKV\b|Matroska", "MKV"), (r"\bMP4\b", "MP4"), (r"\bAVI\b", "AVI"), (r"\bM2TS\b", "M2TS"),
|
||||
(r"\bISO\b", "ISO"), (r"DVD[-\s]?Video", "DVD-Video"), (r"\bTS\b", "TS"), (r"\bWEBM\b", "WEBM"),
|
||||
(r"\bMOV\b", "MOV"), (r"\bWMV\b", "WMV"),
|
||||
]
|
||||
RE_HMS = re.compile(r"(?<!\d)(\d{1,2}):(\d{2}):(\d{2})(?!\d)")
|
||||
|
||||
|
||||
def release_quality(text: str) -> str:
|
||||
"""«Тип релиза» / «Качество видео» / «Качество» — без «| Исходник: …» и подписей после него."""
|
||||
v = _first(text, r"Тип\s+релиза", r"Качество\s+видео", r"Качество")
|
||||
v = re.split(r"\s+\|\s+|\s+Исходник\s*:", v, maxsplit=1)[0]
|
||||
return v.strip(" .;")
|
||||
|
||||
|
||||
def release_info(text: str, title: str = "") -> dict | None:
|
||||
quality = release_quality(text)
|
||||
# заголовок — только если по «Качеству» тип не понять («Hybrid (2160p)», «Без хардсаба»)
|
||||
source = next((tag for s in (quality, title or "") for rx, tag in SOURCES
|
||||
if re.search(rx, s, re.I)), None)
|
||||
# «Формат : AVC» у MediaInfo nnm — не контейнер: берём первое значение, похожее на контейнер
|
||||
container = next((tag for key in (r"Контейнер", r"Формат\s+видео", r"Формат")
|
||||
for v in _field(text, key)
|
||||
for rx, tag in CONTAINERS if re.search(rx, v, re.I)), None)
|
||||
duration = _first(text, r"Продолжительность", r"Продолжительность\s+серии", r"Длительность")
|
||||
# секунды — только для одного фильма: не «18 x ~ 00:20:00», не «~00:22:30 серия», не список файлов
|
||||
hms = RE_HMS.search(duration)
|
||||
single = hms and len(RE_HMS.findall(duration)) == 1 and not re.search(r"(?i)\d\s*[xх×]|сери|эпизод", duration)
|
||||
sec = int(hms.group(1)) * 3600 + int(hms.group(2)) * 60 + int(hms.group(3)) if single else None
|
||||
if not (quality or source or container or duration):
|
||||
return None
|
||||
return {"quality": quality or None, "source": source, "container": container,
|
||||
"duration": duration or None, "duration_sec": sec}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ субтитры
|
||||
SUB_KEYS = (r"Субтитры(?:\s*#?\s*\d+|\s+[A-Za-z]{3})?", r"Subtitles?(?:\s*#?\s*\d+)?",
|
||||
r"Язык\s+(?:подключаемых\s+)?субтитров")
|
||||
RE_SUB_NONE = re.compile(r"(?i)^\s*(?:нет|отсутству\w*|no|none|-+)\s*\.?$")
|
||||
RE_HARDSUB = re.compile(r"(?i)вшит\w*|hardsub|встроенн\w*\s+в\s+видео")
|
||||
RE_FORCED = re.compile(r"(?i)forced|форсирован\w*|на\s+надписи|надписи")
|
||||
RE_FULL = re.compile(r"(?i)\bfull\b|полн\w*")
|
||||
RE_SDH = re.compile(r"(?i)\bSDH\b|для\s+слабослыш\w*|closed\s+captions")
|
||||
RE_LANG_CODE = re.compile(r"(?<![A-Za-z])([A-Z][A-Za-z]{2})(?![A-Za-z])")
|
||||
RE_TITLE_SUB = re.compile(r"(?i)\bSub\w*\s*\(([^()]{2,80})\)")
|
||||
|
||||
|
||||
def _split_top(s: str) -> list[str]:
|
||||
"""Делит по запятым/«;» вне скобок: «Русские (Forced), Украинские (Forced, SDH)» -> 2 части."""
|
||||
out, depth, cur = [], 0, []
|
||||
for ch in s:
|
||||
if ch in "([":
|
||||
depth += 1
|
||||
elif ch in ")]":
|
||||
depth -= 1
|
||||
if ch in ",;" and depth <= 0:
|
||||
out.append("".join(cur))
|
||||
cur = []
|
||||
else:
|
||||
cur.append(ch)
|
||||
out.append("".join(cur))
|
||||
return [x.strip() for x in out if x.strip()]
|
||||
|
||||
|
||||
def _lang(part: str) -> str:
|
||||
code = lang_of_text(part)
|
||||
if code:
|
||||
return code
|
||||
for m in RE_LANG_CODE.finditer(part):
|
||||
c = LANG_CODES.get(m.group(1).upper())
|
||||
if c:
|
||||
return c
|
||||
return ""
|
||||
|
||||
|
||||
def subtitles_info(text: str, title: str = "") -> dict | None:
|
||||
"""Строки «Субтитры: …» (и «Субтитры 2:», «Язык субтитров:»); нет строк — языки из «Sub (Rus, Eng)» заголовка."""
|
||||
values = [v for key in SUB_KEYS for v in _field(text, key)]
|
||||
fmt = _first(text, r"Формат\s+субтитров") or None
|
||||
tracks, raw = [], []
|
||||
hardsub = False
|
||||
for v in values:
|
||||
if not v or RE_SUB_NONE.match(v):
|
||||
continue
|
||||
raw.append(v)
|
||||
hardsub = hardsub or bool(RE_HARDSUB.search(v))
|
||||
for part in _split_top(v):
|
||||
lang = _lang(part)
|
||||
if not lang:
|
||||
continue
|
||||
# «Украинские (Forced, 2x Full, SDH)» — одна запись на язык, флаги объединяются
|
||||
tracks.append({"lang": lang, "forced": bool(RE_FORCED.search(part)),
|
||||
"full": bool(RE_FULL.search(part)), "sdh": bool(RE_SDH.search(part))})
|
||||
if not raw and title:
|
||||
m = RE_TITLE_SUB.search(title)
|
||||
if m:
|
||||
raw.append(m.group(0))
|
||||
for part in _split_top(m.group(1)):
|
||||
lang = _lang(part)
|
||||
if lang:
|
||||
tracks.append({"lang": lang, "forced": False, "full": False, "sdh": False})
|
||||
if not raw and not values:
|
||||
return None
|
||||
merged: dict[str, dict] = {}
|
||||
for t in tracks:
|
||||
cur = merged.setdefault(t["lang"], {"lang": t["lang"], "forced": False, "full": False, "sdh": False})
|
||||
for k in ("forced", "full", "sdh"):
|
||||
cur[k] = cur[k] or t[k]
|
||||
return {"languages": list(merged), "tracks": list(merged.values()), "format": fmt,
|
||||
"hardsub": hardsub, "raw": "; ".join(raw or [v for v in values if v]) or None}
|
||||
|
||||
|
||||
def tech_info(text: str, title: str = "") -> dict:
|
||||
return {"video": video_info(text, title), "release": release_info(text, title),
|
||||
"subtitles": subtitles_info(text, title)}
|
||||
@@ -34,6 +34,8 @@ 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_KHZ = re.compile(r"(?i)(?<![\d.,])(\d{2}(?:[.,]\d{1,3})?)\s*(?:khz|кгц)|(?<!\d)(\d{5,6})\s*(?:hz|гц)")
|
||||
RE_ATMOS = re.compile(r"(?i)\batmos\b")
|
||||
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|оригинал")
|
||||
@@ -78,7 +80,11 @@ def parse_audio_line(line: str) -> dict:
|
||||
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)
|
||||
khz = RE_KHZ.search(fixed)
|
||||
khz_val = (float(khz.group(1).replace(",", ".")) if khz.group(1) else int(khz.group(2)) / 1000) if khz else None
|
||||
return {"raw": body, "lang": lang,
|
||||
"khz": (int(khz_val) if khz_val == int(khz_val) else khz_val) if khz_val else None,
|
||||
"atmos": bool(RE_ATMOS.search(body)),
|
||||
"codec": re.sub(r"\s+", "", codec.group(1)).upper() if codec 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)),
|
||||
@@ -86,6 +92,15 @@ def parse_audio_line(line: str) -> dict:
|
||||
"names": names}
|
||||
|
||||
|
||||
def audio_summary(info: dict | None) -> dict | None:
|
||||
"""Поле `audio` ответа: {codec, channels, bitrate_kbps, khz, atmos}; нет строки «Аудио» — None."""
|
||||
if not info:
|
||||
return None
|
||||
return {"codec": info["codec"] or None, "channels": int(info["channels"]) if info["channels"] else None,
|
||||
"bitrate_kbps": int(info["kbps"]) if info["kbps"] else None,
|
||||
"khz": info.get("khz"), "atmos": bool(info.get("atmos"))}
|
||||
|
||||
|
||||
def audio_lines_all(text: str) -> list[dict]:
|
||||
out = []
|
||||
for m in RE_AUDIO.finditer(text or ""):
|
||||
|
||||
@@ -385,6 +385,58 @@ check("аудио: дорожка студии находит свою стро
|
||||
_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)
|
||||
check("оригинал: E-AC3+Atmos -> atmos, 48 kHz", _orig and _orig["atmos"] and _orig["khz"] == 48 and _orig["codec"] == "E-AC3", str(_orig))
|
||||
from src.trackinfo import audio_summary, parse_audio_line
|
||||
check("аудио: без Atmos -> atmos false", audio_summary(_au[0])["atmos"] is False)
|
||||
check("аудио: 44.1 KHz и 48000 Гц", parse_audio_line("AC3, 2 ch, 44.1 KHz, 192 kbps")["khz"] == 44.1
|
||||
and parse_audio_line("AAC 48000 Гц 2 ch 128 kbps")["khz"] == 48)
|
||||
check("аудио: «48 kHz» не меняет вход модели (кодек без +Atmos)", parse_audio_line("English E-AC3+Atmos / 5.1 / 48 kHz / 768 kbps")["codec"] == "E-AC3")
|
||||
|
||||
print("== техданные: видео, тип релиза, субтитры ==")
|
||||
from src.release import title_from_html
|
||||
from src.techinfo import release_info, subtitles_info, video_info, _rate_kbps
|
||||
_fx_html = open("data/samples/rt-topic-6870900.html", encoding="utf-8").read()
|
||||
_fx_title = title_from_html(_fx_html)
|
||||
check("заголовок rutracker без разрывов слов (<span>/<wbr>)",
|
||||
_fx_title.startswith("Супер Марио: Галактическое кино / The Super Mario Galaxy Movie (Аарон Хорват"), _fx_title[:80])
|
||||
_fx_text = html_to_text(_fx_html)
|
||||
_v = video_info(_fx_text, _fx_title)
|
||||
check("видео: HEVC 3840x2160 2160p 23.976 fps 24800 kbps 10 бит",
|
||||
_v and (_v["codec"], _v["width"], _v["height"], _v["resolution"], _v["fps"], _v["bitrate_kbps"], _v["bit_depth"])
|
||||
== ("HEVC", 3840, 2160, "2160p", 23.976, 24800, 10), str(_v))
|
||||
check("видео: HDR — Dolby Vision, HDR10+, HDR10; профиль DV 8",
|
||||
_v and _v["hdr"] == ["Dolby Vision", "HDR10+", "HDR10"] and _v["dv_profile"] == "8", str(_v and _v["hdr"]))
|
||||
_r = release_info(_fx_text, _fx_title)
|
||||
check("релиз: WEB-DL, MKV, 01:38:12 = 5892 с",
|
||||
_r and (_r["source"], _r["container"], _r["duration"], _r["duration_sec"]) == ("WEB-DL", "MKV", "01:38:12", 5892), str(_r))
|
||||
_s = subtitles_info(_fx_text, _fx_title)
|
||||
check("субтитры: RUS forced, UKR forced+full+SDH, ENG full+SDH, softsub (SRT)",
|
||||
_s and [(t["lang"], t["forced"], t["full"], t["sdh"]) for t in _s["tracks"]]
|
||||
== [("RUS", True, False, False), ("UKR", True, True, True), ("ENG", False, True, True)]
|
||||
and _s["format"] == "softsub (SRT)", str(_s))
|
||||
check("видео: битрейт не склеивается с высотой кадра", _rate_kbps("Xvid 640x272 699 kb/s") == 699
|
||||
and _rate_kbps("DivX 704 x 400 699 KB/s") == 699 and _rate_kbps("8 200 Кбит/сек") == 8200)
|
||||
check("видео: «5763 Mбит/с» — на деле килобиты", _rate_kbps("MPEG-4 AVC, 5763 Mбит/с, 1280x720") == 5763)
|
||||
check("видео: кадр 1920x800 — всё равно 1080p; fps 23.809523 целиком",
|
||||
(lambda v: v["resolution"] == "1080p" and v["fps"] == 23.809523)(video_info("Видео: AVC, 1920x800, 23.809523 fps, 1776 Kbps")))
|
||||
check("видео: строки «Видео» нет -> None", video_info("Аудио: AC3, 2 ch") is None)
|
||||
check("релиз: «18 x ~ 00:20:00» и список файлов — без duration_sec",
|
||||
release_info("Продолжительность: 18 x ~ 00:20:00")["duration_sec"] is None
|
||||
and release_info("Продолжительность: 01:31:25, 01:25:23")["duration_sec"] is None)
|
||||
check("релиз: «Формат : AVC» MediaInfo — не контейнер, «Формат: Matroska» — MKV",
|
||||
release_info("Формат : AVC\nФормат : Matroska")["container"] == "MKV")
|
||||
check("релиз: тип из заголовка, если «Качество» его не называет",
|
||||
release_info("Качество: Hybrid (2160p)", "Фильм / Film [2024, BDRemux 2160p]")["source"] == "BDRemux")
|
||||
_sn = subtitles_info("Субтитры: отсутствуют")
|
||||
check("субтитры: «отсутствуют» — пустой список языков, а не None", _sn is not None and _sn["languages"] == [], str(_sn))
|
||||
check("субтитры: nnm — поле до табуляции («Отсутствуют\\tДата…»)",
|
||||
subtitles_info("Субтитры: Отсутствуют\tДата мировой премьеры: 2 апреля 2011")["languages"] == [])
|
||||
check("субтитры: kinozal «Субтитры1: Full | iTunes • RUS»",
|
||||
subtitles_info("Субтитры1: Full | iTunes • RUS\nСубтитры2: Forced | Official • UKR")["languages"] == ["RUS", "UKR"])
|
||||
check("субтитры: вшитые -> hardsub", subtitles_info("Субтитры: русские, вшитые")["hardsub"] is True)
|
||||
check("субтитры: строк нет — языки из «Sub (Rus, Eng)» заголовка",
|
||||
subtitles_info("", "Фильм [2024] Dub + Sub (Rus, Eng)")["languages"] == ["RUS", "ENG"])
|
||||
check("субтитры: нигде нет -> None", subtitles_info("Видео: AVC") is None)
|
||||
_T = ["проф. дубляж", "проф. многоголосый", "люб. многоголосый", "многоголосый"]
|
||||
check("тип: «Любительский (многоголосый …) Дубляжная» не дубляж",
|
||||
guard_type("проф. дубляж", "Любительский (многоголосый закадровый) Дубляжная", "Дубляжная", _T) == "люб. многоголосый")
|
||||
|
||||
Reference in new issue
Block a user