docker / build (push) Successful in 1m18s
src/techinfo.py — правила по строкам «Видео:», «Тип релиза:»/«Качество», «Контейнер:», «Продолжительность:», «Субтитры:» и заголовку: поля video (кодек, размер кадра, класс разрешения, fps, битрейт, битность, HDR, профиль DV), release (тип, нормализованный источник, контейнер, продолжительность), subtitles (языки с forced/full/SDH, формат, вшитые). audio дорожек и original: khz и atmos (вход модели не меняется). title_from_html больше не рвёт заголовок rutracker пробелами (<span>/<wbr> на каждом слове). Покрытие на 600 страницах каждого трекера: видео 90/93/100%, e2e озвучки без изменений. Тесты 275 ok. Co-Authored-By: Claude Opus 5.5 <[email protected]>
247 lines
13 KiB
Markdown
247 lines
13 KiB
Markdown
# Интеграция из 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": "<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), поэтому
|
||
обучаемый разбор можно делать после накопления данных сборщиком.
|