From dc19fe6255282468ab977ca553e3350b76c74f78 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 2 Nov 2026 22:26:57 +0700 Subject: [PATCH] =?UTF-8?q?API:=20=D0=B2=D0=B8=D0=B4=D0=B5=D0=BE,=20=D1=82?= =?UTF-8?q?=D0=B8=D0=BF=20=D1=80=D0=B5=D0=BB=D0=B8=D0=B7=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D1=81=D1=83=D0=B1=D1=82=D0=B8=D1=82=D1=80=D1=8B=20=D0=B2=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B5=20=D1=80=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0;=20Atmos=20=D0=B8=20kHz=20=D1=83=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B6=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/techinfo.py — правила по строкам «Видео:», «Тип релиза:»/«Качество», «Контейнер:», «Продолжительность:», «Субтитры:» и заголовку: поля video (кодек, размер кадра, класс разрешения, fps, битрейт, битность, HDR, профиль DV), release (тип, нормализованный источник, контейнер, продолжительность), subtitles (языки с forced/full/SDH, формат, вшитые). audio дорожек и original: khz и atmos (вход модели не меняется). title_from_html больше не рвёт заголовок rutracker пробелами (/ на каждом слове). Покрытие на 600 страницах каждого трекера: видео 90/93/100%, e2e озвучки без изменений. Тесты 275 ok. Co-Authored-By: Claude Opus 5.5 --- docs/api-reference.md | 59 +++++++++- docs/inference.md | 6 + docs/integration.md | 7 +- docs/plan.md | 1 + src/predict.py | 6 +- src/release.py | 14 ++- src/techinfo.py | 265 ++++++++++++++++++++++++++++++++++++++++++ src/trackinfo.py | 15 +++ tests/run_tests.py | 52 +++++++++ 9 files changed, 409 insertions(+), 16 deletions(-) create mode 100644 src/techinfo.py diff --git a/docs/api-reference.md b/docs/api-reference.md index 312c38e..c3e0702 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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; // { "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+ и язык. Схема файла и происхождение меток — в diff --git a/docs/inference.md b/docs/inference.md index 4daddba..d04fcb7 100644 --- a/docs/inference.md +++ b/docs/inference.md @@ -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 diff --git a/docs/integration.md b/docs/integration.md index 2beed0d..94e652e 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -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+ (если на странице две строки «Аудио» с одной студией). diff --git a/docs/plan.md b/docs/plan.md index ad778ee..c063ff2 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -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-клиентах этих полей нет | ## Открытые вопросы diff --git a/src/predict.py b/src/predict.py index 13f6724..d959cb8 100644 --- a/src/predict.py +++ b/src/predict.py @@ -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 diff --git a/src/release.py b/src/release.py index 71e86ff..0f483dd 100644 --- a/src/release.py +++ b/src/release.py @@ -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 оборачивает каждое русское слово в и режет + # («Галактическое»), пробелы между словами — отдельные текстовые узлы + 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 diff --git a/src/techinfo.py b/src/techinfo.py new file mode 100644 index 0000000..e92f073 --- /dev/null +++ b/src/techinfo.py @@ -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[^\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"(? 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"(? 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"(? 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)} diff --git a/src/trackinfo.py b/src/trackinfo.py index 28ac5b4..ff31217 100644 --- a/src/trackinfo.py +++ b/src/trackinfo.py @@ -34,6 +34,8 @@ 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|оригинал") @@ -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 ""): diff --git a/tests/run_tests.py b/tests/run_tests.py index d86ecc2..2278c11 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -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 без разрывов слов (/)", + _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) == "люб. многоголосый")