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 <noreply@anthropic.com>
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"}],
|
"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|"},
|
"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": []},
|
"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": "Название раздачи",
|
"title": "Название раздачи",
|
||||||
"segmenter": "model",
|
"segmenter": "model",
|
||||||
"tracker": "rutracker"
|
"tracker": "rutracker"
|
||||||
@@ -50,14 +58,39 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `tracks` | array | дорожки релиза без дублей (дубль — одинаковые тип + студия) |
|
| `tracks` | array | дорожки релиза без дублей (дубль — одинаковые тип + студия) |
|
||||||
| `studios` | array | уникальные `{studio, translation_type, lang, is_18plus, episodes}`; `lang` — язык дорожки студии (как в `tracks[].lang`, может быть `null`). Одна студия с русским и украинским дубляжом даёт две записи |
|
| `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 | отсортированный набор типов (значения — из таблицы классов ниже) |
|
| `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` — сведений нет |
|
||||||
|
| `video` | object \| null | видео из строки «Видео:» / «Video:» (у nnm — также «Видеокодек», «Размер кадра», «Битрейт видео»), см. ниже; `null` — строки нет |
|
||||||
|
| `release` | object \| null | тип релиза, контейнер, продолжительность, см. ниже; `null` — ничего из этого на странице нет |
|
||||||
|
| `subtitles` | object \| null | субтитры из строк «Субтитры:», «Субтитры 2:», «Язык субтитров:» (нет строк — языки из «Sub (Rus, Eng)» заголовка), см. ниже; `null` — о субтитрах ничего не сказано |
|
||||||
| `title` | string \| null | заголовок раздачи, если найден |
|
| `title` | string \| null | заголовок раздачи, если найден |
|
||||||
| `segmenter` | `"model"` \| `"rules"` | чем страница разбита на строки дорожек: обученным сегментатором или запасными правилами |
|
| `segmenter` | `"model"` \| `"rules"` | чем страница разбита на строки дорожек: обученным сегментатором или запасными правилами |
|
||||||
| `tracker` | string | только для `url`: `rutracker`, `nnm`, `kinozal` или `lostfilm` |
|
| `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»);
|
`series`: `season` — номер сезона строкой или `null`; `episodes` — выпущенные серии («1-9»);
|
||||||
`total` — всего серий; `by_studio` — `{студия: серии}`; `full_release` — студии без указанных серий
|
`total` — всего серий; `by_studio` — `{студия: серии}`; `full_release` — студии без указанных серий
|
||||||
(для сериала это значит «озвучены все вышедшие серии»). Подробности — в [integration.md](integration.md).
|
(для сериала это значит «озвучены все вышедшие серии»). Подробности — в [integration.md](integration.md).
|
||||||
@@ -79,7 +112,7 @@
|
|||||||
| `lang_source` | `"audio"` \| `"model"` \| `"text"` \| null | откуда язык, в порядке приоритета: строка «Аудио» страницы (`Ukrainian E-AC3 …` → `UKR`, самый надёжный источник), нейросеть (классы `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}` из строки «Аудио» страницы (`"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"` | только в ответе релиза: дорожка найдена в описании или студия взята из заголовка раздачи |
|
| `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]]`; пусто, если серий нет |
|
||||||
@@ -157,7 +190,7 @@ type Track = {
|
|||||||
lang_source: "model" | "audio" | "text" | "url" | null; // "url" — ссылка lostfilm.tv; null вместе с lang
|
lang_source: "model" | "audio" | "text" | "url" | null; // "url" — ссылка lostfilm.tv; null вместе с lang
|
||||||
role: "перевод" | "оригинал" | "комментарии"; // никогда null
|
role: "перевод" | "оригинал" | "комментарии"; // никогда null
|
||||||
official: boolean;
|
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):
|
// только в ответе релиза (html / url):
|
||||||
source: "page" | "title";
|
source: "page" | "title";
|
||||||
episodes: string | null; // "1-4", "1,3-5"; null — серии не указаны
|
episodes: string | null; // "1-4", "1,3-5"; null — серии не указаны
|
||||||
@@ -167,7 +200,8 @@ type Track = {
|
|||||||
type Release = {
|
type Release = {
|
||||||
tracks: Track[]; // может быть пустым массивом
|
tracks: Track[]; // может быть пустым массивом
|
||||||
studios: { studio: string | null; translation_type: string; lang: string | null; 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;
|
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[];
|
translation_types: string[];
|
||||||
is_18plus: boolean;
|
is_18plus: boolean;
|
||||||
series: {
|
series: {
|
||||||
@@ -177,6 +211,19 @@ type Release = {
|
|||||||
by_studio: Record<string, string>; // { "LostFilm": "1-4" }, может быть {}
|
by_studio: Record<string, string>; // { "LostFilm": "1-4" }, может быть {}
|
||||||
full_release: string[]; // студии без указанных серий
|
full_release: string[]; // студии без указанных серий
|
||||||
} | null; // null — ни сезона, ни серий в раздаче нет
|
} | 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;
|
title: string | null;
|
||||||
segmenter: "model" | "rules";
|
segmenter: "model" | "rules";
|
||||||
tracker?: "rutracker" | "nnm" | "kinozal" | "lostfilm"; // только для url
|
tracker?: "rutracker" | "nnm" | "kinozal" | "lostfilm"; // только для url
|
||||||
@@ -226,6 +273,8 @@ Cloudflare: FlareSolverr нестабилен.
|
|||||||
по тексту; отдельно берётся `original`; определяются `role`, `official`, `audio`.
|
по тексту; отдельно берётся `original`; определяются `role`, `official`, `audio`.
|
||||||
7. **Агрегат релиза.** Дубли (тип + студия) склеиваются; студии из заголовка раздачи, которых нет
|
7. **Агрегат релиза.** Дубли (тип + студия) склеиваются; студии из заголовка раздачи, которых нет
|
||||||
в описании, добавляются с `source: "title"`; серии и сезон разбираются правилами (`src/episodes.py`).
|
в описании, добавляются с `source: "title"`; серии и сезон разбираются правилами (`src/episodes.py`).
|
||||||
|
8. **Техданные** (`video`, `release`, `subtitles`) — правилами по строкам «Видео:», «Тип релиза:»,
|
||||||
|
«Контейнер:», «Продолжительность:», «Субтитры:» и заголовку (`src/techinfo.py`), без нейросети.
|
||||||
|
|
||||||
Размеченные данные (`data/labels.csv`) используются только при обучении: на них учатся метки
|
Размеченные данные (`data/labels.csv`) используются только при обучении: на них учатся метки
|
||||||
`translation_type`, спан студии, 18+ и язык. Схема файла и происхождение меток — в
|
`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}` |
|
| `studios` | уникальные `{studio, translation_type, is_18plus}` |
|
||||||
| `translation_types` | отсортированный список типов релиза |
|
| `translation_types` | отсортированный список типов релиза |
|
||||||
| `is_18plus` | `true`, если 18+ хотя бы у одной дорожки |
|
| `is_18plus` | `true`, если 18+ хотя бы у одной дорожки |
|
||||||
|
| `original`, `series` | оригинальная дорожка; сезон и серии |
|
||||||
|
| `video` | кодек, разрешение, fps, битрейт, битность, HDR (Dolby Vision / HDR10+ / HDR10 / HLG) |
|
||||||
|
| `release` | тип релиза («WEB-DL», «BDRemux»…), контейнер, продолжительность |
|
||||||
|
| `subtitles` | языки субтитров с пометками forced / full / SDH, формат, вшитые ли |
|
||||||
| `title` | заголовок раздачи (если найден в HTML) |
|
| `title` | заголовок раздачи (если найден в HTML) |
|
||||||
| `tracker` | только для `--url` |
|
| `tracker` | только для `--url` |
|
||||||
|
|
||||||
Дорожки дедуплицируются по (тип, студия). Студии из заголовка раздачи вида
|
Дорожки дедуплицируются по (тип, студия). Студии из заголовка раздачи вида
|
||||||
`ПМ (LostFilm)`, которых нет в описании, добавляются с `source: "title"`
|
`ПМ (LostFilm)`, которых нет в описании, добавляются с `source: "title"`
|
||||||
(у остальных `source: "page"`). `--text` по-прежнему возвращает только `tracks`.
|
(у остальных `source: "page"`). `--text` по-прежнему возвращает только `tracks`.
|
||||||
|
Все поля и их значения — в [api-reference.md](api-reference.md); `video`, `release`, `subtitles`
|
||||||
|
считаются правилами (`src/techinfo.py`), без нейросети.
|
||||||
|
|
||||||
## Python API
|
## Python API
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -41,7 +41,12 @@ API:
|
|||||||
| `lang`, `lang_confidence`, `lang_source` | язык дорожки (`RUS`, `UKR`, `ENG`, …); источник: `model` (языковая голова), `audio` (строка «Аудио»), `text` (слова в строке дорожки) или `null` |
|
| `lang`, `lang_confidence`, `lang_source` | язык дорожки (`RUS`, `UKR`, `ENG`, …); источник: `model` (языковая голова), `audio` (строка «Аудио»), `text` (слова в строке дорожки) или `null` |
|
||||||
| `role` | `перевод` / `оригинал` / `комментарии` (по строке «Аудио» и тексту, правилами) |
|
| `role` | `перевод` / `оригинал` / `комментарии` (по строке «Аудио» и тексту, правилами) |
|
||||||
| `official` | в строке «Аудио» сказано «официальный» / «лицензионный» |
|
| `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+ (если на странице две строки «Аудио» с одной студией).
|
дорожка студии и её дорожка 18+ (если на странице две строки «Аудио» с одной студией).
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
| 7 тесты | `run_tests.py`: 57 ok (URL allowlist/SSRF, декодирование, заглушки Cloudflare/логина, title, слияние, агрегат) |
|
| 7 тесты | `run_tests.py`: 57 ok (URL allowlist/SSRF, декодирование, заглушки Cloudflare/логина, title, слияние, агрегат) |
|
||||||
| 8 `target/` | сделано |
|
| 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 и сегментатор на новой разметке — открыты |
|
| 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 .model import load_model, predict_with_lang
|
||||||
from .studio_norm import canonical
|
from .studio_norm import canonical
|
||||||
from .spans import MAX_LEN, decode_span
|
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,
|
"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": int(info["channels"]) if info["channels"] else None,
|
"audio": audio_summary(info),
|
||||||
"bitrate_kbps": int(info["kbps"]) if info["kbps"] else None}
|
|
||||||
if info else None),
|
|
||||||
"_audio18": info["adult"] if info else None,
|
"_audio18": info["adult"] if info else None,
|
||||||
})
|
})
|
||||||
return results
|
return results
|
||||||
|
|||||||
+8
-6
@@ -16,7 +16,8 @@ import re
|
|||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from .audio18 import apply_audio_18
|
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 .episodes import format_ranges, merge_ranges, parse_episodes
|
||||||
from .html_extract import (RE_LINE_PREFIX, _split_entries, extract_segments,
|
from .html_extract import (RE_LINE_PREFIX, _split_entries, extract_segments,
|
||||||
html_to_text)
|
html_to_text)
|
||||||
@@ -43,7 +44,9 @@ def title_from_html(html: str) -> str:
|
|||||||
for sel in ("h1", "title"):
|
for sel in ("h1", "title"):
|
||||||
node = soup.select_one(sel)
|
node = soup.select_one(sel)
|
||||||
if node and node.get_text(strip=True):
|
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 ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -153,9 +156,7 @@ def original_info(lines: list[dict]) -> dict | None:
|
|||||||
"""Оригинальная дорожка из строки «Аудио … |Original|» (без студии и комментариев); нет — None."""
|
"""Оригинальная дорожка из строки «Аудио … |Original|» (без студии и комментариев); нет — None."""
|
||||||
for ln in lines:
|
for ln in lines:
|
||||||
if ln["original"] and not ln["names"] and not ln["commentary"]:
|
if ln["original"] and not ln["names"] and not ln["commentary"]:
|
||||||
return {"lang": ln["lang"] or None, "codec": ln["codec"] or None,
|
return {"lang": ln["lang"] or None, **audio_summary(ln), "raw": ln["raw"]}
|
||||||
"channels": int(ln["channels"]) if ln["channels"] else None,
|
|
||||||
"bitrate_kbps": int(ln["kbps"]) if ln["kbps"] else None, "raw": ln["raw"]}
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -169,7 +170,7 @@ def lostfilm_release(title: str | None = None) -> dict:
|
|||||||
"source": "url", "episode_ranges": [], "episodes": None}
|
"source": "url", "episode_ranges": [], "episodes": None}
|
||||||
result = aggregate([track])
|
result = aggregate([track])
|
||||||
result.update(original=None, series=series_info(title or "", result["tracks"]),
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -374,6 +375,7 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
|
|||||||
for t in result["tracks"]:
|
for t in result["tracks"]:
|
||||||
t.pop("_audio18", None)
|
t.pop("_audio18", None)
|
||||||
result["series"] = series_info(title or "", result["tracks"])
|
result["series"] = series_info(title or "", result["tracks"])
|
||||||
|
result.update(tech_info(page_text, title or ""))
|
||||||
result["title"] = title or None
|
result["title"] = title or None
|
||||||
result["segmenter"] = used
|
result["segmenter"] = used
|
||||||
return result
|
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_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_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_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|оригинал")
|
||||||
@@ -78,7 +80,11 @@ def parse_audio_line(line: str) -> dict:
|
|||||||
for n in re.split(r"\s*[,;/]\s*", chunk) # «|Дубляж, Le Doyen|» -> тип и студия отдельно
|
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)
|
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,
|
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 "",
|
"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 "",
|
"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)),
|
||||||
@@ -86,6 +92,15 @@ def parse_audio_line(line: str) -> dict:
|
|||||||
"names": names}
|
"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]:
|
def audio_lines_all(text: str) -> list[dict]:
|
||||||
out = []
|
out = []
|
||||||
for m in RE_AUDIO.finditer(text or ""):
|
for m in RE_AUDIO.finditer(text or ""):
|
||||||
|
|||||||
@@ -385,6 +385,58 @@ check("аудио: дорожка студии находит свою стро
|
|||||||
_orig = original_info(_au)
|
_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("оригинал: 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("оригинал: нет строки 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 = ["проф. дубляж", "проф. многоголосый", "люб. многоголосый", "многоголосый"]
|
_T = ["проф. дубляж", "проф. многоголосый", "люб. многоголосый", "многоголосый"]
|
||||||
check("тип: «Любительский (многоголосый …) Дубляжная» не дубляж",
|
check("тип: «Любительский (многоголосый …) Дубляжная» не дубляж",
|
||||||
guard_type("проф. дубляж", "Любительский (многоголосый закадровый) Дубляжная", "Дубляжная", _T) == "люб. многоголосый")
|
guard_type("проф. дубляж", "Любительский (многоголосый закадровый) Дубляжная", "Дубляжная", _T) == "люб. многоголосый")
|
||||||
|
|||||||
Reference in new issue
Block a user