# Интеграция из Rust / TypeScript Два пути. **А (HTTP)** — самый простой: Python-процесс один раз загружает модель, любой язык ходит по REST. **Б (ONNX)** — без Python в рантайме: модель запускается локально в вашем процессе. | | Путь А: HTTP-сервис | Путь Б: ONNX | |---|---|---| | Зависимости рантайма | запущенный Python-процесс | onnxruntime (+ токенизатор) в вашем языке | | Сегментация HTML | да, на стороне сервиса | нет — в примерах обрабатывается одна дорожка; сегментацию нужно вызвать отдельно или портировать (`html_extract.py` ~60 строк) | | Канонизация имён | да, на стороне сервиса | по `aliases.json` (простой словарь) | | Скорость | сеть + инференс (~мс) | локальный инференс (~мс) | | Кому подходит | сервис/бэкенд | CLI/десктоп/edge, где Python нельзя | ## Путь А: HTTP-сервис Запуск (из корня проекта): ```bash python -m src.service --model models/best --port 8000 # или открыто наружу: python -m src.service --host 0.0.0.0 --port 8000 ``` В Docker (VPS, FlareSolverr через переменные окружения, CI/CD в Gitea) — см. [docker.md](docker.md). API: * `GET /health` → `{"status": "ok", "model": "...", "device": "...", "classes": [...]}` * `POST /predict` — ровно одно из полей (`url` принимает опциональный `cookie`; ответ для `html`/`url` — агрегат релиза, см. docs/inference.md): * `{"url": "https://rutracker.org/forum/viewtopic.php?t=…"}` → страница загружается сервисом; * `{"text": "Перевод: Дублированный | MovieDalen"}` → одна дорожка; * `{"html": "…вся страница…"}` → все дорожки релиза. Ответ: `{"tracks": [ …контракт из docs/inference.md… ]}`. Каждая дорожка помимо типа, студии и 18+ несёт (поля только добавляются, прежние не менялись): | Поле | Что это | |---|---| | `lang`, `lang_confidence`, `lang_source` | язык дорожки (`RUS`, `UKR`, `ENG`, …); источник: `model` (языковая голова), `audio` (строка «Аудио»), `text` (слова в строке дорожки) или `null` | | `role` | `перевод` / `оригинал` / `комментарии` (по строке «Аудио» и тексту, правилами) | | `official` | в строке «Аудио» сказано «официальный» / «лицензионный» | | `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+ (если на странице две строки «Аудио» с одной студией). curl: ```bash curl -X POST http://127.0.0.1:8000/predict -H "Content-Type: application/json" \ -d '{"text": "Перевод: Дублированный | MovieDalen"}' ``` > **Windows/Git Bash:** кириллица в аргументе `-d` может искажаться кодировкой > консоли — используйте `--data-binary @req.json` с файлом в UTF-8. ### Rust (examples/rust/http_client) ```bash cd examples/rust/http_client cargo run -- --text "Перевод: Дублированный | MovieDalen" cargo run -- --html page.html DUB_URL=http://host:8000 cargo run -- --text "..." ``` Ядро клиента: ```rust let resp: PredictResponse = client .post("http://127.0.0.1:8000/predict") .json(&serde_json::json!({ "html": page_html })) .send().await? .json().await?; for t in &resp.tracks { println!("{} — {}{}", t.translation_type, t.studio.clone().unwrap_or_else(|| "—".into()), if t.is_18plus { " 18+" } else { "" }); } ``` ### TypeScript / Node.js (examples/typescript) Без зависимостей (Node >= 18, глобальный fetch): ```bash node examples/typescript/predict.mjs --text "Одноголосый закадровый — Дмитрий Есарев" node examples/typescript/predict.mjs --html page.html ``` ```ts const res = await fetch("http://127.0.0.1:8000/predict", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Одноголосый закадровый — Дмитрий Есарев" }), }); const { tracks } = await res.json(); ``` Типизированная версия — `examples/typescript/predict.ts` (запуск: `npx tsx predict.ts --text "..."`). ### PowerShell (examples/powershell/predict.ps1) Скрипт (работает и в Windows PowerShell 5.1, и в PowerShell 7): ```powershell powershell -NoProfile -ExecutionPolicy Bypass -File examples\powershell\predict.ps1 -Text "Перевод: Дублированный | MovieDalen" pwsh -File examples\powershell\predict.ps1 -HtmlFile page.html -Url http://host:8000 ``` Однострочник (POST с кириллицей; UTF-8 байтами — важно для PS 5.1, где строковый `-Body` уходит в ISO-8859-1): ```powershell $b = [Text.Encoding]::UTF8.GetBytes((@{ text = "Перевод: Дублированный | MovieDalen" } | ConvertTo-Json)) (Invoke-RestMethod -Uri http://127.0.0.1:8000/predict -Method Post -ContentType "application/json" -Body $b).tracks ``` Проверка живости: `Invoke-RestMethod http://127.0.0.1:8000/health`. Ответ сервис отдаёт с `charset=utf-8`, поэтому кириллица в ответе корректно декодируется и в PS 5.1. ### Cloudflare / 403 на трекерах (FlareSolverr) Как и Jackett/Prowlarr, сервис умеет ходить через **ваш** экземпляр [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr): при ответе трекера 403/429/503 или странице-заглушке («Один момент…») запрос повторяется через него. ```bash docker run -d --name flaresolverr -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest python -m src.service --flaresolverr http://127.0.0.1:8191 # или env DUB_FLARESOLVERR_URL python -m src.predict --url <ссылка> --flaresolverr http://127.0.0.1:8191 ``` Адрес FlareSolverr задаётся только настройкой сервиса, не полем запроса. Если grabber уже работает через Prowlarr/FlareSolverr, проще скачать страницу там и прислать её в поле `html`. Ограничения: FlareSolverr не гарантирует обход (rutracker дополнительно требует авторизацию / может блокировать по региону), cookie сессии передаются через `cookie`. ### grabber (PHP) Описание на kinozal часто открывается и без входа, и тогда достаточно `{"url": …}` (cookie необязателен). Для раздач, закрытых для гостей, а также чтобы не хранить cookie в сервисе, grabber скачивает страницу сам (`KinozalClient`) и отправляет сервису готовый HTML вместе с заголовком раздачи: ```php $ch = curl_init('http://grabber-ai:8000/predict'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode(['html' => $tab, 'title' => $releaseTitle], JSON_UNESCAPED_UNICODE), ]); $release = json_decode(curl_exec($ch), true); // $tab — ответ get_srv_details.php (заголовка в нём нет, поэтому передаём title) // $release['studios'], $release['translation_types'], $release['is_18plus'] ``` ## Путь Б: ONNX без Python Экспорт (из корня проекта, после обучения): ```bash python -m src.export_onnx # -> models/best-onnx/ + reports/onnx_parity.md ``` Для встраивания — малая модель: `python -m src.export_onnx --model models/tiny2 --quantize` кладёт в `models/tiny2-onnx/` ещё и `model.int8.onnx` (29 МБ вместо ~440 МБ у base; parity с PyTorch: тип 99.8%, спан 99.2%, 18+ 100% — `reports/onnx_parity_tiny2_int8.md`). Состав `models/best-onnx/`: `model.onnx` (входы `input_ids`, `attention_mask`, `token_type_ids` int64 [B, seq]; выходы `type_logits` [B, 14], `bio_logits` [B, seq, 3], `age_logits` [B, 2]), `tokenizer.json` (HF fast-токенизатор), `label_maps.json` (классы), `aliases.json` (нормализация имён). Эквивалентность PyTorch проверяется на всём held-out test (совпадение типа/спана/18+ ≥ 99%) — отчёт в `reports/onnx_parity.md`. Клиентская постобработка (одинакова во всех языках): 1. токенизировать дорожку (max_len 128, truncation); 2. прогнать ONNX, взять `argmax`; 3. спан: первый токен с меткой B, затем цепочка I; текст спана — по offsets токенизатора (или `decode` токенов спана); 4. `is_18plus = argmax(age_logits) == 1`; 5. канонизация автора по `aliases.json`. ### Rust (examples/rust/onnx_local) ```bash cd examples/rust/onnx_local && cargo build ORT_DYLIB_PATH="$(find ../../.venv -name onnxruntime.dll | head -1)" \ ./target/debug/grabber-ai-onnx-local.exe ../../models/best-onnx \ "Перевод: Дублированный | MovieDalen" ``` Крейты: `ort 2.0.0-rc.15` + `tokenizers` (читает `tokenizer.json` как есть). Библиотека onnxruntime грузится динамически — DLL можно взять из pip-пакета `onnxruntime` (уже есть в `.venv` проекта) или из официального релиза; проверено: тип, спан автора и 18+ совпадают с Python-инференсом. ### TypeScript (examples/typescript/onnx_local.mjs) ```bash cd examples/typescript npm init -y && npm i onnxruntime-node @huggingface/transformers node onnx_local.mjs "../../models/best-onnx" "Перевод: Дублированный | MovieDalen" ``` `@huggingface/transformers` используется только как токенизатор; инференс — `onnxruntime-node`. ## Замечание про сегментацию страниц в пути Б Сервис (путь А) сам находит все дорожки в HTML. В пути Б примеры принимают одну дорожку; для полной обработки страницы нужно сначала получить сегменты (например, один вызов лёгкого HTTP-эндпоинта или порт `html_extract.py` — это регулярки без зависимостей, кроме BeautifulSoup для HTML). ## Серии по студиям (для Sonarr) В ответе релиза у каждой дорожки есть `episodes` («1-4», «1,3-5») и `episode_ranges` (`[[1, 4]]`); `null` — серии у дорожки не указаны, считается, что она покрывает весь релиз. Для сериалов добавлен блок `series`: ```json "series": {"season": "2", "episodes": "1-9", "total": 10, "by_studio": {"LostFilm": "1-4", "Red Head Sound": "1"}, "full_release": ["Syncmer"]} ``` `full_release` — студии, у которых серии не указаны: для сериала это значит, что озвучены все серии, вышедшие в релизе. `episodes` и `total` берутся из заголовка («сезон 2, серии 1-9 из 10»), `by_studio` — из строк дорожек. Диапазоны разбирает `src/episodes.py` правилами (числа и диапазоны точны); размеченных примеров пока мало (серии указаны примерно в 5% строк nnm), поэтому обучаемый разбор можно делать после накопления данных сборщиком.