Docker-сервис, выбор основы и сравнение моделей, сборщик датасета
docker / build (push) Failing after 39s
docker / build (push) Failing after 39s
- Docker: Dockerfile (CPU), compose с FlareSolverr, настройка через DUB_* переменные, токен доступа к /predict, tools/pack_models.py, workflow Gitea Actions, docs/docker.md - Модель грузится офлайн (config.json рядом с весами); общий код вынесен в src/spans.py, сервису не нужны sklearn/matplotlib/pandas - Обучение: профили tiny2/base/large/modern, запуски в models/runs, реестр и честное сравнение на одной тестовой выборке (src/runs.py), пункт меню «Модели» - Датасет: язык и аудио-параметры дорожек, 18+ по строкам «Аудио», ручные метки по ссылкам, переиндексация сохранённого HTML, параллельная сборка, сбалансированная выборка - Тексты страниц (data/pages) больше не хранятся в git Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d155b8584b
commit
efc9a4f56a
411 files changed
+87495
-27363
No files matched your search
@@ -46,7 +46,9 @@ def run(predictor, rows, mode: str, test_only: bool) -> dict:
|
||||
|
||||
def main() -> None:
|
||||
rows = json.loads((ROOT / "data" / "processed" / "dataset.json").read_text(encoding="utf-8"))
|
||||
predictor = DubPredictor()
|
||||
seg_dir = sys.argv[1] if len(sys.argv) > 1 else "models/segmenter"
|
||||
predictor = DubPredictor(segmenter_dir=seg_dir)
|
||||
print(f"сегментатор: {seg_dir}")
|
||||
if predictor.segmenter is None:
|
||||
sys.exit("нет models/segmenter: python -m src.segmenter --train")
|
||||
for test_only, name in ((True, "тестовые страницы (не виделись сегментатору)"),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Проверка параллельной переиндексации: те же метки, страницы и meta, что и в одном процессе.
|
||||
|
||||
Отдельный скрипт, потому что multiprocessing на Windows заново импортирует главный модуль:
|
||||
tests/run_tests.py без `if __name__ == "__main__"` запустился бы в каждом процессе.
|
||||
Печатает одну строку JSON.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(ROOT), str(ROOT / "tools"), str(ROOT / "tools" / "harvest")]
|
||||
import collect as col # noqa: E402
|
||||
|
||||
POST = ("Год выпуска: 2020\nАудио 1: Opus, 6 ch, 221 Kbps | Русский AVO (Дмитрий Пучков)\n"
|
||||
"Аудио 2: AC3, 6 ch, 448 Kbps | Русский MVO (LostFilm)\n")
|
||||
|
||||
|
||||
def run(base: Path, name: str, workers: int) -> dict:
|
||||
d = base / name
|
||||
(d / "raw" / "nnm").mkdir(parents=True)
|
||||
(d / "labels.csv").write_text("", encoding="utf-8")
|
||||
for i in range(50):
|
||||
(d / "raw" / "nnm" / f"{9000 + i}.json").write_text(json.dumps(
|
||||
{"id": str(9000 + i), "source": "nnm", "ok": True, "title": f"Фильм {i} (2020) BDRip", "post": POST},
|
||||
ensure_ascii=False), encoding="utf-8")
|
||||
(d / "raw" / "nnm" / f"{9000 + i}.html").write_text(
|
||||
f"<html><body><h1>Фильм {i}</h1><div>{POST.replace(chr(10), '<br>')}</div></body></html>", encoding="utf-8")
|
||||
col.RAW, col.PAGES, col.LABELS = d / "raw", d / "pages", d / "labels.csv"
|
||||
col.POSTONLY, col.META, col.MANUAL = d / "po.txt", d / "meta.csv", d / "m.txt"
|
||||
return col.cmd_build(rewrite=True, runner=col.Runner(workers=workers))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
base = Path(tempfile.mkdtemp())
|
||||
a, b = run(base, "serial", 1), run(base, "par", 2)
|
||||
same = (
|
||||
(base / "serial" / "labels.csv").read_bytes() == (base / "par" / "labels.csv").read_bytes()
|
||||
and (base / "serial" / "meta.csv").read_bytes() == (base / "par" / "meta.csv").read_bytes()
|
||||
and sorted(p.name for p in (base / "serial" / "pages").glob("*.txt"))
|
||||
== sorted(p.name for p in (base / "par" / "pages").glob("*.txt")))
|
||||
print("RESULT " + json.dumps({"same": same, "pages": b["pages"], "labeled": [a["labeled"], b["labeled"]]}))
|
||||
@@ -301,6 +301,782 @@ _agg2 = aggregate([_t("проф. дубляж", "Продубляж"), _t("пр
|
||||
check("aggregate: дорожка без студии того же типа схлопывается",
|
||||
[x["translation_type"] for x in _agg2["tracks"]] == ["проф. дубляж", "проф. двухголосый"])
|
||||
|
||||
print("== серии по студиям ==")
|
||||
from src.episodes import format_ranges as _fr, parse_episodes as _pe # noqa: E402
|
||||
from src.release import series_info as _si # noqa: E402
|
||||
|
||||
check("episodes: диапазоны, перечисления и «из N»",
|
||||
[(_fr(_pe(s)["ranges"]), _pe(s)["total"]) for s in
|
||||
("русский (MVO, Lostfilm) серии 1-2", "серии 1, 3-5 из 12", "с 1 по 4 серии", "1-3 серии",
|
||||
"S01E05-E08", "AC3, 5.1 ch, 640 Kbps", "Перевод: Профессиональный (многоголосый)")]
|
||||
== [("1-2", None), ("1,3-5", 12), ("1-4", None), ("1-3", None), ("5-8", None), ("", None), ("", None)])
|
||||
check("episodes: соседние диапазоны склеиваются", _fr(_pe("серии 1-3, 4-6")["ranges"]) == "1-6")
|
||||
|
||||
|
||||
def _tk(typ, studio, eps=None):
|
||||
r = _pe(eps or "")["ranges"]
|
||||
return {**_t(typ, studio), "episode_ranges": r, "episodes": _fr(r) or None}
|
||||
|
||||
|
||||
_ag = aggregate([_tk("неизвестно", "LostFilm"), _tk("проф. многоголосый", "LostFilm", "серии 1-2"),
|
||||
_tk("проф. многоголосый", "LostFilm", "серии 3-4"), _tk("проф. дубляж", "Red Head Sound", "серия 1")])
|
||||
check("aggregate: «неизвестно» сливается с известным типом, серии объединяются",
|
||||
[(x["translation_type"], x["studio"], x["episodes"]) for x in _ag["tracks"]]
|
||||
== [("проф. многоголосый", "LostFilm", "1-4"), ("проф. дубляж", "Red Head Sound", "1")], str(_ag["tracks"]))
|
||||
_s = _si("Тёмная материя / Dark Matter (2026) (сезон 2, серии 1-9 из 10)", _ag["tracks"])
|
||||
check("series_info: сезон, серии, total и «студия -> серии»",
|
||||
(_s["season"], _s["episodes"], _s["total"]) == ("2", "1-9", 10)
|
||||
and _s["by_studio"] == {"LostFilm": "1-4", "Red Head Sound": "1"}, str(_s))
|
||||
|
||||
print("== сборщик датасета (tools/collect.py) ==")
|
||||
import csv as _csv # noqa: E402
|
||||
import json as _json # noqa: E402
|
||||
import tempfile as _tmp # noqa: E402
|
||||
from pathlib import Path as _P # noqa: E402
|
||||
|
||||
sys.path.insert(0, str(_P(__file__).resolve().parents[1] / "tools"))
|
||||
import collect as _col # noqa: E402
|
||||
|
||||
_lab = {"rutracker": {"100", "200"}, "nnm": {"200", "300"}, "kinozal": set()}
|
||||
check("page_stem: размеченный id другого источника не используется bare",
|
||||
_col.page_stem("rutracker", "200", _lab) == "rt-200"
|
||||
and _col.page_stem("nnm", "200", _lab) == "nnm-200")
|
||||
check("page_stem: у размеченной раздачи прежний id, у новой — префикс",
|
||||
_col.page_stem("rutracker", "100", _lab) == "100"
|
||||
and _col.page_stem("kinozal", "5", _lab) == "kz-5")
|
||||
check("login_state: выход > гость > неизвестно",
|
||||
_col.login_state({"out": True, "guest": True}) == "in"
|
||||
and _col.login_state({"guest": True}) == "guest"
|
||||
and _col.login_state({}) == "unknown")
|
||||
_hv = _col.hv
|
||||
check("ошибка сервера: 503/502/Cloudflare определяются по заголовку и тексту, обычные страницы нет",
|
||||
all(_hv.RE_ERROR_TITLE.search(t) for t in
|
||||
("503 Service Temporarily Unavailable", "Error 502", "Attention Required! | Cloudflare",
|
||||
"504 Gateway Time-out", "Ошибка 503"))
|
||||
and all(_hv.RE_ERROR_HEAD.search(t) for t in
|
||||
("error code: 1015 you are being rate limited", "web server is returning an unknown error",
|
||||
"ошибка 503 сервис недоступен"))
|
||||
and not any(_hv.RE_ERROR_TITLE.search(t) for t in
|
||||
("Зарубежное кино (HD Video) [стр. 2] :: RuTracker.org", "Фильм 5030 (2026)", "Раздачи :: Кинозал.МЕ")))
|
||||
|
||||
|
||||
def _retry_run(results, retries=3):
|
||||
seq, hooks, slept = list(results), [], []
|
||||
old = (_hv._goto_once, _hv.time.sleep, _hv.RETRY_HOOK)
|
||||
try:
|
||||
_hv._goto_once = lambda *a, **k: seq.pop(0)
|
||||
_hv.time.sleep = lambda s: slept.append(s)
|
||||
_hv.RETRY_HOOK = lambda url, a, n, why: hooks.append((a, n, why))
|
||||
res = _hv.goto(None, "http://x", retries=retries)
|
||||
finally:
|
||||
_hv._goto_once, _hv.time.sleep, _hv.RETRY_HOOK = old
|
||||
return res, hooks, slept, seq
|
||||
|
||||
|
||||
_e = {"ok": False, "server_error": True, "reason": "503"}
|
||||
_r, _h, _s, _left = _retry_run([_e, _e, {"ok": True}])
|
||||
check("retry: 503 дважды, потом успех — 2 повтора, паузы 3 и 8 с, хук получил попытки",
|
||||
_r["ok"] and _h == [(1, 3, "503"), (2, 3, "503")] and _s == [3.0, 8.0] and not _left)
|
||||
_r, _h, _s, _left = _retry_run([_e, _e, _e, _e])
|
||||
check("retry: упорная ошибка — ровно 3 повтора и отказ", not _r["ok"] and len(_h) == 3 and not _left
|
||||
and _s == [3.0, 8.0, 20.0])
|
||||
_r, _h, _s, _left = _retry_run([{"ok": False, "timeout": True, "reason": "таймаут"},
|
||||
{"ok": False, "timeout": True, "reason": "таймаут"}])
|
||||
check("retry: таймаут повторяется один раз", not _r["ok"] and len(_h) == 1 and not _left)
|
||||
_r, _h, _s, _left = _retry_run([{"ok": False, "dead": True}, {"ok": True}])
|
||||
check("retry: удалённая раздача не повторяется", _r.get("dead") and not _h and len(_left) == 1)
|
||||
|
||||
_sd = _P(_tmp.mkdtemp())
|
||||
_old_raw = _col.RAW
|
||||
try:
|
||||
_col.RAW = _sd
|
||||
check("settings: по умолчанию 3 вкладки; значение сохраняется и ограничивается 1–4",
|
||||
_col.settings()["tabs"] == 3)
|
||||
_col.save_settings({**_col.settings(), "tabs": 9, "limit": 50})
|
||||
_s = _col.settings()
|
||||
check("settings: сохранённые вкладки (9 -> 4), лимит и переменная DUB_TABS",
|
||||
_s["tabs"] == 4 and _s["limit"] == 50 and _col.os.environ["DUB_TABS"] == "4"
|
||||
and _col.clamp_tabs("x") == 3 and _col.clamp_tabs(0) == 1)
|
||||
finally:
|
||||
_col.RAW = _old_raw
|
||||
_col.os.environ["DUB_TABS"] = "3"
|
||||
check("is_dead: «нет раздачи» — удалена, обычная ошибка загрузки — нет",
|
||||
_col.is_dead({"ok": False, "error": "не открылась: нет раздачи: Торрент трекер"})
|
||||
and not _col.is_dead({"ok": False, "error": "не открылась: Just a moment"})
|
||||
and not _col.is_dead({"ok": True}))
|
||||
_dd = _P(_tmp.mkdtemp())
|
||||
_old_dead = _col.DEAD
|
||||
try:
|
||||
_col.DEAD = _dd / "dead.json"
|
||||
_col.mark_dead("kinozal", "18364907")
|
||||
_col.mark_dead("kinozal", "5")
|
||||
_col.mark_dead("nnm", "7")
|
||||
check("mark_dead: id запоминаются по трекерам и без дублей",
|
||||
_col.load_dead("kinozal") == {"18364907", "5"} and _col.load_dead("nnm") == {"7"}
|
||||
and _col.load_dead("rutracker") == set())
|
||||
finally:
|
||||
_col.DEAD = _old_dead
|
||||
check("has_body: пустой пост — не раздача",
|
||||
not _col.has_body("nnm", {"post": " "}) and _col.has_body("kinozal", {"release_tab": "Аудио"}))
|
||||
|
||||
import discover as _dc # noqa: E402
|
||||
|
||||
|
||||
def _picked(site, name):
|
||||
return bool(_dc.INCLUDE[site].search(name)) and not _dc.EXCLUDE.search(name)
|
||||
|
||||
|
||||
check("discover: rutracker берёт зарубежное кино, сериалы и годовые разделы",
|
||||
all(_picked("rutracker", n) for n in
|
||||
["Зарубежное кино (UHD Video)", "Зарубежные сериалы (HD Video)",
|
||||
"Фильмы 2021-2025", "Новинки и сериалы в стадии показа (HD Video)",
|
||||
"Классика мирового кинематографа"]))
|
||||
check("discover: rutracker не берёт наше кино, русские сериалы, музыку и Apple",
|
||||
not any(_picked("rutracker", n) for n in
|
||||
["Наше кино (HD Video)", "Русские сериалы (HD Video)",
|
||||
"Зарубежный Rock", "Фильмы HD для Apple TV", "Азиатские сериалы"]))
|
||||
check("discover: аниме-музыка (OST, Lossless, AMV, саундтреки) исключена, само аниме берётся",
|
||||
not any(_picked(site, n) for site in ("nnm", "rutracker") for n in
|
||||
["Аниме OST (Lossless)", "Аниме OST", "Саундтреки к аниме", "Аниме AMV и клипы", "Anime Music (MP3)"])
|
||||
and all(_picked("nnm", n) for n in ["Аниме (HD, FHD)", "Аниме (SD)"]))
|
||||
check("discover: nnm берёт форматы и годы, но не коллекции и оригиналы",
|
||||
all(_picked("nnm", n) for n in
|
||||
["Зарубежные Фильмы (UHD)", "Зарубежные Новинки (SD, DVD)", "Зарубежная Классика (DVD)",
|
||||
"Старые Зарубежные Фильмы (SD)", "Зарубежные Мультсериалы"])
|
||||
and not any(_picked("nnm", n) for n in
|
||||
["Зарубежное кино (коллекции / *логии)", "Фильмы в оригинале (SD, DVD)",
|
||||
"Отечественные Фильмы (HD, FHD, UHD)", "Зарубежное видео для устройств Apple"]))
|
||||
|
||||
check("discover: мультфильмы, мультсериалы и аниме берутся, русские мультфильмы нет",
|
||||
all(_picked("rutracker", n) for n in
|
||||
["Мультфильмы (HD Video)", "Мультсериалы (SD Video)", "Аниме (HD Video)", "Онгоинги (HD Video)"])
|
||||
and all(_picked("nnm", n) for n in
|
||||
["Зарубежные Мультфильмы 21-го века (SD)", "Аниме с озвучкой (HD)", "Мультфильмы (3D)"])
|
||||
and not any(_picked(s, n) for s, n in
|
||||
[("nnm", "Отечественные Мультфильмы 20-го века (SD)"),
|
||||
("rutracker", "Наши мультфильмы"), ("nnm", "Мультимедиа и Графика")]))
|
||||
_kz = [x["ref"] for x in _dc.kinozal_grid()]
|
||||
check("discover: kinozal — общие списки фильмы/сериалы/мульты/аниме и сетка без 4K до 2012",
|
||||
all(any(f"c={c}&v=0&d=0" in u for u in _kz) for c in ("1002", "46", "21", "20"))
|
||||
and not any("v=7" in u and int(u.split("d=")[1].split("&")[0]) < 2012 for u in _kz)
|
||||
and len(_kz) == len(set(_kz)))
|
||||
|
||||
_calls = []
|
||||
_orig = (_dc.candidate_forums, _dc.probe, _dc.time.sleep)
|
||||
try:
|
||||
_dc.candidate_forums = lambda cdp, site: [{"ref": "1", "name": "Зарубежное кино (HD Video)"},
|
||||
{"ref": "2", "name": "Зарубежное кино (DVD Video)"},
|
||||
{"ref": "3", "name": "Зарубежные сериалы (UHD Video)"},
|
||||
{"ref": "4", "name": "Фильмы 2026"}]
|
||||
_dc.probe = lambda cdp, site, ref: (_calls.append(ref), {"2": 50, "3": 2, "4": 0}.get(ref, 0))[1]
|
||||
_dc.time.sleep = lambda s: None
|
||||
_saved = {}
|
||||
_res = _dc.discover_forums(None, "rutracker", probes={"1": 47}, on_probe=lambda r, n: _saved.update({r: n}))
|
||||
finally:
|
||||
_dc.candidate_forums, _dc.probe, _dc.time.sleep = _orig
|
||||
_st = {e["ref"]: (e["status"], e["keep"]) for e in _res}
|
||||
check("discover: раздел из кэша проверок повторно не открывается",
|
||||
"1" not in _calls and _saved.get("1") is None and _st["1"] == ("ok", True), str(_calls))
|
||||
check("discover: статусы ok / мало / не проверен (оставляется), прогресс-колбэки",
|
||||
_st["2"] == ("ok", True) and _st["3"] == ("мало", False) and _st["4"] == ("?", True)
|
||||
and _saved == {"2": 50, "3": 2, "4": 0}, str((_st, _saved)))
|
||||
import ui as _ui # noqa: E402
|
||||
check("ui: таблица найденных разделов строится", _ui.sections_table("rutracker", _res) is not None
|
||||
and _ui.sections_table("kinozal", [{"ref": "https://kinozal.me/browse.php?s=&g=0&c=46&v=3&d=2020&w=0&t=0&f=0",
|
||||
"name": "2020, HD", "topics": None, "keep": True, "status": "ok"}]) is not None)
|
||||
|
||||
import retrain as _rt # noqa: E402
|
||||
|
||||
_cmp = {"rules": {"nnm": (300, .84, .93), "rutracker": (250, .86, .94), "kinozal": (12, .5, .6)},
|
||||
"old": {"nnm": (300, .64, .96), "rutracker": (250, .90, .95)},
|
||||
"good": {"nnm": (300, .85, .95), "rutracker": (250, .91, .95)},
|
||||
"badrec": {"nnm": (300, .90, .80), "rutracker": (250, .91, .95)}}
|
||||
check("verdict: precision выше и recall держится -> лучше", _rt.verdict(_cmp, "old", "good")[0])
|
||||
check("verdict: провал recall на трекере -> не лучше", not _rt.verdict(_cmp, "old", "badrec")[0])
|
||||
check("compare: разбор строки e2e",
|
||||
_rt.RE_CELL.findall("model: nnm n=301 P=0.637 R=0.961; kinozal n=12 P=0.5 R=0.75")
|
||||
== [("nnm", "301", "0.637", "0.961"), ("kinozal", "12", "0.5", "0.75")])
|
||||
|
||||
import meta as _mt # noqa: E402
|
||||
|
||||
_nnm = _mt.extract("nnm", {"id": "1", "title": (
|
||||
"Хрустальное озеро / Crystal Lake (2026) WEB-DL [H.265/2160p] [4K, HDR10, DV 8.1, 10-bit] "
|
||||
"(сезон 1, серии 1-8 из 8) Novamedia, HDRezka"), "post": (
|
||||
"Производство: США, A24, Peacock\nЖанр: ужасы, триллер, драма\n"
|
||||
"Режиссер: Логан Джордж, Селин Хелд\nАктеры: Линда Карделлини, Уильям Кэтлетт\n"
|
||||
"Возраст: 18+ (зрителям, достигшим 18 лет)\nРелиз от: kupec\n"
|
||||
"Продолжительность: ~00:56:00 серия\nКачество видео: WEB-DL\n"
|
||||
"Язык озвучки: Русский, Английский\nСубтитры: Русские (Forced), Английские\n"
|
||||
"Видео: HEVC/H.265, 3840x2160, ~15.0 Mbps\n"
|
||||
"Аудио 1: AC3, 6 ch, 384 Kbps - Русский (DUB, Novamedia) серии 1-8\n"
|
||||
"Аудио 2: AC3, 2 ch, 384 Kbps - Русский (MVO, HDRezka Studio 18+) серии 1\n"
|
||||
"Аудио 3: E-AC3 JOC, 6 ch, 640 Kbps - Английский\n")})
|
||||
_t = _json.loads(_nnm["audio_tracks"])
|
||||
check("meta nnm: сезон, серии, разрешение, кодек, HDR, возраст",
|
||||
(_nnm["season"], _nnm["episodes"], _nnm["episodes_total"], _nnm["resolution"],
|
||||
_nnm["video_codec"], _nnm["age_limit"], _nnm["is_series"], _nnm["kind"])
|
||||
== ("1", "1-8", "8", "2160p", "HEVC", "18", True, "series") and "HDR10" in _nnm["hdr"]
|
||||
and "DV" in _nnm["hdr"], str({k: _nnm[k] for k in ("season", "episodes", "hdr", "kind")}))
|
||||
check("meta nnm: дорожки — язык, кодек, каналы, битрейт, тип, серии, 18+",
|
||||
len(_t) == 3 and (_t[0]["lang"], _t[0]["codec"], _t[0]["channels"], _t[0]["bitrate_kbps"],
|
||||
_t[0]["tag"], _t[0]["episodes"]) == ("RUS", "AC3", "6", 384, "DUB", "1-8")
|
||||
and _t[1]["is_18plus"] and _t[2]["lang"] == "ENG" and _t[2]["codec"].startswith("E-AC3"), str(_t))
|
||||
check("meta nnm: субтитры, forced, языки озвучки",
|
||||
_nnm["sub_langs"] == "RUS+ENG" and _nnm["sub_forced"] and _nnm["audio_langs"] == "RUS+ENG")
|
||||
|
||||
_rt = _mt.extract("rutracker", {"id": "2", "title": (
|
||||
"Отрочество / Boyhood (Ричард Линклейтер) [2014, США, драма, BDRip 720p] Dub (НТВ+) + Sub Rus"), "post": (
|
||||
"Страна: США\nЖанр: Драма\nГод выпуска: 2014\nПродолжительность: 02:45:16\n"
|
||||
"Оригинальная аудиодорожка: английский\nКонтейнер: MKV\n"
|
||||
"Видео: x264@L4.1, 1280x692, 10.7 Mbps, 23.976 fps.\n"
|
||||
"Аудио 1: 48 kHz, AC3, 3/2 (L,C,R,l,r) + LFE ch, 384.00 kbps\n"
|
||||
"Аудио 2: 48 kHz, DTS, 3/2 (L,C,R,l,r) + LFE ch, 1510 kbps avg | Одноголосый закадровый, А. Дасевич\n"
|
||||
"Аудио 3: 48 kHz, DTS, 2/0 ch, 1510 kbps | Original\n")})
|
||||
_tr = _json.loads(_rt["audio_tracks"])
|
||||
check("meta rutracker: год, контейнер, 720p/AVC, длительность, оригинал",
|
||||
(_rt["year"], _rt["container"], _rt["resolution"], _rt["video_codec"], _rt["duration_min"],
|
||||
_rt["orig_audio"]) == ("2014", "MKV", "720p", "AVC", 165, "ENG"), str(_rt))
|
||||
check("meta rutracker: схема каналов 3/2+LFE = 6, тип VO, Original",
|
||||
_tr[0]["channels"] == "6" and _tr[1]["tag"] == "AVO" and _tr[2]["tag"] == "ORIG", str(_tr))
|
||||
|
||||
_kz = _mt.extract("kinozal", {"id": "3", "h1": "Гран-при / Grand Prix / 1966 / ДБ, ПМ / BDRip (1080p)",
|
||||
"release_tab": ("Жанр: Драма\nКачество: BDRip (720p)\n"
|
||||
"Видео: MPEG-4 AVC, 5200 Кбит/с, 1280x580, 23.976 кадр/с\n"
|
||||
"Аудио: Русский (AC3, 2 ch, 256 Кбит/с), (DTS, 6 ch, 1509 Кбит/с), "
|
||||
"английский (DTS, 6 ch, 1509 Кбит/с)\nРазмер: 10.66 ГБ\n"
|
||||
"Субтитры: Русские, английские")})
|
||||
_tk = _json.loads(_kz["audio_tracks"])
|
||||
check("meta kinozal: однострочное «Аудио» разбирается на дорожки с языками",
|
||||
[(x["lang"], x["codec"], x["channels"], x["bitrate_kbps"]) for x in _tk]
|
||||
== [("RUS", "AC3", "2", 256), ("RUS", "DTS", "6", 1509), ("ENG", "DTS", "6", 1509)], str(_tk))
|
||||
check("meta kinozal: размер, качество, год, тип по разделу",
|
||||
_kz["size_gb"] == 10.66 and _kz["year"] == "1966" and _kz["resolution"] == "720p"
|
||||
and _mt.kind_of("Аниме (HD Video)", "", "", False) == "anime"
|
||||
and _mt.kind_of("Зарубежные Мультфильмы", "", "", False) == "cartoon")
|
||||
|
||||
_d = _P(_tmp.mkdtemp())
|
||||
_old = (_col.RAW, _col.PAGES, _col.LABELS, _col.POSTONLY, _col.META)
|
||||
try:
|
||||
_col.RAW, _col.PAGES, _col.LABELS, _col.POSTONLY, _col.META = (
|
||||
_d / "raw", _d / "pages", _d / "labels.csv", _d / "po.txt", _d / "meta.csv")
|
||||
_col.LABELS.write_text("", encoding="utf-8")
|
||||
(_col.RAW / "nnm").mkdir(parents=True)
|
||||
_post = ("Год выпуска: 2020\nАудио 1: Opus, 6 ch, 221 Kbps | Русский AVO (Дмитрий Пучков)\n"
|
||||
"Аудио 2: AC3, 6 ch, 448 Kbps | Русский MVO (LostFilm)\n")
|
||||
(_col.RAW / "nnm" / "777.json").write_text(_json.dumps(
|
||||
{"id": "777", "source": "nnm", "ok": True, "title": "Фильм (2020) BDRip",
|
||||
"post": _post}, ensure_ascii=False), encoding="utf-8")
|
||||
(_col.RAW / "nnm" / "777.html").write_text(
|
||||
f"<html><body><h1>Фильм (2020) BDRip</h1><div class='postbody'>"
|
||||
f"{_post.replace(chr(10), '<br>')}</div></body></html>", encoding="utf-8")
|
||||
_st = _col.cmd_build()
|
||||
check("build: страница раздачи nnm записана с префиксом",
|
||||
(_col.PAGES / "nnm-777.txt").exists(), str(_st))
|
||||
with _col.LABELS.open(encoding="utf-8", newline="") as _f:
|
||||
_rows = list(_csv.reader(_f))
|
||||
check("build: метки добавлены с release_id=nnm-777", len(_rows) >= 3
|
||||
and _rows[0][1] == "release_id" and all(r[1] == "nnm-777" for r in _rows[1:]),
|
||||
str(_rows[:2]))
|
||||
_before = _col.LABELS.read_text(encoding="utf-8")
|
||||
_calls_x = []
|
||||
_orig_x = _col.metamod.extract
|
||||
_col.metamod.extract = lambda *a, **k: (_calls_x.append(1), _orig_x(*a, **k))[1]
|
||||
try:
|
||||
_st2 = _col.cmd_build()
|
||||
check("build идемпотентен: повторный запуск не дублирует метки",
|
||||
_col.LABELS.read_text(encoding="utf-8") == _before)
|
||||
check("build инкрементален: неизменённые страницы и meta не разбираются повторно",
|
||||
_st2["unchanged"] == 1 and _st2["meta_reused"] == 1 and not _calls_x, str(_st2))
|
||||
(_col.RAW / "nnm" / "777.json").touch() # файл изменился -> разбирается заново
|
||||
_st3 = _col.cmd_build()
|
||||
check("build: изменённый файл разбирается заново, остальное из кэша",
|
||||
_st3["unchanged"] == 0 and len(_calls_x) == 1, str(_st3))
|
||||
_col.cmd_build(rewrite=True)
|
||||
check("build: rewrite=True игнорирует кэши", len(_calls_x) == 2)
|
||||
finally:
|
||||
_col.metamod.extract = _orig_x
|
||||
check("build: meta.csv содержит релиз с префиксным id",
|
||||
"nnm-777" in _col.META.read_text(encoding="utf-8-sig"))
|
||||
_good = _col.LABELS.read_text(encoding="utf-8")
|
||||
_col.LABELS.write_text(_good.replace("LostFilm", "СТАРАЯ_РАЗМЕТКА"), encoding="utf-8")
|
||||
_st4 = _col.cmd_build(rewrite=True)
|
||||
check("build без relabel не трогает размеченные релизы", "СТАРАЯ_РАЗМЕТКА" in _col.LABELS.read_text(encoding="utf-8"))
|
||||
_st5 = _col.cmd_build(rewrite=True, relabel=True)
|
||||
_now = _col.LABELS.read_text(encoding="utf-8-sig")
|
||||
check("relabel: метки пересчитаны из сохранённого HTML, дублей нет",
|
||||
"СТАРАЯ_РАЗМЕТКА" not in _now and _now.count("nnm-777") == _good.count("nnm-777"), str(_st5))
|
||||
check("relabel: копия прежних меток сохранена", bool(list(_d.glob("labels.bak_*.csv"))))
|
||||
finally:
|
||||
_col.RAW, _col.PAGES, _col.LABELS, _col.POSTONLY, _col.META = _old
|
||||
|
||||
|
||||
print("== 18+ из строк «Аудио» ==")
|
||||
_h = open("data/samples/rt-topic-6894333.html", encoding="utf-8").read()
|
||||
_pt = html_to_text(_h)
|
||||
_res = {"tracks": [{"segment": "Профессиональный (многоголосый закадровый) HDRezka", "studio": "HDrezka",
|
||||
"translation_type": "проф. многоголосый", "is_18plus": False, "source": "page"},
|
||||
{"segment": "Профессиональный (многоголосый закадровый) LostFilm", "studio": "LostFilm",
|
||||
"translation_type": "проф. многоголосый", "is_18plus": False, "source": "page"}]}
|
||||
from src.audio18 import apply_audio_18 # noqa: E402
|
||||
_res = apply_audio_18(_res, _pt)
|
||||
_hd = [t for t in _res["tracks"] if t["studio"] == "HDrezka"]
|
||||
check("HDrezka: две дорожки, обычная и 18+", sorted(t["is_18plus"] for t in _hd) == [False, True], str(_hd))
|
||||
check("LostFilm без 18+", not any(t["is_18plus"] for t in _res["tracks"] if t["studio"] == "LostFilm"))
|
||||
check("общий флаг 18+ у релиза", _res["is_18plus"] is True)
|
||||
import sys as _s, os as _o # noqa: E402
|
||||
_s.path.insert(0, _o.path.join("tools", "harvest"))
|
||||
import harvest_core as _hc # noqa: E402
|
||||
_rows, _ = _hc.rt_parse({"id": "6894333", "title": "Джек Ричер / Reacher / Сезон 4 [2026, WEB-DL 1080p]",
|
||||
"post": "\n".join(l for l in _pt.splitlines() if l.startswith(("Перевод", "Аудио")))})
|
||||
_hr = [r for r in _rows if r["studio"] == "HDrezka"]
|
||||
check("метки rt_parse: HDrezka обычная + 18+", sorted(int(r["age_18"]) for r in _hr) == [0, 1], str(_hr))
|
||||
check("метки rt_parse: кодек и битрейт из строки «Аудио»",
|
||||
all(r["codec"] == "AC3" and r["kbps"] == "192" for r in _hr), str(_hr))
|
||||
check("метки rt_parse: 5 студий + 1 доп. 18+", len(_rows) == 6, str(len(_rows)))
|
||||
|
||||
_sf = __import__("src.release", fromlist=["x"]).series_info("Сериал / Сезон 1 / Серии 1-8 из 8 [2026]", [
|
||||
{"studio": "LostFilm", "episodes": "1-4", "episode_ranges": [[1, 4]]},
|
||||
{"studio": "HDrezka", "episodes": None, "episode_ranges": []}])
|
||||
check("серии не указаны -> full_release", _sf["full_release"] == ["HDrezka"], str(_sf))
|
||||
|
||||
import json as _js2 # noqa: E402
|
||||
sys.path.insert(0, "tools")
|
||||
import meta as _meta # noqa: E402
|
||||
_mm = _meta.extract("rutracker", {"id": "6894333", "post": _pt[:6000],
|
||||
"title": "Джек Ричер / Reacher / Сезон 4 / Серии 1-8 из 8 [2026, WEB-DL 1080p]"}, page_text=_pt)
|
||||
_at = _js2.loads(_mm["audio_tracks"])
|
||||
check("meta: 7 аудиодорожек rutracker-вида «Аудио RUS: …»", _mm["n_audio"] == 7, str(_mm["n_audio"]))
|
||||
check("meta: HDrezka обычная и 18+, кодек AC3, тип MVO из «Перевод»",
|
||||
sorted(a["is_18plus"] for a in _at if a["studio"] == "HDrezka") == [False, True]
|
||||
and all(a["codec"] == "AC3" and a["tag"] == "MVO" for a in _at if a["studio"]))
|
||||
|
||||
print("== разнообразная выборка ==")
|
||||
import random as _rnd # noqa: E402
|
||||
import pathlib as _pl, tempfile as _tf # noqa: E402
|
||||
import diversity as _dv # noqa: E402
|
||||
check("год из скобок заголовка", _dv.year_of("Матрица / The Matrix (1999) BDRip 1080p") == 1999
|
||||
and _dv.year_of("Сериал / Season 2 [2021, WEB-DL 2160p]") == 2021 and _dv.year_of("Без года") is None)
|
||||
check("корзина: десятилетие до 2010, дальше по годам",
|
||||
_dv.year_band(1994) == "1990-е" and _dv.year_band(2023) == "2023" and _dv.year_band(None) == "?")
|
||||
check("качество и сериал в заголовке", _dv.bucket("Сериал (2021) Сезон 1 WEB-DL 2160p") == ("2021", "4K", "сериал")
|
||||
and _dv.bucket("Фильм (2005) DVDRip")[1] == "SD")
|
||||
_items = [{"id": str(i), "text": f"Фильм {i} / Film ({2024}) WEB-DL 1080p"} for i in range(8)] + [{"id": "old", "text": "Старое кино / Old (1975) DVDRip"}]
|
||||
_cnt = _dv.counts_from_meta(_pl.Path("нет_такого_файла.csv"))
|
||||
_cnt[_dv.bucket(_items[0]["text"])] = 500 # свежие 1080p уже в избытке
|
||||
_got = _dv.pick(_items, 1, _cnt, _rnd.Random(1))
|
||||
check("pick: берётся тема из недопредставленной корзины (старое кино)", _got[0]["id"] == "old", str(_got))
|
||||
_vis: set[int] = set()
|
||||
_rng = _rnd.Random(3)
|
||||
_order = []
|
||||
for _ in range(40):
|
||||
_nx = _dv.next_pages(40, _vis, 1, _rng)
|
||||
_order += _nx
|
||||
_vis |= set(_nx)
|
||||
check("next_pages: все 40 страниц ровно по разу, не подряд", sorted(_order) == list(range(40)) and _order[:5] != [0, 1, 2, 3, 4], str(_order[:8]))
|
||||
check("next_pages: когда всё посещено — пусто", _dv.next_pages(5, set(range(5)), 1, _rng) == [])
|
||||
|
||||
_tmp_meta = _pl.Path(_tf.mkdtemp()) / "meta.csv"
|
||||
_tmp_meta.write_text(chr(10).join(['source,title,year,quality,is_series', 'nnm,A,2020,WEB-DL (2160p),False', 'nnm,B,2020,WEB-DL (2160p),False', 'rutracker,C,1999,DVD,True']) + chr(10), encoding="utf-8")
|
||||
_c2 = _dv.counts_from_meta(_tmp_meta, "nnm")
|
||||
check("counts_from_meta: считает корзины нужного трекера", _c2[("2020", "4K", "фильм")] == 2 and sum(_c2.values()) == 2, str(_c2))
|
||||
|
||||
# раздел вразброс: страницы с годами, глубина 20, берутся разные страницы и недопредставленные годы
|
||||
_calls = []
|
||||
_pages = {p: [{"id": f"{p}{k}", "text": f"Фильм {p}{k} / F ({2025 - p}) WEB-DL 1080p"} for k in range(5)] for p in range(20)}
|
||||
_o_li, _o_hi, _o_depth, _o_un = _col.list_items, _col.harvest_ids, _col.hv.list_depth, _col.update_state
|
||||
_col.list_items = lambda cdp, site, ref, page: (True, _pages.get(page, []))
|
||||
_col.hv.list_depth = lambda cdp: 20
|
||||
_col.update_state = lambda *a, **k: None
|
||||
_o_ls = _col.load_state
|
||||
_col.load_state = lambda: {} # состояние реального сбора не должно влиять на тест
|
||||
_col.harvest_ids = lambda cdps, site, ids, limit, delay, stop, section="": (_calls.append(list(ids)), len(ids))[1]
|
||||
try:
|
||||
_col.ui.get_stats("rutracker").begin(30)
|
||||
_n = _col.collect_ref_balanced("rutracker", "313", None, type("T", (), {"cdps": [None]})(), set(),
|
||||
_dv.Counter(), 12, 100, 0.0, __import__("threading").Event(), 3, _rnd.Random(5))
|
||||
_years = {int(i[0] if len(i) == 2 else i[:2]) for c in _calls for i in c}
|
||||
check("balanced: набран лимит раздела по 3 раздачи со страницы", _n == 12 and all(len(c) <= 3 for c in _calls), str(_calls))
|
||||
check("balanced: взяты разные страницы, а не первые подряд", len({c[0][:-1] for c in _calls}) == len(_calls)
|
||||
and {c[0][:-1] for c in _calls} != {"0", "1", "2", "3"}, str(_calls))
|
||||
finally:
|
||||
_col.list_items, _col.harvest_ids, _col.hv.list_depth, _col.update_state = _o_li, _o_hi, _o_depth, _o_un
|
||||
_col.load_state = _o_ls
|
||||
|
||||
print("== читаемый HTML ==")
|
||||
_old3 = _col.RAW
|
||||
_rt3 = _pl.Path(_tf.mkdtemp())
|
||||
try:
|
||||
_col.RAW = _rt3
|
||||
(_rt3 / "nnm").mkdir()
|
||||
_hp = _rt3 / "nnm" / "1.html"
|
||||
_hp.write_text("<html><head><title>Т</title></head><body>Привет</body></html>", encoding="utf-8")
|
||||
_mt = _hp.stat().st_mtime_ns
|
||||
_r1 = _col.cmd_repair_html(with_css=False)
|
||||
_txt = _hp.read_text(encoding="utf-8")
|
||||
check("repair-html: добавлены UTF-8 и base, содержимое цело",
|
||||
'<meta charset="utf-8">' in _txt and "<base href=" in _txt and "Привет" in _txt and _r1["fixed"] == 1, _txt[:120])
|
||||
check("repair-html: время файла не изменилось (кэши разбора остаются)", _hp.stat().st_mtime_ns == _mt)
|
||||
check("repair-html: повторный запуск ничего не меняет", _col.cmd_repair_html(with_css=False)["fixed"] == 0)
|
||||
check("store_css: таблица стилей пишется один раз по хешу",
|
||||
_col.store_css("a{color:red}") == _col.store_css("a{color:red}") and len(list((_rt3 / "_css").glob("*.css"))) == 1)
|
||||
check("snapshot.js читается и содержит перенос стилей и удаление скриптов",
|
||||
"@@CSS" in _col.SNAPSHOT_JS and "script" in _col.SNAPSHOT_JS)
|
||||
finally:
|
||||
_col.RAW = _old3
|
||||
|
||||
print("== язык и строки «Аудио» ==")
|
||||
from src import trackinfo as _ti # noqa: E402
|
||||
_kz_txt = ("Аудио 1: AC3, 6 ch, 448 Кбит/с - Русский дубляж (Movie Dubbing) - официальный дубляж для СНГ\n"
|
||||
"Аудио 3: E-AC3, 6 ch, 640 Кбит/с - Украинский дубляж (LeDoyen)\n"
|
||||
"Аудио 4: AC3, 6 ch, 640 Кбит/с - Английский\n")
|
||||
_ls = _ti.audio_lines_all(_kz_txt)
|
||||
check("trackinfo: язык, кодек, студия, официальность строки kinozal",
|
||||
[l["lang"] for l in _ls] == ["RUS", "UKR", "ENG"] and _ls[0]["names"] == ["Movie Dubbing"]
|
||||
and _ls[0]["codec"] == "AC3" and _ls[0]["kbps"] == "448" and _ls[0]["official"], str(_ls[0]))
|
||||
_rt_ls = _ti.audio_lines_all(_pt)
|
||||
check("trackinfo: rutracker «Аудио RUS: АС3 … |HDrezka| 18+» — обе строки HDrezka",
|
||||
len(_ti.match_audio("Профессиональный (многоголосый закадровый) HDRezka", _rt_ls)) == 2
|
||||
and _ti.context(_rt_ls[1]) == "[RUS AC3 2ch 192kbps 18+]", _ti.context(_rt_ls[1]))
|
||||
check("trackinfo: «Аудиокниги» и меню — не дорожки", _ti.audio_lines_all("Аудиокниги и аудио\nАудио и видео") == [])
|
||||
check("trackinfo: роль дорожки", _ti.role_of(_ls[0]) == "перевод" and _ti.role_of(_rt_ls[-1]) == "оригинал")
|
||||
check("model_text: без строки «Аудио» вход не меняется", _ti.model_text("Дублированный", None) == "Дублированный")
|
||||
|
||||
_kz_rows, _ = _hc.kz_parse({"id": "9", "page_title": "Фильм / Film / 2025 / ДБ :: Кинозал",
|
||||
"release_tab": _kz_txt, "tech_tab": ""})
|
||||
check("kinozal: формат «Аудио N … - Русский дубляж (Студия)» даёт метки со студией, типом и языком",
|
||||
[(r["studio"], r["lang"], r["translation_type"]) for r in _kz_rows] ==
|
||||
[("Movie Dubbing", "RUS", "проф. дубляж"), ("LeDoyen", "UKR", "проф. дубляж")], str(_kz_rows))
|
||||
check("метки rt_parse: lang и audio_raw из строки «Аудио»",
|
||||
all(r["lang"] == "RUS" and r["audio_raw"] for r in _hr), str(_hr[:1]))
|
||||
|
||||
import csv as _csv2 # noqa: E402
|
||||
_old4 = (_col.LABELS,)
|
||||
_tl = _pl.Path(_tf.mkdtemp()) / "labels.csv"
|
||||
try:
|
||||
_col.LABELS = _tl
|
||||
_hdr = [f for f in _hc.FIELDS if f not in ("lang", "audio_raw")]
|
||||
with _tl.open("w", encoding="utf-8-sig", newline="") as _f:
|
||||
_w = _csv2.DictWriter(_f, fieldnames=_hdr)
|
||||
_w.writeheader()
|
||||
_w.writerow({**{k: "" for k in _hdr}, "source": "nnm", "release_id": "1", "studio": "X"})
|
||||
check("labels.csv: старая шапка переписывается на новую", _col.migrate_labels() is True)
|
||||
check("labels.csv: повторная миграция ничего не делает", _col.migrate_labels() is False)
|
||||
_got2 = _col.read_labels()
|
||||
check("labels.csv: данные целы, новые колонки пустые",
|
||||
len(_got2) == 1 and _got2[0]["studio"] == "X" and _got2[0]["lang"] == "")
|
||||
with _tl.open("a", encoding="utf-8", newline="") as _f: # дописывание строк как в cmd_build
|
||||
_csv2.DictWriter(_f, fieldnames=_hc.FIELDS, extrasaction="ignore").writerow(
|
||||
{**{k: "" for k in _hc.FIELDS}, "release_id": "2", "lang": "UKR", "raw": "r"})
|
||||
check("labels.csv: дописанная строка не сдвигает колонки",
|
||||
_col.read_labels()[1]["lang"] == "UKR" and _col.read_labels()[1]["release_id"] == "2")
|
||||
finally:
|
||||
(_col.LABELS,) = _old4
|
||||
|
||||
_mk = lambda **k: {"segment": "s", "translation_type": "проф. дубляж", "studio": "Студия", # noqa: E731
|
||||
"is_18plus": False, **k}
|
||||
_agg = aggregate([_mk(lang="RUS"), _mk(lang="UKR"), _mk(lang=None)])
|
||||
check("aggregate: русский и украинский дубляж одной студии — две дорожки; без языка сливается",
|
||||
sorted(t["lang"] for t in _agg["tracks"]) == ["RUS", "UKR"], str(_agg["tracks"]))
|
||||
_agg2 = aggregate([_mk(_audio18=False), _mk(_audio18=True, is_18plus=True), _mk()])
|
||||
check("aggregate: обычная и 18+ дорожки одной студии раздельны, из заголовка — к обычной",
|
||||
len(_agg2["tracks"]) == 2 and sorted(t["is_18plus"] for t in _agg2["tracks"]) == [False, True], str(_agg2["tracks"]))
|
||||
|
||||
import torch as _torch # noqa: E402
|
||||
from src.model import DubTagger as _DT, load_model as _lm, save_model as _sm # noqa: E402
|
||||
try:
|
||||
_bb = "cointegrated/rubert-tiny2"
|
||||
_tiny = _DT(_bb, n_types=3, n_langs=4)
|
||||
_ids = _torch.randint(5, 100, (2, 8))
|
||||
_out = _tiny(_ids, _torch.ones_like(_ids), type_labels=_torch.tensor([0, 1]),
|
||||
lang_labels=_torch.tensor([1, -100]), return_lang=True)
|
||||
check("модель: языковая голова даёт логиты [batch, n_langs], лосс конечен",
|
||||
_out[4].shape == (2, 4) and bool(_torch.isfinite(_out[0])))
|
||||
_out0 = _tiny(_ids, _torch.ones_like(_ids), type_labels=_torch.tensor([0, 1]),
|
||||
lang_labels=_torch.tensor([-100, -100]), return_lang=True)
|
||||
check("модель: все языки неизвестны — лосс языка не входит (нет NaN)", bool(_torch.isfinite(_out0[0])))
|
||||
_legacy = _DT(_bb, n_types=3)
|
||||
check("модель без языковой головы: прежний интерфейс и lang=None",
|
||||
len(_legacy(_ids, _torch.ones_like(_ids))) == 4 and _legacy(_ids, _torch.ones_like(_ids), return_lang=True)[4] is None)
|
||||
_d = _pl.Path(_tf.mkdtemp())
|
||||
_sm(_legacy, __import__("transformers").AutoTokenizer.from_pretrained(_bb), _d)
|
||||
check("чекпоинт без языковой головы загружается (n_langs=0)", _lm(_d).n_langs == 0)
|
||||
except Exception as _e: # noqa: BLE001
|
||||
print(f" пропущено (нет {_bb}): {_e}")
|
||||
|
||||
import subprocess as _sp, json as _js3, sys as _sys3 # noqa: E402
|
||||
_wd = _pl.Path(_tf.mkdtemp())
|
||||
_rows3 = []
|
||||
for _i in range(30):
|
||||
_rows3.append({"source": "kinozal", "release_id": f"r{_i}", "studio": f"Студия{_i % 5}", "translation_type": "проф. дубляж",
|
||||
"raw": f"Русский дубляж (Студия{_i % 5})", "age_18": 0, "lang": "RUS",
|
||||
"audio_raw": "AC3, 6 ch, 448 Кбит/с - Русский дубляж (Студия%d)" % (_i % 5)})
|
||||
_rows3.append({"source": "kinozal", "release_id": f"u{_i}", "studio": f"Студия{_i % 5}", "translation_type": "проф. дубляж",
|
||||
"raw": f"Украинский дубляж (Студия{_i % 5})", "age_18": 0, "lang": "UKR",
|
||||
"audio_raw": "AC3, 6 ch, 640 Кбит/с - Украинский дубляж (Студия%d)" % (_i % 5)})
|
||||
_rows3.append({"source": "nnm", "release_id": "p1", "studio": "(не указана)", "translation_type": "проф. дубляж",
|
||||
"raw": "Дублированный", "age_18": 0, "lang": "POL", "audio_raw": ""})
|
||||
with (_wd / "l.csv").open("w", encoding="utf-8-sig", newline="") as _f:
|
||||
_w = _csv2.DictWriter(_f, fieldnames=_hc.FIELDS, restval="")
|
||||
_w.writeheader()
|
||||
_w.writerows(_rows3)
|
||||
_pr = _sp.run([_sys3.executable, "-c",
|
||||
f"import sys; sys.path.insert(0, r'{_o.getcwd()}'); from src.prepare_data import main; main()",
|
||||
"--csv", str(_wd / "l.csv"), "--out", str(_wd / "out")], cwd=_wd, capture_output=True, text=True,
|
||||
encoding="utf-8", env={**_o.environ, "PYTHONUTF8": "1"})
|
||||
check("prepare_data с языком выполняется", _pr.returncode == 0, (_pr.stderr or "")[-300:])
|
||||
_ds = _js3.loads((_wd / "out" / "dataset.json").read_text(encoding="utf-8"))
|
||||
_lm2 = _js3.loads((_wd / "out" / "label_maps.json").read_text(encoding="utf-8"))
|
||||
check("prepare_data: классы языка (редкий POL отброшен), флаг audio_context",
|
||||
_lm2["langs"] == ["RUS", "UKR"] and _lm2["audio_context"] is True, str(_lm2))
|
||||
check("prepare_data: вход модели несёт строку «Аудио», спан автора по-прежнему в чистом тексте",
|
||||
any(r["text"].endswith("[UKR AC3 6ch 640kbps]") for r in _ds)
|
||||
and all(r["text"][r["span"][0]:r["span"][1]].startswith("Студия") for r in _ds if r["span"]))
|
||||
check("prepare_data: у редкого языка метка пустая", [r["lang"] for r in _ds if r["release_id"] == "p1"] == [None])
|
||||
|
||||
print("== зависания сбора ==")
|
||||
import json as _js5, threading as _th5, time as _tm5 # noqa: E402
|
||||
|
||||
|
||||
class _FakeCDP:
|
||||
def __init__(self, state):
|
||||
self.state, self.calls, self.aborted, self.reconnected = state, 0, 0, 0
|
||||
|
||||
def send(self, *a, **k):
|
||||
return {}
|
||||
|
||||
def eval(self, expr, await_promise=True):
|
||||
self.calls += 1
|
||||
return _js5.dumps(self.state) if "JSON.stringify" in expr else "короткая страница"
|
||||
|
||||
def abort(self):
|
||||
self.aborted += 1
|
||||
|
||||
def reconnect(self):
|
||||
self.reconnected += 1
|
||||
|
||||
|
||||
_t0 = _tm5.time()
|
||||
_res = _hc._goto_once(_FakeCDP({"t": "Тема перенесена", "r": "complete", "len": 300}), "http://x", min_len=900, timeout=45)
|
||||
check("короткая загруженная страница принимается за ~5 с, а не ждёт таймаут 45 с",
|
||||
_res.get("ok") and _res.get("short") and _tm5.time() - _t0 < 15, f"{_res} {_tm5.time() - _t0:.1f}")
|
||||
_res2 = _hc._goto_once(_FakeCDP({"t": "Just a moment...", "r": "complete", "len": 300}), "http://x", min_len=900, timeout=7)
|
||||
check("короткая страница проверки Cloudflare за готовую не считается", _res2.get("timeout"), str(_res2))
|
||||
|
||||
_old5 = (_col.fetch_release, _col.HARD_LIMIT_S, _col.SLOW_S, _col.mark_dead)
|
||||
try:
|
||||
_col.HARD_LIMIT_S, _col.SLOW_S = 0.6, 0.3
|
||||
_col.fetch_release = lambda cdp, site, rid: (_tm5.sleep(1.2), {"ok": False, "error": "x"})[1]
|
||||
_fc = _FakeCDP({})
|
||||
_col.ui.get_stats("rutracker").begin(5)
|
||||
_col.harvest_ids([_fc], "rutracker", ["111"], 5, 0.0, _th5.Event())
|
||||
check("сторож: зависшая раздача — вызов оборван, вкладка переподключена",
|
||||
_fc.aborted == 1 and _fc.reconnected == 1, f"abort={_fc.aborted} reconnect={_fc.reconnected}")
|
||||
_col.HARD_LIMIT_S = 30
|
||||
_fc2 = _FakeCDP({})
|
||||
_col.harvest_ids([_fc2], "rutracker", ["112"], 5, 0.0, _th5.Event())
|
||||
check("сторож не трогает нормальную (медленную, но завершившуюся) раздачу", _fc2.reconnected == 0)
|
||||
finally:
|
||||
_col.fetch_release, _col.HARD_LIMIT_S, _col.SLOW_S, _col.mark_dead = _old5
|
||||
|
||||
print("== параллельная переиндексация ==")
|
||||
import subprocess as _sp6 # noqa: E402
|
||||
_pc = _sp6.run([sys.executable, "tests/par_check.py"], capture_output=True, text=True, encoding="utf-8",
|
||||
env={**__import__("os").environ, "PYTHONUTF8": "1"})
|
||||
_line = next((l for l in _pc.stdout.splitlines() if l.startswith("RESULT ")), "")
|
||||
_pr6 = _json.loads(_line[7:]) if _line else {}
|
||||
check("параллельная сборка (процессы) даёт те же метки, страницы и meta, что и последовательная",
|
||||
_pr6.get("same") is True and _pr6.get("pages") == 50 and _pr6.get("labeled") == [50, 50], (_pc.stderr or _pc.stdout)[-300:])
|
||||
check("worker_count: значение по умолчанию в пределах 1–12", 1 <= _col.worker_count() <= 12)
|
||||
|
||||
print("== обучение по ссылкам ==")
|
||||
check("ссылка rutracker", _col.parse_link("https://rutracker.org/forum/viewtopic.php?t=6894333") == ("rutracker", "6894333"))
|
||||
check("ссылка nnm", _col.parse_link("https://nnmclub.to/forum/viewtopic.php?p=1&t=77") == ("nnm", "77"))
|
||||
check("ссылка kinozal", _col.parse_link("https://kinozal.me/details.php?id=2155882") == ("kinozal", "2155882"))
|
||||
check("не ссылка на раздачу", _col.parse_link("https://rutracker.org/forum/viewforum.php?f=313") is None)
|
||||
import tempfile as _tf, pathlib as _pl # noqa: E402
|
||||
_old2 = (_col.PAGES, _col.LABELS, _col.MANUAL, _col.RAW)
|
||||
_tmp = _pl.Path(_tf.mkdtemp())
|
||||
try:
|
||||
_col.PAGES, _col.LABELS, _col.MANUAL, _col.RAW = _tmp / "pages", _tmp / "labels.csv", _tmp / "manual_ids.txt", _tmp / "raw"
|
||||
_rows2 = [dict(_r, age_18=0) for _r in _rows[:2]]
|
||||
_stem = _col.store_manual("rutracker", {"id": "6894333"}, _rows2, "текст страницы")
|
||||
check("ручные метки: страница и id записаны",
|
||||
(_col.PAGES / f"{_stem}.txt").exists() and _stem in _col.MANUAL.read_text(encoding="utf-8").split())
|
||||
_lab = _col.read_labels()
|
||||
check("ручные метки: строки помечены manual", len(_lab) == 2 and all(r["studio_source"] == "manual" for r in _lab))
|
||||
_col.store_manual("rutracker", {"id": "6894333"}, _rows2[:1], "текст")
|
||||
check("повторная запись заменяет строки релиза", len(_col.read_labels()) == 1)
|
||||
from src.manual import manual_ids # noqa: E402
|
||||
check("manual_ids читает файл", manual_ids(_col.MANUAL) == {_stem})
|
||||
finally:
|
||||
_col.PAGES, _col.LABELS, _col.MANUAL, _col.RAW = _old2
|
||||
|
||||
print("== реестр моделей и сравнение (src/runs.py) ==")
|
||||
import json as _json # noqa: E402
|
||||
import subprocess as _sp7 # noqa: E402
|
||||
import tempfile as _tf7 # noqa: E402
|
||||
|
||||
from src import runs as _runs # noqa: E402
|
||||
from src.train import DEFAULT_MODELS, MODEL_CONFIGS # noqa: E402
|
||||
|
||||
check("plain_text: снимает хвост из поля tail",
|
||||
_runs.plain_text({"text": "Дубляж [RUS AC3 6ch]", "tail": "[RUS AC3 6ch]"}) == "Дубляж")
|
||||
check("plain_text: пустой tail — текст как есть", _runs.plain_text({"text": "Дубляж [X]", "tail": ""}) == "Дубляж [X]")
|
||||
check("plain_text: старый датасет без tail — по шаблону хвоста",
|
||||
_runs.plain_text({"text": "Дубляж [RUS AC3 6ch 448kbps 18+]"}) == "Дубляж"
|
||||
and _runs.plain_text({"text": "Дубляж [? AC3]"}) == "Дубляж"
|
||||
and _runs.plain_text({"text": "Озвучка [Лостфильм]"}) == "Озвучка [Лостфильм]")
|
||||
_ra = [{"text": "a", "translation_type": "x", "span": None, "is_18plus": False},
|
||||
{"text": "b", "translation_type": "y", "span": [0, 1], "is_18plus": True, "lang": "RUS"}]
|
||||
check("отпечаток выборки не зависит от порядка и меняется с метками",
|
||||
_runs.fingerprint(_ra) == _runs.fingerprint(_ra[::-1])
|
||||
and _runs.fingerprint(_ra) != _runs.fingerprint([_ra[0], {**_ra[1], "translation_type": "z"}]))
|
||||
_lo, _hi = _runs.wilson(94, 100)
|
||||
check("Уилсон: интервал вокруг доли", _lo < 0.94 < _hi and 0.85 < _lo and _hi < 0.98 and _runs.wilson(0, 0) == (0.0, 0.0))
|
||||
check("знаковый критерий: 30/5 значимо, 12/10 — шум, 0/0 — 1",
|
||||
_runs.sign_test(30, 5) < 0.001 and _runs.sign_test(12, 10) > 0.5 and _runs.sign_test(0, 0) == 1.0)
|
||||
check("парное сравнение считает починенные и сломанные",
|
||||
_runs.paired([1, 0, 1, 0, 1], [1, 1, 0, 0, 1])["fixed"] == 1
|
||||
and _runs.paired([1, 0, 1, 0, 1], [1, 1, 0, 0, 1])["broken"] == 1)
|
||||
import retrain as _rt7 # noqa: E402
|
||||
check("профили меню совпадают с профилями обучения", set(MODEL_CONFIGS) == set(_rt7.PROFILES))
|
||||
check("python -m src.train по умолчанию — tiny2 и base, крупные профили только по запросу",
|
||||
DEFAULT_MODELS == ["tiny2", "base"] and {"large", "modern"} <= set(MODEL_CONFIGS))
|
||||
|
||||
with _tf7.TemporaryDirectory() as _td7:
|
||||
_m = Path(_td7)
|
||||
_old7 = (_runs.MODELS, _runs.RUNS)
|
||||
_runs.MODELS, _runs.RUNS = _m, _m / "runs"
|
||||
try:
|
||||
def _mk(d: Path, backbone="x/bert", cfg=None, input_names=None):
|
||||
d.mkdir(parents=True)
|
||||
(d / "model.pt").write_bytes(b"w" * 10)
|
||||
(d / "dub_config.json").write_text(_json.dumps({"backbone": backbone}), encoding="utf-8")
|
||||
(d / "train_config.json").write_text(_json.dumps(cfg or {"name": d.name, "uid": d.name}), encoding="utf-8")
|
||||
(d / "tokenizer_config.json").write_text(_json.dumps(
|
||||
{"model_input_names": input_names or ["input_ids", "token_type_ids", "attention_mask"]}), encoding="utf-8")
|
||||
_mk(_m / "best", cfg={"name": "base"})
|
||||
_mk(_m / "base", cfg={"name": "base"}) # копия best — в списке не нужна
|
||||
_mk(_m / "runs" / "20260101_000000_large", backbone="x/large")
|
||||
_mk(_m / "runs" / "20260102_000000_modern", backbone="x/modern", input_names=["input_ids", "attention_mask"])
|
||||
_names = [p.name for p in _runs.candidates()]
|
||||
check("кандидаты: best первой, копия best не дублируется", _names[0] == "best" and "base" not in _names and len(_names) == 3, str(_names))
|
||||
check("удалять можно запуск, но не best", _runs.deletable(_m / "runs" / "20260101_000000_large") and not _runs.deletable(_m / "best"))
|
||||
_w = _runs.warnings_for(_m / "runs" / "20260102_000000_modern")
|
||||
check("предупреждения: другая основа и нет token_type_ids", any("другая основа" in x for x in _w) and any("token_type_ids" in x for x in _w), str(_w))
|
||||
_arch = _runs.promote(_m / "runs" / "20260101_000000_large")
|
||||
check("promote: best заменена, прежняя в best_backup_*",
|
||||
_arch and (_m / _arch / "dub_config.json").exists()
|
||||
and _runs.describe(_m / "best")["backbone"] == "x/large" and not (_m / ".best_new").exists())
|
||||
check("promote: исходный запуск остаётся (копирование, не перенос)", (_m / "runs" / "20260101_000000_large" / "model.pt").exists())
|
||||
check("promote той же модели ничего не делает", _runs.promote(_m / "best") is None)
|
||||
_runs.delete(_m / _arch)
|
||||
check("delete убрал архивную копию", not (_m / _arch).exists())
|
||||
try:
|
||||
_runs.delete(_m / "best")
|
||||
check("delete не трогает best", False)
|
||||
except ValueError:
|
||||
check("delete не трогает best", (_m / "best").exists())
|
||||
finally:
|
||||
_runs.MODELS, _runs.RUNS = _old7
|
||||
|
||||
_ds7 = Path("data/processed/dataset.json")
|
||||
if _ds7.exists():
|
||||
# короткий настоящий прогон: tiny2, 1 эпоха, подвыборка; запуск лежит в своём каталоге, best/reports не тронуты
|
||||
import random as _rnd7 # noqa: E402
|
||||
with _tf7.TemporaryDirectory() as _td7:
|
||||
_t = Path(_td7)
|
||||
_all = _json.loads(_ds7.read_text(encoding="utf-8"))
|
||||
_tr = [r for r in _all if r["split"] == "train"]
|
||||
_te = [r for r in _all if r["split"] == "test"]
|
||||
_rnd7.Random(1).shuffle(_tr)
|
||||
_rnd7.Random(1).shuffle(_te)
|
||||
(_t / "dataset.json").write_text(_json.dumps(_tr[:300] + _te[:100], ensure_ascii=False), encoding="utf-8")
|
||||
(_t / "label_maps.json").write_text(Path("data/processed/label_maps.json").read_text(encoding="utf-8"), encoding="utf-8")
|
||||
_best_before = (Path("models/best/model.pt").stat().st_mtime if Path("models/best/model.pt").exists() else None)
|
||||
_sum_before = Path("reports/summary.md").read_bytes() if Path("reports/summary.md").exists() else None
|
||||
_p = _sp7.run([sys.executable, "-m", "src.train", "--runs", "--models", "tiny2", "--folds", "0",
|
||||
"--epochs-override", "1", "--data", str(_t / "dataset.json"), "--runs-dir", str(_t / "runs"),
|
||||
"--tag", "_t"], capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
_dirs = list((_t / "runs").glob("*_tiny2_t"))
|
||||
check("обучение --runs: запуск создан со всеми файлами",
|
||||
_p.returncode == 0 and len(_dirs) == 1
|
||||
and all((_dirs[0] / f).exists() for f in ("model.pt", "run.json", "eval.json", "label_maps.json", "confusion_tiny2.png")),
|
||||
(_p.stderr or "")[-300:])
|
||||
check("обучение --runs не трогает models/best и reports/summary.md",
|
||||
(_best_before is None or Path("models/best/model.pt").stat().st_mtime == _best_before)
|
||||
and (_sum_before is None or Path("reports/summary.md").read_bytes() == _sum_before))
|
||||
if _dirs:
|
||||
_rows7 = [r for r in _json.loads((_t / "dataset.json").read_text(encoding="utf-8")) if r["split"] == "test"]
|
||||
_fp7 = _runs.fingerprint(_rows7)
|
||||
_stored = _runs.load_eval(_dirs[0], _fp7)
|
||||
_fresh = _runs.eval_checkpoint(_dirs[0], _rows7)
|
||||
check("оценка чекпоинта воспроизводит метрики обучения построчно",
|
||||
_stored is not None and _stored["per_row"] == _fresh["per_row"] and len(_fresh["per_row"]) == len(_rows7))
|
||||
_cmp = _runs.compare([_dirs[0]], _rows7)
|
||||
check("compare: модель без baseline, доверительный интервал считается", _cmp[0]["vs"] is None and _cmp[0]["ci"][0] <= _cmp[0]["ci"][1])
|
||||
else:
|
||||
print(" пропущено (нет data/processed/dataset.json)")
|
||||
|
||||
print("== Docker: сервис без пакетов обучения, токен, настройки ==")
|
||||
_code = chr(10).join(["import sys",
|
||||
"for m in ('sklearn','matplotlib','pandas','onnx','onnxruntime'): sys.modules[m] = None",
|
||||
"import src.service, src.predict, src.release, src.segmenter",
|
||||
"print('IMPORT_OK')"])
|
||||
_r8 = _sp7.run([sys.executable, "-c", _code], capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
check("сервис импортируется без sklearn/matplotlib/pandas/onnx", "IMPORT_OK" in _r8.stdout, (_r8.stderr or "")[-300:])
|
||||
from src import spans as _spans # noqa: E402
|
||||
from src import train as _train8 # noqa: E402
|
||||
check("decode_span и MAX_LEN живут в src.spans, train их реэкспортирует",
|
||||
_train8.decode_span is _spans.decode_span and _train8.MAX_LEN == _spans.MAX_LEN == 128)
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from src.service import create_app # noqa: E402
|
||||
|
||||
|
||||
class _Stub:
|
||||
model_dir, device, types, segmenter = Path("models/best"), "cpu", ["x"], None
|
||||
|
||||
def predict_text(self, text):
|
||||
return {"translation_type": "x", "text": text}
|
||||
|
||||
|
||||
_c = TestClient(create_app(_Stub(), api_token="tok"))
|
||||
check("токен: /health открыт, /predict без токена — 401",
|
||||
_c.get("/health").status_code == 200 and _c.post("/predict", json={"text": "a"}).status_code == 401
|
||||
and _c.post("/predict", json={"text": "a"}, headers={"Authorization": "Bearer bad"}).status_code == 401)
|
||||
check("токен: Bearer и X-API-Key принимаются",
|
||||
_c.post("/predict", json={"text": "a"}, headers={"Authorization": "Bearer tok"}).status_code == 200
|
||||
and _c.post("/predict", json={"text": "a"}, headers={"X-API-Key": "tok"}).status_code == 200)
|
||||
check("без DUB_API_TOKEN /predict открыт, /health показывает segmenter и auth",
|
||||
TestClient(create_app(_Stub())).post("/predict", json={"text": "a"}).status_code == 200
|
||||
and _c.get("/health").json()["auth"] is True and _c.get("/health").json()["segmenter"] == "rules")
|
||||
import src.service as _svc8 # noqa: E402
|
||||
_fetch_calls: list = []
|
||||
_orig_fetch = _svc8.fetch_html
|
||||
_svc8.fetch_html = lambda url, **kw: (_fetch_calls.append(kw), ("<html></html>", "rutracker"))[1]
|
||||
try:
|
||||
_svc8.predict_release = lambda *a, **k: {"tracks": []}
|
||||
TestClient(create_app(_Stub(), fetch_timeout=77)).post("/predict", json={"url": "https://rutracker.org/forum/viewtopic.php?t=1"})
|
||||
check("DUB_FETCH_TIMEOUT доходит до загрузки страницы", _fetch_calls and _fetch_calls[0].get("timeout") == 77, str(_fetch_calls))
|
||||
finally:
|
||||
_svc8.fetch_html = _orig_fetch
|
||||
import importlib # noqa: E402
|
||||
importlib.reload(_svc8)
|
||||
check("env_float: мусор и пусто — значение по умолчанию", _svc8.env_float("DUB_NOPE_XYZ", 5.0) == 5.0)
|
||||
for _f in ("Dockerfile", ".dockerignore", "docker-compose.yml", ".env.example", ".gitea/workflows/docker.yml",
|
||||
"requirements-service.txt", "docs/docker.md", "docker-models/models/.gitkeep"):
|
||||
check(f"есть {_f}", Path(_f).exists())
|
||||
check("в requirements-service.txt нет пакетов обучения",
|
||||
not any(x in Path("requirements-service.txt").read_text(encoding="utf-8") for x in ("scikit", "pandas", "matplotlib", "onnx")))
|
||||
try:
|
||||
import yaml as _yaml # noqa: E402
|
||||
for _f in ("docker-compose.yml", ".gitea/workflows/docker.yml"):
|
||||
_yaml.safe_load(Path(_f).read_text(encoding="utf-8"))
|
||||
check("YAML compose и workflow разбираются", True)
|
||||
except ImportError:
|
||||
print(" пропущено: нет PyYAML")
|
||||
|
||||
print("== сегментатор (модель) ==")
|
||||
try:
|
||||
from src.segmenter import LineSegmenter, is_test_release # noqa: E402
|
||||
@@ -312,6 +1088,8 @@ try:
|
||||
check("сегментатор: «Субтитры» и «Оригинальная аудиодорожка» не дорожки",
|
||||
not any(l.startswith(("Субтитры", "Оригинальная")) for l in _lines))
|
||||
check("разбиение страниц детерминировано", is_test_release("6870900") == is_test_release("6870900"))
|
||||
check("сегментатор: строка из знаков препинания (>160 токенов) не роняет",
|
||||
len(_seg.score(["|;," * 100, "Перевод: Профессиональный (дублированный)"])) == 2)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" пропущено (нет models/segmenter): {e}")
|
||||
|
||||
|
||||
Reference in new issue
Block a user