Compare commits

..
26 Commits
Author SHA1 Message Date
DenozordecandClaude Opus 5.5 e8035e1e9d Переобучение на нескольких авторах в строке: метки PARSE_VERSION 9, новая models/best (base)
docker / build (push) Successful in 43s
Метки пересобраны новыми парсерами (collect.py build --relabel): 69 511 строк из 30 451 релиза;
после prepare_data 52 658 строк, 3 529 с несколькими авторами, 5 ручных решений применены.

Новая models/best (ruBert-base) на общем тесте 4 984 строк: joint exact 0.903 против 0.800 у
прежней, span exact 0.919 против 0.813, type macro-F1 0.976 против 0.909; строки с 2+ авторами —
span 0.815 против 0.011. Прежняя модель — models/best_backup_20261109_164800. ONNX пересобран,
паритет 100 %.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:56:12 +07:00
DenozordecandClaude Opus 5.5 8511744d3a Несколько студий в одной строке, разбор противоречий, правки парсеров студий
docker / build (push) Successful in 1m21s
Парсеры (PARSE_VERSION 9): «т/к» и единицы битрейта не режутся по слэшу, техпараметры
(kb/s, Кбит/сек, 16 бит, channels, CBR, Atmos) и пол голоса (муж./жен.) не становятся студией,
«от X» -> X; «N-й канал», «5 канал», «Лексикон» больше не выбрасываются как мусор; строка без типа
со списком из 3+ имён (актёры, титры сабберов) не даёт дорожек; в кодек-строках имя рядом со
скобкой пола становится кандидатом, а строка только с мусором даёт дорожку с типом без студии.

Несколько авторов строки («Многоголосый (HDRezka Studio, LostFilm)» — верны все): prepare_data
склеивает строки релиза в одну со списком spans, обучение размечает все спаны BIO, span_exact
сравнивает наборы, decode_spans возвращает все прогоны; сервис отдаёт studio_spans / studios /
studio_confidences, в ответе релиза — дорожка на каждого автора. Противоречий в датасете
20 % -> 1.2 %; проверочная tiny2 находит весь набор студий в 71 % строк с 2+ авторами
(прежняя модель — 1 %).

Разбор противоречий (collect.py, пункт 8): тексты, которые релизы разметили по-разному;
решения в data/text_fixes.csv применяются в prepare_data поверх labels.csv.

Метки и модель не пересобраны: нужны collect.py build --relabel, prepare_data, переобучение.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:52 +07:00
Denozordec 0f359837c5 Датасет: полное покрытие data/raw (71 339 строк из 30 401 релиза), правки парсеров и аудит
docker / build (push) Successful in 1m20s
Переразбор всех страниц data/raw: legacy-страницы импорта, форматы префиксов ([*]Перевод (1):, Озвучка:, №2, диапазоны), дорожки только в строках «Аудио» (в т.ч. «(Русский, X)», «| Rus | Звук: …»), «Язык озвучки», несколько студий в одной строке, студия без типа. Кодек-строки: «Аудио #N Русский:», «Аудио pic RUS:», «Звук N:», «Аудиодорожка 01:», кириллические АVO/МVO. «Авторский» без числа голосов -> авт. одноголосый, «двугоголосый». Чистка студий: языки, кодеки, «по заказу», «в составе контейнера», отказы от озвучки.

Итог: страниц с метками 30 260 (85.5 %), страниц с дорожкой без меток 1 259 -> 223; data/pages 30 444; после prepare_data 60 944 строки (train 54 686 / test 6 258).

Новый tools/coverage_audit.py + reports/coverage_audit.md, docs/dataset-coverage.md. Переобучен models/best (base): на общем тесте type acc 0.976 против 0.949 у прежней, span 0.674 против 0.643; e2e kinozal 0.805/0.867, rt 0.771/0.740, nnm 0.752/0.693.
2026-11-09 13:29:53 +07:00
DenozordecandClaude Sonnet 5.5 d2cf488b9c Звук дорожки: студия в заголовке короче, чем в строке «Аудио» (HDrezka ~ HDrezka Studio)
docker / build (push) Successful in 1m21s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 09:40:40 +07:00
DenozordecandClaude Sonnet 5.5 690275dcb0 Релиз: аудио дорожек из строк «Аудио», дорожка без студии на другом языке (укр. дубляж), профиль DV из «Dolby Vision P8»
docker / build (push) Successful in 1m36s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 13:39:57 +07:00
DenozordecandClaude Sonnet 5.5 76e887b4ba Сегментатор: веса со scene-негативами, RE_SCENE без катастрофического отката
docker / build (push) Successful in 3m11s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 12:17:31 +07:00
DenozordecandClaude Sonnet 5.5 729eeac045 Сегментатор: scene-имя релиза не может быть дорожкой
docker / build (push) Successful in 1m49s
Kinozal 2160145: после переобучения модель выбирала первую строку вкладки «Релиз»
(«Memoirs.of.a.Geisha.2005.2160p.UHD.Blu-ray.Remux…-CiNEPHiLES», оценка 0.96), и
имя релиза попадало в ответ как «студия». Прежние веса строку не выбирали.

- html_extract.RE_SCENE: без пробелов и ≥ 4 точек — дорожкой не бывает;
  LineSegmenter.select_lines отбрасывает такие строки при любой оценке модели.
- build_examples: scene-имена — трудные негативы, берутся все (раньше прореживались
  до 15 %); эффект — при следующем переобучении.
- На реальных данных страницы (вкладки Техданные/Интересно/Награды/Релиз):
  1 дорожка «проф. дубляж», без студии. Тесты: 288 ok.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 02:57:43 +07:00
DenozordecandClaude Sonnet 5.5 e6705c2b7b Сегментатор дообучен: список дорожек под пустым «Перевод:»
docker / build (push) Successful in 1m42s
Переобучен после исправления парсера меток (rt_parse): раздачи rutracker с пустым
«Перевод:» и списком дорожек ниже получили метки, в том числе вручную добавленные
6919056 и 6927571. Строки такого списка у нового сегментатора 0.94–1.00
(у прежнего 0.004–0.12); на 6919056 пайплайн с реальными весами даёт
segmenter=model, 4 дорожки, без segmenter_warning.

- docker-models/models/segmenter: новые веса (Git LFS)
- data: labels.csv, manual_ids.txt, meta.csv; reports/data_audit.md
- tests: 286 ok

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 19:33:25 +07:00
DenozordecandClaude Sonnet 5.5 e9c85c4132 Метки rutracker: «Перевод:» без значения и список дорожек ниже
docker / build (push) Successful in 2m21s
Парсер меток (rt_parse) требовал текст после «Перевод:» на той же строке, поэтому
раздачи с пустым «Перевод:» и списком «Профессиональный (…) | Студия» по строке на
дорожку (t=6919056, 6927571) не получали меток, релиз не сохранялся, а сегментатор
учился на их дорожках как на негативах. Теперь rt_bare_list берёт подряд идущие
строки со словом типа (через пустые строки; строки «тип: определение» —
глоссарий — не берутся). То же правило в html_extract.extract_segments.

На data/raw: +252 раздачи получают метки, в 12 добавляются недостающие дорожки.
PARSE_VERSION 3 -> 4 (переразбор при «Переиндексировать data/raw»). Тест rt_parse
на 6919056; 276 ok.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 16:30:10 +07:00
DenozordecandClaude Sonnet 5.5 b3ceefda4f Сегментация: список дорожек под «Перевод:» на реальной вёрстке, достройка блока, segmenter_warning
docker / build (push) Successful in 2m1s
- html_extract: строки дорожек под пустым «Перевод:» берутся и через пустые
  строки (в HTML после <br> стоит \n исходника — прежняя правка на реальной
  странице rutracker t=6919056 отдавала 1 дорожку из 4); шаблон начала строки
  требует полные слова типа озвучки.
- release: complete_block достраивает список, из которого модель выбрала не все
  строки; segmenter_warning (+ лог) — модель и правила (без кодек-строк)
  разошлись; поле segmenter_warning в ответе.
- docs: retrain-segmenter.md — как дообучить сегментатор, поле в api-reference.
- tests: 275 ok.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 16:14:50 +07:00
DenozordecandClaude Opus 5.5 e185c73714 Сегментация: все строки дорожек под пустым «Перевод:»
docker / build (push) Successful in 1m24s
На rutracker дорожки бывают перечислены по одной на строку под
«Перевод:» без значения; правила брали только первую строку и теряли
остальные (t=6919056: 1 из 4, t=6927571: 1 из 7). Теперь берутся
подряд идущие строки, начинающиеся со слова типа озвучки.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:49:35 +07:00
DenozordecandClaude Opus 5.5 3c42d2d4e2 Видео: 10-bit, DV и профиль DV из заголовка
docker / build (push) Successful in 5m30s
На nnm строка «Видео» не называет битность и HDR — они только в заголовке («[4K, HDR10, DV 8.1, 10-bit]»).
- bit_depth: если в строке «Видео» нет, ищется в заголовке
- «DV» считается Dolby Vision (\b не цепляет DVD/DVDRip)
- профиль из «DV 8.1», «DV P7», «DoVi P5»; только реальные профили, «DV 10-bit» — не профиль
- тесты: раздача nnm «В поисках Дори», DVDRip / DV 10-bit, DV P7

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:02:37 +07:00
DenozordecandClaude Opus 5.5 dc19fe6255 API: видео, тип релиза и субтитры в ответе релиза; Atmos и kHz у дорожек
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]>
2026-11-02 22:26:57 +07:00
DenozordecandClaude Opus 5.5 89ffe4375b Документация: кодек-строки, аудит дорожек, текущие метрики
docker / build (push) Successful in 39s
README: метрики модели 02.11.2026 (test 4 377 строк, e2e по трекерам), размер
датасета, kinozal-вкладки. dataset.md: размер снимка, колонки studio_source и
audio_raw. architecture.md: кодек-строки как позитивы сегментатора, E2E_MODEL.
collecting.md: tools/audit_tracks.py. training-windows.md: доразметка, сравнение
кандидата и постановка в models/best и Docker. plan.md: шаг «аудит дорожек».

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:55:43 +07:00
DenozordecandClaude Opus 5.5 588b1f1579 Модель для Docker: base, обученная со студиями из кодек-строк
docker / build (push) Successful in 4m43s
models/runs/20261102_190555_basecodec -> models/best -> docker-models
(tools/pack_models.py --stage). Тестовая выборка (4 377 строк): joint
exact 0.891 -> 0.906, span exact 0.905 -> 0.922, type macro F1 0.893 ->
0.903; исправлено 244 строки, сломано 179 (p = 0.002). Релизы (по 400
страниц, 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. Сегментатор прежний.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:30:54 +07:00
DenozordecandClaude Opus 5.5 e3af778068 Студии из кодек-строк: codec_line_rows, доразметка, аудит дорожек
tools/audit_tracks.py и reports/tracks_audit.md — аудит всех дорожек
техданных по data/raw с планом. Пункт 1 плана: harvest_core.codec_line_rows
добавляет студии из строк «Аудио 2: AC3 … (MVO, LostFilm)», «Аудио Rus: … |
TVShows» (studio_source=codec), которых нет среди строк релиза; тот же шаг
в tools/relabel_studios.py (повторный запуск ничего не добавляет).
labels.csv: +6 428 строк в 2 862 релизах (kinozal 3 444, nnm 1 609,
rutracker 1 375), со студией 29 331 -> 35 759. tests/e2e_segmenter.py:
модель через E2E_MODEL.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:30:52 +07:00
DenozordecandClaude Opus 5.5 63878f7874 Модель для Docker: base, обученная на доразмеченных студиях
docker / build (push) Successful in 1m21s
models/runs/20261102_152919_base_relabel -> models/best -> docker-models
(tools/pack_models.py --stage). Тестовая выборка (5 235 строк): joint
exact 0.880 -> 0.953, span exact 0.880 -> 0.955; исправлено 541 строка,
сломано 157. Релизы (по 400 страниц, P/R пар тип+студия): nnm
0.583/0.590 -> 0.853/0.859, rutracker 0.891/0.921 -> 0.887/0.911,
kinozal 0.779/0.926 -> 0.781/0.930. Сегментатор прежний.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 15:43:31 +07:00
DenozordecandClaude Opus 5.5 666caa60d5 Отчёт аудита данных после доразметки студий
docker / build (push) Successful in 1m27s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 15:38:55 +07:00
DenozordecandClaude Opus 5.5 83bab13893 Доразметка студий: nnm «Перевод: … (Студия)», kinozal из заголовка раздачи
* harvest_core.nnm_part_studio: студия из части строки «Перевод:» nnm
  («Дублированный (Невафильм)», «… - Jaskier»); раньше такие строки шли с
  «(не указана)», и модель училась не извлекать студию даже из
  «Дублированный (Пифагор)». tech-строки со студиями больше не
  выбрасываются, если на странице нет строк «Аудио N» с именами.
* harvest_core.kz_title_rows: kinozal, студии из заголовка «ПМ (LostFilm,
  HDrezka Studio)», которых нет на странице; вход — сегмент как у сервиса,
  строка «Аудио Rus: … | Студия» — в audio_raw.
* tools/relabel_studios.py — то же по готовому labels.csv без пересборки
  страниц; применено к data/labels.csv: со студией 22 061 -> 29 331 строк
  (nnm +3 051, kinozal +4 219 строк в 3 007 релизах); prepare_data:
  39 418 строк, спан студии 24 004 -> 31 214.
* e2e: эталон как aggregate — «тип без студии» убирается, если у типа есть
  студия.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 15:20:03 +07:00
DenozordecandClaude Opus 5.5 f91f6cb48e Сегментатор: -1 только для кодек-строк с типом или студией
Пустая кодек-строка («Аудио 1: AC3, 6 ch, 384 kbps - Русский») снова
негатив: с -1 модель разучилась её отбрасывать и на nnm подменяла ею
строку «Перевод: …».

Пробное обучение (2 эпохи, ~34 мин на 27.9k страниц) e2e по 400 тестовых
страниц, P/R пар (тип, студия), текущие веса -> новые: nnm 0.872/0.879 ->
0.836/0.860, rutracker 0.891/0.917 -> 0.883/0.910, kinozal 0.720/0.844 ->
0.724/0.845. Новые веса не лучше текущих, models/segmenter не заменён.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:10:32 +07:00
DenozordecandClaude Opus 5.5 6470f882f1 Сегментатор: текст kinozal из raw JSON, кодек-строки не как негатив; e2e по 400 страниц
Обучение:
* page_text/kinozal_text: текст kinozal = h1 + «Техданные» + «Релиз» из
  data/raw/kinozal/*.json (в pages/kz-*.txt нет заголовка и «Техданных»,
  откуда половина меток kinozal; сервис видит обе вкладки).
* label_page: позитив — raw или audio_raw; неразмеченная кодек-строка
  русской дорожки («Аудио 3: … Русский (MVO, X)», «Аудио Rus: … | X»)
  получает -1 и в обучение не идёт: как негатив она учила отбрасывать
  кодек-строки. Сводка «Аудио: Русский, английский (…)» остаётся негативом.

Инференс (release):
* model_segments -> (основные, кодек); выбранные моделью кодек-строки идут
  второстепенным источником вместе с правилом codec_segments; иноязычные
  («Казахский (Dub, …)», «English Dubbing», «Original») отбрасываются.
* merge_extra_tracks(adopt_type): кодек-дорожка с типом «неизвестно»
  получает тип единственной дорожки страницы без студии.

e2e_segmenter: тот же текст kinozal, не больше E2E_MAX (400) страниц на
трекер, train-страницы только с --train-pages (меню парсит только тест).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:05:20 +07:00
DenozordecandClaude Opus 5.5 099d33bdaa Слияние claude/advisor-opus-78114c: модели на пересобранном датасете, Docker, язык и серии
Конфликт в src/release.py: взяты original_info, lostfilm_release,
series_info и серии дорожек из advisor; сохранены same_studio,
merge_extra_tracks и codec_segments. Кодек-дорожкам тоже проставляются
серии.

Кодек-строки теперь подключаются, только если у какой-то дорожки страницы
нет студии: с новыми моделями безусловное подключение роняло precision
студий на rutracker (0.908 -> 0.793). С условием, test-сплит, по 300
релизов, студии P/R: rutracker 0.908/0.882 -> 0.840/0.900, nnm
0.875/0.915 -> 0.875/0.940, kinozal 0.816/0.845 -> 0.800/0.863. Тесты:
240 ok.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:50:44 +07:00
DenozordecandClaude Opus 5.5 666a266b2f Сегментатор: truncation longest_first — не падать на строке длиннее max_length
С only_second токенизатор бросал исключение, если сама строка (до 220
символов) не помещалась в MAX_LEN и обрезать было нечего, кроме контекста.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:44:18 +07:00
DenozordecandClaude Opus 5.5 1f9ee46a0d kinozal: догрузка вкладки «Релиз» по URL; кодек-строки «Аудио N» как источник студий
На kinozal во встроенном #tabs только «Техданные» («Перевод:» без студий),
а строки «Аудио N: … (MVO, LostFilm)» лежат во вкладке «Релиз», которую
страница грузит JS. Поэтому по URL определялись только типы без студий.

* fetch: для kinozal скачиваются вкладки showtab(id,N)
  (get_srv_details.php?id=…&pagesd=N, кроме «Техданных» и «Скриншотов»)
  и дописываются в #tabs; недоступная вкладка пропускается.
* release: кодек-строки с типом озвучки берутся правилом codec_segments
  (сегментатор их отбрасывает — в разметке rutracker это не raw) и идут
  второстепенным источником (source=codec / page+codec): только дорожки
  со студией, дубли студии (same_studio) пропускаются, студия вписывается
  в дорожку того же типа без студии. «English Dubbing» и т.п. не берутся.
* merge_title_tracks переведён на общий merge_extra_tracks.

e2e test rutracker без изменений (P 0.988 / R 0.982); train-страницы
P 0.975 -> 0.951. Тесты: 77 ok.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:33:01 +07:00
DenozordecandClaude Sonnet 5.5 6a87bc993c Слияние claude/advisor-opus-78114c: сегментатор страниц, URL/FlareSolverr, агрегат релиза
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 20:07:22 +07:00
Denozordec 2eb4f8b72e Merge claude/advisor-opus-78114c: релиз целиком, URL-вход, int8 tiny2, фикс сегментации, датасет 6864 2026-10-27 17:22:31 +07:00
48 changed files with 48473 additions and 19401 deletions

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ reports/*.png
# локальное
*.log
.tmp/
.DS_Store
# служебное ZCode
+41 -21
View File
@@ -10,8 +10,10 @@ kinozal / nnm-club) модель определяет:
алиасов («HDRezka Studio» → `HDrezka`, «Ю. Сербин» → «Юрий Сербин»);
* **18+** дорожка или нет — бинарная голова.
Обучено на датасете из 3286 разметанных дорожек + 458 аугментированных
морфологических вариантов (снимок `labels.csv` из deepseek-harness).
Обучено на датасете из 52 658 строк (69 511 размеченных строк из 30 451 релиза
`data/raw` + морфологическая аугментация; у строки может быть несколько авторов —
«Многоголосый (HDRezka Studio, LostFilm)» даёт обоих). Как страницы превращаются в строки и
что было исправлено в сборщике — [docs/dataset-coverage.md](docs/dataset-coverage.md).
Multi-task RuBERT: сравниваются `rubert-tiny2` и `ruBert-base`,
лучшая кладётся в `models/best`.
@@ -69,6 +71,8 @@ docs/ документация
* [docs/architecture.md](docs/architecture.md) — устройство модели и пайплайна
* [docs/dataset.md](docs/dataset.md) — схема датасета, семантика классов, аудит
* [docs/dataset-coverage.md](docs/dataset-coverage.md) — покрытие всех страниц
`data/raw`, найденные дыры сборщика и что исправлено
* [docs/training-windows.md](docs/training-windows.md) — обучение на Windows+GPU, troubleshooting
* [docs/api-reference.md](docs/api-reference.md) — **все значения ответа сервиса (15 классов `translation_type` и др.) и принцип работы; `labels.csv` для этого открывать не нужно**
* [docs/inference.md](docs/inference.md) — CLI/API, JSON-контракт
@@ -76,37 +80,53 @@ docs/ документация
## Качество
Held-out test (645 строк, группировка по релизам и текстам; полная таблица и
графики — [reports/summary.md](reports/summary.md)):
Held-out test (4 984 строки, группировка по релизам и текстам; метки пересобраны
парсерами `PARSE_VERSION` 9, строки со списком студий склеены в одну с несколькими
авторами). Все модели оценены на **одном и том же** тесте:
| модель | type acc | type macro-F1 | span exact | 18+ acc | joint exact |
|---|---|---|---|---|---|
| rubert-tiny2 | 0.991 | 0.970 | 0.918 | 1.000 | 0.915 |
| **ruBert-base (models/best)** | **0.994** | 0.966 | **0.950** | **1.000** | **0.946** |
| модель | type acc | type macro-F1 | span exact | 18+ acc | lang acc | joint exact [95 %] | строки с 2+ авторами (438), span |
|---|---|---|---|---|---|---|---|
| прежняя `models/best` (09.11, один автор на строку) | 0.981 | 0.909 | 0.813 | 1.000 | 0.998 | 0.800 [0.788–0.810] | 0.011 |
| rubert-tiny2, несколько авторов | 0.978 | 0.904 | 0.869 | 0.999 | 0.999 | 0.854 [0.843–0.863] | 0.637 |
| **ruBert-base, несколько авторов (`models/best`)** | **0.979** | **0.976** | **0.919** | **1.000** | **0.999** | **0.903** [0.895–0.911] | **0.815** |
* joint exact — строка распознана полностью (тип + автор + 18+).
* E2E на сохранённых страницах релизов: rutracker — precision 0.83 / recall 0.98
по парам (тип, автор); kinozal-текстовые дампы содержат только тех-вкладку
без авторов (автор подгружался на сайте через JS), поэтому там автор
недоступен в принципе.
* ONNX-экспорт эквивалентен PyTorch на 100% строк test — [reports/onnx_parity.md](reports/onnx_parity.md).
* Smoke на формулировках, которых нет в датасете: тип 9/10, 18+ 10/10,
автор 5/10 (полное извлечение автора на непривычных форматах записи —
известное ограничение, см. ниже).
* joint exact — строка распознана полностью (тип + все авторы + 18+); span exact
сравнивает набор авторов строки целиком.
* Новая `models/best` против прежней на тех же строках: исправлено 647, сломано 131
(знаковый критерий p ≈ 1e-83). Прежняя модель часть тестовых строк видела при
обучении, так что сравнение в её пользу.
* Противоречивых строк (один текст — разные метки) в датасете 1.2 % вместо 20 %:
исправлены парсеры студий (пол голоса, техпараметры, «т/к», «N-й канал»), а
списки студий размечаются целиком. Оставшиеся расхождения между релизами
разбираются вручную: `tools/collect.py` → «Разбор противоречий»
(`data/text_fixes.csv`).
* E2E по релизам (замер для модели от 09.11 с одним автором на строку) (`tests/e2e_segmenter.py`, по 400 тестовых страниц на трекер, пары
тип + студия, precision / recall) — новая модель на тех же страницах:
kinozal 0.805 / 0.867, rutracker 0.771 / 0.740, nnm 0.752 / 0.693; прежняя
модель там же: 0.752 / 0.830, 0.729 / 0.700, 0.669 / 0.604. Справочно, правила:
0.792 / 0.859, 0.697 / 0.721, 0.670 / 0.646.
* Сегментатор (`models/segmenter`) переобучен на расширенном наборе страниц
(`models/segmenter_v2`, line precision 0.933 / recall 0.977), но e2e новых весов
хуже по nnm (0.610 / 0.560) — в `models/segmenter` оставлены прежние веса.
* `models/best-onnx` пересобран под новую модель: паритет с PyTorch 100 % по типу,
авторам и 18+ — [reports/onnx_parity.md](reports/onnx_parity.md).
## Ограничения
* Метки датасета сгенерированы регулярками (пайплайн deepseek-harness) — метрики
измеряют согласование с этой разметкой; ценность модели — обобщение на новые
формулировки без сопровождения регулярок.
* Источники несбалансированы (rutracker 1658 / nnm 1549 / kinozal 77 строк).
* Датасет: 69 511 строк из 30 451 релиза (kinozal 23 749 / nnm 26 478 /
rutracker 19 284); после подготовки 52 658 строк (3 529 — с несколькими авторами). Покрытие страниц `data/raw`
— 85.5 % (30 260 из 35 385); оставшиеся — почти все без озвучки (русский
оригинал, «не требуется», музыка/сканы), см. [docs/dataset-coverage.md](docs/dataset-coverage.md).
* Извлечение автора уверенно работает на форматах записи, представленных в
датасете (имя после типа через разделитель, в скобках у AVO/MVO/DVO-дорожек,
после тире); на непривычных форматах («Одноголосый закадровый (Фамилия)»)
модель может не выделить имя — span остаётся пустым, тип при этом верный.
* kinozal-страницы: авторы озвучки подгружаются на сайте JavaScript-ом; в
статическом HTML и текстовых дампах их нет — модель их не увидит (это
ограничение данных, не модели).
* kinozal-страницы: авторы озвучки лежат во вкладках, которые сайт подгружает
JavaScript-ом. По `url` сервис скачивает их сам (`fetch_html`, `showtab`); в
присланном `html`/`text` без этих вкладок студий может не быть.
* Классы с 1–8 примерами почти не выучиваются (видно в macro-F1 и confusion
matrix); морфологическая аугментация (458 вариантов: «многоголосое» и т.п.)
частично закрывает этот пробел.
+43656 -18822
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -1 +1,3 @@
rt-6894333
rt-6919056
rt-6927571
+1
View File
@@ -35093,6 +35093,7 @@ rutracker,rt-6918940,"Полицейский-экстрасенс Тёдзё / C
rutracker,rt-6918967,"Человек-паук: Новый день / Spider-Man: Brand New Day (Дестин Дэниел Креттон / Destin Daniel Cretton) [2026, США, Канада, Великобритания, Германия, фэнтези, комедия, приключения, семейный, WEB-DL 2160p, SDR] 4x Dub + 6x MVO + DVO + Original (Eng) + Sub (Rus, Eng)",Человек-паук: Новый день,Spider-Man: Brand New Day,2026,film,False,,,,Зарубежное кино (UHD Video),WEB-DL 2160p (SDR) [MA],2160p,,,,,,,145,False,"США, Канада, Великобритания, Германия","фантастика, боевик, приключения",Дестин Дэниел Креттон / Destin Daniel Cretton,Том Холланд; Зендея; Сэди Синк; Джейкоб Баталон; Джон Бернтал; Трэмелл Тиллман; Майкл Мэндо; Мелани Скрофано,,,,,ENG,ENG,0,,RUS+ENG,True,
rutracker,rt-6919009,"Петер Фосс - герой наших дней / Peter Voss, der Held des Tages (Георг Маришка / Georg Marischka) [1959, Западная Германия, комедия, криминал, DVDRip-AVC] VO (Grampy) + Sub Rus + Original Deu",Петер Фосс - герой наших дней,"Peter Voss, der Held des Tages",1959,film,False,,,,Классика мирового кинематографа,DVDRip-AVC,,AVC,,,,,,105,False,Западная Германия,"комедия, криминал",Георг Маришка / Georg Marischka,,,,,,DEU,DEU,2,"[{""lang"": """", ""codec"": ""AC-3"", ""channels"": """", ""bitrate_kbps"": """", ""tag"": """", ""studio"": """", ""episodes"": """", ""is_18plus"": false}, {""lang"": """", ""codec"": ""AC-3"", ""channels"": """", ""bitrate_kbps"": """", ""tag"": """", ""studio"": """", ""episodes"": """", ""is_18plus"": false}]",RUS,False,softsub (SRT)
rutracker,rt-6919020,"Отель для нелюдей (ТВ-2) / Hotel Inhumans 2nd Season [TV] [01-03 из 13] [RUS(int), JAP+Sub] [2026, Экшен, Драма, WEB-DL] [1080p]",Отель для нелюдей (ТВ-2),Hotel Inhumans 2nd Season,2026,anime,False,,,,Онгоинги (HD Video),WEB-DL (Crunchyroll),1080p,,,,,,,,False,Япония,"Экшен, Драма",Амино Тэцуро,,,,,,,,0,,,False,
rutracker,rt-6919056,"Астрал 6: Они уже здесь / Insidious: Out of the Further (Джейкоб Чейз / Jacob Chase) [2026, США, ужасы, триллер, WEB-DL 2160p, 4k, HDR10+, Dolby Vision] [Hybrid] Dub (Cinema Tone Production, HDRezka Studio) + MVO (HDRezka Studio, TVShows) + Original Eng + Sub (Rus, Eng)",Астрал 6: Они уже здесь,Insidious: Out of the Further,2026,film,False,,,,,WEB-DL 2160p (iT),2160p,HEVC,HDR10++HDR10+DV+HDR,,23.976,MKV,,105,False,США,"ужасы, триллер",Джейкоб Чейз / Jacob Chase,Амелия Ив; Айленд Остин; Лин Шэй; Сэм Спруэлл; Брэндон Переа; Мэйси Ричардсон-Селлерс; Waylon Devens; Джозеф Лопес,,,,,RUS+ENG,ENG,5,"[{""lang"": ""RUS"", ""codec"": ""AC3"", ""channels"": ""2"", ""bitrate_kbps"": 192, ""tag"": """", ""studio"": """", ""episodes"": """", ""is_18plus"": false}, {""lang"": ""RUS"", ""codec"": ""AAC"", ""channels"": ""2"", ""bitrate_kbps"": 335, ""tag"": ""DUB"", ""studio"": ""Дубляж"", ""episodes"": """", ""is_18plus"": false}, {""lang"": ""RUS"", ""codec"": ""AAC"", ""channels"": ""2"", ""bitrate_kbps"": 301, ""tag"": """", ""studio"": """", ""episodes"": """", ""is_18plus"": false}, {""lang"": ""RUS"", ""codec"": ""AC3"", ""channels"": ""6"", ""bitrate_kbps"": 384, ""tag"": """", ""studio"": """", ""episodes"": """", ""is_18plus"": false}, {""lang"": ""ENG"", ""codec"": ""E-AC3"", ""channels"": ""6"", ""bitrate_kbps"": 640, ""tag"": """", ""studio"": """", ""episodes"": """", ""is_18plus"": false}]",RUS+ENG,False,
rutracker,rt-6919120,"Шериф поневоле / The Deputy (Мэттью Суккар / Matt Sukkar) [2026, США, боевик, триллер, драма, криминал, WEB-DLRip-AVC] Dub",Шериф поневоле,The Deputy,2026,film,False,,,,Фильмы 2026,WEB-DLRip-AVC,,AVC,,,,,,100,False,США,"боевик, триллер, драма, криминал",Мэттью Суккар / Matt Sukkar,Дьюк Николсон; Уильям Х. Мэйси; Тиффани Хэддиш; Стивен Дорфф; Девон Росс; Билли Лурд; Джулия Фокс; Алисса Вилла,,,,,,,1,"[{""lang"": """", ""codec"": ""AC3"", ""channels"": ""6"", ""bitrate_kbps"": 448, ""tag"": """", ""studio"": """", ""episodes"": """", ""is_18plus"": false}]",RUS,True,
rutracker,rt-6919131,"Изгнанный читер-чародей наслаждается беззаботной второй жизнью / Tsuihou Sareta Cheat Fuyo Majutsushi wa Kimama na Second Life o (wo) Ouka Suru [TV] [01-03 из XX] [RUS(int), ENG, JAP+Sub] [2026, Фэнтези, WEB-DL] [1080p]",Изгнанный читер-чародей наслаждается беззаботной второй жизнью,Tsuihou Sareta Cheat Fuyo Majutsushi wa Kimama na Second Life o,2026,anime,False,,,,Онгоинги (HD Video),WEB-DL (Crunchyroll),1080p,,,,,,,,False,Япония,Фэнтези,Сю Хомма,,,,,,,,0,,,False,
rutracker,rt-6919143,"Война / War / Сезон: 1 / Серии: 1 из 8 (Чина Му-Ен, Бен Тэйлор, Дженнифер Шеридан) [2026, Великобритания, Триллер, драма, WEB-DL 1080p] MVO (Red Head Sound) + Original + Sub (Eng)",Война,War,2026,series,True,,,,Новинки и сериалы в стадии показа (HD Video),WEB-DL 1080p,1080p,AVC,,33,00,MKV,,53,False,Великобритания,"Триллер, драма","Чина Му-Ен, Бен Тэйлор, Дженнифер Шеридан",Сиенна Миллер; Доминик Уэст; Арчи Рено; Нина Сосанья; Пип Торренс; Джеймс МакАрдл; Ребекка Беллавиа; Свен Ванниювенхюзе,,,,,RUS+ENG,,2,"[{""lang"": ""RUS"", ""codec"": ""AC3"", ""channels"": ""6"", ""bitrate_kbps"": 640, ""tag"": """", ""studio"": """", ""episodes"": """", ""is_18plus"": false}, {""lang"": ""ENG"", ""codec"": ""E-AC3"", ""channels"": ""6"", ""bitrate_kbps"": 640, ""tag"": """", ""studio"": """", ""episodes"": """", ""is_18plus"": false}]",ENG,False,
Can't render this file because it is too large.
+8
View File
@@ -0,0 +1,8 @@
text,translation_type,studio,action
Дублированный (Невафильм),проф. дубляж,Невафильм,set
Дублированный (Пифагор),проф. дубляж,Пифагор,set
Профессиональный (дублированный) Bravo Records Georgia / Movie Dubbing,проф. дубляж,Movie Dubbing,set
Дублированный (Red Head Sound),проф. дубляж,Red Head Sound,set
"Ukrainian AC3 / 5.1 / 48 kHz / 384 kbps |MVO, DniproFilm / HDRezka Studio|",проф. многоголосый,HDRezka Studio,set
Профессиональный (дублированный) Bravo Records Georgia / Movie Dubbing,проф. дубляж,Bravo Records Georgia ; Movie Dubbing,set
"Ukrainian AC3 / 5.1 / 48 kHz / 384 kbps |MVO, DniproFilm / HDRezka Studio|",проф. многоголосый,DniproFilm ; HDRezka Studio,set
1 text translation_type studio action
2 Дублированный (Невафильм) проф. дубляж Невафильм set
3 Дублированный (Пифагор) проф. дубляж Пифагор set
4 Профессиональный (дублированный) Bravo Records Georgia / Movie Dubbing проф. дубляж Movie Dubbing set
5 Дублированный (Red Head Sound) проф. дубляж Red Head Sound set
6 Ukrainian AC3 / 5.1 / 48 kHz / 384 kbps |MVO, DniproFilm / HDRezka Studio| проф. многоголосый HDRezka Studio set
7 Профессиональный (дублированный) Bravo Records Georgia / Movie Dubbing проф. дубляж Bravo Records Georgia ; Movie Dubbing set
8 Ukrainian AC3 / 5.1 / 48 kHz / 384 kbps |MVO, DniproFilm / HDRezka Studio| проф. многоголосый DniproFilm ; HDRezka Studio set
Binary file not shown.
Binary file not shown.
+62 -5
View File
@@ -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,40 @@
|---|---|---|
| `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"` | чем страница разбита на строки дорожек: обученным сегментатором или запасными правилами |
| `segmenter_warning` | string (необязательно) | модель-сегментатор и запасные правила разошлись (модель ничего не выбрала или правила нашли больше): страница — кандидат на дообучение, см. `docs/retrain-segmenter.md` |
| `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).
@@ -72,6 +106,9 @@
| `studio_span` | string \| null | фрагмент текста, распознанный как автор/студия |
| `studio` | string \| null | каноническое имя автора из словаря алиасов; неизвестное имя возвращается как в тексте; `null` — автора нет |
| `studio_confidence` | float \| null | средняя вероятность токенов спана |
| `studio_spans` | array of string | все авторы, найденные в строке, по порядку в тексте («Многоголосый (HDRezka Studio, LostFilm)» → два); первый совпадает со `studio_span`; пусто — автора нет. В ответе релиза такая строка раскладывается на дорожку на каждого автора, и в каждой дорожке здесь один элемент |
| `studios` | array of string | канонические имена для `studio_spans`, в том же порядке |
| `studio_confidences` | array of float \| null | уверенность для каждого элемента `studio_spans` |
| `is_18plus` | bool | дорожка помечена 18+ (по модели или по строке «Аудио … 18+» страницы) |
| `age_confidence` | float 0..1 | вероятность предсказанного класса 18+ |
| `lang` | string \| null | язык дорожки, ISO-подобный 3-буквенный код: `RUS`, `UKR`, `ENG`, также `BEL`, `KAZ`, `POL`, `FRA`, `DEU`, `ITA`, `SPA`, `JPN`, `KOR`, `CHI`, `CZE`, `LAV`, `LIT`, `EST`, `GEO`, `ARM`, `AZE`, `UZB`, `TUR`, `POR`, `HIN`; `null` — не определён |
@@ -79,7 +116,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]]`; пусто, если серий нет |
@@ -150,6 +187,9 @@ type Track = {
studio_span: string | null; // null — автора в тексте нет
studio: string | null; // null вместе со studio_span
studio_confidence: number | null; // null вместе со studio_span
studio_spans: string[]; // все авторы строки по порядку; [] — автора нет; [0] == studio_span
studios: string[]; // канонические имена studio_spans, тот же порядок
studio_confidences: (number | null)[]; // уверенность каждого автора
is_18plus: boolean;
age_confidence: number; // 0..1
lang: string | null; // 3-буквенный код или null
@@ -157,7 +197,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 +207,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,8 +218,22 @@ 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";
segmenter_warning?: string; // только если модель и правила разошлись
tracker?: "rutracker" | "nnm" | "kinozal" | "lostfilm"; // только для url
};
```
@@ -226,6 +281,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+ и язык. Схема файла и происхождение меток — в
+29 -8
View File
@@ -42,10 +42,17 @@ BIO-разметка строится по символьным спанам ч
исправлена; см. `decode_span` — он дополнительно пропускает токены с
пустыми offsets).
Декодирование спана: первый `B`-токен, затем цепочка `I` → символьный
Декодирование (`decode_spans`): каждая цепочка `B`/`I` → символьный
диапазон через offsets → текст спана → каноническое имя автора через
словарь алиасов (`src/studio_norm.py`, порт из генератора датасета).
У строки может быть несколько авторов: «Многоголосый (HDRezka Studio,
LostFilm, TVShows)» значит, что верны все три. `prepare_data` склеивает
строки одного релиза с одинаковым текстом и типом в одну строку
(`spans` — все спаны, `span` — первый), BIO размечает все, `span_exact`
сравнивает наборы. Сервис отдаёт `studio_spans`/`studios`; в ответе
релиза такая строка раскладывается на дорожку на каждого автора.
## Почему автор — извлечение, а не классификация
В датасете 253+ уникальных авторов, 177 из них встречаются ровно 1 раз.
@@ -73,13 +80,27 @@ BIO-разметка строится по символьным спанам ч
используются, если `models/segmenter` нет или модель не нашла ни одной строки;
режим можно форсировать: `predict_release(..., segmenter="rules")`.
* Обучение: `python -m src.segmenter --train` (~1 мин). Разметка строк — по
`raw` из `labels.csv` (362 страницы в `data/pages`); синтетика: `raw` всех
train-строк (в т.ч. nnm/kinozal без страниц) вставляются в контекст реальных
строк страниц. Страницы делятся по хешу `release_id` (≈20% в тест).
* Метрики: строки (test) P 0.936 / R 0.994; e2e на непросмотренных страницах
rutracker (n=64) — precision 0.904 → 0.946 против правил, recall 0.975 → 0.975
(`tests/e2e_segmenter.py`; выборка мала, разброс большой).
* Обучение: `python -m src.segmenter --train --out models/segmenter_v2` (или меню
`tools/collect.py` → «2»). Разметка строк — по `raw` и `audio_raw` из `labels.csv`
(страницы `data/pages`); синтетика: `raw` всех train-строк вставляются в контекст
реальных строк страниц. Страницы делятся по хешу `release_id` (≈20% в тест).
* Текст kinozal берётся из `data/raw/kinozal/<id>.json` (`h1` + «Техданные» +
«Релиз», `segmenter.page_text`): в `data/pages/kz-*.txt` нет ни заголовка, ни
«Техданных», откуда взята половина меток kinozal, а сервис (`fetch_html`)
видит обе вкладки.
* Кодек-строки со студией с 02.11.2026 размечены сами (`studio_source=codec`, см.
`docs/collecting.md`): их `raw`/`audio_raw` дают позитивы. Кодек-строка русской
дорожки («Аудио 3: AC3 … Русский (MVO, LostFilm)», «Аудио Rus: … | LostFilm»),
не попавшая в разметку, в обучение не идёт
(метка -1): у rutracker `raw` — строка «Перевод N:», и как негатив она учила
модель отбрасывать кодек-строки — а на kinozal студии только в них.
* В `predict_release` выбранные моделью кодек-строки — второстепенный источник
(`source: codec`): подключаются, только если у какой-то дорожки страницы нет
студии; если других строк нет (nnm), они и есть дорожки.
* e2e: `tests/e2e_segmenter.py [каталог]` — пары (тип, студия) на тестовых
страницах, не больше `E2E_MAX` (400) на трекер; `--train-pages` — ещё и train;
основная модель — `E2E_MODEL` (по умолчанию `models/best`), например
`set E2E_MODEL=models/runs/<запуск>` перед запуском, чтобы сравнить кандидата.
* Нарезка ВНУТРИ строки («Дублированный, многоголосый») в данных не размечена
(91 группа из 6735) — остаётся на правилах `_split_entries`, но только на
строках, выбранных моделью.
+40 -2
View File
@@ -65,8 +65,16 @@ HTML страниц хранится в `data/raw`, поэтому после п
«Переиндексировать data/raw?» (то же: `collect.py build --rewrite --relabel`).
Страницы, `meta.csv` и метки размеченных релизов строятся заново текущими
парсерами; прежний `labels.csv` копируется в `data/labels.bak_<время>.csv`. Не
трогаются ручные метки (пункт 6), релизы без HTML (импорт старого harvest) и
релизы, по которым парсер ничего не нашёл.
трогаются ручные метки (пункт 6) и релизы, по которым парсер ничего не нашёл.
С версии разбора 6 переразбираются и legacy-страницы импорта старого harvest
(`*.legacy.json`: текст поста есть, HTML нет) — метки обновляются, страница
`data/pages` не перезаписывается.
Покрытие страниц датасетом проверяется по всем `data/raw` без выборки:
`.venv\Scripts\python tools\coverage_audit.py` → `reports/coverage_audit.md`
(страницы без меток, но с дорожкой; строки, не попавшие в метки; кластеры
форматов). Выводы по последнему прогону и правки парсеров —
[docs/dataset-coverage.md](dataset-coverage.md).
## Обучение по ссылкам
@@ -142,6 +150,36 @@ id разных трекеров не пересекались. Коллизий
повторяют логику правил. Для нарезки внутри строки («Дублированный,
многоголосый») нужна ручная проверка — режим ревью ещё не сделан.
Студии, которые парсеры раньше теряли (доразметка от 02.11.2026):
* **nnm, «Перевод: Дублированный (Невафильм)»** — строки «Перевод:» (tech) шли с
«(не указана)», хотя студия названа в той же части. Модель училась её не извлекать.
Теперь — `nnm_part_studio` (скобки или «… - Студия»; тип, «x2», «Лицензия», источник
вроде iTunes/Blu-ray — не студия). +3 051 студия.
* **kinozal, заголовок «… / ПМ (LostFilm, HDrezka Studio), СТ / …»** — во «Техданных»
только «Перевод: Профессиональный многоголосый», студии названы в заголовке и в
строках «Аудио Rus: … | LostFilm» без типа. Теперь — `kz_title_rows`: строка на каждую
студию из заголовка, которой нет на странице (`studio_source=title`, вход — тот же
сегмент, что строит сервис из заголовка; строка «Аудио» с этой студией — в
`audio_raw`). +4 219 строк в 3 007 релизах.
* **Студии только в кодек-строках** («Аудио 2: AC3 … (MVO, LostFilm)», «Аудио Rus: … | TVShows»,
«… • DUB | LeDoyen • UKR») — разметка шла по «Перевод:», кодек-строка была лишь `audio_raw`
у найденной дорожки. Теперь — `codec_line_rows`: строка на каждую студию русской/украинской
кодек-строки, которой нет среди меток релиза (`studio_source=codec`, вход — сегмент, как его
режет сервис; тип — из строки, иначе единственный тип релиза, иначе «неизвестно»). Студия —
из словаря меток (≥ 5 строк) или из скобок/`|…|`; раскладка, кодек, «поздний», «серии» — не
студия. +6 428 строк в 2 862 релизах (kinozal 3 444, nnm 1 609, rutracker 1 375). Аудит,
откуда взялась доработка: `reports/tracks_audit.md`.
Повторить аудит дорожек по `data/raw` (сколько студий из кодек-строк, «Перевод:»
и заголовков не попало в метки, мусор в колонке студий, форматы, которые не ловят регулярки):
`.venv\Scripts\python tools\audit_tracks.py` → `reports/tracks_audit_numbers.md`
(`--cache путь.pkl` ускоряет повторный запуск). Выводы и план — `reports/tracks_audit.md`.
Сборщик делает это при переиндексации. Для уже собранного `labels.csv` без пересборки
страниц: `.venv\Scripts\python tools\relabel_studios.py` (копия — `data/labels.bak_relabel_*.csv`;
`--dry-run` — только статистика; повторный запуск ничего не добавляет).
## Разнообразная выборка (режим balanced)
Списки разделов идут от новых к старым, поэтому чтение подряд даёт только свежие
+153
View File
@@ -0,0 +1,153 @@
# Покрытие страниц `data/raw` датасетом: аудит сборщика
Отчёт о том, как сборщик (`tools/collect.py` → `tools/harvest/harvest_core.py` →
`src/prepare_data.py`) превращает сохранённые страницы в обучающий датасет, какие
форматы строк он терял и что исправлено. Цифры получены на всех страницах
`data/raw` (35 385 шт.), а не на выборке.
Инструмент замера — `tools/coverage_audit.py` (пишет `reports/coverage_audit.md`,
`data/raw/_coverage_pages.csv`, `data/raw/_coverage_lines.csv`). Строка считается
«дорожкой» независимым от парсеров детектором (префикс «Перевод/Озвучка/Аудио…»,
слово типа озвучки или блок `|студия|`), а «покрытой» — если для неё есть строка
`labels.csv` этого релиза (`raw` / `audio_raw` / `role_raw`).
## Как устроен сборщик и где терялись данные
Пять стадий, у каждой свой кэш:
| стадия | вход → выход | кэш / версия |
|---|---|---|
| `collect` | браузер → `data/raw/<трекер>/<id>.json` + `.html` | `_state.json` |
| `build` | `data/raw` → `data/pages/<release_id>.txt` + строки `data/labels.csv` | `_built.json`, `PARSE_VERSION` |
| `meta` | страницы → `data/meta.csv` | `_meta_cache.json`, `META_VERSION` |
| `prepare_data` | `labels.csv` → `data/processed/dataset.json` | — |
| `train` | `dataset.json` → `models/best`, `reports/` | — |
До этого аудита датасет собирался из 28 155 релизов (56 131 строка), хотя страниц
в `data/raw` было 35 385: **7 371 страница не дала ни одной строки**. Часть из них
законно не содержит озвучки (русский оригинал, «Перевод: не требуется», немое
кино, музыка/сканы), но не менее **1 259 страниц с настоящими дорожками** в
датасет не попадали. Причины — в порядке убывания потерь:
1. **legacy-страницы не переразбирались.** Импорт старого harvest дал 2 633
страницы только с текстом поста (`.legacy.json`); `build` брал лишь пары
`json+html`, поэтому их метки остались от старых регулярок и больше не
обновлялись.
2. **Форматы строк, которых не понимали префиксы.** `[*]Перевод (1):`,
`►Перевод:`, `Перевод №2:`, `Перевод 01-02:`, `Аудио RUS 1-3:`, `Озвучка:`,
`Озвучивание:` (nnm/rutracker) не совпадали с `RT_RE_FIELD` / `NNM_RE_PEREVOD`,
потому что те ждали ровно «Перевод:» или «Перевод 2:».
3. **Дорожки, названные только строкой «Аудио»** (nnm/rutracker, аниме-раздачи):
`Аудио 4: AC3 … (внешние) (Русский, Anilibria)`, `| Rus | Звук: Zendos &
Eladiel`, `Аудио RUS: … Озвучка: двухголосая (… )`. Студия была в скобках после
языка или в хвосте «Звук:/Озвучка:», а разметка такие строки не читала: у nnm
требовался «(MVO, Студия)», а язык — только «Русский/Английский/Украинский».
4. **Студия без типа озвучки** («Перевод: Dreamers Team», «Перевод: CrunchyRoll»,
«Озвучка: Persona99») выбрасывалась: `translation_type` давал «неизвестно», и
строка отбрасывалась вместе с автором. Для головы автора это потеря: на
странице автора назвали.
5. **Несколько студий в одной строке** («Профессиональный (многоголосый) — Jaskier,
TVShows, HDrezka Studio, LostFilm», «| ОРТ, KidsCo»): в метки шла одна студия
или склеенное «Jaskier, TVShows».
6. **Строка «Язык озвучки: Русский (Berofu, Takera, Euji [AniMaunt])»** (nnm, аниме)
вообще не считалась дорожкой.
7. **kinozal читал только одну вкладку.** Если в «Релизе» была строка «Перевод:»,
«Техданные» игнорировались (и наоборот) — терялись типы или студии второй
вкладки.
8. **Кодек-строки мимо правил сервиса** (`segmenter.RE_AUDIO_TRACK`,
`release.RE_CODEC_TRACK`): «Аудио pic RUS:», «Аудио #1 Русский:», «Аудио N Rus:»,
«Звук N:», «Аудиодорожка 01:», «Audio #3:», кириллические «АVO/МVO». Это дыра не
только в метках сегментатора, но и в запасном пути инференса.
## Что исправлено
`tools/harvest/harvest_core.py` (`PARSE_VERSION` 4 → 6), `src/html_extract.py`,
`src/release.py`, `src/segmenter.py`, `src/trackinfo.py`, `tools/collect.py`,
`src/prepare_data.py`, `src/studio_norm.py`:
* общий разбор префикса дорожки: декоративные `[*] ► •`, номер `(1)`, `№2`,
диапазоны `01-02`, «Озвучка/Озвучивание/Дубляж» — для rutracker, nnm и kinozal;
* `build` переразбирает legacy-страницы (метки обновляются, страница не
перезаписывается);
* nnm: студии из `(Русский, Anilibria)`, `| Rus | Звук: …`, язык `RUS/Rus/RU`;
строка «Язык озвучки»; несколько студий в «Перевод:» → по строке на студию;
студия без типа сохраняется с типом «неизвестно»;
* rutracker: разбор `(Русский, Студия)` и `[студия]`, хвостов «Озвучка:»/«Звук:»,
списков студий; те же правила для «Перевод/Озвучка/Озвучивание»;
* kinozal: обе вкладки, несколько студий в строке, «Отсутствует/Без перевода» не
считаются автором;
* кодек-строки: префиксы «Аудио #N Русский:», «Аудио pic RUS:», «Звук N:»,
«Аудиодорожка 01:», «Audio #3:», «Track #2:», «Title :»; тип-слова с
кириллическими двойниками (АVO/МVO/DVO); если у релиза нет ни одной строки,
из кодек-строки берётся дорожка без имени (тип без студии);
* чистка имён: «Русский, - AlexFilm» → AlexFilm, «X по заказу Y» → X,
«озвучил - N» → N; мусор (коды/каналы/языки/«по умолчанию»/«в составе
контейнера»/«Отсутствует»/эпизоды) отбрасывается; в `prepare_data` строки-отказы
от озвучки удаляются отдельным счётчиком.
## Результат
| | было | стало |
|---|---|---|
| страниц в `data/raw` | 35 385 | 35 385 |
| релизов в `labels.csv` | 28 155 | 30 401 |
| строк в `labels.csv` | 56 131 | 71 339 |
| страниц с метками | 28 014 | 30 260 (85.5 %) |
| страниц без меток, но с дорожкой (по детектору) | 1 259 | 223 |
| `data/pages` (страниц для сегментатора) | 28 198 | 30 444 |
| строк после `prepare_data` | 46 894 | 60 944 (train 54 686 / test 6 258) |
| строк со студией (спан найден) | 38 583 (82.3 %) | 50 477 (82.8 %) |
| уникальных текстов | 25 614 | 30 761 |
| дорожек 18+ | 1 006 | 1 156 |
По трекерам (строки `labels.csv` → строки после подготовки, включая аугментацию):
* kinozal: 21 773 → 23 969 (15 188 после подготовки);
* nnm: 18 384 → 27 092 (24 453);
* rutracker: 15 974 → 20 278 (21 303).
Остаток 223 страниц — почти весь музыка/OST-раздачи, где «дорожкой» оказался
трек-лист, плюс единичные нестандартные строки («Аудио 6: двухголосая закадровая
Flowers Media» без кодек-параметров, «Аудио ShinkaDan: …», «Аудио #1: Russian …
| Original | Censor»). Они перечислены в `data/raw/_coverage_lines.csv` и годятся
для следующей итерации правил.
Непокрытые строки на страницах **с** метками (12 908 из 56 921) — в основном не
потерянные дорожки, а дубли: строка «Аудио N:» той же дорожки, что уже размечена
из «Перевод:», дорожки оригинала/другого языка и техданные. Их доля видна в
`reports/coverage_audit.md`.
## Как воспроизвести
```powershell
# замер покрытия (кэш текстов ускоряет повтор)
.venv\Scripts\python tools\coverage_audit.py --cache data\raw\_coverage.pkl
# переиндексация: страницы + метки + meta (после правки парсеров поднять PARSE_VERSION)
.venv\Scripts\python tools\collect.py build --rewrite --relabel
# подготовка датасета и аудит
.venv\Scripts\python -m src.prepare_data
```
Переиндексация всех 35 тыс. страниц одним процессом занимает ~30 мин (основное
время — запись новых страниц и `meta.csv`); на нескольких процессах — в разы
быстрее (`Runner(workers)`), но в песочнице DSH процессы с именованными каналами
недоступны.
## Что это дало модели
Переобучение на расширенном датасете (held-out test 6 258 строк, обе модели
оценены на одном тесте):
| модель | type acc | type macro-F1 | span exact | joint exact |
|---|---|---|---|---|
| прежняя `models/best` (02.11) | 0.949 | 0.880 | 0.643 | 0.615 |
| новая `models/best` (09.11) | 0.976 | 0.906 | 0.674 | 0.658 |
E2E по релизам (пары тип + студия, precision / recall; те же 400 страниц на
трекер): прежняя модель — kinozal 0.752 / 0.830, rutracker 0.729 / 0.700,
nnm 0.669 / 0.604; новая — 0.805 / 0.867, 0.771 / 0.740, 0.752 / 0.693.
Абсолютный `span exact` на новом тесте ниже прежних 0.922 из-за того, что в тест
попали форматы, которых раньше в датасете не было; сравнение «на одном тесте»
показывает рост по всем головам.
+10 -5
View File
@@ -10,11 +10,14 @@
регулярками** — модель учится воспроизводить эту разметку и обобщать её на
новые формулировки.
Размер (на момент последней сборки): ~41 тыс. строк из ~28 тыс. релизов, 3 источника
(rutracker, nnm, kinozal); HTML-дамп страниц — `data/raw` (~32 тыс. файлов, ≈2.6 ГБ, в git не
хранится), разобранные тексты — `data/pages`. Точные числа показывает «Статистика датасета» в
`tools/collect.py` (пункт 3) и отчёт `reports/data_audit.md` после `prepare_data`.
Ранние цифры этого документа (3286 строк) относились к первому снимку.
Размер (снимок 09.11.2026, после полной переиндексации `data/raw`): 72 239 строк
из 30 401 релиза, 3 источника (rutracker, nnm, kinozal); HTML-дамп страниц —
`data/raw` (35 385 страниц, ≈2.8 ГБ, в git не хранится), разобранные тексты —
`data/pages` (30 444). Аудит покрытия страниц и правки парсеров —
[docs/dataset-coverage.md](dataset-coverage.md).
Точные числа показывает «Статистика датасета» в `tools/collect.py` (пункт 3) и
отчёт `reports/data_audit.md` после `prepare_data`. Ранние цифры этого документа
(3286 строк) относились к первому снимку.
К строкам при подготовке добавляются аугментированные морфологические варианты
(«многоголосое», «дублированное» и т.п.). Файлы `*_entries.csv` / `*_releases.csv` — те же данные в нарезке
по источникам и по релизам; модель используется только `labels.csv`.
@@ -29,6 +32,8 @@
| `studio` | автор («(не указана)» если нет) | **метка 2** (спан в тексте) |
| `age_18`, `note` | флаг/пометка 18+ | **метка 3** |
| `role_raw` | скобочная часть / конкретный тип из перечисления | вход для nnm tech-строк |
| `studio_source` | откуда взята строка: `release` / `tech` (описание релиза / техданные, в т.ч. nnm «Перевод:»), `codec` (кодек-строка «Аудио N: … (MVO, LostFilm)»), `title` (заголовок kinozal), `audio` (только строка «Аудио»), `manual` (ручная разметка) | не используется; `codec` не попадает в словарь студий `codec_line_rows` |
| `audio_raw` | кодек-строка той же дорожки целиком | разметка строк для сегментатора |
| остальные (`ru_title`, `codec`, …) | контекст релиза | не используются |
## Форматы входа по источникам
+6
View File
@@ -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
View File
@@ -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+ (если на странице две строки «Аудио» с одной студией).
+3 -1
View File
@@ -65,12 +65,14 @@
| 1 baseline | сделано |
| 2 URL + агрегат релиза | сделано: `src/fetch.py`, `src/release.py`, `--url`, `POST /predict {"url"}` |
| 3 title-fallback | сделано (`ПМ (LostFilm)` → дорожка с `source: "title"`); заголовок берётся из HTML (`<h1>`/`<title>`), из текстового дампа (первая строка вида «… / год / …»), либо передаётся явно (`title`). Если на странице есть дорожка того же типа без студии, студия вписывается в неё (`source: page+title`), а не дублируется. **Эффект на e2e мал**: kinozal P/R 0.107 → 0.143, rutracker без изменений (0.826 / 0.984) — в разметке датасета студия из заголовка почти не нужна |
| 4 kinozal-вкладка | **не сделано** в сервисе: страница релиза требует авторизации, а `get_srv_details.php?id=…&pagesd=N` уже вызывает `KinozalClient.php` в grabber. Рекомендация: grabber скачивает вкладку сам и шлёт `{"html": …, "title": <заголовок раздачи>}` (во фрагменте вкладки заголовка нет) |
| 4 kinozal-вкладка | сделано: `fetch_html` для kinozal скачивает вкладки `showtab(id,N)` (`get_srv_details.php?id=…&pagesd=N`, кроме «Техданных» и «Скриншотов») и дописывает их в `#tabs`. Кодек-строки «Аудио N: … (MVO, LostFilm)» сегментатор отбрасывает (в разметке rutracker это не `raw`), поэтому они берутся правилом `codec_segments` как второстепенный источник (`source: codec` / `page+codec`): только дорожки со студией, студия-дубль (`same_studio`) пропускается. Кодек-строки подключаются, только если у какой-то дорожки страницы нет студии. На test-сплите модели 29.10 (по 300 релизов), студии P/R без → с кодек-строками: rutracker 0.908/0.882 → 0.840/0.900 (почти все «ложные» — реальные студии, которых нет в эталоне), nnm 0.875/0.915 → 0.875/0.940, kinozal (текст из raw JSON) 0.816/0.845 → 0.800/0.863 |
| 5 false-positives сегментации | сделано: починена резка «Авторский, одноголосый» по запятой; постобработка спана (`refine_span`: до границ слова, мусор вроде `L,C,R,l,r`/кодеков → null). Остаток — промахи модели на `kbps \| DUB Автор` и особенности разметки |
| 9 переобучение | датасет вырос 3286 → 6864 строк; обе модели переобучены (см. ниже) |
| 6 tiny2 + int8 | сделано: 29.3 МБ, parity 99.8% тип / 99.2% спан / 100% 18+ (`reports/onnx_parity_tiny2_int8.md`) |
| 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-клиентах этих полей нет |
## Открытые вопросы
+93
View File
@@ -0,0 +1,93 @@
# Дообучение сегментатора страниц
Сегментатор решает, какие строки страницы описывают дорожки озвучки
(`src/segmenter.py`, rubert-tiny2). Дообучать его нужно, когда `/predict` теряет
дорожки или в ответе появилось `segmenter_warning` (модель и правила
разошлись). Делается на машине, где лежат `data/raw`, `data/pages` и `models/segmenter`
(в git их нет; в репозитории на машине разработки их не оказалось, пути ниже — относительно каталога проекта, где они есть): без них обучение выдаст более слабые веса, чем рабочие.
## 0. Понять причину (по желанию, 1 минута)
Какие оценки модель ставит строкам страницы (`docker compose exec` — на
сервере, либо то же в `.venv` локально):
```bash
docker compose exec grabber-ai python -c "from src.fetch import fetch_html; from src.html_extract import html_to_text; from src.segmenter import LineSegmenter, page_lines; h,_=fetch_html('https://rutracker.org/forum/viewtopic.php?t=6919056', timeout=60); L=page_lines(html_to_text(h)); s=LineSegmenter('/app/models/segmenter'); [print(round(float(p),3), l[:90]) for l,p in zip(L, s.score(L)) if p>0.01]"
```
* оценки 0.3–0.49 у строк-дорожек — упёрлись в порог (`THRESHOLD = 0.5`);
* оценки около 0 — модель не знает такую вёрстку, нужны примеры (шаги ниже).
## 1. Добавить проблемные раздачи
```powershell
cd <каталог проекта, где лежат data\raw и models>
.venv\Scripts\python tools\collect.py
```
Главное меню → **6** («Обучение по ссылкам»): вставьте ссылки на раздачи, у
которых дорожки определились неверно (например,
`https://rutracker.org/forum/viewtopic.php?t=6919056`). Страницы скачиваются
вашим Chrome (вход на трекер делаете сами), парсер строит метки. Проверьте
таблицу: строк-дорожек должно быть столько же, сколько на странице (для
6919056 — четыре, у двух HDRezka разные типы). Правки: «2 18» — переключить
18+, «3 d» — удалить строку, «1 t <тип>» — тип, «1 s <студия>» — студия.
Сохраните: id попадёт в `data/manual_ids.txt`, такие релизы всегда в train и
повторяются при обучении ×5.
Чем больше похожих раздач (3–10 штук с разными студиями и той же вёрсткой),
тем надёжнее; одна страница редко меняет поведение модели.
## 2. Переобучить
Главное меню → **2** («Переобучить и протестировать»). Шаги идут сами:
`prepare_data`, обучение сегментатора в `models/segmenter_v2`
(≈ минуты на RTX 5070 Ti), `tests/run_tests.py`, таблица «правила / старые веса /
новые веса» по трекерам.
Без меню:
```powershell
.venv\Scripts\python -m src.prepare_data
.venv\Scripts\python -m src.segmenter --train --out models/segmenter_v2
.venv\Scripts\python tests\run_tests.py
.venv\Scripts\python tests\e2e_segmenter.py models/segmenter_v2
```
## 3. Принять новые веса
Меню покажет, лучше ли новые веса. На «y» они заменят `models/segmenter`, старые
уйдут в `models/segmenter_old_<время>`. Если хуже — не принимайте: добавьте
больше примеров вёрстки и повторите шаг 2.
Исправленная раздача проверяется на сервере после пересборки образа (шаг 5).
## 4. Упаковать модели и пересобрать образ
```powershell
.venv\Scripts\python tools\pack_models.py --stage docker-models
docker compose up -d --build
```
(в CI — workflow `.gitea/workflows/docker.yml`, см. `docs/docker.md`).
## 5. Проверить на сервере
```powershell
irm http://192.168.100.67:7654/health # "segmenter": "model"
irm http://192.168.100.67:7654/predict -Method Post -ContentType "application/json; charset=utf-8" -Headers @{Authorization="Bearer <токен>"} -Body ([Text.Encoding]::UTF8.GetBytes('{"url":"https://rutracker.org/forum/viewtopic.php?t=6919056"}')) | ConvertTo-Json -Depth 10
```
Должно быть `"segmenter": "model"`, четыре дорожки, без `segmenter_warning`.
## Что делает сервис, пока модель не дообучена
* **Достройка блока** (`release.complete_block`): модель выбрала часть строк
списка под «Перевод:» — соседние строки с типом озвучки (Проф…, Любит…, …голос…)
добавляются в тот же список.
* **Запасные правила**: модель не нашла ни одной строки — дорожки берут правила
`html_extract.extract_segments`; в ответе `"segmenter": "rules"`.
* **`segmenter_warning`** в ответе и строка `segmenter: …` в логе сервиса — модель
и правила разошлись. На глоссариях типов озвучки правила дают ложные
срабатывания, поэтому предупреждение — повод посмотреть страницу, а не
всегда ошибка.
+8
View File
@@ -84,6 +84,14 @@ copy /Y <путь>\labels.csv data\labels.csv
.venv\Scripts\python -m src.train
```
Если поменялась только логика разметки студий (без перекачки страниц), сначала
`.venv\Scripts\python tools\relabel_studios.py` (см. `docs/collecting.md`), затем
`prepare_data` и обучение. Кандидата из `models/runs/` перед заменой `models/best`
сравнить: `python -m src.runs --out runs.json --dirs models/best models/runs/<запуск>` (test-сплит) и
`E2E_MODEL=models/runs/<запуск>` + `tests/e2e_segmenter.py` (релизы). Поставить —
меню `tools/collect.py`, пункт 7, или `src.runs.promote`; для Docker затем
`python tools/pack_models.py --stage docker-models`.
## Troubleshooting
| Симптом | Причина | Решение |
+360
View File
@@ -0,0 +1,360 @@
# Покрытие страниц data/raw датасетом
Сгенерировано `tools/coverage_audit.py`. Строка «дорожки» — независимый от парсеров
детектор (префикс «Перевод/Озвучка/Аудио…», тип озвучки или блок `|студия|`);
«покрыта» — для строки есть строка `labels.csv` этого релиза (`raw`/`audio_raw`/`role_raw`).
## kinozal
- страниц: 10541
- обычных: 10491
- legacy: 50
- без меток: 604
- без меток: нет аудио-строк: 197
- без меток: только русская дорожка/не озвучка: 396
- без меток: ЕСТЬ дорожка озвучки: 11
- с метками: 9937
- строк дорожек найдено: 22665
- строк покрыто: 18347
- покрытие строк: 80.9%
**без меток · аудио:** — 10
- 1853874: Аудио: English E-AC3 / 5.1 / 48 kHz / 640 kbps |original|
- 2002018: Аудио: AC3, 48 kHz, 6 ch, ~448.00 kbps avg | русский |
- 2030240: Аудио: AC3, 48 kHz, 6 ch, ~448.00 kbps avg | русский |
**без меток · аудио #N:** — 4
- 1987221: Аудио #1: Russian: AC3, 448 Kb/s (6 ch) | Original | Censor
- 1987221: Аудио #2: Russian: AC3, 448 Kb/s (6 ch) | Original | 18+
- 2155859: Аудио #01: 48 kHz, AAC, 2 ch, 223 Kbps | stereo | zxx (музыка) |
**без меток · аудио#N:** — 1
- 1531412: Аудио#2: French: 48 kHz/24-bit, DTS-HD MA 2.0, ~1679.00 kbps avg |Audio Description|
**не покрыто · аудио N:** — 1506
- 1059424: Аудио 2: 48 kHz, AC3, 2/0 (L,R), ~192.00 kbps avg |Многоголосый закадровый|
- 1059424: Аудио 2: 48 kHz, AC3, 2/0 (L,R), ~192.00 kbps avg |Многоголосый закадровый|
- 1067525: Аудио 1: 48 kHz, AC3 Dolby Digital, 3/2 (L,C,R,l,r) + LFE ch, ~640 kbps (VO) [А.Кизуб]
**не покрыто · перевод:** — 1134
- 1040622: Перевод: Профессиональный многоголосый
- 1065182: Перевод: Профессиональный многоголосый
- 1068463: Перевод: Дублированный, профессиональный многоголосый
**не покрыто · аудио #N:** — 468
- 1013995: Аудио #2: AC-3, 48.0 KHz, 6 ch, 384 kbps | Оригинал |
- 1046248: Аудио #2: Русский (DTS, 6 ch, 768 Кбит/с) – Дублированный (Blu-ray CEE )
- 1046248: Аудио #3: Украинский (AC3, 6 ch, 448 Кбит/с) - Дублированный (Blu-ray CEE )
**не покрыто · аудиоN:** — 251
- 1129830: Аудио1: (48 kHz, AC3 Dolby Digital, 3/2 (L,C,R,l,r) + LFE ch, ~384 kbps) MVO (ДВД-Групп)
- 1178400: Аудио1: Русский (AC3, 2 ch, 192 Кбит/с) - Дублированный - студия SaTI
- 1428231: Аудио1: Русский (DTS-HD MA, 6 ch, 3759 Кбит/с) - профессиональный многоголосый
**не покрыто · аудио:** — 148
- 1004884: Аудио: Russian / Dolby Digital Audio / 5.1 / 48 kHz / 448 kbps - дубляж
- 1004884: Аудио: Ukrainian / Dolby Digital Audio / 5.1 / 48 kHz / 384 kbps - дубляж
- 1026823: Аудио: Подробнее смотрите вкладку перевод
**не покрыто · аудио#N:** — 120
- 1069534: Аудио#1: Russian: 48 kHz, AC3, 3/2 (L,C,R,l,r) + LFE ch, ~384.00 kbps avg |Дубляж, R5|
- 1075593: Аудио#1: Русский (MVO) - AC3, 2/0 ((L,R), 48 kHz, 16-bit, ~192 kbps avg
- 1075593: Аудио#2: Русский (VO) - AC3, 3/2 (L,C,R,l,r) + LFE ch, 48 kHz, 16-bit, ~384 kbps avg
**не покрыто · аудио pic eng:** — 87
- 1755345: Аудио pic Eng: E-AC-3, 48 kHz, 6 ch, 640 kbps |Original|
- 1968059: Аудио pic Eng: E-AC-3 JOC, 48 kHz, 6 ch, 768 kbps |Original|
- 1974407: Аудио pic ENG: E-AC3, 6 ch, 48 kHz, 640 kbps |Original|
**не покрыто · аудио eng:** — 60
- 1781406: Аудио Eng: E-AC3, 48 kHz, 6 ch, 640 kbps |Original|
- 1913634: Аудио Eng: E-AC-3, 48 kHz, 6 ch, 768 kbps |Original|
- 1913635: Аудио Eng: E-AC-3, 48 kHz, 6 ch, 768 kbps |Original|
**не покрыто · аудио #N русский:** — 54
- 1808795: Аудио #1 Русский: AC3, 48.0 kHz, 5.1 ch, 384 Kbps [Дубляж, iTunes]
- 1808795: Аудио #2 Русский: DTS-HD MA, 48.0 kHz, 5.1 ch, 3953 Kbps [AVO, Ю.Сербин]
- 1808795: Аудио #3 Русский: DTS-HD MA, 48.0 kHz, 5.1 ch, 3809 Kbps [AVO, Д.Есарев]
**не покрыто · audio #N:** — 49
- 1775928: Audio #3: Русский (Дубляж [Мосфильм-Мастер]) / 6 channels / 448 Кб/с
- 1775928: Audio #4: Украинский (Дубляж [НЛО]) / 2 channels / 192 Кб/с
- 1775928: Audio #5: Украинский (MVO [Новий]) / 2 channels / 192 Кб/с
**не покрыто · аудио rus:** — 37
- 1279432: Аудио Rus: AC3 / 48.0 KHz / 2.0 / 192kbps / Профессиональный многоголосый - 1 канал
- 1767672: Аудио Rus: AC3, 6 ch, 640 Кбит/с; AC3, 2 ch, 640 Кбит/с Eng: E-AC3 Atmos, 48 kHz, 6 ch, 768 kbps |Original|
- 2006199: Аудио Rus: AC3, 48,0 kHz, 2 ch, 192 Кбит/сек | HDRezka Studio | Dub (1-10 серии)
**не покрыто · аудио №N:** — 31
- 1128869: Аудио №2: Украинский AC3, 48 kHz, 2.0 ch, ~192.00 kbps | Дубляж, M1
- 1299487: Аудио №1: русский (AC3, 6 ch, 640 Кбит/с) дубляж
- 1299487: Аудио №2: русский (DTS-HD MA, 6 ch, 3818 Кбит/с) авторский Гаврилов
## nnm
- страниц: 13677
- обычных: 12182
- legacy: 1495
- без меток: 2106
- без меток: нет аудио-строк: 1949
- без меток: только русская дорожка/не озвучка: 83
- без меток: ЕСТЬ дорожка озвучки: 74
- с метками: 11571
- строк дорожек найдено: 21260
- строк покрыто: 18452
- покрытие строк: 86.8%
**без меток · N | N:** — 222
- 1280472: 001 | 00:21min | 320kbps CBR | 48000 Hz | Joint Stereo | MP3 | 959,30 KB | 1.01 ???BGM | ???BGM
- 1280472: 002 | 05:52min | 320kbps CBR | 48000 Hz | Joint Stereo | MP3 | 13,55 MB | 1.02 ???????? | ????????
- 1280472: 003 | 13:17min | 320kbps CBR | 48000 Hz | Joint Stereo | MP3 | 30,55 MB | 1.03 ?????????(BGM)?????? | ?????????(BGM)??????
**без меток · N.** — 126
- 1138457: 01. Redo | Suzuki Konomi | 001 | 04:19 min | 320kbps CBR | 44100 Hz | Joint Stereo | MP3 | 9,94 MB
- 1138457: 02. Mobius | Suzuki Konomi | 002 | 04:17 min | 320kbps CBR | 44100 Hz | Joint Stereo | MP3 | 9,86 MB
- 1138457: 03. Sekai wa Kizu wo Dakishimeru (Acoustic ver.) | Suzuki Konomi | 003 | 05:47 min | 320kbps CBR | 44100 Hz | Joint Stereo | MP3 | 13,30 MB
**без меток · аудио:** — 26
- 1296989: Аудио: AC3, 2 ch, 192kbps [Закадровый перевод] MediaInfo
- 1296989: Аудио: AC3, 2 ch, 192kbps [Дублирование]
- 1296989: Аудио: AC3, 2 ch, 192kbps [Закадровый перевод Kinomania] MediaInfo
**без меток · аудио #N:** — 22
- 1320092: Аудио #1: AAC 2.0, 48.0 KHz, 192 Kbps | NewComers [Многоголосый, любительский]
- 1492421: Аудио #1: AC3, 2 ch, 48.0 KHz, 192 Kbps | Перевод: Любительский двухголосый | [AniDub]: JAM, Holly
- 1492421: Аудио #2: AC3, 2 ch, 48.0 KHz, 192 Kbps | Перевод: Любительский двухголосый | [Animedia]: Nazel, Freya
**без меток · #N:** — 17
- 1438922: #1: FLAC, Встроенное, 2 ch, 48 kHz, 24 bit, ~1351 kbps | | Оригинал;
- 1438922: #2: AAC, Встроенное, 2 ch, 48 kHz, ~192 kb/s | | AniLibria (Sharon, HectoR, Amikiri, Dejz, Hekomi, Narea, MyAska)
- 1438922: #3: AAC, Встроенное, 2 ch, 48 kHz, ~192-320 kb/s | | Wakanim
**без меток · заголовок :** — 8
- 1438660: Заголовок : x264 | 4784 Kbps | 19.84 fps | 1.36:1
- 1438660: Заголовок : [Musical Score] AC3 | 192 Kbps | 16 bits | 48 KHz | 2 chan
- 1525763: Заголовок : x264 | 4784 Kbps | 21.98 fps | 1.38:1
**без меток · переводчик:** — 7
- 1017562: Переводчик: AleX_MytH | Редактор: St_Ranger | Оформление: Jarly Список эпизодов:
- 1034792: Переводчик: SeshaRim | Редактор: Little Devil, Red-blue_heart | Оформление/Лирика: Nika_Elrik Тех. параметры:
- 682258: Переводчик: Sasha aka Zub | Переводчик/Редактор: Jenia aka Zub | Лирика: Мария Гостюхина Эпизоды:
**без меток · aac | N khz | Nch | ~N kbp** — 7
- 664483: AAC | 48 KHz | 2ch | ~192 Kbps - [BalFor & Viki] (отдельным файлом)
- 664483: AAC | 48 KHz | 2ch | ~192 Kbps - [DemonOFmooN & Lali] (отдельным файлом)
- 664483: AAC | 48 KHz | 2ch | ~128 Kbps - [loster01 & Emeri] (отдельным файлом)
**без меток · аудиодорожка N:** — 4
- 337900: Аудиодорожка 01: AC3 | 48khz | 2ch | 192kb/s Профессиональный (Многоголосый)(Рен-ТВ)
- 337900: Аудиодорожка 02: AC3 | 48khz | 2ch | 192kb/s Commentary By Chris Carter(только первая серия)
- 337900: Аудиодорожка 03: AC3 | 48khz | 2ch | 192kb/s Commentary By Dan Sackheim(только первая серия)
**без меток · aудио#N:** — 4
- 654940: Aудио#1: Русский: 48 kHz, AC3, 2/0 (L,R) ch, 192 kbps avg |Многоголосый, "Mega-Anime"|
- 665428: Aудио#1: Русский / дубляж, Dolby Digital / 5.1 / 48 kHz / 448 kbps
- 665428: Aудио#2: Русский / двухголосый (П.Гланц и И.Королёва), DTS / 5.1 / 48 kHz / 1510 kbps / 24-bit
**без меток · acN | N khz | Nch | N kbps** — 4
- 670747: AC3 | 48 KHz | 2ch | 128 Kbps - [Audio Commentary]
- 670747: AC3 | 48 KHz | 2ch | 192 Kbps - [MifSnaiper & KroshkaRu] (отдельным файлом)
- 670747: AC3 | 48 KHz | 2ch | 192 Kbps - [Eladiel & Jam] (отдельным файлом)
**без меток · кодек:** — 3
- 1138457: Кодек: MP3 | Битрейт: 320 Kbps | Тип битрейта: CBR | Источник: WEB
- 1140030: Кодек: FLAC | Битрейт: Lossless | Тип рипа: Tracks | Источник: WEB
- 1153137: Кодек: MP3 | Битрейт: 320 Kbps | Тип битрейта: CBR | Источник: WEB
**не покрыто · перевод:** — 1080
- 1014436: Перевод: Профессиональный двухголосый, авторский
- 1029447: Перевод: Профессиональный Многоголосый
- 1042803: Перевод: Многоголосый профессиональный - NewStudio
**не покрыто · аудио N:** — 793
- 1003946: Аудио 2: AC3 5.1, 384 Kbps (Польский - одноголосый закадровый)
- 1003946: Аудио 3: AC3 5.1, 384 Kbps (Русский - дублированный)
- 1013202: Аудио 1: AC3, 48 kHz, 2 channels, 192 kbps - MVO [Jaskier]
**не покрыто · аудио #N:** — 150
- 1019903: Аудио #1: PCM, 2 ch, 1536 Kbps - Немецкий дубляж
- 1019903: Аудио #2: PCM, 2 ch, 1536 Kbps - Немецкий дубляж
- 1021232: Аудио #3: AC3, 2 ch, 384 Kbps - Немецкий дубляж
**не покрыто · аудио:** — 97
- 1058509: Аудио: AC3, 6 ch, 640 kbps - Дубляж
- 1078261: Аудио: MP3, 2 ch, 128 Kbps | двухголосый закадровый Mediainfo:
- 1190443: Аудио: AC3, 2 ch, 192 kbps - Украинский, MVO "NLO TV"
**не покрыто · title :** — 76
- 1208293: Title : DD 5.1 @ 448 kbps - Dub | Blu-ray CEE |
- 1263024: Title : RUSSIAN DTS Dub
- 1568781: Title : Ukrainian DUB AC3 2.0 192 kbps (LINE)
**не покрыто · N.** — 76
- 1789505: 01. Русский, AAC LC, 2 ch, 48.0 kHz, ~192 kb/s - StudioBand (DUB)
- 1789505: 02. Русский, AAC LC, 2 ch, 48.0 kHz, ~192 kb/s - AniLibria (MVO)
- 1789505: 03. Русский, AC3, 2 ch, 48.0 kHz, ~224 kb/s - TVShows (MVO)
**не покрыто · заголовок :** — 63
- 1204506: Заголовок : | Original, AC3 CORE |
- 1212717: Заголовок : | Original, AC3 CORE |
- 1217830: Заголовок : | Original, AC3 Core |
**не покрыто · аудио#N:** — 58
- 1129717: Аудио#1: AAC-LC, 2 ch, 93 kbps, VBR - MVO Студия Drunk Tank
- 1129717: Аудио#2: AAC-LC, 2 ch, 72 kbps, VBR - MVO NewComers
- 1129717: Аудио#3: AAC-LC, 2 ch, 93 kbps, VBR - MVO Saint-Sound TV
**не покрыто · track #N:** — 46
- 1011093: Track #2: audio (DUB [iTunes])
- 1011093: Track #3: audio (AVO [Gaevsky])
- 1129717: Track #2: audio (MVO ￐ᄀ￑ツ￑テ￐ᄡ￐ᄌ￑マ Drunk Tank)
**не покрыто · #N:** — 43
- 1411675: #1: AAC LC, 2 ch, 48 kHz, ~192 kbps | | AniLibria (Lupin, Cleo-chan, Hekomi)
- 1411675: #2: AAC LC, 2 ch, 48 kHz, ~128 kbps (~192 — 1-3 эп.) | | AniDub (Berial, Jade, Trina_D, RiZZ_fisher)
- 1411675: #3: AAC LC, 2 ch, 48 kHz, ~128 kbps | | Crunchyroll
**не покрыто · аудио rus:** — 31
- 1020041: Аудио Rus: MPEG Layer 3, 48,0 kHz, 2 ch, 128 kb/s — многоголосый закадровый
- 1029753: Аудио Rus: MP3, 48,0 kHz, 2 ch, 128 kbps — многоголосый закадровый
- 1030983: Аудио Rus: AC3, 2 канала, 192 Кбит/с - двуголосый закадровый
**не покрыто · аудио №N:** — 29
- 1259348: Аудио №1: AC3, 2 ch, 192 kbps (MVO Good People с 1 -16 серию)
- 1259348: Аудио №2: AC3, 2 ch, 192 kbps (MVO OMSKBIRD records)
- 1259348: Аудио №3: AC3, 2 ch, 192 kbps (MVO IdeaFilm с 1 -11 серию)
## rutracker
- страниц: 11167
- обычных: 10029
- legacy: 1138
- без меток: 2415
- без меток: нет аудио-строк: 2048
- без меток: только русская дорожка/не озвучка: 229
- без меток: ЕСТЬ дорожка озвучки: 138
- с метками: 8752
- строк дорожек найдено: 22996
- строк покрыто: 17998
- покрытие строк: 78.3%
**без меток · аудио:** — 81
- 1370450: Аудио: RU - VO, AAC LC SBR, stereo, 48 KHz, ~80 kbps (в контейнере) (1-11 серии поверх английской дубляжа, 12-26 поверх японской аудиодорожки)
- 1623230: Аудио: язык: японский, перевод: оригинал, аудиокодек: AAC битрейт аудио: ~184 кб/с
- 2655927: Аудио: Аудио #1: язык: Английский, перевод: оригинал, кодек: AC3 2.0, битрейт: 192 кб/с
**без меток · аудио N:** — 66
- 3464619: Аудио 1: AC3, 448 kbps, 6ch - Язык Русский, озвучено - JeFerSon
- 5373913: Аудио 1: AC3, 6 ch, 384 kbps Язык Русский (многоголосый, закадровый) MC-Entertainment
- 5543025: Аудио 1: AC3, ~192 kbps, 48000 Hz, 2 ch., язык русский (в составе контейнера); озвучка: многоголосая Rokana, Баяна, Delete, Constantine
**без меток · аудиодорожка N:** — 17
- 5584690: Аудиодорожка 01: AC3 | 48khz | 2ch | 192kb/s Профессиональный (многоголосый закадровый)
- 5584690: Аудиодорожка 02: AC3 | 48khz | 2ch | 192kb/s Оригинал (english)
- 5666350: Аудиодорожка 01: AC3 | 48khz | 2ch | 192kb/s Профессиональный (многоголосый закадровый)
**без меток · ▪ rus:** — 17
- 5735110: ▪ RUS: FLAC, 48.0 kHz, 2.0 ch, 700 Kbps | двухголосая муж/жен от NIKITOS & Viki (SHIZA) [1-2]
- 5735110: ▪ RUS: AAC, 48.0 kHz, 2.0 ch, 200 Kbps | двухголосая муж/жен от NIKITOS & Viki (SHIZA) [3]
- 5735110: ▪ RUS: AC3, 48.0 kHz, 2.0 ch, 190 Kbps | одноголосая муж от aos_dubber (AOS)
**без меток · видео:** — 15
- 4011180: Видео: MPEG-4 AVC Video | NTSC | 1920x1080 | 23.976 fps | ~17851 kb/s | ~0.353 bit/pixel
- 4496716: Видео: AVC (x264) | 1280x720p | 23.976 fps | 2 044 Kbps | 0.093 bits/pix
- 4780285: Видео: MPEG-2 | 720x480 | 29.970 fps | 4 890 Kbps
**без меток · aудио:** — 8
- 6547393: Aудио: Opus, 2.0 канала, 48.0 кГц, 128 кбит/с | Crunchyroll | (многоголосая закадровая, TV)
- 6547393: Aудио: AC3, 2.0 канала, 48.0 кГц, 192 кбит/с | AniLibria | Озвучили: Silv, Hekomi (1-4, 6+ эп.), Cleo-chan (5 эп.), December, Malevich (многоголосая з
- 6547393: Aудио: AC3, 2.0 канала, 48.0 кГц, 192 кбит/с | Shiza Project | Озвучили: Лизавета, Daelit, Mamoru, AmnesiaMoon, Wozwald (многоголосая закадровая, TV+S
**без меток · аудио #N:** — 6
- 2655927: Аудио #2: язык: Французский, перевод: дублированный (дубляж), кодек: AC3 2.0, битрейт: 192 кб/с
- 4063013: Аудио #1: 48.0 kHz, 2ch, ~224 kbps avg (RUS) (DUB)
- 4506285: Аудио #1: 48 kHz, AC3 Dolby Digital, 2/0 (L,R) ch, ~256 kbps (DUB)
**без меток · качество:** — 5
- 2861316: Качество: HDTVRip | Формат: MP4 | Видео кодек: H.264 | Аудио кодек: AAC
- 3034531: Качество: WEBRip | Формат: MP4 | Видео кодек: H.264 | Аудио кодек: AAC
- 3078634: Качество: HDTVRip | Формат: MP4 | Видео кодек: H.264 | Аудио кодек: AAC
**без меток · ∙ озвучка:** — 4
- 4958219: ∙ Озвучка: многоголосая (жен. / жен. / муж. / муж.) (Jade & Nami_Li & NASTR & SpasmSound) Сэмпл.
- 4958219: ∙ Озвучка: одноголосая (жен.) (Kirigava Yuki) Сэмпл.
- 4958219: ∙ Озвучка: двухголосая (жен. / жен.) (Mutsuko_Air & Marie_Bibika) Сэмпл.
**без меток · аудио ac-N, Nhz, N kbps, N.** — 4
- 6526614: Аудио AC-3, 48000Hz, 192 Kbps, 2.0 | AniLibria | Многоголосая закадровая (Anzen, Hekomi, Ados, Amikiri, OkanaTsoy, HectoR, Gomer)
- 6526614: Аудио AC-3, 48000Hz, 448 Kbps, 5.1 | HDrezka Studio| Многоголосая закадровая
- 6526614: Аудио AC-3, 48000Hz, 192 Kbps, 2.0 | JAM | Одноголосая закадровая
**без меток · N.** — 3
- 4006090: 1. Russian (Dolby AC3, 2 ch, 192 kbps) - Дубляж Selena Int.
- 4006090: 4. Russian (Dolby AC3, 2 ch, 192 kbps) - Двухголосый, Флагман-трейд
- 4006090: 5. Russian (Dolby AC3, 2 ch, 192 kbps) - Двухголосый
**без меток · rus - aac, N kbps, N.** — 3
- 4916503: RUS - AAC, 192 kbps, 48.0 KHz, 2 ch Язык Русский (внешним файлом) двухголосый (жен+муж), NASTR и Cuba77
- 5091156: RUS - AAC, 192 kbps, 48.0 KHz, 2 ch Язык Русский (внешним файлом) двухголосый (муж+жен), Cuba77 & Trina_D
- 5135588: RUS - AAC, 192 kbps, 48.0 KHz, 2 ch Язык Русский (внешним файлом) одноголосый (жен), Eladiel
**не покрыто · аудио N:** — 3384
- 1033471: Аудио 1: Русский (Дублированный), DTS, 6ch (L, C, R, Ls, Rs +LFE), 768 кб/с
- 1083542: Аудио 1: Русский DD 2.0 (mono) 192kbps (дубляж)
- 114788: Аудио 3: Russian (Dolby AC3, 2 ch (L,R) ch, 192 kbps) дубляж (VHS-микс)
**не покрыто · аудио #N:** — 382
- 1561637: Аудио #4: 48 kHz, AC3 Dolby Digital, 2/0 (dual mono) ch, ~192.00 kbps avg (американский дубляж) -- серия 15
- 1775072: Аудио #1: Русский DTS 5.1, 48 KHz, 768 kbps - Дубляж
- 1786919: Аудио #1: Русский DTS 5.1, 48 KHz, 768 kbps - Дубляж
**не покрыто · аудио:** — 362
- 1007860: Аудио: RU - VO, AC3, stereo, 224 kbps, 48 kHz; JP - AC3, stereo, 4482 kbps, 48 kHz;
- 1028802: Аудио: Русский ДД 2.0 (дубляж) 192 Kbps., японский ДД 2.0 192Kbps
- 1165266: Аудио: Dolby AC3 48000Hz 2ch 256Kbps (дубляж); Dolby AC3 48000Hz 6ch 384Kbps (многоголосый); Dolby AC3 48000Hz 6ch 384Kbps (Английский)
**не покрыто · аудио#N:** — 114
- 1325281: Аудио#1: Russian DTS 5.1, 1536 kbps (Дубляж)
- 1643963: Аудио#1: Russian: 48 kHz, AC3, 3/2 (L,C,R,l,r) + LFE ch, ~640.00 kbps avg |Дубляж, Blu-ray CEE|
- 1643963: Аудио#2: Ukrainian: 48 kHz, AC3, 3/2 (L,C,R,l,r) + LFE ch, ~448.00 kbps avg |Дубляж|
**не покрыто · аудио N rus:** — 99
- 5399397: Аудио 1 Rus: AC3 / 48.0 KHz / 5.1 / 384kbps / Dub / R5
- 5424086: Аудио 1 Rus: AC3 / 48.0 KHz / 5.1 / 448 kbps / MVO / CP-Digital
- 5424086: Аудио 2 Rus: AC3 / 48.0 KHz / 2.0 / 192 kbps / MVO / Синема Трейд
**не покрыто · аудио №N:** — 97
- 2421299: Аудио №1: RUS AC3 48000Hz, 448 kbps, 16bit, 6ch - Дубляж XL-Media
- 2424106: Аудио №3: Russian: AC3 5.1, 448 kbps (Многоголосый закадровый)
- 2664885: Аудио №1: Русский (Дублированный), AC3 5.1, 448 kb/s
**не покрыто · аудио eng:** — 67
- 4186469: Аудио ENG: E-AC3 JOC, 6 ch, 48 kHz, 576 kbps |Original|
- 5042398: Аудио ENG: E-AC3 JOC, 6 ch, 48 kHz, 576 kbps |Original|
- 5411992: Аудио Eng: AC3, 48 kHz, 6 ch, 640 kbps |Original|
**не покрыто · русская:** — 52
- 4749024: русская: MPEG Layer 2 48000Hz stereo 256Kbps [Дубляж Нева-1]
- 5389600: русская: Dolby AC3 48000Hz ---|Stereo|--- 192Kbps [Карусель] (Серии 01-26) {Серии 16-18 DualMono}
- 5389600: русская: Dolby AC3 48000Hz -----|5.1|----- 384Kbps [ALEKS KV] (Серии 01-26)
**не покрыто · аудио rus:** — 50
- 3660326: Аудио Rus: 48 kHz, AC3 Dolby Digital, 2/0 (L,R) ch, ~192 kbps("Дубляж")
- 4321129: Аудио RUS: AC3, 5.1, 48 kHz, 16 bit, 448 kbps (полный дубляж, но песни на японском)
- 4413295: Аудио Rus: 48 kHz, AC3 Dolby Digital, 2/0 (L,R) ch, ~192 kbps (VO-production)
**не покрыто · видео:** — 37
- 2536708: Видео: H.264 | 852x480 | 23,976 fps | ~ 1500 kbps
- 3359571: Видео: H.264 | 848x480 | 23,976 fps | ~ 1083 - 1777 kbps
- 3553755: Видео: MPEG4 (H264) | 1280x720 | 23.98 fps | ~1860 kbps
**не покрыто · аудиоN:** — 23
- 1792844: Аудио1: AC3, 1/0 ch, 192Kbps, Russian (дубляж)
- 1814859: Аудио1: AC3 48000Hz 6ch 384Kbps (перевод А. Гаврилов)
- 1814859: Аудио3: AC3 48000Hz 2ch 192 Kbps (перевод С.Визгунов)
**не покрыто · N) russian:** — 11
- 1643485: 1) Russian: AC3 2.0, 192 kbps (многоголосный);
- 2219776: 1) Russian: DTS 5.1, 755 kbps (многоголоска, лицензия);
- 2432425: 1) Russian: AC3 5.1, 640 kvps (Дубляж);
+53 -39
View File
@@ -1,45 +1,59 @@
# Аудит датасета: data/labels.csv
строк в CSV: 44677
строк после подготовки: 35383 (train 31343 / test 4040)
источники: {'kinozal': 6382, 'rutracker': 14342, 'nnm': 14659}
уникальных нормализованных текстов: 18870
групп (релиз ∪ одинаковый текст): 6681
удалено: пустой текст 0, мусорный studio 155, не-озвучка (оригинал/субтитры) 6, противоречивые дубли 0 (групп-противоречий: 0), несколько дорожек в одной строке 20, лишние повторы (> 40 одинаковых) 16950
спан автора найден: 24004 (67.8%), не найден: 231
аугментировано морфологических вариантов: 7658
дорожек 18+: 701 (из них без упоминания «18» в тексте: 8)
язык размечен: 15282 из 35383; со строкой «Аудио» во входе: 11796; классы: {'RUS': 14673, 'UKR': 556, 'ENG': 53}; редкие (<20, не учатся): {'DEU': 7, 'FRA': 14, 'CHI': 3, 'KAZ': 10, 'GEO': 2, 'BEL': 2, 'KOR': 1, 'JPN': 4, 'LAV': 1, 'CZE': 1, 'ITA': 6, 'SPA': 8}
строк в CSV: 69511
строк после подготовки: 52658 (train 47674 / test 4984)
источники: {'kinozal': 14015, 'rutracker': 17812, 'nnm': 20831}
уникальных нормализованных текстов: 30041
групп (релиз ∪ одинаковый текст): 8749
удалено: пустой текст 0, мусорный studio 359, не-озвучка (оригинал/субтитры) 13, отказ от озвучки 184, противоречивые дубли 109 (групп-противоречий: 109), несколько дорожек в одной строке 7, лишние повторы (> 40 одинаковых) 20592
несколько авторов в одной строке: склеено 5727 строк, строк с 2+ авторами 3529
ручные решения data/text_fixes.csv: 5 текстов, метка заменена в 409 строках, выброшено 0 строк
спан автора найден: 42489 (80.7%), не найден: 272
аугментировано морфологических вариантов: 9711
дорожек 18+: 1066 (из них без упоминания «18» в тексте: 25)
язык размечен: 34038 из 52658; со строкой «Аудио» во входе: 26700; классы: {'RUS': 32125, 'UKR': 1848, 'ENG': 65}; редкие (<20, не учатся): {'DEU': 8, 'FRA': 13, 'CHI': 3, 'KAZ': 14, 'BEL': 4, 'GEO': 2, 'ITA': 5, 'KOR': 1, 'JPN': 14, 'LAV': 1, 'UZB': 1, 'CZE': 1, 'SPA': 10}
## Распределение translation_type
- проф. дубляж: 9936
- проф. многоголосый: 9831
- авт. одноголосый: 4265
- неизвестно: 2800
- проф. двухголосый: 2153
- одноголосый: 2126
- люб. многоголосый: 1381
- многоголосый: 824
- люб. одноголосый: 646
- люб. двухголосый: 551
- люб. дубляж: 380
- проф. одноголосый: 293
- двухголосый: 192
- авт. двухголосый: 3
- проф. многоголосый: 13827
- проф. дубляж: 13226
- авт. одноголосый: 6463
- неизвестно: 4918
- проф. двухголосый: 3079
- одноголосый: 3016
- многоголосый: 2446
- люб. многоголосый: 2039
- двухголосый: 923
- люб. двухголосый: 921
- люб. одноголосый: 914
- люб. дубляж: 492
- проф. одноголосый: 387
- авт. двухголосый: 5
- авт. многоголосый: 2
## Примеры противоречий (один текст — разные типы)
- rutracker:6111749 «(св-дубль - вгтрк) [vhs] [rus ac3 2ch 192kbps]» -> ['неизвестно', 'проф. дубляж']
- nnm:nnm-1014436 «ac-3, 48.0 khz, 2 ch, 192 kbps, cbr (russian) - двухголосый » -> ['двухголосый', 'проф. двухголосый']
- nnm:nnm-1028054 «ac-3, 48.0 khz, 6 ch, 448 kbps (russian) - многоголосый [rus» -> ['многоголосый', 'проф. многоголосый']
- nnm:nnm-1058230 «ac3, 2 канала, 384 кбит/сек - русский (многоголосый закадров» -> ['многоголосый', 'проф. многоголосый']
- nnm:nnm-1104137 «ac-3, 48.0 khz, 1 ch, 192 kbps, cbr (русский, проф. многогол» -> ['многоголосый', 'проф. многоголосый']
- nnm:nnm-1104203 «lpcm, 1 ch, 1152 kbps, 48,0 khz - русский (многоголосый ''ка» -> ['многоголосый', 'проф. многоголосый']
- nnm:nnm-1116916 «ac3, 2 ch, 192 kbps, 48,0 khz (русский - двухголосый ''видео» -> ['двухголосый', 'проф. двухголосый']
- nnm:nnm-1126664 «1 ac3, 2 ch, 192 kbps - русский, многоголосый (св-дубль) [ru» -> ['многоголосый', 'проф. многоголосый']
- nnm:nnm-1141333 «ac3, 6 ch, 448 kbps (русский - многоголосый ''d2lab'') [rus » -> ['многоголосый', 'проф. многоголосый']
- nnm:nnm-1154096 «ac3, 2 ch, 192 kbps (русский - двухголосый ''нтв+'') [rus ac» -> ['двухголосый', 'проф. двухголосый']
## Спан не найден (примеры)
- [rutracker] studio=«datynet; : Куценко» text=«Одноголосый закадровый datynet; перевод: Куценко»
- [rutracker] studio=«datynet и Yuka_chan; :Куценко» text=«Любительский (двухголосый закадровый) datynet и Yuka_chan; перевод:Куц»
- [rutracker] studio=«Дубляж, Blu-ray CEE_ _2009г» text=«Профессиональный (дублированный) |Дубляж, Blu-ray CEE_[МОСФИЛЬМ]_2009г»
- [rutracker] studio=«AC3, 6ch , 448 кбит/с Ист-Вест *» text=«Русский (Дублированный), AC3, 6ch (L, C, R, Ls, Rs +LFE), 448 кбит/с |»
- [rutracker] studio=«ТК Россия, / озвучен в 2009 году» text=«Профессиональный (многоголосый закадровый) |ТК Россия, (Илья Хвостиков»
- [rutracker] studio=«den904 & DeadSno Janetta» text=«Любительский (двухголосый закадровый) den904 & DeadSno перевод Janetta»
- [rutracker] studio=«AC3, 6ch , 448 кбит/с Blu-ray CEE +» text=«Русский (Дублированный), AC3, 6ch (L, C, R, Ls, Rs +LFE), 448 кбит/с |»
- [rutracker] studio=«AC3, 6ch , 448 кбит/с Blu-ray CEE +» text=«Украинский (Дублированный), AC3, 6ch (L, C, R, Ls, Rs +LFE), 448 кбит/»
- [rutracker] studio=«AC3, 6ch , 384 кбит/с Киномания» text=«Русский (Профессиональный (многоголосый)), AC3, 6ch (L, C, R, Ls, Rs +»
- [rutracker] studio=«AC3, 6ch , 384 кбит/с Сербин» text=«Русский (Профессиональный (один голос)), AC3, 6ch (L, C, R, Ls, Rs +LF»
- [rutracker] studio=«AC3, 6ch , 384 кбит/с Гаврилов» text=«Русский (Профессиональный (один голос)), AC3, 6ch (L, C, R, Ls, Rs +LF»
- [rutracker] studio=«Oдноголосый Матвеев Алексей» text=«Oдноголосый закадровый |Матвеев Алексей|»
- [rutracker] studio=«1+1 / Новий канал украинский» text=«Профессиональный (двухголосый закадровый) 1+1 / Новий канал [Євген Паш»
- [rutracker] studio=«Дубляж, Blu-ray CEE_ _2007г» text=«Профессиональный (дублированный) |Дубляж, Blu-ray CEE_[Невафильм]_2007»
- [rutracker] studio=«Дубляж, Blu-ray CEE_ _2004г» text=«Профессиональный (дублированный) |Дубляж, Blu-ray CEE_[Невафильм]_2004»
- [rutracker] studio=«студия Кадр , по заказу РТР, 1995 г» text=«Профессиональный (дублированный): студия "Кадр" (при уч. студии "Нота"»
- [rutracker] studio=«от : : gamekoff» text=«от [Fansub AOS]: Переводчик: gamekoff, Anku, Cracknight, Makentosh, sh»
- [nnm] studio=«SovetRomantica 2) HorribleSubs» text=«1) GerKo, Ryuji, Tousaka Jun (01-06 + SP); SovetRomantica (07-11) 2) H»
- [nnm] studio=«Истари Комикс + Anilibria» text=«Funimation; Истари Комикс (OVA) + Anilibria (TV)»
- [nnm] studio=«FRT Sora & KaijuKeizer Media Info на первую серию» text=«Shift (TV) | FRT Sora & KaijuKeizer (TV + OVA) Media Info на первую се»
- [nnm] studio=«YakuSub Studio + SovetRomantica» text=«Wakanim (TV 01-12 + OVA); YakuSub Studio (TV 01-10) + SovetRomantica (»
- [nnm] studio=«Zottig & Daidzobu» text=«Zottig (Teisoku) & Daidzobu (Aragami Fansubs)»
- [rutracker] studio=«Ancord & Noir Ancord» text=«Ancord & Noir (01-35) Ancord (36+)»
- [rutracker] studio=«Правильный от студии ПЭ http» text=«Любительский (Гоблинa, "Правильный от студии "Полный ПЭ" http://www.op»
- [rutracker] studio=«Тот самый с кассет» text=«Профессиональный (полное дублирование) + Aвторский перевод А.Михалёва »
- [rutracker] studio=«+ Aвторский А.Михалёва» text=«Профессиональный (полное дублирование) + Aвторский перевод А.Михалёва »
- [rutracker] studio=«но его довольно часто встречал» text=«Любительская (одноголосая) - озвучника не помню как зовут, но его озву»
- [rutracker] studio=«absurd95 & Eladiel» text=«absurd95 (м.) & Eladiel (ж.)»
- [rutracker] studio=«Доп.инфо о : Инна Королева и Петр Гланц» text=«Профессиональный (двухголосый закадровый) Доп.инфо о переводе: Инна Ко»
- [rutracker] studio=«Persona99 ^_^» text=«Persona99 (одноголосое закадровое женское) ^_^»
+55 -90
View File
@@ -2,110 +2,75 @@
"history": [
{
"epoch": 0,
"loss": 1.0322551016006376,
"type_accuracy": 0.9871559633027523,
"type_macro_f1": 0.8369379389908281,
"span_exact": 0.8495412844036697,
"age_accuracy": 0.9926605504587156,
"age_f1": 0.9230769230769231,
"joint_exact": 0.8330275229357799
"loss": 0.874984362499072,
"lang_accuracy": 0.9868667917448405,
"lang_n": 1599,
"type_accuracy": 0.9707081942899518,
"type_macro_f1": 0.9569463713111677,
"span_exact": 0.617723396366333,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.6028921023359288
},
{
"epoch": 1,
"loss": 0.06671750026621989,
"type_accuracy": 0.9889908256880734,
"type_macro_f1": 0.9153726455986941,
"span_exact": 0.9174311926605505,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.9100917431192661
"loss": 0.17198748634640987,
"lang_accuracy": 0.9887429643527205,
"lang_n": 1599,
"type_accuracy": 0.9733036707452726,
"type_macro_f1": 0.9717413388263517,
"span_exact": 0.6447905079718206,
"age_accuracy": 0.9996292176492398,
"age_f1": 0.9873417721518988,
"joint_exact": 0.6292176492398962
},
{
"epoch": 2,
"loss": 0.03165916350924156,
"type_accuracy": 0.9926605504587156,
"type_macro_f1": 0.9186768671962279,
"span_exact": 0.9394495412844037,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.9321100917431193
"loss": 0.1308889834726086,
"lang_accuracy": 0.9874921826141339,
"lang_n": 1599,
"type_accuracy": 0.9729328883945124,
"type_macro_f1": 0.9068459919629618,
"span_exact": 0.6566555431961438,
"age_accuracy": 0.9996292176492398,
"age_f1": 0.9873417721518988,
"joint_exact": 0.6384872080088988
},
{
"epoch": 3,
"loss": 0.018931047540375744,
"type_accuracy": 0.9908256880733946,
"type_macro_f1": 0.9158000930443979,
"span_exact": 0.926605504587156,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.9192660550458716
"loss": 0.11097981555473346,
"lang_accuracy": 0.9931207004377736,
"lang_n": 1599,
"type_accuracy": 0.9699666295884316,
"type_macro_f1": 0.9059061793140248,
"span_exact": 0.6603633667037448,
"age_accuracy": 0.9996292176492398,
"age_f1": 0.9873417721518988,
"joint_exact": 0.6384872080088988
},
{
"epoch": 4,
"loss": 0.01237113052410642,
"type_accuracy": 0.9908256880733946,
"type_macro_f1": 0.9158000930443979,
"span_exact": 0.9431192660550459,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.9339449541284404
},
{
"epoch": 5,
"loss": 0.009695118124439538,
"type_accuracy": 0.9908256880733946,
"type_macro_f1": 0.9158000930443979,
"span_exact": 0.9467889908256881,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.9376146788990826
},
{
"epoch": 6,
"loss": 0.0085221077923288,
"type_accuracy": 0.9908256880733946,
"type_macro_f1": 0.9158000930443979,
"span_exact": 0.9431192660550459,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.9357798165137615
},
{
"epoch": 7,
"loss": 0.007850127838939042,
"type_accuracy": 0.9908256880733946,
"type_macro_f1": 0.9158000930443979,
"span_exact": 0.9412844036697248,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.9339449541284404
},
{
"epoch": 8,
"loss": 0.0069279176914798365,
"type_accuracy": 0.9908256880733946,
"type_macro_f1": 0.9158000930443979,
"span_exact": 0.9467889908256881,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.9394495412844037
"loss": 0.09677471212526927,
"lang_accuracy": 0.9931207004377736,
"lang_n": 1599,
"type_accuracy": 0.9755283648498332,
"type_macro_f1": 0.9099159088603298,
"span_exact": 0.6674082313681868,
"age_accuracy": 0.9977753058954394,
"age_f1": 0.9285714285714286,
"joint_exact": 0.6477567667779014
}
],
"test": {
"type_accuracy": 0.996011046333231,
"type_macro_f1": 0.9608362408399977,
"span_exact": 0.9687020558453513,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.9662473151273396
"lang_accuracy": 0.9946581196581197,
"lang_n": 3744,
"type_accuracy": 0.9761904761904762,
"type_macro_f1": 0.9057782572824744,
"span_exact": 0.6736976669862575,
"age_accuracy": 0.9998402045381911,
"age_f1": 0.9948186528497409,
"joint_exact": 0.6581975071907957
},
"cv_mean": {
"type_accuracy": 0.9967343071842485,
"type_macro_f1": 0.9581255225864929,
"span_exact": 0.9392830173430274,
"age_accuracy": 0.9997553679873908,
"age_f1": 0.9944724556489263,
"joint_exact": 0.9365250772813656
},
"train_seconds": 1013.3
"cv_mean": null,
"train_seconds": 773.1
}
+62 -115
View File
@@ -2,140 +2,87 @@
"history": [
{
"epoch": 0,
"loss": 1.8807837918323556,
"type_accuracy": 0.9211009174311927,
"type_macro_f1": 0.6878025559450562,
"span_exact": 0.5155963302752293,
"age_accuracy": 0.9486238532110092,
"age_f1": 0.0,
"joint_exact": 0.44403669724770645
"loss": 1.4419865617013066,
"lang_accuracy": 0.9874921826141339,
"lang_n": 1599,
"type_accuracy": 0.9588431590656284,
"type_macro_f1": 0.9580993705655323,
"span_exact": 0.5472747497219133,
"age_accuracy": 0.9985168705969596,
"age_f1": 0.9473684210526315,
"joint_exact": 0.5305895439377085
},
{
"epoch": 1,
"loss": 0.19847860801096406,
"type_accuracy": 0.9743119266055046,
"type_macro_f1": 0.9018587930989455,
"span_exact": 0.7981651376146789,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.7853211009174312
"loss": 0.21983068588511678,
"lang_accuracy": 0.9881175734834271,
"lang_n": 1599,
"type_accuracy": 0.9647756766777902,
"type_macro_f1": 0.9694674213789587,
"span_exact": 0.6384872080088988,
"age_accuracy": 0.9996292176492398,
"age_f1": 0.9873417721518988,
"joint_exact": 0.6166110493140526
},
{
"epoch": 2,
"loss": 0.07505214461390836,
"type_accuracy": 0.9908256880733946,
"type_macro_f1": 0.9158000930443979,
"span_exact": 0.8330275229357799,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.8293577981651377
"loss": 0.16362552792017163,
"lang_accuracy": 0.9899937460913071,
"lang_n": 1599,
"type_accuracy": 0.9736744530960326,
"type_macro_f1": 0.9647373413686515,
"span_exact": 0.6599925843529848,
"age_accuracy": 0.9996292176492398,
"age_f1": 0.9873417721518988,
"joint_exact": 0.6418242491657397
},
{
"epoch": 3,
"loss": 0.04440977191069969,
"type_accuracy": 0.9908256880733946,
"type_macro_f1": 0.9158000930443979,
"span_exact": 0.8807339449541285,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.8733944954128441
"loss": 0.13469500560886233,
"lang_accuracy": 0.9912445278298937,
"lang_n": 1599,
"type_accuracy": 0.9632925472747497,
"type_macro_f1": 0.9647998951696939,
"span_exact": 0.6329254727474972,
"age_accuracy": 0.9996292176492398,
"age_f1": 0.9873417721518988,
"joint_exact": 0.6091954022988506
},
{
"epoch": 4,
"loss": 0.028807769589840285,
"type_accuracy": 0.9889908256880734,
"type_macro_f1": 0.9096810438971369,
"span_exact": 0.8660550458715597,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.8605504587155963
"loss": 0.11948855609379865,
"lang_accuracy": 0.9912445278298937,
"lang_n": 1599,
"type_accuracy": 0.9655172413793104,
"type_macro_f1": 0.9657867430958291,
"span_exact": 0.6496106785317018,
"age_accuracy": 0.9996292176492398,
"age_f1": 0.9873417721518988,
"joint_exact": 0.6262513904338154
},
{
"epoch": 5,
"loss": 0.022258058590862728,
"type_accuracy": 0.9908256880733946,
"type_macro_f1": 0.9155121981076735,
"span_exact": 0.8954128440366973,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.8880733944954129
},
{
"epoch": 6,
"loss": 0.017832277844529697,
"type_accuracy": 0.9908256880733946,
"type_macro_f1": 0.9155121981076735,
"span_exact": 0.9045871559633027,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.8972477064220183
},
{
"epoch": 7,
"loss": 0.014963243124099362,
"type_accuracy": 0.9908256880733946,
"type_macro_f1": 0.9158000930443979,
"span_exact": 0.8862385321100917,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.8788990825688073
},
{
"epoch": 8,
"loss": 0.012998920554111813,
"type_accuracy": 0.9908256880733946,
"type_macro_f1": 0.9158000930443979,
"span_exact": 0.9119266055045872,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.9045871559633027
},
{
"epoch": 9,
"loss": 0.011474552309777529,
"type_accuracy": 0.9908256880733946,
"type_macro_f1": 0.9158000930443979,
"span_exact": 0.9100917431192661,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.9027522935779817
},
{
"epoch": 10,
"loss": 0.010157171327384295,
"type_accuracy": 0.9908256880733946,
"type_macro_f1": 0.9158000930443979,
"span_exact": 0.9027522935779817,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.8954128440366973
},
{
"epoch": 11,
"loss": 0.009997131634706275,
"type_accuracy": 0.9908256880733946,
"type_macro_f1": 0.9158000930443979,
"span_exact": 0.9045871559633027,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.8972477064220183
"loss": 0.10735353268224741,
"lang_accuracy": 0.9912445278298937,
"lang_n": 1599,
"type_accuracy": 0.9695958472376714,
"type_macro_f1": 0.9629303509524766,
"span_exact": 0.639599555061179,
"age_accuracy": 0.9996292176492398,
"age_f1": 0.9873417721518988,
"joint_exact": 0.617723396366333
}
],
"test": {
"type_accuracy": 0.9941699907947223,
"type_macro_f1": 0.9576679877290362,
"span_exact": 0.941699907947223,
"lang_accuracy": 0.9959935897435898,
"lang_n": 3744,
"type_accuracy": 0.9678811121764141,
"type_macro_f1": 0.8999603592898254,
"span_exact": 0.6708213486736977,
"age_accuracy": 1.0,
"age_f1": 1.0,
"joint_exact": 0.9389383246394599
"joint_exact": 0.6506871204857783
},
"cv_mean": {
"type_accuracy": 0.9945265233277791,
"type_macro_f1": 0.9549157743958997,
"span_exact": 0.9167176505761322,
"age_accuracy": 0.9994757007005666,
"age_f1": 0.9892257817741743,
"joint_exact": 0.9125275899299806
},
"train_seconds": 589.2
"cv_mean": null,
"train_seconds": 241.3
}
+3 -2
View File
@@ -1,9 +1,10 @@
# ONNX parity (test split)
- строк: 645
- модель: models/best / model.onnx (711.2 MB)
- строк: 2000
- совпадение класса типа: 1.0000
- совпадение текста спана: 1.0000
- совпадение флага 18+: 1.0000
- макс. расхождение type_logits: 4.29e-06
- макс. расхождение type_logits: 2.57e-05
Вердикт: **OK** (≥99% все критерии)
+1
View File
@@ -0,0 +1 @@
{"fingerprint": "54756b3c3b", "n": 6258, "items": [{"dir": "models\\best", "name": "best", "profile": "base", "trained_fp": null, "backbone": "ai-forever/ruBert-base", "mb": 713, "created": "2026-11-09 12:57", "seconds": 773.1, "audio_context": true, "lang_head": true, "token_type_ids": true, "metrics": {"lang_accuracy": 0.9946581196581197, "lang_n": 3744, "type_accuracy": 0.9761904761904762, "type_macro_f1": 0.9057782572824744, "span_exact": 0.6736976669862575, "age_accuracy": 0.9998402045381911, "age_f1": 0.9948186528497409, "joint_exact": 0.6581975071907957}, "n": 6258, "n_unscorable": 0, "ci": [0.6463518685471816, 0.6698490395051715], "fingerprint": "54756b3c3b", "vs": null, "leak_risk": true}, {"dir": "models\\best_backup_20261109_121841", "name": "best_backup_20261109_121841", "profile": "base", "trained_fp": "6b426e4138", "backbone": "ai-forever/ruBert-base", "mb": 713, "created": "2026-11-02 19:05:56", "seconds": 1379.9, "audio_context": true, "lang_head": true, "token_type_ids": true, "metrics": {"lang_accuracy": 0.9943865276663993, "lang_n": 3741, "type_accuracy": 0.9485458612975392, "type_macro_f1": 0.8801508325527028, "span_exact": 0.6428571428571429, "age_accuracy": 0.9996804090763822, "age_f1": 0.9895833333333334, "joint_exact": 0.6152125279642058}, "n": 6258, "n_unscorable": 0, "ci": [0.6030904999707764, 0.6271931916511898], "fingerprint": "54756b3c3b", "vs": {"fixed": 422, "broken": 691, "p": 6.903749983548058e-16}, "leak_risk": true}]}
+2 -2
View File
@@ -2,7 +2,7 @@
| модель | type acc | type macro-F1 | span exact | age acc | joint exact | время, с |
|---|---|---|---|---|---|---|
| tiny2 | 0.994 | 0.958 | 0.942 | 1.000 | 0.939 | 589.2 |
| base | 0.996 | 0.961 | 0.969 | 1.000 | 0.966 | 1013.3 |
| tiny2 | 0.968 | 0.900 | 0.671 | 1.000 | 0.651 | 241.3 |
| base | 0.976 | 0.906 | 0.674 | 1.000 | 0.658 | 773.1 |
Лучшая модель: **base** -> `models/best`
+126
View File
@@ -0,0 +1,126 @@
# Аудит дорожек в описаниях релизов (raw) — выводы и план
Вход: все сохранённые страницы `data/raw/{kinozal,nnm,rutracker}/*.json` (35 384 релиза:
kinozal 10 541, nnm 13 677, rutracker 11 166) против `data/labels.csv` (48 896 строк).
Текст как у сервиса: kinozal — «Техданные» + «Релиз», nnm/rutracker — пост.
Цифры и примеры: `reports/tracks_audit_numbers.md`, пересчёт:
.venv\Scripts\python tools\audit_tracks.py [--cache путь.pkl]
Строка дорожки = «Перевод N: / Озвучка: / Дубляж:» или кодек-строка («Аудио N: AC3 … (MVO, X)»)
русской/украинской дорожки; субтитры и сводки без типа/студии не считаются. Студии сравниваются
через `release.same_studio` («А. Гаврилов» = «Андрей Гаврилов»), словарь — студии меток (≥ 5 раз)
без мусора.
## Что нашлось
### 1. Студии из кодек-строк не попадают в метки (главное)
Студия названа в строке дорожки релиза, но её нет среди меток этого релиза:
| | релизов с метками | из них с недостающей студией | недостающих студий | из кодек-строк «Аудио» | из «Перевод:» | релизов, где у меток **нет ни одной** студии, а в кодек-строке есть |
|---|---|---|---|---|---|---|
| kinozal | 9 932 | 1 690 (17 %) | 3 736 | 3 240 | 496 | 1 129 |
| nnm | 10 085 | 1 295 (13 %) | 2 032 | 1 003 | 1 029 | 653 |
| rutracker | 7 742 | 972 (13 %) | 1 645 | 531 | 1 114 | 495 |
Топ недостающих: kinozal — HDrezka 362, TVShows 339, LostFilm 211, Red Head Sound 164, LeDoyen 117,
Jaskier 101, MovieDalen 89, Мосфильм-Мастер 80; nnm/rutracker — LeDoyen, Postmodern, НТВ+, HDrezka,
1+1, NewStation, Невафильм.
Причина: разметка строится по строкам «Перевод:», а кодек-строки идут только как `audio_raw`
к уже найденной дорожке (`studio_source=audio` — всего 992 строки). Типичный kinozal:
«Перевод: Профессиональный (многоголосый закадровый)» без студии + «Аудио 2: AC3 … (MVO, LostFilm)».
Это та же дыра, что закрыла доразметка nnm «Перевод:» (nnm 0.58 → 0.85 по релизам), только
крупнее. Обратное (студия в метках, которой нет на странице) почти не встречается: 1 / 22 / 14.
### 2. «Перевод N:» с несколькими студиями размечен частично (nnm, rutracker)
~1 000 студий на трекер — из строк вида «Перевод 3-6: Профессиональный (многоголосый) — Jaskier,
TVShows, HDrezka Studio, LostFilm» или «Перевод: Дублированный (Iyuno-SDI Group, Le Doyen),
Многоголосый (LostFilm, Jaskier, TVShows)»: в метках часть списка. Сводка prepare_data тоже
видит «несколько дорожек в одной строке».
### 3. Форматы кодек-строк, которые не ловят правила
Это правила разметки (`segmenter.RE_AUDIO_TRACK`) и запасной путь сервиса
(`release.codec_segments`), а не модель-сегментатор: промах здесь — дыра в метках сегментатора и
в фолбэке, не измеренная потеря предсказания.
| | кодек-строк | мимо RE_AUDIO_TRACK | мимо codec_segments |
|---|---|---|---|
| kinozal | 18 297 | 11 664 (из них 10 395 — «Аудио:» без номера) | 12 858 |
| nnm | 15 151 | 2 850 | 4 696 |
| rutracker | 11 471 | 2 496 | 5 058 |
* **мимо обоих**: «Аудио pic RUS:» (374), «Аудио #N Русский:» (175), «Аудио N Rus:» / «Аудио 3 Rus:» (~220),
«Аудио rus N:», «Звук N:», «Аудио #N (rus):», «• №N: Русский (АС3 …)», «Аудио 2. Русский:»,
nnm «Russian (Dolby AC3, 6 ch) …» без префикса (1 483), «Аудио (rus):».
* **«Аудио:» без номера** исключено намеренно (сводка «Аудио: Русский, английский (AC3…)»), но
~1 000 таких строк (kinozal 184, nnm 378, rt 463) — одна дорожка с типом/студией («Аудио: AC3, 6 ch, 448 kbps - Русский (MVO, Jaskier)»).
Нужно отличать сводку из нескольких языков от одиночной дорожки, а не снимать правило.
* **мимо codec_segments, но ловит RE_AUDIO_TRACK** (1 378 / 2 224 / 3 025): кодек-строка со студией,
но без тип-слова — «Аудио Rus: AC3 … |HDRezka|», «… | AlexFilm», «- Rus (HDRezka Studio)».
`RE_CODEC_TRACK` требует тип озвучки, и на kinozal это та же студия из п. 1.
* «АVO / МVO» с кириллической буквой — 99 строк, `\bAVO\b` их не видит.
### 4. Мусор в колонке studio меток
| вид | kinozal | nnm | rutracker |
|---|---|---|---|
| источник: Лицензия / Blu-ray CEE/EUR / iTunes / Netflix | 245 | 590 | 231 |
| «X по заказу Y» | 131 | 67 | 120 |
| язык-префикс «Русский, - AlexFilm», «Украинский, AMC» | 72 | — | 13 |
| тип-слово («дубляж», «полное дублирование») | 45 | 41 | 19 |
| каналы/кодек («6 ch», «AC3, 6ch, 448 кбит/с Ист-Вест») | 7 | 89 | 18 |
| язык вместо студии («Русский», «Украинский») | 7 | 26 | 18 |
| неру дорожка (казахский, «+ Английский + Комментарии») | 4 | 6 | 26 |
Всего ~1 800 строк (≈ 4 %, вместе с «субтитрами» в studio). Часть уже чистит prepare_data («мусорный studio 155»), остальное идёт в обучение.
### 5. Релизы без меток
| | без меток | нет строк дорожек | «не требуется»/немой | строки без типа | **есть тип озвучки** |
|---|---|---|---|---|---|
| kinozal | 609 | 15 | 186 | 407 (русские фильмы «Аудио: Русский (AC3…)») | 1 |
| nnm | 3 592 | 1 605 | 520 | 1 050 | **417** |
| rutracker | 3 424 | 1 537 | 535 | 790 | **562** |
legacy и `ok=False` среди них нет — все страницы обычные. ~980 релизов nnm/rutracker с
настоящими дорожками сборщик не разметил: «Перевод 1: Профессиональный (двухголосый закадровый)
Пётр Гланц и Евгения Лютая», «Перевод: Двуголосый (НТВ+)», аниме «Озвучка: … от StudioBand»,
«Аудио 1: AC-3, RUS(int) … | Озвучкa: Дубляж Reanimedia». «Строки без типа» — в основном
«Перевод: DreamersTeam» / «Озвучка: Persona99» (тип неизвестен, но студия есть).
## План (по ожидаемому эффекту на e2e)
Каждый пункт: сначала `--dry-run` со счётом строк, потом prepare → train, ворота — e2e
`tests/e2e_segmenter.py` (E2E_MAX=400) и joint exact на test-split против текущей `models/best`.
1. **Метки из кодек-строк** (kinozal 3 240, nnm 1 003, rt 531 студий; 2 277 релизов, где у меток нет
студий вовсе). В `harvest_core` + `tools/relabel_studios.py`: студия из кодек-строки русской
дорожки, которой нет среди меток, → заполнить studio-less строку того же типа или добавить строку
`studio_source=audio`, `raw` = кодек-строка. Ожидание: kinozal recall студий (сейчас 0.93 по
парам, но в gold этих студий просто нет — метрика тоже сдвинется, сравнивать на одном gold).
Dry-run: число строк по трекерам, выборка 50 глазами.
2. **Разбор списков студий в одной «Перевод:»** (~2 100 студий nnm+rt): одна дорожка на студию с
общим типом; «Дублированный (A, B), Многоголосый (C, D)» — тип у каждой группы свой.
3. **Доразметка ~980 релизов без меток с типом озвучки** (nnm 417, rt 562): найти, почему сборщик
их пропустил (вероятно, формат «Перевод 1:» в nnm и «Озвучка: … от X» в аниме), поправить
парсер, прогнать relabel.
4. **Форматы кодек-строк в правилах**: расширить `RE_AUDIO_TRACK` / `RE_AUDIO_PREFIX` на
«Аудио pic RUS», «Аудио N Rus:», «Аудио #N Русский:», «Звук N:», «• №N:», «Russian (…)»
без префикса; одиночное «Аудио:» с типом/студией; `RE_CODEC_TRACK` — студия из словаря без
тип-слова; кириллические «АVO/МVO». Тесты на каждый формат. Даёт метки сегментатору
(переобучение сегментатора — только если e2e лучше текущих весов) и покрытие фолбэка.
5. **Чистка мусорных студий** — после решений ниже; правила в prepare_data (мусор) и в сборщике
(язык-префикс, тип-слова, каналы).
## Решения за вами (не исправления)
* **Лицензия / Blu-ray CEE / iTunes / Netflix** (~1 070 строк): студия или источник дорожки?
Сейчас модель учится выдавать их как студию.
* **«X по заказу Y»** (~320): хранить целиком, только X, или X + заказчик отдельным полем?
* **«Русский, - AlexFilm»** (85): срезать язык-префикс — почти наверняка да, но это меняет метки.
* **Неру дорожки** (казахский Qazaqstan, «+ Английский + Комментарии», ~36): выкидывать из меток
или учить lang-голову (KAZ сейчас < 20 примеров и не учится).
+892
View File
@@ -0,0 +1,892 @@
# Аудит дорожек в raw-страницах (цифры)
Сгенерировано `tools/audit_tracks.py`; выводы и план — `reports/tracks_audit.md`.
## kinozal
**releases**: всего — 10541; с метками — 9932; с метками, есть недостающая студия — 1690; с метками без студий, студия есть в кодек-строке — 1129; без меток — 609
**строки**: audio — 18297; trans — 15072
**кодек-строки**: мимо RE_AUDIO_TRACK | мимо codec_segments — 11480; RE_AUDIO_TRACK | codec_segments — 5255; RE_AUDIO_TRACK | мимо codec_segments — 1378; мимо RE_AUDIO_TRACK | codec_segments — 184
**форматы мимо: seg codec**: аудио: — 10395; аудио pic rus: — 374; аудио #N русский: — 175; аудио N rus: — 62; аудио x rus: — 61; звук N: — 61; аудио #N украинский: — 41; аудио rus #N: — 33; аудио N · rus: — 20; аудио N#: — 19; аудио #N (rus): — 19; russian: — 17; • аудио №N: — 14; звук#N: — 13; русский (acN, N ch, N — 12; русская: — 11; • аудио #N русский: — 9; аудио-N: — 8; аудио #N russian: — 7; русский N (асN, N ch, — 6; аудио#N rus: — 6; • аудио N: — 5; аудио pic ukr: — 5; audio N (rus): — 5; аудио #N - (acN, N — 5
**недостающие студии: вид строки**: audio — 3240; trans — 496
**недостающие студии: топ**: HDrezka — 362; TVShows — 339; LostFilm — 211; Red Head Sound — 164; LeDoyen — 117; Jaskier — 101; MovieDalen — 89; Мосфильм-Мастер — 80; NewComers — 76; NewStudio — 71; Пифагор — 67; WinMedia — 64; Movie Dubbing — 63; Le-Production — 63; Postmodern — 61; Невафильм — 53; Кубик в Кубе — 48; Flarrow Films — 48; ViruseProject — 45; Дубляжная — 41; DniproFilm — 40; Синема УС — 35; SDI Media — 33; AlexFilm — 31; Custom — 31
**студии меток**: всего — 8462; нет в тексте/заголовке — 1
**форматы мимо: seg**: аудио: — 167; аудио : — 13; audio: — 4
**без меток**: обычный, есть строки дорожек — 594; обычный, нет строк дорожек — 15
**форматы мимо: codec**: аудио rus: — 662; аудио N: — 494; аудио #N: — 153; аудио ukr: — 26; аудиоN: — 9; аудио N. русский: — 7; аудио#N: — 6; аудио N.N: — 6; аудиодорожка N: — 6; аудио №N: — 3; аудио N. украински — 3; audio N: — 2; аудио N. украинский: — 1
**студии меток: нет в тексте, studio_source**: audio — 1
**мусор в studio меток**: источник: лицензия/Blu-ray/iTunes/Netflix — 245; «X по заказу Y» — 131; язык-префикс «Русский, - X» — 72; тип-слово — 45; каналы/кодек — 7; язык вместо студии — 7; субтитры — 7; неру дорожка — 4
<details><summary>примеры fmt:codec (1378)</summary>
- 2070202 · Аудио Rus: pic AC3, 48 kHz, 2 ch, 192 kbps \|HDRezka\|
- 1914764 · Аудио 06: Русский, AC-3, 48 kHz, 2 ch, 192 kb/s - Д. Пучков "Goblin" (2-5 сезоны)
- 2124461 · Аудио 7: Russian E-AC3 / 5.1 / 48 kHz / 1024 kbps \|Ю. Живов\| поздний
- 2158278 · Аудио Rus: AC3, 48 kHz, 2 ch, 192 kbps \|HDRezka\|
- 1630832 · Аудио #7: DTS-HD Master Audio Russian 7.1 / 48 kHz / 4232 kbps / 24-bit (DTS Core: 5.1 / 48 kHz / 1509 kbps / 24-bit) \| Юрий Немахов
- 1769927 · Аудио 3: AC3, 48 kHz, 2 ch, 192 kbps - Rus (HDRezka Studio)
- 2149720 · Аудио Rus: AC3, 48,0 kHz, 2 ch, 192 Кбит/сек \| AlexFilm
- 1795296 · Аудио #01: 48 kHz, DTS-HD MA, 5.1, 2239 Kbps \| Лицензия \| RUS \|
- 2105709 · Аудио Rus: AC3, 48,0 kHz, 6 ch, 384 Кбит/сек \|TVShows\|
- 2153009 · Аудио Rus: AC3, 48,0 kHz, 2 ch, 192 Кбит/сек \| Viju
- 1716051 · Аудио 4: Russian DTS / 5.1 / 48 kHz / 1509 kbps / 24 bits \|Ю. Живов\|
- 2145084 · Аудио Rus: AC3, 48,0 kHz, 2 ch, 192 Кбит/сек \| Red Head Sound
- 2005766 · Аудио 1: AC3, 2 ch, 192-384 kbps, русский (ПM HDRezka) (18+) без цензуры (1-10 серии)
- 1581690 · Аудио 4: Russian DTS-HD Master Audio / 7.1 / 48 kHz / 4829 kbps / 24-bit (DTS Core: 5.1 / 48 kHz / 1509 kbps / 24-bit) \|Ю. Сербин\|
- 1780718 · Аудио 6: Russian AC3 / 2.0 / 48 kHz / 192 kbps \|Д. Пучков aka Goblin\|
</details>
<details><summary>примеры fmt:seg (184)</summary>
- 2017336 · Аудио: E-AC3, 6 ch, 256 Kbps - Украинский Дублированный (Original)
- 2009954 · Аудио: AC3, 6 ch, 448 kbps \| русский (VO Александр Дасевич по переводу Groby)
- 1521470 · Аудио: Русский 48 kHz, (AC3), 6ch, 640 kbps [Профессиональный (полное дублирование)]
- 1991298 · Аудио: Е-AC3, 6 ch, 384 Kbps - Русский (DUB, LeDoyen)
- 1579711 · Аудио: Russian: 48 kHz, АС3, 2 ch, 192 kbps / 16-bit_\|Дубляж_[ТВ3]_\|_
- 2129647 · Аудио: Russian: AC3, 448 Kb/s (6 ch) \| DUB \| Марафон
- 2009954 · Аудио: AC3, 6 ch, 448 kbps \| русский (VO Вячеслав Замез по переводу Groby)
- 1481240 · Аудио: Russian / DTS Audio / 5.1 / 48 kHz / 768 kbps \| Дубляж, Blu-ray CEE
- 2138814 · Аудио: AC3, 48 kHz, 6 ch, ~384.00 kbps avg \| русский \| DUB \| WinMedia
- 1776297 · Аудио: 48 kHz, AC3, 5.1, 448 Kbps \| MVO \| RUS \|_Профессиональный (многоголосый, закадровый) \| HDRezka Studio_присутствует ненормативная лексика
- 1982638 · Аудио: Е-AC3, 6 ch, 640 Kbps Украинский профессиональный дублированный \| Postmodern (Netflix)- (8 из 8)
- 2158258 · Аудио: АC3, 48 kHz, 6 ch, ~640.00 kbps avg \| русский \| DVO \| ViruseProject \| 18+
- 2158258 · Аудио: АC3, 48 kHz, 2 ch, ~192.00 kbps avg \| русский \| DUB \| HDRezka Studio \| 18+
- 2149774 · Аудио: Dolby AC3 48000Hz 2ch 192kbps [rus DVO SDI Media]
- 2145264 · Аудио: АC3, 48 kHz, 2 ch, ~192.00 kbps avg \| русский \| DUB \| HDRezka Studio \| 18+
</details>
<details><summary>примеры fmt:seg codec (3000)</summary>
- 1704679 · Аудио: Русский, (AC3, 6 ch, 384 Кбит/с)
- 1504140 · Аудио: Русский (AC3, 6 ch, 640 Кбит/с), английский (DTS-HD MA, 8 ch, 2538 Кбит/с)
- 1046106 · Русский 2 (АС3, 6 ch, 448 Кбит/с) Профессиональный многоголосый [C.Р.И.]
- 1331558 · Аудио: Русский, английский (AC3, 6 ch, 448 Кбит/с)
- 1044555 · Аудио: Русский, английский (AC3, 2 ch, 192 Кбит/с), (AC3, 6 ch, 384 Кбит/с)
- 1658245 · Аудио: Русский (AAC, 2 ch, 192 Кбит/с)
- 1150580 · Аудио: Русский (АС3, 2 ch, 224 Кбит/с), английский (АС3, 2 ch, 192 Кбит/с)
- 1431453 · Аудио: Русский (AC3, 6 ch, 384 Кбит/с) английский (AC3, 2 ch, 192 Кбит/с)
- 1519187 · Аудио: Русский (AC3, 6 ch, 448 Кбит/с), английский (DTS-HD MA, 8 ch, 4090 Кбит/с)
- 1168919 · Аудио: Русский (AC3, 6 ch, 448 Кбит/с)
- 1634361 · Аудио: Русский (АС3, 6 ch, 384 Кбит/с), английский (английский, DTS-HD MA, 6 ch,1509 Кбит/с)
- 1122316 · Аудио: Русский, украинский, английский (АС3, 6 ch, 384 Кбит/с)
- 1681156 · Аудио: Русский (AC3, 2 ch, 192 Кбит/с), японский (AC3, 6 ch, 448 Кбит/с)
- 1449948 · Аудио: Русский (AC3, 6 ch, 448 Кбит/с), (DTS, 6 ch, 755 Кбит/с), украинский (AC3, 6 ch, 448 Кбит/с), английский (DTS, 6 ch, 1509 Кбит/с)
- 1663939 · Аудио: Русский (AC3, 2 ch, 192 Кбит/с), немецкий (AAC, 2 ch, 96 Кбит/с)
</details>
<details><summary>примеры ghost (1)</summary>
- 2010302 · TVShows · Russian E-AC3 / 5.1 / 48 kHz / 256 kbps \|MVO, TV Shows\|
</details>
<details><summary>примеры miss:audio (3240)</summary>
- 1899316 · Пифагор · Аудио #1 Русский: AC3, 48.0 kHz, 5.1 ch, 384 Kbps [Дубляж, Пифагор]
- 2149078 · LeDoyen · Аудио: AC3, 6 ch, 448 Kbps - Украинский (DUB, LeDoyen)
- 2147682 · Paragraph Media · Аудио #01: 48 kHz, AC3, 5.1, 384 Kbps \| Дублированный \| Paragraph Media \| RUS \|
- 2152575 · HTB · Аудио 2: AC3, 2 ch, 192 Kbps - русский (DVO HTB)
- 1981418 · HDrezka · Аудио RUS: АС3, 6 ch, 48 kHz, 448 kbps \|HDrezka Studio\|
- 2063569 · Jaskier · Аудио pic RUS: AC3, 6 ch, 48 kHz, 384 kbps \|Jaskier\|
- 1882053 · Ruscico · Аудио#1: Russian: 48 kHz, АС3, 2 ch, ~192.00 kbps avg \| Профессиональный (многоголосый, закадровый) [Сонотек, Ruscico]
- 2144862 · TVShows · Аудио 6: AC3, 6 ch, 384 Kbps - Русский (MVO, TVShows)
- 2157571 · Movie Dubbing · Аудио 1: 48.0 kHz, AC3, 6 ch, 384 Kbps - Русский (DUB, Movie Dubbing)
- 1811439 · AppleTV+ · Аудио #01: 48 kHz, E-AC3, 5.1, 768 Kbps \| Дублированный \| AppleTV+ (Пифагор) \| RUS \|
- 2146921 · Postmodern · Аудио 2: E-AC3, 6 ch, 640 Кбит/с - Украинский дубляж (Postmodern)
- 1810512 · Ю. Немахов · Аудио 10: Русский, АС3, 6 ch, 640 Кбит/с Одноголосый закадровый, Ю. Немахов
- 2151270 · СВЕТЛА · Аудио 1: AC3, 2 ch, 192 Kbps - русский (MVO Светла)
- 1984794 · DniproFilm · Аудио10: AC-3 • 5.1 • 384 kbps • MVO \| DniproFilm • UKR
- 2136518 · Red Head Sound · Аудио 6: AAC, 2 ch, 192 Kbps - Русский (MVO, Red Head Sound)
</details>
<details><summary>примеры miss:trans (496)</summary>
- 2138973 · AniStar · Озвучка: Любительский двухголосый \| AniStar (Mutsuko Air & Horie)
- 2094579 · Condor Films · Перевод: Дублированный \| Condor Films Studio (с цензурой)
- 2014244 · Кураж-Бамбей · Перевод #1: Профессиональный (одноголосый) (Кураж-Бамбей)
- 2153210 · SDI Media · Перевод #1. Профессиональный (многоголосый, закадровый) - SDI Media. Запись звука с т/к: василисса. Огромное спасибо soso4eg за релиз с переводом.
- 1937992 · HamsterStudio · Перевод: 1001cinema / HamsterStudio, присутствует ненормативная лексика.
- 2034410 · TVShows · Перевод: Профессиональный (многоголосый закадровый) (TVShows) (Flex)
- 2124828 · VSI Moscow · Перевод 1: Профессиональный (дублированный), русский (VSI Moscow)
- 2158761 · С. Визгунов · Перевод: Многоголосый профессиональный (РТР), авторский (С. Визгунов, В. Горчаков), одноголосый любительский (Сука-Падла)
- 2029926 · Невафильм · Перевод: Профессиональный (полное дублирование) [Невафильм]
- 1992132 · Инис · Перевод #2: Профессиональный двухголосый [Инис]
- 1914764 · HamsterStudio · Перевод 7: Русский, Профессиональный многоголосый закадровый - HamsterStudio
- 1860544 · Синема УС · Перевод: Любительский (многоголосый) ["Синема УС"]
- 2154508 · TVShows · Перевод: Дублированный (Red Head Sound), Многоголосый (NewComers, TVShows, LostFilm, HDRezka, Двухголосый (Кубик в кубе)
- 1805099 · Синема УС · Перевод: Любительский (двухголосый) ["Синема УС"]
- 2142169 · Megogo · Перевод №1 (русский): Дублированный \| Megogo
</details>
<details><summary>примеры noise:«X по заказу Y» (131)</summary>
- kz-2105683 · по заказу Амедиа · Перевод: Профессиональный многоголосый (Кравец-Рекордз), по заказу Амедиа (2025 год)
- kz-1796214 · Студия Кириллица по заказу Amedia · Перевод: Профессиональный многоголосый - Студия Кириллица по заказу Amedia.
- kz-2129097 · Дубляжная по заказу DEEP · Перевод: Дублированный (Дубляжная по заказу DEEP)
- kz-2158272 · AniLibria по заказу IVI · Перевод: Любительский (многоголосый закадровый) \| AniLibria по заказу IVI
- kz-2136502 · Force Media по заказу ИВИ · Перевод: Профессиональный (многоголосый закадровый) \| Force Media по заказу ИВИ \| Официальный
- kz-2150544 · Syncmer по заказу HBO · Перевод 1: Профессиональный (двухголосый закадровый) — Syncmer по заказу HBO
- kz-2127699 · Айгуль Шамсутдинова по заказу Digi Media · Озвучивание: Профессиональное (одноголосое) \| Айгуль Шамсутдинова по заказу Digi Media
- kz-1772489 · Greb & Creative по заказу Amedia · Перевод 1: профессиональный (многоголосый) Greb & Creative по заказу Amedia
- kz-1903750 · по заказу канала 2х2 · Перевод: Авторский (одноголосый) [Дмитрий Пучков] по заказу канала 2х2
- kz-2145848 · AniStar по заказу DEEP · Перевод: Любительский (многоголосый закадровый) \| AniStar по заказу DEEP \| Официальный
- kz-2006086 · студия Sigil по заказу Nickelodeon · Перевод: Дублированный \| студия «Sigil» по заказу Nickelodeon
- kz-1998690 · Novamedia по заказу Amedia · Перевод: Профессиональный (дублированный) Novamedia по заказу Amedia
- kz-2158114 · Дубляжная по заказу DEEP · Перевод: Дублированный \| Дубляжная по заказу DEEP
- kz-1475970 · По Заказу Дисней Россия · Озвучивание: Дублированное "Невафильм" (По Заказу Дисней Россия)
- kz-1791492 · Greb&CGC по заказу Amedia · Перевод: Профессиональный (многоголосый закадровый) - Greb&CGC по заказу Amedia.
</details>
<details><summary>примеры noise:источник: лицензия/Blu-ray/iTunes/Netflix (245)</summary>
- kz-2046269 · Лицензия · Перевод: Профессиональный многоголосый (Лицензия)
- kz-1868122 · Blu-ray RUS Лицензия · Перевод: Профессиональный (полное дублирование) \| Blu-ray RUS \| Лицензия
- kz-1876839 · iTunes · Перевод 1: Профессиональный (дублированный) iTunes
- kz-2083422 · лицензия BD · Перевод: Профессиональный (многоголосый закадровый) лицензия BD
- kz-2140559 · iTunes · Перевод 1: Профессиональный (дублированный) \| iTunes
- kz-2087781 · VSI Moscow по заказу Netflix · Перевод 1: Профессиональный (дублированный) VSI Moscow по заказу Netflix
- kz-1786227 · Пифагор по заказу Netflix · Перевод: Профессиональный (дублированный) Пифагор по заказу Netflix
- kz-1797177 · iTunes · Озвучивание: Дублированное \| iTunes
- kz-1990227 · Blu-ray CEE / Мост Видео · Перевод: Дублированный (Blu-ray CEE / Мост Видео)
- kz-1799697 · iTunes · Озвучивание: Дублированное \| iTunes
- kz-1901335 · iTunes · Озвучивание: Дублированное \| iTunes
- kz-1851685 · UHD Blu-Ray · Russian AC3 / 5.1 / 48 kHz / 640 kbps \|Dub, UHD Blu-Ray\|
- kz-1882179 · Akimbo Production iTunes · Перевод: Профессиональный (полное дублирование) \| Akimbo Production \| iTunes
- kz-2086101 · Пифагор по заказу Netflix · Перевод 1: Профессиональный (дублированный) Пифагор по заказу Netflix
- kz-1847720 · iTunes · Озвучивание: Дублированное \| iTunes
</details>
<details><summary>примеры noise:каналы/кодек (7)</summary>
- kz-1039053 · AC3, 6 ch, 448 Кбит/с · Перевод: Дублированный (AC3, 6 ch, 448 Кбит/с)
- kz-2156869 · AC3, 6 ch, 384 Кбит/с · Перевод 2: (AC3, 6 ch, 384 Кбит/с), Профессиональный многоголосый (TVShows)
- kz-2029179 · AC3, 6 ch, 448 Кбит/с · Озвучивание #1: Дублированное (неофициальное) [TVShows] - AC3, 6 ch, 448 Кбит/с
- kz-2029179 · AC3, 6 ch, 384 Кбит/с · Озвучивание #2: Профессиональное многоголосое [Jaskier \| 18 +] - AC3, 6 ch, 384 Кбит/с
- kz-2137517 · 192-224 kb/s, CBR VSI Moscow · Перевод: AC-3, 48.0 kHz, 2 ch (Stereo), 192-224 kb/s, CBR (Russian) \| Профессиональный (дублированный) \| VSI Moscow
- kz-2156869 · AC3, 2 ch, 192 Кбит/с · Перевод 1: (AC3, 2 ch, 192 Кбит/с), Профессиональный многоголосый (HDrezka Studio)
- kz-1824511 · DTS · Russian DTS-HD Master Audio / 5.1 / 48 kHz / 4265 kbps / 24-bit (DTS Core: 5.1 / 48 kHz / 1509 kbps / 24-bit) \|Дубляж, D
</details>
<details><summary>примеры noise:неру дорожка (4)</summary>
- kz-2124460 · Телеканал Qazaqstan · Kazakh AC3 / 1.0 / 48 kHz / 192 kbps \|Dub, Телеканал "Qazaqstan"\|
- kz-2124461 · Телеканал Qazaqstan · Kazakh AC3 / 1.0 / 48 kHz / 192 kbps \|Dub, Телеканал "Qazaqstan"\|
- kz-2035938 · Qazaqstan · Перевод 7: Профессиональный (дублированный) Qazaqstan
- kz-1081469 · Tannikka & 3opuH + Оригинальная английская дорожка · Перевод: Любительский (двухголосый, закадровый) Tannikka & 3opuH + Оригинальная английская дорожка
</details>
<details><summary>примеры noise:субтитры (7)</summary>
- kz-2070931 · Light Breeze по субтитрам tonyvika · Перевод: Любительский (двухголосый закадровый) Light Breeze по субтитрам tonyvika
- kz-2029956 · W³: voices по субтитрам ASIAN DRAGONS · Перевод 1: Любительский (двухголосый закадровый) W³: voices по субтитрам ASIAN DRAGONS
- kz-2024527 · Unicorn по субтитрам ФСГ Азалии · Перевод 1: Любительский (одноголосый закадровый) Unicorn по субтитрам ФСГ Азалии
- kz-1459417 · Расширенные места без русского озвучивания. Переведены русскими форсированными субтитрами. Включены по умолчанию · Озвучивание: Дублированное [iTunes] (Расширенные места без русского озвучивания. Переведены русскими форсированными субт
- kz-1633732 · О. Воротилин по субтитрам Zinovev · Перевод: Одноголосый закадровый О. Воротилин по субтитрам Zinov'ev
- kz-2058019 · den904 по субтитрам Катерины Яцук · Перевод: Одноголосый закадровый \| den904 по субтитрам Катерины Яцук
- kz-2156009 · Novamedia + субтитры на вырезанные цензурой места · Перевод: Профессиональный (многоголосый закадровый) Novamedia + субтитры на вырезанные цензурой места
</details>
<details><summary>примеры noise:тип-слово (45)</summary>
- kz-2097155 · полное дублирование · Перевод: Профессиональный (полное дублирование)
- kz-1577270 · полное дублирование · Перевод: Профессиональный (полное дублирование) [Лицензия]
- kz-2046404 · полное дублирование · Перевод: Профессиональный (полное дублирование)
- kz-1373383 · полное дублирование · Перевод: Профессиональный (полное дублирование)
- kz-1782714 · полное дублирование · Перевод: Профессиональный (полное дублирование) [iTunes]
- kz-1823534 · полное дублирование · Перевод: Профессиональный (полное дублирование) [iTunes]
- kz-1577726 · Дубляж · Перевод: Дубляж [Blu-ray RUS]
- kz-1806566 · полное дублирование · Перевод: Профессиональный (полное дублирование) [Пифагор]
- kz-2014726 · полное дублирование · Перевод: Профессиональный (полное дублирование) \| Официальный
- kz-2093319 · полное дублирование · Перевод: Профессиональный (полное дублирование)
- kz-2127996 · полное дублирование · Перевод: Профессиональный (полное дублирование) \| Официальный
- kz-1528391 · полное дублирование · Перевод: Профессиональный (полное дублирование) [Лицензия Blu-ray CEE]
- kz-1598461 · полное дублирование · Перевод: Профессиональный (полное дублирование)
- kz-2029926 · полное дублирование · Перевод: Профессиональный (полное дублирование) [Невафильм]
- kz-1748299 · полное дублирование · Перевод: Профессиональный (полное дублирование)
</details>
<details><summary>примеры noise:язык вместо студии (7)</summary>
- kz-2036420 · Русский · АС-3, 6 ch, 384 kbps \| Русский, MVO \| TVShows \|
- kz-1650921 · Английские · Перевод: Английские
- kz-1261226 · украинский · украинский профессиональный многоголосый [1+1
- kz-1872823 · Английский · Перевод 3: Английский дублированный
- kz-2124828 · русский · Перевод 1: Профессиональный (дублированный), русский (VSI Moscow)
- kz-2124828 · украинский · Перевод 2: Профессиональный (дублированный), украинский (Le Doyen)
- kz-1107366 · английский · Перевод: профессиональный многоголосый, R5 + оригинал (английский)
</details>
<details><summary>примеры noise:язык-префикс «Русский, - X» (72)</summary>
- kz-2136993 · Русский, - AlexFilm · Перевод 1: Русский, профессиональный многоголосый закадровый - AlexFilm
- kz-2136993 · Русский, - AlexFilm · Перевод 1: Русский, профессиональный многоголосый закадровый - AlexFilm
- kz-1986288 · Русский, - The Kitchen Russia · Перевод 2: Русский, дублированный - The Kitchen Russia
- kz-1986288 · Русский, - The Kitchen Russia · Перевод 2: Русский, дублированный - The Kitchen Russia
- kz-1914764 · Русский, - HDRezka · Перевод 5: Русский, Профессиональный многоголосый закадровый - HDRezka Studio
- kz-1986288 · Русский, - Good People · Перевод 1: Русский, профессиональный многоголосый закадровый - Good People
- kz-2136993 · Украинский, - Цікава Ідея · Перевод 3: Украинский, профессиональный многоголосый закадровый - Цікава Ідея
- kz-2136993 · Русский, - AlexFilm · Перевод 1: Русский, профессиональный многоголосый закадровый - AlexFilm
- kz-2136993 · Русский, - TVShows · Перевод 2: Русский, профессиональный многоголосый закадровый - TVShows
- kz-1202498 · Русские, английские · Перевод: Русские, английские
- kz-1996184 · Русский, - Red Head Sound · Перевод 1: Русский, дублированный - Red Head Sound
- kz-2136993 · Украинский, - Цікава Ідея · Перевод 3: Украинский, профессиональный многоголосый закадровый - Цікава Ідея
- kz-2081511 · Русские, английские · Перевод: Русские, английские
- kz-1914764 · Русский, - IdeaFilm · Перевод 6: Русский, Профессиональный многоголосый закадровый - IdeaFilm
- kz-1986288 · Русский, - TVShows · Перевод 3: Русский, профессиональный многоголосый закадровый - TVShows
</details>
<details><summary>примеры unlab (594)</summary>
- 2014296 · Гардемарины 1787 (Дилогия) / 2020-2023 / РУ, СТ / WEB-DLRip · Аудио: Русский (MP3, 2 ch, 192 Кбит/с)
- 1018580 · Орда / 2012 / РУ / BDRip (AVC) · Аудио: Русский (AC3, 6 ch, 448 Кбит/с)
- 1604585 · Большой (Расширенная ТВ-версия) (1-2 серии из 2) / 2016 / РУ / HDTV (1080i) · Аудио: Русский (MP2, 2 ch, 192 Кбит/с)
- 2148998 · Где моё кольцо? (Операция "Гүлдер") / 2020 / РУ / WEB-DL (1080p) · Аудио: Русский (AAC, 2 ch, 192 Кбит/с)
- 287608 · На краю стою (Гульфара) / 2008 / РУ / DVD-9 · Аудио: Русский (Dolby AC3, 6 ch), (DTS, 5 ch)
- 2128892 · Без предметов (Без объектов) / Sans objets / 2020 / БП / WEB-DL (1080p) · Перевод: Не требуется
- 861037 · Тихая семейная жизнь (2 серии из 2) / 2008 / РУ / DVD-9 · Аудио: Русский (Dolby AC3, 6 ch)
- 2088285 · Красный карандаш (Красный карандашик) / Sartulis / 2014 / БП / WEBRip · Перевод: Не требуется
- 1508276 · Взломать блогеров / 2016 / РУ / WEB-DL (720p) · Аудио: Русский (AC3, 6 ch, 384 Кбит/с)
- 2158475 · Третий удар (Южный узел) / 1948 / РУ, СТ / DVDRip · Аудио: Русский (AC3, 2 ch, 256 Кбит/с)
- 1206915 · Рихард Вагнер - Тангейзер (Большой театр Лисео) / Richard Wagner - Tannhauser (G · Перевод: Не требуется
- 2153202 · Тимур и его команда / 1940 / РУ, СТ / IPTV · Аудио: Русский (AAC, 2 ch, 125 Кбит/с)
- 715445 · Адмиралъ / 2008 / РУ / BDRip · Аудио: Русский (AC3, 6 ch, 448 Кбит/с)
- 2141802 · Марк и Галя (1 сезон: 1-5 серии из 5) / 2023-2024 / БП / WEB-DL (1080p) · Перевод: Не требуется
- 2142544 · Эпические приключения Морфа (1 сезон: 1-15 серии из 15) / The Epic Adventures of · Перевод: Не требуется
</details>
## nnm
**releases**: всего — 13677; с метками — 10085; без меток — 3592; с метками, есть недостающая студия — 1295; с метками без студий, студия есть в кодек-строке — 653
**строки**: audio — 15151; trans — 12541
**без меток**: обычный, есть строки дорожек — 1987; обычный, нет строк дорожек — 1605
**кодек-строки**: RE_AUDIO_TRACK | codec_segments — 10077; мимо RE_AUDIO_TRACK | мимо codec_segments — 2472; RE_AUDIO_TRACK | мимо codec_segments — 2224; мимо RE_AUDIO_TRACK | codec_segments — 378
**недостающие студии: вид строки**: trans — 1029; audio — 1003
**недостающие студии: топ**: LeDoyen — 69; Postmodern — 57; НТВ+ — 54; HDrezka — 53; 1+1 — 49; Так Треба Продакшн — 43; TVShows — 39; Советский дубляж — 37; ОРТ — 35; НТВ — 35; заКАДРЫ — 32; А. Михалёв — 29; А.Михалев — 28; СВ-Дубль — 28; Михалёв — 25; Ю.Живов — 24; DniproFilm — 24; LostFilm — 23; Юрий Живов — 22; Пифагор — 22; А.Гаврилов — 21; Андрей Гаврилов — 20; Киномания — 20; Le Doyen — 20; RHS — 20
**форматы мимо: seg codec**: russian (dolby acN, N — 1483; russian, acN, N/N (l, — 308; аудио: — 199; аудио (rus): — 68; ukrainian (dolby acN, N — 58; audio: — 53; russian (dts, N ch) — 35; russian acN, N khz, — 31; аудио N rus: — 25; аудио #N rus: — 21; русский: — 14; аудио »N: — 10; аудио#№N: — 9; аудио #N (rus): — 9; аудио (русский): — 9; аудио дорожка N: — 8; аудио rus(int) N: — 6; russian, dts, N/N (l, — 5; звук #N: — 5; звуковая дорожка N: — 5; аудио #N (русс): — 5; rus: — 4; ukrainian, acN, N/N (l, — 4; audio(N): — 4; аудиокодек: — 3
**форматы мимо: codec**: аудио N: — 1582; аудио #N: — 316; аудио#N: — 132; аудио rus: — 74; аудио №N: — 40; аудиоN: — 30; audio #N: — 20; аудиодорожка N: — 13; аудио # N: — 4; аудио N : — 4; audio N: — 4; аудио№N: — 2; аудио #N. acN, N ch — 2; аудио rus : — 1
**студии меток**: всего — 10003; нет в тексте/заголовке — 22
**форматы мимо: seg**: аудио: — 370; аудио N: — 8
**студии меток: нет в тексте, studio_source**: release — 20; tech — 2
**мусор в studio меток**: источник: лицензия/Blu-ray/iTunes/Netflix — 590; каналы/кодек — 89; «X по заказу Y» — 67; тип-слово — 41; язык вместо студии — 26; субтитры — 7; неру дорожка — 6
<details><summary>примеры fmt:codec (2224)</summary>
- 649043 · Аудио #3: MP3, 44.1 KHz, 2 ch, 128 Kbps, CBR (Русский)
- 626763 · Аудио #1: Русский (MP3, 2ch, 48 kHz, 128 kb/s)
- 1422226 · Аудио#1: AC3, 2 ch, 192 Kbps \| Русский
- 1728373 · Аудио #1: MP3, 2 ch, 128 Kbps (Русский) 18+ (Ненормативная лексика!)
- 655684 · Аудио 1: Dolby AC3, 48000Hz, 6ch, 384Kbps (русский)
- 1272634 · Аудио #3: AC3, 6 ch, 384 kbps - русский (АVO, Ю. Живов)
- 1735316 · Аудио 1: AC3, 2 ch, 192 Kbps, Русский, (Movie Dubbing / Bravo Records Georgia)
- 1292412 · Аудио #1: AC3, 2 ch, 192 Kbps \| Русский HDRezka
- 1771613 · Аудио 4: Е-AC3, 8 ch, 1536 Kbps, русский (АVO - В. Дохалов)
- 892406 · Аудио #1: AC3, 48.0 kHz, 2 ch, 192 kbps (Русский)
- 1608297 · Аудио 2: AAC, 48.0 KHz, 192 kbps, 2 ch (Русский, AniLibria.TV)
- 1439239 · Аудио1: AC3, 6 ch, 640 Kbps - Русский (LostFilm)
- 220287 · Аудио 1: (rus) 48 kHz, AC3 Dolby Digital, 1/0 (C) ch, 192 kbps avg
- 1222018 · Аудио #1: AC-3, 2 ch, 192 Kbps \| Русский
- 1418749 · Аудио#1: AC3, 2 ch, 192 Kbps \| Русский
</details>
<details><summary>примеры fmt:seg (378)</summary>
- 1009049 · Аудио: Русский: FLAC, 2 ch, 652 kbps, 48,0 КГц, 16 bit MVO Mediainfo:
- 482371 · Аудио: (Dolby AC3, 2 ch) ~192 kb/s \|Русский,одноголосый закадровый\|
- 1197476 · Аудио: 1 AC3, 2 ch, 192 kbps - Русский, многоголосый (ТК "Домашний")
- 453252 · Аудио: Dolby Digital Audio 5.1 / Русский / 640 Кбит/сек / 48 kHz (DUB)
- 1152863 · Аудио: AC3, 2 ch, 192 Kbps - русский, многоголосый закадровый Mediainfo:
- 1596114 · Аудио: AC3, 2 ch, 192 kbps - Русский (Дублированный)
- 553342 · Аудио: AC3, 6 ch, 384 kb/s - Русский, многоголосый
- 1054025 · Аудио: AC3, 6 ch, 640 kbps - Русский, Дубляж
- 1121293 · Аудио: 1 AC3, 6 ch, 384 kbps - Русский дубляж
- 1339316 · Аудио: MP3, 2 ch, 192 Kbps - Русский (MVO) MediaInfo:
- 1651071 · Аудио 5: TrueHD + Atmos, 8 ch, ~3661 Kbps - Українська (MVO, HDRezka Studio)
- 1196431 · Аудио: AC3, 6 ch, 384 kbps - Украинский, Дубляж
- 603650 · Аудио: (Dolby AC3, 2 ch) ~192 kb/s \|Русский, многоголосый закадровый\|
- 1432165 · Аудио: AC3, 6 ch, 384 Kbps - Русский, дубляж (iTunes) MediaInfo:
- 1564969 · Аудио: AC3, 2 ch, 192 Kbps - Русский (MVO, TVShows) MediaInfo:
</details>
<details><summary>примеры fmt:seg codec (2472)</summary>
- 961845 · Аудио #1 Rus: AC3, 2 канала, 384 Кбит/сек, CBR - многоголосый закадровый
- 464967 · Russian (Dolby AC3, 2 ch)
- 624704 · Russian (Dolby AC3, 6 ch)
- 1655275 · Russian, AC3, 2/0 (L,R) ch, 192 kbps, Delay 0 mSec
- 1617406 · Russian (Dolby AC3, 2 ch)
- 649787 · Russian (Dolby AC3, 2 ch)
- 597243 · Russian (Dolby AC3, 6 ch)
- 635903 · Russian (Dolby AC3, 2 ch)
- 643340 · Russian (Dolby AC3, 2 ch)
- 419146 · Russian (Dolby AC3, 2 ch)
- 1457385 · Russian (Dolby AC3, 2 ch)
- 1745806 · Аудио: AAC LC, 2 ch, 48.0 kHz, 192 kbps \| Звук: Русский
- 1514814 · Russian (Dolby AC3, 2 ch)
- 449635 · Russian, AC3, 2/0 (L,R) ch, 192 kbps, Delay 0 mSec
- 338450 · Аудио дорожка 1: 48,0 КГц, AC3, 192 Кбит/сек, 2 канала, русский, полное дублирование - СССР
</details>
<details><summary>примеры ghost (22)</summary>
- 1887058 · НТВ СТС · Аудио 4: AC3, 2 ch, 192 Kbps - русский (MVO НТВ \| СТС)
- 1701274 · ТК ОРТ · Аудио 07: AC3, 2 ch, 192 Kbps - Русский (MVO, ТК "ОРТ")
- 1887072 · НТВ СТС · Аудио 4: AC3, 2 ch, 192 Kbps - русский (MVO НТВ \| СТС)
- 1700762 · ТК СТС · Аудио 03: AC3, 2 ch, 192 Kbps - Русский (MVO, ТК "СТС")
- 1720458 · ТК НТВ+ · Аудио 5: AC3, 2 ch, 192 Kbps - Русский (MVO, ТК "НТВ+")
- 1671752 · TVShows · Аудио 7: AС3, 6 ch, 640 kbps - Русский, MVO (TV Shows)
- 1723189 · ТК СТС · Аудио 06: AC3, 2 ch, 192 Kbps - Русский (MVO, ТК "СТС")
- 1700762 · ТК НТВ · Аудио 06: AC3, 2 ch, 192 Kbps - Русский (MVO, ТК "НТВ")
- 1883291 · НТВ ТНТ Ren-TV · Аудио 2: AC3, 2 ch, 192 Kbps \| Русский, MVO (НТВ \| ТНТ \| Ren-TV)
- 1721934 · ТК ОРТ · Аудио 07: AC3, 2 ch, 192 Kbps - Русский (MVO, ТК "ОРТ")
- 1671752 · TVShows · Аудио 6: AС3, 6 ch, 384 kbps - Русский, MVO (TV Shows) - псевдо-дубляж
- 1723189 · ТК НТВ+ · Аудио 08: AC3, 2 ch, 192 Kbps - Русский (MVO, ТК "НТВ+")
- 1701066 · ТК ОРТ · Аудио 12: AC3, 2 ch, 192 Kbps - Русский (MVO, ТК "ОРТ")
- 1700762 · ТК ОРТ · Аудио 04: AC3, 2 ch, 192 Kbps - Русский (MVO, ТК "ОРТ")
- 1721934 · ТК РТР · Аудио 08: AC3, 2 ch, 192 Kbps - Русский (MVO, ТК "РТР")
</details>
<details><summary>примеры miss:audio (1003)</summary>
- 1763763 · TVShows · Аудио 2: AC-3, 2 ch, 48.0 kHz, 192 kbps \| Звук: Русский дубляж [TVShows]
- 1541528 · Инис · Аудио 2: AC3, 2 ch, 192 Kbps (русский, многоголосый - студия ИНИС)
- 1807127 · DniproFilm · Аудио 10: AC3, 6 ch, 640 Kbps - Украинский (MVO - DniproFilm)
- 1328029 · Film Prestige · Аудио 1: AC3, 2 ch, 224 Kbps (русский - Film Prestige)
- 1520154 · Королев · Аудио:5 DTS XLL, 8 ch, 3898 Kbps - Русский, AVO (В.Королёв)
- 857991 · П. Гланц и Т. Казакова · Аудио 2: Русский AC3 5.1, 384 Kbps, 48.0 KHz (Двуголосый) П. Гланц и Т. Казакова
- 1381272 · ОРТ · Аудио #2: AC3, 48 kHz, 6 ch, 448 kbps - Русский, Dub \| ОРТ
- 1771912 · 1+1 · Аудио5: AC3, 2 ch, 256 Kbps - Украинский, DVO (1+1)
- 1780225 · Postmodern · Аудио 5: Е-AC3, 2 ch, 256 Kbps - Украинский (DUB, Postmodern)
- 872505 · Кашкин · Аудио 5: Русский 48 kHz/24-bit, DTS-HD MA, 6 каналов ~4129 kbps \|Авторский, Кашкин (Первомайский)\|
- 1741989 · LeDoyen · Аудио 5: AC3, 6 ch, 384 Kbps - Украинский (DUB, LeDoyen)
- 1499836 · А. Михалёв · Аудио 7: DTS, 6 ch, 1509 Kbps (русский, одноголосый - А. Михалёв)
- 1888656 · LeDoyen · Аудио 6: E-AC3, 6 ch, 640 Kbps - Украинский (DUB, LeDoyen)
- 1872532 · Postmodern · Аудио 2: AC3, 6 ch, 640 Кbps - Украинский (Dub, Postmodern)
- 949884 · Л.Володарский · Аудио #2: AC-3, 48.0 KHz, 2 ch, 192 Kbps, CBR (Russian) - авторский Л.Володарский
</details>
<details><summary>примеры miss:trans (1029)</summary>
- 1887475 · НТВ+ · Перевод: профессиональный многоголосый (Светла), профессиональный двухголосый (НТВ+)
- 306718 · Союзмультфильм · Перевод: Дублированный [Союзмультфильм]
- 1801712 · ICTV · Перевод: дублированный неофициальный (Мост-Видео), профессиональный многоголосый (Novamedia, НТВ, Fox Crime, ICTV), авторский (Антон Карповский, Юрий Сербин, Дм
- 241721 · Гланц · Автор перевода: Петр Гланц
- 1891998 · СВ-Дубль · Перевод: двуголосый профессиональный (СВ-Дубль)
- 1896364 · Postmodern · Перевод: Дублированный (Movie Dubbing, укр. Postmodern)
- 897659 · СВЕТЛА · Перевод: Двуголосый (Светла), Одноголосый (Г.Либергал)
- 1181555 · LostFilm · Перевод: Многоголосый, профессиональный - NewStudio, LostFilm
- 1180186 · BadBajo · Перевод: Одноголосый авторский, А.Важенин (BadBajo)
- 541044 · Zamez · Перевод: Любительский (одноголосый) [zamez]
- 1888887 · Так Треба Продакшн · Перевод: Дублированный (Movie Dubbing, укр. Так Треба Продакшн)
- 434267 · Позитив · Перевод: Профессиональный (двухголосый, закадровый) НТВ+, профессиональный (многоголосый, закадровый) Позитив, авторский (одноголосый, закадровый) Алексей Михал
- 1884985 · AniLiberty · Перевод: AniLiberty, Sanae, Alvakarp
- 848518 · Нева-1 · Перевод: Советский Дубляж + Дубляж от "Нева-1" + Многоголосый
- 1872380 · Так Треба Продакшн · Перевод: Дублированный (Videofilm Int., укр. Так Треба Продакшн)
</details>
<details><summary>примеры noise:«X по заказу Y» (67)</summary>
- nnm-1867973 · по заказу Смотри кино · Аудио 2: AC3, 2 ch, 192 Kbps - Русский (MVO, по заказу "Смотри кино" 18+)
- nnm-1878037 · Paragraph Media по заказу Capella Film · Аудио 1: AC3, 2 ch, 192 Kbps - русский (MVO Paragraph Media по заказу Capella Film)
- 1875813 · СВ-Дубль по заказу ТК Культура · Аудио 04: AC3, 2 ch, 192 Kbps - Русский, DVO (СВ-Дубль по заказу ТК Культура)
- nnm-1379089 · студия Novamedia по заказу Amedia · Перевод: Многоголосый профессиональный (студия Novamedia по заказу Amedia), Aвторский (Ю. Сербин)
- 1229051 · студия Кравец по заказу Amedia · Перевод: Дублированный - студия "Кравец" по заказу Amedia
- 1854965 · студия TrueDubbing Studio по заказу Paramount Comedy · Аудио 3: AC3, 2 ch, 192 Kbps - Русский, MVO (студия "TrueDubbing Studio" по заказу Paramount Comedy)
- nnm-1499746 · по заказу Amedia · Аудио 1: AC3, 6 ch, 384 Kbps, русский (по заказу Amedia)
- 1865453 · студия TrueDubbing Studio по заказу Paramount Comedy · Аудио 2: AC3, 2 ch, 192 Kbps - Русский, MVO (студия "TrueDubbing Studio" по заказу Paramount Comedy)
- nnm-1087457 · 6 Канал, по заказу СТС · Аудио#3: AC3, 2 ch, 192 Kbps \| Русский - MVO (6 Канал, по заказу СТС)
- 1854965 · студия Нота по заказу РТР · Аудио 2: E-AC3, 2 ch, 224 Kbps - Русский, MVO (студия "Нота" по заказу РТР)
- nnm-806894 · студия NovaMedia по заказу Amedia · Перевод: Профессиональный, многоголосый закадровый - студия NovaMedia по заказу Amedia
- nnm-1797122 · Лексикон по заказу Гала Медиа · Аудио 1: AC3, 2 ch, 192 Kbps \| русский (MVO Лексикон по заказу Гала Медиа)
- nnm-1896805 · Селена Интернешнл по заказу ОРТ · Аудио 3: AC3, 2 ch, 192 Kbps - Русский, DVO (Селена Интернешнл по заказу ОРТ)
- nnm-1896063 · Велес по заказу CP Digital · Аудио 2: AC3, 2 ch, 224 Kbps - русский (DVO Велес по заказу CP Digital (2008 г., Станислав Войнич и Светлана Репетина))
- 1896444 · Студия Нота по заказу РТР · Аудио 04: AC3, 2 ch, 192 Kbps - Русский, MVO (Студия Нота по заказу РТР)
</details>
<details><summary>примеры noise:источник: лицензия/Blu-ray/iTunes/Netflix (500)</summary>
- nnm-1437447 · iTunes · Аудио 1: AC3, 6 ch, 384 kbps - Русский (DUB \| iTunes)
- nnm-1807653 · Blu-ray · Аудио 1: AC3, 6 ch, 640 Kbps русский ( DUB, Blu-ray)
- nnm-1525970 · iTunes · Аудио 1: AC3, 6 ch, 384 kbps - Русский (DUB, iTunes)
- nnm-1372998 · Blu-Ray · Аудио 01: DTS-HD MA, 8 ch, 3091 Kbps - Русский (DUB, Blu-Ray)
- nnm-1641984 · Лицензия · Аудио 1: AC3, 6 ch, 640 Kbps - Русский, Дубляж (Лицензия)
- nnm-1515035 · Blu-Ray CEE · Аудио 2: AC3, 6 ch, 448 Kbps - Русский (DUB \| Blu-Ray CEE)
- nnm-1026540 · iTunes · Аудио: AC3, 6 ch, 384 kbps, CBR, Русский (iTunes)
- nnm-1691690 · Blu-Ray CEE · Аудио 1: AC3, 6 ch, 640 Kbps - Русский (Dub, Blu-Ray CEE)
- nnm-1532438 · CPI Films, iTunes · Аудио 1: AC3, 6 ch, 384 Kbps - Русский (Dub CPI Films, iTunes)
- nnm-1098424 · iTunes · Аудио 1: AC3 5.1 384 Кбит/сек - Русский (iTunes)
- nnm-1404235 · BD CEE · Аудио 1: AC3, 6 ch, 640 kbps, Русский (DUB BD CEE)
- nnm-1635787 · BD CEE · Аудио 1: E-AC3, 6 ch, 448 kbps - Русский (Dub,BD CEE)
- nnm-1571635 · BD EUR · Аудио 1: AC3, 6 ch, 640 kbps - Русский (Dub, BD EUR)
- nnm-1607165 · BD EUR · Перевод: Дублированный (BD EUR)
- nnm-1312426 · Лицензия · Аудио 1: AC3, 6 ch, 640 Kbps - Русский (DUB, Лицензия)
</details>
<details><summary>примеры noise:каналы/кодек (89)</summary>
- nnm-511174 · L,R · Аудио 1: Русский: 48 kHz, AC3, 2/0 (L,R), ~384.00 kbps avg \|Дубляж, Видеосервис\|
- nnm-388165 · L, C, R, l, r · Аудио: Русский, AC3, 48 kHz, 6 каналов, 448 kbps, 16 bit, (L, C, R, l, r) + LFE \|Гоблинский, Многоголосный\|
- nnm-505307 · AC3, 2 ch, 192 kb/s · Аудио #3: Русский ( AC3, 2 ch, 192 kb/s ) (одноголосный)
- nnm-849036 · L,C,R,l,r · Аудио 2: Русский 48 kHz, AC3, 3/2 (L,C,R,l,r) + LFE ch, 640 kbps avg \| Многоголосый, Superbit
- nnm-471722 · L,C,R,l,r · Аудио#1: Русский: 48 kHz, AC3, 3/2 (L,C,R,l,r) + LFE ch, ~640 kbps avg \|Дубляж, Blu-ray CEE\|
- nnm-320358 · AC3, 6 ch, 448 kb/s · Аудио #1: Русский (AC3, 6 ch, 448 kb/s)
- nnm-492468 · L, R · Аудио 2: Русский, AC-3, 192 kbps, 2 канала, 16 bit, (L, R) - Многоголосый
- nnm-508334 · Dolby AC3, 6 ch · Аудио 2: Русский (Dolby AC3, 6 ch) 384 kb/s - Многоголосый,профессиональный - 20th Century Fox MI
- nnm-494281 · L, C, R, l, r · Аудио 1: Русский, DTS, 755 kbps, 6 каналов, 24 bit, (L, C, R, l, r) + LFE
- nnm-300455 · DTS, 6 ch, 755 kb/s · Аудио #3: Русский (DTS, 6 ch, 755 kb/s) одноголосый закадровый - Гаврилов
- nnm-374286 · AC3, 6 ch, 384 kb/s · Аудио #2: Русский ( AC3, 6 ch, 384 kb/s )
- nnm-380475 · L,C,R,l,r · Аудио#3: Русский: 48 kHz, AC3, 3/2 (L,C,R,l,r) + LFE ch, ~384.00 kbps avg \|Дубляж, R5\|
- nnm-356829 · AC3, 6 ch, 384 kb/s · Аудио #2: Русский ( AC3, 6 ch, 384 kb/s )
- nnm-1057579 · L,R · Аудио#1: Русский - 48 kHz, AC3, 2/0 (L,R) ch, 192 kbps avg
- nnm-369518 · AC3, 6 ch, 448 kb/s · Аудио #2: Русский (AC3, 6 ch, 448 kb/s)
</details>
<details><summary>примеры noise:неру дорожка (6)</summary>
- nnm-29325 · песни на английском языке · Перевод: Многоголосый профессиональный (песни на английском языке)
- nnm-1784983 · Казахфильм · Перевод: Дублированный (Казахфильм)
- nnm-1571754 · Cinema Tone Production, Казахстан · Аудио 1: AC-3, 2 ch, 192 Kbps - Русский (DUB \| Cinema Tone Production, Казахстан)
- nnm-1572189 · Cinema Tone Production, Казахстан · Аудио 1: AC-3, 2 ch, 192 Kbps - Русский (DUB \| Cinema Tone Production, Казахстан)
- nnm-51930 · слышен английский язык · Перевод: Профессиональный многоголосый (слышен английский язык)
- nnm-1450412 · Казахский язык · Перевод: Дублированный (Казахский язык)
</details>
<details><summary>примеры noise:субтитры (7)</summary>
- nnm-1843999 · Мика Бондарик, по субтитрам Lisok · Аудио 1: AC3, 2 ch, 192 Kbps \| русский (VO Мика Бондарик, по субтитрам Lisok)
- nnm-1894229 · Мика Бондарик, по субтитрам Lisok · Аудио 1: AC3, 2 ch, 192 Kbps - русский (VO Мика Бондарик, по субтитрам Lisok)
- nnm-1894835 · А. Наследников по субтитрам lobo · Аудио 1: AC3, 2 ch, 192 Kbps - русский (VO А. Наследников (MrRose) по субтитрам lobo)
- nnm-1894859 · А. Наследников по субтитрам lobo · Аудио 1: AC3, 2 ch, 192 Kbps - русский (VO А. Наследников (MrRose) по субтитрам lobo)
- nnm-1622631 · Andre1288 по субтитрам Антона Каптелова · Аудио 1: AC3, 2 ch, 192 kbps \| русский (VO Andre1288 по субтитрам Антона Каптелова)
- nnm-1874776 · Матвей Кенс по субтитрам Silken · Аудио 1: AC3, 2 ch, 192 Kbps - русский (VO Матвей Кенс по субтитрам Silken)
- nnm-1894210 · Мика Бондарик, по субтитрам Lisok · Аудио 1: AC3, 2 ch, 192 Kbps - русский (VO Мика Бондарик, по субтитрам Lisok)
</details>
<details><summary>примеры noise:тип-слово (41)</summary>
- nnm-857721 · Двуголосый · Аудио 4: Русский AC3 5.1, 384 Kbps, 48.0 KHz (Двуголосый) П. Гланц и Т. Казакова
- nnm-114327 · дубляж · Аудио1: Русский AC3 5.1, 48 KHz, 384 kbps (дубляж)
- nnm-389317 · Дубляж · Аудио 1: DTS Audio, Русский, 768 kbps, 5.1 / 48 kHz / 768 Кбит/сек / 24-bit (Дубляж)
- nnm-1365221 · Дубляж · Аудио 1: AC3, 6 ch, 448 Kbps - Русский (Дубляж)
- nnm-1457626 · дубляж · Аудио #1: AC3, 6 ch, 384 kbps - русский (дубляж)
- 1342511 · дубляж · Аудио #1: AC3, 6 ch, 640 kbps - русский (дубляж)
- nnm-1553559 · дубляж · Аудио1: AC3, 6 ch, 48.0 KHz, 448 Kbps \| русский (дубляж) [Студийная Банда]
- nnm-967458 · Двуголосый · Аудио 4: Русский AC3 5.1, 384 Kbps, 48.0 KHz (Двуголосый) Tycoon-Studio
- nnm-1650999 · дубляж · Аудио 1: AC3, 2 ch, 192 Kbps - русский (дубляж)
- nnm-1538061 · дубляж · Аудио #1: АС3, 6 ch, 384 kbps - русский (дубляж)
- nnm-312080 · полное дублирование · Аудио 2: DTS, 6 ch, 768Kbps, 48.0khZ Русский профессиональный (полное дублирование)
- nnm-821242 · Полное дублирование · Аудио 1: Русский AC3 5.1, 640 Kbps, 48.0 KHz (Полное дублирование)
- nnm-1181270 · дубляж · Аудио #1: AC3, 48.0 KHz, 6 ch, 384 Kbps, CBR - Русский (дубляж)
- nnm-997923 · Двуголосый · Аудио 3: Русский AC3 5.1, 384 Kbps, 48.0 KHz (Двуголосый) П. Гланц и Т. Казакова
- nnm-1578209 · Дубляж · Аудио #1: AC3, 6 ch, 448 Кбит/с, русский (Дубляж)
</details>
<details><summary>примеры noise:язык вместо студии (26)</summary>
- nnm-506684 · Испанский · (Dolby AC3, 2 ch) ~192 kb/s \|Испанский,дублированный\|
- nnm-268206 · английский · Перевод: Дублированный + Оригинальный (английский,отдельно)
- nnm-1074532 · украинский · Перевод: Дублированный (украинский)
- nnm-506333 · Испанский · (Dolby AC3, 2 ch) ~192 kb/s \|Испанский,дублированный\|
- nnm-528788 · Испанский · (Dolby AC3, 2 ch) ~192 kb/s \|Испанский, дублированный\|
- nnm-537011 · Французский · (Dolby AC3, 2 ch) ~192 kb/s \|Французский, дублированный\|
- nnm-534886 · Испанский · (Dolby AC3, 2 ch) ~192 kb/s \|Испанский, дублированный\|
- nnm-701414 · русский · Аудио 1: AC3; 192 Kbps; 48 kHz; 2 ch [Русский Репортаж (русский)]
- nnm-537011 · Испанский · (Dolby AC3, 2 ch) ~192 kb/s \|Испанский, дублированный\|
- nnm-649658 · Французский · (Dolby AC3, 2 ch) ~192 kb/s \|Французский, дублированный\|
- nnm-536934 · Французский · (Dolby AC3, 1 ch) ~192 kb/s \|Французский, дублированный\|
- nnm-452881 · Русский · (Dolby AC3, 6 ch) ~384 kb/s \|Русский,дублированный\|
- nnm-587730 · Украинский · Перевод: Дублированный (Русский) + Дублированный (Украинский)
- 1839838 · Украинский · Перевод: Профессиональный многоголосый (Syncmer), Дублированный (Украинский)
- nnm-372312 · английский · Перевод: Профессиональный (двухголосый, закадровый) R.U.S.C.I.C.O / Оригинальный (английский) + Профессиональный (многог
</details>
<details><summary>примеры unlab (1987)</summary>
- 429630 · Симпсоны / The Simpsons (1989 –2012) HDTVRip [720p], TVRip (23 сезона 510 серии · Перевод:
- 1396241 · Marmalade Boy \| Мальчик-мармелад [1994, TV, 76 из 76] BDRip 960p raw · Перевод: Жанна Тачёва, Tiona, KelendrosS
- 1849320 · Umibe no Etranger \| The Stranger by the Shore \| Незнакомец на берегу моря [2020, · Аудио 1: AC-3, RUS(int), 2.0 ch, 48.0 khz, 192 kbps \| Озвучкa: Закадр. (MVO) от StudioBand
- 1008316 · Тайное вторжение / The Secret Invasion (1964) HDTV [H.264/1080i-LQ] · Перевод: Двуголосый (НТВ+)
- 428151 · Fractale / Фрактал [2011, TV, 11] BDRip 720p raw + rus · Перевод: DreamersTeam
- 482868 · Ningen Kousaten / Human Crossing Point / Точки соприкосновения [2003, TV, 13 ep. · Перевод: Whizz Описание:
- 1365052 · Hanasaku Iroha \| Hanasaku Iroha: Blossoms for Tomorrow \| Азбука Цветов [2011, TV · Озвучка: Одноголосая, Двухголосая закадровая
- 1389210 · Море Солтона / The Salton Sea (2002) WEB-DL [H.264/720p] · Перевод 1: Профессиональный (двухголосый закадровый) Пётр Гланц и Евгения Лютая
- 1360458 · Sword Art Online \| Мастера Меча Онлайн [ТВ-1] [2012, TV, 25 ep] BDRip 1080p 10-b · Озвучка: Одноголосая, Двухголосая, Многоголосая закадровая
- 1753801 · Бог располагает / Gud raader (1912) WEBRip [H.264/720p] · Перевод: немой с интертитрами (В. Сачков)
- 1860621 · Дочь магазина / Magasinets Datter (1918) WEBRip [H.264/1080p] · Перевод: немой с интертитрами
- 253552 · Метрополис / Metropolis (1927) HDTV [H.264/720p] · Перевод: Немой с интертитрами
- 1331498 · Takarajima \| Treasure Island \| Остров сокровищ [1978, TV, 26 из 26] BDRip 1080p · Перевод: Gerale + Komugi-chan
- 1818600 · Evangelion Shin Gekijouban: Jo \| Evangelion: 1.0 You Are (Not) Alone \| Evangelio · Аудио 1: AC-3, RUS(int), 2.0, 48.0 khz, 192 kbps \| Озвучкa: Дубляж Reanimedia 2.0
- 1184789 · Taboo-Tattoo \| Запретная татуировка [2016, TV, 12 эп.] BDrip 1080p raw · Перевод: AOS
</details>
## rutracker
**releases**: всего — 11166; с метками — 7742; без меток — 3424; с метками, есть недостающая студия — 972; с метками без студий, студия есть в кодек-строке — 495
**строки**: trans — 15192; audio — 11471
**кодек-строки**: RE_AUDIO_TRACK | codec_segments — 5950; RE_AUDIO_TRACK | мимо codec_segments — 3025; мимо RE_AUDIO_TRACK | мимо codec_segments — 2033; мимо RE_AUDIO_TRACK | codec_segments — 463
**форматы мимо: seg codec**: аудио: — 1066; аудио N rus: — 161; аудио rus N: — 63; русская: — 59; аудио rus N-N: — 46; аудио #N русский, — 46; russian acN, N khz, — 31; russian: — 30; russian (dolby acN, N — 29; аудио #N (rus): — 28; аудио N ukr: — 23; аудио #N (внешняя) — 18; аудио : — 16; аудиопоток №N (rus): — 15; аудио rus#N (int.): — 13; аудио rus (ext): — 13; audio #N (rus): — 10; russian acN / N.N / N — 10; аудио №N (rus): — 9; звук (rus(int)): — 8; аудио N-N: — 8; russian, aac, N.N kh — 8; аудио rus N-N (ext): — 7; rus N (ext) aac, — 7; аудио #N (ext): — 7
**студии меток**: всего — 9107; нет в тексте/заголовке — 14
**без меток**: обычный, есть строки дорожек — 1887; обычный, нет строк дорожек — 1537
**недостающие студии: вид строки**: trans — 1114; audio — 531
**недостающие студии: топ**: LeDoyen — 60; NewStation — 48; HDrezka — 46; TVShows — 45; Невафильм — 40; Jaskier — 39; Киномания — 35; 2x2 — 31; LostFilm — 31; Кубик в Кубе — 31; СТС — 29; Red Head Sound — 25; Postmodern — 24; Гоблин — 22; А.Гаврилов — 22; Так Треба Продакшн — 21; Ю.Живов — 20; Андрей Гаврилов — 19; А. Михалёв — 19; Михалёв — 19; ICTV — 19; Пифагор — 17; ОРТ — 17; 1+1 — 17; Гланц — 15
**форматы мимо: seg**: аудио: — 437; аудио : — 20; аудио N: — 4; аудио #N: — 2
**форматы мимо: codec**: аудио N: — 1856; аудио rus: — 661; аудио #N: — 354; аудиоN: — 62; аудио#N: — 44; аудио №N: — 29; аудио ukr: — 10; audio #N: — 4; аудиодорожка N: — 2; audio#N: — 1; аудио N : — 1; аудио N.N: — 1
**студии меток: нет в тексте, studio_source**: release — 7; audio — 7
**мусор в studio меток**: источник: лицензия/Blu-ray/iTunes/Netflix — 231; «X по заказу Y» — 120; субтитры — 27; неру дорожка — 26; тип-слово — 19; каналы/кодек — 18; язык вместо студии — 18; язык-префикс «Русский, - X» — 13
<details><summary>примеры fmt:codec (3000)</summary>
- 6259831 · Аудио 4: Russian E-AC3 / 7.1 / 48 kHz / 1536 kbps \|Ю. Сербин\|
- 5711781 · Аудио 4: DTS-HD Master Audio Russian 3412 kbps 5.1 / 48 kHz / 3412 kbps / 24-bit (DTS Core: 5.1 / 48 kHz / 768 kbps / 24-bit) \| А. Михалев
- 6846635 · Аудио 8: Russian AAC (Advanced Audio Codec), 44.1 kHz, 2.0 ch (Stereo), 192 kb/s \| «LE-Vitation»
- 6887455 · Аудио #1: AAC (Advanced Audio Coding), 48.0 kHz, 2.0 ch (Stereo), ~128 kb/s (Russian) \| "Сыендук" (18+)
- 6899403 · Аудио Rus: AC3, 48 kHz, 2 ch, 384 kbps \|Lostfilm\|
- 6798816 · Аудио 9: Russian / Dolby Digital Audio, 256 kbps, 2.0 / 48 kHz / 256 kbps - НТВ+ (2014 / 2016 г.)
- 4767310 · Аудио #3: Russian (Dolby AC3, 6 ch) 384 Кбит/сек, 48,0 КГц
- 6902539 · Аудио Rus: AC3, 48,0 kHz, 6 ch, 640 Кбит/сек \| Red Head Sound \| Серии: 1-8
- 4186469 · Аудио RUS: АС3, 2 ch, 48 kHz, 192 kbps \|HDrezka\|
- 5921821 · Аудио 3: AC-3 384 kb/s 6 channels, 48.0 kH (Russian) [Flarrow Films]
- 5645820 · Аудио 1: 48 kHz, AC3 Dolby Digital, 3/2 (L,C,R,l,r) + LFE ch, ~448 kbps [русский]
- 6063299 · Аудио RUS: AAC, 48.0 kHz, 2.0 ch, 192 Kbps (в составе контейнера). Озвучка: многоголосая закадровая [KANSAI Studio]
- 3930926 · Аудио 6: Russian (Dolby AC3, 2 ch (2/0 (L,R) ch), 192 kbps)
- 4667342 · Аудио #2: [UKR] 48 kHz, AC3 Dolby Digital, 2/0 (L,R) Surround Encoded ch, ~192 kbps
- 6824956 · Аудио Rus: AC-3, 48 kHz, 6 ch, 384 kbps \|TVShows\|
</details>
<details><summary>примеры fmt:seg (463)</summary>
- 5838316 · Аудио: VBR ~128 Kbps, 44.1 kHz, 2ch Язык Русский; Озвучка: VO - Sahawk (AniMedia)
- 6425819 · Аудио: AC-3, 320 кбит/с, 48.0 кГц, 2 канала Язык Русский; Озвучка: Arrow Hell (NiksoN, n0active, Libra, Mufdkl) [Многоголосая закадровая]
- 2196723 · Аудио: Английский (Dolby AC3, 6 ch,448 Kbps), Русский (Dolby AC3, 6 ch, 448 Kbps) П. Гланц,И.Королёва, Русский (Dolby AC3, 6 ch,448 Kbps) дубляж, Китайский (Dol
- 2368674 · Аудио: Russian: DTS 5.1, 768 kbps (одноголосый, Немахов) (отдельно)
- 6691277 · Аудио: AC3 Dolby Digital, 48 kHz, 2/0 ch, 192 kbps, Russian MVO
- 2822758 · Аудио: russian: AC3 48000 6ch 384 kbps \|многоголосый, закадровый, студия Kravec (lostfilm)\|
- 5749545 · Аудио: Русский; AC-3; 192 Кбит/сек; 2 канала; 48,0 КГц (Студия "Кириллица" (По заказу Disney)) Дубляж
- 5053740 · Аудио: AC-3, 192kbps, 48,0Hz, 2ch Язык Русский (в составе контейнера) ; Озвучка: одноголосая женская Persona99
- 6440498 · Аудио: AC-3, 192 кбит/с, 48.0 кГц, 2 канала Язык Русский; Озвучка AniDub (Shaman, HelgardRay, Oriko, Лизавета) [Многоголосая закадровая]
- 6222916 · Аудио: Russian / Dolby Digital Audio / 5.1 / 48 kHz / 384 kbps \| Дубляж
- 6434506 · Аудио: AC-3, 320 кбит/с, 48.0 кГц, 2 канала Язык Русский; Озвучка SHIZA Project (Dancel, DroEd, Abe, Saylot, Kitsune, Bvia) [Многоголосая закадровая]
- 6292078 · Аудио: Russian AAC / 2.0 / 48 kHz / 192 kbps \| Дубляж Невафильм \|
- 3988042 · Аудио: AC3, 2/0 (L,R) ch, 192 kbps Язык Русский (Любительский одноголосый)
- 6648621 · Аудио: AC-3, 192 kbps, 48.0 kHz, 2 ch, Язык Русский; Озвучка: многоголосая закадровая, MiraiDuB
- 4811969 · Аудио: Russian (AC3, 2 ch, 192 kbps, 16 bits, 48.0 KHz) - VO
</details>
<details><summary>примеры fmt:seg codec (2033)</summary>
- 4952029 · Аудио: Russian: AC3, 2/0 (L,R) ch, 256 kbps
- 5343227 · Аудио: AC3, 2/0 (L,R) ch, 192 kbps, Delay 0 mSec (rus)
- 5454128 · русская: Dolby AC3 48000Hz --\|DualMono\|-- 192Kbps [Ren-TV] (Серии 01-13)
- 6789362 · Аудио 1 Rus: AC3, 48 kHz, 2 ch, 192 kbps (Dаблин)
- 5124321 · Аудио RUS 1: AC3, 48000Hz, ~192 Kbps, 2ch (внешними файлами)
- 5341902 · Аудио #4-8: AC3, 192 kbps, 2 ch (Русский, Греческий, Румынский, Турецкий, Чешский)
- 5169587 · Аудио: Rus, DTS, 768 kb/s (6 ch), 48 kHz
- 3874340 · Audio Русский (DTS-HD Master Audio / 5.1 / 48 kHz / 3957 kbps / 24-bit (DTS Core: 5.1 / 48 kHz / 1509 kbps / 24-bit) - Одноголосый закадровый [-Доцент-]
- 6392123 · Аудио: RUS \| AС-3, 48 кГц, ~192 кбит/с, 2 канала \| AniLibria
- 541355 · Аудио: Russian (Dolby AC3, 6 ch) Japanese (Dolby AC3, 6 ch), Russian (Dolby AC3, 2ch)
- 742076 · Audio:Russian (Dolby AC3, 2 ch 192Kbps)
- 735299 · Аудио: Japanese (Dolby AC3, 2 ch), Russian (Dolby AC3, 2 ch)
- 5220817 · Аудио: Rus, AC3, 640 kb/s (6 ch), 48 kHz
- 4224158 · Аудио: Dolby Digital Audio Russian 5.1 / 48 kHz / 384 kbps \| Попов
- 3969441 · Аудио : Русский (Dolby AC3, 2 ch, 192 кб/с, СТС)
</details>
<details><summary>примеры ghost (14)</summary>
- 6700775 · ТК 1+1 · Ukrainian AC3 / 2.0 / 48 kHz / 192 kbps \|MVO, ТК "1+1"\|
- 5854979 · RUS , UKR · Профессиональный (дублированный) \| RUS (BD 3D CEE), UKR (Ledoyen)
- 6698664 · ТК 1+1 · Ukrainian AC3 / 2.0 / 48 kHz / 192 kbps \|MVO, ТК "1+1"\|
- 5852176 · RUS , UKR · Профессиональный (дублированный) \| RUS ( iTunes), UKR (студія LeDoyen)
- 6641488 · ТК 1+1 · Ukrainian AC3 / 2.0 / 48 kHz / 192 kbps \|MVO, ТК "1+1"\|
- 5641267 · ТКТочкаТВ · Профессиональный (многоголосый закадровый) MVO ТК"ТочкаТВ";
- 6803833 · ТК 1+1 · Ukrainian AC3 / 2.0 / 48 kHz / 192 kbps \|MVO, ТК "1+1"\|
- 6821405 · ТК 1+1 · Ukrainian AC3 / 2.0 / 48 kHz / 192 kbps \|MVO, ТК "1+1"\|
- 6813764 · ТК 1+1 · Ukrainian AC3 / 2.0 / 48 kHz / 192 kbps \|DVO, ТК "1+1"\|
- 3376591 · эп. 1-9 , эп. 10-13 · эп. 1-9 [YakuSub Studio], эп. 10-13 [Advantage]
- 6891775 · ViruseProject · Любительский (двухголосый закадровый) Viruse Project
- 6821395 · ТК 1+1 · Ukrainian AC3 / 2.0 / 48 kHz / 192 kbps \|MVO, ТК "1+1"\|
- 6181833 · НТВ+ ТНТ · Профессиональный (двухголосый закадровый) - НТВ+ \| ТНТ
- 6396910 · ТО Дия · Профессиональный (дублированный) ТО "Дия"
</details>
<details><summary>примеры miss:audio (531)</summary>
- 3296081 · Королев · Аудио 3: Русский одноголосый Королёв DTS / 5.1 / 768 kbps / 48 kHz
- 5806891 · Shiza Project · Аудио: Russian (ext), AC-3, 48000Hz, ~192 Kbps, 2ch \| SHIZA Project
- 6053237 · Невафильм · Аудио 1: русский (профессиональный, дублированный, Невафильм) AC3 256 kbps 48kHz 2Ch
- 4496622 · Киномания · Аудио: Russian: 48 kHz, DTS, 3/2 (L,C,R,l,r) + LFE ch, ~1536.00 kbps avg \|Многоголосый закадровый, Киномания\|
- 5931625 · NewComers · Аудио2: AC3, 3/2 (L,C,R,SL,SR)+LFE ch, 384 kbps........................ Русский NewComers
- 1328322 · Tycoon · Аудио 3: Русский AC3, 6ch 448 Kbps (двухголосый, Tycoon)
- 5899190 · HDrezka · Аудио4: AC3, 3/2 (L,C,R,SL,SR)+LFE ch, 448 kbps.................................................................................................. Русский HDRezk
- 6130286 · Юрий Живов · Аудио 5: Russian \| AC3 / 2.0 Ch / 48 kHz / 224 kbps \| AVO \| Юрий Живов (с VHS)
- 6680266 · Garsu Pasaulis · Аудио 6: Russian \| FLAC / 2.0 Ch / 48 kHz / 24 bit / 1537 kbps \| DVO \| "Garsu Pasaulis" (Литва) [VHS Hi-Fi Stereo]
- 5148134 · Persona99 · Аудио 1: AC-3 48 КГц; 192 Кбит/сек , 2.0 Язык Русский (в составе контейнера) ; Озвучка: женская одноголосая: Persona99
- 5056191 · Нота · Аудио #03: Russian: 48 kHz, AC3, 2/0 (L,R) ch, ~192 kbps avg \|Многоголосый закадровый, Нота\|
- 6344693 · DniproFilm · Аудио 7: АС3, 6 ch, 48 kHz, 384 kbps Украинский MVO DniproFilm/HDRezka Studio
- 6096897 · AlexFilm · Аудио Rus: AC3, 48 kHz, 3/2 (L R C LFE Ls Rs) ch, 384 Кбит/сек \| AlexFilm
- 6680266 · Михаил Яроцкий · Аудио 11: Russian \| DTS-HD MA / 7.1 Ch / 48 kHz / 16 bit / 2951 kbps (core: DTS / 5.1 Ch / 48 kHz / 16 bit / 1509 kbps) \| VO \| Михаил Яроцкий aka kyberpunk
- 6086348 · Сэмпл · Аудио: AAC, 144 кбит/сек, 48 КГц, 2 канала; Язык: Русский (в составе контейнера); Озвучка: Dantes (мужской голос) & Yuki (женский) (Amazing Dubbing) Сэмпл
</details>
<details><summary>примеры miss:trans (1114)</summary>
- 4919872 · Невафильм · Перевод 1: Профессиональный (дублированный) \|Дубляж, Blu-ray CEE_[Невафильм]_2007г\|
- 6771413 · заКАДРЫ · Перевод #3: Любительский (многоголосый, закадровый) [заКАДРЫ]
- 3569656 · Видеосервис · Перевод: Профессиональный (двухголосый закадровый) x2 (ОРТ, ВидеоСервис)
- 6792103 · DniproFilm · Перевод 7: Профессиональный (многоголосый закадровый) HDRezka (Dniprofilm)
- 4431588 · 1+1 · Перевод 5: Профессиональный (двухголосый закадровый) [1+1]
- 6279898 · Невафильм · Перевод: Профессиональный (полное дублирование) [Невафильм]
- 6845439 · отсутствует · Перевод 3: Казахский, профессиональный (дублированный) (Cinema Tone Production) по заказу Nickelodeon (временно отсутствует в S02E07)
- 6713595 · Red Head Sound · Перевод 2-3: Профессиональный (псевдодублированный) — Red Head Sound, Jaskier
- 5007272 · ОРТ · Перевод #2: Профессиональный многоголосый закадровый (ОРТ)
- 6860022 · А.Михалев · Перевод 3-5: авторский (одноголосый закадровый) - В.Горчаков, А.Михалев, П.Прямостанов
- 2555358 · Persona99 · Перевод: Двухголосый (Persona99 & MaxDamage;Noir & Cuba77), Субтитры
- 6790851 · ИДДК · Перевод 3-6: профессиональный (двухголосый закадровый) - Мьюзик-Трейд, DVD Магия / Флагман, ИДДК, Дивайс
- 6100973 · Рудой · Перевод 6: Авторский (одноголосый, закадровый) [Е.Рудой]
- 5711781 · А. Михалёв · Перевод 4: Авторский (одноголосый закадровый) А. Михалев
- 2455771 · MC Entertainment · Перевод: Профессиональный (многоголосый, закадровый) [MC Entertainment]
</details>
<details><summary>примеры noise:«X по заказу Y» (120)</summary>
- rt-5389600 · СВ-Дубль по заказу ВГТРК · Профессиональный (Полное Дублирование) "СВ-Дубль" по заказу "ВГТРК" (Карусель)
- rt-5087462 · по заказу 2х2 · Профессиональный (многоголосый, закадровый) по заказу 2х2 (Быстров, Савина, Эльдаров)
- rt-6130327 · SDI Sun-Studio Russia по заказу ОРТ 2006 г · Профессиональный (дублированный) «SDI Sun-Studio Russia» по заказу ОРТ 2006 г
- rt-6572883 · VSI Moscow по заказу Nickelodeon · Русский, профессиональный (дублированный) \| «VSI Moscow» по заказу Nickelodeon
- rt-4820398 · Творческая студия работников дубляжа 1995 год по заказу Центр Скип Фильм · закадровый) Творческая студия работников дубляжа 1995 год по заказу Центр Скип Фильм
- rt-6124488 · SDI Sun-Studio Russia по заказу ОРТ 2006 г · Профессиональный (дублированный) «SDI Sun-Studio Russia» по заказу ОРТ 2006 г
- 5485340 · кинокомпания Эй Би Видео по заказу Первого канала · Профессиональный (многоголосый закадровый) кинокомпания Эй Би Видео по заказу Первого канала
- 3373172 · студия Кадр , по заказу РТР, 1995 г · Профессиональный (дублированный): студия "Кадр" (при уч. студии "Нота"), по заказу РТР, 1995 г
- rt-6895972 · Syncmer по заказу HBO · Профессиональный (двухголосый закадровый) — Syncmer по заказу HBO
- rt-6845439 · по заказу Nickelodeon · Украинский, профессиональный (дублированный) (1+1) по заказу Nickelodeon
- rt-4397160 · студия Кипарис по заказу 2х2 · Профессиональный (двухголосый закадровый) "студия "Кипарис" по заказу 2х2"
- rt-6878274 · Дубляж Reanimedia По заказу Amediateka · Дубляж Reanimedia [русский] По заказу Amediateka
- 6851700 · По заказу смотри кино · Профессиональный (многоголосый закадровый) \| По заказу "смотри кино \| 18+
- 5485351 · кинокомпания Эй Би Видео по заказу Первого канала · Профессиональный (многоголосый закадровый) кинокомпания Эй Би Видео по заказу Первого канала
- rt-4609190 · СВ-Дубль, по заказу МНВК в 1996 году · Профессиональный (дублированный) СВ-Дубль, по заказу МНВК в 1996 году
</details>
<details><summary>примеры noise:источник: лицензия/Blu-ray/iTunes/Netflix (231)</summary>
- 4659385 · iTunes Russia · Профессиональный (дублированный) \| iTunes Russia
- 3893431 · лицензия · Профессиональный (дублированный), лицензия
- rt-6292012 · Пифагор по заказу Netflix · Профессиональный (дублированный) Пифагор по заказу Netflix
- 5967155 · BD CEE со вставками Д. Есарева и Замеза · Профессиональный (дублированный) \| BD CEE со вставками Д. Есарева и Замеза
- rt-6239239 · Пифагор по заказу Netflix · Профессиональный (дублированный) Пифагор по заказу Netflix
- 5516896 · BluRay / По заказу Первого канала · Профессиональный (двухголосый закадровый) - BluRay / По заказу Первого канала
- rt-6426925 · iTunes · Ukrainian AC3 / 5.1 / 48 kHz / 384 kbps \|Dub, iTunes\|
- rt-5010737 · Blu-ray CEE · Профессиональный (дублированный) Blu-ray CEE
- rt-6591933 · BD CEE Невафильм · Профессиональный (дублированный) BD CEE \| Невафильм (2001 год)
- rt-6130327 · Blu-Ray CPIG 2010 г · Профессиональный (дублированный) Blu-Ray «CPIG» 2010 г
- 6320223 · Blu-ray CEE · Профессиональный (дублированный) - Blu-ray CEE
- rt-4822676 · Blu-ray CEE · Профессиональный (полное дублирование) Blu-ray CEE
- 5096972 · Blu-ray CEE · Профессиональный (дублированный) Blu-ray CEE
- rt-5727410 · RUS + UKR Дубляж iTunes · Профессиональный (дублированный) RUS + UKR Дубляж \| iTunes
- rt-5409044 · iTunes · #2: Ukrainian: 48 kHz, AAC, 2/0 (L,R) ch, ~160.00 kbps avg \|Дубляж, iTunes\|
</details>
<details><summary>примеры noise:каналы/кодек (18)</summary>
- rt-5521273 · AAC, 48.0 KHz, 2 ch, 317 Kbps · AAC, 48.0 KHz, 2 ch, 317 Kbps (Китайский)
- rt-4206667 · Shachiburi , внешним файломОзвучивание: Eladiel & Zendos, , внешним файлом Аудио, Japanese: AAC, 48000Hz, 2ch, 192 Kbps CBR · Shachiburi , внешним файломОзвучивание: Eladiel & Zendos, , внешним файлом[*]Аудио, Japanese: AAC, 48000Hz, 2ch, 192 Kbp
- 3163663 · AC3, 6ch , 448 кбит/с Blu-ray CEE + · Украинский (Дублированный), AC3, 6ch (L, C, R, Ls, Rs +LFE), 448 кбит/с \|Blu-ray CEE\| + (вставки из киномании)
- 3163663 · AC3, 6ch , 384 кбит/с Сербин · Русский (Профессиональный (один голос)), AC3, 6ch (L, C, R, Ls, Rs +LFE), 384 кбит/с \|Сербин\|
- 2579545 · AC3, 6ch , 448 кбит/с Ист-Вест * · Русский (Дублированный), AC3, 6ch (L, C, R, Ls, Rs +LFE), 448 кбит/с \|«Ист-Вест»\|*
- rt-4311341 · JAP AAC, 128 kbps, 44,1 Hz, Stereo · JAP AAC, 128 kbps, 44,1 Hz, Stereo
- rt-4311341 · RUS AAC, 256 kbps, 48,0 Hz, Stereo · RUS AAC, 256 kbps, 48,0 Hz, Stereo
- rt-5521273 · AAC, 44.1 KHz, 2 ch, 192 Kbps · AAC, 44.1 KHz, 2 ch, 192 Kbps (Китайский)
- rt-2985347 · Rus AC-3, 6Ch, 384 Kbps · Rus AC-3, 6Ch, 384 Kbps
- 3163663 · AC3, 6ch , 448 кбит/с Blu-ray CEE + · Русский (Дублированный), AC3, 6ch (L, C, R, Ls, Rs +LFE), 448 кбит/с \|Blu-ray CEE\| + (вставки из киномании)
- rt-5521273 · AAC, 48.0 KHz, 2 ch, 192 Kbps · AAC, 48.0 KHz, 2 ch, 192 Kbps (Русский)
- rt-4557862 · MPEG Audio, 48.0 KHz, 2 ch, ~192 Kbps · MPEG Audio, 48.0 KHz, 2 ch, ~192 Kbps
- 3163663 · AC3, 6ch , 384 кбит/с Гаврилов · Русский (Профессиональный (один голос)), AC3, 6ch (L, C, R, Ls, Rs +LFE), 384 кбит/с \|Гаврилов\|
- rt-3972129 · П.Гланц и И.Королёва , в составе контейнера Аудио, Japanese: AC3, 48000Hz, 5.1ch, 448 Kbps CBR · П.Гланц и И.Королёва , в составе контейнера[*]Аудио, Japanese: AC3, 48000Hz, 5.1ch, 448 Kbps CBR
- rt-5521273 · AAC, 44.1 KHz, 2 ch, 192 Kbps · AAC, 44.1 KHz, 2 ch, 192 Kbps (Русский)
</details>
<details><summary>примеры noise:неру дорожка (26)</summary>
- 6405953 · Qazaqstan казахский · Профессиональный (многоголосый закадровый) Qazaqstan казахский
- rt-6802844 · Телеканал Qazaqstan · Kazakh AC3 / 1.0 / 48 kHz / 192 kbps \|Dub, Телеканал "Qazaqstan"\|
- rt-5555650 · Ужастика с английского · Авторский (одноголосый закадровый) "Ужастика" с английского
- rt-6831331 · Qazaqstan · Профессиональный (дублированный) Qazaqstan
- rt-6146562 · Телеканал Qazaqstan · Kazakh AC3 / 2.0 / 48 kHz / 192 kbps \|Dub, Телеканал "Qazaqstan"\|
- 3842468 · + Английский + Комментарии · Авторский (В.Назаров / Ю.Живов) + Английский (оригинал) + Комментарии (оригинал)
- rt-6438249 · телеканал Qazaqstan · Kazakh AC3 / 2.0 / 48 kHz / 192 kbps \|MVO, телеканал "Qazaqstan"\|
- 4078156 · + Английский + Комментарии · Авторский (А.Гаврилов, В.Королев) + Английский (оригинал) + Комментарии (оригинал)
- rt-6274168 · Телеканал Qazaqstan · Kazakh AC3 / 2.0 / 48 kHz / 192 kbps \|MVO, Телеканал "Qazaqstan"\|
- rt-6304693 · Телеканал Qazaqstan · Kazakh AC3 / 2.0 / 48 kHz / 192 kbps \|MVO, Телеканал "Qazaqstan"\|
- rt-6804228 · Телеканал Qazaqstan · Kazakh AC3 / 1.0 / 48 kHz / 192 kbps \|Dub, Телеканал "Qazaqstan"\|
- rt-6274165 · Телеканал Qazaqstan · Kazakh AC3 / 2.0 / 48 kHz / 192 kbps \|MVO, Телеканал "Qazaqstan"\|
- rt-6808952 · Телеканал Qazaqstan · Kazakh AC3 / 1.0 / 48 kHz / 192 kbps \|Dub, Телеканал "Qazaqstan"\|
- rt-6688661 · Qazaqstan · Профессиональный (дублированный) Qazaqstan
- rt-649606 · + английская дорожка · Профессиональный (многоголосый, закадровый) + английская дорожка
</details>
<details><summary>примеры noise:субтитры (27)</summary>
- rt-5305351 · Субтитры · Любительский многоголосый закадровый; Субтитры (см. информацию о каждом томе)
- rt-1456998 · Субтитры · Русский закадровый (одноголосый) \| Субтитры
- 6283156 · Viruse Project по субтитрам StasSlender · Любительский (многоголосый закадровый) Viruse Project по субтитрам StasSlender
- 4325644 · den904 по субтитрам Janetta · Одноголосый (закадровый) den904 по субтитрам Janetta
- rt-2421299 · Субтитры · Профессиональный (полное дублирование, XL-Media), Субтитры
- 4429717 · Doctor Joker по субтитрам romero007 · Одноголосый закадровый Doctor Joker по субтитрам romero007
- rt-5360746 · + субтитры на французскую речь · Профессиональный (дублированный) (BD CEE) + субтитры на французскую речь
- 6926094 · Субтитры · Субтитры любительский
- rt-2555358 · Субтитры · Двухголосый (Persona99 & MaxDamage;Noir & Cuba77), Субтитры
- rt-4199725 · по субтитрам Bellena · Одноголосый закадровый (iDimo) по субтитрам Bellena
- rt-3923673 · den904 по субтитрам disluo · Одноголосый закадровый den904 по субтитрам disluo
- rt-4562795 · /Andi по субтитрам Gabo/ · одноголосый закадровый /Andi по субтитрам Gabo/
- 4322131 · den904 по субтитрам Janetta · Одноголосый (закадровый) den904 по субтитрам Janetta
- rt-6198714 · allecs2010 по субтитрам Nebëhr Gudahtt · Одноголосый закадровый allecs2010 по субтитрам Nebëhr Gudahtt
- rt-6858166 · Пифагор , дальше только оригинал + субтитры · Русский: Профессиональный (дублированный) Пифагор (1-2 сезоны) , дальше только оригинал + субтитры
</details>
<details><summary>примеры noise:тип-слово (19)</summary>
- rt-2343090 · полное дублирование · полное дублирование
- rt-2754519 · полное дублирование · полное дублирование
- rt-3174375 · Дубляж · Дубляж
- 1750614 · дубляж · дубляж
- rt-2288687 · полное дублирование · полное дублирование
- rt-4245673 · полное дублирование · Профессиональный полное дублирование
- rt-3502191 · дубляж · любительский дубляж
- rt-3280195 · Дубляж · Дубляж
- rt-5809379 · Дубляж · Дубляж
- rt-3163687 · Дубляж · Дубляж (Лексикон)
- rt-1239852 · полное дублирование · полное дублирование
- rt-2500434 · дубляж · дубляж
- rt-1024963 · дубляж · дубляж (с VHS
- rt-2985347 · дубляж · дубляж
- rt-4557862 · полное дублирование · полное дублирование [ НЕВАФИЛЬМ ]
</details>
<details><summary>примеры noise:язык вместо студии (18)</summary>
- rt-6561750 · украинский · Профессиональный (дублированный), украинский (Le Doyen)
- rt-6872631 · Украинский · Профессиональный (дублированный) \| [Cinema Sound] - Украинский
- rt-6561750 · русский · Профессиональный (дублированный), русский (VSI Moscow)
- rt-5448541 · русский · Профессиональный (дублированный) русский
- rt-4486630 · Украинский · Украинский, (многоголосый закадровый)
- rt-5801324 · Украинский · Профессиональный (дублированный) Украинский
- rt-4131293 · украинский · украинский многоголосный
- rt-6923497 · Французский · Профессиональный (дублированный) Французский
- rt-521554 · русский · Профессиональный русский(многоголосый, закадровый)
- rt-5705161 · Украинский · Профессиональный (дублированный) Украинский
- rt-3502191 · украинский · украинский многоголосный
- rt-6807308 · Украинский · Профессиональный (дублированный) \| [LeDoyen] - Украинский
- rt-5415702 · украинский · Профессиональный (дублированный) украинский
- 6287332 · Казахский · Профессиональный (дублированный) Казахский
- rt-2439828 · Русский · Русский многоголосый перевод
</details>
<details><summary>примеры noise:язык-префикс «Русский, - X» (13)</summary>
- 6105068 · Украинский, AMC · Профессиональный (многоголосый) \| Украинский, AMC
- 6008543 · Украинский, R5 · Профессиональный (дублированный) \| Украинский, R5
- rt-6887455 · Русский, - HBO · Русский, двухголосый закадровый - "HBO"
- rt-6887455 · Русский, - Сыендук · Русский, одноголосый закадровый - "Сыендук" (18+)
- rt-6887455 · Русский, - HDRezka · Русский, многоголосый закадровый - "HDRezka Studio"
- rt-6449707 · Украинский, Le Doyen / Netflix · Профессиональный (дублированный) Украинский, Le Doyen / Netflix
- rt-6887455 · Украинский, - HDRezka · Украинский, многоголосый закадровый - "HDRezka Studio"
- rt-6887455 · Русский, псевдодублированный - HDRezka · Русский, псевдодублированный - "HDRezka Studio" (18+)
- rt-6450195 · Украинский, MEGOGO · Профессиональный (многоголосый закадровый) Украинский, MEGOGO
- rt-6887455 · Русский, - HDRezka · Русский, многоголосый закадровый - "HDRezka Studio" (18+)
- rt-6887455 · Русский, - TVShows · Русский, многоголосый закадровый - "TVShows" \| (серии 1-8)
- rt-6887455 · Русский, - ТО Дубляжная по заказу ТВОЁ · Русский, многоголосый закадровый - "ТО Дубляжная" по заказу «ТВОЁ»
- rt-6887455 · Украинский, - HBO · Украинский, многоголосый закадровый - "HBO"
</details>
<details><summary>примеры unlab (1887)</summary>
- 242182 · Золотая коллекция любимых мультфильмов. Выпуск 2 (Про бегемота, который боялся п · Аудио: Russian (Dolby AC3, 2 ch)
- 5501688 · Смешарики. Пин-код. Гуманитарные технологии / Сезон: 3 / Серии: 1-20 из 20 (Алек · Перевод: Не требуется
- 4262044 · Связь сердец / Kokoro Connect / Единение сердец (Онума Син) [TV+Special] [13+4 и · Перевод:
- 4094721 · Драконий Жемчуг: Фильм второй / Dragon Ball Movie 2: Sleeping Princess in Devil' · Аудио: AC3, 2/0 (L,R) ch, 192 kbps Язык Русский (Любительский одноголосый)
- 4777768 · Мангака и все-все-все / Mangaka-san to Assistant-san to The Animation / The Comi · Перевод:
- 5441268 · Принцесса-шпионка / Princess Principal [TV] [12 из 12] [RUS(int), JAP+Sub] [2017 · Аудио 1: AC3, ~192 kbps, 48000 Hz, 2 ch., язык русский (в составе контейнера); озвучка: многоголосая Торгиль, Esther, LeeAnnushka, Demetra,
- 1078722 · Бернард: выпуски 1, 2, 3 / Bernard, Bernard 2, Bernard 3 (Клаудио Бьерн Ливирья, · Перевод: Не требуется
- 4735130 · Сломанный Меч / Break Blade [TV] [12 из 12] [RUS(int), JAP+Sub] [2014, приключен · Аудио 1: AC3, ~192 kbps, 48000 Hz, 2 ch., язык русский (в составе контейнера); озвучка: двухголосая (муж.+жен.) Absurd & Eladiel
- 5182067 · Попотан / Popotan [TV] [12 из 12] [RUS, ENG, JAP] & [5-8 из 12] [Sub] [2003, ком · Аудио 3: AC-3, 192 Kbps, 2 channels Язык Русский - мужская одноголосая от Гнус
- 5046410 · Ямада и семь ведьм / Yamada-kun to 7-nin no Majo [TV] [12 из 12] [RUS(int), JAP+ · Аудио: Русская / 2 ch / AC-3 / 44,1 kHz / 192 Kbps / RUS(int) [озвучка: Persona99]
- 5950622 · Владыка духовного меча / Ling Jian Zun / Spirit Sword Sovereign / Сезон: 1-3 / С · Аудио: AC3, 48000Hz, 2ch, 128kbps, Язык Русский (в составе контейнера)
- 5019993 · Бамбуковый Клинок / Bamboo Blade (Сайто Хисаси) [TV] [26 из 26] [RUS(ext),JAP+Su · Озвучка:
- 4629918 · Навсикая из Долины ветров / Kaze no Tani no Naushika / Nausicaa of the Valley of · Аудио 5: Русский [Сонотек/Ruscico (2007 г., дубляж)] Dolby Digital Audio 2.0 / 48 kHz / 192 kbps / DN -27dB
- 1932640 · Лучшее от Загребфильм / The Best of Zagreb Film (Dušan Vukotic,Zdenko Gašparovic · Перевод: Не требуется
- 6916333 · Абдукция / Сезон: 1 / Серии: 1-12 из 12 (Анастасия Бондаренко) [2025, Россия, му · Перевод: не требуется
</details>
+2 -7
View File
@@ -27,7 +27,7 @@ from transformers import AutoTokenizer
from .model import load_model
from .studio_norm import STUDIO_ALIASES, STUDIO_PLACEHOLDERS
from .train import MAX_LEN, Rows, decode_span, make_collate
from .train import MAX_LEN, Rows, decode_spans, make_collate
from torch.utils.data import DataLoader
@@ -152,12 +152,7 @@ def parity(model_dir: str, onnx_dir: str, data_path: str, limit: int = 2000,
same_type += tpt[i] == pot[i]
same_age += apt[i] == apo[i]
attn = enc["attention_mask"][i].tolist()
st = decode_span(bpt[i], offsets[i], attn)
so = decode_span(bot[i], offsets[i], attn)
row = loader.dataset.rows[n - 1]
st_txt = row["text"][st[0]:st[1]] if st else None
so_txt = row["text"][so[0]:so[1]] if so else None
same_span += (st_txt or "") == (so_txt or "")
same_span += decode_spans(bpt[i], offsets[i], attn) == decode_spans(bot[i], offsets[i], attn)
return {"n": n, "type_match": same_type / max(n, 1),
"span_match": same_span / max(n, 1),
"age_match": same_age / max(n, 1),
+73 -11
View File
@@ -6,12 +6,19 @@
Публичный API:
detect_tracker(url) — 'rutracker' | 'kinozal' | 'nnm' | ValueError
fetch_html(url, ...) — скачать страницу, вернуть (html, tracker)
kinozal_tabs(html) — вкладки kinozal, которые грузятся JS (Релиз, …)
inline_tabs(html, t) — вписать скачанные вкладки в блок #tabs
decode_bytes(data) — байты -> текст (utf-8 -> cp1251, чинит «двойную»)
kinozal: описание многих раздач открывается и без входа (cookie необязателен). Если трекер
вернул форму входа, передайте cookie (`cookie="uid=...; pass=..."`) либо скачайте страницу сами
(grabber уже умеет) и отдайте сервису готовый `html`.
На kinozal во встроенном блоке #tabs только «Техданные» (там «Перевод:» без
студий), а «Релиз» со строками «Аудио N: … (MVO, LostFilm)» грузится JS:
`showtab(id, N)` -> `get_srv_details.php?id=…&pagesd=N`. fetch_html скачивает
эти вкладки и дописывает их в #tabs, чтобы описание разбиралось целиком.
Cloudflare / защита от ботов: если задан FlareSolverr (ваш собственный
экземпляр, как у Jackett/Prowlarr) — `flaresolverr="http://host:8191"` или
переменная окружения DUB_FLARESOLVERR_URL, — то при ответе 403/503 или
@@ -23,10 +30,13 @@ from __future__ import annotations
import json
import os
import re
import urllib.error
import urllib.request
from urllib.parse import urlparse
from bs4 import BeautifulSoup
from .html_extract import _fix_mojibake
TRACKER_HOSTS = {
@@ -127,32 +137,84 @@ def _fetch_flaresolverr(endpoint: str, url: str, timeout: float,
return data["solution"]["response"]
def fetch_html(url: str, *, timeout: float = 15.0, cookie: str | None = None,
flaresolverr: str | None = None) -> tuple[str, str]:
"""Скачивает страницу релиза. Возвращает (html, tracker).
Сначала прямой запрос; при 403/503 или странице-заглушке и заданном
FlareSolverr — повтор через него.
"""
tracker = detect_tracker(url)
flaresolverr = flaresolverr or os.environ.get("DUB_FLARESOLVERR_URL") or None
html, direct_error = None, None
def _get(url: str, timeout: float, cookie: str | None,
flaresolverr: str | None) -> str:
"""Прямой запрос; при 403/503 или странице-заглушке — через FlareSolverr."""
html = None
try:
html = _fetch_direct(url, timeout, cookie)
except urllib.error.HTTPError as e:
if e.code not in RETRY_CODES or not flaresolverr:
raise
direct_error = e
if html is not None and blocked_reason(html) and flaresolverr:
html = None
if html is None:
if not flaresolverr:
raise ValueError("страница недоступна")
html = _fetch_flaresolverr(flaresolverr, url, timeout, cookie)
return html
# «showtab(2155882,0)»; 100 — «Техданные», они уже встроены в страницу
RE_KZ_TAB = re.compile(r"showtab\((\d+)\s*,\s*(\d+)\)[^>]*>([^<]*)<")
KZ_INLINE_TAB = 100
KZ_SKIP_TABS = ("скриншот",)
def kinozal_tabs(html: str) -> list[tuple[str, int, str]]:
"""Вкладки описания kinozal, которые грузятся JS: [(id, N, название)]."""
out, seen = [], set()
for rid, n, label in RE_KZ_TAB.findall(html):
n = int(n)
if n == KZ_INLINE_TAB or n in seen or label.strip().lower().startswith(KZ_SKIP_TABS):
continue
seen.add(n)
out.append((rid, n, label.strip()))
return out
def inline_tabs(html: str, tabs: list[tuple[str, str]]) -> str:
"""Дописывает фрагменты вкладок [(название, html)] в конец блока #tabs."""
soup = BeautifulSoup(html, "lxml")
node = soup.select_one("#tabs")
if node is None or not tabs:
return html
for label, fragment in tabs:
box = soup.new_tag("div", attrs={"class": "dub-tab", "data-tab": label})
frag = BeautifulSoup(fragment, "lxml")
for child in list((frag.body or frag).contents):
box.append(child)
node.append(box)
return str(soup)
def fetch_html(url: str, *, timeout: float = 15.0, cookie: str | None = None,
flaresolverr: str | None = None) -> tuple[str, str]:
"""Скачивает страницу релиза. Возвращает (html, tracker).
Сначала прямой запрос; при 403/503 или странице-заглушке и заданном
FlareSolverr — повтор через него. Для kinozal дополнительно скачиваются
вкладки описания («Релиз», «Внимание»); недоступная вкладка пропускается.
"""
tracker = detect_tracker(url)
flaresolverr = flaresolverr or os.environ.get("DUB_FLARESOLVERR_URL") or None
html = _get(url, timeout, cookie, flaresolverr)
reason = blocked_reason(html)
if reason:
hint = "" if flaresolverr else " (задайте FlareSolverr: DUB_FLARESOLVERR_URL)"
raise ValueError(f"страница недоступна: {reason}{hint}")
if tracker == "kinozal":
parsed = urlparse(url)
tabs = []
for rid, n, label in kinozal_tabs(html):
tab_url = f"{parsed.scheme}://{parsed.netloc}/get_srv_details.php?id={rid}&pagesd={n}"
try:
frag = _get(tab_url, timeout, cookie, flaresolverr)
except (urllib.error.URLError, ValueError, OSError):
continue
if not blocked_reason(frag):
tabs.append((label, frag))
html = inline_tabs(html, tabs)
return html, tracker
+28 -8
View File
@@ -17,20 +17,22 @@ from pathlib import Path
from bs4 import BeautifulSoup
# декор и номер перед ключевым словом: «[*]Перевод (1):», «►Перевод №2:», «Аудио #1:»
_LEAD = r"^\s*(?:\[\*\]|[*•·►→▸▪◦]+\s*|[-–—]\s*)?"
_NUM = (r"(?:\(\s*\d+(?:\s*[-–]\s*\d+)?\s*\)|№\s*\d+(?:\s*[-–]\s*\d+)?"
r"|#\s*\d+(?:\s*[-–]\s*\d+)?|\d+(?:\s*[-–]\s*\d+)?)?")
_WORD = r"(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук|Аудио|Дубляж)"
# строка дорожки: «Перевод:», «Перевод 2:», «Озвучивание:», «Аудио 1:», …
RE_LINE = re.compile(
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук|Аудио)"
r"\s*(?:#\s*\d+|\d+)?\s*[:.]\s*(?P<body>.+)$", re.I)
RE_LINE = re.compile(_LEAD + _WORD + r"\s*" + _NUM + r"\s*[:.]\s*(?P<body>.+)$", re.I)
# то же, но значение на СЛЕДУЮЩЕЙ строке (kinozal: «<b>Перевод:</b> значение»)
RE_KEY_ONLY = re.compile(
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук|Аудио)"
r"\s*(?:#\s*\d+|\d+)?\s*:\s*$", re.I)
RE_KEY_ONLY = re.compile(_LEAD + _WORD + r"\s*" + _NUM + r"\s*:\s*$", re.I)
# префикс строки дорожки (для получения «чистого» текста дорожки)
RE_LINE_PREFIX = re.compile(
r"^\s*(?:Перевод|Озвучиван\w*|Озвучк\w*|Озвучен\w*|Звук|Аудио)"
r"\s*(?:#\s*\d+|\d+)?\s*[:.]\s*", re.I)
_LEAD + r"(?:Перевод|Озвучиван\w*|Озвучк\w*|Озвучен\w*|Звук|Аудио|Дубляж)"
r"\s*" + _NUM + r"\s*[:.]\s*", re.I)
# признаки того, что в тексте есть информация о типе озвучки/авторе
RE_TYPE_HINT = re.compile(
@@ -47,6 +49,13 @@ RE_SEP = re.compile(
r"\s*(?:\+|;|/)\s*(?=Проф|Люб|Авт|Дубл|Многоголос|Двухголос|Одноголос)",
re.I)
# строка, начинающаяся со слова типа озвучки (продолжение списка под «Перевод:»)
RE_TYPE_START = re.compile(
r"^\s*(?:Профессиональн|Любительск|Авторск|Дублирован|Многоголос|Двухголос|Одноголос)", re.I)
# scene-имя релиза «A.b.c.2005.2160p.UHD.Blu-ray.Remux-GROUP»: без пробелов, ≥ 4 точек; дорожкой не бывает
RE_SCENE = re.compile(r"^[^\s.]+(?:\.[^\s.]+){4,}$") # без вложенной неоднозначности: линейно
SELECTORS = ("div.post_body", "div[id^=post-text-]", ".postbody", "#tabs")
@@ -122,6 +131,17 @@ def extract_segments(text: str) -> list[str]:
if j >= len(lines):
break
body, i = lines[j].strip(), j + 1
# rutracker: «Перевод:» и под ним по строке на дорожку — берём
# подряд идущие строки, начинающиеся со слова типа
# (в HTML после <br> стоит ещё и \n исходника — строки через пустую)
while True:
k = i
while k < len(lines) and not lines[k].strip():
k += 1
if k >= len(lines) or not RE_TYPE_START.match(lines[k]) or ":" in lines[k]: # «тип: определение» — глоссарий
break
body += " ; " + lines[k].strip()
i = k + 1
else:
i += 1
continue
+22 -14
View File
@@ -14,6 +14,7 @@ Python API:
Формат результата (JSON):
{"segment": "...", "translation_type": "...", "type_confidence": 0.99,
"studio_span": "..." | null, "studio": "..." | null, "studio_confidence": 0.97,
"studio_spans": [...], "studios": [...], "studio_confidences": [...], # все авторы строки, первый = studio
"is_18plus": false, "age_confidence": 0.99}
"""
@@ -32,8 +33,8 @@ from .html_extract import (RE_LINE_PREFIX, extract_segments, html_to_text,
read_input)
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 .spans import MAX_LEN, decode_spans
from .trackinfo import audio_summary, lang_of_text, model_text, role_of
# мусор, который модель иногда принимает за автора: раскладка каналов, кодеки
@@ -132,16 +133,22 @@ class DubPredictor:
seg = texts[i]
tid = int(type_prob[i].argmax())
aid = int(age_prob[i].argmax())
span = refine_span(text, decode_span(
bio_pred[i], offsets[i], enc["attention_mask"][i].tolist()))
span_text = text[span[0]:span[1]].strip() if span else None
# все авторы строки («Многоголосый (HDRezka Studio, LostFilm)»); основной — первый по тексту
spans, span_texts = [], []
for sp in decode_spans(bio_pred[i], offsets[i], enc["attention_mask"][i].tolist()):
sp = refine_span(text, sp)
piece = text[sp[0]:sp[1]].strip() if sp else ""
if piece and piece not in span_texts:
spans.append(sp)
span_texts.append(piece)
span_text = span_texts[0] if span_texts else None
ttype = guard_type(self.types[tid], text, span_text, self.types)
span_conf = None
if span is not None:
toks = [t for t, (a, z) in enumerate(offsets[i])
if z > a and a < span[1] and z > span[0]]
confs = [float(bio_prob[i, t, bio_pred[i, t]]) for t in toks]
span_conf = float(np.mean(confs)) if confs else None
span_confs = []
for a0, z0 in spans:
confs = [float(bio_prob[i, t, bio_pred[i, t]]) for t, (a, z) in enumerate(offsets[i])
if z > a and a < z0 and z > a0]
span_confs.append(round(float(np.mean(confs)), 4) if confs else None)
span_conf = span_confs[0] if span_confs else None
lang, lang_conf, lang_src = "", None, None
if info and info["lang"]: # язык, прямо названный в строке «Аудио», надёжнее нейросети
lang, lang_src = info["lang"], "audio"
@@ -159,6 +166,9 @@ class DubPredictor:
"studio_span": span_text,
"studio": canonical(span_text) if span_text else None,
"studio_confidence": round(span_conf, 4) if span_conf else None,
"studio_spans": span_texts, # все авторы строки, по порядку в тексте
"studios": [canonical(t) for t in span_texts],
"studio_confidences": span_confs,
"is_18plus": bool(aid == 1) or bool(info and info["adult"]), # 18+ в строке «Аудио» — прямое свидетельство
"age_confidence": round(float(age_prob[i, aid]), 4),
"lang": lang or None,
@@ -166,9 +176,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
+108 -40
View File
@@ -32,12 +32,17 @@ import re
from collections import Counter, defaultdict
from pathlib import Path
from . import text_fixes
from .html_extract import RE_LINE_PREFIX, RE_TYPE_HINT, _split_entries
from .manual import REPEAT, manual_ids
from .studio_norm import JUNK_STUDIO_RE, find_studio_span
from .trackinfo import context, model_text, parse_audio_line
RE_18 = re.compile(r"\b18\s*\+")
# строка-отказ от озвучки, попавшая в метки старыми парсерами («Перевод: Отсутствует»)
RE_NO_DUB_TEXT = re.compile(
r"(?i)^\W*(?:отсутств\w*|отсутсву\w*|не\s*требу\w*|немо[йе]\b|без\s*слов|без\s*перевода"
r"|нет\s*(?:перевода|озвучки)|без\s*диалог\w*|нет\s*диалог\w*)\b")
UNKNOWN_TYPES = {"неизвестно", "авт. неизвестно", "проф. неизвестно",
"люб. неизвестно"}
@@ -71,6 +76,52 @@ def build_text(row: dict) -> str:
return re.sub(r"\s+", " ", body).strip()
FIX_SEP = ";" # несколько авторов в ручном решении data/text_fixes.csv
def add_span(studios: list[str], spans: list[list[int]], name: str, span: list[int]) -> None:
"""Добавить автора строки: спаны по порядку в тексте, пересекающиеся не дублируются."""
if any(span[0] < z and a < span[1] for a, z in spans):
return
i = sum(1 for a, _ in spans if a < span[0])
studios.insert(i, name)
spans.insert(i, span)
def row_label(r: dict, fixes: dict[str, dict] | None = None) -> dict:
"""Строка labels.csv -> текст дорожки, тип и автор (общая часть prepare_data и разбора противоречий).
drop: причина отбраковки («empty», «nodub», «fix», «multi», «type») или "". junk — автор
оказался мусором и снят; fixed — метка взята из data/text_fixes.csv.
"""
out = {"drop": "", "text": build_text(r), "type": "", "studio": "", "junk": False, "fixed": False}
text = out["text"] # без хвоста «Аудио»: спан автора ищем в чистом тексте дорожки
if not text:
return {**out, "drop": "empty"}
if RE_NO_DUB_TEXT.match(text):
return {**out, "drop": "nodub"}
fix = (fixes or {}).get(text_fixes.norm(text))
if fix and fix["action"] == "drop":
return {**out, "drop": "fix"}
# несколько дорожек в одной строке при одной метке — неоднозначный пример (после переиндексации
# такие строки уже разложены сборщиком; здесь отсекаются остатки старых данных); ручное решение важнее
nested = [p for p in _split_entries(text) if RE_TYPE_HINT.search(p)]
if not fix and len(nested) >= 2 and not (r.get("source") == "nnm" and r.get("studio_source") == "tech"):
return {**out, "drop": "multi"}
ttype = ((fix or r).get("translation_type") or "").strip()
if ttype in UNKNOWN_TYPES:
ttype = "неизвестно"
if DROP_TYPES_RE.search(ttype):
return {**out, "drop": "type", "type": ttype}
studio = ((fix or r).get("studio") or "").strip()
junk = False
if studio == NO_STUDIO:
studio = ""
elif JUNK_STUDIO_RE.match(studio):
studio, junk = "", True
return {**out, "type": ttype, "studio": studio, "junk": junk, "fixed": bool(fix)}
class UnionFind:
def __init__(self, n: int):
self.p = list(range(n))
@@ -109,39 +160,39 @@ def main() -> None:
# --- 1..4: текст, тип, автор, спан --------------------------------
rows = []
dropped_empty = dropped_junk = dropped_type = dropped_multi = 0
dropped_empty = dropped_junk = dropped_type = dropped_multi = dropped_nodub = 0
span_misses: list[tuple[str, str, str]] = []
fixes = text_fixes.load()
n_fixed = dropped_fix = 0
for r in raw_rows:
text = build_text(r) # без хвоста «Аудио»: спан автора ищем в чистом тексте дорожки
if not text:
lab = row_label(r, fixes)
if lab["drop"] == "empty":
dropped_empty += 1
continue
# несколько дорожек в одной строке при одной метке — неоднозначный пример (после переиндексации
# такие строки уже разложены сборщиком; здесь отсекаются остатки старых данных)
nested = [p for p in _split_entries(text) if RE_TYPE_HINT.search(p)]
if len(nested) >= 2 and not (r.get("source") == "nnm" and r.get("studio_source") == "tech"):
if lab["drop"] == "nodub":
dropped_nodub += 1
continue
if lab["drop"] == "fix":
dropped_fix += 1
continue
if lab["drop"] == "multi":
dropped_multi += 1
continue
ttype = (r.get("translation_type") or "").strip()
if ttype in UNKNOWN_TYPES:
ttype = "неизвестно"
if DROP_TYPES_RE.search(ttype):
if lab["drop"] == "type":
dropped_type += 1
continue
studio = (r.get("studio") or "").strip()
if studio == NO_STUDIO:
studio = ""
elif JUNK_STUDIO_RE.match(studio):
studio = ""
dropped_junk += 1
span = None
if studio:
found = find_studio_span(text, studio)
text, ttype, studio = lab["text"], lab["type"], lab["studio"]
dropped_junk += lab["junk"]
n_fixed += lab["fixed"]
# ручное решение может назвать несколько авторов строки: «HDRezka Studio ; LostFilm»
names = [n.strip() for n in studio.split(FIX_SEP)] if lab["fixed"] else [studio]
studios, spans = [], []
for name in filter(None, names):
found = find_studio_span(text, name)
if found:
span = [found[0], found[1]]
else:
span_misses.append((r.get("source", ""), studio, text))
studio = "__nospan__" # автор есть, но спан не найден
add_span(studios, spans, name, [found[0], found[1]])
else: # автор есть, но спан не найден: строка идёт в обучение без него
span_misses.append((r.get("source", ""), name, text))
# 18+: колонка age_18, пометка в note или «18+» в самой строке дорожки
is_18 = (r.get("age_18", "").strip() in {"1", "True", "true"}
or "18+" in (r.get("note") or "")
@@ -149,8 +200,10 @@ def main() -> None:
rows.append({
"text": text,
"translation_type": ttype,
"studio": studio,
"span": span,
"studio": studios[0] if studios else "",
"span": spans[0] if spans else None,
"studios": studios,
"spans": spans,
"is_18plus": is_18,
"source": r.get("source", ""),
"release_id": r.get("release_id", ""),
@@ -162,19 +215,32 @@ def main() -> None:
rows[-1]["text"] = model_text(text, info)
rows[-1]["tail"] = context(info) # хвост входа: по нему сравнение моделей без аудио-контекста снимает его
# --- 4б: несколько авторов одной строки ---------------------------
# «Многоголосый (HDRezka Studio, LostFilm, TVShows)»: сборщик даёт строку на каждую студию, а верны
# все сразу — склеиваем в одну строку со списком спанов (BIO размечает их все), иначе один и тот же
# вход получает несколько разных «правильных» ответов
merged: dict[tuple, dict] = {}
for row in rows:
key = (row["source"], row["release_id"], row["text"], row["translation_type"])
m = merged.setdefault(key, row)
if m is row:
continue
m["is_18plus"] = m["is_18plus"] or row["is_18plus"]
m["lang"] = m["lang"] or row["lang"]
for name, sp in zip(row["studios"], row["spans"]):
add_span(m["studios"], m["spans"], name, sp)
m["studio"], m["span"] = m["studios"][0] if m["studios"] else "", m["spans"][0] if m["spans"] else None
n_merged = len(rows) - len(merged)
rows = list(merged.values())
# релизы, добавленные вручную по ссылке: усиливаем (копии попадут в ту же группу)
manual = manual_ids()
man_rows = [r for r in rows if r["release_id"] in manual]
for _ in range(REPEAT - 1):
rows.extend(dict(r) for r in man_rows)
# строки с ненайденным спаном: из BIO-обучения их исключаем (флаг ниже),
# но для классификации типа они пригодны — оставляем с studio="" и span=null
# строки с ненайденным спаном автора для классификации типа пригодны — остаются без этого автора
n_span_miss = len(span_misses)
for row in rows:
if row["studio"] == "__nospan__":
row["studio"] = ""
row["span"] = None
# --- аугментация морфологических вариантов -------------------------
rng = random.Random(args.seed)
@@ -187,13 +253,11 @@ def main() -> None:
new_text = row["text"].replace(src_w, dst_w, 1)
if new_text == row["text"]:
continue
span = None
if row["span"]:
found = find_studio_span(new_text, row["studio"])
if not found:
break # автора не нашли в варианте — не добавляем
span = [found[0], found[1]]
rows.append({**row, "text": new_text, "span": span})
found = [find_studio_span(new_text, st) for st in row["studios"]]
if not all(found):
break # автора не нашли в варианте — не добавляем
spans = [[a, z] for a, z in found]
rows.append({**row, "text": new_text, "spans": spans, "span": spans[0] if spans else None})
n_aug += 1
break
@@ -296,9 +360,13 @@ def main() -> None:
f"уникальных нормализованных текстов: {uniq_text}",
f"групп (релиз ∪ одинаковый текст): {len(roots)}",
f"удалено: пустой текст {dropped_empty}, мусорный studio {dropped_junk}, "
f"не-озвучка (оригинал/субтитры) {dropped_type}, "
f"не-озвучка (оригинал/субтитры) {dropped_type}, отказ от озвучки {dropped_nodub}, "
f"противоречивые дубли {len(drop)} (групп-противоречий: {len(contradictions)}), "
f"несколько дорожек в одной строке {dropped_multi}, лишние повторы (> {args.max_dup} одинаковых) {n_capped}",
f"несколько авторов в одной строке: склеено {n_merged} строк, строк с 2+ авторами "
f"{sum(len(r['spans']) > 1 for r in rows)}",
f"ручные решения data/text_fixes.csv: {len(fixes)} текстов, метка заменена в {n_fixed} строках, "
f"выброшено {dropped_fix} строк",
f"спан автора найден: {with_studio} ({with_studio / max(len(rows), 1):.1%}), "
f"не найден: {n_span_miss}",
f"аугментировано морфологических вариантов: {n_aug}",
+234 -36
View File
@@ -11,15 +11,19 @@
from __future__ import annotations
import logging
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)
from .html_extract import (RE_LINE_PREFIX, RE_TYPE_START, _split_entries,
extract_segments, html_to_text)
log = logging.getLogger(__name__)
# аббревиатуры типа озвучки в заголовках раздач -> фраза, понятная модели
TITLE_TYPES = {
@@ -43,7 +47,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 ""
@@ -136,8 +142,8 @@ def aggregate(tracks: list[dict]) -> dict:
uniq = rest
# дорожка без студии, при наличии дорожки того же типа со студией, — как
# правило, та же дорожка в виде кодек-строки («AC3 … | MVO, AlexFilm»)
typed = {t["translation_type"] for t in uniq if t.get("studio")}
uniq = [t for t in uniq if t.get("studio") or t["translation_type"] not in typed]
typed = {(t["translation_type"], t.get("lang")) for t in uniq if t.get("studio")}
uniq = [t for t in uniq if t.get("studio") or (t["translation_type"], t.get("lang")) not in typed]
studios = [{"studio": t["studio"], "translation_type": t["translation_type"],
"lang": t.get("lang"), "is_18plus": t["is_18plus"], "episodes": t.get("episodes")}
for t in uniq if t.get("studio")]
@@ -149,14 +155,11 @@ def aggregate(tracks: list[dict]) -> dict:
}
def original_info(lines: list[dict]) -> dict | None:
"""Оригинальная дорожка из строки «Аудио … |Original|» (без студии и комментариев); нет — None."""
for ln in lines:
if ln["original"] and not ln["names"] and not ln["commentary"]:
return {"lang": ln["lang"] or None, "codec": ln["codec"] or None,
"channels": int(ln["channels"]) if ln["channels"] else None,
"bitrate_kbps": int(ln["kbps"]) if ln["kbps"] else None, "raw": ln["raw"]}
return {"lang": ln["lang"] or None, **audio_summary(ln), "raw": ln["raw"]}
return None
@@ -170,7 +173,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
@@ -198,17 +201,45 @@ def series_info(title: str, tracks: list[dict]) -> dict | None:
if t.get("studio") and not t.get("episodes")})}
def merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> None:
"""Добавляет дорожки из заголовка к дорожкам страницы (на месте).
def _studio_tokens(name: str) -> set[str]:
return {w for w in re.findall(r"\w+", name.casefold()) if len(w) >= 4}
* студия уже есть на странице -> пропуск;
* на странице есть дорожка того же типа без студии -> студия берётся
из заголовка (source=page+title): это та же дорожка;
* иначе -> новая дорожка (source=title).
def same_studio(a: str | None, b: str | None) -> bool:
"""Одна студия в разном написании: «DVD» ~ «DVD Магия», «НТВ» ~ «НТВ+»,
«Михаил Яроцкий» ~ «М.Яроцкий/Kyberpunk» (общее слово ≥ 4 букв)."""
a, b = (a or "").casefold().strip(), (b or "").casefold().strip()
if not a or not b:
return False
return a in b or b in a or bool(_studio_tokens(a) & _studio_tokens(b))
def merge_extra_tracks(tracks: list[dict], extra: list[dict], source: str,
match=lambda a, b: (a or "").casefold() == (b or "").casefold(),
adopt_type: bool = False) -> None:
"""Добавляет дорожки второстепенного источника к дорожкам страницы (на месте).
* такая студия уже есть (по `match`) -> пропуск;
* есть дорожка того же типа без студии -> студия вписывается в неё
(source=page+<source>): это та же дорожка;
* иначе -> новая дорожка (source=<source>).
Дорожки без студии не берутся: тип без студии страница уже даёт, а в
кодек-строках он часто грубее («одноголосый» вместо «авт. одноголосый»).
Дорожка с типом «неизвестно» («Аудио Rus: AC3 … | LostFilm») берётся
только при `adopt_type` и если у страницы ровно один тип без студии
(«Перевод: Профессиональный многоголосый»): она получает этот тип.
"""
for t in title_tracks:
name = (t.get("studio") or "").casefold()
if any((x.get("studio") or "").casefold() == name for x in tracks):
bare = {x["translation_type"] for x in tracks
if not x.get("studio") and x["translation_type"] != UNKNOWN_TYPE}
for t in extra:
if not t.get("studio"):
continue
if t["translation_type"] == UNKNOWN_TYPE:
if not (adopt_type and len(bare) == 1):
continue
t["translation_type"] = next(iter(bare))
if any(match(x.get("studio"), t["studio"]) for x in tracks):
continue
same = next((x for x in tracks if not x.get("studio")
and x["translation_type"] == t["translation_type"]), None)
@@ -216,25 +247,170 @@ def merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> None:
same.update(studio=t["studio"], studio_span=t["studio_span"],
studio_confidence=t["studio_confidence"],
is_18plus=same["is_18plus"] or t["is_18plus"],
source="page+title")
source=f"page+{source}")
else:
t["source"] = "title"
t["source"] = source
tracks.append(t)
def model_segments(segmenter, page_text: str) -> list[str]:
def merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> None:
"""Дорожки из заголовка («ПМ (LostFilm)») -> к дорожкам страницы."""
merge_extra_tracks(tracks, title_tracks, "title")
# кодек-строка: «Аудио 3: AC3 …», «Аудио Rus: …», «Аудиодорожка 1: …», «Audio #2: …»,
# «Аудио #1 Русский: …», «Аудио pic RUS: …», «Звук 1: …», «Track #2: …»
RE_AUDIO_PREFIX = re.compile(
r"(?i)^\s*(?:Аудио\w*|Audio|Звук\w*|Аудиодорожка\w*|Дорожка|Track)\s*[^:\n]{0,16}?[:.]")
# с типом озвучки: «Аудио 3: AC3 … (MVO, HDrezka Studio)»; «English Dubbing» — не тип.
# Аббревиатуры — с кириллическими двойниками («АVO», «МVO», «DVO»).
RE_CODEC_TYPE = (r"(?:[MМ][VВ][OО]|[DД][VВ][OО]|[AА][VВ][OО]|[DД][UУ][BВ]"
r"|(?<![A-Za-zА-Яа-яё])[VВ][OО](?![A-Za-zА-Яа-яё])"
r"|дубл|многоголос|дву[хг]?голос|одноголос|закадр|авторск)")
RE_CODEC_TRACK = re.compile(
r"(?i)^\s*(?:Аудио\w*|Audio|Звук\w*|Аудиодорожка\w*|Дорожка|Track)\s*[^:\n]{0,16}?:.*?" + RE_CODEC_TYPE)
RE_RU_TRACK = re.compile(r"(?i)русск|russian|\brus\b|украин|ukrain|\bukr\b")
RE_FOREIGN_TRACK = re.compile(
r"(?i)казах|english|англ|француз|немец|немецк|итальян|испан|японск|корейск|китайск"
r"|\boriginal\b|оригинал|\beng\b")
def codec_ok(line: str) -> bool:
"""Кодек-строка русской (украинской) дорожки: «Казахский (Dub, TK Qazaqstan)»,
«English Dubbing», «Original» — нет."""
if RE_RU_TRACK.search(line):
return True
return not RE_FOREIGN_TRACK.search(line) and bool(RE_CODEC_TRACK.match(line))
def _entries(line: str) -> list[str]:
body = RE_LINE_PREFIX.sub("", line, count=1)
return [e for e in (re.sub(r"\s+", " ", x).strip() for x in _split_entries(body)) if e]
def codec_segments(page_text: str) -> list[str]:
"""Кодек-строки с типом озвучки («Аудио 2: AC3 … (MVO, LostFilm)») по правилу.
Запасной путь к тому, что выбирает сегментатор (`model_segments`): старые
веса сегментатора кодек-строки отбрасывают. Они нужны там, где студии есть
только в них (kinozal: вкладка «Релиз» против «Перевод:» без студий в
«Техданных»), и идут второстепенным источником, см. `merge_extra_tracks`.
"""
out = []
for line in (page_text or "").splitlines():
if RE_CODEC_TRACK.match(line) and codec_ok(line):
out += _entries(line)
return out
def complete_block(selected: list[str], page_text: str) -> list[str]:
"""Достраивает список дорожек, из которого модель выбрала не все строки.
Сегментатор оценивает строки по отдельности и может взять две из четырёх
строк списка под «Перевод:». Соседние с выбранной строки, начинающиеся со
слова типа озвучки, добавляются в тот же список; строки вдали от выбранных
не трогаются (глоссарии типов правилами не подхватываются).
"""
lines = [re.sub(r"\s+", " ", l).strip() for l in (page_text or "").splitlines()]
lines = [l for l in lines if l]
chosen = set(selected)
idx = [i for i, l in enumerate(lines) if l in chosen]
add = set()
for step in (1, -1): # вниз и вверх от каждой выбранной
for i in idx:
j = i + step
while 0 <= j < len(lines) and lines[j] not in chosen and RE_TYPE_START.match(lines[j]) and not RE_AUDIO_PREFIX.match(lines[j]):
add.add(j)
j += step
return [l for i, l in enumerate(lines) if l in chosen or i in add]
def model_segments(segmenter, page_text: str) -> tuple[list[str], list[str]]:
"""Дорожки страницы: строки выбирает обученный сегментатор.
Возвращает (основные, кодек): строки «Перевод/Озвучка …» и кодек-строки
«Аудио N: AC3 … (MVO, LostFilm)». Кодек-строки обычно дублируют основные
в другом написании, поэтому они второстепенный источник (`predict_release`);
если основных нет (nnm: дорожки только в «Аудио N:») — они и есть дорожки.
Нарезка внутри строки («Дублированный, многоголосый») в данных не
размечена и пока делается правилами `_split_entries`.
"""
main, codec = [], []
for line in complete_block(segmenter.select_lines(page_text), page_text):
if not RE_AUDIO_PREFIX.match(line):
main += _entries(line)
elif codec_ok(line):
codec += _entries(line)
return main, codec
def rule_segments(page_text: str) -> list[str]:
"""Правила по строкам без «Аудио»: кодек-строки модельный путь отбирает сам (`codec_ok`)."""
kept = [l for l in (page_text or "").splitlines() if not RE_AUDIO_PREFIX.match(l)]
return extract_segments("\n".join(kept))
def segmenter_warning(model_on: bool, used: str, segs_page: list[str],
rule_segs: list[str]) -> str | None:
"""Сегментатор расходится с правилами — кандидат на дообучение.
Возвращает текст предупреждения или None. «Нашёл меньше правил» не всегда
ошибка модели (правила дают ложные срабатывания на глоссариях), но такие
страницы стоит просмотреть и добавить в обучение.
"""
if not model_on:
return None
if used == "rules" and rule_segs:
return f"модель не выбрала строк, дорожки по правилам ({len(rule_segs)})"
have = {re.sub(r"\s+", " ", x).strip() for x in segs_page}
missed = [x for x in rule_segs if x not in have]
if used == "model" and missed:
return f"правила нашли дорожек больше, чем модель: +{len(missed)} ({missed[0][:60]})"
return None
def fill_audio(predictor, tracks: list[dict], lines: list[dict]) -> None:
"""Строки «Аудио N: … |студия|» -> к дорожкам (на месте).
* у дорожки нет `audio` (студию дали кодек-строка или заголовок) -> берём строку по студии;
* строка с типом озвучки, но без студии («Ukrainian AC3 … |Dub|»), и такого языка среди
дорожек ещё нет -> новая дорожка (source=codec): тип без студии страница не даёт.
"""
for t in tracks:
if not t.get("audio") and t.get("studio"):
info = next((i for i in lines if any(same_studio(t["studio"], n) for n in i["names"])), None) # «HDrezka» ~ «HDrezka Studio»
if info:
t["audio"] = audio_summary(info)
have = {t.get("lang") for t in tracks}
for info in lines:
if (info["names"] or info["original"] or info["commentary"] or not info["lang"] or info["lang"] in have
or not RE_CODEC_TRACK.match("Аудио: " + info["raw"]) or not codec_ok(info["raw"])):
continue
t = predictor._predict_batch([info["raw"]], [info])[0]
t.update(source="codec", episode_ranges=[], episodes=None)
tracks.append(t)
have.add(info["lang"])
def expand_studios(tracks: list[dict]) -> list[dict]:
"""Строка с несколькими авторами («Многоголосый (HDRezka Studio, LostFilm)») -> дорожка на каждого.
Так их видят сведение дорожек релиза, эпизоды по студиям и сопоставление со строками «Аудио».
"""
out = []
for line in segmenter.select_lines(page_text):
body = RE_LINE_PREFIX.sub("", line, count=1)
for entry in _split_entries(body):
entry = re.sub(r"\s+", " ", entry).strip()
if entry:
out.append(entry)
for t in tracks:
names = t.get("studios") or []
if len(names) < 2:
out.append(t)
continue
seen = set()
for name, span, conf in zip(names, t["studio_spans"], t.get("studio_confidences") or [None] * len(names)):
if name.casefold() in seen:
continue
seen.add(name.casefold())
out.append({**t, "studio": name, "studio_span": span, "studio_confidence": conf,
"studios": [name], "studio_spans": [span], "studio_confidences": [conf]})
return out
@@ -246,17 +422,19 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
(если он загружен и нашёл хотя бы одну строку), иначе правила; "rules" —
только правила.
Заголовок раздачи используется как дополнительный источник: студии,
названные только там («ПМ (LostFilm)»), добавляются как дорожки с
пометкой source=title.
Кодек-строки («Аудио N: … (MVO, LostFilm)») и заголовок раздачи —
дополнительные источники: студии, названные только там, добавляются как
дорожки с пометкой source=codec / source=title.
"""
if (html is None) == (text is None):
raise ValueError("нужно ровно одно из: html, text")
page_text = html_to_text(html) if html is not None else text
segs_page, used = [], "rules"
segs_page, segs_codec, used = [], [], "rules"
seg_model = getattr(predictor, "segmenter", None)
if segmenter == "model" and seg_model is not None:
segs_page = model_segments(seg_model, page_text)
segs_page, segs_codec = model_segments(seg_model, page_text)
if not segs_page:
segs_page, segs_codec = segs_codec, []
used = "model" if segs_page else "rules"
if not segs_page:
segs_page = extract_segments(page_text)
@@ -266,23 +444,43 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
for info in (match_audio(seg, lines) or [None]):
texts.append(seg)
infos.append(info)
tracks = predictor._predict_batch(texts, infos) if texts else []
tracks = expand_studios(predictor._predict_batch(texts, infos)) if texts else []
for t in tracks:
t["source"] = "page"
ep = parse_episodes(t["segment"])
t["episode_ranges"] = ep["ranges"]
t["episodes"] = format_ranges(ep["ranges"]) or None
# кодек-строки — только если страница не назвала студию хотя бы у одной
# дорожки (kinozal: «Перевод: многоголосый» + «Аудио 3: … (MVO, LostFilm)»);
# иначе они дублируют «Перевод N:» в другом написании и дают ложные студии
seen = set(segs_page)
segs_codec = list(dict.fromkeys(x for x in segs_codec + codec_segments(page_text)
if x not in seen))
if segs_codec and (not tracks or any(not t.get("studio") for t in tracks)):
codec = expand_studios(predictor._predict_batch(segs_codec))
for t in codec:
ep = parse_episodes(t["segment"])
t["episode_ranges"] = ep["ranges"]
t["episodes"] = format_ranges(ep["ranges"]) or None
merge_extra_tracks(tracks, codec, "codec", match=same_studio, adopt_type=True)
if title is None:
title = title_from_html(html) if html is not None else title_from_text(text)
segs = title_segments(title or "")
if segs:
merge_title_tracks(tracks, predictor._predict_batch(segs))
merge_title_tracks(tracks, expand_studios(predictor._predict_batch(segs)))
fill_audio(predictor, tracks, lines)
result = aggregate(tracks)
warn = segmenter_warning(seg_model is not None and segmenter == "model", used,
segs_page + segs_codec, rule_segments(page_text))
if warn:
log.warning("segmenter: %s", warn)
result["segmenter_warning"] = warn
result["original"] = original_info(lines)
apply_audio_18(result, page_text)
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
+1 -1
View File
@@ -47,7 +47,7 @@ def test_rows(dataset: Path | str | None = None) -> list[dict]:
def fingerprint(rows: list[dict]) -> str:
"""Отпечаток тестовой выборки: те же строки с теми же метками (порядок не важен)."""
keys = sorted(json.dumps([r["text"], r["translation_type"], r.get("span"), bool(r.get("is_18plus")),
keys = sorted(json.dumps([r["text"], r["translation_type"], r.get("spans") or r.get("span"), bool(r.get("is_18plus")),
r.get("lang")], ensure_ascii=False) for r in rows)
return hashlib.sha1("\n".join(keys).encode("utf-8")).hexdigest()[:10]
+89 -13
View File
@@ -11,8 +11,10 @@ Python API:
seg = LineSegmenter("models/segmenter")
seg.select_lines(page_text) -> list[str] # строки-дорожки (префикс не снят)
Разметка: строка страницы положительна, если содержит `raw` строки датасета
этого релиза (или наоборот). Положительные примеры добавляются синтетически:
Разметка: строка страницы положительна, если содержит `raw` (или `audio_raw`)
строки датасета этого релиза (или наоборот); неразмеченные кодек-строки русских
дорожок в обучение не идут (см. `label_page`). Текст kinozal собирается из
data/raw/kinozal/*.json (`kinozal_text`). Положительные примеры добавляются синтетически:
`raw` всех train-строк датасета (включая nnm/kinozal, у которых нет страниц)
вставляются в контекст реальных строк страниц. Нарезка ВНУТРИ строки
(«Дублированный, многоголосый») в данных не размечена и остаётся на правилах.
@@ -32,7 +34,7 @@ import numpy as np
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer
from .html_extract import read_input
from .html_extract import RE_SCENE, read_input
BACKBONE = "cointegrated/rubert-tiny2"
MAX_LEN = 160
@@ -69,8 +71,34 @@ def page_lines(text: str) -> list[str]:
# ---------------------------------------------------------------- данные
RAW_KINOZAL = Path("data/raw/kinozal")
def kinozal_text(release_id: str, raw_dir: Path = RAW_KINOZAL) -> str | None:
"""Текст страницы kinozal так, как его видит сервис: заголовок + «Техданные»
(«Перевод: …») + «Релиз» («Аудио N: … (MVO, LostFilm)»).
В data/pages/kz-*.txt нет ни заголовка, ни «Техданных», а метки kinozal
наполовину взяты именно оттуда, поэтому для kinozal берётся raw JSON.
"""
path = raw_dir / f"{str(release_id).removeprefix('kz-')}.json"
if not path.exists():
return None
j = json.loads(path.read_text(encoding="utf-8"))
return "\n".join(x for x in (j.get("h1"), j.get("tech_tab"), j.get("release_tab")) if x)
def page_text(path: Path) -> str:
"""Текст сохранённой страницы релиза (kinozal — из raw JSON, если он есть)."""
if path.stem.startswith("kz-"):
text = kinozal_text(path.stem, path.parent.parent / "raw" / "kinozal")
if text:
return text
return read_input(path)
def load_pages(pages_dir: Path = Path("data/pages")) -> dict[str, list[str]]:
return {p.stem: page_lines(read_input(p)) for p in sorted(pages_dir.glob("*.txt"))}
return {p.stem: page_lines(page_text(p)) for p in sorted(pages_dir.glob("*.txt"))}
def load_raws(csv_path: Path = Path("data/labels.csv")) -> list[dict]:
@@ -78,12 +106,53 @@ def load_raws(csv_path: Path = Path("data/labels.csv")) -> list[dict]:
return list(csv.DictReader(f))
# кодек-строка русской/украинской дорожки: «Аудио 2: AC3 … Русский (MVO, X)»,
# «Аудио Rus: AC3 … | LostFilm», «Аудиодорожка 1: … | Russian | TVShows»,
# «Аудио #1 Русский: …», «Аудио pic RUS: …», «Звук 1: …»;
# сводка «Аудио: Русский, английский (AC3 …)» без номера — не дорожка
RE_AUDIO_TRACK = re.compile(
r"(?i)^\s*(?:Аудио\w*|Audio|Звук\w*|Аудиодорожка\w*|Дорожка|Track)\s*[^:\n]{0,16}?[:.]"
r".*(?:русск|russian|\brus\b|украин|ukrain|\bukr\b)")
RE_TYPE_WORD = re.compile(
r"(?i)\b(?:MVO|DVO|AVO|DUB|VO)\b|дубл|многоголос|двухголос|одноголос|закадр|авторск")
# технические слова кодек-строки: всё, что после их удаления осталось, — имя студии/автора
RE_TECH_WORD = re.compile(
r"(?i)^(?:аудио\w*|audio|e?-?ac-?3|eac3|ac|dts|hd|ma|mpeg|mp3|aac|lc|opus|flac|pcm|lpcm|truehd"
r"|atmos|joc|dolby|digital|surround|stereo|mono|cbr|vbr|avg|constant|core|bit|ch|сh|channels?"
r"|каналов|канала|kbps|kb|кбит|бит|сек|khz|кгц|hz|lfe|dn|db|rus|ukr|eng|russian|русск\w*"
r"|украин\w*|english|англ\w*|l|c|r|sl|sr|ls|rs|lr|cbr|a)$")
def _informative(line: str) -> bool:
"""Кодек-строка называет тип озвучки или студию/автора, а не только параметры.
«Аудио 1: AC3, 6 ch, 384 kbps - Русский» — нет; «… | LostFilm»,
«… - Русский, Юрий Живов», «… (MVO)» — да.
"""
if RE_TYPE_WORD.search(line):
return True
body = re.sub(r"(?i)^\s*(?:Аудио\w*|Audio)\s*(?:№|#)?\s*(?:\d+|rus|ukr)?\s*[:.]", "", line)
words = [w for w in re.findall(r"[^\W\d_]+", body) if not RE_TECH_WORD.match(w)]
return any(len(w) >= 3 for w in words)
def label_page(lines: list[str], raws: list[str]) -> list[int]:
"""1, если строка содержит raw (или raw содержит строку ≥ 15 символов)."""
"""1 — строка содержит raw (или raw содержит строку ≥ 15 символов);
-1 — неразмеченная кодек-строка русской дорожки с типом или студией: в
обучение не идёт; пустая кодек-строка («AC3, 6 ch, 384 kbps - Русский») — 0.
Кодек-строка «Аудио N: … (MVO, LostFilm)» — дорожка, но у rutracker
`raw` — строка «Перевод N:», а кодек-строка в разметку не попадает. Как
негатив она учила модель отбрасывать такие строки (на kinozal студии
только в них), поэтому неразмеченные кодек-строки пропускаются.
"""
out = []
for line in lines:
hit = any(r and (r in line or (len(line) >= 15 and line in r)) for r in raws)
out.append(int(hit))
skip = RE_AUDIO_TRACK.match(line) and _informative(line)
out.append(1 if hit else (-1 if skip else 0))
return out
@@ -93,7 +162,10 @@ def build_examples(split: str, rng: random.Random, synth_per_page: int = 6) -> l
rows = load_raws()
raws_by_rel: dict[str, list[str]] = {}
for r in rows:
raws_by_rel.setdefault(r["release_id"], []).append(norm(r["raw"]))
# audio_raw — кодек-строка той же дорожки («AC3 … Русский (MVO, X)»), тоже позитив
for text in (r["raw"], r.get("audio_raw")):
if norm(text):
raws_by_rel.setdefault(r["release_id"], []).append(norm(text))
want_test = split == "test"
page_items = [] # (release_id, lines, labels)
@@ -105,7 +177,7 @@ def build_examples(split: str, rng: random.Random, synth_per_page: int = 6) -> l
# все raw train-релизов (без тестовых страниц) — материал для синтетики
test_pos = {l for rid, lines in pages.items() if is_test_release(rid)
for l, y in zip(lines, label_page(lines, raws_by_rel.get(rid, []))) if y}
for l, y in zip(lines, label_page(lines, raws_by_rel.get(rid, []))) if y == 1}
pool = sorted({norm(r["raw"]) for r in rows
if r["raw"] and not is_test_release(r["release_id"])
and norm(r["raw"]) not in test_pos})
@@ -113,13 +185,15 @@ def build_examples(split: str, rng: random.Random, synth_per_page: int = 6) -> l
ex: list[tuple] = []
for rid, lines, labels in page_items:
for i, (line, y) in enumerate(zip(lines, labels)):
if y < 0:
continue # неразмеченная кодек-строка: ни позитив, ни негатив
prev = lines[i - 1] if i else ""
nxt = lines[i + 1] if i + 1 < len(lines) else ""
if not y and not HINT.search(line) and rng.random() > 0.15:
continue # простые негативы прореживаем, трудные (с намёками) берём все
if not y and not HINT.search(line) and not RE_SCENE.match(line) and rng.random() > 0.15:
continue # простые негативы прореживаем, трудные (с намёками, scene-имена релизов) берём все
ex.append((*make_input(prev, line, nxt), y))
if not want_test:
negs = [l for l, y in zip(lines, labels) if not y]
negs = [l for l, y in zip(lines, labels) if y == 0]
for _ in range(synth_per_page if negs else 0):
raw = rng.choice(pool)
p, n = rng.choice(negs), rng.choice(negs)
@@ -157,7 +231,7 @@ class LineSegmenter:
if not lines:
return []
probs = self.score(lines)
return [l for l, p in zip(lines, probs) if p >= threshold]
return [l for l, p in zip(lines, probs) if p >= threshold and not RE_SCENE.match(l)]
# ---------------------------------------------------------------- обучение
@@ -202,8 +276,10 @@ def train(out_dir: str = "models/segmenter", epochs: int = 4, batch: int = 32, l
opt.step()
sched.step()
opt.zero_grad()
if steps >= 4000 and (s // batch) % 2000 == 0:
print(f" epoch {ep + 1}: {s // batch}/{steps} шагов, loss {loss.item():.3f}", flush=True)
p, r = evaluate(te)
print(f"epoch {ep + 1}: test line precision {p:.3f} recall {r:.3f}")
print(f"epoch {ep + 1}: test line precision {p:.3f} recall {r:.3f}", flush=True)
Path(out_dir).mkdir(parents=True, exist_ok=True)
model.save_pretrained(out_dir)
tok.save_pretrained(out_dir)
+30 -24
View File
@@ -1,4 +1,4 @@
"""Общие для обучения и сервиса: длина входа и разбор BIO-предсказания в символьный спан.
"""Общие для обучения и сервиса: длина входа и разбор BIO-предсказания в символьные спаны.
Вынесено из train.py, чтобы контейнеру с сервисом не нужны были sklearn и остальные пакеты обучения.
"""
@@ -8,33 +8,39 @@ from __future__ import annotations
MAX_LEN = 128
def decode_span(bio_pred, offsets, attention) -> tuple[int, int] | None:
"""B/I-прогон -> символьный спан.
def decode_spans(bio_pred, offsets, attention) -> list[tuple[int, int]]:
"""B/I-прогоны -> символьные спаны всех авторов строки («Многоголосый (HDRezka Studio, LostFilm)»).
Учитываются только реальные токены: с attention=1 и непустым offsets
(спецтокены имеют offsets (0, 0) и пропускаются).
Прогон начинается с B или I и продолжается токенами I; B начинает новый прогон, O и спецтокен
его заканчивают. Учитываются только реальные токены: с attention=1 и непустым offsets
(спецтокены имеют offsets (0, 0)).
"""
bi = None
out: list[tuple[int, int]] = []
cur: list[int] | None = None
for t in range(len(bio_pred)):
if not attention[t]:
break
a, z = offsets[t]
if z <= a:
tag = int(bio_pred[t]) if z > a else 0
if tag == 2 and cur is not None:
cur[1] = int(z)
continue
if bio_pred[t] in (1, 2):
bi = t
break
if bi is None:
return None
ei = bi
for t in range(bi + 1, len(bio_pred)):
if not attention[t]:
break
a, z = offsets[t]
if z <= a:
break
if bio_pred[t] == 2:
ei = t
else:
break
return int(offsets[bi][0]), int(offsets[ei][1])
if cur is not None:
out.append((cur[0], cur[1]))
cur = None
if tag in (1, 2):
cur = [int(a), int(z)]
if cur is not None:
out.append((cur[0], cur[1]))
return out
def decode_span(bio_pred, offsets, attention) -> tuple[int, int] | None:
"""Первый B/I-прогон -> символьный спан (основной автор строки)."""
spans = decode_spans(bio_pred, offsets, attention)
return spans[0] if spans else None
def row_spans(row: dict) -> list[list[int]]:
"""Золотые спаны строки датасета: «spans» (все авторы), у старых датасетов — один «span»."""
return row.get("spans") or ([row["span"]] if row.get("span") else [])
+12 -2
View File
@@ -56,10 +56,20 @@ SUFFIX_NOISE = re.compile(
r"[\s\-–—|]*\(?\b(НЕНОРМАТИВНАЯ\s+ЛЕКСИКА|ненормативная\s+лексика|18\s*\+)\)?",
re.I)
# «мусорные» значения studio, которые автором не являются
# «мусорные» значения studio, которые автором не являются: отказы от озвучки,
# техпараметры, языки, источники, служебные пометки
JUNK_STUDIO_RE = re.compile(
r"^(?:\+?\s*вставки?|x4|viare|лицензия(?:\s+R5)?|полное дублирование R5"
r"|R5 Premier Digital|Гемини фильм R5)$", re.I)
r"|R5 Premier Digital|Гемини фильм R5"
r"|отсутств\w*|отсутсву\w*|без\s*перевода|не\s*требу\w*|немо[йе]|без\s*слов"
r"|в\s*составе\s*контейнера|поверх\s*\w+|по\s*умолчанию|оригинал\w*"
r"|(?:русск|украин|англ|япон|китай|корей|немец|француз|итальян|испан|казах)\w*"
r"|\d+\s*(?:ch|канал(?:а|ов)|сери\w*)|.*\b(?:kbps|кбит|khz|кгц)\b.*"
r"|(?:e-?ac-?3|ac-?3|dts|aac|flac|mp3|opus|pcm|truehd)(?:\s+[\d.]+)?"
r"|\(?\d+\)?\s*(?:[.)]|[-–](?!\s*(?:й|я|ый|ой|ий)\b))\s*\S.*|\d+\s*[-–]?\s*\d*\s*эп\w*"
r"|тайминг\w*|редактор\w*|оформлени\w*|неизвестн\w*"
r"|\d+(?:[.,]\d+)?\s*(?:kbps|кбит|kb/s|khz|кгц|гц|hz|ch|канал(?:а|ов))"
r"|anidb|mal|shikimori|world\s*art|описание|список)$", re.I)
def clean_studio(raw: str) -> str:
+268
View File
@@ -0,0 +1,268 @@
"""Техданные релиза помимо озвучки: видео, тип релиза, контейнер, продолжительность, субтитры.
Только правила по строкам страницы («Видео: …», «Тип релиза: …», «Субтитры: …») и по
заголовку раздачи — нейросеть здесь не нужна, форматы у трекеров устойчивые.
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+)")
# сокращение в заголовке: «DV 8.1», «DV P7», «DoVi P5»; только реальные профили, «DV 10-bit» — не профиль
RE_DV_SHORT = re.compile(r"(?i)\b(?:DV|DoVi|Dolby\s*Vision)\s*(?:P|profile\s*)?([45789](?:\.[1-4])?)\b(?!\s*-?\s*bit)")
HDR_FLAGS = [ # (шаблон, флаг) — что нашли, в этом порядке и отдаём
(r"dolby\s*vision|\bDoVi\b|\bDV\b", "Dolby Vision"), # «DV» из заголовка; \b не цепляет DVD/DVDRip
(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) or RE_DEPTH.search(title or "") # nnm: «10-bit» бывает только в заголовке
dv = (RE_DV_PROFILE.search(src) or RE_DV_PROFILE.search("\n".join(_field(text, r"HDR\s*format")))
or RE_DV_SHORT.search(src) or RE_DV_SHORT.search(title or ""))
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)}
+87
View File
@@ -0,0 +1,87 @@
"""Ручные решения по противоречивым строкам: одинаковый текст дорожки получил разные метки.
data/text_fixes.csv (text, translation_type, studio, action): action «set» — всем строкам с этим
текстом ставится указанный тип и авторы (studio пусто — автора нет; несколько авторов строки —
через «;»: «HDRezka Studio ; LostFilm»), «drop» — строки выбрасываются.
Ключ — текст дорожки без хвоста «Аудио» (build_text), без учёта регистра и пробелов. Решения
применяет prepare_data поверх labels.csv, поэтому они переживают пересборку меток из data/raw.
Файл дописывается построчно; при повторном решении по тому же тексту действует последнее.
Разбор — tools/collect.py → «Разбор противоречий».
"""
from __future__ import annotations
import csv
import re
from collections import defaultdict
from pathlib import Path
FIXES_FILE = Path("data/text_fixes.csv")
FIELDS = ["text", "translation_type", "studio", "action"]
ACTIONS = {"set", "drop"}
def norm(text: str) -> str:
return re.sub(r"\s+", " ", (text or "").strip().lower())
def load(path: Path | None = None) -> dict[str, dict]:
p = path or FIXES_FILE
if not p.exists():
return {}
with p.open(encoding="utf-8-sig", newline="") as f:
return {norm(r["text"]): r for r in csv.DictReader(f) if r.get("action") in ACTIONS}
def save(fix: dict, path: Path | None = None) -> None:
"""Дописать одно решение (сразу на диск: разбор можно прервать в любой момент)."""
assert fix.get("action") in ACTIONS and fix.get("text"), fix
p = path or FIXES_FILE
fresh = not p.exists() or p.stat().st_size == 0
with p.open("a", encoding="utf-8-sig" if fresh else "utf-8", newline="") as f:
w = csv.DictWriter(f, FIELDS)
if fresh:
w.writeheader()
w.writerow({k: fix.get(k, "") for k in FIELDS})
def conflicts(raw_rows: list[dict], fixes: dict[str, dict] | None = None) -> list[dict]:
"""Тексты, которые разные релизы разметили по-разному (ещё не решённые).
Вариант разметки — тип и набор авторов строки в релизе: «Многоголосый (HDRezka Studio, LostFilm)»
даёт в релизе две строки, но это один вариант (оба автора верны, prepare_data склеит их в одну).
Автор берётся так, как его видит модель, — фрагментом текста («NewStudio» и «Newstudio» совпадают).
"""
from .prepare_data import FIX_SEP, row_label # prepare_data сам импортирует этот модуль
from .studio_norm import find_studio_span
fixes = load() if fixes is None else fixes
# текст -> релиз -> тип -> {начало спана: автор}
by_text: dict[str, dict] = {}
for r in raw_rows:
lab = row_label(r)
if lab["drop"] or norm(lab["text"]) in fixes:
continue
key = re.sub(r"\s+", " ", lab["text"].strip()) # регистр различается моделью: «LostFilm» ≠ «Lostfilm»
item = by_text.setdefault(key, {"text": lab["text"], "rel": defaultdict(lambda: defaultdict(dict)),
"rows": 0})
names = item["rel"][(r.get("source", ""), r.get("release_id", ""))][lab["type"]]
for name in filter(None, (n.strip() for n in lab["studio"].split(FIX_SEP))):
span = find_studio_span(lab["text"], name)
if span:
names[span[0]] = lab["text"][span[0]:span[1]]
item["rows"] += 1
out = []
for item in by_text.values():
variants: dict[tuple, list] = defaultdict(list)
for rel, per_type in item["rel"].items():
for ttype, names in per_type.items():
studios = tuple(dict.fromkeys(names[k] for k in sorted(names)))
variants[(ttype, studios)].append(rel)
if len(variants) < 2:
continue
out.append({"text": item["text"], "rows": item["rows"],
"variants": sorted(((t, list(st), sorted(rels)) for (t, st), rels in variants.items()),
key=lambda v: (-len(v[2]), v[0], v[1]))})
out.sort(key=lambda i: (-i["rows"], i["text"]))
return out
+27 -2
View File
@@ -21,6 +21,7 @@ LANG_WORDS = [ # корень слова -> код; пор
("чешск", "CZE"), ("латв", "LAV"), ("литов", "LIT"), ("эстон", "EST"),
("грузин", "GEO"), ("армян", "ARM"), ("азербайдж", "AZE"), ("узбек", "UZB"),
("тюркск", "TUR"), ("турецк", "TUR"), ("португал", "POR"), ("хинди", "HIN"),
("rus", "RUS"), ("ukr", "UKR"), ("eng", "ENG"), # «Аудио RUS: …», «| Rus |»
]
LANG_CODES = {"RUS": "RUS", "UKR": "UKR", "ENG": "ENG", "BEL": "BEL", "KAZ": "KAZ",
"POL": "POL", "FRA": "FRA", "FRE": "FRA", "DEU": "DEU", "GER": "DEU",
@@ -28,12 +29,18 @@ LANG_CODES = {"RUS": "RUS", "UKR": "UKR", "ENG": "ENG", "BEL": "BEL", "KAZ": "KA
"CZE": "CZE", "LAV": "LAV", "LIT": "LIT", "EST": "EST", "GEO": "GEO",
"ARM": "ARM", "AZE": "AZE", "UZB": "UZB", "TUR": "TUR", "POR": "POR", "HIN": "HIN"}
RE_AUDIO = re.compile(r"(?im)^[^\S\n]*Аудио(?:[^\S\n]*0*\d*)?[^\S\n]*:?[^\S\n]*(?P<body>[^\n]+)$")
RE_AUDIO = re.compile(
r"(?im)^[^\S\n]*(?P<kw>Аудио\w*|Audio|Звук\w*|Аудиодорожка\w*|Дорожка|Track)"
r"(?P<mid>[^:\n]{0,16}?)[:.]\s*(?P<body>[^\n]+)$")
# «Аудио кодек: MP3», «Аудио битрейт: …» — поля описания, а не дорожка
RE_AUDIO_LABEL = re.compile(r"(?i)^\s*(?:кодек|битрейт|канал|язык)")
RE_LEAD_CODE = re.compile(r"^\s*([A-Za-z]{3})\s*:")
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 +85,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,10 +97,24 @@ 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 ""):
info = parse_audio_line(m.group("body"))
mid = (m.group("mid") or "").strip()
if RE_AUDIO_LABEL.match(mid):
continue
# язык может стоять до двоеточия: «Аудио RUS: АС3 …», «Аудио pic RUS: …»
info = parse_audio_line(f"{mid} {m.group('body')}".strip())
info["raw"] = m.group("body").strip()
if info["codec"] or info["channels"] or info["kbps"]: # настоящая строка дорожки
out.append(info)
return out
+13 -17
View File
@@ -30,7 +30,7 @@ from torch.utils.data import DataLoader, Dataset
from transformers import AutoTokenizer, get_linear_schedule_with_warmup
from .model import DubTagger, predict_with_lang, save_model
from .spans import MAX_LEN, decode_span # noqa: F401 (прежнее место; сервису не нужны sklearn/matplotlib)
from .spans import MAX_LEN, decode_span, decode_spans, row_spans # noqa: F401 (прежнее место; сервису не нужны sklearn/matplotlib)
MODEL_CONFIGS = {
"tiny2": {"backbone": "cointegrated/rubert-tiny2", "lr": 1e-4,
@@ -89,15 +89,12 @@ def make_collate(tokenizer, type2id: dict[str, int], max_len: int = MAX_LEN,
bio = torch.zeros_like(enc["input_ids"])
bio[enc["attention_mask"] == 0] = -100
for i, b in enumerate(batch):
span = b.get("span")
if not span:
continue
s, e = span
first = True
for t, (a, z) in enumerate(offsets[i]):
if z > a and a < e and z > s: # токен пересекает спан
bio[i, t] = 1 if first else 2 # B / I
first = False
for s, e in row_spans(b): # все авторы строки: «(HDRezka Studio, LostFilm)»
first = True
for t, (a, z) in enumerate(offsets[i]):
if z > a and a < e and z > s: # токен пересекает спан
bio[i, t] = 1 if first else 2 # B / I
first = False
types = torch.tensor([type2id[b["translation_type"]] for b in batch])
ages = torch.tensor([int(bool(b.get("is_18plus"))) for b in batch])
if lang2id: # неизвестный язык (None) = -100: в лосс и метрику не входит
@@ -117,8 +114,8 @@ def _NORM(s: str | None) -> str | None:
def evaluate(model, loader, device, id2type, per_row: list | None = None) -> dict:
"""Метрики на выборке: тип (acc, macro-F1), спан (exact), 18+ (acc, F1).
Exact match спана сравнивает нормализованный текст предсказанного спана
с текстом золотого спана (None == None считается совпадением).
Exact match спана сравнивает набор нормализованных текстов предсказанных спанов
с набором золотых (у строки может быть несколько авторов; пустой == пустой — совпадение).
joint_exact — строка распознана полностью: тип + автор + 18+.
"""
model.eval()
@@ -143,11 +140,10 @@ def evaluate(model, loader, device, id2type, per_row: list | None = None) -> dic
base = len(span_ok)
rows = loader.dataset.rows
for i in range(len(types)):
pred = decode_span(bio_pred[i], offsets[i], attn[i])
gold = rows[base + i].get("span")
pred_txt = rows[base + i]["text"][pred[0]:pred[1]] if pred else None
gold_txt = rows[base + i]["text"][gold[0]:gold[1]] if gold else None
span_ok.append(_NORM(pred_txt) == _NORM(gold_txt))
row = rows[base + i]
pred = {_NORM(row["text"][a:z]) for a, z in decode_spans(bio_pred[i], offsets[i], attn[i])}
gold = {_NORM(row["text"][a:z]) for a, z in row_spans(row)}
span_ok.append(pred == gold) # весь набор авторов строки
y = [id2type[v] for v in y_ids]
p = [id2type[v] for v in p_ids]
type_ok = [a == b for a, b in zip(y, p)]
+30 -12
View File
@@ -2,24 +2,31 @@
Метрика — пары (тип, автор) на уровне релиза против меток датасета, только по
страницам, которых сегментатор не видел при обучении (is_test_release).
Запуск: .venv/Scripts/python tests/e2e_segmenter.py
Текст kinozal — из data/raw/kinozal/*.json (как у сервиса), см. segmenter.page_text.
На трекер берётся не больше E2E_MAX страниц (по умолчанию 400, детерминированно);
train-страницы считаются только с флагом --train-pages. Основная модель — E2E_MODEL
(по умолчанию models/best).
Запуск: .venv/Scripts/python tests/e2e_segmenter.py [каталог_сегментатора] [--train-pages]
"""
from __future__ import annotations
import hashlib
import json
import os
import sys
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.html_extract import read_input # noqa: E402
from src.predict import DubPredictor # noqa: E402
from src.release import predict_release # noqa: E402
from src.segmenter import is_test_release # noqa: E402
from src.segmenter import is_test_release, page_text # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
MAX_PER_SOURCE = int(os.environ.get("E2E_MAX", "400"))
MODEL_DIR = os.environ.get("E2E_MODEL", "models/best")
def run(predictor, rows, mode: str, test_only: bool) -> dict:
@@ -27,17 +34,25 @@ def run(predictor, rows, mode: str, test_only: bool) -> dict:
for r in rows:
by_rel[r["release_id"]].append(r)
per_src = defaultdict(list)
for page in sorted((ROOT / "data" / "pages").glob("*.txt")):
pages = [p for p in (ROOT / "data" / "pages").glob("*.txt")
if is_test_release(p.stem) == test_only and p.stem in by_rel]
pages.sort(key=lambda p: hashlib.md5(p.stem.encode()).hexdigest())
for page in pages:
rid = page.stem
if is_test_release(rid) != test_only or rid not in by_rel:
src = by_rel[rid][0]["source"]
if len(per_src[src]) >= MAX_PER_SOURCE:
continue
gold = {(r["translation_type"], r["studio"] or None) for r in by_rel[rid]}
res = predict_release(predictor, text=read_input(page), segmenter=mode)
# как aggregate: «тип без студии» не отдельная дорожка, если у типа есть студия
# (kinozal: «Перевод: многоголосый» + студии из заголовка)
named = {t for t, s in gold if s}
gold = {(t, s) for t, s in gold if s or t not in named}
res = predict_release(predictor, text=page_text(page), segmenter=mode)
pred = {(t["translation_type"], t["studio"]) for t in res["tracks"]}
tp = len(gold & pred)
p = tp / len(pred) if pred else 0.0
r = tp / len(gold) if gold else 0.0
per_src[by_rel[rid][0]["source"]].append((p, r))
per_src[src].append((p, r))
out = {}
for s, v in per_src.items():
out[s] = (len(v), sum(x[0] for x in v) / len(v), sum(x[1] for x in v) / len(v))
@@ -46,13 +61,16 @@ def run(predictor, rows, mode: str, test_only: bool) -> dict:
def main() -> None:
rows = json.loads((ROOT / "data" / "processed" / "dataset.json").read_text(encoding="utf-8"))
seg_dir = sys.argv[1] if len(sys.argv) > 1 else "models/segmenter"
predictor = DubPredictor(segmenter_dir=seg_dir)
print(f"сегментатор: {seg_dir}")
args = [a for a in sys.argv[1:] if not a.startswith("--")]
seg_dir = args[0] if args else "models/segmenter"
predictor = DubPredictor(MODEL_DIR, segmenter_dir=seg_dir)
print(f"модель: {MODEL_DIR}; сегментатор: {seg_dir}")
if predictor.segmenter is None:
sys.exit("нет models/segmenter: python -m src.segmenter --train")
for test_only, name in ((True, "тестовые страницы (не виделись сегментатору)"),
(False, "train-страницы (для справки, оптимистично)")):
blocks = [(True, "тестовые страницы (не виделись сегментатору)")]
if "--train-pages" in sys.argv:
blocks.append((False, "train-страницы (для справки, оптимистично)"))
for test_only, name in blocks:
print(f"== {name} ==")
for mode in ("rules", "model"):
res = run(predictor, rows, mode, test_only)
+359 -1
View File
@@ -16,7 +16,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.html_extract import (RE_LINE_PREFIX, _split_entries, # noqa: E402
from src.html_extract import (RE_LINE_PREFIX, RE_SCENE, _split_entries, # noqa: E402
extract_segments, html_to_text)
from src.studio_norm import canonical, find_studio_span # noqa: E402
@@ -102,6 +102,29 @@ segs = extract_segments("Перевод:\nПрофессиональный (ду
check("значение на следующей строке",
segs == ["Профессиональный (дублированный) Невафильм"], f"got {segs}")
segs = extract_segments(
"Перевод: \n"
"Профессиональный (дублированный) | Cinema Tone Production\n"
"Профессиональный (псевдодублированный) | HDRezka Studio\n"
"Профессиональный (многоголосый закадровый) | HDRezka Studio\n"
"Профессиональный (многоголосый закадровый) | TVShows\n"
"\nОригинальная аудиодорожка: Английская\n")
check("список строк под «Перевод:» (rt 6919056)", segs == [
"Профессиональный (дублированный) | Cinema Tone Production",
"Профессиональный (псевдодублированный) | HDRezka Studio",
"Профессиональный (многоголосый закадровый) | HDRezka Studio",
"Профессиональный (многоголосый закадровый) | TVShows"], f"got {segs}")
_rt_html = ('<div class="post_body"><span class="post-b">Перевод</span>: <br>\n'
'Профессиональный (дублированный) <span class="post-b">| Cinema Tone Production</span><br>\n'
'Профессиональный (псевдодублированный) <span class="post-b">| HDRezka Studio</span><br>\n'
'Профессиональный (многоголосый закадровый) <span class="post-b">| HDRezka Studio</span><br>\n'
'Профессиональный (многоголосый закадровый) <span class="post-b">| TVShows</span><br>\n<br>\n'
'<span class="post-b">Оригинальная аудиодорожка</span>: Английская</div>')
check("rutracker: <br>+\\n между дорожками (t=6919056)",
len(extract_segments(html_to_text(_rt_html))) == 4,
f"got {extract_segments(html_to_text(_rt_html))}")
segs = extract_segments("Перевод: Профессиональный (двухголосый закадровый) + Любительский (одноголосый закадровый)")
check("разделитель +", len(segs) == 2, f"got {segs}")
@@ -114,6 +137,31 @@ check("codec-строка без типа пропущена", len(segs) == 0, f
check("_split_entries одиночная", _split_entries("Дублированный") == ["Дублированный"])
# ---------------------------------------------------------------- сегментатор: достройка блока
print("== complete_block / segmenter_warning ==")
from src.release import complete_block, segmenter_warning # noqa: E402
_pg = ("Перевод:\nПрофессиональный (дублированный) | A\n\nПрофессиональный (псевдодублированный) | B\n"
"Профессиональный (многоголосый закадровый) | C\nПрофессиональный (многоголосый закадровый) | D\n"
"Субтитры: русские\n\nПрофессиональный дублированный: глоссарий\n")
_got = complete_block(["Профессиональный (псевдодублированный) | B"], _pg)
check("модель взяла 1 из 4 -> достроены все 4, глоссарий не тронут", len(_got) == 4
and all("глоссарий" not in x for x in _got), f"got {_got}")
check("ничего не выбрано -> ничего не добавляется", complete_block([], _pg) == [])
check("предупреждение: модель пуста, правила нашли", "не выбрала" in (segmenter_warning(True, "rules", [], ["x"]) or ""))
check("предупреждение: правила нашли больше", "больше" in (segmenter_warning(True, "model", ["a"], ["a", "b"]) or ""))
check("без предупреждения: совпало / нет модели",
segmenter_warning(True, "model", ["a"], ["a"]) is None and segmenter_warning(False, "rules", [], ["x"]) is None)
check("scene-имя релиза — не дорожка (kinozal 2160145)",
RE_SCENE.match("Memoirs.of.a.Geisha.2005.2160p.UHD.Blu-ray.Remux.DV.HDR.HEVC.TrueHD.Atmos.7.1-CiNEPHiLES")
and not RE_SCENE.match("Профессиональный (многоголосый закадровый) | HDRezka Studio")
and not RE_SCENE.match("Аудио 1: Русский (DTS-HD MA, 6 ch, 2240 Кбит/с) / Дублированный"))
from src.segmenter import LineSegmenter # noqa: E402
import numpy as _np # noqa: E402
_seg = object.__new__(LineSegmenter)
_seg.score = lambda lines: _np.ones(len(lines)) # модель «выбирает» всё
_got = _seg.select_lines("Перевод: Дублированный\nMemoirs.of.a.Geisha.2005.2160p.UHD.Remux-GRP\nАудио 1: Русский / Дублированный")
check("select_lines отбрасывает scene-имя даже при оценке 1.0", len(_got) == 2 and all("Geisha" not in x for x in _got), str(_got))
# ---------------------------------------------------------------- html
print("== html_to_text ==")
html = ("<html><body><div>меню форума</div>"
@@ -219,6 +267,74 @@ check("title: студия вписана в дорожку без студии"
check("title: дубль студии пропущен, новая добавлена",
len(_page) == 3 and _page[2]["source"] == "title")
from src.fetch import inline_tabs, kinozal_tabs # noqa: E402
from src.release import codec_segments, merge_extra_tracks, same_studio # noqa: E402
_kz = ('<ul><li><a onclick="showtab(7,100); return false;" href="#">Техданные</a></li>'
'<li><a onclick="showtab(7,0); return false;" href="#">Релиз</a></li>'
'<li><a onclick="showtab(7,2); return false;" href="#">Скриншоты</a></li></ul>'
'<div id="tabs"><b>Перевод:</b> Профессиональный многоголосый</div>')
check("kinozal: вкладки без «Техданных» и скриншотов", kinozal_tabs(_kz) == [("7", 0, "Релиз")])
_kz_text = html_to_text(inline_tabs(_kz, [("Релиз", "Аудио 1: AC3 - Русский (MVO, LostFilm)<br>")]))
check("kinozal: вкладка «Релиз» попадает в текст",
"Перевод: Профессиональный многоголосый" in _kz_text and "(MVO, LostFilm)" in _kz_text)
check("codec_segments: русская дорожка с типом, без иноязычной",
codec_segments("Аудио 3: AC3, 2.0 ch - Русский (MVO, HDrezka Studio)\n"
"Аудио 6: E-AC3, 5.1 ch, 640 Kbps - English Dubbing\n"
"Перевод: Профессиональный") == ["AC3, 2.0 ch - Русский (MVO, HDrezka Studio)"])
check("same_studio: варианты написания",
same_studio("DVD", "DVD Магия") and same_studio("Михаил Яроцкий", "М.Яроцкий/Kyberpunk")
and not same_studio("LostFilm", "HDrezka"))
_page2 = [dict(_t("проф. многоголосый", None), studio_span=None, studio_confidence=None)]
merge_extra_tracks(_page2, [
dict(_t("проф. многоголосый", "HDrezka"), studio_span="HDrezka Studio", studio_confidence=1),
dict(_t("проф. многоголосый", "LostFilm"), studio_span="LostFilm", studio_confidence=1),
dict(_t("одноголосый", None), studio_span=None, studio_confidence=None)],
"codec", match=same_studio)
check("codec: студия вписана, новая добавлена, без студии — нет",
[(x["studio"], x["source"]) for x in _page2]
== [("HDrezka", "page+codec"), ("LostFilm", "codec")])
_page3 = [dict(_t("проф. многоголосый", None), studio_span=None, studio_confidence=None)]
merge_extra_tracks(_page3, [
dict(_t("неизвестно", "LostFilm"), studio_span="LostFilm", studio_confidence=1),
dict(_t("неизвестно", "TVShows"), studio_span="TVShows", studio_confidence=1)],
"codec", match=same_studio, adopt_type=True)
check("codec: «Аудио Rus: … | LostFilm» получает тип дорожки страницы",
[(x["translation_type"], x["studio"]) for x in _page3]
== [("проф. многоголосый", "LostFilm"), ("проф. многоголосый", "TVShows")])
from src.release import codec_ok, model_segments # noqa: E402
from src.segmenter import kinozal_text, label_page # noqa: E402
class _FakeSeg:
def select_lines(self, text):
return text.splitlines()
_main, _codec = model_segments(_FakeSeg(), "Перевод: Профессиональный многоголосый\n"
"Аудио 3: AC3 - Русский (MVO, LostFilm)\n"
"Аудио 6: AC3 - Казахский (Dub, TK Qazaqstan)")
check("model_segments: основные и кодек-строки раздельно, иноязычная отброшена",
_main == ["Профессиональный многоголосый"] and _codec == ["AC3 - Русский (MVO, LostFilm)"])
check("codec_ok: «English Dubbing» и «Original» — не русская дорожка",
not codec_ok("Аудио 6: E-AC3 - English Dubbing") and not codec_ok("Аудио 5: E-AC3 - Original (французский)")
and codec_ok("Аудио Rus: AC3, 2 ch | LostFilm"))
check("label_page: raw — 1, неразмеченная кодек-строка — -1, сводка «Аудио:» — 0",
label_page(["Перевод: Дублированный", "Аудио 2: AC3 - Русский (MVO, X)",
"Аудио: Русский, английский (AC3)", "Размер: 6 ГБ"], ["Перевод: Дублированный"])
== [1, -1, 0, 0])
import json # noqa: E402
import tempfile as _tmp # noqa: E402
with _tmp.TemporaryDirectory() as _d:
Path(_d, "123.json").write_text(json.dumps({"h1": "X / Y / 2026 / ПМ (LostFilm)",
"tech_tab": "Перевод: Профессиональный многоголосый",
"release_tab": "Аудио Rus: AC3 | LostFilm"}), encoding="utf-8")
check("kinozal_text: заголовок + Техданные + Релиз из raw JSON",
kinozal_text("kz-123", Path(_d)).splitlines()
== ["X / Y / 2026 / ПМ (LostFilm)", "Перевод: Профессиональный многоголосый", "Аудио Rus: AC3 | LostFilm"]
and kinozal_text("kz-404", Path(_d)) is None)
_dump = ("Меню\nГангстерленд / MobLand / 2025 / ПМ (LostFilm) / WEB\n"
"Гангстерленд / 2026 / ДБ (X) / WEB-DL")
check("title_from_text: первая строка",
@@ -317,6 +433,68 @@ 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)
_vn = video_info("Видео: HEVC/H.265, 3840x2160, ~21.4 Mbps",
"В поисках Дори / Finding Dory (2016) UHD BDRip [H.265/2160p] [4K, HDR10, DV 8.1, 10-bit]")
check("видео nnm: 10-bit и «DV 8.1» только в заголовке -> bit_depth 10, Dolby Vision, профиль 8.1",
(_vn["bit_depth"], _vn["hdr"], _vn["dv_profile"]) == (10, ["Dolby Vision", "HDR10"], "8.1"), str(_vn))
check("видео kinozal: «Dolby Vision P8» в заголовке -> профиль 8",
video_info("Видео: MPEG-H HEVC, 24 Мбит/с, 3840x2074, 10 бит", "Настройщик / 4K, HEVC, HDR10+, Dolby Vision P8, Hybrid / WEB-DL")["dv_profile"] == "8")
check("видео: «DVDRip», «DV 10-bit» — не Dolby Vision-профиль",
(lambda a, b: a["hdr"] == [] and a["dv_profile"] is None and b["dv_profile"] is None and b["bit_depth"] == 10)(
video_info("Видео: XviD, 720x400", "Фильм [2001, DVDRip]"), video_info("Видео: HEVC, 3840x2160", "Фильм [DV 10-bit]")))
check("видео: «DV P7» в заголовке -> профиль 7", video_info("Видео: HEVC, 3840x2160", "Film 2160p DV P7 HDR10")["dv_profile"] == "7")
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) == "люб. многоголосый")
@@ -353,6 +531,7 @@ check("series_info: сезон, серии, total и «студия -> сери
print("== сборщик датасета (tools/collect.py) ==")
import csv as _csv # noqa: E402
import json as _json # noqa: E402
import json # noqa: E402
import tempfile as _tmp # noqa: E402
from pathlib import Path as _P # noqa: E402
@@ -656,6 +835,15 @@ check("метки rt_parse: кодек и битрейт из строки «А
all(r["codec"] == "AC3" and r["kbps"] == "192" for r in _hr), str(_hr))
check("метки rt_parse: 5 студий + 1 доп. 18+", len(_rows) == 6, str(len(_rows)))
_bare = ("Перевод:\nПрофессиональный (дублированный) | Cinema Tone Production\n\nПрофессиональный (псевдодублированный) | HDRezka Studio\n"
"Профессиональный (многоголосый закадровый) | HDRezka Studio\nПрофессиональный (многоголосый закадровый) | TVShows\n"
"\nОригинальная аудиодорожка: Английская\nСубтитры: Русские, английские\n")
_br, _ = _hc.rt_parse({"id": "6919056", "title": "Астрал 6 [2026, WEB-DL 2160p]", "post": _bare})
check("метки rt_parse: «Перевод:» без значения + список строк (6919056)",
[(r["translation_type"], r["studio"]) for r in _br] == [
("проф. дубляж", "Cinema Tone Production"), ("люб. дубляж", "HDrezka"), # псевдодубляж = «люб. дубляж»
("проф. многоголосый", "HDrezka"), ("проф. многоголосый", "TVShows")], str([(r["translation_type"], r["studio"]) for r in _br]))
_sf = __import__("src.release", fromlist=["x"]).series_info("Сериал / Сезон 1 / Серии 1-8 из 8 [2026]", [
{"studio": "LostFilm", "episodes": "1-4", "episode_ranges": [[1, 4]]},
{"studio": "HDrezka", "episodes": None, "episode_ranges": []}])
@@ -857,6 +1045,51 @@ check("prepare_data: у редкого языка метка пустая", [r["
print("== метки: тип, раскладка, строки из «Аудио», веса классов ==")
import harvest_core as _hc2 # noqa: E402
_ps = _hc2.nnm_part_studio
check("nnm «Перевод:»: студия из скобок и после тире",
_ps("Дублированный (Невафильм)") == "Невафильм" and _ps("Многоголосый профессиональный - Jaskier") == "Jaskier"
and _ps("Авторский (Живов") == "Юрий Живов")
check("nnm «Перевод:»: тип, кратность, лицензия, соседнее поле — не студия",
_ps("Двуголосый профессиональный") == "" and _ps("Одноголосый x2") == ""
and _ps("Дублированный (Лицензия BD CEE)") == ""
and _ps("одноголосый закадровый Дата мировой премьеры: 4 февраля 1983 (USA)") == "")
_kzr = _hc2.kz_title_rows(
{"id": "kz-1", "h1": "Что, если...? / What If...? / 2023 / ПМ (LostFilm, HDrezka Studio), СТ / WEB-DL"},
"Аудио Rus: AC3, 48 kHz, 2 ch, 384 Кбит/сек | LostFilm",
[{"studio": "(не указана)", "raw": "Перевод: Профессиональный многоголосый", "translation_type": "проф. многоголосый"}])
check("kinozal: студии из заголовка, строка «Аудио» — в audio_raw",
[(r["translation_type"], r["studio"], r["studio_source"], bool(r["audio_raw"])) for r in _kzr]
== [("проф. многоголосый", "LostFilm", "title", True), ("проф. многоголосый", "HDrezka", "title", False)])
check("kinozal: студия, уже названная на странице, из заголовка не дублируется",
_hc2.kz_title_rows({"id": "kz-1", "h1": "X / Y / 2023 / ПМ (LostFilm)"}, "",
[{"studio": "LostFilm", "raw": "Перевод: … LostFilm"}]) == [])
import re as _re # noqa: E402
_known = _re.compile(r"(?i)(?<![\w])(LostFilm|TVShows|HDrezka Studio|HDrezka|Дубляжная|1\+1)(?![\w])")
_kzrel = [{"source": "kinozal", "studio": "(не указана)", "raw": "Перевод: Профессиональный (многоголосый закадровый)",
"translation_type": "проф. многоголосый"}]
_cr = _hc2.codec_line_rows({"id": "kz-2"}, "\n".join([
"Аудио 1: AC3, 6 ch, 448 Кбит/с - Русский дубляж (Мосфильм-Мастер) - лицензия",
"Аудио Rus: AC3, 48,0 kHz, 6 ch, 384 Кбит/сек | TVShows",
"Аудио 3: AC3, 2 ch, 192 Kbps - Украинский (DVO, ТК \"1+1\")",
"Аудио 4: AC3, 2/0 (L,R) ch, 192 kbps, Delay 0 mSec Русский (MVO, LostFilm)",
"Аудио 5: AC3, 6 ch, 448 kbps - Английский (Original)",
"Аудио: Русский (AC3, 6 ch, 384 Кбит/с) Дубляжная, английский (AC3, 6 ch)",
]), _kzrel, known=_known)
check("кодек-строки: студия, тип из строки или единственный тип релиза, язык",
[(r["studio"], r["translation_type"], r["lang"], r["studio_source"]) for r in _cr]
== [("Мосфильм-Мастер", "проф. дубляж", "RUS", "codec"), ("TVShows", "проф. многоголосый", "RUS", "codec"),
("1+1", "проф. двухголосый", "UKR", "codec"), ("LostFilm", "проф. многоголосый", "RUS", "codec")],
str([(r["studio"], r["translation_type"]) for r in _cr]))
check("кодек-строки: вход — сегмент без префикса «Аудио N:», вся строка — в audio_raw",
_cr[0]["raw"].startswith("AC3, 6 ch") and _cr[0]["audio_raw"].startswith("Аудио 1:"))
check("кодек-строки: студия, уже размеченная, не дублируется (в т.ч. «А. Гаврилов» = «Андрей Гаврилов»)",
_hc2.codec_line_rows({"id": "x"}, "Аудио 2: AC3, 2 ch, 192 Kbps - Русский (AVO, А. Гаврилов)",
[{"studio": "Андрей Гаврилов", "translation_type": "авт. одноголосый"}], known=_known) == [])
check("кодек-строки: при нескольких типах релиза тип строки без тег-слова — «неизвестно»",
[r["translation_type"] for r in _hc2.codec_line_rows(
{"id": "x"}, "Аудио Rus: AC3, 2 ch, 192 Кбит/сек | HDrezka Studio",
[{"studio": "", "translation_type": "проф. дубляж"}, {"studio": "", "translation_type": "авт. одноголосый"}],
known=_known)] == ["неизвестно"])
_tt = _hc2.translation_type
check("тип: любительский дубляж — отдельный класс", _tt("Любительский (дублированный) Red Head Sound")[0] == "люб. дубляж")
check("тип: псевдодубляж -> люб. дубляж", _tt("Любительский (псевдодублированный)")[0] == "люб. дубляж")
@@ -1159,5 +1392,130 @@ try:
except Exception as e: # noqa: BLE001
print(f" пропущено (нет models/segmenter): {e}")
print("== чистка студий: пол, техпараметры, т/к, каналы, списки ==")
import harvest_core as _hc3 # noqa: E402
from src.studio_norm import JUNK_STUDIO_RE as _JUNK # noqa: E402
_pst = _hc3.part_studios
check("пол голоса не студия: «(муж.) от Ancord», «женская одноголосая: Persona99»",
_pst("одноголосое закадровое (муж.) от Ancord") == ["Ancord"]
and _pst("Озвучка: женская одноголосая: Persona99") == ["Persona99"], str(_pst("одноголосое закадровое (муж.) от Ancord")))
check("техпараметры не студия: «192 kb/s», «Кбит/сек», «16 бит», «2 channels»",
_pst("Русский (AC3, 2 ch, 192 kb/s) Многоголосый") == []
and _pst("Русский АС-3 2.0 192 Кбит/сек 48,0 КГц 16 бит - авторский, одноголосый Андрей Гаврилов") == ["Андрей Гаврилов"]
and all(_hc3.is_studio_junk(x) for x in ("муж", "женская", "2 channels", "16 бит", "CBR", "192 kb", "сек", "2 AC3")))
check("«т/к Россия» -> «Россия», слэш между студиями по-прежнему делит",
_hc3.split_studios("т/к Россия") == ["Россия"]
and _hc3.split_studios("Paramount Comedy / Кураж-Бамбей") == ["Paramount Comedy", "Кураж-Бамбей"]
and _hc3.split_studios("Пифагор/Кинопоиск") == ["Пифагор", "Кинопоиск"])
check("настоящие студии не мусор: «1-й канал», «5-й канал СПб», «Лексикон», «Первый канал», «1+1», «2x2»",
not any(_JUNK.match(x) for x in ("1-й канал", "5-й канал СПб", "5 канал", "Лексикон", "Первый канал", "1+1", "2x2"))
and _JUNK.match("1 - Пифагор") and _JUNK.match("6 каналов") and not _hc3.is_studio_junk("5 канал") and not _hc3.is_studio_junk("Лексикон") and not _hc3.is_studio_junk("Мужской клуб"))
check("имя рядом со скобкой пола в кодек-строке",
[m.group(1) for m in _hc3.RE_AFTER_GENDER.finditer("двухголосая (муж.+жен.) GreyMoon & Aska")] == ["GreyMoon & Aska"]
and not list(_hc3.RE_AFTER_GENDER.finditer("Ура (Женя) Жуков")))
check("список из 3+ имён без типа — не дорожки, одиночная студия без типа — дорожка",
_hc3.untyped_list("неизвестно", ["a", "b", "c"]) and not _hc3.untyped_list("неизвестно", ["CrunchyRoll"])
and not _hc3.untyped_list("проф. многоголосый", ["a", "b", "c"]))
print("== разбор противоречий (src/text_fixes.py) ==")
import tempfile # noqa: E402
from src import text_fixes as _tf # noqa: E402
from src.prepare_data import row_label as _rl # noqa: E402
def _lr(rid: str, raw: str, ttype: str, studio: str) -> dict:
return {"source": "rutracker", "release_id": rid, "raw": raw, "translation_type": ttype, "studio": studio}
_cr = [_lr("1", "Перевод: Дублированный (Red Head Sound)", "проф. дубляж", "Red Head Sound"),
_lr("2", "Перевод: Дублированный (Red Head Sound)", "проф. дубляж", "(не указана)"),
_lr("3", "Перевод: Многоголосый (HDRezka Studio, NewStudio)", "многоголосый", "HDRezka Studio"),
_lr("3", "Перевод: Многоголосый (HDRezka Studio, NewStudio)", "многоголосый", "NewStudio"),
_lr("5", "Перевод: Многоголосый (HDRezka Studio, NewStudio)", "многоголосый", "NewStudio"),
_lr("5", "Перевод: Многоголосый (HDRezka Studio, NewStudio)", "многоголосый", "HDRezka Studio"),
_lr("4", "Перевод: Авторский (Гаврилов)", "авт. одноголосый", "Гаврилов"),
_lr("6", "Перевод: Дублированный | LostFilm", "проф. дубляж", "LostFilm"),
_lr("7", "Перевод: Дублированный | Lostfilm", "проф. дубляж", "Lostfilm")]
_ci = {i["text"]: i for i in _tf.conflicts(_cr, {})}
check("противоречия: только расхождения между релизами; список студий одного релиза — один вариант",
set(_ci) == {"Дублированный (Red Head Sound)"}
and sorted(v[1] for v in _ci["Дублированный (Red Head Sound)"]["variants"]) == [[], ["Red Head Sound"]],
str({k: v["variants"] for k, v in _ci.items()}))
with tempfile.TemporaryDirectory(dir=".") as _td:
_fp = Path(_td) / "fixes.csv"
_tf.save({"text": "Дублированный (Red Head Sound)", "translation_type": "проф. дубляж",
"studio": "Red Head Sound", "action": "set"}, _fp)
_tf.save({"text": "Многоголосый (HDRezka Studio, NewStudio)", "action": "drop"}, _fp)
_fx = _tf.load(_fp)
check("решение «set» заменяет метку у всех строк текста, «drop» выбрасывает",
_rl(_cr[1], _fx)["studio"] == "Red Head Sound" and _rl(_cr[1], _fx)["fixed"]
and _rl(_cr[2], _fx)["drop"] == "fix" and not _rl(_cr[6], _fx)["fixed"])
check("решённые тексты не показываются снова", _tf.conflicts(_cr, _fx) == [])
print("== несколько авторов в одной строке ==")
import subprocess as _sp9 # noqa: E402
from src.release import expand_studios as _xs # noqa: E402
from src.spans import decode_span as _ds1, decode_spans as _dss # noqa: E402
_off = [(0, 0), (0, 3), (3, 6), (6, 7), (8, 10), (10, 12), (0, 0), (0, 0)]
check("decode_spans: все прогоны B/I, B начинает новый, O и спецтокен заканчивают; decode_span — первый",
_dss([0, 1, 2, 0, 1, 2, 0, 0], _off, [1] * 8) == [(0, 6), (8, 12)]
and _dss([0, 2, 1, 2, 2, 0, 0, 0], _off, [1] * 8) == [(0, 3), (3, 10)]
and _dss([1, 1, 2, 0, 1, 2, 1, 1], _off, [1, 1, 1, 1, 1, 1, 0, 0]) == [(0, 6), (8, 12)]
and _ds1([0, 1, 2, 0, 1, 2, 0, 0], _off, [1] * 8) == (0, 6) and _dss([0] * 8, _off, [1] * 8) == [])
_tr = {"segment": "x", "translation_type": "многоголосый", "studio": "HDrezka", "studio_span": "HDRezka Studio",
"studio_confidence": 0.9, "studio_spans": ["HDRezka Studio", "LostFilm"], "studios": ["HDrezka", "LostFilm"],
"studio_confidences": [0.9, 0.8]}
_xt = _xs([_tr, {**_tr, "studios": ["LostFilm"], "studio_spans": ["LostFilm"], "studio_confidences": [0.7]}])
check("ответ релиза: строка с двумя авторами -> дорожка на каждого",
[(t["studio"], t["studio_span"], t["studio_confidence"]) for t in _xt]
== [("HDrezka", "HDRezka Studio", 0.9), ("LostFilm", "LostFilm", 0.8), ("HDrezka", "HDRezka Studio", 0.9)])
with tempfile.TemporaryDirectory(dir=".") as _td:
_wd2 = Path(_td).resolve()
(_wd2 / "data").mkdir()
_ml = [_lr("1", "Перевод: Многоголосый (HDRezka Studio, LostFilm)", "многоголосый", "HDRezka Studio"),
_lr("1", "Перевод: Многоголосый (HDRezka Studio, LostFilm)", "многоголосый", "LostFilm"),
_lr("2", "Перевод: Многоголосый (HDRezka Studio, LostFilm)", "многоголосый", "LostFilm"),
_lr("3", "Перевод: Дублированный (Пифагор, Кинопоиск)", "проф. дубляж", "Пифагор")]
with (_wd2 / "l.csv").open("w", encoding="utf-8-sig", newline="") as _f:
_w = _csv2.DictWriter(_f, fieldnames=_hc.FIELDS, restval="")
_w.writeheader()
_w.writerows(_ml)
_tf.save({"text": "Дублированный (Пифагор, Кинопоиск)", "translation_type": "проф. дубляж",
"studio": "Пифагор ; Кинопоиск", "action": "set"}, _wd2 / "data" / "text_fixes.csv")
_pr = _sp9.run([_sys3.executable, "-c",
f"import sys; sys.path.insert(0, r'{_o.getcwd()}'); from src.prepare_data import main; main()",
"--csv", str(_wd2 / "l.csv"), "--out", str(_wd2 / "out")], cwd=_wd2, capture_output=True,
text=True, encoding="utf-8", env={**_o.environ, "PYTHONUTF8": "1"})
_mds = _js3.loads((_wd2 / "out" / "dataset.json").read_text(encoding="utf-8")) if _pr.returncode == 0 else []
_by = {(r["release_id"], r["text"]): [r["text"][a:z] for a, z in r["spans"]] for r in _mds}
check("prepare_data: строки одного релиза склеены в одну со всеми авторами, порядок по тексту",
_by.get(("1", "Многоголосый (HDRezka Studio, LostFilm)")) == ["HDRezka Studio", "LostFilm"]
and _by.get(("2", "Многоголосый (HDRezka Studio, LostFilm)")) == ["LostFilm"]
and all(r["span"] == (r["spans"][0] if r["spans"] else None) for r in _mds),
(_pr.stderr or "")[-300:] + str(_by))
check("prepare_data: ручное решение с несколькими авторами через «;»",
_by.get(("3", "Дублированный (Пифагор, Кинопоиск)")) == ["Пифагор", "Кинопоиск"], str(_by))
try:
from transformers import AutoTokenizer as _AT
from src.train import make_collate as _mc
_tok = _AT.from_pretrained("cointegrated/rubert-tiny2")
_txt = "Многоголосый (HDRezka Studio, LostFilm)"
_a, _b = _txt.index("HDRezka"), _txt.index("LostFilm")
_enc, _, _bio, _, _ = _mc(_tok, {"многоголосый": 0})(
[{"text": _txt, "translation_type": "многоголосый", "is_18plus": False,
"spans": [[_a, _a + len("HDRezka Studio")], [_b, _b + len("LostFilm")]], "span": [_a, _a + 14]}])
_tags = _bio[0].tolist()
_got = _dss(_tags, _enc["offset_mapping"][0].tolist() if "offset_mapping" in _enc else
_tok(_txt, return_offsets_mapping=True)["offset_mapping"], [1] * len(_tags))
check("обучение: BIO размечает всех авторов строки (два B)",
_tags.count(1) == 2 and [_txt[a:z] for a, z in _got] == ["HDRezka Studio", "LostFilm"], str(_tags))
except Exception as _e: # noqa: BLE001
print(f" пропущено (нет токенизатора rubert-tiny2): {_e}")
print(f"\nитог: {PASS} ok, {FAIL} failed")
sys.exit(1 if FAIL else 0)
+260
View File
@@ -0,0 +1,260 @@
"""Аудит дорожек в описаниях релизов: data/raw/* против data/labels.csv.
Читает текст каждой сохранённой страницы (kinozal: «Техданные» + «Релиз»;
nnm / rutracker: пост), находит строки русских/украинских дорожек
(«Перевод N:», «Озвучка:», «Аудио N: … (MVO, X)» и т. п., без субтитров) и считает:
* какие форматы строк встречаются и какие из них не ловят правила разметки
(segmenter.RE_AUDIO_TRACK) и запасной путь сервиса (release.codec_segments);
* студии из словаря меток, названные в строках дорожек, но отсутствующие
в метках релиза (сравнение через release.same_studio — «А. Гаврилов» = «Андрей Гаврилов»);
* мусор в колонке studio (каналы, языки, тип-слова, «Лицензия», «по заказу»);
* релизы без меток: legacy / ok / есть ли в них строки дорожек.
Результат — reports/tracks_audit_numbers.md (цифры и примеры); выводы и план —
reports/tracks_audit.md. Чтение raw ~2–3 мин; --cache сохраняет тексты в pickle.
.venv\\Scripts\\python tools\\audit_tracks.py [--cache путь.pkl]
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import pickle
import random
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.release import RE_CODEC_TRACK, codec_ok, same_studio # noqa: E402
from src.segmenter import RE_AUDIO_TRACK # noqa: E402
RAW = ROOT / "data" / "raw"
LABELS = ROOT / "data" / "labels.csv"
OUT = ROOT / "reports" / "tracks_audit_numbers.md"
SOURCES = (("kinozal", "kz-"), ("nnm", "nnm-"), ("rutracker", "rt-"))
NO_STUDIO = "(не указана)"
# строка дорожки: перевод/озвучка или кодек-строка
RE_TRANS_LINE = re.compile(
r"(?i)^\s*[#№*•\-\s]*(перевод\w*|озвуч\w*|дубляж\w*|автор перевода)\s*(№|#)?\s*[\d\-–,\s]*(\([^)]*\))?\s*[:.]")
RE_AUDIO_LINE = re.compile(
r"(?i)^\s*[#№*•\-\s]*(аудио\w*|audio\w*|звук\w*|аудиодорожк\w*|дорожк\w*|track\w*)\b")
RE_CODEC = re.compile(
r"(?i)\b(e-?ac-?3|ac-?3|ас3|dts|aac|truehd|flac|mp3|opus|l?pcm|mp2|dolby)\b|\d\s*(kbps|кбит|kb/s|khz|кгц)")
RE_RU = re.compile(r"(?i)русск|russian|\brus\b|украин|ukrain|\bukr\b|україн")
RE_NON_RU = re.compile(
r"(?i)english|\beng\b|англ|япон|japan|\bjap\b|казах|kazakh|корей|китай|немец|француз|итальян|испан|оригинал|original")
RE_TYPE = re.compile(
r"(?i)\b(MVO|DVO|AVO|DUB|VO)\b|дубл|многоголос|двухголос|двуголос|одноголос|закадр|авторск|любительск|профессиональн")
RE_SUBS = re.compile(r"(?i)субтитр")
# не студия: тех. параметры, языки, тип-слова, источник дорожки
JUNK = re.compile(
r"(?i)^(\d+\s*ch|.*\b(ac-?3|dts|aac|kbps|kb/s|ch)\b.*|l,.*|.*русск\w*.*|английск\w*|украинск\w*|французск\w*"
r"|испанск\w*|немецк\w*|итальянск\w*|японск\w*|казахск\w*|субтитр\w*|ukr|rus|eng|eur|cee|vhs|tv|ts"
r"|дубляж|полное дублирование|дублированный|дву(х)?голосый|многоголосый|одноголосый|закадровый"
r"|профессиональный|любительский|авторский|студийный|неизвестн\w*|оригинал\w*|original"
r"|.*лицензи\w*.*|звук.*|blu-?ray.*|bd.*|itunes.*|netflix|amazon|web-?dl|в составе контейнера"
r"|.*ненормативн\w*.*|18\+)$")
# виды мусора для отчёта (первое совпадение)
NOISE_KINDS = (
("каналы/кодек", r"(?i)\b(\d+\s*ch|ac-?3|dts|aac|kbps|kb/s)\b|^l,"),
("язык вместо студии", r"(?i)^(русск|английск|украинск|французск|испанск|казахск)\w*$"),
("язык-префикс «Русский, - X»", r"(?i)^(русск|украинск|english)\w*\s*,"),
("тип-слово", r"(?i)^(дубляж|полное дублирование|дву(х)?голосый|многоголосый|одноголосый|закадровый)$"),
("источник: лицензия/Blu-ray/iTunes/Netflix", r"(?i)лицензи|blu-?ray|^bd\b|itunes|netflix|amazon"),
("«X по заказу Y»", r"(?i)по заказу"),
("субтитры", r"(?i)субтитр"),
("неру дорожка", r"(?i)казах|qazaq|kazakh|английск|english"),
)
def nid(rid: str) -> str:
return re.sub(r"^(kz|nnm|rt)-", "", rid)
def norm(s: str) -> str:
return re.sub(r"\s+", " ", s).strip().lower().replace("ё", "е")
def load_texts(cache: Path | None) -> dict:
"""{(source, id): (title, text, legacy)}; kinozal — h1 и вкладки, nnm/rt — пост."""
if cache and cache.exists():
return pickle.loads(cache.read_bytes())
out = {}
for src, _ in SOURCES:
d = RAW / src
for n in sorted(os.listdir(d), key=lambda n: "legacy" in n):
if not n.endswith(".json"):
continue
rid = n.split(".")[0]
if (src, rid) in out:
continue
try:
rec = json.loads((d / n).read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
if src == "kinozal":
title = rec.get("h1") or ""
text = "\n".join(x for x in (rec.get("tech_tab"), rec.get("release_tab")) if x)
else:
title, text = rec.get("title") or "", rec.get("post") or ""
out[(src, rid)] = (title, text, "legacy" in n, rec.get("ok", True))
if cache:
cache.write_bytes(pickle.dumps(out))
return out
def track_lines(text: str) -> list[tuple[str, str]]:
"""[(вид, строка)] строк русских/украинских дорожек; вид: trans | audio."""
out = []
for line in text.splitlines():
line = line.strip()
if not line or len(line) > 400 or RE_SUBS.search(line) and not RE_TYPE.search(line):
continue
if RE_TRANS_LINE.match(line):
if RE_NON_RU.search(line.split(":", 1)[0]) and not RE_RU.search(line):
continue
out.append(("trans", line))
elif (RE_AUDIO_LINE.match(line) or RE_RU.match(line)) and RE_CODEC.search(line):
if RE_RU.search(line) and (RE_TYPE.search(line) or "|" in line or "(" in line):
out.append(("audio", line))
return out
def line_prefix(line: str) -> str:
p = re.match(r"\s*\W*\w*[^:]{0,14}:?", line).group(0).strip().lower()
return re.sub(r"\s+", " ", re.sub(r"\d+", "N", p))
def studio_vocab(rows: list[dict]) -> tuple[re.Pattern, dict]:
cnt = Counter(r["studio"].strip() for r in rows if r["studio"] not in ("", NO_STUDIO))
vocab = [s for s, c in cnt.items() if c >= 5 and len(s) >= 3 and not JUNK.match(s)]
vocab.sort(key=len, reverse=True)
rx = re.compile(r"(?i)(?<![\w])(" + "|".join(map(re.escape, vocab)) + r")(?![\w])")
return rx, {norm(s): s for s in vocab}
def audit(texts: dict, rows: list[dict]) -> dict:
lab = defaultdict(list)
for r in rows:
lab[(r["source"], nid(r["release_id"]))].append(r)
voc_rx, canon = studio_vocab(rows)
R = defaultdict(lambda: defaultdict(Counter))
ex = defaultdict(list)
for (src, rid), (title, text, legacy, ok) in texts.items():
L = lab.get((src, rid), [])
lines = track_lines(text)
rel = R[src]["releases"]
rel["всего"] += 1
if not L:
rel["без меток"] += 1
key = ("legacy" if legacy else "обычный") + (", ok=False" if not ok else "") + \
(", есть строки дорожек" if lines else ", нет строк дорожек")
R[src]["без меток"][key] += 1
if lines and len(ex[(src, "unlab")]) < 3000:
ex[(src, "unlab")].append((rid, title[:80], lines[0][1][:140]))
labs = [r["studio"] for r in L if r["studio"] not in ("", NO_STUDIO) and not JUNK.match(r["studio"].strip())]
missing: dict[str, tuple[str, str]] = {}
for kind, line in lines:
R[src]["строки"][kind] += 1
if kind == "audio":
a = bool(RE_AUDIO_TRACK.match(line))
b = bool(RE_CODEC_TRACK.match(line) and codec_ok(line))
R[src]["кодек-строки"][("RE_AUDIO_TRACK " if a else "мимо RE_AUDIO_TRACK ") +
("| codec_segments" if b else "| мимо codec_segments")] += 1
if not a or not b:
miss = ("seg " if not a else "") + ("codec" if not b else "")
R[src]["форматы мимо: " + miss.strip()][line_prefix(line)] += 1
if len(ex[(src, "fmt:" + miss.strip())]) < 3000:
ex[(src, "fmt:" + miss.strip())].append((rid, line[:160]))
if not L:
continue
for m in voc_rx.finditer(line):
st = canon.get(norm(m.group(1)), m.group(1))
if any(same_studio(st, x) for x in labs) or any(same_studio(st, x) for x, _ in missing.values()):
continue
missing[norm(st)] = (st, kind)
if len(ex[(src, "miss:" + kind)]) < 5000:
ex[(src, "miss:" + kind)].append((rid, st, line[:160]))
if L:
rel["с метками"] += 1
rel["с метками, есть недостающая студия"] += bool(missing)
for st, kind in missing.values():
R[src]["недостающие студии: вид строки"][kind] += 1
R[src]["недостающие студии: топ"][st] += 1
# недостающие студии в релизе, где у меток вообще нет студий
if missing and not labs:
rel["с метками без студий, студия есть в кодек-строке"] += 1
full = norm(title + "\n" + text)
for r in L:
st = r["studio"].strip()
if st in ("", NO_STUDIO) or JUNK.match(st):
continue
R[src]["студии меток"]["всего"] += 1
words = [w for w in re.findall(r"\w{4,}", norm(st))]
if norm(st) not in full and not any(w in full for w in words):
R[src]["студии меток"]["нет в тексте/заголовке"] += 1
R[src]["студии меток: нет в тексте, studio_source"][r["studio_source"]] += 1
if len(ex[(src, "ghost")]) < 3000:
ex[(src, "ghost")].append((rid, st, r["raw"][:140]))
for r in rows:
st = r["studio"].strip()
for k, rx in NOISE_KINDS:
if re.search(rx, st):
R[r["source"]]["мусор в studio меток"][k] += 1
if len(ex[(r["source"], "noise:" + k)]) < 500:
ex[(r["source"], "noise:" + k)].append((r["release_id"], st, r["raw"][:120]))
break
return {"stats": R, "examples": ex}
def write_md(res: dict, path: Path) -> None:
R, ex = res["stats"], res["examples"]
rnd = random.Random(7)
out = ["# Аудит дорожек в raw-страницах (цифры)", "",
"Сгенерировано `tools/audit_tracks.py`; выводы и план — `reports/tracks_audit.md`.", ""]
for src, _ in SOURCES:
out += [f"## {src}", ""]
for block, cnt in R[src].items():
top = cnt.most_common(25)
out.append(f"**{block}**: " + "; ".join(f"{k} — {v}" for k, v in top))
out.append("")
for key in sorted(k for k in ex if k[0] == src):
v = ex[key]
out.append(f"<details><summary>примеры {key[1]} ({len(v)})</summary>\n")
for t in rnd.sample(v, min(15, len(v))):
out.append("- " + " · ".join(str(x).replace("|", "\\|") for x in t))
out += ["", "</details>", ""]
path.write_text("\n".join(out), encoding="utf-8")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--cache", type=Path, help="pickle с текстами raw (создаётся, если нет)")
ap.add_argument("--out", type=Path, default=OUT)
args = ap.parse_args()
sys.stdout.reconfigure(encoding="utf-8")
with LABELS.open(encoding="utf-8-sig", newline="") as f:
rows = list(csv.DictReader(f))
res = audit(load_texts(args.cache), rows)
args.out.parent.mkdir(exist_ok=True)
write_md(res, args.out)
for src, _ in SOURCES:
print(f"== {src}")
for block, cnt in res["stats"][src].items():
print(f" {block}: " + "; ".join(f"{k} — {v}" for k, v in cnt.most_common(12)))
print(f"записано: {args.out.relative_to(ROOT)}")
if __name__ == "__main__":
main()
+86 -4
View File
@@ -929,8 +929,10 @@ def cmd_build(rewrite: bool = False, relabel: bool = False, runner: "Runner | No
if not d.exists():
continue
done = built.setdefault(site, {})
# обычные страницы — json+html; legacy (импорт старого harvest) — только json с
# текстом поста: метки по нему пересобираются текущими парсерами, страница не трогается
files = [jp for jp in sorted(d.glob("*.json"))
if not jp.name.endswith(".legacy.json") and jp.with_suffix(".html").exists()]
if jp.with_suffix(".html").exists() or jp.name.endswith(".legacy.json")]
ps = per[site] = {"files": len(files), "unchanged": 0, "pages": 0, "relabeled": 0,
"new": 0, "no_labels": 0, "t": time.time()}
task_id = prog.add_task(f"{site}: разбор HTML", total=len(files))
@@ -946,14 +948,16 @@ def cmd_build(rewrite: bool = False, relabel: bool = False, runner: "Runner | No
ps["unchanged"] += 1
prog.advance(task_id)
continue
rid = jp.stem
rid = jp.name.removesuffix(".json").removesuffix(".legacy")
stem = page_stem(site, rid, labeled)
known = rid in labeled.get(site, ()) or stem in labeled.get(site, ())
page = PAGES / f"{stem}.txt"
has_html = hp.exists()
# полная страница заменяет урезанную (текст поста) из импорта старого harvest
jobs.append({"site": site, "json": str(jp), "html": str(hp), "sig": sig, "stem": stem,
"known": known, "parse": not known or (relabel and stem not in manual),
"page": str(page) if (rewrite or not page.exists() or stem in postonly) else None})
"page": (str(page) if has_html and
(rewrite or not page.exists() or stem in postonly) else None)})
# проход 2: разбор HTML и запись страниц — параллельно
for job, res in zip(jobs, runner.map(rw.build_one, jobs)):
prog.advance(task_id)
@@ -1028,7 +1032,7 @@ def cmd_build(rewrite: bool = False, relabel: bool = False, runner: "Runner | No
# Версия разбора: поднимите при правке парсеров (harvest_core, html_to_text) — тогда
# кэш «уже разобрано» сбросится сам и данные пересоберутся один раз.
PARSE_VERSION = 3
PARSE_VERSION = 9
def load_json(path: Path) -> dict:
@@ -1676,6 +1680,81 @@ def settings_screen() -> None:
f"режим {cfg['mode']}, раздач со страницы {cfg['per_page']}, страниц списка {cfg['pages']}")
TOPIC_URL = {"rutracker": "https://rutracker.org/forum/viewtopic.php?t={}",
"nnm": "https://nnmclub.to/forum/viewtopic.php?t={}",
"kinozal": "https://kinozal.me/details.php?id={}"}
def topic_url(site: str, release_id: str) -> str:
rid = real_id(site, release_id).removesuffix(".legacy")
return TOPIC_URL[site].format(rid) if site in TOPIC_URL else release_id
def wizard_conflicts() -> None:
"""Разбор противоречий: один и тот же текст дорожки размечен по-разному -> выбрать верную метку.
Решения пишутся в data/text_fixes.csv сразу после ответа и применяются в prepare_data поверх
labels.csv (переживают пересборку меток). Повторный запуск показывает только нерешённые тексты.
"""
from rich.markup import escape
from src import text_fixes
from src.prepare_data import FIX_SEP
from src.studio_norm import find_studio_span
rows = read_labels()
todo = text_fixes.conflicts(rows)
say(f"Нерешённых противоречий: {len(todo)}. Решено раньше: {len(text_fixes.load())} "
f"(data/text_fixes.csv). Строка со списком студий — это один вариант: верны все студии.")
if not todo or ask("Начать разбор? (Y/n)", "y").lower() == "n":
return
types = sorted({r.get("translation_type", "") for r in rows} - {""})
done = 0
for n, item in enumerate(todo, 1):
ui.clear()
ui.console.print(f"[dim]{n}/{len(todo)} · решено за сеанс {done} · строк с этим текстом "
f"{item['rows']}[/dim]\n")
ui.console.print(f"[bold]{escape(item['text'])}[/bold]\n")
table = []
for k, (ttype, studios, rels) in enumerate(item["variants"], 1):
links = ", ".join(topic_url(s, rid) for s, rid in rels[:2]) + (" …" if len(rels) > 2 else "")
table.append([k, ttype, ", ".join(studios) or "—", len(rels), links])
ui.console.print(ui.summary_table("Варианты разметки", ["№", "тип", "авторы", "релизов", "примеры"], table))
ui.console.print("[dim]№ — выбрать вариант · e — своя метка · d — выбросить текст из обучения · "
"Enter — пропустить · q — выйти[/dim]")
ans = ask("Решение", "").lower()
if ans == "q":
break
fix = None
if ans.isdigit() and 1 <= int(ans) <= len(item["variants"]):
ttype, studios, _ = item["variants"][int(ans) - 1]
fix = {"text": item["text"], "translation_type": ttype, "studio": f" {FIX_SEP} ".join(studios),
"action": "set"}
elif ans == "e":
ttype = ask(f"Тип ({', '.join(types)})", item["variants"][0][0])
if ttype not in types:
say(f"нет такого типа: {ttype} — пропускаю")
ui.pause("Enter — дальше")
continue
studio = ask(f"Авторы через «{FIX_SEP}» (пусто — не указан)", f" {FIX_SEP} ".join(item["variants"][0][1]))
missing = [n for n in (x.strip() for x in studio.split(FIX_SEP)) if n and not find_studio_span(item["text"], n)]
if missing:
say(f"нет в тексте: {', '.join(missing)} — для них строка пойдёт в обучение без спана автора")
ui.pause("Enter — дальше")
fix = {"text": item["text"], "translation_type": ttype, "studio": studio, "action": "set"}
elif ans == "d":
fix = {"text": item["text"], "action": "drop"}
if fix:
text_fixes.save(fix)
done += 1
ui.clear()
say(f"Решено за сеанс: {done}. Они применятся при следующей сборке data/processed.")
if done and ask("Пересобрать data/processed сейчас? (y/N)", "n").lower() == "y":
import retrain
if not retrain.prepare():
say("prepare_data завершился с ошибкой")
def menu() -> None:
while True:
ui.clear()
@@ -1688,6 +1767,7 @@ def menu() -> None:
("5", "Настройки [dim](вкладок на трекер, пауза, лимиты)[/dim]"),
("6", "Обучение по ссылкам [dim](добавить раздачи вручную, поправить метки, переобучить)[/dim]"),
("7", "Модели [dim](сравнить обученные на одной выборке, выбрать лучшую, откатиться)[/dim]"),
("8", "Разбор противоречий [dim](релизы разметили один текст по-разному: выбрать верную метку)[/dim]"),
("0", "Выход")]))
choice = ask("Пункт", "").lower()
if choice in ("0", "q", ""):
@@ -1709,6 +1789,8 @@ def menu() -> None:
wizard_links()
elif choice == "7":
wizard_models()
elif choice == "8":
wizard_conflicts()
else:
continue
except KeyboardInterrupt:
+273
View File
@@ -0,0 +1,273 @@
"""Аудит покрытия: все страницы data/raw против data/labels.csv.
Для каждой сохранённой страницы (35 тыс.) считает, что на ней есть строки дорожек
озвучки (независимыми от парсеров правилами), и что из этого попало в датасет:
* страницы без меток: есть ли на них дорожки вообще (русский оригинал/«не
требуется»/немой — не дорожка) и какие форматы строк не разобраны;
* страницы с метками: строки дорожек, которым не соответствует ни одна строка
датасета (пропущенные дорожки/студии);
* кластеры форматов (по префиксу строки) для правки парсеров.
Результат: reports/coverage_audit.md (цифры, кластеры, примеры),
data/raw/_coverage_pages.csv (по странице) и data/raw/_coverage_lines.csv
(непокрытые строки, до 20 тыс.).
.venv\\Scripts\\python tools\\coverage_audit.py [--cache data/raw/_coverage.pkl]
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import pickle
import random
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RAW = ROOT / "data" / "raw"
LABELS = ROOT / "data" / "labels.csv"
OUT_MD = ROOT / "reports" / "coverage_audit.md"
OUT_PAGES = RAW / "_coverage_pages.csv"
OUT_LINES = RAW / "_coverage_lines.csv"
SOURCES = ("kinozal", "nnm", "rutracker")
NO_STUDIO = "(не указана)"
# ---------------------------------------------------------------- детектор строк
# латинские/кириллические двойники в аббревиатурах: «АVO», «МVO», «DVO»
_HOMO = str.maketrans("АВЕКМНОРСТУХ", "ABEKMHOPCTYX")
def up(s: str) -> str:
return s.translate(_HOMO).upper()
RE_KW = re.compile(
r"(?i)^[\s\W]{0,6}(?:перевод\w*|озвуч\w*|озвучиван\w*|дубляж\w*|дубль\w*|звук\w*"
r"|аудио\w*|audio\w*|аудиодорожк\w*|дорожк\w*|track\w*)"
r"\s*(?:№|#)?\s*(?:\(?\d+\)?|\d+\s*[-–]\s*\d+|rus|ukr|jap|eng|pic|ru|uk)?\s*[:.]")
RE_PREFIX = re.compile(
r"(?i)^[\s\W]{0,6}(?:перевод\w*|озвуч\w*|озвучиван\w*|дубляж\w*|дубль\w*|звук\w*"
r"|аудио\w*|audio\w*|аудиодорожк\w*|дорожк\w*|track\w*)"
r"\s*(?:№|#)?\s*(?:\(?\d+\)?|\d+\s*[-–]\s*\d+|rus|ukr|jap|eng|pic|ru|uk)?\s*[:.]\s*")
RE_TYPE = re.compile(
r"(?i)(?:\bMVO\b|\bDVO\b|\bAVO\b|\bDUB\b|\bVO\b|дубл|многоголос|двухголос|двуголос"
r"|одноголос|закадр|авторск|любительск|профессиональн|озвучен|перевод\b)")
RE_NO_DUB = re.compile(
r"(?i)не\s*требу|немо[йе]|без\s*слов|без\s*диалог|нет\s*диалог|оригинал\w*\s*(?:дорожк|звук|аудио)?\s*[:.]?\s*$"
r"|^оригинал|субтитр|без\s*перевода|отсутств|не\s*озвуч|только\s*(?:музык|звук)|музыкальное\s*сопровожд")
RE_RU = re.compile(r"(?i)русск|russian|\brus\b|\bru\b|украин|ukrain|\bukr\b|україн")
RE_LANG = re.compile(
r"(?i)\b(русск\w*|russian|english|англ\w*|украин\w*|ukrainian|японск\w*|japanese"
r"|китайск\w*|корейск\w*|немецк\w*|французск\w*|итальянск\w*|испанск\w*|казахск\w*"
r"|оригинал\w*|original)\b")
RE_CODEC = re.compile(
r"(?i)\b(?:e-?ac-?3|ac-?3|ас3|dts|aac|truehd|flac|mp3|opus|l?pcm|mp2|dolby|ddp?)\b"
r"|\d\s*(?:kbps|кбит|kb/s|khz|кгц)")
RE_PIPE = re.compile(r"\|([^|]{2,60})\|")
RE_PAREN = re.compile(r"\(([^()]{2,80})\)")
def track_kind(line: str) -> str | None:
"""'dub' — строка дорожки озвучки, 'other' — аудио без признаков озвучки, None — не строка."""
s = line.strip()
if not s or len(s) > 300:
return None
m = RE_KW.match(s)
if not m:
# строка без префикса: «Russian (Dolby AC3, 6 ch) … | LostFilm» (nnm, rutracker)
if RE_CODEC.search(s) and (RE_RU.search(s) or RE_PIPE.search(s)) and len(s) < 220:
pass
else:
return None
body = s[m.end():] if m else s
if RE_NO_DUB.search(body) or (m and RE_NO_DUB.search(s[m.start():m.end() + 40])):
return None
if RE_TYPE.search(up(body)):
return "dub"
if RE_PIPE.search(body) or re.search(r"\([^()]*(?:mvo|dvo|avo|dub|дубл)", up(body)):
return "dub"
if RE_RU.search(body) and (RE_CODEC.search(s) or m and "перевод" in up(s[:30]).lower()):
return "other" # русская дорожка без типа: русский оригинал или неизвестный дубляж
return None
def norm(s: str) -> str:
return re.sub(r"\s+", " ", (s or "").replace("\u00a0", " ")).strip().casefold()
def line_prefix(line: str) -> str:
s = norm(line)
s = re.sub(r"\d+", "N", s)
m = re.match(r"^(.{0,26}?[:.])", s)
return (m.group(1) if m else s[:26]).strip()
def page_text(src: str, rec: dict) -> str:
if src == "kinozal":
return "\n".join(x for x in (rec.get("h1"), rec.get("tech_tab"), rec.get("release_tab")) if x)
return rec.get("post") or ""
# ---------------------------------------------------------------- загрузка
def load_pages(cache: Path | None) -> list[tuple[str, str, str, bool, bool]]:
if cache and cache.exists():
return pickle.loads(cache.read_bytes())
out = []
for src in SOURCES:
d = RAW / src
for name in sorted(os.listdir(d)):
if not name.endswith(".json"):
continue
rid = name.split(".")[0]
try:
rec = json.loads((d / name).read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
out.append((src, rid, page_text(src, rec), "legacy" in name, bool(rec.get("ok", True))))
if cache:
cache.write_bytes(pickle.dumps(out))
return out
def row_strings(rows: list[dict]) -> list[str]:
out = []
for r in rows:
for k in ("raw", "audio_raw", "role_raw"):
v = norm(r.get(k) or "")
if len(v) >= 4:
out.append(v)
return out
def covered(line: str, rstr: list[str]) -> bool:
l = norm(line)
return any(r in l or (len(l) >= 12 and l in r) for r in rstr)
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--cache", type=Path)
args = ap.parse_args()
sys.stdout.reconfigure(encoding="utf-8")
pages = load_pages(args.cache)
rows = list(csv.DictReader(LABELS.open(encoding="utf-8-sig", newline="")))
by_rel = defaultdict(list)
for r in rows:
by_rel[(r["source"], re.sub(r"^(kz|nnm|rt)-", "", r["release_id"]))].append(r)
per = defaultdict(Counter)
fmt_unlab = defaultdict(Counter)
fmt_part = defaultdict(Counter)
per_rel = defaultdict(Counter)
gaps = [] # (src, rid, kind, line, why)
examples = defaultdict(list)
page_csv = []
rnd = random.Random(11)
for src, rid, text, legacy, ok in pages:
L = by_rel.get((src, rid), [])
lines = []
for raw in text.splitlines():
kind = track_kind(raw)
if kind:
lines.append((kind, re.sub(r"\s+", " ", raw).strip()))
dub = [l for k, l in lines if k == "dub"]
stat = per[src]
stat["страниц"] += 1
stat["legacy" if legacy else "обычных"] += 1
if not ok:
stat["ok=False"] += 1
page_csv.append({"source": src, "release_id": rid, "has_labels": int(bool(L)),
"n_rows": len(L), "n_lines": len(lines), "n_dub_lines": len(dub)})
if not L:
stat["без меток"] += 1
if not lines:
stat["без меток: нет аудио-строк"] += 1
elif not dub:
stat["без меток: только русская дорожка/не озвучка"] += 1
else:
stat["без меток: ЕСТЬ дорожка озвучки"] += 1
for l in dub:
key = line_prefix(l)
fmt_unlab[src][key] += 1
if len(examples[(src, "unlabeled", key)]) < 20:
examples[(src, "unlabeled", key)].append((rid, l))
gaps.append((src, rid, "no_labels", l, "есть дорожка, строки датасета нет"))
continue
stat["с метками"] += 1
rs = row_strings(L)
unmatched = [l for l in dub if not covered(l, rs)]
page_csv[-1]["n_unmatched"] = len(unmatched)
if unmatched:
per_rel[src]["релизов с непокрытыми строками"] += 1
per_rel[src]["непокрытых строк"] += len(unmatched)
for l in unmatched:
key = line_prefix(l)
fmt_part[src][key] += 1
if len(examples[(src, "covered", key)]) < 20:
examples[(src, "covered", key)].append((rid, l))
gaps.append((src, rid, "partial", l, "строка не попала в метки"))
stat["строк дорожек найдено"] += len(dub)
stat["строк покрыто"] += len(dub) - len(unmatched)
# ---------------------------------------------------------------- отчёт
out = ["# Покрытие страниц data/raw датасетом", "",
"Сгенерировано `tools/coverage_audit.py`. Строка «дорожки» — независимый от парсеров",
"детектор (префикс «Перевод/Озвучка/Аудио…», тип озвучки или блок `|студия|`);",
"«покрыта» — для строки есть строка `labels.csv` этого релиза (`raw`/`audio_raw`/`role_raw`).",
""]
for src in SOURCES:
stat = per[src]
out += [f"## {src}", ""]
for k in ("страниц", "обычных", "legacy", "ok=False", "без меток", "без меток: нет аудио-строк",
"без меток: только русская дорожка/не озвучка", "без меток: ЕСТЬ дорожка озвучки",
"с метками", "строк дорожек найдено", "строк покрыто"):
if stat[k]:
out.append(f"- {k}: {stat[k]}")
if stat["с метками"]:
found = stat["строк дорожек найдено"]
out.append(f"- покрытие строк: {stat['строк покрыто'] / max(found, 1):.1%}")
out.append("")
for key in sorted(fmt_unlab[src], key=fmt_unlab[src].get, reverse=True)[:12]:
out.append(f"**без меток · {key}** — {fmt_unlab[src][key]}")
for rid, l in examples[(src, "unlabeled", key)][:3]:
out.append(f" - {rid}: {l[:150]}")
out.append("")
for key in sorted(fmt_part[src], key=fmt_part[src].get, reverse=True)[:12]:
out.append(f"**не покрыто · {key}** — {fmt_part[src][key]}")
for rid, l in examples[(src, "covered", key)][:3]:
out.append(f" - {rid}: {l[:150]}")
out.append("")
OUT_MD.parent.mkdir(exist_ok=True)
OUT_MD.write_text("\n".join(out), encoding="utf-8")
with OUT_PAGES.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=["source", "release_id", "has_labels", "n_rows",
"n_lines", "n_dub_lines", "n_unmatched"])
w.writeheader()
for r in page_csv:
w.writerow({**{"n_unmatched": ""}, **r})
with OUT_LINES.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f)
w.writerow(["source", "release_id", "kind", "line", "why"])
w.writerows(gaps[:20000])
print(f"страниц {len(pages)}, непокрытых строк {len(gaps)}")
for src in SOURCES:
s = per[src]
print(f"== {src}: страниц {s['страниц']}, без меток {s['без меток']} "
f"(есть дорожка {s['без меток: ЕСТЬ дорожка озвучки']}), "
f"с метками {s['с метками']}, строк {s['строк дорожек найдено']}, "
f"непокрыто {s['строк дорожек найдено'] - s['строк покрыто']}")
print(f"отчёт: {OUT_MD.relative_to(ROOT)}")
if __name__ == "__main__":
main()
+660 -94
View File
@@ -996,6 +996,46 @@ def harvest_nnm(forums: list[str], topics: list[str], pages: int, limit: int,
CYR_TO_LAT = str.maketrans({"А": "A", "В": "B", "С": "C", "Е": "E", "Н": "H",
"К": "K", "М": "M", "О": "O", "Р": "P", "Т": "T", "Х": "X"})
# Декоративный префикс строки дорожки: «[*]Перевод (1):», «►Перевод:», «• Озвучка»,
# «- Перевод:», «№2:». Снимается до сопоставления с ключевым словом, сама строка
# (`raw`) остаётся в датасете как на странице.
RE_LEAD_DECOR = re.compile(r"^\s*(?:\[\s*\*?\s*\]|[*•·►→▸▪◦]+|[-–—]\s*|№\s*(?=\d))+\s*")
# номер дорожки: «(1)», «1-4», «№2», «#3», «1» — один шаблон для «Перевод»/«Аудио»
RE_TRACK_NUM = (r"(?:\(\s*\d+(?:\s*[-–]\s*\d+)?\s*\)|№\s*\d+(?:\s*[-–]\s*\d+)?"
r"|#\s*\d+(?:\s*[-–]\s*\d+)?|\d+(?:\s*[-–]\s*\d+)?)")
# язык в начале имени студии: «Русский, - AlexFilm», «Украинский, AMC»
RE_LANG_PREFIX = re.compile(
r"(?i)^(?:русск\w*|украин\w*|russian|ukrainian|english|англ\w*|японск\w*|китайск\w*"
r"|корейск\w*|немецк\w*|французск\w*|итальянск\w*|испанск\w*|казахск\w*)\s*[,:]\s*[-–—]?\s*")
# «X по заказу Y» — студия X, заказчик в метке не нужен
RE_ON_ORDER = re.compile(r"(?i)^(.+?)\s+по\s+заказу\s+.+$")
# «Перевод: Отсутствует», «Без слов», «Немой», «Не требуется» — дорожки озвучки нет
RE_NO_DUB = re.compile(
r"(?i)\b(?:отсутств\w*|отсутсву\w*|не\s*требу\w*|немо[йе]\b|без\s*слов|без\s*перевода"
r"|нет\s*(?:перевода|озвучки)|оригинал\w*\s*(?:дорожк\w*|звук\w*|аудио)\b"
r"|без\s*диалог\w*|нет\s*диалог\w*)\b")
# то же, но только если строка НАЧИНАЕТСЯ с «нет перевода»: «Профессиональный …,
# оставлена оригинальная дорожка» — это дорожка с пометкой, а не отказ от озвучки
RE_NO_DUB_LINE = re.compile(
r"(?i)^\W*(?:отсутств\w*|отсутсву\w*|не\s*требу\w*|немо[йе]\b|без\s*слов|без\s*перевода"
r"|нет\s*(?:перевода|озвучки)|без\s*диалог\w*|нет\s*диалог\w*)\b")
# пост nnm/rutracker: «Перевод:» может склеиться со следующим полем
RE_FIELD_CUT = re.compile(
r"\s+(?:Описание|Жанр|Год(?:\s*выпуска)?|Страна|Режиссер|Режиссёр|Актеры|Актёры"
r"|Продолжительность|Субтитры|Размер|Качество|Видео|Аудио|Формат)\s*:")
def trim_field(body: str) -> str:
"""Обрезать значение поля на следующем поле поста («… || AniDB», «… Описание:»)."""
return RE_FIELD_CUT.split((body or "").split("||")[0], maxsplit=1)[0].strip()
def strip_lead(line: str) -> str:
"""Снять декоративный префикс («[*]», «►», «•»): ключевое слово встаёт в начало."""
return RE_LEAD_DECOR.sub("", line or "", count=1)
STUDIO_ALIASES = {
"hdrezka": "HDrezka", "hd rezka": "HDrezka", "hdrezka studio": "HDrezka",
"red head sound": "Red Head Sound", "read head sound": "Red Head Sound",
@@ -1033,7 +1073,7 @@ STUDIO_NOISE = re.compile(
# «дубляж» как тип, а не часть имени студии («Дубляжная», «Продубляж», «Дубляж-Студия»)
RE_DUB_TYPE = r"дублир|(?<![а-яё])дубляж(?![а-яё])"
ROLE_PATTERNS = [(RE_DUB_TYPE, "дубляж"), (r"многоголос", "многоголосый"),
(r"двухголос", "двухголосый"), (r"одноголос", "одноголосый"),
(r"дву[хг]?о?голос", "двухголосый"), (r"одноголос", "одноголосый"),
(r"субтитр", "субтитры"), (r"оригинал", "оригинал")]
# 18+ у kinozal/rutracker/nnm стоит в строке дорожки; отдельная строка
@@ -1060,9 +1100,21 @@ def mark_18(note: str, age: bool) -> str:
return (note or "").strip()
# пол голоса перед именем или вместо него: «(муж.)», «женская : Persona99», «муж./жен.»
RE_GENDER_LEAD = re.compile(
r"(?i)^(?:(?:мужск\w*|женск\w*|мужчин\w*|(?:муж|жен)\b\.?)\s*(?:[+/&,]|\s+и\s+)?\s*)+[:,\-–—]?\s*")
def clean_studio(raw: str) -> tuple[str, str]:
note = ""
s = (raw or "").strip().strip("|").strip()
s = RE_LANG_PREFIX.sub("", s) # «Русский, - AlexFilm» -> «AlexFilm»
s = re.sub(r"(?i)^(?:озвучил\w*|озвучено|озвучивание|озвучк\w*|перевёл|перевел)\s*[-–—:]?\s*", "", s).strip()
s = RE_GENDER_LEAD.sub("", s.lstrip(" :,;-–—"))
s = re.sub(r"(?i)^от\s+", "", s) # «(муж.) от Ancord» -> «Ancord»
m = RE_ON_ORDER.match(s) # «X по заказу Y» -> «X»
if m:
s = m.group(1)
s = re.sub(r"\[[^\]]*\]", " ", s)
s = re.sub(r"\([^)]*\)", " ", s)
if re.search(r"\b18\s*\+", s):
@@ -1100,6 +1152,8 @@ def translation_type(text: str) -> tuple[str, str]:
if re.search(r"любительск|псевдо", t): # любительский дубляж — отдельный класс, не «проф.»
return "люб. дубляж", "люб."
return "проф. дубляж", "проф."
if kind == "неизвестно" and "авторск" in t:
kind = "одноголосый" # «Авторский (Павел Санаев)» — один голос
if "профессиональн" in t:
pref = "проф."
elif "любительск" in t:
@@ -1115,7 +1169,8 @@ def translation_type(text: str) -> tuple[str, str]:
# ---- kinozal -------------------------------------------------------
KZ_RE_TRANSLATION = re.compile(
r"^\s*(?:Перевод|Озвучиван\w*|Озвучк\w*)\s*(?:#\s*\d+|\d+)?\s*:\s*(?P<body>.+)$", re.I)
r"^\s*(?:Перевод|Озвучиван\w*|Озвучк\w*|Дубляж)\s*" + RE_TRACK_NUM +
r"?\s*:\s*(?P<body>.+)$", re.I)
KZ_RE_AUDIO_X = re.compile(
r"^\s*[АA]удио(?:\s*#?\s*\d+)?\s*x\s*(?P<lang>[A-ZА-Я/]\w*)\s*:\s*(?P<body>.+)$", re.I)
KZ_RE_AUDIO = re.compile(r"^\s*[АA]удио\s*:\s*(?P<body>.+)$", re.I)
@@ -1172,7 +1227,7 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
tech_text = (rec.get("tech_tab") or "").strip()
def has_transl(t: str) -> bool:
return any(KZ_RE_TRANSLATION.match(l) for l in t.splitlines())
return any(KZ_RE_TRANSLATION.match(strip_lead(l)) for l in t.splitlines())
if has_transl(release_text):
source, studio_source = release_text, "release"
@@ -1182,39 +1237,48 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
translations: list[dict] = []
audios: list[dict] = []
subs = ""
# переводы берём только из приоритетной вкладки, иначе «Техданные»
# добавляют вторую, обезличенную строку «Перевод: Дублированный»
for line in source.splitlines():
line = line.strip()
m = KZ_RE_TRANSLATION.match(line)
if m:
body = m.group("body").strip()
def parse_tab(text: str, src: str) -> None:
"""«Перевод:» из вкладки: одна строка может называть несколько студий."""
for raw in text.splitlines():
line = strip_lead(raw.strip())
m = KZ_RE_TRANSLATION.match(line)
if not m:
continue
body = trim_field(m.group("body"))
if not body or RE_NO_DUB_LINE.match(body):
continue
actors = ""
am = KZ_RE_ACTORS.search(body)
if am:
actors = am.group("names").strip(" .")
role, candidates = "", []
role = ""
pm = re.search(r"\(([^)]*)\)", body)
if pm:
role = pm.group(1)
candidates = [body[pm.end():].strip(" |.-–—"), role,
body[:pm.start()].strip(" |.-–—")]
else:
candidates = [body]
studio, note = "", ""
for cand in candidates:
s1, n1 = clean_studio(cand)
s1, n2 = canonical_studio(s1)
if s1:
studio, note = s1, " ".join(x for x in (n1, n2) if x)
break
kind, pro = translation_type(body)
if re.search(r"не требуется", body, re.I):
kind = "оригинал"
translations.append({"studio": studio, "type": kind, "pro": pro,
"role": role, "actors": actors, "note": note,
"line": line})
names = part_studios(body)
if untyped_list(kind, names):
continue
for name in names or [""]:
translations.append({"studio": name, "type": kind, "pro": pro,
"role": role, "actors": actors, "note": "",
"line": raw.strip(), "src": src})
# переводы из обеих вкладок: «Техданные» дают тип, «Релиз» — студии
parse_tab(release_text, "release")
parse_tab(tech_text, "tech")
named_types = {t["type"] for t in translations if t["studio"]}
seen_t, uniq_t = set(), []
for t in translations:
if not t["studio"] and t["type"] in named_types:
continue # обезличенный дубль строки со студией
key = (t["type"], t["studio"].casefold(), t["line"])
if key in seen_t:
continue
seen_t.add(key)
uniq_t.append(t)
translations = uniq_t
# аудиодорожки и субтитры могут быть в любой из вкладок
for line in (source + "\n" + tech_text).splitlines():
@@ -1274,7 +1338,7 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
"note": mark_18(note, age_18), "age_18": int(age_18),
"codec": a.get("codec", ""), "channels": a.get("channels", ""),
"khz": a.get("khz", ""), "kbps": a.get("kbps", ""),
"subtitles": subs, "studio_source": studio_source, "raw": t["line"],
"subtitles": subs, "studio_source": t["src"], "raw": t["line"],
"lang": (a.get("lang") or "RUS") if match else "",
})
rel = {"source": "kinozal", "release_id": rec["id"], "ru_title": info["ru_title"],
@@ -1287,7 +1351,13 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
# ---- rutracker -----------------------------------------------------
RT_RE_FIELD = re.compile(
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук)\s*(?:#\s*\d+|\d+)?\s*:\s*(?P<body>.+)$", re.I)
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук|Дубляж)\s*" + RE_TRACK_NUM +
r"?\s*[:.]\s*(?P<body>.+)$", re.I)
RT_RE_BARE = re.compile(
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук|Дубляж)\s*" + RE_TRACK_NUM +
r"?\s*:\s*$", re.I)
RT_RE_TYPE_START = re.compile(
r"^\s*(?:Профессиональн|Любительск|Авторск|Дублирован|Многоголос|Двухголос|Одноголос)", re.I)
RT_RE_SUBS = re.compile(r"^\s*Субтитры\s*:\s*(?P<body>.+)$", re.I)
RT_RE_BRACKET = re.compile(r"\[(?P<body>[^\]]*)\]")
RT_RE_SEP = re.compile(r"\s*(?:\+|;|/)\s*(?=Проф|Люб|Авт|Дубл|Многоголос|Двухголос|Одноголос)", re.I)
@@ -1366,22 +1436,53 @@ def audio18_rows(rows: list[dict], post: str) -> list[dict]:
return rows + extra
def rt_bare_list(lines: list[str], i: int, taken: set[int]) -> list[str]:
"""«Перевод:» без значения: дорожки списком ниже, по строке на дорожку.
Берутся подряд идущие строки со словом типа озвучки; между ними могут стоять
пустые (в HTML после <br> идёт ещё и перевод строки). Номера взятых строк
кладутся в taken, чтобы их не разбирали второй раз.
"""
out, j = [], i + 1
while True:
k = j
while k < len(lines) and not lines[k].strip():
k += 1
if k >= len(lines) or not RT_RE_TYPE_START.match(lines[k]) or ":" in lines[k]: # «тип: определение» — глоссарий
return out
out.append(lines[k])
taken.add(k)
j = k + 1
def _rt_parse_core(rec: dict) -> tuple[list[dict], dict]:
post = rec.get("post") or ""
info = rt_parse_title(rec.get("page_title") or rec.get("title") or "")
entries, subs = [], ""
for line in post.splitlines():
m = RT_RE_FIELD.match(line)
if m:
for part in rt_split_entries(m.group("body")):
e = rt_parse_entry(part)
if e["type"] in ("оригинал", "субтитры"):
continue
if not e["studio"] and e["type"] == "неизвестно":
continue
entries.append(e)
entries, subs, taken = [], "", set()
lines = post.splitlines()
for i, line in enumerate(lines):
if i in taken:
continue
m = RT_RE_SUBS.match(line)
head = strip_lead(line)
m = RT_RE_FIELD.match(head)
bodies = [trim_field(m.group("body"))] if m else (
rt_bare_list(lines, i, taken) if RT_RE_BARE.match(head) else [])
if bodies:
for body in bodies:
for part in rt_split_entries(body):
if RE_NO_DUB_LINE.match(part):
continue
e = rt_parse_entry(part)
if e["type"] in ("оригинал", "субтитры"):
continue
# несколько студий в одной части: «(многоголосый) — Jaskier, TVShows, LostFilm»
names = part_studios(part) or ([e["studio"]] if e["studio"] else [])
if (e["type"] == "неизвестно" and not names) or untyped_list(e["type"], names):
continue
for name in names or [""]:
entries.append({**e, "studio": name})
continue
m = RT_RE_SUBS.match(head)
if m and not subs:
subs = m.group("body").strip()
rows = [{
@@ -1402,9 +1503,14 @@ def _rt_parse_core(rec: dict) -> tuple[list[dict], dict]:
# ---- nnmclub -------------------------------------------------------
NNM_RE_AUDIO = re.compile(r"^\s*Аудио\s*(?:#?\s*\d+)?\s*:\s*(?P<body>.+)$", re.I)
NNM_RE_PEREVOD = re.compile(r"^\s*Перевод\s*:\s*(?P<body>.+)$", re.I)
NNM_RE_AUDIO = re.compile(
r"^\s*(?:(?:Аудио|Audio)\s*[^:\n]{0,12}?|Звук\s*(?:[#№]\s*\d+|\d+)?)\s*[:.]\s*(?P<body>.+)$", re.I)
NNM_RE_PEREVOD = re.compile(
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Дубляж)\s*" + RE_TRACK_NUM +
r"?\s*:\s*(?P<body>.+)$", re.I)
NNM_LANG_RE = re.compile(r"(Русский|Английский|Украинский)", re.I)
# «Язык озвучки: Русский (Berofu, Takera, Euji [AniMaunt])» — имена без строки «Аудио»
NNM_RE_VOICE_LANG = re.compile(r"(?i)^\s*Язык\s*озвучк\w*\s*[:.]\s*(?P<body>.+)$")
NNM_LANG_MAP = {"русский": "RUS", "английский": "ENG", "украинский": "UKR"}
NNM_TAG_RE = re.compile(r"\b(DUB|MVO|DVO|AVO|VO|ДБ|ПМ|ПД|АП|ЛМ|ЛД|ЛО)\b", re.I)
NNM_RE_CODEC = re.compile(r"\b(Opus|AC-?3|E-?AC-?3|AAC|DTS-?HD|DTS|FLAC|MP3|TrueHD)\b", re.I)
@@ -1460,6 +1566,196 @@ def nnm_studio(name: str) -> tuple[str, str]:
return cleaned, " ".join(x for x in (note, extra) if x).strip()
# слова, которые в «Перевод: <тип> - <студия>» не студия: тип, кратность, язык
NNM_PART_TYPEISH = re.compile(
r"(?i)голос|дубл|закадр|авторск|любител|профессион|русск|оригинал|субтитр|лицензи"
r"|^\W*[xх]?\d+[xх]?\W*$")
def nnm_part_studio(part: str) -> str:
"""Студия из части строки «Перевод:» nnm: «Многоголосый профессиональный - Jaskier»,
«Дублированный (Невафильм)», «Авторский (Живов» -> имя; «Дублированный (Лицензия)»,
«Одноголосый x2», «Двуголосый профессиональный» -> ""."""
if len(part) > 90 or re.search(r"(?i)премьер|\bдата\b", part):
return "" # склеилось соседнее поле описания
m = re.search(r"\s[-–—:]\s*(.+)$", part)
cands = ([m.group(1)] if m else []) + [c for c in re.findall(r"\(([^()]*)\)?", part) if c]
for cand in cands:
studio, _ = clean_studio(cand)
studio, _ = canonical_studio(studio)
if len(studio) >= 2 and not NNM_PART_TYPEISH.search(studio) \
and studio.lower() not in SOURCE_LIKE:
return studio
return ""
# ---------------------------------------------------------------- студии из перечислений
# корни «голосности»: по ним решается, режется ли перечисление через запятую
RE_TYPE_ROOT = re.compile(r"(?i)\b(дубл|многоголос|дву[хг]?голос|одноголос)")
# «Звук: X», «Озвучка: двухголосая (X)» — хвост с типом/именем в кодек-строке
RE_DESC_TAIL = re.compile(r"(?i)\b(?:звук|озвучк\w*|озвучиван\w*|перевод|дубляж)\s*[:.]\s*(.+)$")
# язык в начале скобки: «(Русский, Anilibria)» -> «Anilibria»
RE_PAREN_LANG = re.compile(
r"(?i)^\s*(?:русск\w*|russian|rus|ru|украин\w*|ukrainian|ukr|англ\w*|english|eng"
r"|японск\w*|japanese|jap|китайск\w*|корейск\w*|немецк\w*|французск\w*|итальянск\w*"
r"|испанск\w*|казахск\w*|оригинал\w*|original)\s*[,;:]?\s*")
NOT_STUDIO_WORD = re.compile(
r"(?i)^(?:по умолчанию|внешн\w*|встроенн\w*|в контейнере|в составе|оригинал\w*|комментар\w*"
r"|сезон\w*|сери\w*|эпизод\w*|фильм\w*|часть\w*|диск\w*|дорожк\w*|звук\w*|перевод\w*)$")
STUDIO_SEP = re.compile(r"\s*[,;|/]\s*")
SOURCE_RE = re.compile(r"(?i)^(?:blu-?ray|bd\b|dvd|web-?dl|webrip|hdtv|uhd|itunes|netflix|amazon|r5)")
RE_CODEC_PARAM_ANY = re.compile(
r"(?i)\b(?:e-?ac-?3|ac-?3|ас3|dts|aac|truehd|flac|mp3|opus|l?pcm|mp2|dolby|ddp?)\b"
r"|\d+(?:[.,]\d+)?\s*(?:kbps|кбит|kb/s|khz|кгц|гц|hz|ch|канал(?:а|ов))\b")
# описания дорожки, которые остаются после снятия слов типа: пол, голос, сокращения типа, единицы
NOT_STUDIO_DESCR = re.compile(
r"(?i)(?:мужск\w*|женск\w*|мужчин\w*|муж|жен|голос\w*|проф|люб|авт|сек|cbr|vbr|avg|kb|kbps"
r"|\d+\s*(?:channels?|бит|bits?|kb)|\d+\s+(?:e-?ac-?3|ac-?3|dts|aac|mp3))\.?")
# техпараметры в тексте перед разбиением на имена: «192 kb/s», «Кбит/сек», «2 channels», «5.1»
RE_TECH_SCRUB = re.compile(
r"(?i)\d+(?:[.,]\d+)?\s*(?:kbps|kb/s|kbit/s|кбит/с(?:ек)?|кб/с|kb|кбит|khz|кгц|гц|hz|ch|channels?|бит|bits?)\b"
r"|\b(?:kb|kbit|кбит|кб)\s*/\s*(?:s|с|сек)\b"
r"|\b(?:e-?ac-?3|ac-?3|ас-?3|dts(?:-hd)?|aac|truehd|atmos|flac|mp3|opus|l?pcm|dolby\s+digital|dolby|cbr|vbr|avg)\b"
r"|\b\d\.\d\b")
RE_TV_PREFIX = re.compile(r"(?i)\bт\s*/\s*[кc]\.?\s*") # «т/к Россия» -> «Россия» (слэш — не разделитель)
def is_studio_junk(s: str) -> bool:
"""Не имя студии: источник, тип, язык, техпараметры, «по умолчанию», число."""
low = (s or "").strip().lower()
return (len(low) < 3 or low in SOURCE_LIKE or low in STUDIO_PLACEHOLDERS
or bool(NOT_STUDIO_DESCR.fullmatch(low))
or bool(NOT_STUDIO_WORD.match(low)) or bool(CODEC_NOT_STUDIO.match(low))
or bool(SOURCE_RE.match(low)) or bool(RE_NO_DUB.search(low))
or bool(RE_CODEC_PARAM_ANY.match(low))
or bool(re.search(r"(?i)anidb|\bmal\b|shikimori|world\s*art|описан|список|эпизод|поверх"
r"|\bсери[ияй]\b|\bсезон\w*\b|неизвестн\w*", low))
or bool(re.fullmatch(r"[\d\s.,:/\-]+", low)))
def split_studios(text: str) -> list[str]:
"""Перечисление студий -> имена: «Jaskier, TVShows, HDrezka Studio, LostFilm»."""
out: list[str] = []
text = RE_TECH_SCRUB.sub(" ", RE_TV_PREFIX.sub("", text or ""))
for part in STUDIO_SEP.split(text.strip(" |.-–—")):
st, _ = clean_studio(part.strip(" .|"))
st, _ = canonical_studio(st)
if st and not is_studio_junk(st) and st.casefold() not in {x.casefold() for x in out}:
out.append(st)
return out
def split_translation_parts(body: str) -> list[str]:
"""Значение «Перевод:» -> части-дорожки, не разрезая скобки.
«Профессиональный (многоголосый, закадровый), Авторский» — две дорожки;
«Авторский, одноголосый» — одна (второе слово уточняет первую);
«Профессиональное (многоголосое) / Любительское (одноголосое, Живов)» — две.
"""
text = (body or "").strip()
if not text:
return []
parts, depth, cur = [], 0, ""
for ch in text:
if ch in "([{":
depth += 1
elif ch in ")]}":
depth = max(0, depth - 1)
if ch in ",/" and depth == 0:
parts.append(cur)
cur = ""
else:
cur += ch
parts.append(cur)
parts = [p.strip() for p in parts if p.strip()]
roots = sum(bool(RE_TYPE_ROOT.search(p)) for p in parts)
starts = sum(bool(RT_RE_TYPE_START.match(p)) for p in parts)
return parts if len(parts) > 1 and (roots >= 2 or starts >= 2) else [text]
def untyped_list(kind: str, names: list[str]) -> bool:
"""Строка без типа озвучки с перечнем из 3+ имён — актёры (оригинала или дубляжа), а не дорожки."""
return kind == "неизвестно" and len(names) >= 3
def part_studios(part: str) -> list[str]:
"""Студии из части «Перевод:»: хвост после тире, скобки, остаток без слов типа."""
names: list[str] = []
def add(chunk: str) -> None:
for n in split_studios(chunk):
if n.casefold() not in {x.casefold() for x in names}:
names.append(n)
m = re.search(r"\s[-–—:]\s*(.+)$", part)
if m:
add(m.group(1))
for pm in re.finditer(r"\(([^()]*)\)", part):
chunk = RE_PAREN_LANG.sub("", pm.group(1))
if chunk and not NNM_PART_TYPEISH.search(chunk):
add(chunk)
rest = re.sub(r"\([^()]*\)", " ", part)
rest = re.sub(r"(?i)\b(?:профессиональн\w*|любительск\w*|авторск\w*|дублированн\w*|многоголос\w*"
r"|дву[хг]?голос\w*|одноголос\w*|закадров\w*|перевод\w*|озвучк\w*|х?\d+\s*[xх])\b",
" ", rest)
add(rest)
return names[:8]
RE_LANG_TOKEN = re.compile(
r"(?i)(?<![a-zа-яё])(русск\w*|russian|rus|ru|украин\w*|ukrainian|ukr"
r"|англ\w*|english|eng|японск\w*|japanese|jap|китайск\w*|корейск\w*|немецк\w*"
r"|французск\w*|итальянск\w*|испанск\w*|казахск\w*|оригинал\w*|original)(?![a-zа-яё])")
def lang_tokens(text: str) -> list[str]:
"""Коды языков, названных в строке: «| Rus |», «(Русский, Anilibria)», «RUS(int)»."""
out: list[str] = []
for m in RE_LANG_TOKEN.finditer(text or ""):
w = m.group(1).lower()
code = ("RUS" if w.startswith(("русск", "russ", "rus")) or w == "ru"
else "UKR" if w.startswith(("укра", "ukra", "ukr")) or w == "uk"
else "ENG" if w.startswith(("англ", "engl", "eng")) or w == "en"
else "JPN" if w.startswith(("япон", "japan", "jap"))
else "OTHER")
if code not in out:
out.append(code)
return out
def audio_names(body: str) -> list[str]:
"""Имена студий из строки «Аудио …».
«Аудио 4: AC3 … (внешние) (Русский, Anilibria)» -> Anilibria;
«Аудио: AAC … | Rus | Звук: Zendos & Eladiel» -> Zendos & Eladiel;
«Аудио 2: … (MVO, LostFilm)» -> LostFilm.
"""
names: list[str] = []
def add(chunk: str) -> None:
chunk = (chunk or "").strip()
if not chunk or RE_CODEC_PARAM_ANY.search(chunk) and not RE_TYPE_ROOT.search(chunk):
return
for n in split_studios(chunk):
if n.casefold() not in {x.casefold() for x in names}:
names.append(n)
for seg in re.split(r"\|", body):
seg = seg.strip()
if not seg:
continue
dm = RE_DESC_TAIL.search(seg)
add(dm.group(1) if dm else seg)
for pm in re.finditer(r"\(([^()]*)\)", body):
chunk = RE_PAREN_LANG.sub("", pm.group(1))
if chunk and not NNM_PART_TYPEISH.search(chunk):
add(chunk)
return names[:8]
def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
post = rec.get("post") or ""
title = rec.get("page_title") or rec.get("title") or ""
@@ -1475,37 +1771,36 @@ def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
for line in post.splitlines():
line = line.strip()
m = NNM_RE_AUDIO.match(line)
if m:
body = m.group("body").strip()
lm = NNM_LANG_RE.search(body)
if not lm or NNM_LANG_MAP.get(lm.group(1).lower()) != "RUS":
continue # английскую/украинскую дорожку пропускаем
rest = body[lm.end():]
tm = NNM_TAG_RE.search(rest)
m = NNM_RE_AUDIO.match(strip_lead(line))
if m and not re.match(r"(?i)^\s*аудио\s*кодек", strip_lead(line)):
body = trim_field(m.group("body"))
if RE_NO_DUB_LINE.match(body):
continue
langs = lang_tokens(body)
if not langs or not ({"RUS", "UKR"} & set(langs)):
continue # английская/японская дорожка
lang = "RUS" if "RUS" in langs else "UKR"
flat = body.translate(CYR_TO_LAT)
tm = NNM_TAG_RE.search(flat)
tag = tm.group(1).upper() if tm else ""
low = body.lower()
if not tag: # формат «... - дубляж» без аббревиатуры
low = rest.lower()
if "дубляж" in low:
tag = "DUB"
elif "многоголос" in low:
tag = "MVO"
elif "двухголос" in low:
elif re.search(r"дву[хг]?голос", low):
tag = "DVO"
elif "авторск" in low:
tag = "AVO"
elif "одноголос" in low:
tag = "VO"
name_raw = ""
pm = re.search(r"\((.*)\)", rest)
if pm:
name_raw = pm.group(1).strip()
studio, note = nnm_studio(name_raw)
age_18 = has_18(rest)
note = mark_18(note, age_18)
ttype, pro = nnm_tag_type(tag)
if not ttype:
ttype, pro = "неизвестно", ""
names = audio_names(body)
if not names and ttype == "неизвестно":
continue # ни типа, ни имени — строке нечего дать датасету
codec_m = NNM_RE_CODEC.search(body)
kb_m = NNM_RE_KBP.search(body)
ch_m = NNM_RE_CH.search(body)
@@ -1514,45 +1809,77 @@ def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
lay = NNM_RE_LAYOUT.search(body)
if lay:
channels = str(int(lay.group(1)) + (1 if lay.group(2) != "0" else 0))
rows.append({
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
"year": year, "quality": quality, "is_series": is_series,
"studio": studio or "(не указана)", "translation_type": ttype,
"professional": pro, "role_raw": tag, "actors": name_raw,
"note": note,
"age_18": int(age_18),
"codec": (codec_m.group(1).upper() if codec_m else ""),
"channels": channels,
"khz": "",
"kbps": (kb_m.group(1) if kb_m else ""),
"subtitles": "", "studio_source": "release", "raw": line,
})
continue
m = NNM_RE_PEREVOD.match(line)
if m:
for part in re.split(r"[,/]", m.group("body")):
part = part.strip()
if not part:
continue
ttype, pro = translation_type(part)
if ttype in ("неизвестно", "оригинал", "субтитры"):
continue
perevod_types.append(part)
# без имён в скобках студия неизвестна, но тип перевода ценен
age_18 = has_18(body)
for name in names or [""]:
rows.append({
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
"year": year, "quality": quality, "is_series": is_series,
"studio": "(не указана)", "translation_type": ttype,
"professional": pro, "role_raw": part, "actors": "",
"note": mark_18("", has_18(line)), "age_18": int(has_18(line)),
"codec": "", "channels": "", "khz": "", "kbps": "",
"subtitles": "", "studio_source": "tech", "raw": line,
"studio": name or "(не указана)", "translation_type": ttype,
"professional": pro, "role_raw": tag, "actors": name,
"note": mark_18("", age_18),
"age_18": int(age_18),
"codec": (codec_m.group(1).upper() if codec_m else ""),
"channels": channels,
"khz": "",
"kbps": (kb_m.group(1) if kb_m else ""),
"subtitles": "", "studio_source": "release", "raw": line, "lang": lang,
})
continue
m = NNM_RE_PEREVOD.match(strip_lead(line))
if m:
body = trim_field(m.group("body"))
for part in split_translation_parts(body):
if RE_NO_DUB_LINE.match(part):
continue
ttype, pro = translation_type(part)
if ttype in ("оригинал", "субтитры"):
continue
names = part_studios(part)
if (ttype == "неизвестно" and not names) or untyped_list(ttype, names):
continue
# студия — если названа в части («Дублированный (Невафильм)»); без неё тип всё равно ценен
for name in names or [""]:
rows.append({
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
"year": year, "quality": quality, "is_series": is_series,
"studio": name or "(не указана)", "translation_type": ttype,
"professional": pro, "role_raw": part, "actors": name,
"note": mark_18("", has_18(line)), "age_18": int(has_18(line)),
"codec": "", "channels": "", "khz": "", "kbps": "",
"subtitles": "", "studio_source": "tech", "raw": line,
})
# страницы, где дорожка описана только полем «Язык озвучки: Русский (Актеры [Студия])»
if not rows:
for line in post.splitlines():
m = NNM_RE_VOICE_LANG.match(strip_lead(line.strip()))
if not m:
continue
body = trim_field(m.group("body"))
if "RUS" not in lang_tokens(body):
continue
names = audio_names(body)
teams = [n for team in re.findall(r"\[([^\[\]]{2,60})\]", body) for n in split_studios(team)]
if teams: # «Русский (Berofu, Takera [AniMaunt])»: автор — команда, а не актёры
names = list(dict.fromkeys(teams))
for name in names[:8]:
rows.append({
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
"year": year, "quality": quality, "is_series": is_series,
"studio": name, "translation_type": "неизвестно", "professional": "",
"role_raw": "", "actors": "", "note": mark_18("", has_18(line)),
"age_18": int(has_18(line)), "codec": "", "channels": "", "khz": "", "kbps": "",
"subtitles": "", "studio_source": "release", "raw": line.strip(), "lang": "RUS",
})
# если построчных «Аудио N» с именами не было — оставляем только типы
if any(r["studio"] != "(не указана)" for r in rows):
rows = [r for r in rows if r["studio"] != "(не указана)"]
# есть построчные «Аудио N» с именами — строки «Перевод:» (tech) того же типа
# не нужны; типы, которых в «Аудио» нет, остаются (авторская дорожка и т. п.)
if any(r["studio"] != "(не указана)" and r["studio_source"] == "release" for r in rows):
rel_types = {r["translation_type"] for r in rows
if r["studio_source"] == "release" and r["studio"] != "(не указана)"}
rows = [r for r in rows
if r["studio_source"] == "release" or r["translation_type"] not in rel_types]
# дорожка без имени и без типа («- Русский») ничего не даёт датасету
rows = [r for r in rows
@@ -1715,6 +2042,242 @@ def audio_only_rows(rec: dict, text: str, rows: list[dict]) -> list[dict]:
return out
def kz_title_rows(rec: dict, text: str, rows: list[dict]) -> list[dict]:
"""kinozal: студии из заголовка раздачи «… / ПМ (LostFilm, HDrezka Studio), ДБ (X) / …».
На kinozal студии часто названы только в заголовке и в строках «Аудио Rus: … | LostFilm»
(без типа), а «Перевод:» во «Техданных» — без студий. Строка-вход — тот же синтетический
сегмент, что строит сервис из заголовка (src.release.title_segments); строка «Аудио» с этой
студией (если есть) — в audio_raw (позитив для сегментатора).
"""
from src.release import RE_TITLE_TRACK, TITLE_TYPES
def key(x: str) -> str:
return re.sub(r"[\W_]+", "", (x or "").casefold())
title = (rec.get("h1") or rec.get("page_title") or "").split(" :: ")[0]
have = key(" ".join(((r.get("studio") or "") + " " + (r.get("raw") or "")) for r in rows))
lines = [ln for ln in audio_lines_all(text) if not ln["original"] and not ln["commentary"]]
base = rows[0] if rows else {}
info = kz_split_title(title)
out = []
for abbr, names in RE_TITLE_TRACK.findall(title):
for name in re.split(r"\s*[,;]\s*", names.strip()):
studio, note = clean_studio(name)
studio, extra = canonical_studio(studio)
k = key(studio)
if len(k) < 2 or k in have or studio.lower() in SOURCE_LIKE:
continue
kind, pro = translation_type(TITLE_TYPES[abbr])
a = next((ln for ln in lines if k in key(ln["raw"])), None)
out.append({
"source": "kinozal", "release_id": rec.get("id", ""),
"ru_title": base.get("ru_title") or info["ru_title"],
"year": base.get("year") or info["year"], "quality": base.get("quality", ""),
"is_series": base.get("is_series", bool(re.search(r"сезон|серии", title, re.I))),
"studio": studio, "translation_type": kind, "professional": pro,
"role_raw": TITLE_TYPES[abbr], "actors": "",
"note": mark_18(" ".join(x for x in (note, extra) if x).strip(), bool(a and a["adult"])),
"age_18": int(bool(a and a["adult"])),
"codec": a["codec"] if a else "", "channels": a["channels"] if a else "", "khz": "",
"kbps": a["kbps"] if a else "", "subtitles": "", "studio_source": "title",
"raw": f"{TITLE_TYPES[abbr]} | {name.strip()}",
"lang": (a["lang"] or "RUS") if a else "RUS", "audio_raw": a["raw"] if a else ""})
have += k
return out
# кодек-строка одной дорожки: «Аудио 2: …», «Аудио Rus: …», «Аудио pic RUS: …», «Звук #1: …»,
# «• №3: Русский (АС3 …)», «Russian (Dolby AC3, 6 ch) …»
RE_CODEC_LINE = re.compile(
r"(?i)^\s*[•#№*\-\s]*(?:(?:аудио\w*|audio|звук\w*|track|битрейт\s*аудио|title|заголовок)\b[^:\n]{0,24}:"
r"|№\s*\d+(?:\s*[-–]\s*\d+)?\s*:|#\s*\d+\s*:|(?:russian|русск\w*|украин\w*)\b)")
RE_CODEC_PARAM = re.compile(r"(?i)\b(?:e-?ac-?3|ac-?3|ас3|dts|aac|truehd|flac|mp3|opus|l?pcm|mp2)\b|\d\s*(?:kbps|кбит|kb/s)")
RE_LANG_WORD = re.compile(
r"(?i)русск|russian|\brus\b|украин|ukrain|\bukr\b|україн|англ|english|\beng\b|япон|japan|казах|kazakh"
r"|француз|french|немецк|german|итальян|испан|корей|китайск")
# не студия: язык, тех. параметры, тип, источник, пометки
CODEC_NOT_STUDIO = re.compile(
r"(?i)^(?:rus|ukr|eng|ru|ua|int|ext|pic|dub|mvo|dvo|avo|vo|лицензи\w*|ненормативн\w*.*|.*\bлексик[аеиоу]\b.*"
r"|присутствует.*|внешн\w*|в составе.*|серии.*|эп\w*\.?\s*\d.*|[\d\s.,:/\-]+|.*\b(?:kbps|ch|khz|кбит)\b.*"
r"|.*(?:русск|английск|украинск|russian|english|ukrainian|оригинал|original|японск|китайск"
r"|корейск|немецк|французск|итальянск|испанск|казахск)\w*.*"
r"|.*(?:голос|дублир|закадр|перевод).*|.*(?<![а-яё])дубляж(?![а-яё]).*|звук.*"
r"|.*(?:blu-?ray|itunes|netflix|amazon|\bbd\b|\bdvd\b|web-?dl).*)$")
# имя из скобок кодек-строки, которого нет в словаре: «(L,C,R,l,r)», «(АС3 …)», «(1-8 серии)», «(поздний)»
CODEC_NAME_NOISE = re.compile(
r"(?i)^(?:[lrc]|sl|sr|ls|rs|lfe)\b|\b(?:[lrc]|lfe)\s*[,;]|^(?:cee|eur|vhs|cbr|vbr|lfe|stereo|custom"
r"|line|production|россия|сек|[aа]{1,2}[сc]\w?|мр\w?|front|side)\b|\bmono\b|dolby|поздн|ранн|отдельн|тифло|комментар"
r"|сезон|сери[ия]|эпизод|файл|контейнер|bits?\b|sound\s*track|sdh|forced|вставк|сэмпл|sample|неизвестн|unknown")
def _lang_key(word: str) -> str:
w = word.lower()
return "RUS" if w.startswith(("русс", "russ", "rus")) else "UKR" if w.startswith(("укр", "ukr")) else w[:4]
def _known_studios() -> re.Pattern | None:
"""Словарь студий из data/labels.csv (≥ 5 строк): имена, которые kinozal пишет без скобок
(«Аудио Rus: … | LostFilm», «… - Русский, LostFilm», «… MVO DniproFilm / HDRezka Studio»)."""
global _KNOWN_RX
if _KNOWN_RX is None:
path = _REPO / "data" / "labels.csv"
cnt: Counter = Counter()
if path.exists():
with path.open(encoding="utf-8-sig", newline="") as f:
for r in csv.DictReader(f):
if r.get("studio_source") == "codec": # словарь — без своих же строк: повторный прогон стабилен
continue
st = (r.get("studio") or "").strip()
if len(st) >= 3 and st != "(не указана)" and not CODEC_NOT_STUDIO.match(st) \
and st.lower() not in SOURCE_LIKE and not CODEC_NAME_NOISE.search(st):
cnt[st] += 1
names = sorted((s for s, c in cnt.items() if c >= 5), key=len, reverse=True)
_KNOWN_RX = re.compile(r"(?i)(?<![\w])(" + "|".join(map(re.escape, names)) + r")(?![\w])") \
if names else re.compile(r"(?!x)x")
return _KNOWN_RX
_KNOWN_RX: re.Pattern | None = None
def _rec_base(rec: dict) -> dict:
"""Метаданные релиза, когда размеченных строк ещё нет (для строк из кодек-линий)."""
src = rec.get("source") or ""
title = rec.get("page_title") or rec.get("title") or ""
if src == "kinozal":
info = kz_split_title(title.split(" :: ")[0])
return {"source": src, "ru_title": info["ru_title"], "year": info["year"], "quality": "",
"is_series": bool(re.search(r"сезон|серии", title, re.I))}
info = rt_parse_title(title)
return {"source": src, "ru_title": info["ru_title"], "year": info["year"],
"quality": info["quality"], "is_series": info["is_series"]}
RE_AFTER_GENDER = re.compile(r"(?i)\([^()]*\b(?:муж|жен)(?:\.|ск\w*|\b)[^()]*\)\s*:?\s*([^()\[\]|;,:\s][^()\[\]|;,:]{1,59}?)\s*(?=[(\[|;,]|$)")
RE_BEFORE_GENDER = re.compile(r"(?i)\s[-–—]\s*([^()\[\]|;,:\s][^()\[\]|;,:]{1,59}?)\s*\([^()]*\b(?:муж|жен)(?:\.|ск\w*|\b)")
def _line_names(line: str, info: dict, known: re.Pattern) -> list[tuple[str, bool]]:
"""Кандидаты в студии кодек-строки: словарь, `|…|`, `<…>`, `[…]`, `(…)`."""
cands: list[tuple[str, bool]] = [(m.group(1), True) for m in known.finditer(line)]
cands += [(n, False) for n in info["names"]]
for rx in (r"\|([^|]{2,60})\|", r"<([^<>]{2,60})>", r"\[([^\[\]]{2,60})\]", r"\(([^()]{2,80})\)"):
cands += [(m.group(1), False) for m in re.finditer(rx, line)]
# имена рядом со скобкой пола: «двухголосая (муж.+жен.) GreyMoon & Aska», «- Dejz & Mikrobelka (мужская/женская)»
for rx in (RE_AFTER_GENDER, RE_BEFORE_GENDER):
cands += [(m.group(1), False) for m in rx.finditer(line)]
seen, out = set(), []
for name, voc in cands:
k = name.strip().casefold()
if k and k not in seen:
seen.add(k)
out.append((name, voc))
return out
def codec_line_rows(rec: dict, text: str, rows: list[dict], known: re.Pattern | None = None) -> list[dict]:
"""Студии и типы из кодек-строк русских/украинских дорожек.
kinozal: «Перевод: Профессиональный (многоголосый закадровый)» без студий + «Аудио 2: AC3 …
(MVO, LostFilm)», «Аудио Rus: … | TVShows»; nnm/rutracker — те же кодек-строки рядом с
«Перевод N:» со списком, где этой студии нет, и строки вида «Аудио 1: язык: Русский,
перевод: многоголосый закадровый, кодек: AC3». Строка-вход — сегмент кодек-строки, как его
режет сервис (`src.release._entries`); тип — из самой строки (MVO, «дубляж»), иначе
единственный тип релиза, иначе «неизвестно». Если у релиза ещё нет ни одной строки, из
кодек-строки берётся и дорожка без имени (тип без студии) — так размечиваются страницы,
где дорожки описаны только строками «Аудио». Сводки из нескольких языков, оригинал и
комментарии пропускаются.
"""
from src.release import _entries, same_studio
from src.trackinfo import parse_audio_line
known = known if known is not None else _known_studios()
have = [r["studio"] for r in rows if r.get("studio") and r["studio"] != "(не указана)"]
types = {r["translation_type"] for r in rows} - {"неизвестно", ""}
base = rows[0] if rows else _rec_base(rec)
out: list[dict] = []
for line in (text or "").splitlines():
line = re.sub(r"\s+", " ", line).strip()
if len(line) > 300 or not RE_CODEC_LINE.match(line) or not RE_CODEC_PARAM.search(line):
continue
if RE_NO_DUB_LINE.match(line):
continue
langs = set(lang_tokens(line))
if len(langs & {"RUS", "UKR", "ENG", "JPN", "OTHER"}) > 1:
continue
info = parse_audio_line(re.sub(r"(?i)^\s*[^:]{0,30}:\s*", "", line, count=1))
if info["original"] or info["commentary"] or info["lang"] not in ("RUS", "UKR", ""):
continue
if not info["lang"] and not (langs & {"RUS", "UKR"}) and not re.search(r"(?i)\brus\b|\bukr\b", line):
continue
tag = NNM_TAG_RE.search(line) or NNM_TAG_RE.search(line.translate(CYR_TO_LAT)) # «АVO» кириллицей
kind, pro = nnm_tag_type(tag.group(1)) if tag else ("", "")
if not kind:
kind, pro = translation_type(line)
if kind.endswith("неизвестно"):
kind, pro = "", ""
if not kind:
kind = next(iter(types)) if len(types) == 1 else "неизвестно"
pro = kind.split()[0] if kind.split()[0] in ("проф.", "люб.", "авт.") else ""
names = _line_names(line, info, known)
found = False # строка назвала настоящую студию (новую или уже известную)
for name, in_vocab in names:
studio, note = clean_studio(name)
studio, extra = canonical_studio(studio)
if len(studio) < 3 or is_studio_junk(studio):
continue
if CODEC_NAME_NOISE.search(studio) or not in_vocab and re.search(r"\d", studio):
continue
if any(same_studio(studio, x) for x in have):
found = True
continue
entries = _entries(line)
seg = next((e for e in entries if name.lower() in e.lower()), entries[0] if entries else line)
out.append({
"source": base.get("source") or rec.get("source", ""), "release_id": rec.get("id", ""),
"ru_title": base.get("ru_title", ""), "year": base.get("year", ""),
"quality": base.get("quality", ""), "is_series": base.get("is_series", False),
"studio": studio, "translation_type": kind, "professional": pro,
"role_raw": "", "actors": "",
"note": mark_18(" ".join(x for x in (note, extra) if x).strip(), info["adult"]),
"age_18": int(info["adult"]), "codec": info["codec"], "channels": info["channels"],
"khz": "", "kbps": info["kbps"], "subtitles": "", "studio_source": "codec",
"raw": seg, "lang": info["lang"] or ("UKR" if re.search(r"(?i)\bukr\b", line) else "RUS"),
"audio_raw": line})
have.append(studio)
found = True
junk_only = bool(names) and not found and kind not in types
if (not rows and not names or junk_only) and kind and kind != "неизвестно":
# дорожка без имени: датасету ценен хотя бы тип; для релизов без строк вовсе или когда
# все кандидаты строки — мусор («(муж.+жен.)», «192 kb/s»), а такого типа у релиза ещё нет
entries = _entries(line)
out.append({
"source": base.get("source") or rec.get("source", ""), "release_id": rec.get("id", ""),
"ru_title": base.get("ru_title", ""), "year": base.get("year", ""),
"quality": base.get("quality", ""), "is_series": base.get("is_series", False),
"studio": "(не указана)", "translation_type": kind, "professional": pro,
"role_raw": "", "actors": "",
"note": mark_18("", info["adult"]),
"age_18": int(info["adult"]), "codec": info["codec"], "channels": info["channels"],
"khz": "", "kbps": info["kbps"], "subtitles": "", "studio_source": "codec",
"raw": entries[0] if entries else line,
"lang": info["lang"] or ("UKR" if re.search(r"(?i)\bukr\b", line) else "RUS"),
"audio_raw": line})
# один и тот же тип/студия из соседних строк-дублей
seen, uniq = set(), []
for r in out:
key = (r["studio"].casefold(), r["translation_type"],
re.sub(r"\s+", " ", r["raw"]).strip().casefold())
if key in seen:
continue
seen.add(key)
uniq.append(r)
return uniq
def _with_audio(core, texts):
"""Обёртка парсера: lang/audio_raw у строк; число переводов в rel — по итоговым строкам."""
def parse(rec: dict) -> tuple[list[dict], dict]:
@@ -1725,6 +2288,9 @@ def _with_audio(core, texts):
rows += audio_only_rows(rec, text, rows)
elif core is _kz_parse_core:
rows = kz_audio_only_rows(rec, text)
if core is _kz_parse_core:
rows += kz_title_rows(rec, text, rows)
rows += codec_line_rows(rec, text, rows)
if rows:
rel["n_translations"], rel["ok"] = len(rows), True
rel["studios"] = " | ".join(sorted({r["studio"] for r in rows}))
+136
View File
@@ -0,0 +1,136 @@
"""Доразметка студий в data/labels.csv без пересборки страниц.
То же, что теперь делает сборщик (tools/harvest/harvest_core.py) при переиндексации,
но по готовому labels.csv и data/raw — минуты вместо полного разбора HTML:
* nnm, строки «Перевод:» (studio_source=tech) с «(не указана)»: студия, названная
в самой части строки («Дублированный (Невафильм)», «… - Jaskier») — nnm_part_studio;
* kinozal: студии из заголовка раздачи «ПМ (LostFilm, HDrezka Studio)», которых нет
среди строк релиза (studio_source=title) — kz_title_rows по data/raw/kinozal/<id>.json;
* все трекеры: студии из кодек-строк «Аудио 2: AC3 … (MVO, LostFilm)», «Аудио Rus: … | TVShows»,
которых нет среди строк релиза (studio_source=codec) — codec_line_rows по data/raw/<трекер>/<id>.json.
Ручные релизы (data/manual_ids.txt) не трогаются. Перед записью — копия
data/labels.bak_relabel_<время>.csv. Повторный запуск ничего не добавляет.
.venv\\Scripts\\python tools\\relabel_studios.py # применить
.venv\\Scripts\\python tools\\relabel_studios.py --dry-run # только статистика
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import shutil
import sys
import time
from collections import Counter, defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tools" / "harvest"))
import harvest_core as H # noqa: E402
LABELS = ROOT / "data" / "labels.csv"
RAW = ROOT / "data" / "raw"
RAW_KZ = RAW / "kinozal"
MANUAL = ROOT / "data" / "manual_ids.txt"
NO_STUDIO = "(не указана)"
def relabel(rows: list[dict], raw_kz: Path = RAW_KZ, manual: set[str] | None = None) -> tuple[list[dict], Counter]:
"""rows -> (новые rows, статистика). Исходные dict меняются на месте."""
manual = manual or set()
stats: Counter = Counter()
for r in rows:
if r["source"] == "nnm" and r.get("studio_source") == "tech" and r["studio"] == NO_STUDIO \
and r["release_id"] not in manual:
studio = H.nnm_part_studio(r.get("role_raw") or "")
if studio:
r["studio"] = studio
stats["nnm: студия из «Перевод:»"] += 1
by_rel: dict[str, list[dict]] = defaultdict(list)
for r in rows:
if r["source"] == "kinozal":
by_rel[r["release_id"]].append(r)
added: list[dict] = []
for rid, rel_rows in by_rel.items():
if rid in manual:
continue
path = raw_kz / f"{rid.removeprefix('kz-')}.json"
if not path.exists():
stats["kinozal: нет raw JSON"] += 1
continue
rec = json.loads(path.read_text(encoding="utf-8"))
rec["id"] = rid
text = "\n".join(x for x in (rec.get("release_tab") or "", rec.get("tech_tab") or "") if x)
new = H.kz_title_rows(rec, text, rel_rows)
if new:
stats["kinozal: релизов со студиями из заголовка"] += 1
stats["kinozal: студий из заголовка"] += len(new)
stats["kinozal: из них со строкой «Аудио»"] += sum(bool(x["audio_raw"]) for x in new)
added += new
rows = rows + added
by_rel = defaultdict(list)
for r in rows:
by_rel[(r["source"], r["release_id"])].append(r)
codec: list[dict] = []
for (src, rid), rel_rows in by_rel.items():
if rid in manual:
continue
text = raw_text(raw_kz.parent, src, rid)
if text is None:
continue
new = H.codec_line_rows({"id": rid, "source": src}, text, rel_rows)
if new:
stats[f"{src}: релизов со студиями из кодек-строк"] += 1
stats[f"{src}: студий из кодек-строк"] += len(new)
codec += new
return rows + codec, stats
def raw_text(raw_dir: Path, src: str, rid: str) -> str | None:
"""Текст страницы, как у сборщика: kinozal — «Релиз» + «Техданные», nnm/rutracker — пост."""
num = re.sub(r"^(kz|nnm|rt)-", "", rid)
for name in (f"{num}.json", f"{num}.legacy.json"):
path = raw_dir / src / name
if path.exists():
rec = json.loads(path.read_text(encoding="utf-8"))
if src == "kinozal":
return "\n".join(x for x in (rec.get("release_tab") or "", rec.get("tech_tab") or "") if x)
return rec.get("post") or ""
return None
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--dry-run", action="store_true", help="только посчитать, файл не менять")
args = ap.parse_args()
with LABELS.open(encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
fields = reader.fieldnames
rows = list(reader)
manual = set(MANUAL.read_text(encoding="utf-8").split()) if MANUAL.exists() else set()
before = sum(r["studio"] != NO_STUDIO for r in rows)
rows, stats = relabel(rows, manual=manual)
after = sum(r["studio"] != NO_STUDIO for r in rows)
for k, v in stats.items():
print(f"{k}: {v}")
print(f"строк: {len(rows)}; со студией: {before} -> {after}")
if args.dry_run:
return
backup = LABELS.with_name(f"labels.bak_relabel_{time.strftime('%Y%m%d_%H%M%S')}.csv")
shutil.copy(LABELS, backup)
with LABELS.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
w.writeheader()
w.writerows(rows)
print(f"записано: {LABELS.relative_to(ROOT)}; копия: {backup.name}")
if __name__ == "__main__":
main()