Compare commits
14
Commits
588b1f1579
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8035e1e9d | ||
|
|
8511744d3a | ||
|
|
0f359837c5 | ||
|
|
d2cf488b9c | ||
|
|
690275dcb0 | ||
|
|
76e887b4ba | ||
|
|
729eeac045 | ||
|
|
e6705c2b7b | ||
|
|
e9c85c4132 | ||
|
|
b3ceefda4f | ||
|
|
e185c73714 | ||
|
|
3c42d2d4e2 | ||
|
|
dc19fe6255 | ||
|
|
89ffe4375b |
No files matched your search
@@ -14,6 +14,7 @@ reports/*.png
|
|||||||
|
|
||||||
# локальное
|
# локальное
|
||||||
*.log
|
*.log
|
||||||
|
.tmp/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# служебное ZCode
|
# служебное ZCode
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ kinozal / nnm-club) модель определяет:
|
|||||||
алиасов («HDRezka Studio» → `HDrezka`, «Ю. Сербин» → «Юрий Сербин»);
|
алиасов («HDRezka Studio» → `HDrezka`, «Ю. Сербин» → «Юрий Сербин»);
|
||||||
* **18+** дорожка или нет — бинарная голова.
|
* **18+** дорожка или нет — бинарная голова.
|
||||||
|
|
||||||
Обучено на датасете из 3286 разметанных дорожек + 458 аугментированных
|
Обучено на датасете из 52 658 строк (69 511 размеченных строк из 30 451 релиза
|
||||||
морфологических вариантов (снимок `labels.csv` из deepseek-harness).
|
`data/raw` + морфологическая аугментация; у строки может быть несколько авторов —
|
||||||
|
«Многоголосый (HDRezka Studio, LostFilm)» даёт обоих). Как страницы превращаются в строки и
|
||||||
|
что было исправлено в сборщике — [docs/dataset-coverage.md](docs/dataset-coverage.md).
|
||||||
Multi-task RuBERT: сравниваются `rubert-tiny2` и `ruBert-base`,
|
Multi-task RuBERT: сравниваются `rubert-tiny2` и `ruBert-base`,
|
||||||
лучшая кладётся в `models/best`.
|
лучшая кладётся в `models/best`.
|
||||||
|
|
||||||
@@ -69,6 +71,8 @@ docs/ документация
|
|||||||
|
|
||||||
* [docs/architecture.md](docs/architecture.md) — устройство модели и пайплайна
|
* [docs/architecture.md](docs/architecture.md) — устройство модели и пайплайна
|
||||||
* [docs/dataset.md](docs/dataset.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/training-windows.md](docs/training-windows.md) — обучение на Windows+GPU, troubleshooting
|
||||||
* [docs/api-reference.md](docs/api-reference.md) — **все значения ответа сервиса (15 классов `translation_type` и др.) и принцип работы; `labels.csv` для этого открывать не нужно**
|
* [docs/api-reference.md](docs/api-reference.md) — **все значения ответа сервиса (15 классов `translation_type` и др.) и принцип работы; `labels.csv` для этого открывать не нужно**
|
||||||
* [docs/inference.md](docs/inference.md) — CLI/API, JSON-контракт
|
* [docs/inference.md](docs/inference.md) — CLI/API, JSON-контракт
|
||||||
@@ -76,37 +80,53 @@ docs/ документация
|
|||||||
|
|
||||||
## Качество
|
## Качество
|
||||||
|
|
||||||
Held-out test (645 строк, группировка по релизам и текстам; полная таблица и
|
Held-out test (4 984 строки, группировка по релизам и текстам; метки пересобраны
|
||||||
графики — [reports/summary.md](reports/summary.md)):
|
парсерами `PARSE_VERSION` 9, строки со списком студий склеены в одну с несколькими
|
||||||
|
авторами). Все модели оценены на **одном и том же** тесте:
|
||||||
|
|
||||||
| модель | type acc | type macro-F1 | span exact | 18+ acc | joint exact |
|
| модель | type acc | type macro-F1 | span exact | 18+ acc | lang acc | joint exact [95 %] | строки с 2+ авторами (438), span |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
| rubert-tiny2 | 0.991 | 0.970 | 0.918 | 1.000 | 0.915 |
|
| прежняя `models/best` (09.11, один автор на строку) | 0.981 | 0.909 | 0.813 | 1.000 | 0.998 | 0.800 [0.788–0.810] | 0.011 |
|
||||||
| **ruBert-base (models/best)** | **0.994** | 0.966 | **0.950** | **1.000** | **0.946** |
|
| 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+).
|
* joint exact — строка распознана полностью (тип + все авторы + 18+); span exact
|
||||||
* E2E на сохранённых страницах релизов: rutracker — precision 0.83 / recall 0.98
|
сравнивает набор авторов строки целиком.
|
||||||
по парам (тип, автор); kinozal-текстовые дампы содержат только тех-вкладку
|
* Новая `models/best` против прежней на тех же строках: исправлено 647, сломано 131
|
||||||
без авторов (автор подгружался на сайте через JS), поэтому там автор
|
(знаковый критерий p ≈ 1e-83). Прежняя модель часть тестовых строк видела при
|
||||||
недоступен в принципе.
|
обучении, так что сравнение в её пользу.
|
||||||
* ONNX-экспорт эквивалентен PyTorch на 100% строк test — [reports/onnx_parity.md](reports/onnx_parity.md).
|
* Противоречивых строк (один текст — разные метки) в датасете 1.2 % вместо 20 %:
|
||||||
* Smoke на формулировках, которых нет в датасете: тип 9/10, 18+ 10/10,
|
исправлены парсеры студий (пол голоса, техпараметры, «т/к», «N-й канал»), а
|
||||||
автор 5/10 (полное извлечение автора на непривычных форматах записи —
|
списки студий размечаются целиком. Оставшиеся расхождения между релизами
|
||||||
известное ограничение, см. ниже).
|
разбираются вручную: `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) — метрики
|
* Метки датасета сгенерированы регулярками (пайплайн 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-дорожек,
|
датасете (имя после типа через разделитель, в скобках у AVO/MVO/DVO-дорожек,
|
||||||
после тире); на непривычных форматах («Одноголосый закадровый (Фамилия)»)
|
после тире); на непривычных форматах («Одноголосый закадровый (Фамилия)»)
|
||||||
модель может не выделить имя — span остаётся пустым, тип при этом верный.
|
модель может не выделить имя — span остаётся пустым, тип при этом верный.
|
||||||
* kinozal-страницы: авторы озвучки подгружаются на сайте JavaScript-ом; в
|
* kinozal-страницы: авторы озвучки лежат во вкладках, которые сайт подгружает
|
||||||
статическом HTML и текстовых дампах их нет — модель их не увидит (это
|
JavaScript-ом. По `url` сервис скачивает их сам (`fetch_html`, `showtab`); в
|
||||||
ограничение данных, не модели).
|
присланном `html`/`text` без этих вкладок студий может не быть.
|
||||||
* Классы с 1–8 примерами почти не выучиваются (видно в macro-F1 и confusion
|
* Классы с 1–8 примерами почти не выучиваются (видно в macro-F1 и confusion
|
||||||
matrix); морфологическая аугментация (458 вариантов: «многоголосое» и т.п.)
|
matrix); морфологическая аугментация (458 вариантов: «многоголосое» и т.п.)
|
||||||
частично закрывает этот пробел.
|
частично закрывает этот пробел.
|
||||||
+43717
-29530
File diff suppressed because it is too large.
Load diff
@@ -1 +1,3 @@
|
|||||||
rt-6894333
|
rt-6894333
|
||||||
|
rt-6919056
|
||||||
|
rt-6927571
|
||||||
@@ -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-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-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-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-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-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,
|
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.
|
@@ -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
|
||||||
|
Binary file not shown.
+62
-5
@@ -38,8 +38,16 @@
|
|||||||
"studios": [{"studio": "LostFilm", "translation_type": "проф. многоголосый", "lang": "RUS", "is_18plus": false, "episodes": "1-4"}],
|
"studios": [{"studio": "LostFilm", "translation_type": "проф. многоголосый", "lang": "RUS", "is_18plus": false, "episodes": "1-4"}],
|
||||||
"translation_types": ["проф. многоголосый"],
|
"translation_types": ["проф. многоголосый"],
|
||||||
"is_18plus": false,
|
"is_18plus": false,
|
||||||
"original": {"lang": "ENG", "codec": "E-AC3", "channels": 6, "bitrate_kbps": 768, "raw": "English E-AC3+Atmos / 5.1 / 48 kHz / 768 kbps |Original|"},
|
"original": {"lang": "ENG", "codec": "E-AC3", "channels": 6, "bitrate_kbps": 768, "khz": 48, "atmos": true, "raw": "English E-AC3+Atmos / 5.1 / 48 kHz / 768 kbps |Original|"},
|
||||||
"series": {"season": "2", "episodes": "1-9", "total": 10, "by_studio": {"LostFilm": "1-4"}, "full_release": []},
|
"series": {"season": "2", "episodes": "1-9", "total": 10, "by_studio": {"LostFilm": "1-4"}, "full_release": []},
|
||||||
|
"video": {"codec": "HEVC", "width": 3840, "height": 2160, "resolution": "2160p", "fps": 23.976, "bitrate_kbps": 24800,
|
||||||
|
"bit_depth": 10, "hdr": ["Dolby Vision", "HDR10+", "HDR10"], "dv_profile": "8", "raw": "MPEG-H HEVC Video / 24,8 Mbps / 3840x2160 / …"},
|
||||||
|
"release": {"quality": "WEB-DL 2160p Dolby Vision", "source": "WEB-DL", "container": "MKV", "duration": "01:38:12", "duration_sec": 5892},
|
||||||
|
"subtitles": {"languages": ["RUS", "UKR", "ENG"],
|
||||||
|
"tracks": [{"lang": "RUS", "forced": true, "full": false, "sdh": false},
|
||||||
|
{"lang": "UKR", "forced": true, "full": true, "sdh": true},
|
||||||
|
{"lang": "ENG", "forced": false, "full": true, "sdh": true}],
|
||||||
|
"format": "softsub (SRT)", "hardsub": false, "raw": "Русские (Forced), Украинские (Forced, 2x Full, SDH), Английские (Full, SDH)"},
|
||||||
"title": "Название раздачи",
|
"title": "Название раздачи",
|
||||||
"segmenter": "model",
|
"segmenter": "model",
|
||||||
"tracker": "rutracker"
|
"tracker": "rutracker"
|
||||||
@@ -50,14 +58,40 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `tracks` | array | дорожки релиза без дублей (дубль — одинаковые тип + студия) |
|
| `tracks` | array | дорожки релиза без дублей (дубль — одинаковые тип + студия) |
|
||||||
| `studios` | array | уникальные `{studio, translation_type, lang, is_18plus, episodes}`; `lang` — язык дорожки студии (как в `tracks[].lang`, может быть `null`). Одна студия с русским и украинским дубляжом даёт две записи |
|
| `studios` | array | уникальные `{studio, translation_type, lang, is_18plus, episodes}`; `lang` — язык дорожки студии (как в `tracks[].lang`, может быть `null`). Одна студия с русским и украинским дубляжом даёт две записи |
|
||||||
| `original` | object \| null | оригинальная дорожка из строки «Аудио … \|Original\|»: `{lang, codec, channels, bitrate_kbps, raw}` (поля могут быть `null`); `null` — такой строки на странице нет |
|
| `original` | object \| null | оригинальная дорожка из строки «Аудио … \|Original\|»: `{lang, codec, channels, bitrate_kbps, khz, atmos, raw}` (поля могут быть `null`, `atmos` — bool); `null` — такой строки на странице нет |
|
||||||
| `translation_types` | array of string | отсортированный набор типов (значения — из таблицы классов ниже) |
|
| `translation_types` | array of string | отсортированный набор типов (значения — из таблицы классов ниже) |
|
||||||
| `is_18plus` | bool | `true`, если 18+ хотя бы у одной дорожки |
|
| `is_18plus` | bool | `true`, если 18+ хотя бы у одной дорожки |
|
||||||
| `series` | object \| null | сезон, выпущенные серии, «какая студия какие серии» (для Sonarr); `null` — сведений нет |
|
| `series` | object \| null | сезон, выпущенные серии, «какая студия какие серии» (для Sonarr); `null` — сведений нет |
|
||||||
|
| `video` | object \| null | видео из строки «Видео:» / «Video:» (у nnm — также «Видеокодек», «Размер кадра», «Битрейт видео»), см. ниже; `null` — строки нет |
|
||||||
|
| `release` | object \| null | тип релиза, контейнер, продолжительность, см. ниже; `null` — ничего из этого на странице нет |
|
||||||
|
| `subtitles` | object \| null | субтитры из строк «Субтитры:», «Субтитры 2:», «Язык субтитров:» (нет строк — языки из «Sub (Rus, Eng)» заголовка), см. ниже; `null` — о субтитрах ничего не сказано |
|
||||||
| `title` | string \| null | заголовок раздачи, если найден |
|
| `title` | string \| null | заголовок раздачи, если найден |
|
||||||
| `segmenter` | `"model"` \| `"rules"` | чем страница разбита на строки дорожек: обученным сегментатором или запасными правилами |
|
| `segmenter` | `"model"` \| `"rules"` | чем страница разбита на строки дорожек: обученным сегментатором или запасными правилами |
|
||||||
|
| `segmenter_warning` | string (необязательно) | модель-сегментатор и запасные правила разошлись (модель ничего не выбрала или правила нашли больше): страница — кандидат на дообучение, см. `docs/retrain-segmenter.md` |
|
||||||
| `tracker` | string | только для `url`: `rutracker`, `nnm`, `kinozal` или `lostfilm` |
|
| `tracker` | string | только для `url`: `rutracker`, `nnm`, `kinozal` или `lostfilm` |
|
||||||
|
|
||||||
|
`video` (все поля, кроме `hdr` и `raw`, могут быть `null`):
|
||||||
|
`codec` — `HEVC`, `AVC`, `AV1`, `VP9`, `VC-1`, `MPEG-2`, `XviD`, `DivX`, `MPEG-4`;
|
||||||
|
`width`/`height` — размер кадра; `resolution` — класс по ширине (`2160p`, `1440p`, `1080p`, `720p`; кадр с
|
||||||
|
полосами 1920x800 — тоже `1080p`), для SD — высота кадра (`576p`, `400p`); `fps`; `bitrate_kbps`;
|
||||||
|
`bit_depth` — 8/10/12; `hdr` — массив из `Dolby Vision`, `HDR10+`, `HDR10`, `HLG` или просто `HDR`, если вид
|
||||||
|
не назван (ищется в строке видео, «HDR format», типе релиза и заголовке), пустой — HDR нет; `dv_profile` —
|
||||||
|
профиль Dolby Vision строкой («8», «5»); `raw` — строка видео целиком.
|
||||||
|
|
||||||
|
`release`: `quality` — значение «Тип релиза» / «Качество видео» / «Качество» как на странице (без
|
||||||
|
«\| Исходник: …»); `source` — нормализованный источник: `UHD BDRemux`, `BDRemux`, `WEB-DLRip`, `WEB-DL`, `WEBRip`,
|
||||||
|
`BDRip`, `HDRip`, `Blu-ray`, `DVDRip`, `DVD9`, `DVD5`, `HDTVRip`, `HDTV`, `SATRip`, `TVRip`, `DVB`, `VHSRip`,
|
||||||
|
`DCPRip`, `IPTV`, `CAMRip`, `DVD` (из «Качества», иначе из заголовка); `container` — `MKV`, `MP4`, `AVI`, `M2TS`,
|
||||||
|
`ISO`, `DVD-Video`, `TS`, `WEBM`, `MOV`, `WMV` (kinozal строку контейнера обычно не пишет — тогда `null`);
|
||||||
|
`duration` — «Продолжительность» как на странице («01:38:12», «18 x ~ 00:20:00»); `duration_sec` — секунды,
|
||||||
|
только если это одно значение ЧЧ:ММ:СС фильма (не серии и не список файлов). Любое поле может быть `null`.
|
||||||
|
|
||||||
|
`subtitles`: `languages` — коды языков по порядку (те же коды, что у `lang` дорожки); `tracks` — по записи
|
||||||
|
на язык: `{lang, forced, full, sdh}` (флаги «Forced/форсированные/надписи», «Full/полные», «SDH/для
|
||||||
|
слабослышащих» объединяются по всем строкам); `format` — «Формат субтитров» как на странице или `null`;
|
||||||
|
`hardsub` — субтитры вшиты в видео; `raw` — исходные строки через «; ». «Субтитры: нет/отсутствуют» даёт
|
||||||
|
объект с пустым `languages` — субтитров точно нет; `null` — страница о субтитрах молчит.
|
||||||
|
|
||||||
`series`: `season` — номер сезона строкой или `null`; `episodes` — выпущенные серии («1-9»);
|
`series`: `season` — номер сезона строкой или `null`; `episodes` — выпущенные серии («1-9»);
|
||||||
`total` — всего серий; `by_studio` — `{студия: серии}`; `full_release` — студии без указанных серий
|
`total` — всего серий; `by_studio` — `{студия: серии}`; `full_release` — студии без указанных серий
|
||||||
(для сериала это значит «озвучены все вышедшие серии»). Подробности — в [integration.md](integration.md).
|
(для сериала это значит «озвучены все вышедшие серии»). Подробности — в [integration.md](integration.md).
|
||||||
@@ -72,6 +106,9 @@
|
|||||||
| `studio_span` | string \| null | фрагмент текста, распознанный как автор/студия |
|
| `studio_span` | string \| null | фрагмент текста, распознанный как автор/студия |
|
||||||
| `studio` | string \| null | каноническое имя автора из словаря алиасов; неизвестное имя возвращается как в тексте; `null` — автора нет |
|
| `studio` | string \| null | каноническое имя автора из словаря алиасов; неизвестное имя возвращается как в тексте; `null` — автора нет |
|
||||||
| `studio_confidence` | float \| 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+» страницы) |
|
| `is_18plus` | bool | дорожка помечена 18+ (по модели или по строке «Аудио … 18+» страницы) |
|
||||||
| `age_confidence` | float 0..1 | вероятность предсказанного класса 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` — не определён |
|
| `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; без строки «Аудио» ей приходится угадывать по тексту), слова в тексте («Украинский дубляж») |
|
| `lang_source` | `"audio"` \| `"model"` \| `"text"` \| null | откуда язык, в порядке приоритета: строка «Аудио» страницы (`Ukrainian E-AC3 …` → `UKR`, самый надёжный источник), нейросеть (классы `ENG`/`RUS`/`UKR`, порог 0.5; без строки «Аудио» ей приходится угадывать по тексту), слова в тексте («Украинский дубляж») |
|
||||||
| `role` | string | `перевод`, `оригинал` (оригинальная дорожка без озвучки) или `комментарии` (комментарии режиссёра и т. п.) |
|
| `role` | string | `перевод`, `оригинал` (оригинальная дорожка без озвучки) или `комментарии` (комментарии режиссёра и т. п.) |
|
||||||
| `official` | bool | в строке «Аудио» есть слова «официальный» / «лицензионный» / «licensed» |
|
| `official` | bool | в строке «Аудио» есть слова «официальный» / «лицензионный» / «licensed» |
|
||||||
| `audio` | object \| null | `{codec, channels, bitrate_kbps}` из строки «Аудио» страницы (`"E-AC3"`, `6`, `768`; `channels` — число каналов, «5.1» → 6); каждое поле может быть `null`; весь объект `null`, если строка не найдена |
|
| `audio` | object \| null | `{codec, channels, bitrate_kbps, khz, atmos}` из строки «Аудио» страницы (`"E-AC3"`, `6`, `768`, `48`, `true`; `channels` — число каналов, «5.1» → 6; `khz` — частота дискретизации, 44.1 / 48; `atmos` — в строке есть «Atmos», кодек при этом остаётся `E-AC3`/`TrueHD`); поля, кроме `atmos`, могут быть `null`; весь объект `null`, если строка не найдена |
|
||||||
| `source` | `"page"` \| `"title"` | только в ответе релиза: дорожка найдена в описании или студия взята из заголовка раздачи |
|
| `source` | `"page"` \| `"title"` | только в ответе релиза: дорожка найдена в описании или студия взята из заголовка раздачи |
|
||||||
| `episodes` | string \| null | серии, которые озвучивает дорожка («1-4», «1,3-5»); `null` — серии не указаны (покрывает весь релиз) |
|
| `episodes` | string \| null | серии, которые озвучивает дорожка («1-4», «1,3-5»); `null` — серии не указаны (покрывает весь релиз) |
|
||||||
| `episode_ranges` | array | то же в виде `[[1, 4]]`; пусто, если серий нет |
|
| `episode_ranges` | array | то же в виде `[[1, 4]]`; пусто, если серий нет |
|
||||||
@@ -150,6 +187,9 @@ type Track = {
|
|||||||
studio_span: string | null; // null — автора в тексте нет
|
studio_span: string | null; // null — автора в тексте нет
|
||||||
studio: string | null; // null вместе со studio_span
|
studio: string | null; // null вместе со studio_span
|
||||||
studio_confidence: number | 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;
|
is_18plus: boolean;
|
||||||
age_confidence: number; // 0..1
|
age_confidence: number; // 0..1
|
||||||
lang: string | null; // 3-буквенный код или null
|
lang: string | null; // 3-буквенный код или null
|
||||||
@@ -157,7 +197,7 @@ type Track = {
|
|||||||
lang_source: "model" | "audio" | "text" | "url" | null; // "url" — ссылка lostfilm.tv; null вместе с lang
|
lang_source: "model" | "audio" | "text" | "url" | null; // "url" — ссылка lostfilm.tv; null вместе с lang
|
||||||
role: "перевод" | "оригинал" | "комментарии"; // никогда null
|
role: "перевод" | "оригинал" | "комментарии"; // никогда null
|
||||||
official: boolean;
|
official: boolean;
|
||||||
audio: { codec: string | null; channels: number | null; bitrate_kbps: number | null } | null;
|
audio: { codec: string | null; channels: number | null; bitrate_kbps: number | null; khz: number | null; atmos: boolean } | null;
|
||||||
// только в ответе релиза (html / url):
|
// только в ответе релиза (html / url):
|
||||||
source: "page" | "title";
|
source: "page" | "title";
|
||||||
episodes: string | null; // "1-4", "1,3-5"; null — серии не указаны
|
episodes: string | null; // "1-4", "1,3-5"; null — серии не указаны
|
||||||
@@ -167,7 +207,8 @@ type Track = {
|
|||||||
type Release = {
|
type Release = {
|
||||||
tracks: Track[]; // может быть пустым массивом
|
tracks: Track[]; // может быть пустым массивом
|
||||||
studios: { studio: string | null; translation_type: string; lang: string | null; is_18plus: boolean; episodes: string | null }[];
|
studios: { studio: string | null; translation_type: string; lang: string | null; is_18plus: boolean; episodes: string | null }[];
|
||||||
original: { lang: string | null; codec: string | null; channels: number | null; bitrate_kbps: number | null; raw: string } | null;
|
original: { lang: string | null; codec: string | null; channels: number | null; bitrate_kbps: number | null;
|
||||||
|
khz: number | null; atmos: boolean; raw: string } | null;
|
||||||
translation_types: string[];
|
translation_types: string[];
|
||||||
is_18plus: boolean;
|
is_18plus: boolean;
|
||||||
series: {
|
series: {
|
||||||
@@ -177,8 +218,22 @@ type Release = {
|
|||||||
by_studio: Record<string, string>; // { "LostFilm": "1-4" }, может быть {}
|
by_studio: Record<string, string>; // { "LostFilm": "1-4" }, может быть {}
|
||||||
full_release: string[]; // студии без указанных серий
|
full_release: string[]; // студии без указанных серий
|
||||||
} | null; // null — ни сезона, ни серий в раздаче нет
|
} | null; // null — ни сезона, ни серий в раздаче нет
|
||||||
|
video: {
|
||||||
|
codec: string | null; width: number | null; height: number | null; resolution: string | null;
|
||||||
|
fps: number | null; bitrate_kbps: number | null; bit_depth: number | null;
|
||||||
|
hdr: string[]; // [] — HDR нет
|
||||||
|
dv_profile: string | null; raw: string;
|
||||||
|
} | null;
|
||||||
|
release: { quality: string | null; source: string | null; container: string | null;
|
||||||
|
duration: string | null; duration_sec: number | null } | null;
|
||||||
|
subtitles: {
|
||||||
|
languages: string[]; // [] — «Субтитры: нет»
|
||||||
|
tracks: { lang: string; forced: boolean; full: boolean; sdh: boolean }[];
|
||||||
|
format: string | null; hardsub: boolean; raw: string | null;
|
||||||
|
} | null; // null — о субтитрах на странице ничего нет
|
||||||
title: string | null;
|
title: string | null;
|
||||||
segmenter: "model" | "rules";
|
segmenter: "model" | "rules";
|
||||||
|
segmenter_warning?: string; // только если модель и правила разошлись
|
||||||
tracker?: "rutracker" | "nnm" | "kinozal" | "lostfilm"; // только для url
|
tracker?: "rutracker" | "nnm" | "kinozal" | "lostfilm"; // только для url
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@@ -226,6 +281,8 @@ Cloudflare: FlareSolverr нестабилен.
|
|||||||
по тексту; отдельно берётся `original`; определяются `role`, `official`, `audio`.
|
по тексту; отдельно берётся `original`; определяются `role`, `official`, `audio`.
|
||||||
7. **Агрегат релиза.** Дубли (тип + студия) склеиваются; студии из заголовка раздачи, которых нет
|
7. **Агрегат релиза.** Дубли (тип + студия) склеиваются; студии из заголовка раздачи, которых нет
|
||||||
в описании, добавляются с `source: "title"`; серии и сезон разбираются правилами (`src/episodes.py`).
|
в описании, добавляются с `source: "title"`; серии и сезон разбираются правилами (`src/episodes.py`).
|
||||||
|
8. **Техданные** (`video`, `release`, `subtitles`) — правилами по строкам «Видео:», «Тип релиза:»,
|
||||||
|
«Контейнер:», «Продолжительность:», «Субтитры:» и заголовку (`src/techinfo.py`), без нейросети.
|
||||||
|
|
||||||
Размеченные данные (`data/labels.csv`) используются только при обучении: на них учатся метки
|
Размеченные данные (`data/labels.csv`) используются только при обучении: на них учатся метки
|
||||||
`translation_type`, спан студии, 18+ и язык. Схема файла и происхождение меток — в
|
`translation_type`, спан студии, 18+ и язык. Схема файла и происхождение меток — в
|
||||||
|
|||||||
+15
-4
@@ -42,10 +42,17 @@ BIO-разметка строится по символьным спанам ч
|
|||||||
исправлена; см. `decode_span` — он дополнительно пропускает токены с
|
исправлена; см. `decode_span` — он дополнительно пропускает токены с
|
||||||
пустыми offsets).
|
пустыми offsets).
|
||||||
|
|
||||||
Декодирование спана: первый `B`-токен, затем цепочка `I` → символьный
|
Декодирование (`decode_spans`): каждая цепочка `B`/`I` → символьный
|
||||||
диапазон через offsets → текст спана → каноническое имя автора через
|
диапазон через offsets → текст спана → каноническое имя автора через
|
||||||
словарь алиасов (`src/studio_norm.py`, порт из генератора датасета).
|
словарь алиасов (`src/studio_norm.py`, порт из генератора датасета).
|
||||||
|
|
||||||
|
У строки может быть несколько авторов: «Многоголосый (HDRezka Studio,
|
||||||
|
LostFilm, TVShows)» значит, что верны все три. `prepare_data` склеивает
|
||||||
|
строки одного релиза с одинаковым текстом и типом в одну строку
|
||||||
|
(`spans` — все спаны, `span` — первый), BIO размечает все, `span_exact`
|
||||||
|
сравнивает наборы. Сервис отдаёт `studio_spans`/`studios`; в ответе
|
||||||
|
релиза такая строка раскладывается на дорожку на каждого автора.
|
||||||
|
|
||||||
## Почему автор — извлечение, а не классификация
|
## Почему автор — извлечение, а не классификация
|
||||||
|
|
||||||
В датасете 253+ уникальных авторов, 177 из них встречаются ровно 1 раз.
|
В датасете 253+ уникальных авторов, 177 из них встречаются ровно 1 раз.
|
||||||
@@ -81,15 +88,19 @@ BIO-разметка строится по символьным спанам ч
|
|||||||
«Релиз», `segmenter.page_text`): в `data/pages/kz-*.txt` нет ни заголовка, ни
|
«Релиз», `segmenter.page_text`): в `data/pages/kz-*.txt` нет ни заголовка, ни
|
||||||
«Техданных», откуда взята половина меток kinozal, а сервис (`fetch_html`)
|
«Техданных», откуда взята половина меток kinozal, а сервис (`fetch_html`)
|
||||||
видит обе вкладки.
|
видит обе вкладки.
|
||||||
* Кодек-строка русской дорожки («Аудио 3: AC3 … Русский (MVO, LostFilm)»,
|
* Кодек-строки со студией с 02.11.2026 размечены сами (`studio_source=codec`, см.
|
||||||
«Аудио Rus: … | LostFilm»), не попавшая в разметку, в обучение не идёт
|
`docs/collecting.md`): их `raw`/`audio_raw` дают позитивы. Кодек-строка русской
|
||||||
|
дорожки («Аудио 3: AC3 … Русский (MVO, LostFilm)», «Аудио Rus: … | LostFilm»),
|
||||||
|
не попавшая в разметку, в обучение не идёт
|
||||||
(метка -1): у rutracker `raw` — строка «Перевод N:», и как негатив она учила
|
(метка -1): у rutracker `raw` — строка «Перевод N:», и как негатив она учила
|
||||||
модель отбрасывать кодек-строки — а на kinozal студии только в них.
|
модель отбрасывать кодек-строки — а на kinozal студии только в них.
|
||||||
* В `predict_release` выбранные моделью кодек-строки — второстепенный источник
|
* В `predict_release` выбранные моделью кодек-строки — второстепенный источник
|
||||||
(`source: codec`): подключаются, только если у какой-то дорожки страницы нет
|
(`source: codec`): подключаются, только если у какой-то дорожки страницы нет
|
||||||
студии; если других строк нет (nnm), они и есть дорожки.
|
студии; если других строк нет (nnm), они и есть дорожки.
|
||||||
* e2e: `tests/e2e_segmenter.py [каталог]` — пары (тип, студия) на тестовых
|
* e2e: `tests/e2e_segmenter.py [каталог]` — пары (тип, студия) на тестовых
|
||||||
страницах, не больше `E2E_MAX` (400) на трекер; `--train-pages` — ещё и train.
|
страницах, не больше `E2E_MAX` (400) на трекер; `--train-pages` — ещё и train;
|
||||||
|
основная модель — `E2E_MODEL` (по умолчанию `models/best`), например
|
||||||
|
`set E2E_MODEL=models/runs/<запуск>` перед запуском, чтобы сравнить кандидата.
|
||||||
* Нарезка ВНУТРИ строки («Дублированный, многоголосый») в данных не размечена
|
* Нарезка ВНУТРИ строки («Дублированный, многоголосый») в данных не размечена
|
||||||
(91 группа из 6735) — остаётся на правилах `_split_entries`, но только на
|
(91 группа из 6735) — остаётся на правилах `_split_entries`, но только на
|
||||||
строках, выбранных моделью.
|
строках, выбранных моделью.
|
||||||
|
|||||||
+15
-2
@@ -65,8 +65,16 @@ HTML страниц хранится в `data/raw`, поэтому после п
|
|||||||
«Переиндексировать data/raw?» (то же: `collect.py build --rewrite --relabel`).
|
«Переиндексировать data/raw?» (то же: `collect.py build --rewrite --relabel`).
|
||||||
Страницы, `meta.csv` и метки размеченных релизов строятся заново текущими
|
Страницы, `meta.csv` и метки размеченных релизов строятся заново текущими
|
||||||
парсерами; прежний `labels.csv` копируется в `data/labels.bak_<время>.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).
|
||||||
|
|
||||||
## Обучение по ссылкам
|
## Обучение по ссылкам
|
||||||
|
|
||||||
@@ -163,6 +171,11 @@ id разных трекеров не пересекались. Коллизий
|
|||||||
студия. +6 428 строк в 2 862 релизах (kinozal 3 444, nnm 1 609, rutracker 1 375). Аудит,
|
студия. +6 428 строк в 2 862 релизах (kinozal 3 444, nnm 1 609, rutracker 1 375). Аудит,
|
||||||
откуда взялась доработка: `reports/tracks_audit.md`.
|
откуда взялась доработка: `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` без пересборки
|
Сборщик делает это при переиндексации. Для уже собранного `labels.csv` без пересборки
|
||||||
страниц: `.venv\Scripts\python tools\relabel_studios.py` (копия — `data/labels.bak_relabel_*.csv`;
|
страниц: `.venv\Scripts\python tools\relabel_studios.py` (копия — `data/labels.bak_relabel_*.csv`;
|
||||||
`--dry-run` — только статистика; повторный запуск ничего не добавляет).
|
`--dry-run` — только статистика; повторный запуск ничего не добавляет).
|
||||||
|
|||||||
@@ -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
@@ -10,11 +10,14 @@
|
|||||||
регулярками** — модель учится воспроизводить эту разметку и обобщать её на
|
регулярками** — модель учится воспроизводить эту разметку и обобщать её на
|
||||||
новые формулировки.
|
новые формулировки.
|
||||||
|
|
||||||
Размер (на момент последней сборки): ~41 тыс. строк из ~28 тыс. релизов, 3 источника
|
Размер (снимок 09.11.2026, после полной переиндексации `data/raw`): 72 239 строк
|
||||||
(rutracker, nnm, kinozal); HTML-дамп страниц — `data/raw` (~32 тыс. файлов, ≈2.6 ГБ, в git не
|
из 30 401 релиза, 3 источника (rutracker, nnm, kinozal); HTML-дамп страниц —
|
||||||
хранится), разобранные тексты — `data/pages`. Точные числа показывает «Статистика датасета» в
|
`data/raw` (35 385 страниц, ≈2.8 ГБ, в git не хранится), разобранные тексты —
|
||||||
`tools/collect.py` (пункт 3) и отчёт `reports/data_audit.md` после `prepare_data`.
|
`data/pages` (30 444). Аудит покрытия страниц и правки парсеров —
|
||||||
Ранние цифры этого документа (3286 строк) относились к первому снимку.
|
[docs/dataset-coverage.md](dataset-coverage.md).
|
||||||
|
Точные числа показывает «Статистика датасета» в `tools/collect.py` (пункт 3) и
|
||||||
|
отчёт `reports/data_audit.md` после `prepare_data`. Ранние цифры этого документа
|
||||||
|
(3286 строк) относились к первому снимку.
|
||||||
К строкам при подготовке добавляются аугментированные морфологические варианты
|
К строкам при подготовке добавляются аугментированные морфологические варианты
|
||||||
(«многоголосое», «дублированное» и т.п.). Файлы `*_entries.csv` / `*_releases.csv` — те же данные в нарезке
|
(«многоголосое», «дублированное» и т.п.). Файлы `*_entries.csv` / `*_releases.csv` — те же данные в нарезке
|
||||||
по источникам и по релизам; модель используется только `labels.csv`.
|
по источникам и по релизам; модель используется только `labels.csv`.
|
||||||
@@ -29,6 +32,8 @@
|
|||||||
| `studio` | автор («(не указана)» если нет) | **метка 2** (спан в тексте) |
|
| `studio` | автор («(не указана)» если нет) | **метка 2** (спан в тексте) |
|
||||||
| `age_18`, `note` | флаг/пометка 18+ | **метка 3** |
|
| `age_18`, `note` | флаг/пометка 18+ | **метка 3** |
|
||||||
| `role_raw` | скобочная часть / конкретный тип из перечисления | вход для nnm tech-строк |
|
| `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`, …) | контекст релиза | не используются |
|
| остальные (`ru_title`, `codec`, …) | контекст релиза | не используются |
|
||||||
|
|
||||||
## Форматы входа по источникам
|
## Форматы входа по источникам
|
||||||
|
|||||||
@@ -37,12 +37,18 @@ python -m src.predict --url https://kinozal.tv/details.php?id=1 --cookie "uid=..
|
|||||||
| `studios` | уникальные `{studio, translation_type, is_18plus}` |
|
| `studios` | уникальные `{studio, translation_type, is_18plus}` |
|
||||||
| `translation_types` | отсортированный список типов релиза |
|
| `translation_types` | отсортированный список типов релиза |
|
||||||
| `is_18plus` | `true`, если 18+ хотя бы у одной дорожки |
|
| `is_18plus` | `true`, если 18+ хотя бы у одной дорожки |
|
||||||
|
| `original`, `series` | оригинальная дорожка; сезон и серии |
|
||||||
|
| `video` | кодек, разрешение, fps, битрейт, битность, HDR (Dolby Vision / HDR10+ / HDR10 / HLG) |
|
||||||
|
| `release` | тип релиза («WEB-DL», «BDRemux»…), контейнер, продолжительность |
|
||||||
|
| `subtitles` | языки субтитров с пометками forced / full / SDH, формат, вшитые ли |
|
||||||
| `title` | заголовок раздачи (если найден в HTML) |
|
| `title` | заголовок раздачи (если найден в HTML) |
|
||||||
| `tracker` | только для `--url` |
|
| `tracker` | только для `--url` |
|
||||||
|
|
||||||
Дорожки дедуплицируются по (тип, студия). Студии из заголовка раздачи вида
|
Дорожки дедуплицируются по (тип, студия). Студии из заголовка раздачи вида
|
||||||
`ПМ (LostFilm)`, которых нет в описании, добавляются с `source: "title"`
|
`ПМ (LostFilm)`, которых нет в описании, добавляются с `source: "title"`
|
||||||
(у остальных `source: "page"`). `--text` по-прежнему возвращает только `tracks`.
|
(у остальных `source: "page"`). `--text` по-прежнему возвращает только `tracks`.
|
||||||
|
Все поля и их значения — в [api-reference.md](api-reference.md); `video`, `release`, `subtitles`
|
||||||
|
считаются правилами (`src/techinfo.py`), без нейросети.
|
||||||
|
|
||||||
## Python API
|
## Python API
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -41,7 +41,12 @@ API:
|
|||||||
| `lang`, `lang_confidence`, `lang_source` | язык дорожки (`RUS`, `UKR`, `ENG`, …); источник: `model` (языковая голова), `audio` (строка «Аудио»), `text` (слова в строке дорожки) или `null` |
|
| `lang`, `lang_confidence`, `lang_source` | язык дорожки (`RUS`, `UKR`, `ENG`, …); источник: `model` (языковая голова), `audio` (строка «Аудио»), `text` (слова в строке дорожки) или `null` |
|
||||||
| `role` | `перевод` / `оригинал` / `комментарии` (по строке «Аудио» и тексту, правилами) |
|
| `role` | `перевод` / `оригинал` / `комментарии` (по строке «Аудио» и тексту, правилами) |
|
||||||
| `official` | в строке «Аудио» сказано «официальный» / «лицензионный» |
|
| `official` | в строке «Аудио» сказано «официальный» / «лицензионный» |
|
||||||
| `audio` | `{codec, channels, bitrate_kbps}` из строки «Аудио» (или `null`) |
|
| `audio` | `{codec, channels, bitrate_kbps, khz, atmos}` из строки «Аудио» (или `null`) |
|
||||||
|
|
||||||
|
Ответ релиза (`html`/`url`) кроме дорожек несёт `video`, `release` и `subtitles` — разрешение, HDR,
|
||||||
|
тип релиза, контейнер, продолжительность, языки субтитров; поля и значения — в
|
||||||
|
[api-reference.md](api-reference.md). ONNX-путь (Б) их не считает: это правила `src/techinfo.py`,
|
||||||
|
их нужно повторить на стороне клиента или ходить в HTTP-сервис.
|
||||||
|
|
||||||
Русский и украинский дубляж одной студии — две отдельные дорожки; так же раздельны обычная
|
Русский и украинский дубляж одной студии — две отдельные дорожки; так же раздельны обычная
|
||||||
дорожка студии и её дорожка 18+ (если на странице две строки «Аудио» с одной студией).
|
дорожка студии и её дорожка 18+ (если на странице две строки «Аудио» с одной студией).
|
||||||
|
|||||||
@@ -71,6 +71,8 @@
|
|||||||
| 6 tiny2 + int8 | сделано: 29.3 МБ, parity 99.8% тип / 99.2% спан / 100% 18+ (`reports/onnx_parity_tiny2_int8.md`) |
|
| 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, слияние, агрегат) |
|
| 7 тесты | `run_tests.py`: 57 ok (URL allowlist/SSRF, декодирование, заглушки Cloudflare/логина, title, слияние, агрегат) |
|
||||||
| 8 `target/` | сделано |
|
| 8 `target/` | сделано |
|
||||||
|
| 10 аудит дорожек | сделано 02.11.2026: `tools/audit_tracks.py` → `reports/tracks_audit.md` (план из 5 пунктов). Пункт 1 — студии из кодек-строк в разметке (`codec_line_rows`, +6 428 строк); модель переобучена: test joint exact 0.891 → 0.906, e2e P/R kinozal 0.745/0.850 → 0.766/0.879, rutracker 0.866/0.859 → 0.877/0.870, nnm 0.845/0.829 → 0.838/0.825. Пункты 2–5 и сегментатор на новой разметке — открыты |
|
||||||
|
| 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-клиентах этих полей нет |
|
||||||
|
|
||||||
## Открытые вопросы
|
## Открытые вопросы
|
||||||
|
|
||||||
|
|||||||
@@ -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: …` в логе сервиса — модель
|
||||||
|
и правила разошлись. На глоссариях типов озвучки правила дают ложные
|
||||||
|
срабатывания, поэтому предупреждение — повод посмотреть страницу, а не
|
||||||
|
всегда ошибка.
|
||||||
@@ -84,6 +84,14 @@ copy /Y <путь>\labels.csv data\labels.csv
|
|||||||
.venv\Scripts\python -m src.train
|
.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
|
## Troubleshooting
|
||||||
|
|
||||||
| Симптом | Причина | Решение |
|
| Симптом | Причина | Решение |
|
||||||
|
|||||||
@@ -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 (Дубляж);
|
||||||
+49
-47
@@ -1,57 +1,59 @@
|
|||||||
# Аудит датасета: data/labels.csv
|
# Аудит датасета: data/labels.csv
|
||||||
строк в CSV: 55324
|
строк в CSV: 69511
|
||||||
строк после подготовки: 46083 (train 41706 / test 4377)
|
строк после подготовки: 52658 (train 47674 / test 4984)
|
||||||
источники: {'kinozal': 14004, 'rutracker': 15825, 'nnm': 16254}
|
источники: {'kinozal': 14015, 'rutracker': 17812, 'nnm': 20831}
|
||||||
уникальных нормализованных текстов: 25056
|
уникальных нормализованных текстов: 30041
|
||||||
групп (релиз ∪ одинаковый текст): 7354
|
групп (релиз ∪ одинаковый текст): 8749
|
||||||
удалено: пустой текст 0, мусорный studio 156, не-озвучка (оригинал/субтитры) 6, противоречивые дубли 83 (групп-противоречий: 73), несколько дорожек в одной строке 20, лишние повторы (> 40 одинаковых) 18218
|
удалено: пустой текст 0, мусорный studio 359, не-озвучка (оригинал/субтитры) 13, отказ от озвучки 184, противоречивые дубли 109 (групп-противоречий: 109), несколько дорожек в одной строке 7, лишние повторы (> 40 одинаковых) 20592
|
||||||
спан автора найден: 37875 (82.2%), не найден: 240
|
несколько авторов в одной строке: склеено 5727 строк, строк с 2+ авторами 3529
|
||||||
аугментировано морфологических вариантов: 9062
|
ручные решения data/text_fixes.csv: 5 текстов, метка заменена в 409 строках, выброшено 0 строк
|
||||||
дорожек 18+: 959 (из них без упоминания «18» в тексте: 8)
|
спан автора найден: 42489 (80.7%), не найден: 272
|
||||||
язык размечен: 25977 из 46083; со строкой «Аудио» во входе: 19805; классы: {'RUS': 24401, 'UKR': 1523, '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}
|
аугментировано морфологических вариантов: 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
|
## Распределение translation_type
|
||||||
- проф. многоголосый: 13064
|
- проф. многоголосый: 13827
|
||||||
- проф. дубляж: 12127
|
- проф. дубляж: 13226
|
||||||
- авт. одноголосый: 5756
|
- авт. одноголосый: 6463
|
||||||
- неизвестно: 4198
|
- неизвестно: 4918
|
||||||
- проф. двухголосый: 2693
|
- проф. двухголосый: 3079
|
||||||
- одноголосый: 2623
|
- одноголосый: 3016
|
||||||
- люб. многоголосый: 1778
|
- многоголосый: 2446
|
||||||
- многоголосый: 1311
|
- люб. многоголосый: 2039
|
||||||
- люб. двухголосый: 752
|
- двухголосый: 923
|
||||||
- люб. одноголосый: 716
|
- люб. двухголосый: 921
|
||||||
- люб. дубляж: 387
|
- люб. одноголосый: 914
|
||||||
- проф. одноголосый: 354
|
- люб. дубляж: 492
|
||||||
- двухголосый: 319
|
- проф. одноголосый: 387
|
||||||
- авт. двухголосый: 3
|
- авт. двухголосый: 5
|
||||||
- авт. многоголосый: 2
|
- авт. многоголосый: 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-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-1116916 «ac3, 2 ch, 192 kbps, 48,0 khz (русский - двухголосый ''видео» -> ['двухголосый', 'проф. двухголосый']
|
||||||
- nnm:nnm-1116916 «dts, 2 ch, 768 kbps, 48,0 khz (русский - авторский ''андрей » -> ['авт. одноголосый', 'неизвестно']
|
- nnm:nnm-1126664 «1 ac3, 2 ch, 192 kbps - русский, многоголосый (св-дубль) [ru» -> ['многоголосый', 'проф. многоголосый']
|
||||||
- nnm:nnm-1129736 «ac3, 48 khz, ~192 kbps, 2ch, русский (любительский, многогол» -> ['люб. многоголосый', 'проф. многоголосый']
|
- nnm:nnm-1141333 «ac3, 6 ch, 448 kbps (русский - многоголосый ''d2lab'') [rus » -> ['многоголосый', 'проф. многоголосый']
|
||||||
- nnm:nnm-1129736 «ac3, 48 khz, ~192 kbps, 2ch, русский (любительский, двухголо» -> ['люб. двухголосый', 'проф. двухголосый']
|
- nnm:nnm-1154096 «ac3, 2 ch, 192 kbps (русский - двухголосый ''нтв+'') [rus ac» -> ['двухголосый', 'проф. двухголосый']
|
||||||
- nnm:nnm-1129736 «ac3, 48 khz, ~192 kbps, 2ch, русский (любительский, двухголо» -> ['люб. двухголосый', 'проф. двухголосый']
|
|
||||||
- nnm:nnm-1129736 «ac3, 48 khz, ~192 kbps, 2ch, русский (любительский, одноголо» -> ['люб. одноголосый', 'одноголосый']
|
|
||||||
- nnm:nnm-1131052 «ac3, 2 ch, 192 kbps (русский - авторский ''юрий живов'') [ru» -> ['авт. одноголосый', 'неизвестно']
|
|
||||||
- nnm:nnm-1131273 «ac3, 2 ch, 192 kbps (русский - авторский ''юрий живов'') - о» -> ['авт. одноголосый', 'неизвестно']
|
|
||||||
- nnm:nnm-1141333 «ac3, 2 ch, 192 kbps (русский - авторский ''андрей гаврилов''» -> ['авт. одноголосый', 'неизвестно']
|
|
||||||
|
|
||||||
## Спан не найден (примеры)
|
## Спан не найден (примеры)
|
||||||
- [rutracker] studio=«datynet; : Куценко» text=«Одноголосый закадровый datynet; перевод: Куценко»
|
- [rutracker] studio=«студия Кадр , по заказу РТР, 1995 г» text=«Профессиональный (дублированный): студия "Кадр" (при уч. студии "Нота"»
|
||||||
- [rutracker] studio=«datynet и Yuka_chan; :Куценко» text=«Любительский (двухголосый закадровый) datynet и Yuka_chan; перевод:Куц»
|
- [rutracker] studio=«от : : gamekoff» text=«от [Fansub AOS]: Переводчик: gamekoff, Anku, Cracknight, Makentosh, sh»
|
||||||
- [rutracker] studio=«Дубляж, Blu-ray CEE_ _2009г» text=«Профессиональный (дублированный) |Дубляж, Blu-ray CEE_[МОСФИЛЬМ]_2009г»
|
- [nnm] studio=«SovetRomantica 2) HorribleSubs» text=«1) GerKo, Ryuji, Tousaka Jun (01-06 + SP); SovetRomantica (07-11) 2) H»
|
||||||
- [rutracker] studio=«AC3, 6ch , 448 кбит/с Ист-Вест *» text=«Русский (Дублированный), AC3, 6ch (L, C, R, Ls, Rs +LFE), 448 кбит/с |»
|
- [nnm] studio=«Истари Комикс + Anilibria» text=«Funimation; Истари Комикс (OVA) + Anilibria (TV)»
|
||||||
- [rutracker] studio=«ТК Россия, / озвучен в 2009 году» text=«Профессиональный (многоголосый закадровый) |ТК Россия, (Илья Хвостиков»
|
- [nnm] studio=«FRT Sora & KaijuKeizer Media Info на первую серию» text=«Shift (TV) | FRT Sora & KaijuKeizer (TV + OVA) Media Info на первую се»
|
||||||
- [rutracker] studio=«den904 & DeadSno Janetta» text=«Любительский (двухголосый закадровый) den904 & DeadSno перевод Janetta»
|
- [nnm] studio=«YakuSub Studio + SovetRomantica» text=«Wakanim (TV 01-12 + OVA); YakuSub Studio (TV 01-10) + SovetRomantica (»
|
||||||
- [rutracker] studio=«AC3, 6ch , 448 кбит/с Blu-ray CEE +» text=«Русский (Дублированный), AC3, 6ch (L, C, R, Ls, Rs +LFE), 448 кбит/с |»
|
- [nnm] studio=«Zottig & Daidzobu» text=«Zottig (Teisoku) & Daidzobu (Aragami Fansubs)»
|
||||||
- [rutracker] studio=«AC3, 6ch , 448 кбит/с Blu-ray CEE +» text=«Украинский (Дублированный), AC3, 6ch (L, C, R, Ls, Rs +LFE), 448 кбит/»
|
- [rutracker] studio=«Ancord & Noir Ancord» text=«Ancord & Noir (01-35) Ancord (36+)»
|
||||||
- [rutracker] studio=«AC3, 6ch , 384 кбит/с Киномания» text=«Русский (Профессиональный (многоголосый)), AC3, 6ch (L, C, R, Ls, Rs +»
|
- [rutracker] studio=«Правильный от студии ПЭ http» text=«Любительский (Гоблинa, "Правильный от студии "Полный ПЭ" http://www.op»
|
||||||
- [rutracker] studio=«AC3, 6ch , 384 кбит/с Сербин» text=«Русский (Профессиональный (один голос)), AC3, 6ch (L, C, R, Ls, Rs +LF»
|
- [rutracker] studio=«Тот самый с кассет» text=«Профессиональный (полное дублирование) + Aвторский перевод А.Михалёва »
|
||||||
- [rutracker] studio=«AC3, 6ch , 384 кбит/с Гаврилов» text=«Русский (Профессиональный (один голос)), AC3, 6ch (L, C, R, Ls, Rs +LF»
|
- [rutracker] studio=«+ Aвторский А.Михалёва» text=«Профессиональный (полное дублирование) + Aвторский перевод А.Михалёва »
|
||||||
- [rutracker] studio=«Oдноголосый Матвеев Алексей» text=«Oдноголосый закадровый |Матвеев Алексей|»
|
- [rutracker] studio=«но его довольно часто встречал» text=«Любительская (одноголосая) - озвучника не помню как зовут, но его озву»
|
||||||
- [rutracker] studio=«1+1 / Новий канал украинский» text=«Профессиональный (двухголосый закадровый) 1+1 / Новий канал [Євген Паш»
|
- [rutracker] studio=«absurd95 & Eladiel» text=«absurd95 (м.) & Eladiel (ж.)»
|
||||||
- [rutracker] studio=«Дубляж, Blu-ray CEE_ _2007г» text=«Профессиональный (дублированный) |Дубляж, Blu-ray CEE_[Невафильм]_2007»
|
- [rutracker] studio=«Доп.инфо о : Инна Королева и Петр Гланц» text=«Профессиональный (двухголосый закадровый) Доп.инфо о переводе: Инна Ко»
|
||||||
- [rutracker] studio=«Дубляж, Blu-ray CEE_ _2004г» text=«Профессиональный (дублированный) |Дубляж, Blu-ray CEE_[Невафильм]_2004»
|
- [rutracker] studio=«Persona99 ^_^» text=«Persona99 (одноголосое закадровое женское) ^_^»
|
||||||
+55
-90
@@ -2,110 +2,75 @@
|
|||||||
"history": [
|
"history": [
|
||||||
{
|
{
|
||||||
"epoch": 0,
|
"epoch": 0,
|
||||||
"loss": 1.0322551016006376,
|
"loss": 0.874984362499072,
|
||||||
"type_accuracy": 0.9871559633027523,
|
"lang_accuracy": 0.9868667917448405,
|
||||||
"type_macro_f1": 0.8369379389908281,
|
"lang_n": 1599,
|
||||||
"span_exact": 0.8495412844036697,
|
"type_accuracy": 0.9707081942899518,
|
||||||
"age_accuracy": 0.9926605504587156,
|
"type_macro_f1": 0.9569463713111677,
|
||||||
"age_f1": 0.9230769230769231,
|
"span_exact": 0.617723396366333,
|
||||||
"joint_exact": 0.8330275229357799
|
"age_accuracy": 1.0,
|
||||||
|
"age_f1": 1.0,
|
||||||
|
"joint_exact": 0.6028921023359288
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"epoch": 1,
|
"epoch": 1,
|
||||||
"loss": 0.06671750026621989,
|
"loss": 0.17198748634640987,
|
||||||
"type_accuracy": 0.9889908256880734,
|
"lang_accuracy": 0.9887429643527205,
|
||||||
"type_macro_f1": 0.9153726455986941,
|
"lang_n": 1599,
|
||||||
"span_exact": 0.9174311926605505,
|
"type_accuracy": 0.9733036707452726,
|
||||||
"age_accuracy": 1.0,
|
"type_macro_f1": 0.9717413388263517,
|
||||||
"age_f1": 1.0,
|
"span_exact": 0.6447905079718206,
|
||||||
"joint_exact": 0.9100917431192661
|
"age_accuracy": 0.9996292176492398,
|
||||||
|
"age_f1": 0.9873417721518988,
|
||||||
|
"joint_exact": 0.6292176492398962
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"epoch": 2,
|
"epoch": 2,
|
||||||
"loss": 0.03165916350924156,
|
"loss": 0.1308889834726086,
|
||||||
"type_accuracy": 0.9926605504587156,
|
"lang_accuracy": 0.9874921826141339,
|
||||||
"type_macro_f1": 0.9186768671962279,
|
"lang_n": 1599,
|
||||||
"span_exact": 0.9394495412844037,
|
"type_accuracy": 0.9729328883945124,
|
||||||
"age_accuracy": 1.0,
|
"type_macro_f1": 0.9068459919629618,
|
||||||
"age_f1": 1.0,
|
"span_exact": 0.6566555431961438,
|
||||||
"joint_exact": 0.9321100917431193
|
"age_accuracy": 0.9996292176492398,
|
||||||
|
"age_f1": 0.9873417721518988,
|
||||||
|
"joint_exact": 0.6384872080088988
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"epoch": 3,
|
"epoch": 3,
|
||||||
"loss": 0.018931047540375744,
|
"loss": 0.11097981555473346,
|
||||||
"type_accuracy": 0.9908256880733946,
|
"lang_accuracy": 0.9931207004377736,
|
||||||
"type_macro_f1": 0.9158000930443979,
|
"lang_n": 1599,
|
||||||
"span_exact": 0.926605504587156,
|
"type_accuracy": 0.9699666295884316,
|
||||||
"age_accuracy": 1.0,
|
"type_macro_f1": 0.9059061793140248,
|
||||||
"age_f1": 1.0,
|
"span_exact": 0.6603633667037448,
|
||||||
"joint_exact": 0.9192660550458716
|
"age_accuracy": 0.9996292176492398,
|
||||||
|
"age_f1": 0.9873417721518988,
|
||||||
|
"joint_exact": 0.6384872080088988
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"epoch": 4,
|
"epoch": 4,
|
||||||
"loss": 0.01237113052410642,
|
"loss": 0.09677471212526927,
|
||||||
"type_accuracy": 0.9908256880733946,
|
"lang_accuracy": 0.9931207004377736,
|
||||||
"type_macro_f1": 0.9158000930443979,
|
"lang_n": 1599,
|
||||||
"span_exact": 0.9431192660550459,
|
"type_accuracy": 0.9755283648498332,
|
||||||
"age_accuracy": 1.0,
|
"type_macro_f1": 0.9099159088603298,
|
||||||
"age_f1": 1.0,
|
"span_exact": 0.6674082313681868,
|
||||||
"joint_exact": 0.9339449541284404
|
"age_accuracy": 0.9977753058954394,
|
||||||
},
|
"age_f1": 0.9285714285714286,
|
||||||
{
|
"joint_exact": 0.6477567667779014
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"test": {
|
"test": {
|
||||||
"type_accuracy": 0.996011046333231,
|
"lang_accuracy": 0.9946581196581197,
|
||||||
"type_macro_f1": 0.9608362408399977,
|
"lang_n": 3744,
|
||||||
"span_exact": 0.9687020558453513,
|
"type_accuracy": 0.9761904761904762,
|
||||||
"age_accuracy": 1.0,
|
"type_macro_f1": 0.9057782572824744,
|
||||||
"age_f1": 1.0,
|
"span_exact": 0.6736976669862575,
|
||||||
"joint_exact": 0.9662473151273396
|
"age_accuracy": 0.9998402045381911,
|
||||||
|
"age_f1": 0.9948186528497409,
|
||||||
|
"joint_exact": 0.6581975071907957
|
||||||
},
|
},
|
||||||
"cv_mean": {
|
"cv_mean": null,
|
||||||
"type_accuracy": 0.9967343071842485,
|
"train_seconds": 773.1
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
+62
-115
@@ -2,140 +2,87 @@
|
|||||||
"history": [
|
"history": [
|
||||||
{
|
{
|
||||||
"epoch": 0,
|
"epoch": 0,
|
||||||
"loss": 1.8807837918323556,
|
"loss": 1.4419865617013066,
|
||||||
"type_accuracy": 0.9211009174311927,
|
"lang_accuracy": 0.9874921826141339,
|
||||||
"type_macro_f1": 0.6878025559450562,
|
"lang_n": 1599,
|
||||||
"span_exact": 0.5155963302752293,
|
"type_accuracy": 0.9588431590656284,
|
||||||
"age_accuracy": 0.9486238532110092,
|
"type_macro_f1": 0.9580993705655323,
|
||||||
"age_f1": 0.0,
|
"span_exact": 0.5472747497219133,
|
||||||
"joint_exact": 0.44403669724770645
|
"age_accuracy": 0.9985168705969596,
|
||||||
|
"age_f1": 0.9473684210526315,
|
||||||
|
"joint_exact": 0.5305895439377085
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"epoch": 1,
|
"epoch": 1,
|
||||||
"loss": 0.19847860801096406,
|
"loss": 0.21983068588511678,
|
||||||
"type_accuracy": 0.9743119266055046,
|
"lang_accuracy": 0.9881175734834271,
|
||||||
"type_macro_f1": 0.9018587930989455,
|
"lang_n": 1599,
|
||||||
"span_exact": 0.7981651376146789,
|
"type_accuracy": 0.9647756766777902,
|
||||||
"age_accuracy": 1.0,
|
"type_macro_f1": 0.9694674213789587,
|
||||||
"age_f1": 1.0,
|
"span_exact": 0.6384872080088988,
|
||||||
"joint_exact": 0.7853211009174312
|
"age_accuracy": 0.9996292176492398,
|
||||||
|
"age_f1": 0.9873417721518988,
|
||||||
|
"joint_exact": 0.6166110493140526
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"epoch": 2,
|
"epoch": 2,
|
||||||
"loss": 0.07505214461390836,
|
"loss": 0.16362552792017163,
|
||||||
"type_accuracy": 0.9908256880733946,
|
"lang_accuracy": 0.9899937460913071,
|
||||||
"type_macro_f1": 0.9158000930443979,
|
"lang_n": 1599,
|
||||||
"span_exact": 0.8330275229357799,
|
"type_accuracy": 0.9736744530960326,
|
||||||
"age_accuracy": 1.0,
|
"type_macro_f1": 0.9647373413686515,
|
||||||
"age_f1": 1.0,
|
"span_exact": 0.6599925843529848,
|
||||||
"joint_exact": 0.8293577981651377
|
"age_accuracy": 0.9996292176492398,
|
||||||
|
"age_f1": 0.9873417721518988,
|
||||||
|
"joint_exact": 0.6418242491657397
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"epoch": 3,
|
"epoch": 3,
|
||||||
"loss": 0.04440977191069969,
|
"loss": 0.13469500560886233,
|
||||||
"type_accuracy": 0.9908256880733946,
|
"lang_accuracy": 0.9912445278298937,
|
||||||
"type_macro_f1": 0.9158000930443979,
|
"lang_n": 1599,
|
||||||
"span_exact": 0.8807339449541285,
|
"type_accuracy": 0.9632925472747497,
|
||||||
"age_accuracy": 1.0,
|
"type_macro_f1": 0.9647998951696939,
|
||||||
"age_f1": 1.0,
|
"span_exact": 0.6329254727474972,
|
||||||
"joint_exact": 0.8733944954128441
|
"age_accuracy": 0.9996292176492398,
|
||||||
|
"age_f1": 0.9873417721518988,
|
||||||
|
"joint_exact": 0.6091954022988506
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"epoch": 4,
|
"epoch": 4,
|
||||||
"loss": 0.028807769589840285,
|
"loss": 0.11948855609379865,
|
||||||
"type_accuracy": 0.9889908256880734,
|
"lang_accuracy": 0.9912445278298937,
|
||||||
"type_macro_f1": 0.9096810438971369,
|
"lang_n": 1599,
|
||||||
"span_exact": 0.8660550458715597,
|
"type_accuracy": 0.9655172413793104,
|
||||||
"age_accuracy": 1.0,
|
"type_macro_f1": 0.9657867430958291,
|
||||||
"age_f1": 1.0,
|
"span_exact": 0.6496106785317018,
|
||||||
"joint_exact": 0.8605504587155963
|
"age_accuracy": 0.9996292176492398,
|
||||||
|
"age_f1": 0.9873417721518988,
|
||||||
|
"joint_exact": 0.6262513904338154
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"epoch": 5,
|
"epoch": 5,
|
||||||
"loss": 0.022258058590862728,
|
"loss": 0.10735353268224741,
|
||||||
"type_accuracy": 0.9908256880733946,
|
"lang_accuracy": 0.9912445278298937,
|
||||||
"type_macro_f1": 0.9155121981076735,
|
"lang_n": 1599,
|
||||||
"span_exact": 0.8954128440366973,
|
"type_accuracy": 0.9695958472376714,
|
||||||
"age_accuracy": 1.0,
|
"type_macro_f1": 0.9629303509524766,
|
||||||
"age_f1": 1.0,
|
"span_exact": 0.639599555061179,
|
||||||
"joint_exact": 0.8880733944954129
|
"age_accuracy": 0.9996292176492398,
|
||||||
},
|
"age_f1": 0.9873417721518988,
|
||||||
{
|
"joint_exact": 0.617723396366333
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"test": {
|
"test": {
|
||||||
"type_accuracy": 0.9941699907947223,
|
"lang_accuracy": 0.9959935897435898,
|
||||||
"type_macro_f1": 0.9576679877290362,
|
"lang_n": 3744,
|
||||||
"span_exact": 0.941699907947223,
|
"type_accuracy": 0.9678811121764141,
|
||||||
|
"type_macro_f1": 0.8999603592898254,
|
||||||
|
"span_exact": 0.6708213486736977,
|
||||||
"age_accuracy": 1.0,
|
"age_accuracy": 1.0,
|
||||||
"age_f1": 1.0,
|
"age_f1": 1.0,
|
||||||
"joint_exact": 0.9389383246394599
|
"joint_exact": 0.6506871204857783
|
||||||
},
|
},
|
||||||
"cv_mean": {
|
"cv_mean": null,
|
||||||
"type_accuracy": 0.9945265233277791,
|
"train_seconds": 241.3
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
# ONNX parity (test split)
|
# ONNX parity (test split)
|
||||||
|
|
||||||
- строк: 645
|
- модель: models/best / model.onnx (711.2 MB)
|
||||||
|
- строк: 2000
|
||||||
- совпадение класса типа: 1.0000
|
- совпадение класса типа: 1.0000
|
||||||
- совпадение текста спана: 1.0000
|
- совпадение текста спана: 1.0000
|
||||||
- совпадение флага 18+: 1.0000
|
- совпадение флага 18+: 1.0000
|
||||||
- макс. расхождение type_logits: 4.29e-06
|
- макс. расхождение type_logits: 2.57e-05
|
||||||
|
|
||||||
Вердикт: **OK** (≥99% все критерии)
|
Вердикт: **OK** (≥99% все критерии)
|
||||||
@@ -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
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
| модель | type acc | type macro-F1 | span exact | age acc | joint exact | время, с |
|
| модель | type acc | type macro-F1 | span exact | age acc | joint exact | время, с |
|
||||||
|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| tiny2 | 0.994 | 0.958 | 0.942 | 1.000 | 0.939 | 589.2 |
|
| tiny2 | 0.968 | 0.900 | 0.671 | 1.000 | 0.651 | 241.3 |
|
||||||
| base | 0.996 | 0.961 | 0.969 | 1.000 | 0.966 | 1013.3 |
|
| base | 0.976 | 0.906 | 0.674 | 1.000 | 0.658 | 773.1 |
|
||||||
|
|
||||||
Лучшая модель: **base** -> `models/best`
|
Лучшая модель: **base** -> `models/best`
|
||||||
+2
-7
@@ -27,7 +27,7 @@ from transformers import AutoTokenizer
|
|||||||
|
|
||||||
from .model import load_model
|
from .model import load_model
|
||||||
from .studio_norm import STUDIO_ALIASES, STUDIO_PLACEHOLDERS
|
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
|
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_type += tpt[i] == pot[i]
|
||||||
same_age += apt[i] == apo[i]
|
same_age += apt[i] == apo[i]
|
||||||
attn = enc["attention_mask"][i].tolist()
|
attn = enc["attention_mask"][i].tolist()
|
||||||
st = decode_span(bpt[i], offsets[i], attn)
|
same_span += decode_spans(bpt[i], offsets[i], attn) == decode_spans(bot[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 "")
|
|
||||||
return {"n": n, "type_match": same_type / max(n, 1),
|
return {"n": n, "type_match": same_type / max(n, 1),
|
||||||
"span_match": same_span / max(n, 1),
|
"span_match": same_span / max(n, 1),
|
||||||
"age_match": same_age / max(n, 1),
|
"age_match": same_age / max(n, 1),
|
||||||
|
|||||||
+28
-8
@@ -17,20 +17,22 @@ from pathlib import Path
|
|||||||
|
|
||||||
from bs4 import BeautifulSoup
|
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:», …
|
# строка дорожки: «Перевод:», «Перевод 2:», «Озвучивание:», «Аудио 1:», …
|
||||||
RE_LINE = re.compile(
|
RE_LINE = re.compile(_LEAD + _WORD + r"\s*" + _NUM + r"\s*[:.]\s*(?P<body>.+)$", re.I)
|
||||||
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук|Аудио)"
|
|
||||||
r"\s*(?:#\s*\d+|\d+)?\s*[:.]\s*(?P<body>.+)$", re.I)
|
|
||||||
|
|
||||||
# то же, но значение на СЛЕДУЮЩЕЙ строке (kinozal: «<b>Перевод:</b> значение»)
|
# то же, но значение на СЛЕДУЮЩЕЙ строке (kinozal: «<b>Перевод:</b> значение»)
|
||||||
RE_KEY_ONLY = re.compile(
|
RE_KEY_ONLY = re.compile(_LEAD + _WORD + r"\s*" + _NUM + r"\s*:\s*$", re.I)
|
||||||
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук|Аудио)"
|
|
||||||
r"\s*(?:#\s*\d+|\d+)?\s*:\s*$", re.I)
|
|
||||||
|
|
||||||
# префикс строки дорожки (для получения «чистого» текста дорожки)
|
# префикс строки дорожки (для получения «чистого» текста дорожки)
|
||||||
RE_LINE_PREFIX = re.compile(
|
RE_LINE_PREFIX = re.compile(
|
||||||
r"^\s*(?:Перевод|Озвучиван\w*|Озвучк\w*|Озвучен\w*|Звук|Аудио)"
|
_LEAD + r"(?:Перевод|Озвучиван\w*|Озвучк\w*|Озвучен\w*|Звук|Аудио|Дубляж)"
|
||||||
r"\s*(?:#\s*\d+|\d+)?\s*[:.]\s*", re.I)
|
r"\s*" + _NUM + r"\s*[:.]\s*", re.I)
|
||||||
|
|
||||||
# признаки того, что в тексте есть информация о типе озвучки/авторе
|
# признаки того, что в тексте есть информация о типе озвучки/авторе
|
||||||
RE_TYPE_HINT = re.compile(
|
RE_TYPE_HINT = re.compile(
|
||||||
@@ -47,6 +49,13 @@ RE_SEP = re.compile(
|
|||||||
r"\s*(?:\+|;|/)\s*(?=Проф|Люб|Авт|Дубл|Многоголос|Двухголос|Одноголос)",
|
r"\s*(?:\+|;|/)\s*(?=Проф|Люб|Авт|Дубл|Многоголос|Двухголос|Одноголос)",
|
||||||
re.I)
|
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")
|
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):
|
if j >= len(lines):
|
||||||
break
|
break
|
||||||
body, i = lines[j].strip(), j + 1
|
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:
|
else:
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|||||||
+22
-14
@@ -14,6 +14,7 @@ Python API:
|
|||||||
Формат результата (JSON):
|
Формат результата (JSON):
|
||||||
{"segment": "...", "translation_type": "...", "type_confidence": 0.99,
|
{"segment": "...", "translation_type": "...", "type_confidence": 0.99,
|
||||||
"studio_span": "..." | null, "studio": "..." | null, "studio_confidence": 0.97,
|
"studio_span": "..." | null, "studio": "..." | null, "studio_confidence": 0.97,
|
||||||
|
"studio_spans": [...], "studios": [...], "studio_confidences": [...], # все авторы строки, первый = studio
|
||||||
"is_18plus": false, "age_confidence": 0.99}
|
"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)
|
read_input)
|
||||||
from .model import load_model, predict_with_lang
|
from .model import load_model, predict_with_lang
|
||||||
from .studio_norm import canonical
|
from .studio_norm import canonical
|
||||||
from .spans import MAX_LEN, decode_span
|
from .spans import MAX_LEN, decode_spans
|
||||||
from .trackinfo import lang_of_text, model_text, role_of
|
from .trackinfo import audio_summary, lang_of_text, model_text, role_of
|
||||||
|
|
||||||
|
|
||||||
# мусор, который модель иногда принимает за автора: раскладка каналов, кодеки
|
# мусор, который модель иногда принимает за автора: раскладка каналов, кодеки
|
||||||
@@ -132,16 +133,22 @@ class DubPredictor:
|
|||||||
seg = texts[i]
|
seg = texts[i]
|
||||||
tid = int(type_prob[i].argmax())
|
tid = int(type_prob[i].argmax())
|
||||||
aid = int(age_prob[i].argmax())
|
aid = int(age_prob[i].argmax())
|
||||||
span = refine_span(text, decode_span(
|
# все авторы строки («Многоголосый (HDRezka Studio, LostFilm)»); основной — первый по тексту
|
||||||
bio_pred[i], offsets[i], enc["attention_mask"][i].tolist()))
|
spans, span_texts = [], []
|
||||||
span_text = text[span[0]:span[1]].strip() if span else None
|
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)
|
ttype = guard_type(self.types[tid], text, span_text, self.types)
|
||||||
span_conf = None
|
span_confs = []
|
||||||
if span is not None:
|
for a0, z0 in spans:
|
||||||
toks = [t for t, (a, z) in enumerate(offsets[i])
|
confs = [float(bio_prob[i, t, bio_pred[i, t]]) for t, (a, z) in enumerate(offsets[i])
|
||||||
if z > a and a < span[1] and z > span[0]]
|
if z > a and a < z0 and z > a0]
|
||||||
confs = [float(bio_prob[i, t, bio_pred[i, t]]) for t in toks]
|
span_confs.append(round(float(np.mean(confs)), 4) if confs else None)
|
||||||
span_conf = float(np.mean(confs)) if confs else None
|
span_conf = span_confs[0] if span_confs else None
|
||||||
lang, lang_conf, lang_src = "", None, None
|
lang, lang_conf, lang_src = "", None, None
|
||||||
if info and info["lang"]: # язык, прямо названный в строке «Аудио», надёжнее нейросети
|
if info and info["lang"]: # язык, прямо названный в строке «Аудио», надёжнее нейросети
|
||||||
lang, lang_src = info["lang"], "audio"
|
lang, lang_src = info["lang"], "audio"
|
||||||
@@ -159,6 +166,9 @@ class DubPredictor:
|
|||||||
"studio_span": span_text,
|
"studio_span": span_text,
|
||||||
"studio": canonical(span_text) if span_text else None,
|
"studio": canonical(span_text) if span_text else None,
|
||||||
"studio_confidence": round(span_conf, 4) if span_conf 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+ в строке «Аудио» — прямое свидетельство
|
"is_18plus": bool(aid == 1) or bool(info and info["adult"]), # 18+ в строке «Аудио» — прямое свидетельство
|
||||||
"age_confidence": round(float(age_prob[i, aid]), 4),
|
"age_confidence": round(float(age_prob[i, aid]), 4),
|
||||||
"lang": lang or None,
|
"lang": lang or None,
|
||||||
@@ -166,9 +176,7 @@ class DubPredictor:
|
|||||||
"lang_source": lang_src,
|
"lang_source": lang_src,
|
||||||
"role": role_of(info, seg),
|
"role": role_of(info, seg),
|
||||||
"official": bool(info and info["official"]),
|
"official": bool(info and info["official"]),
|
||||||
"audio": ({"codec": info["codec"] or None, "channels": int(info["channels"]) if info["channels"] else None,
|
"audio": audio_summary(info),
|
||||||
"bitrate_kbps": int(info["kbps"]) if info["kbps"] else None}
|
|
||||||
if info else None),
|
|
||||||
"_audio18": info["adult"] if info else None,
|
"_audio18": info["adult"] if info else None,
|
||||||
})
|
})
|
||||||
return results
|
return results
|
||||||
|
|||||||
+108
-40
@@ -32,12 +32,17 @@ import re
|
|||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import text_fixes
|
||||||
from .html_extract import RE_LINE_PREFIX, RE_TYPE_HINT, _split_entries
|
from .html_extract import RE_LINE_PREFIX, RE_TYPE_HINT, _split_entries
|
||||||
from .manual import REPEAT, manual_ids
|
from .manual import REPEAT, manual_ids
|
||||||
from .studio_norm import JUNK_STUDIO_RE, find_studio_span
|
from .studio_norm import JUNK_STUDIO_RE, find_studio_span
|
||||||
from .trackinfo import context, model_text, parse_audio_line
|
from .trackinfo import context, model_text, parse_audio_line
|
||||||
|
|
||||||
RE_18 = re.compile(r"\b18\s*\+")
|
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 = {"неизвестно", "авт. неизвестно", "проф. неизвестно",
|
UNKNOWN_TYPES = {"неизвестно", "авт. неизвестно", "проф. неизвестно",
|
||||||
"люб. неизвестно"}
|
"люб. неизвестно"}
|
||||||
@@ -71,6 +76,52 @@ def build_text(row: dict) -> str:
|
|||||||
return re.sub(r"\s+", " ", body).strip()
|
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:
|
class UnionFind:
|
||||||
def __init__(self, n: int):
|
def __init__(self, n: int):
|
||||||
self.p = list(range(n))
|
self.p = list(range(n))
|
||||||
@@ -109,39 +160,39 @@ def main() -> None:
|
|||||||
|
|
||||||
# --- 1..4: текст, тип, автор, спан --------------------------------
|
# --- 1..4: текст, тип, автор, спан --------------------------------
|
||||||
rows = []
|
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]] = []
|
span_misses: list[tuple[str, str, str]] = []
|
||||||
|
fixes = text_fixes.load()
|
||||||
|
n_fixed = dropped_fix = 0
|
||||||
for r in raw_rows:
|
for r in raw_rows:
|
||||||
text = build_text(r) # без хвоста «Аудио»: спан автора ищем в чистом тексте дорожки
|
lab = row_label(r, fixes)
|
||||||
if not text:
|
if lab["drop"] == "empty":
|
||||||
dropped_empty += 1
|
dropped_empty += 1
|
||||||
continue
|
continue
|
||||||
# несколько дорожек в одной строке при одной метке — неоднозначный пример (после переиндексации
|
if lab["drop"] == "nodub":
|
||||||
# такие строки уже разложены сборщиком; здесь отсекаются остатки старых данных)
|
dropped_nodub += 1
|
||||||
nested = [p for p in _split_entries(text) if RE_TYPE_HINT.search(p)]
|
continue
|
||||||
if len(nested) >= 2 and not (r.get("source") == "nnm" and r.get("studio_source") == "tech"):
|
if lab["drop"] == "fix":
|
||||||
|
dropped_fix += 1
|
||||||
|
continue
|
||||||
|
if lab["drop"] == "multi":
|
||||||
dropped_multi += 1
|
dropped_multi += 1
|
||||||
continue
|
continue
|
||||||
ttype = (r.get("translation_type") or "").strip()
|
if lab["drop"] == "type":
|
||||||
if ttype in UNKNOWN_TYPES:
|
|
||||||
ttype = "неизвестно"
|
|
||||||
if DROP_TYPES_RE.search(ttype):
|
|
||||||
dropped_type += 1
|
dropped_type += 1
|
||||||
continue
|
continue
|
||||||
studio = (r.get("studio") or "").strip()
|
text, ttype, studio = lab["text"], lab["type"], lab["studio"]
|
||||||
if studio == NO_STUDIO:
|
dropped_junk += lab["junk"]
|
||||||
studio = ""
|
n_fixed += lab["fixed"]
|
||||||
elif JUNK_STUDIO_RE.match(studio):
|
# ручное решение может назвать несколько авторов строки: «HDRezka Studio ; LostFilm»
|
||||||
studio = ""
|
names = [n.strip() for n in studio.split(FIX_SEP)] if lab["fixed"] else [studio]
|
||||||
dropped_junk += 1
|
studios, spans = [], []
|
||||||
span = None
|
for name in filter(None, names):
|
||||||
if studio:
|
found = find_studio_span(text, name)
|
||||||
found = find_studio_span(text, studio)
|
|
||||||
if found:
|
if found:
|
||||||
span = [found[0], found[1]]
|
add_span(studios, spans, name, [found[0], found[1]])
|
||||||
else:
|
else: # автор есть, но спан не найден: строка идёт в обучение без него
|
||||||
span_misses.append((r.get("source", ""), studio, text))
|
span_misses.append((r.get("source", ""), name, text))
|
||||||
studio = "__nospan__" # автор есть, но спан не найден
|
|
||||||
# 18+: колонка age_18, пометка в note или «18+» в самой строке дорожки
|
# 18+: колонка age_18, пометка в note или «18+» в самой строке дорожки
|
||||||
is_18 = (r.get("age_18", "").strip() in {"1", "True", "true"}
|
is_18 = (r.get("age_18", "").strip() in {"1", "True", "true"}
|
||||||
or "18+" in (r.get("note") or "")
|
or "18+" in (r.get("note") or "")
|
||||||
@@ -149,8 +200,10 @@ def main() -> None:
|
|||||||
rows.append({
|
rows.append({
|
||||||
"text": text,
|
"text": text,
|
||||||
"translation_type": ttype,
|
"translation_type": ttype,
|
||||||
"studio": studio,
|
"studio": studios[0] if studios else "",
|
||||||
"span": span,
|
"span": spans[0] if spans else None,
|
||||||
|
"studios": studios,
|
||||||
|
"spans": spans,
|
||||||
"is_18plus": is_18,
|
"is_18plus": is_18,
|
||||||
"source": r.get("source", ""),
|
"source": r.get("source", ""),
|
||||||
"release_id": r.get("release_id", ""),
|
"release_id": r.get("release_id", ""),
|
||||||
@@ -162,19 +215,32 @@ def main() -> None:
|
|||||||
rows[-1]["text"] = model_text(text, info)
|
rows[-1]["text"] = model_text(text, info)
|
||||||
rows[-1]["tail"] = context(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()
|
manual = manual_ids()
|
||||||
man_rows = [r for r in rows if r["release_id"] in manual]
|
man_rows = [r for r in rows if r["release_id"] in manual]
|
||||||
for _ in range(REPEAT - 1):
|
for _ in range(REPEAT - 1):
|
||||||
rows.extend(dict(r) for r in man_rows)
|
rows.extend(dict(r) for r in man_rows)
|
||||||
|
|
||||||
# строки с ненайденным спаном: из BIO-обучения их исключаем (флаг ниже),
|
# строки с ненайденным спаном автора для классификации типа пригодны — остаются без этого автора
|
||||||
# но для классификации типа они пригодны — оставляем с studio="" и span=null
|
|
||||||
n_span_miss = len(span_misses)
|
n_span_miss = len(span_misses)
|
||||||
for row in rows:
|
|
||||||
if row["studio"] == "__nospan__":
|
|
||||||
row["studio"] = ""
|
|
||||||
row["span"] = None
|
|
||||||
|
|
||||||
# --- аугментация морфологических вариантов -------------------------
|
# --- аугментация морфологических вариантов -------------------------
|
||||||
rng = random.Random(args.seed)
|
rng = random.Random(args.seed)
|
||||||
@@ -187,13 +253,11 @@ def main() -> None:
|
|||||||
new_text = row["text"].replace(src_w, dst_w, 1)
|
new_text = row["text"].replace(src_w, dst_w, 1)
|
||||||
if new_text == row["text"]:
|
if new_text == row["text"]:
|
||||||
continue
|
continue
|
||||||
span = None
|
found = [find_studio_span(new_text, st) for st in row["studios"]]
|
||||||
if row["span"]:
|
if not all(found):
|
||||||
found = find_studio_span(new_text, row["studio"])
|
break # автора не нашли в варианте — не добавляем
|
||||||
if not found:
|
spans = [[a, z] for a, z in found]
|
||||||
break # автора не нашли в варианте — не добавляем
|
rows.append({**row, "text": new_text, "spans": spans, "span": spans[0] if spans else None})
|
||||||
span = [found[0], found[1]]
|
|
||||||
rows.append({**row, "text": new_text, "span": span})
|
|
||||||
n_aug += 1
|
n_aug += 1
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -296,9 +360,13 @@ def main() -> None:
|
|||||||
f"уникальных нормализованных текстов: {uniq_text}",
|
f"уникальных нормализованных текстов: {uniq_text}",
|
||||||
f"групп (релиз ∪ одинаковый текст): {len(roots)}",
|
f"групп (релиз ∪ одинаковый текст): {len(roots)}",
|
||||||
f"удалено: пустой текст {dropped_empty}, мусорный studio {dropped_junk}, "
|
f"удалено: пустой текст {dropped_empty}, мусорный studio {dropped_junk}, "
|
||||||
f"не-озвучка (оригинал/субтитры) {dropped_type}, "
|
f"не-озвучка (оригинал/субтитры) {dropped_type}, отказ от озвучки {dropped_nodub}, "
|
||||||
f"противоречивые дубли {len(drop)} (групп-противоречий: {len(contradictions)}), "
|
f"противоречивые дубли {len(drop)} (групп-противоречий: {len(contradictions)}), "
|
||||||
f"несколько дорожек в одной строке {dropped_multi}, лишние повторы (> {args.max_dup} одинаковых) {n_capped}",
|
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"спан автора найден: {with_studio} ({with_studio / max(len(rows), 1):.1%}), "
|
||||||
f"не найден: {n_span_miss}",
|
f"не найден: {n_span_miss}",
|
||||||
f"аугментировано морфологических вариантов: {n_aug}",
|
f"аугментировано морфологических вариантов: {n_aug}",
|
||||||
|
|||||||
+126
-19
@@ -11,15 +11,19 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from .audio18 import apply_audio_18
|
from .audio18 import apply_audio_18
|
||||||
from .trackinfo import audio_lines_all, match_audio
|
from .techinfo import tech_info
|
||||||
|
from .trackinfo import audio_lines_all, audio_summary, match_audio
|
||||||
from .episodes import format_ranges, merge_ranges, parse_episodes
|
from .episodes import format_ranges, merge_ranges, parse_episodes
|
||||||
from .html_extract import (RE_LINE_PREFIX, _split_entries, extract_segments,
|
from .html_extract import (RE_LINE_PREFIX, RE_TYPE_START, _split_entries,
|
||||||
html_to_text)
|
extract_segments, html_to_text)
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# аббревиатуры типа озвучки в заголовках раздач -> фраза, понятная модели
|
# аббревиатуры типа озвучки в заголовках раздач -> фраза, понятная модели
|
||||||
TITLE_TYPES = {
|
TITLE_TYPES = {
|
||||||
@@ -43,7 +47,9 @@ def title_from_html(html: str) -> str:
|
|||||||
for sel in ("h1", "title"):
|
for sel in ("h1", "title"):
|
||||||
node = soup.select_one(sel)
|
node = soup.select_one(sel)
|
||||||
if node and node.get_text(strip=True):
|
if node and node.get_text(strip=True):
|
||||||
return re.sub(r"\s+", " ", node.get_text(" ", strip=True))
|
# без разделителя: rutracker оборачивает каждое русское слово в <span> и режет <wbr>
|
||||||
|
# («Галактическо<wbr>е»), пробелы между словами — отдельные текстовые узлы
|
||||||
|
return re.sub(r"\s+", " ", node.get_text()).strip()
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -136,8 +142,8 @@ def aggregate(tracks: list[dict]) -> dict:
|
|||||||
uniq = rest
|
uniq = rest
|
||||||
# дорожка без студии, при наличии дорожки того же типа со студией, — как
|
# дорожка без студии, при наличии дорожки того же типа со студией, — как
|
||||||
# правило, та же дорожка в виде кодек-строки («AC3 … | MVO, AlexFilm»)
|
# правило, та же дорожка в виде кодек-строки («AC3 … | MVO, AlexFilm»)
|
||||||
typed = {t["translation_type"] for t in uniq if t.get("studio")}
|
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"] not in typed]
|
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"],
|
studios = [{"studio": t["studio"], "translation_type": t["translation_type"],
|
||||||
"lang": t.get("lang"), "is_18plus": t["is_18plus"], "episodes": t.get("episodes")}
|
"lang": t.get("lang"), "is_18plus": t["is_18plus"], "episodes": t.get("episodes")}
|
||||||
for t in uniq if t.get("studio")]
|
for t in uniq if t.get("studio")]
|
||||||
@@ -153,9 +159,7 @@ def original_info(lines: list[dict]) -> dict | None:
|
|||||||
"""Оригинальная дорожка из строки «Аудио … |Original|» (без студии и комментариев); нет — None."""
|
"""Оригинальная дорожка из строки «Аудио … |Original|» (без студии и комментариев); нет — None."""
|
||||||
for ln in lines:
|
for ln in lines:
|
||||||
if ln["original"] and not ln["names"] and not ln["commentary"]:
|
if ln["original"] and not ln["names"] and not ln["commentary"]:
|
||||||
return {"lang": ln["lang"] or None, "codec": ln["codec"] or None,
|
return {"lang": ln["lang"] or None, **audio_summary(ln), "raw": ln["raw"]}
|
||||||
"channels": int(ln["channels"]) if ln["channels"] else None,
|
|
||||||
"bitrate_kbps": int(ln["kbps"]) if ln["kbps"] else None, "raw": ln["raw"]}
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -169,7 +173,7 @@ def lostfilm_release(title: str | None = None) -> dict:
|
|||||||
"source": "url", "episode_ranges": [], "episodes": None}
|
"source": "url", "episode_ranges": [], "episodes": None}
|
||||||
result = aggregate([track])
|
result = aggregate([track])
|
||||||
result.update(original=None, series=series_info(title or "", result["tracks"]),
|
result.update(original=None, series=series_info(title or "", result["tracks"]),
|
||||||
title=title or None, segmenter="rules")
|
**tech_info("", title or ""), title=title or None, segmenter="rules")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -254,12 +258,17 @@ def merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> None:
|
|||||||
merge_extra_tracks(tracks, title_tracks, "title")
|
merge_extra_tracks(tracks, title_tracks, "title")
|
||||||
|
|
||||||
|
|
||||||
# кодек-строка: «Аудио 3: AC3 …», «Аудио Rus: …», «Аудиодорожка 1: …», «Audio #2: …»
|
# кодек-строка: «Аудио 3: AC3 …», «Аудио Rus: …», «Аудиодорожка 1: …», «Audio #2: …»,
|
||||||
RE_AUDIO_PREFIX = re.compile(r"(?i)^\s*(?:Аудио\w*|Audio)\s*(?:№|#)?\s*(?:\d+|rus|ukr|eng)?\s*[:.]")
|
# «Аудио #1 Русский: …», «Аудио pic RUS: …», «Звук 1: …», «Track #2: …»
|
||||||
# с типом озвучки: «Аудио 3: AC3 … (MVO, HDrezka Studio)»; «English Dubbing» — не тип
|
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(
|
RE_CODEC_TRACK = re.compile(
|
||||||
r"(?i)^\s*(?:Аудио|Audio)\s*(?:№|#)?\s*\d*\s*:.*(?:\b(?:MVO|DVO|AVO|DUB|VO)\b"
|
r"(?i)^\s*(?:Аудио\w*|Audio|Звук\w*|Аудиодорожка\w*|Дорожка|Track)\s*[^:\n]{0,16}?:.*?" + RE_CODEC_TYPE)
|
||||||
r"|дубл|многоголос|двухголос|одноголос|закадр|авторск)")
|
|
||||||
RE_RU_TRACK = re.compile(r"(?i)русск|russian|\brus\b|украин|ukrain|\bukr\b")
|
RE_RU_TRACK = re.compile(r"(?i)русск|russian|\brus\b|украин|ukrain|\bukr\b")
|
||||||
RE_FOREIGN_TRACK = re.compile(
|
RE_FOREIGN_TRACK = re.compile(
|
||||||
r"(?i)казах|english|англ|француз|немец|немецк|итальян|испан|японск|корейск|китайск"
|
r"(?i)казах|english|англ|француз|немец|немецк|итальян|испан|японск|корейск|китайск"
|
||||||
@@ -294,6 +303,28 @@ def codec_segments(page_text: str) -> list[str]:
|
|||||||
return out
|
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]]:
|
def model_segments(segmenter, page_text: str) -> tuple[list[str], list[str]]:
|
||||||
"""Дорожки страницы: строки выбирает обученный сегментатор.
|
"""Дорожки страницы: строки выбирает обученный сегментатор.
|
||||||
|
|
||||||
@@ -306,7 +337,7 @@ def model_segments(segmenter, page_text: str) -> tuple[list[str], list[str]]:
|
|||||||
размечена и пока делается правилами `_split_entries`.
|
размечена и пока делается правилами `_split_entries`.
|
||||||
"""
|
"""
|
||||||
main, codec = [], []
|
main, codec = [], []
|
||||||
for line in segmenter.select_lines(page_text):
|
for line in complete_block(segmenter.select_lines(page_text), page_text):
|
||||||
if not RE_AUDIO_PREFIX.match(line):
|
if not RE_AUDIO_PREFIX.match(line):
|
||||||
main += _entries(line)
|
main += _entries(line)
|
||||||
elif codec_ok(line):
|
elif codec_ok(line):
|
||||||
@@ -314,6 +345,75 @@ def model_segments(segmenter, page_text: str) -> tuple[list[str], list[str]]:
|
|||||||
return main, codec
|
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 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
|
||||||
|
|
||||||
|
|
||||||
def predict_release(predictor, *, html: str | None = None, text: str | None = None,
|
def predict_release(predictor, *, html: str | None = None, text: str | None = None,
|
||||||
title: str | None = None, segmenter: str = "model") -> dict:
|
title: str | None = None, segmenter: str = "model") -> dict:
|
||||||
"""Предсказание для релиза целиком (html или текст страницы).
|
"""Предсказание для релиза целиком (html или текст страницы).
|
||||||
@@ -344,7 +444,7 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
|
|||||||
for info in (match_audio(seg, lines) or [None]):
|
for info in (match_audio(seg, lines) or [None]):
|
||||||
texts.append(seg)
|
texts.append(seg)
|
||||||
infos.append(info)
|
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:
|
for t in tracks:
|
||||||
t["source"] = "page"
|
t["source"] = "page"
|
||||||
ep = parse_episodes(t["segment"])
|
ep = parse_episodes(t["segment"])
|
||||||
@@ -357,7 +457,7 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
|
|||||||
segs_codec = list(dict.fromkeys(x for x in segs_codec + codec_segments(page_text)
|
segs_codec = list(dict.fromkeys(x for x in segs_codec + codec_segments(page_text)
|
||||||
if x not in seen))
|
if x not in seen))
|
||||||
if segs_codec and (not tracks or any(not t.get("studio") for t in tracks)):
|
if segs_codec and (not tracks or any(not t.get("studio") for t in tracks)):
|
||||||
codec = predictor._predict_batch(segs_codec)
|
codec = expand_studios(predictor._predict_batch(segs_codec))
|
||||||
for t in codec:
|
for t in codec:
|
||||||
ep = parse_episodes(t["segment"])
|
ep = parse_episodes(t["segment"])
|
||||||
t["episode_ranges"] = ep["ranges"]
|
t["episode_ranges"] = ep["ranges"]
|
||||||
@@ -367,13 +467,20 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
|
|||||||
title = title_from_html(html) if html is not None else title_from_text(text)
|
title = title_from_html(html) if html is not None else title_from_text(text)
|
||||||
segs = title_segments(title or "")
|
segs = title_segments(title or "")
|
||||||
if segs:
|
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)
|
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)
|
result["original"] = original_info(lines)
|
||||||
apply_audio_18(result, page_text)
|
apply_audio_18(result, page_text)
|
||||||
for t in result["tracks"]:
|
for t in result["tracks"]:
|
||||||
t.pop("_audio18", None)
|
t.pop("_audio18", None)
|
||||||
result["series"] = series_info(title or "", result["tracks"])
|
result["series"] = series_info(title or "", result["tracks"])
|
||||||
|
result.update(tech_info(page_text, title or ""))
|
||||||
result["title"] = title or None
|
result["title"] = title or None
|
||||||
result["segmenter"] = used
|
result["segmenter"] = used
|
||||||
return result
|
return result
|
||||||
+1
-1
@@ -47,7 +47,7 @@ def test_rows(dataset: Path | str | None = None) -> list[dict]:
|
|||||||
|
|
||||||
def fingerprint(rows: list[dict]) -> str:
|
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)
|
r.get("lang")], ensure_ascii=False) for r in rows)
|
||||||
return hashlib.sha1("\n".join(keys).encode("utf-8")).hexdigest()[:10]
|
return hashlib.sha1("\n".join(keys).encode("utf-8")).hexdigest()[:10]
|
||||||
|
|
||||||
|
|||||||
+11
-9
@@ -34,7 +34,7 @@ import numpy as np
|
|||||||
import torch
|
import torch
|
||||||
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||||
|
|
||||||
from .html_extract import read_input
|
from .html_extract import RE_SCENE, read_input
|
||||||
|
|
||||||
BACKBONE = "cointegrated/rubert-tiny2"
|
BACKBONE = "cointegrated/rubert-tiny2"
|
||||||
MAX_LEN = 160
|
MAX_LEN = 160
|
||||||
@@ -107,12 +107,12 @@ def load_raws(csv_path: Path = Path("data/labels.csv")) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
# кодек-строка русской/украинской дорожки: «Аудио 2: AC3 … Русский (MVO, X)»,
|
# кодек-строка русской/украинской дорожки: «Аудио 2: AC3 … Русский (MVO, X)»,
|
||||||
# «Аудио Rus: AC3 … | LostFilm», «Аудиодорожка 1: … | Russian | TVShows»;
|
# «Аудио Rus: AC3 … | LostFilm», «Аудиодорожка 1: … | Russian | TVShows»,
|
||||||
|
# «Аудио #1 Русский: …», «Аудио pic RUS: …», «Звук 1: …»;
|
||||||
# сводка «Аудио: Русский, английский (AC3 …)» без номера — не дорожка
|
# сводка «Аудио: Русский, английский (AC3 …)» без номера — не дорожка
|
||||||
RE_AUDIO_TRACK = re.compile(
|
RE_AUDIO_TRACK = re.compile(
|
||||||
r"(?i)^\s*(?:(?:Аудио|Audio)\s*(?:rus|ukr)\s*[:.]"
|
r"(?i)^\s*(?:Аудио\w*|Audio|Звук\w*|Аудиодорожка\w*|Дорожка|Track)\s*[^:\n]{0,16}?[:.]"
|
||||||
r"|(?:Аудиодорожка\s*\d*|(?:Аудио|Audio)\s*(?:№|#)?\s*\d+)\s*[:.]"
|
r".*(?:русск|russian|\brus\b|украин|ukrain|\bukr\b)")
|
||||||
r".*(?:русск|russian|\brus\b|украин|ukrain|\bukr\b))")
|
|
||||||
|
|
||||||
|
|
||||||
RE_TYPE_WORD = re.compile(
|
RE_TYPE_WORD = re.compile(
|
||||||
@@ -189,8 +189,8 @@ def build_examples(split: str, rng: random.Random, synth_per_page: int = 6) -> l
|
|||||||
continue # неразмеченная кодек-строка: ни позитив, ни негатив
|
continue # неразмеченная кодек-строка: ни позитив, ни негатив
|
||||||
prev = lines[i - 1] if i else ""
|
prev = lines[i - 1] if i else ""
|
||||||
nxt = lines[i + 1] if i + 1 < len(lines) else ""
|
nxt = lines[i + 1] if i + 1 < len(lines) else ""
|
||||||
if not y and not HINT.search(line) and rng.random() > 0.15:
|
if not y and not HINT.search(line) and not RE_SCENE.match(line) and rng.random() > 0.15:
|
||||||
continue # простые негативы прореживаем, трудные (с намёками) берём все
|
continue # простые негативы прореживаем, трудные (с намёками, scene-имена релизов) берём все
|
||||||
ex.append((*make_input(prev, line, nxt), y))
|
ex.append((*make_input(prev, line, nxt), y))
|
||||||
if not want_test:
|
if not want_test:
|
||||||
negs = [l for l, y in zip(lines, labels) if y == 0]
|
negs = [l for l, y in zip(lines, labels) if y == 0]
|
||||||
@@ -231,7 +231,7 @@ class LineSegmenter:
|
|||||||
if not lines:
|
if not lines:
|
||||||
return []
|
return []
|
||||||
probs = self.score(lines)
|
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)]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- обучение
|
# ---------------------------------------------------------------- обучение
|
||||||
@@ -276,8 +276,10 @@ def train(out_dir: str = "models/segmenter", epochs: int = 4, batch: int = 32, l
|
|||||||
opt.step()
|
opt.step()
|
||||||
sched.step()
|
sched.step()
|
||||||
opt.zero_grad()
|
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)
|
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)
|
Path(out_dir).mkdir(parents=True, exist_ok=True)
|
||||||
model.save_pretrained(out_dir)
|
model.save_pretrained(out_dir)
|
||||||
tok.save_pretrained(out_dir)
|
tok.save_pretrained(out_dir)
|
||||||
|
|||||||
+30
-24
@@ -1,4 +1,4 @@
|
|||||||
"""Общие для обучения и сервиса: длина входа и разбор BIO-предсказания в символьный спан.
|
"""Общие для обучения и сервиса: длина входа и разбор BIO-предсказания в символьные спаны.
|
||||||
|
|
||||||
Вынесено из train.py, чтобы контейнеру с сервисом не нужны были sklearn и остальные пакеты обучения.
|
Вынесено из train.py, чтобы контейнеру с сервисом не нужны были sklearn и остальные пакеты обучения.
|
||||||
"""
|
"""
|
||||||
@@ -8,33 +8,39 @@ from __future__ import annotations
|
|||||||
MAX_LEN = 128
|
MAX_LEN = 128
|
||||||
|
|
||||||
|
|
||||||
def decode_span(bio_pred, offsets, attention) -> tuple[int, int] | None:
|
def decode_spans(bio_pred, offsets, attention) -> list[tuple[int, int]]:
|
||||||
"""B/I-прогон -> символьный спан.
|
"""B/I-прогоны -> символьные спаны всех авторов строки («Многоголосый (HDRezka Studio, LostFilm)»).
|
||||||
|
|
||||||
Учитываются только реальные токены: с attention=1 и непустым offsets
|
Прогон начинается с B или I и продолжается токенами I; B начинает новый прогон, O и спецтокен
|
||||||
(спецтокены имеют offsets (0, 0) и пропускаются).
|
его заканчивают. Учитываются только реальные токены: с 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)):
|
for t in range(len(bio_pred)):
|
||||||
if not attention[t]:
|
if not attention[t]:
|
||||||
break
|
break
|
||||||
a, z = offsets[t]
|
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
|
continue
|
||||||
if bio_pred[t] in (1, 2):
|
if cur is not None:
|
||||||
bi = t
|
out.append((cur[0], cur[1]))
|
||||||
break
|
cur = None
|
||||||
if bi is None:
|
if tag in (1, 2):
|
||||||
return None
|
cur = [int(a), int(z)]
|
||||||
ei = bi
|
if cur is not None:
|
||||||
for t in range(bi + 1, len(bio_pred)):
|
out.append((cur[0], cur[1]))
|
||||||
if not attention[t]:
|
return out
|
||||||
break
|
|
||||||
a, z = offsets[t]
|
|
||||||
if z <= a:
|
def decode_span(bio_pred, offsets, attention) -> tuple[int, int] | None:
|
||||||
break
|
"""Первый B/I-прогон -> символьный спан (основной автор строки)."""
|
||||||
if bio_pred[t] == 2:
|
spans = decode_spans(bio_pred, offsets, attention)
|
||||||
ei = t
|
return spans[0] if spans else None
|
||||||
else:
|
|
||||||
break
|
|
||||||
return int(offsets[bi][0]), int(offsets[ei][1])
|
def row_spans(row: dict) -> list[list[int]]:
|
||||||
|
"""Золотые спаны строки датасета: «spans» (все авторы), у старых датасетов — один «span»."""
|
||||||
|
return row.get("spans") or ([row["span"]] if row.get("span") else [])
|
||||||
+12
-2
@@ -56,10 +56,20 @@ SUFFIX_NOISE = re.compile(
|
|||||||
r"[\s\-–—|]*\(?\b(НЕНОРМАТИВНАЯ\s+ЛЕКСИКА|ненормативная\s+лексика|18\s*\+)\)?",
|
r"[\s\-–—|]*\(?\b(НЕНОРМАТИВНАЯ\s+ЛЕКСИКА|ненормативная\s+лексика|18\s*\+)\)?",
|
||||||
re.I)
|
re.I)
|
||||||
|
|
||||||
# «мусорные» значения studio, которые автором не являются
|
# «мусорные» значения studio, которые автором не являются: отказы от озвучки,
|
||||||
|
# техпараметры, языки, источники, служебные пометки
|
||||||
JUNK_STUDIO_RE = re.compile(
|
JUNK_STUDIO_RE = re.compile(
|
||||||
r"^(?:\+?\s*вставки?|x4|viare|лицензия(?:\s+R5)?|полное дублирование R5"
|
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:
|
def clean_studio(raw: str) -> str:
|
||||||
|
|||||||
+268
@@ -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)}
|
||||||
@@ -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
@@ -21,6 +21,7 @@ LANG_WORDS = [ # корень слова -> код; пор
|
|||||||
("чешск", "CZE"), ("латв", "LAV"), ("литов", "LIT"), ("эстон", "EST"),
|
("чешск", "CZE"), ("латв", "LAV"), ("литов", "LIT"), ("эстон", "EST"),
|
||||||
("грузин", "GEO"), ("армян", "ARM"), ("азербайдж", "AZE"), ("узбек", "UZB"),
|
("грузин", "GEO"), ("армян", "ARM"), ("азербайдж", "AZE"), ("узбек", "UZB"),
|
||||||
("тюркск", "TUR"), ("турецк", "TUR"), ("португал", "POR"), ("хинди", "HIN"),
|
("тюркск", "TUR"), ("турецк", "TUR"), ("португал", "POR"), ("хинди", "HIN"),
|
||||||
|
("rus", "RUS"), ("ukr", "UKR"), ("eng", "ENG"), # «Аудио RUS: …», «| Rus |»
|
||||||
]
|
]
|
||||||
LANG_CODES = {"RUS": "RUS", "UKR": "UKR", "ENG": "ENG", "BEL": "BEL", "KAZ": "KAZ",
|
LANG_CODES = {"RUS": "RUS", "UKR": "UKR", "ENG": "ENG", "BEL": "BEL", "KAZ": "KAZ",
|
||||||
"POL": "POL", "FRA": "FRA", "FRE": "FRA", "DEU": "DEU", "GER": "DEU",
|
"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",
|
"CZE": "CZE", "LAV": "LAV", "LIT": "LIT", "EST": "EST", "GEO": "GEO",
|
||||||
"ARM": "ARM", "AZE": "AZE", "UZB": "UZB", "TUR": "TUR", "POR": "POR", "HIN": "HIN"}
|
"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_LEAD_CODE = re.compile(r"^\s*([A-Za-z]{3})\s*:")
|
||||||
RE_ADULT = re.compile(r"(?<!\d)18\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_CODEC = re.compile(r"(?i)(E-?AC-?3|AC-?3|DTS-HD\s*MA|DTS-HD|DTS:X|DTS|TrueHD|FLAC|AAC|Opus|MP3|L?PCM)")
|
||||||
RE_CH = re.compile(r"(?i)(\d(?:\.\d)?)\s*(?:ch\b|канал)")
|
RE_CH = re.compile(r"(?i)(\d(?:\.\d)?)\s*(?:ch\b|канал)")
|
||||||
RE_KBPS = re.compile(r"(?i)(\d{2,5})\s*(?:k|к)(?:bps|бит)")
|
RE_KBPS = re.compile(r"(?i)(\d{2,5})\s*(?:k|к)(?:bps|бит)")
|
||||||
|
RE_KHZ = re.compile(r"(?i)(?<![\d.,])(\d{2}(?:[.,]\d{1,3})?)\s*(?:khz|кгц)|(?<!\d)(\d{5,6})\s*(?:hz|гц)")
|
||||||
|
RE_ATMOS = re.compile(r"(?i)\batmos\b")
|
||||||
RE_LAYOUT = re.compile(r"(?<![\d.])([1-9])\.([0-2])(?![\d.])") # раскладка «5.1» -> 6 каналов
|
RE_LAYOUT = re.compile(r"(?<![\d.])([1-9])\.([0-2])(?![\d.])") # раскладка «5.1» -> 6 каналов
|
||||||
RE_NAMES = re.compile(r"\|\s*([^|]{2,40}?)\s*\||\(([^()]{2,40})\)")
|
RE_NAMES = re.compile(r"\|\s*([^|]{2,40}?)\s*\||\(([^()]{2,40})\)")
|
||||||
RE_ORIGINAL = re.compile(r"(?i)\boriginal\b|оригинал")
|
RE_ORIGINAL = re.compile(r"(?i)\boriginal\b|оригинал")
|
||||||
@@ -78,7 +85,11 @@ def parse_audio_line(line: str) -> dict:
|
|||||||
for n in re.split(r"\s*[,;/]\s*", chunk) # «|Дубляж, Le Doyen|» -> тип и студия отдельно
|
for n in re.split(r"\s*[,;/]\s*", chunk) # «|Дубляж, Le Doyen|» -> тип и студия отдельно
|
||||||
if n.strip() and not _NOT_NAME.match(n.strip()) and not RE_CODEC.search(n)]
|
if n.strip() and not _NOT_NAME.match(n.strip()) and not RE_CODEC.search(n)]
|
||||||
layout = RE_LAYOUT.search(fixed)
|
layout = RE_LAYOUT.search(fixed)
|
||||||
|
khz = RE_KHZ.search(fixed)
|
||||||
|
khz_val = (float(khz.group(1).replace(",", ".")) if khz.group(1) else int(khz.group(2)) / 1000) if khz else None
|
||||||
return {"raw": body, "lang": lang,
|
return {"raw": body, "lang": lang,
|
||||||
|
"khz": (int(khz_val) if khz_val == int(khz_val) else khz_val) if khz_val else None,
|
||||||
|
"atmos": bool(RE_ATMOS.search(body)),
|
||||||
"codec": re.sub(r"\s+", "", codec.group(1)).upper() if codec else "",
|
"codec": re.sub(r"\s+", "", codec.group(1)).upper() if codec else "",
|
||||||
"channels": _channels(ch.group(1)) if ch else (_channels(layout.group(0)) if layout else ""), "kbps": kbps.group(1) if kbps else "",
|
"channels": _channels(ch.group(1)) if ch else (_channels(layout.group(0)) if layout else ""), "kbps": kbps.group(1) if kbps else "",
|
||||||
"adult": bool(RE_ADULT.search(body)), "original": bool(RE_ORIGINAL.search(body)),
|
"adult": bool(RE_ADULT.search(body)), "original": bool(RE_ORIGINAL.search(body)),
|
||||||
@@ -86,10 +97,24 @@ def parse_audio_line(line: str) -> dict:
|
|||||||
"names": names}
|
"names": names}
|
||||||
|
|
||||||
|
|
||||||
|
def audio_summary(info: dict | None) -> dict | None:
|
||||||
|
"""Поле `audio` ответа: {codec, channels, bitrate_kbps, khz, atmos}; нет строки «Аудио» — None."""
|
||||||
|
if not info:
|
||||||
|
return None
|
||||||
|
return {"codec": info["codec"] or None, "channels": int(info["channels"]) if info["channels"] else None,
|
||||||
|
"bitrate_kbps": int(info["kbps"]) if info["kbps"] else None,
|
||||||
|
"khz": info.get("khz"), "atmos": bool(info.get("atmos"))}
|
||||||
|
|
||||||
|
|
||||||
def audio_lines_all(text: str) -> list[dict]:
|
def audio_lines_all(text: str) -> list[dict]:
|
||||||
out = []
|
out = []
|
||||||
for m in RE_AUDIO.finditer(text or ""):
|
for m in RE_AUDIO.finditer(text or ""):
|
||||||
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"]: # настоящая строка дорожки
|
if info["codec"] or info["channels"] or info["kbps"]: # настоящая строка дорожки
|
||||||
out.append(info)
|
out.append(info)
|
||||||
return out
|
return out
|
||||||
|
|||||||
+13
-17
@@ -30,7 +30,7 @@ from torch.utils.data import DataLoader, Dataset
|
|||||||
from transformers import AutoTokenizer, get_linear_schedule_with_warmup
|
from transformers import AutoTokenizer, get_linear_schedule_with_warmup
|
||||||
|
|
||||||
from .model import DubTagger, predict_with_lang, save_model
|
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 = {
|
MODEL_CONFIGS = {
|
||||||
"tiny2": {"backbone": "cointegrated/rubert-tiny2", "lr": 1e-4,
|
"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 = torch.zeros_like(enc["input_ids"])
|
||||||
bio[enc["attention_mask"] == 0] = -100
|
bio[enc["attention_mask"] == 0] = -100
|
||||||
for i, b in enumerate(batch):
|
for i, b in enumerate(batch):
|
||||||
span = b.get("span")
|
for s, e in row_spans(b): # все авторы строки: «(HDRezka Studio, LostFilm)»
|
||||||
if not span:
|
first = True
|
||||||
continue
|
for t, (a, z) in enumerate(offsets[i]):
|
||||||
s, e = span
|
if z > a and a < e and z > s: # токен пересекает спан
|
||||||
first = True
|
bio[i, t] = 1 if first else 2 # B / I
|
||||||
for t, (a, z) in enumerate(offsets[i]):
|
first = False
|
||||||
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])
|
types = torch.tensor([type2id[b["translation_type"]] for b in batch])
|
||||||
ages = torch.tensor([int(bool(b.get("is_18plus"))) for b in batch])
|
ages = torch.tensor([int(bool(b.get("is_18plus"))) for b in batch])
|
||||||
if lang2id: # неизвестный язык (None) = -100: в лосс и метрику не входит
|
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:
|
def evaluate(model, loader, device, id2type, per_row: list | None = None) -> dict:
|
||||||
"""Метрики на выборке: тип (acc, macro-F1), спан (exact), 18+ (acc, F1).
|
"""Метрики на выборке: тип (acc, macro-F1), спан (exact), 18+ (acc, F1).
|
||||||
|
|
||||||
Exact match спана сравнивает нормализованный текст предсказанного спана
|
Exact match спана сравнивает набор нормализованных текстов предсказанных спанов
|
||||||
с текстом золотого спана (None == None считается совпадением).
|
с набором золотых (у строки может быть несколько авторов; пустой == пустой — совпадение).
|
||||||
joint_exact — строка распознана полностью: тип + автор + 18+.
|
joint_exact — строка распознана полностью: тип + автор + 18+.
|
||||||
"""
|
"""
|
||||||
model.eval()
|
model.eval()
|
||||||
@@ -143,11 +140,10 @@ def evaluate(model, loader, device, id2type, per_row: list | None = None) -> dic
|
|||||||
base = len(span_ok)
|
base = len(span_ok)
|
||||||
rows = loader.dataset.rows
|
rows = loader.dataset.rows
|
||||||
for i in range(len(types)):
|
for i in range(len(types)):
|
||||||
pred = decode_span(bio_pred[i], offsets[i], attn[i])
|
row = rows[base + i]
|
||||||
gold = rows[base + i].get("span")
|
pred = {_NORM(row["text"][a:z]) for a, z in decode_spans(bio_pred[i], offsets[i], attn[i])}
|
||||||
pred_txt = rows[base + i]["text"][pred[0]:pred[1]] if pred else None
|
gold = {_NORM(row["text"][a:z]) for a, z in row_spans(row)}
|
||||||
gold_txt = rows[base + i]["text"][gold[0]:gold[1]] if gold else None
|
span_ok.append(pred == gold) # весь набор авторов строки
|
||||||
span_ok.append(_NORM(pred_txt) == _NORM(gold_txt))
|
|
||||||
y = [id2type[v] for v in y_ids]
|
y = [id2type[v] for v in y_ids]
|
||||||
p = [id2type[v] for v in p_ids]
|
p = [id2type[v] for v in p_ids]
|
||||||
type_ok = [a == b for a, b in zip(y, p)]
|
type_ok = [a == b for a, b in zip(y, p)]
|
||||||
|
|||||||
+245
-1
@@ -16,7 +16,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
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)
|
extract_segments, html_to_text)
|
||||||
from src.studio_norm import canonical, find_studio_span # noqa: E402
|
from src.studio_norm import canonical, find_studio_span # noqa: E402
|
||||||
|
|
||||||
@@ -102,6 +102,29 @@ segs = extract_segments("Перевод:\nПрофессиональный (ду
|
|||||||
check("значение на следующей строке",
|
check("значение на следующей строке",
|
||||||
segs == ["Профессиональный (дублированный) Невафильм"], f"got {segs}")
|
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("Перевод: Профессиональный (двухголосый закадровый) + Любительский (одноголосый закадровый)")
|
segs = extract_segments("Перевод: Профессиональный (двухголосый закадровый) + Любительский (одноголосый закадровый)")
|
||||||
check("разделитель +", len(segs) == 2, f"got {segs}")
|
check("разделитель +", len(segs) == 2, f"got {segs}")
|
||||||
|
|
||||||
@@ -114,6 +137,31 @@ check("codec-строка без типа пропущена", len(segs) == 0, f
|
|||||||
|
|
||||||
check("_split_entries одиночная", _split_entries("Дублированный") == ["Дублированный"])
|
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
|
# ---------------------------------------------------------------- html
|
||||||
print("== html_to_text ==")
|
print("== html_to_text ==")
|
||||||
html = ("<html><body><div>меню форума</div>"
|
html = ("<html><body><div>меню форума</div>"
|
||||||
@@ -385,6 +433,68 @@ check("аудио: дорожка студии находит свою стро
|
|||||||
_orig = original_info(_au)
|
_orig = original_info(_au)
|
||||||
check("оригинал: ENG, 768 kbps, 6 каналов", _orig and _orig["lang"] == "ENG" and _orig["bitrate_kbps"] == 768 and _orig["channels"] == 6, str(_orig))
|
check("оригинал: ENG, 768 kbps, 6 каналов", _orig and _orig["lang"] == "ENG" and _orig["bitrate_kbps"] == 768 and _orig["channels"] == 6, str(_orig))
|
||||||
check("оригинал: нет строки Original -> None", original_info(_au[:2]) is None)
|
check("оригинал: нет строки Original -> None", original_info(_au[:2]) is None)
|
||||||
|
check("оригинал: E-AC3+Atmos -> atmos, 48 kHz", _orig and _orig["atmos"] and _orig["khz"] == 48 and _orig["codec"] == "E-AC3", str(_orig))
|
||||||
|
from src.trackinfo import audio_summary, parse_audio_line
|
||||||
|
check("аудио: без Atmos -> atmos false", audio_summary(_au[0])["atmos"] is False)
|
||||||
|
check("аудио: 44.1 KHz и 48000 Гц", parse_audio_line("AC3, 2 ch, 44.1 KHz, 192 kbps")["khz"] == 44.1
|
||||||
|
and parse_audio_line("AAC 48000 Гц 2 ch 128 kbps")["khz"] == 48)
|
||||||
|
check("аудио: «48 kHz» не меняет вход модели (кодек без +Atmos)", parse_audio_line("English E-AC3+Atmos / 5.1 / 48 kHz / 768 kbps")["codec"] == "E-AC3")
|
||||||
|
|
||||||
|
print("== техданные: видео, тип релиза, субтитры ==")
|
||||||
|
from src.release import title_from_html
|
||||||
|
from src.techinfo import release_info, subtitles_info, video_info, _rate_kbps
|
||||||
|
_fx_html = open("data/samples/rt-topic-6870900.html", encoding="utf-8").read()
|
||||||
|
_fx_title = title_from_html(_fx_html)
|
||||||
|
check("заголовок rutracker без разрывов слов (<span>/<wbr>)",
|
||||||
|
_fx_title.startswith("Супер Марио: Галактическое кино / The Super Mario Galaxy Movie (Аарон Хорват"), _fx_title[:80])
|
||||||
|
_fx_text = html_to_text(_fx_html)
|
||||||
|
_v = video_info(_fx_text, _fx_title)
|
||||||
|
check("видео: HEVC 3840x2160 2160p 23.976 fps 24800 kbps 10 бит",
|
||||||
|
_v and (_v["codec"], _v["width"], _v["height"], _v["resolution"], _v["fps"], _v["bitrate_kbps"], _v["bit_depth"])
|
||||||
|
== ("HEVC", 3840, 2160, "2160p", 23.976, 24800, 10), str(_v))
|
||||||
|
check("видео: HDR — Dolby Vision, HDR10+, HDR10; профиль DV 8",
|
||||||
|
_v and _v["hdr"] == ["Dolby Vision", "HDR10+", "HDR10"] and _v["dv_profile"] == "8", str(_v and _v["hdr"]))
|
||||||
|
_r = release_info(_fx_text, _fx_title)
|
||||||
|
check("релиз: WEB-DL, MKV, 01:38:12 = 5892 с",
|
||||||
|
_r and (_r["source"], _r["container"], _r["duration"], _r["duration_sec"]) == ("WEB-DL", "MKV", "01:38:12", 5892), str(_r))
|
||||||
|
_s = subtitles_info(_fx_text, _fx_title)
|
||||||
|
check("субтитры: RUS forced, UKR forced+full+SDH, ENG full+SDH, softsub (SRT)",
|
||||||
|
_s and [(t["lang"], t["forced"], t["full"], t["sdh"]) for t in _s["tracks"]]
|
||||||
|
== [("RUS", True, False, False), ("UKR", True, True, True), ("ENG", False, True, True)]
|
||||||
|
and _s["format"] == "softsub (SRT)", str(_s))
|
||||||
|
check("видео: битрейт не склеивается с высотой кадра", _rate_kbps("Xvid 640x272 699 kb/s") == 699
|
||||||
|
and _rate_kbps("DivX 704 x 400 699 KB/s") == 699 and _rate_kbps("8 200 Кбит/сек") == 8200)
|
||||||
|
check("видео: «5763 Mбит/с» — на деле килобиты", _rate_kbps("MPEG-4 AVC, 5763 Mбит/с, 1280x720") == 5763)
|
||||||
|
check("видео: кадр 1920x800 — всё равно 1080p; fps 23.809523 целиком",
|
||||||
|
(lambda v: v["resolution"] == "1080p" and v["fps"] == 23.809523)(video_info("Видео: AVC, 1920x800, 23.809523 fps, 1776 Kbps")))
|
||||||
|
check("видео: строки «Видео» нет -> None", video_info("Аудио: AC3, 2 ch") is None)
|
||||||
|
_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 = ["проф. дубляж", "проф. многоголосый", "люб. многоголосый", "многоголосый"]
|
_T = ["проф. дубляж", "проф. многоголосый", "люб. многоголосый", "многоголосый"]
|
||||||
check("тип: «Любительский (многоголосый …) Дубляжная» не дубляж",
|
check("тип: «Любительский (многоголосый …) Дубляжная» не дубляж",
|
||||||
guard_type("проф. дубляж", "Любительский (многоголосый закадровый) Дубляжная", "Дубляжная", _T) == "люб. многоголосый")
|
guard_type("проф. дубляж", "Любительский (многоголосый закадровый) Дубляжная", "Дубляжная", _T) == "люб. многоголосый")
|
||||||
@@ -725,6 +835,15 @@ check("метки rt_parse: кодек и битрейт из строки «А
|
|||||||
all(r["codec"] == "AC3" and r["kbps"] == "192" for r in _hr), str(_hr))
|
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)))
|
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]", [
|
_sf = __import__("src.release", fromlist=["x"]).series_info("Сериал / Сезон 1 / Серии 1-8 из 8 [2026]", [
|
||||||
{"studio": "LostFilm", "episodes": "1-4", "episode_ranges": [[1, 4]]},
|
{"studio": "LostFilm", "episodes": "1-4", "episode_ranges": [[1, 4]]},
|
||||||
{"studio": "HDrezka", "episodes": None, "episode_ranges": []}])
|
{"studio": "HDrezka", "episodes": None, "episode_ranges": []}])
|
||||||
@@ -1273,5 +1392,130 @@ try:
|
|||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
print(f" пропущено (нет models/segmenter): {e}")
|
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")
|
print(f"\nитог: {PASS} ok, {FAIL} failed")
|
||||||
sys.exit(1 if FAIL else 0)
|
sys.exit(1 if FAIL else 0)
|
||||||
+86
-4
@@ -929,8 +929,10 @@ def cmd_build(rewrite: bool = False, relabel: bool = False, runner: "Runner | No
|
|||||||
if not d.exists():
|
if not d.exists():
|
||||||
continue
|
continue
|
||||||
done = built.setdefault(site, {})
|
done = built.setdefault(site, {})
|
||||||
|
# обычные страницы — json+html; legacy (импорт старого harvest) — только json с
|
||||||
|
# текстом поста: метки по нему пересобираются текущими парсерами, страница не трогается
|
||||||
files = [jp for jp in sorted(d.glob("*.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,
|
ps = per[site] = {"files": len(files), "unchanged": 0, "pages": 0, "relabeled": 0,
|
||||||
"new": 0, "no_labels": 0, "t": time.time()}
|
"new": 0, "no_labels": 0, "t": time.time()}
|
||||||
task_id = prog.add_task(f"{site}: разбор HTML", total=len(files))
|
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
|
ps["unchanged"] += 1
|
||||||
prog.advance(task_id)
|
prog.advance(task_id)
|
||||||
continue
|
continue
|
||||||
rid = jp.stem
|
rid = jp.name.removesuffix(".json").removesuffix(".legacy")
|
||||||
stem = page_stem(site, rid, labeled)
|
stem = page_stem(site, rid, labeled)
|
||||||
known = rid in labeled.get(site, ()) or stem in labeled.get(site, ())
|
known = rid in labeled.get(site, ()) or stem in labeled.get(site, ())
|
||||||
page = PAGES / f"{stem}.txt"
|
page = PAGES / f"{stem}.txt"
|
||||||
|
has_html = hp.exists()
|
||||||
# полная страница заменяет урезанную (текст поста) из импорта старого harvest
|
# полная страница заменяет урезанную (текст поста) из импорта старого harvest
|
||||||
jobs.append({"site": site, "json": str(jp), "html": str(hp), "sig": sig, "stem": stem,
|
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),
|
"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 и запись страниц — параллельно
|
# проход 2: разбор HTML и запись страниц — параллельно
|
||||||
for job, res in zip(jobs, runner.map(rw.build_one, jobs)):
|
for job, res in zip(jobs, runner.map(rw.build_one, jobs)):
|
||||||
prog.advance(task_id)
|
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) — тогда
|
# Версия разбора: поднимите при правке парсеров (harvest_core, html_to_text) — тогда
|
||||||
# кэш «уже разобрано» сбросится сам и данные пересоберутся один раз.
|
# кэш «уже разобрано» сбросится сам и данные пересоберутся один раз.
|
||||||
PARSE_VERSION = 3
|
PARSE_VERSION = 9
|
||||||
|
|
||||||
|
|
||||||
def load_json(path: Path) -> dict:
|
def load_json(path: Path) -> dict:
|
||||||
@@ -1676,6 +1680,81 @@ def settings_screen() -> None:
|
|||||||
f"режим {cfg['mode']}, раздач со страницы {cfg['per_page']}, страниц списка {cfg['pages']}")
|
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:
|
def menu() -> None:
|
||||||
while True:
|
while True:
|
||||||
ui.clear()
|
ui.clear()
|
||||||
@@ -1688,6 +1767,7 @@ def menu() -> None:
|
|||||||
("5", "Настройки [dim](вкладок на трекер, пауза, лимиты)[/dim]"),
|
("5", "Настройки [dim](вкладок на трекер, пауза, лимиты)[/dim]"),
|
||||||
("6", "Обучение по ссылкам [dim](добавить раздачи вручную, поправить метки, переобучить)[/dim]"),
|
("6", "Обучение по ссылкам [dim](добавить раздачи вручную, поправить метки, переобучить)[/dim]"),
|
||||||
("7", "Модели [dim](сравнить обученные на одной выборке, выбрать лучшую, откатиться)[/dim]"),
|
("7", "Модели [dim](сравнить обученные на одной выборке, выбрать лучшую, откатиться)[/dim]"),
|
||||||
|
("8", "Разбор противоречий [dim](релизы разметили один текст по-разному: выбрать верную метку)[/dim]"),
|
||||||
("0", "Выход")]))
|
("0", "Выход")]))
|
||||||
choice = ask("Пункт", "").lower()
|
choice = ask("Пункт", "").lower()
|
||||||
if choice in ("0", "q", ""):
|
if choice in ("0", "q", ""):
|
||||||
@@ -1709,6 +1789,8 @@ def menu() -> None:
|
|||||||
wizard_links()
|
wizard_links()
|
||||||
elif choice == "7":
|
elif choice == "7":
|
||||||
wizard_models()
|
wizard_models()
|
||||||
|
elif choice == "8":
|
||||||
|
wizard_conflicts()
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
@@ -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()
|
||||||
+484
-109
@@ -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",
|
CYR_TO_LAT = str.maketrans({"А": "A", "В": "B", "С": "C", "Е": "E", "Н": "H",
|
||||||
"К": "K", "М": "M", "О": "O", "Р": "P", "Т": "T", "Х": "X"})
|
"К": "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 = {
|
STUDIO_ALIASES = {
|
||||||
"hdrezka": "HDrezka", "hd rezka": "HDrezka", "hdrezka studio": "HDrezka",
|
"hdrezka": "HDrezka", "hd rezka": "HDrezka", "hdrezka studio": "HDrezka",
|
||||||
"red head sound": "Red Head Sound", "read head sound": "Red Head Sound",
|
"red head sound": "Red Head Sound", "read head sound": "Red Head Sound",
|
||||||
@@ -1033,7 +1073,7 @@ STUDIO_NOISE = re.compile(
|
|||||||
# «дубляж» как тип, а не часть имени студии («Дубляжная», «Продубляж», «Дубляж-Студия»)
|
# «дубляж» как тип, а не часть имени студии («Дубляжная», «Продубляж», «Дубляж-Студия»)
|
||||||
RE_DUB_TYPE = r"дублир|(?<![а-яё])дубляж(?![а-яё])"
|
RE_DUB_TYPE = r"дублир|(?<![а-яё])дубляж(?![а-яё])"
|
||||||
ROLE_PATTERNS = [(RE_DUB_TYPE, "дубляж"), (r"многоголос", "многоголосый"),
|
ROLE_PATTERNS = [(RE_DUB_TYPE, "дубляж"), (r"многоголос", "многоголосый"),
|
||||||
(r"двухголос", "двухголосый"), (r"одноголос", "одноголосый"),
|
(r"дву[хг]?о?голос", "двухголосый"), (r"одноголос", "одноголосый"),
|
||||||
(r"субтитр", "субтитры"), (r"оригинал", "оригинал")]
|
(r"субтитр", "субтитры"), (r"оригинал", "оригинал")]
|
||||||
|
|
||||||
# 18+ у kinozal/rutracker/nnm стоит в строке дорожки; отдельная строка
|
# 18+ у kinozal/rutracker/nnm стоит в строке дорожки; отдельная строка
|
||||||
@@ -1060,9 +1100,21 @@ def mark_18(note: str, age: bool) -> str:
|
|||||||
return (note or "").strip()
|
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]:
|
def clean_studio(raw: str) -> tuple[str, str]:
|
||||||
note = ""
|
note = ""
|
||||||
s = (raw or "").strip().strip("|").strip()
|
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)
|
||||||
s = re.sub(r"\([^)]*\)", " ", s)
|
s = re.sub(r"\([^)]*\)", " ", s)
|
||||||
if re.search(r"\b18\s*\+", 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): # любительский дубляж — отдельный класс, не «проф.»
|
if re.search(r"любительск|псевдо", t): # любительский дубляж — отдельный класс, не «проф.»
|
||||||
return "люб. дубляж", "люб."
|
return "люб. дубляж", "люб."
|
||||||
return "проф. дубляж", "проф."
|
return "проф. дубляж", "проф."
|
||||||
|
if kind == "неизвестно" and "авторск" in t:
|
||||||
|
kind = "одноголосый" # «Авторский (Павел Санаев)» — один голос
|
||||||
if "профессиональн" in t:
|
if "профессиональн" in t:
|
||||||
pref = "проф."
|
pref = "проф."
|
||||||
elif "любительск" in t:
|
elif "любительск" in t:
|
||||||
@@ -1115,7 +1169,8 @@ def translation_type(text: str) -> tuple[str, str]:
|
|||||||
|
|
||||||
# ---- kinozal -------------------------------------------------------
|
# ---- kinozal -------------------------------------------------------
|
||||||
KZ_RE_TRANSLATION = re.compile(
|
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(
|
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)
|
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)
|
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()
|
tech_text = (rec.get("tech_tab") or "").strip()
|
||||||
|
|
||||||
def has_transl(t: str) -> bool:
|
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):
|
if has_transl(release_text):
|
||||||
source, studio_source = release_text, "release"
|
source, studio_source = release_text, "release"
|
||||||
@@ -1182,39 +1237,48 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
|||||||
translations: list[dict] = []
|
translations: list[dict] = []
|
||||||
audios: list[dict] = []
|
audios: list[dict] = []
|
||||||
subs = ""
|
subs = ""
|
||||||
# переводы берём только из приоритетной вкладки, иначе «Техданные»
|
|
||||||
# добавляют вторую, обезличенную строку «Перевод: Дублированный»
|
def parse_tab(text: str, src: str) -> None:
|
||||||
for line in source.splitlines():
|
"""«Перевод:» из вкладки: одна строка может называть несколько студий."""
|
||||||
line = line.strip()
|
for raw in text.splitlines():
|
||||||
m = KZ_RE_TRANSLATION.match(line)
|
line = strip_lead(raw.strip())
|
||||||
if m:
|
m = KZ_RE_TRANSLATION.match(line)
|
||||||
body = m.group("body").strip()
|
if not m:
|
||||||
|
continue
|
||||||
|
body = trim_field(m.group("body"))
|
||||||
|
if not body or RE_NO_DUB_LINE.match(body):
|
||||||
|
continue
|
||||||
actors = ""
|
actors = ""
|
||||||
am = KZ_RE_ACTORS.search(body)
|
am = KZ_RE_ACTORS.search(body)
|
||||||
if am:
|
if am:
|
||||||
actors = am.group("names").strip(" .")
|
actors = am.group("names").strip(" .")
|
||||||
role, candidates = "", []
|
role = ""
|
||||||
pm = re.search(r"\(([^)]*)\)", body)
|
pm = re.search(r"\(([^)]*)\)", body)
|
||||||
if pm:
|
if pm:
|
||||||
role = pm.group(1)
|
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)
|
kind, pro = translation_type(body)
|
||||||
if re.search(r"не требуется", body, re.I):
|
names = part_studios(body)
|
||||||
kind = "оригинал"
|
if untyped_list(kind, names):
|
||||||
translations.append({"studio": studio, "type": kind, "pro": pro,
|
continue
|
||||||
"role": role, "actors": actors, "note": note,
|
for name in names or [""]:
|
||||||
"line": line})
|
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
|
continue
|
||||||
|
seen_t.add(key)
|
||||||
|
uniq_t.append(t)
|
||||||
|
translations = uniq_t
|
||||||
|
|
||||||
# аудиодорожки и субтитры могут быть в любой из вкладок
|
# аудиодорожки и субтитры могут быть в любой из вкладок
|
||||||
for line in (source + "\n" + tech_text).splitlines():
|
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),
|
"note": mark_18(note, age_18), "age_18": int(age_18),
|
||||||
"codec": a.get("codec", ""), "channels": a.get("channels", ""),
|
"codec": a.get("codec", ""), "channels": a.get("channels", ""),
|
||||||
"khz": a.get("khz", ""), "kbps": a.get("kbps", ""),
|
"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 "",
|
"lang": (a.get("lang") or "RUS") if match else "",
|
||||||
})
|
})
|
||||||
rel = {"source": "kinozal", "release_id": rec["id"], "ru_title": info["ru_title"],
|
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 -----------------------------------------------------
|
# ---- rutracker -----------------------------------------------------
|
||||||
RT_RE_FIELD = re.compile(
|
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_SUBS = re.compile(r"^\s*Субтитры\s*:\s*(?P<body>.+)$", re.I)
|
||||||
RT_RE_BRACKET = re.compile(r"\[(?P<body>[^\]]*)\]")
|
RT_RE_BRACKET = re.compile(r"\[(?P<body>[^\]]*)\]")
|
||||||
RT_RE_SEP = re.compile(r"\s*(?:\+|;|/)\s*(?=Проф|Люб|Авт|Дубл|Многоголос|Двухголос|Одноголос)", re.I)
|
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
|
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]:
|
def _rt_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||||
post = rec.get("post") or ""
|
post = rec.get("post") or ""
|
||||||
info = rt_parse_title(rec.get("page_title") or rec.get("title") or "")
|
info = rt_parse_title(rec.get("page_title") or rec.get("title") or "")
|
||||||
entries, subs = [], ""
|
entries, subs, taken = [], "", set()
|
||||||
for line in post.splitlines():
|
lines = post.splitlines()
|
||||||
m = RT_RE_FIELD.match(line)
|
for i, line in enumerate(lines):
|
||||||
if m:
|
if i in taken:
|
||||||
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)
|
|
||||||
continue
|
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:
|
if m and not subs:
|
||||||
subs = m.group("body").strip()
|
subs = m.group("body").strip()
|
||||||
rows = [{
|
rows = [{
|
||||||
@@ -1402,9 +1503,14 @@ def _rt_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
|||||||
|
|
||||||
|
|
||||||
# ---- nnmclub -------------------------------------------------------
|
# ---- nnmclub -------------------------------------------------------
|
||||||
NNM_RE_AUDIO = re.compile(r"^\s*Аудио\s*(?:#?\s*\d+)?\s*:\s*(?P<body>.+)$", re.I)
|
NNM_RE_AUDIO = re.compile(
|
||||||
NNM_RE_PEREVOD = re.compile(r"^\s*Перевод\s*:\s*(?P<body>.+)$", re.I)
|
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)
|
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_LANG_MAP = {"русский": "RUS", "английский": "ENG", "украинский": "UKR"}
|
||||||
NNM_TAG_RE = re.compile(r"\b(DUB|MVO|DVO|AVO|VO|ДБ|ПМ|ПД|АП|ЛМ|ЛД|ЛО)\b", re.I)
|
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)
|
NNM_RE_CODEC = re.compile(r"\b(Opus|AC-?3|E-?AC-?3|AAC|DTS-?HD|DTS|FLAC|MP3|TrueHD)\b", re.I)
|
||||||
@@ -1483,6 +1589,173 @@ def nnm_part_studio(part: str) -> str:
|
|||||||
return ""
|
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]:
|
def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||||
post = rec.get("post") or ""
|
post = rec.get("post") or ""
|
||||||
title = rec.get("page_title") or rec.get("title") or ""
|
title = rec.get("page_title") or rec.get("title") or ""
|
||||||
@@ -1498,37 +1771,36 @@ def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
|||||||
|
|
||||||
for line in post.splitlines():
|
for line in post.splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
m = NNM_RE_AUDIO.match(line)
|
m = NNM_RE_AUDIO.match(strip_lead(line))
|
||||||
if m:
|
if m and not re.match(r"(?i)^\s*аудио\s*кодек", strip_lead(line)):
|
||||||
body = m.group("body").strip()
|
body = trim_field(m.group("body"))
|
||||||
lm = NNM_LANG_RE.search(body)
|
if RE_NO_DUB_LINE.match(body):
|
||||||
if not lm or NNM_LANG_MAP.get(lm.group(1).lower()) != "RUS":
|
continue
|
||||||
continue # английскую/украинскую дорожку пропускаем
|
langs = lang_tokens(body)
|
||||||
rest = body[lm.end():]
|
if not langs or not ({"RUS", "UKR"} & set(langs)):
|
||||||
tm = NNM_TAG_RE.search(rest)
|
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 ""
|
tag = tm.group(1).upper() if tm else ""
|
||||||
|
low = body.lower()
|
||||||
if not tag: # формат «... - дубляж» без аббревиатуры
|
if not tag: # формат «... - дубляж» без аббревиатуры
|
||||||
low = rest.lower()
|
|
||||||
if "дубляж" in low:
|
if "дубляж" in low:
|
||||||
tag = "DUB"
|
tag = "DUB"
|
||||||
elif "многоголос" in low:
|
elif "многоголос" in low:
|
||||||
tag = "MVO"
|
tag = "MVO"
|
||||||
elif "двухголос" in low:
|
elif re.search(r"дву[хг]?голос", low):
|
||||||
tag = "DVO"
|
tag = "DVO"
|
||||||
elif "авторск" in low:
|
elif "авторск" in low:
|
||||||
tag = "AVO"
|
tag = "AVO"
|
||||||
elif "одноголос" in low:
|
elif "одноголос" in low:
|
||||||
tag = "VO"
|
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)
|
ttype, pro = nnm_tag_type(tag)
|
||||||
if not ttype:
|
if not ttype:
|
||||||
ttype, pro = "неизвестно", ""
|
ttype, pro = "неизвестно", ""
|
||||||
|
names = audio_names(body)
|
||||||
|
if not names and ttype == "неизвестно":
|
||||||
|
continue # ни типа, ни имени — строке нечего дать датасету
|
||||||
codec_m = NNM_RE_CODEC.search(body)
|
codec_m = NNM_RE_CODEC.search(body)
|
||||||
kb_m = NNM_RE_KBP.search(body)
|
kb_m = NNM_RE_KBP.search(body)
|
||||||
ch_m = NNM_RE_CH.search(body)
|
ch_m = NNM_RE_CH.search(body)
|
||||||
@@ -1537,46 +1809,77 @@ def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
|||||||
lay = NNM_RE_LAYOUT.search(body)
|
lay = NNM_RE_LAYOUT.search(body)
|
||||||
if lay:
|
if lay:
|
||||||
channels = str(int(lay.group(1)) + (1 if lay.group(2) != "0" else 0))
|
channels = str(int(lay.group(1)) + (1 if lay.group(2) != "0" else 0))
|
||||||
rows.append({
|
age_18 = has_18(body)
|
||||||
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
|
for name in names or [""]:
|
||||||
"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)
|
|
||||||
# студия — если названа в части («Дублированный (Невафильм)»); без неё тип всё равно ценен
|
|
||||||
rows.append({
|
rows.append({
|
||||||
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
|
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
|
||||||
"year": year, "quality": quality, "is_series": is_series,
|
"year": year, "quality": quality, "is_series": is_series,
|
||||||
"studio": nnm_part_studio(part) or "(не указана)", "translation_type": ttype,
|
"studio": name or "(не указана)", "translation_type": ttype,
|
||||||
"professional": pro, "role_raw": part, "actors": "",
|
"professional": pro, "role_raw": tag, "actors": name,
|
||||||
"note": mark_18("", has_18(line)), "age_18": int(has_18(line)),
|
"note": mark_18("", age_18),
|
||||||
"codec": "", "channels": "", "khz": "", "kbps": "",
|
"age_18": int(age_18),
|
||||||
"subtitles": "", "studio_source": "tech", "raw": line,
|
"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» с именами — строки «Перевод:» (tech) не нужны; иначе
|
# есть построчные «Аудио N» с именами — строки «Перевод:» (tech) того же типа
|
||||||
# оставляем tech-строки: типы и студии, названные в «Перевод:»
|
# не нужны; типы, которых в «Аудио» нет, остаются (авторская дорожка и т. п.)
|
||||||
if any(r["studio"] != "(не указана)" and r["studio_source"] == "release" for r in rows):
|
if any(r["studio"] != "(не указана)" and r["studio_source"] == "release" for r in rows):
|
||||||
rows = [r for r in rows if r["studio_source"] == "release" and r["studio"] != "(не указана)"]
|
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
|
rows = [r for r in rows
|
||||||
@@ -1787,16 +2090,18 @@ def kz_title_rows(rec: dict, text: str, rows: list[dict]) -> list[dict]:
|
|||||||
# кодек-строка одной дорожки: «Аудио 2: …», «Аудио Rus: …», «Аудио pic RUS: …», «Звук #1: …»,
|
# кодек-строка одной дорожки: «Аудио 2: …», «Аудио Rus: …», «Аудио pic RUS: …», «Звук #1: …»,
|
||||||
# «• №3: Русский (АС3 …)», «Russian (Dolby AC3, 6 ch) …»
|
# «• №3: Русский (АС3 …)», «Russian (Dolby AC3, 6 ch) …»
|
||||||
RE_CODEC_LINE = re.compile(
|
RE_CODEC_LINE = re.compile(
|
||||||
r"(?i)^\s*[•#№*\-\s]*(?:(?:аудио\w*|audio|звук\w*)\b[^:\n]{0,24}:|№\s*\d+\s*:|(?:russian|русск\w*|украин\w*)\b)")
|
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_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(
|
RE_LANG_WORD = re.compile(
|
||||||
r"(?i)русск|russian|\brus\b|украин|ukrain|\bukr\b|україн|англ|english|\beng\b|япон|japan|казах|kazakh"
|
r"(?i)русск|russian|\brus\b|украин|ukrain|\bukr\b|україн|англ|english|\beng\b|япон|japan|казах|kazakh"
|
||||||
r"|француз|french|немецк|german|итальян|испан|корей|китайск")
|
r"|француз|french|немецк|german|итальян|испан|корей|китайск")
|
||||||
# не студия: язык, тех. параметры, тип, источник, пометки
|
# не студия: язык, тех. параметры, тип, источник, пометки
|
||||||
CODEC_NOT_STUDIO = re.compile(
|
CODEC_NOT_STUDIO = re.compile(
|
||||||
r"(?i)^(?:rus|ukr|eng|ru|ua|int|ext|pic|dub|mvo|dvo|avo|vo|лицензи\w*|ненормативн\w*.*|.*лексик\w*"
|
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"|присутствует.*|внешн\w*|в составе.*|серии.*|эп\w*\.?\s*\d.*|[\d\s.,:/\-]+|.*\b(?:kbps|ch|khz|кбит)\b.*"
|
||||||
r"|.*(?:русск|английск|украинск|russian|english|ukrainian|оригинал|original)\w*.*"
|
r"|.*(?:русск|английск|украинск|russian|english|ukrainian|оригинал|original|японск|китайск"
|
||||||
|
r"|корейск|немецк|французск|итальянск|испанск|казахск)\w*.*"
|
||||||
r"|.*(?:голос|дублир|закадр|перевод).*|.*(?<![а-яё])дубляж(?![а-яё]).*|звук.*"
|
r"|.*(?:голос|дублир|закадр|перевод).*|.*(?<![а-яё])дубляж(?![а-яё]).*|звук.*"
|
||||||
r"|.*(?:blu-?ray|itunes|netflix|amazon|\bbd\b|\bdvd\b|web-?dl).*)$")
|
r"|.*(?:blu-?ray|itunes|netflix|amazon|\bbd\b|\bdvd\b|web-?dl).*)$")
|
||||||
|
|
||||||
@@ -1838,15 +2143,53 @@ def _known_studios() -> re.Pattern | None:
|
|||||||
_KNOWN_RX: re.Pattern | None = None
|
_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]:
|
def codec_line_rows(rec: dict, text: str, rows: list[dict], known: re.Pattern | None = None) -> list[dict]:
|
||||||
"""Студии из кодек-строк русских/украинских дорожек, которых нет среди строк разметки.
|
"""Студии и типы из кодек-строк русских/украинских дорожек.
|
||||||
|
|
||||||
kinozal: «Перевод: Профессиональный (многоголосый закадровый)» без студий + «Аудио 2: AC3 …
|
kinozal: «Перевод: Профессиональный (многоголосый закадровый)» без студий + «Аудио 2: AC3 …
|
||||||
(MVO, LostFilm)», «Аудио Rus: … | TVShows»; nnm/rutracker — те же кодек-строки рядом с
|
(MVO, LostFilm)», «Аудио Rus: … | TVShows»; nnm/rutracker — те же кодек-строки рядом с
|
||||||
«Перевод N:» со списком, где этой студии нет. Строка-вход — сегмент кодек-строки, как его
|
«Перевод N:» со списком, где этой студии нет, и строки вида «Аудио 1: язык: Русский,
|
||||||
|
перевод: многоголосый закадровый, кодек: AC3». Строка-вход — сегмент кодек-строки, как его
|
||||||
режет сервис (`src.release._entries`); тип — из самой строки (MVO, «дубляж»), иначе
|
режет сервис (`src.release._entries`); тип — из самой строки (MVO, «дубляж»), иначе
|
||||||
единственный тип релиза, иначе «неизвестно». Сводка из нескольких языков
|
единственный тип релиза, иначе «неизвестно». Если у релиза ещё нет ни одной строки, из
|
||||||
(«Аудио: Русский (AC3…), украинский (AC3…)»), оригинал и комментарии — пропускаются.
|
кодек-строки берётся и дорожка без имени (тип без студии) — так размечиваются страницы,
|
||||||
|
где дорожки описаны только строками «Аудио». Сводки из нескольких языков, оригинал и
|
||||||
|
комментарии пропускаются.
|
||||||
"""
|
"""
|
||||||
from src.release import _entries, same_studio
|
from src.release import _entries, same_studio
|
||||||
from src.trackinfo import parse_audio_line
|
from src.trackinfo import parse_audio_line
|
||||||
@@ -1854,18 +2197,21 @@ def codec_line_rows(rec: dict, text: str, rows: list[dict], known: re.Pattern |
|
|||||||
known = known if known is not None else _known_studios()
|
known = known if known is not None else _known_studios()
|
||||||
have = [r["studio"] for r in rows if r.get("studio") and r["studio"] != "(не указана)"]
|
have = [r["studio"] for r in rows if r.get("studio") and r["studio"] != "(не указана)"]
|
||||||
types = {r["translation_type"] for r in rows} - {"неизвестно", ""}
|
types = {r["translation_type"] for r in rows} - {"неизвестно", ""}
|
||||||
base = rows[0] if rows else {}
|
base = rows[0] if rows else _rec_base(rec)
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
for line in (text or "").splitlines():
|
for line in (text or "").splitlines():
|
||||||
line = re.sub(r"\s+", " ", line).strip()
|
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):
|
if len(line) > 300 or not RE_CODEC_LINE.match(line) or not RE_CODEC_PARAM.search(line):
|
||||||
continue
|
continue
|
||||||
if len({_lang_key(m.group(0)) for m in RE_LANG_WORD.finditer(line)}) > 1:
|
if RE_NO_DUB_LINE.match(line):
|
||||||
|
continue
|
||||||
|
langs = set(lang_tokens(line))
|
||||||
|
if len(langs & {"RUS", "UKR", "ENG", "JPN", "OTHER"}) > 1:
|
||||||
continue
|
continue
|
||||||
info = parse_audio_line(re.sub(r"(?i)^\s*[^:]{0,30}:\s*", "", line, count=1))
|
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", ""):
|
if info["original"] or info["commentary"] or info["lang"] not in ("RUS", "UKR", ""):
|
||||||
continue
|
continue
|
||||||
if not info["lang"] and not re.search(r"(?i)\brus\b|\bukr\b", line):
|
if not info["lang"] and not (langs & {"RUS", "UKR"}) and not re.search(r"(?i)\brus\b|\bukr\b", line):
|
||||||
continue
|
continue
|
||||||
tag = NNM_TAG_RE.search(line) or NNM_TAG_RE.search(line.translate(CYR_TO_LAT)) # «АVO» кириллицей
|
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 ("", "")
|
kind, pro = nnm_tag_type(tag.group(1)) if tag else ("", "")
|
||||||
@@ -1876,15 +2222,17 @@ def codec_line_rows(rec: dict, text: str, rows: list[dict], known: re.Pattern |
|
|||||||
if not kind:
|
if not kind:
|
||||||
kind = next(iter(types)) if len(types) == 1 else "неизвестно"
|
kind = next(iter(types)) if len(types) == 1 else "неизвестно"
|
||||||
pro = kind.split()[0] if kind.split()[0] in ("проф.", "люб.", "авт.") else ""
|
pro = kind.split()[0] if kind.split()[0] in ("проф.", "люб.", "авт.") else ""
|
||||||
cands = [(m.group(1), True) for m in known.finditer(line)] + [(n, False) for n in info["names"]]
|
names = _line_names(line, info, known)
|
||||||
for name, in_vocab in cands:
|
found = False # строка назвала настоящую студию (новую или уже известную)
|
||||||
|
for name, in_vocab in names:
|
||||||
studio, note = clean_studio(name)
|
studio, note = clean_studio(name)
|
||||||
studio, extra = canonical_studio(studio)
|
studio, extra = canonical_studio(studio)
|
||||||
if len(studio) < 3 or studio.lower() in SOURCE_LIKE or CODEC_NOT_STUDIO.match(studio):
|
if len(studio) < 3 or is_studio_junk(studio):
|
||||||
continue
|
continue
|
||||||
if CODEC_NAME_NOISE.search(studio) or not in_vocab and re.search(r"\d", studio):
|
if CODEC_NAME_NOISE.search(studio) or not in_vocab and re.search(r"\d", studio):
|
||||||
continue
|
continue
|
||||||
if any(same_studio(studio, x) for x in have):
|
if any(same_studio(studio, x) for x in have):
|
||||||
|
found = True
|
||||||
continue
|
continue
|
||||||
entries = _entries(line)
|
entries = _entries(line)
|
||||||
seg = next((e for e in entries if name.lower() in e.lower()), entries[0] if entries else line)
|
seg = next((e for e in entries if name.lower() in e.lower()), entries[0] if entries else line)
|
||||||
@@ -1900,7 +2248,34 @@ def codec_line_rows(rec: dict, text: str, rows: list[dict], known: re.Pattern |
|
|||||||
"raw": seg, "lang": info["lang"] or ("UKR" if re.search(r"(?i)\bukr\b", line) else "RUS"),
|
"raw": seg, "lang": info["lang"] or ("UKR" if re.search(r"(?i)\bukr\b", line) else "RUS"),
|
||||||
"audio_raw": line})
|
"audio_raw": line})
|
||||||
have.append(studio)
|
have.append(studio)
|
||||||
return out
|
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):
|
def _with_audio(core, texts):
|
||||||
@@ -1915,8 +2290,8 @@ def _with_audio(core, texts):
|
|||||||
rows = kz_audio_only_rows(rec, text)
|
rows = kz_audio_only_rows(rec, text)
|
||||||
if core is _kz_parse_core:
|
if core is _kz_parse_core:
|
||||||
rows += kz_title_rows(rec, text, rows)
|
rows += kz_title_rows(rec, text, rows)
|
||||||
|
rows += codec_line_rows(rec, text, rows)
|
||||||
if rows:
|
if rows:
|
||||||
rows += codec_line_rows(rec, text, rows)
|
|
||||||
rel["n_translations"], rel["ok"] = len(rows), True
|
rel["n_translations"], rel["ok"] = len(rows), True
|
||||||
rel["studios"] = " | ".join(sorted({r["studio"] for r in rows}))
|
rel["studios"] = " | ".join(sorted({r["studio"] for r in rows}))
|
||||||
return rows, rel
|
return rows, rel
|
||||||
|
|||||||
Reference in new issue
Block a user