Слияние claude/advisor-opus-78114c: модели на пересобранном датасете, Docker, язык и серии

Конфликт в src/release.py: взяты original_info, lostfilm_release,
series_info и серии дорожек из advisor; сохранены same_studio,
merge_extra_tracks и codec_segments. Кодек-дорожкам тоже проставляются
серии.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-02 11:50:44 +07:00
commit 099d33bdaa
439 files changed
+296036 -27415

No files matched your search

+4 -2
View File
@@ -1,11 +1,13 @@
# Документация DUB-AI
# Документация grabber-ai
| Документ | Содержание |
|---|---|
| [architecture.md](architecture.md) | Схема пайплайна, multi-task модель (тип + BIO-спан + 18+), почему извлечение вместо классификации |
| [dataset.md](dataset.md) | Схема labels.csv, семантика 14 классов, словарь алиасов, находки аудита и как они обработаны |
| [dataset.md](dataset.md) | Схема labels.csv, семантика 15 классов, словарь алиасов, находки аудита и как они обработаны |
| [training-windows.md](training-windows.md) | Пошаговое обучение на Windows с GPU (RTX 5070 Ti / Blackwell), гиперпараметры, troubleshooting, переобучение на свежем датасете |
| [inference.md](inference.md) | CLI, Python API, JSON-контракт (таблица полей), обработка HTML, ограничения |
| [api-reference.md](api-reference.md) | **Справочник ответа сервиса**: все 15 значений `translation_type`, все поля дорожки и релиза, принцип работы. Читать вместо `data/labels.csv` |
| [integration.md](integration.md) | Интеграция из Rust и TypeScript: HTTP-сервис (путь А) и ONNX без Python (путь Б), трейдоффы |
| [docker.md](docker.md) | Docker-образ сервиса, переменные окружения (в т.ч. FlareSolverr), API, модели в контейнере, CI/CD в Gitea, развёртывание на VPS |
Быстрый старт — в [README.md](../README.md) корня проекта.
+252
View File
@@ -0,0 +1,252 @@
# Справочник API: значения ответа и принцип работы
Этот документ — единственное место, где перечислены все возможные значения полей ответа. **Заглядывать
в `data/labels.csv` для этого не нужно**: файл большой (десятки МБ), нужен только при обучении и в
работе сервиса не участвует. Список классов модели хранится в `models/best/label_maps.json`
(в образе — `/app/models/best/label_maps.json`) и отдаётся сервисом: `GET /health` → `classes`.
Запросы, переменные окружения и запуск — в [docker.md](docker.md); формат вызова из кода —
в [integration.md](integration.md); CLI и Python API — в [inference.md](inference.md).
## Эндпоинты
| Метод | Путь | Назначение |
|---|---|---|
| GET | `/health` | статус, модель, список классов `classes`, режим сегментатора, `flaresolverr`, `auth`. Токен не нужен |
| POST | `/predict` | предсказание. Тело — JSON с **ровно одним** из полей `text`, `html`, `url` |
| GET | `/docs` | интерактивная Swagger-документация FastAPI |
Тело `/predict`:
| Поле | Тип | Описание |
|---|---|---|
| `text` | string | одна дорожка (строка вида «Профессиональный (многоголосый закадровый) \| LostFilm») → ответ `{"tracks": [одна дорожка]}` |
| `html` | string | HTML (или текст) страницы раздачи → ответ-агрегат релиза |
| `url` | string | ссылка на раздачу rutracker / nnmclub / kinozal; страницу сервис скачивает сам (при 403/503 — через FlareSolverr) → ответ-агрегат + `tracker`. Ссылка на `lostfilm.tv` (и поддомены) страницу не загружает: сразу возвращается одна дорожка «проф. многоголосый, LostFilm, RUS» (`tracker: "lostfilm"`, `lang_source: "url"`, `source: "url"`) |
| `title` | string, необязательно | заголовок раздачи для `html` (во фрагменте вкладки его нет); для `url` берётся со страницы |
| `cookie` | string, необязательно | Cookie сессии (`uid=...; pass=...`) для `url`. **Для kinozal обычно не нужен**: описание многих раздач открывается и без входа (проверено на `details.php?id=2158906`). Передавайте, только если сервис вернул `400` про форму входа |
Коды ошибок: `400` — неверное тело, пустой текст, страница не разобрана или запрещённый хост;
`401` — нет/неверный токен (если задан `DUB_API_TOKEN`); `502` — не удалось скачать страницу.
Токен передаётся `Authorization: Bearer <токен>` или `X-API-Key: <токен>`.
## Ответ релиза (`html` и `url`)
```json
{
"tracks": [ /* дорожки, см. ниже */ ],
"studios": [{"studio": "LostFilm", "translation_type": "проф. многоголосый", "lang": "RUS", "is_18plus": false, "episodes": "1-4"}],
"translation_types": ["проф. многоголосый"],
"is_18plus": false,
"original": {"lang": "ENG", "codec": "E-AC3", "channels": 6, "bitrate_kbps": 768, "raw": "English E-AC3+Atmos / 5.1 / 48 kHz / 768 kbps |Original|"},
"series": {"season": "2", "episodes": "1-9", "total": 10, "by_studio": {"LostFilm": "1-4"}, "full_release": []},
"title": "Название раздачи",
"segmenter": "model",
"tracker": "rutracker"
}
```
| Поле | Тип | Описание |
|---|---|---|
| `tracks` | array | дорожки релиза без дублей (дубль — одинаковые тип + студия) |
| `studios` | array | уникальные `{studio, translation_type, lang, is_18plus, episodes}`; `lang` — язык дорожки студии (как в `tracks[].lang`, может быть `null`). Одна студия с русским и украинским дубляжом даёт две записи |
| `original` | object \| null | оригинальная дорожка из строки «Аудио … \|Original\|»: `{lang, codec, channels, bitrate_kbps, raw}` (поля могут быть `null`); `null` — такой строки на странице нет |
| `translation_types` | array of string | отсортированный набор типов (значения — из таблицы классов ниже) |
| `is_18plus` | bool | `true`, если 18+ хотя бы у одной дорожки |
| `series` | object \| null | сезон, выпущенные серии, «какая студия какие серии» (для Sonarr); `null` — сведений нет |
| `title` | string \| null | заголовок раздачи, если найден |
| `segmenter` | `"model"` \| `"rules"` | чем страница разбита на строки дорожек: обученным сегментатором или запасными правилами |
| `tracker` | string | только для `url`: `rutracker`, `nnm`, `kinozal` или `lostfilm` |
`series`: `season` — номер сезона строкой или `null`; `episodes` — выпущенные серии («1-9»);
`total` — всего серий; `by_studio` — `{студия: серии}`; `full_release` — студии без указанных серий
(для сериала это значит «озвучены все вышедшие серии»). Подробности — в [integration.md](integration.md).
## Дорожка (элемент `tracks`)
| Поле | Тип | Значения / описание |
|---|---|---|
| `segment` | string | текст дорожки, по которому сделано предсказание |
| `translation_type` | string | **один из 15 классов** — [таблица ниже](#translation_type-15-классов) |
| `type_confidence` | float 0..1 | вероятность выбранного класса |
| `studio_span` | string \| null | фрагмент текста, распознанный как автор/студия |
| `studio` | string \| null | каноническое имя автора из словаря алиасов; неизвестное имя возвращается как в тексте; `null` — автора нет |
| `studio_confidence` | float \| null | средняя вероятность токенов спана |
| `is_18plus` | bool | дорожка помечена 18+ (по модели или по строке «Аудио … 18+» страницы) |
| `age_confidence` | float 0..1 | вероятность предсказанного класса 18+ |
| `lang` | string \| null | язык дорожки, ISO-подобный 3-буквенный код: `RUS`, `UKR`, `ENG`, также `BEL`, `KAZ`, `POL`, `FRA`, `DEU`, `ITA`, `SPA`, `JPN`, `KOR`, `CHI`, `CZE`, `LAV`, `LIT`, `EST`, `GEO`, `ARM`, `AZE`, `UZB`, `TUR`, `POR`, `HIN`; `null` — не определён |
| `lang_confidence` | float \| null | уверенность модели; заполнено только при `lang_source = "model"` |
| `lang_source` | `"audio"` \| `"model"` \| `"text"` \| null | откуда язык, в порядке приоритета: строка «Аудио» страницы (`Ukrainian E-AC3 …` → `UKR`, самый надёжный источник), нейросеть (классы `ENG`/`RUS`/`UKR`, порог 0.5; без строки «Аудио» ей приходится угадывать по тексту), слова в тексте («Украинский дубляж») |
| `role` | string | `перевод`, `оригинал` (оригинальная дорожка без озвучки) или `комментарии` (комментарии режиссёра и т. п.) |
| `official` | bool | в строке «Аудио» есть слова «официальный» / «лицензионный» / «licensed» |
| `audio` | object \| null | `{codec, channels, bitrate_kbps}` из строки «Аудио» страницы (`"E-AC3"`, `6`, `768`; `channels` — число каналов, «5.1» → 6); каждое поле может быть `null`; весь объект `null`, если строка не найдена |
| `source` | `"page"` \| `"title"` | только в ответе релиза: дорожка найдена в описании или студия взята из заголовка раздачи |
| `episodes` | string \| null | серии, которые озвучивает дорожка («1-4», «1,3-5»); `null` — серии не указаны (покрывает весь релиз) |
| `episode_ranges` | array | то же в виде `[[1, 4]]`; пусто, если серий нет |
Поле `lang` описывает только язык дорожки; `role` и `lang` не зависят от `translation_type`.
### translation_type: 15 классов
> Текущая модель знает 15 классов: к 14 из списка ниже добавлен `люб. дубляж` — любительский дубляж
> (см. [dataset.md](dataset.md)); модели, обученные раньше, его не возвращают и отдают 14 классов.
> Источник истины для конкретной модели — `GET /health` → `classes`; клиенту безопасно сопоставлять по подстроке
> (`дубляж` → дубляж), как описано ниже: `люб. дубляж` её содержит.
Точные строки (регистр, пробелы и точки после префикса — как есть; сортировка — по алфавиту, так их
возвращает `GET /health` → `classes`):
```json
["авт. двухголосый", "авт. многоголосый", "авт. одноголосый", "двухголосый", "люб. двухголосый",
"люб. дубляж", "люб. многоголосый", "люб. одноголосый", "многоголосый", "неизвестно", "одноголосый",
"проф. двухголосый", "проф. дубляж", "проф. многоголосый", "проф. одноголосый"]
```
Для клиентов, которые сводят класс к своим типам (Dub / MVO / DVO / AVO), надёжно сопоставление по
**подстроке** нижнего регистра: `дубляж` → дубляж; `многоголос` → многоголосый; `двухголос` →
двухголосый; `одноголос` → одноголосый; ровно `неизвестно` → тип не определён (такие дорожки обычно
пропускают). Подстрока `дублирован` в классах **не встречается** (дублированные страницы сводятся к
`проф. дубляж`). Префикс (`проф.`/`люб.`/`авт.`/нет) — отдельный признак. Если набор классов в будущей
модели изменится, ориентируйтесь на `/health` → `classes`, а неизвестное значение не считайте ошибкой.
Класс — составная метка «кто делал × сколько голосов». Других значений сервис не возвращает.
| Значение | Кто озвучивал | Вид |
|---|---|---|
| `проф. дубляж` | профессиональная студия | дубляж (речь актёров заменяет оригинал; в страницах «Дублированный») |
| `люб. дубляж` | любительская озвучка | дубляж/псевдодубляж; в текущей модели есть; в моделях, обученных раньше, его нет |
| `проф. многоголосый` | профессиональная студия | закадровый, три и более голоса |
| `проф. двухголосый` | профессиональная студия | закадровый, два голоса |
| `проф. одноголосый` | профессиональная студия | закадровый, один голос |
| `люб. многоголосый` | любительская озвучка | закадровый, три и более голоса |
| `люб. двухголосый` | любительская озвучка | закадровый, два голоса |
| `люб. одноголосый` | любительская озвучка | закадровый, один голос |
| `авт. многоголосый` | авторская (один автор / «авторский перевод») | закадровый, три и более голоса |
| `авт. двухголосый` | авторская | закадровый, два голоса |
| `авт. одноголосый` | авторская | закадровый, один голос |
| `многоголосый` | в тексте не сказано | закадровый, три и более голоса |
| `двухголосый` | в тексте не сказано | закадровый, два голоса |
| `одноголосый` | в тексте не сказано | закадровый, один голос |
| `неизвестно` | — | тип по тексту определить нельзя |
Как читать: префикс `проф.` / `люб.` / `авт.` — категория озвучивающего (на трекерах это слова
«Профессиональный», «Любительский», «Авторский»); без префикса — раздача не указывает категорию.
Для фильтров в приложении обычно достаточно сопоставить по префиксу и по хвосту (`…дубляж`,
`…многоголосый`, `…двухголосый`, `…одноголосый`). Классы с единичными примерами в обучении
распознаются хуже (см. [dataset.md](dataset.md)); при низком `type_confidence` (< 0.6) значению стоит
доверять осторожно. Состав и названия классов задаёт обучение; актуальный список для конкретной
модели — `GET /health` → `classes`.
## Схема полей и допустимые null
Дорожка (`tracks[]`), нотация TypeScript. Все ключи **присутствуют всегда** (значение может быть
`null`, но ключ не пропускается), кроме помеченных «только в ответе релиза».
```ts
type Track = {
segment: string; // никогда null
translation_type: string; // один из 15 классов, никогда null
type_confidence: number; // 0..1
studio_span: string | null; // null — автора в тексте нет
studio: string | null; // null вместе со studio_span
studio_confidence: number | null; // null вместе со studio_span
is_18plus: boolean;
age_confidence: number; // 0..1
lang: string | null; // 3-буквенный код или null
lang_confidence: number | null; // не null только при lang_source == "model"
lang_source: "model" | "audio" | "text" | "url" | null; // "url" — ссылка lostfilm.tv; null вместе с lang
role: "перевод" | "оригинал" | "комментарии"; // никогда null
official: boolean;
audio: { codec: string | null; channels: number | null; bitrate_kbps: number | null } | null;
// только в ответе релиза (html / url):
source: "page" | "title";
episodes: string | null; // "1-4", "1,3-5"; null — серии не указаны
episode_ranges: [number, number][]; // [[1,4]]; пустой массив, если серий нет
};
type Release = {
tracks: Track[]; // может быть пустым массивом
studios: { studio: string | null; translation_type: string; lang: string | null; is_18plus: boolean; episodes: string | null }[];
original: { lang: string | null; codec: string | null; channels: number | null; bitrate_kbps: number | null; raw: string } | null;
translation_types: string[];
is_18plus: boolean;
series: {
season: string | null; // "2"
episodes: string | null; // выпущенные серии, "1-9"
total: number | null; // всего серий
by_studio: Record<string, string>; // { "LostFilm": "1-4" }, может быть {}
full_release: string[]; // студии без указанных серий
} | null; // null — ни сезона, ни серий в раздаче нет
title: string | null;
segmenter: "model" | "rules";
tracker?: "rutracker" | "nnm" | "kinozal" | "lostfilm"; // только для url
};
```
Ответ на `text` — `{"tracks": [Track]}` (без полей релиза и без `source`/`episodes`/`episode_ranges`).
Клиентам стоит читать ответ толерантно: неизвестные поля игнорировать, отсутствующие считать `null`.
## Ошибки
Тело любой ошибки — JSON `{"detail": "<сообщение>"}`; у `401` добавлен заголовок
`WWW-Authenticate: Bearer`.
| Код | Когда | Пример `detail` |
|---|---|---|
| 400 | передано не ровно одно из `text` / `html` / `url` | «нужно ровно одно поле: text (дорожка), html (страница) или url (ссылка на раздачу)» |
| 400 | пустой `text`; страница не разобрана; хост ссылки не из разрешённых (rutracker / nnmclub / kinozal); страница недоступна или слишком большая; трекер отдал заглушку Cloudflare или форму входа (для kinozal — раздача, которая без входа не открывается, тогда нужен `cookie`) или Cloudflare без FlareSolverr; ошибка FlareSolverr | текст причины на русском («неподдерживаемый хост: …», «страница недоступна: …», «FlareSolverr: …») |
| 401 | задан `DUB_API_TOKEN`, а токена нет или он неверный | «нужен токен: Authorization: Bearer <токен> или X-API-Key» |
| 422 | тело запроса не JSON-объект или поля не тех типов (валидация FastAPI; `detail` — массив) | `[{"loc": [...], "msg": "...", "type": "..."}]` |
| 502 | сетевая ошибка при скачивании страницы по `url` (соединение, DNS, таймаут) | «не удалось загрузить страницу: …» |
`GET /health` не требует токена и возвращает: `status`, `model`, `device`, `classes`, `segmenter`
(`"model"`/`"rules"`), `flaresolverr` (bool), `auth` (bool). Страница, на которой не найдено дорожек,
— не ошибка: ответ `200` с `"tracks": []`. Повторяйте запрос при `502` и при ответах `400` про
Cloudflare: FlareSolverr нестабилен.
## Принцип работы
1. **Получение страницы.** `url` скачивается самим сервисом: сначала напрямую, при 403/503 или
Cloudflare-заглушке — через FlareSolverr (`DUB_FLARESOLVERR_URL`). Разрешены только rutracker,
nnmclub и kinozal.
2. **HTML → текст** (BeautifulSoup: пост rutracker, вкладка описания kinozal, иначе весь документ).
3. **Сегментация.** Обученная модель-сегментатор (`models/segmenter`, rubert-tiny2) помечает строки
страницы, в которых описана дорожка (`«Перевод: …»`, `«Аудио 2: …»`, перечисления студий).
Если сегментатора нет или он ничего не нашёл — запасные правила (`"segmenter": "rules"`).
Многодорожечные строки («1. … 2. …», «A + B») разбиваются на отдельные дорожки.
4. **Контекст аудио.** К тексту дорожки подмешивается подходящая строка «Аудио» страницы
(кодек, каналы, битрейт, язык, 18+) в виде хвоста `[RUS AC3 6ch 448kbps 18+]`.
5. **Модель-классификатор** (`DubTagger` на основе ruBert-base, `models/best`) делает за один проход
несколько предсказаний: `translation_type` (15 классов), спан автора (разметка BIO по токенам),
флаг 18+ и язык (`ENG`/`RUS`/`UKR`).
6. **Постобработка.** Автор канонизируется словарём алиасов; 18+ ставится также по строке «Аудио»;
язык: строка «Аудио» → нейросеть → слова в тексте; строка «Аудио» подбирается к дорожке по имени
студии из `|Дубляж, Le Doyen|` (слова-типы `Дубляж`/`MVO`/… отбрасываются); защита от имён вроде
«Дубляжная»: если дубляж назван только в имени студии, а в тексте указано число голосов, класс берётся
по тексту; отдельно берётся `original`; определяются `role`, `official`, `audio`.
7. **Агрегат релиза.** Дубли (тип + студия) склеиваются; студии из заголовка раздачи, которых нет
в описании, добавляются с `source: "title"`; серии и сезон разбираются правилами (`src/episodes.py`).
Размеченные данные (`data/labels.csv`) используются только при обучении: на них учатся метки
`translation_type`, спан студии, 18+ и язык. Схема файла и происхождение меток — в
[dataset.md](dataset.md). Источник правды для набора классов во время работы — `label_maps.json`
рядом с весами модели.
## Пример
```bash
curl -s http://localhost:7654/predict -H "Authorization: Bearer <ТОКЕН>" \
-H "Content-Type: application/json" \
-d '{"text": "Профессиональный (многоголосый закадровый) | HDRezka Studio 18+"}'
```
```json
{"tracks": [{"segment": "Профессиональный (многоголосый закадровый) | HDRezka Studio 18+",
"translation_type": "проф. многоголосый", "type_confidence": 0.99,
"studio_span": "HDRezka Studio", "studio": "HDrezka", "studio_confidence": 0.97,
"is_18plus": true, "age_confidence": 0.99,
"lang": "RUS", "lang_confidence": null, "lang_source": "text",
"role": "перевод", "official": false, "audio": null}]}
```
(значения `lang*` в примере иллюстративны: они зависят от модели и текста.)
+187
View File
@@ -0,0 +1,187 @@
# Сбор датасета: `tools/collect.py`
Интерактивный сборщик страниц rutracker / nnm-club / kinozal. Логин делает
человек в обычном окне Chrome, скрипт пароли и cookie не видит.
```powershell
.venv\Scripts\python tools\collect.py # меню
```
Главное меню состоит из двух пунктов и подменю:
1. **Собрать датасет**: окна, вход, сбор (новые и/или дособрать размеченные),
`build`, `prepare_data`.
2. **Переобучить и протестировать**: `prepare_data`, обучение сегментатора в
`models/segmenter_v2` (по желанию ещё и основной модели), `tests/run_tests.py`,
таблица «правила / старые веса / новые веса» по трекерам и вывод, лучше ли
новые; подмена весов только после вашего «y» (старые уходят в
`models/segmenter_old_<время>`).
3. Дополнительно: отдельные шаги из таблицы ниже.
Подменю «Дополнительно»:
| Пункт | Что делает |
|---|---|
| 1 | Запускает Chrome (один процесс, общий профиль), открывает по **окну на трекер**, ждёт Enter, проверяет статус входа (вошли / гость / не определено) |
| 2 | Новые раздачи из списков разделов (`viewforum.php?f=…`, для kinozal — адрес списка). По потоку на трекер, у каждого своё окно. Продолжает с места остановки |
| 3 | Дособрать страницы для раздач, которые **уже размечены, но страницы нет** (nnm, kinozal) |
| 4 | `data/raw` → `data/pages/*.txt` + новые строки в `data/labels.csv` |
| 5 | Импорт старого harvest без браузера (только текст поста) |
| 6 | Статус по трекерам |
Те же операции без меню: `windows`, `collect`, `backfill`, `build`,
`import-legacy`, `status` (см. `python tools/collect.py -h`).
## Интерфейс
Главное меню (rich) показывает сводку (размечено, страниц, meta, модели, порт
Chrome). Пункт 3 — статистика по трекерам: покрытие и состав датасета (тип,
разрешение, кодек, годы, языки). Во время сбора идёт живая панель: по каждому
трекеру состояние, прогресс, сохранено, ошибки, скорость (стр/мин по последним
сохранениям), время, ETA, текущий раздел и последняя раздача. `Ctrl+C`
останавливает сбор после текущей раздачи.
## Метаданные релизов: `data/meta.csv`
`build` (и команда `meta`) строит из сохранённых страниц таблицу, одна строка на
релиз (`tools/meta.py`; заходить на трекеры повторно не нужно):
| Группа | Поля |
|---|---|
| Название | `title`, `ru_title`, `orig_title`, `year`, `kind` (film / series / cartoon / anime), `is_series`, `season`, `episodes`, `episodes_total`, `section` |
| Видео | `quality`, `resolution`, `video_codec`, `hdr`, `bit_depth`, `fps`, `container`, `size_gb`, `duration_min` |
| О фильме | `countries`, `genres`, `directors`, `cast`, `age_limit`, `mpaa`, `release_group`, `source_media` |
| Звук | `audio_langs`, `orig_audio`, `n_audio`, `audio_tracks` (JSON: язык, кодек, каналы, битрейт, тип DUB/MVO/DVO/AVO/VO/ORIG, студия, серии, 18+) |
| Субтитры | `sub_langs`, `sub_forced`, `sub_format` |
Для старых данных (импорт `import-legacy`) поля берутся из текста поста;
размер раздачи rutracker и nnm виден только на полной странице, поэтому у них он
заполнится после дозагрузки (пункт «Дособрать»).
## Переиндексация без перекачки
HTML страниц хранится в `data/raw`, поэтому после правки парсеров данные
пересобираются без захода на трекеры: пункт 2 «Переобучить» начинается с вопроса
«Переиндексировать data/raw?» (то же: `collect.py build --rewrite --relabel`).
Страницы, `meta.csv` и метки размеченных релизов строятся заново текущими
парсерами; прежний `labels.csv` копируется в `data/labels.bak_<время>.csv`. Не
трогаются ручные метки (пункт 6), релизы без HTML (импорт старого harvest) и
релизы, по которым парсер ничего не нашёл.
## Обучение по ссылкам
Пункт 6 главного меню: вставляете ссылки на раздачи (rutracker / nnm / kinozal),
страницы скачиваются вашим Chrome, парсер строит метки, вы правите их в таблице
(«2 18» — переключить 18+, «3 d» — удалить, «1 t <тип>», «1 s <студия>») и
сохраняете. Метки заменяют прежние строки релиза в `data/labels.csv`
(`studio_source=manual`), id попадает в `data/manual_ids.txt`: такие релизы
всегда в train и повторяются при обучении ×5. Дальше «Переобучить».
Метки и инференс берут 18+ и параметры дорожки (кодек, каналы, битрейт) не только
из строк «Перевод», но и из строк «Аудио … |Студия| 18+» (`src/audio18.py`): у
одной студии может быть обычная дорожка и дорожка 18+.
## Автопоиск разделов
При первом запуске пункта 1 (режим «новые раздачи») после входа в аккаунты
приложение само находит разделы с зарубежными фильмами и сериалами
(`tools/discover.py`) и кэширует их в `data/raw/_sections.json`:
* **rutracker, nnm**: разделы выбираются по названию (зарубежное кино и сериалы,
мультфильмы, новинки, классика, годовые «Фильмы 2021-2025»; наше кино,
азиатское, музыка, Apple, коллекции исключены). Форматы DVD / HD / UHD / 3D
и годы покрываются самими разделами.
* **kinozal**: ваши списки «все фильмы» (`c=1002`) и «сериалы» (`c=46`), затем
сетка год × формат × категория (4K только с 2012 и т. д.).
* Лимит делится между разделами поровну, чтобы выборка не состояла из одного.
* Раздел, который не удалось проверить, остаётся в списке; результат неполной
загрузки кэш не затирает. Искать заново: «Дополнительно» → 7 или
`collect.py discover --force`.
## Сохранённые страницы читаемы
В `data/raw/<трекер>/<id>.html` лежит снимок страницы «как на сайте»: UTF-8
(`<meta charset>`), `<base>` на адрес раздачи (картинки подгружаются с сайта),
таблицы стилей вынесены в `data/raw/_css/<хеш>.css` (общие для всех страниц),
скрипты убраны, чтобы страница из файла не уходила на сайт. Открывается двойным
кликом. Таблицы стилей чужого домена (rutracker, nnm) остаются ссылкой на сайт и
работают при наличии интернета.
Страницы, сохранённые раньше, чинит `python tools/collect.py repair-html`
(кодировка, `<base>`, стили сайта одной загрузкой со страницы трекера; нужен Chrome
и вход, `--no-css` — без браузера). Время файлов не меняется, кэши разбора
остаются.
## Где что лежит
* `data/raw/<трекер>/<id>.html|json` — страница целиком (outerHTML после
загрузки вкладок kinozal) и поля для парсера. В git не попадает.
* Профиль Chrome — `%LOCALAPPDATA%\dub-ai-harvest\chrome-profile`, вне
репозитория (`DUB_CHROME_PROFILE`, `DUB_CHROME_PORT` меняют каталог и порт).
* `tools/harvest/harvest_core.py` — перенесённый `dataset.py` (CDP-клиент и
парсеры меток), единственная правка — переменные окружения для путей.
## id страниц
`release_id` в `labels.csv` совпадает с именем `data/pages/<id>.txt`.
Раздачи, размеченные раньше, сохраняют прежний числовой id (их разбиение
train/test не меняется); новые получают префикс `rt-`, `nnm-`, `kz-`, чтобы
id разных трекеров не пересекались. Коллизий в текущих данных нет, но один id
уже встречался в сырых данных двух трекеров.
## Полные и урезанные страницы
Импорт старого harvest (пункт 5) даёт страницу из сохранённого текста поста:
без шапки сайта, соседних раздач и заголовка. Такие страницы перечислены в
`data/pages_postonly.txt`. При обучении и оценке сегментатора их стоит
считать отдельно: на инференсе модель видит полную страницу.
## Что в метках слабое
Метки строятся парсерами (`tools/harvest/harvest_core.py`), поэтому они
повторяют логику правил. Для нарезки внутри строки («Дублированный,
многоголосый») нужна ручная проверка — режим ревью ещё не сделан.
## Разнообразная выборка (режим balanced)
Списки разделов идут от новых к старым, поэтому чтение подряд даёт только свежие
релизы. По умолчанию (`Настройки` → режим `balanced`, `tools/diversity.py`):
* страницы раздела берутся **вразброс по всей глубине** (число страниц читается из
пагинации; посещённые запоминаются в `_state.json`, круг по разделу продолжается
с места остановки);
* с каждой страницы берётся не всё, а `per_page` тем (по умолчанию 6) из **самых
малочисленных корзин** «год (до 2010 — десятилетие) × качество (4K / 1080 / 720 /
SD) × фильм/сериал»; уже собранное считается по `data/meta.csv`;
* режим `sequential` — прежнее чтение подряд.
## Скорость переиндексации
Разбор HTML (страницы, метки, `meta.csv`) идёт в нескольких процессах: по умолчанию
число ядер минус два (до 12); меняется в «Настройки» (`workers`, 0 — авто, 1 — без
процессов). `meta.csv` берёт текст из уже записанных `data/pages`, а не разбирает
HTML второй раз. На выборке в 600 раздач — 45 с → 8 с при тех же результатах.
## Если сбор «завис»
* В панели у трекера теперь видно номер раздачи, которая качается: `скачиваю 6894333`.
* Короткая страница (закрытая или перенесённая тема), которая загрузилась и не меняется,
принимается через ~5 с; раньше сборщик ждал таймаут 45 с и повторял его.
* Раздача дольше 40 с попадает в ошибки как «медленно, N с» (id можно открыть вручную).
* Сторож: раздача дольше 150 с — вызов к вкладке обрывается, вкладка переподключается, сбор
идёт дальше.
## Скорость и повторный разбор
* **Вкладки.** Сбор идёт в нескольких вкладках одного трекера (общий Chrome, общий
вход). Число вкладок — пункт меню «Настройки» (1–4, по умолчанию 3), запоминается;
в командах `collect` / `backfill` флаг `--tabs`. Больше вкладок — быстрее, но выше
риск лимитов и проверок Cloudflare: при ошибках снизьте до 1–2.
* **Без лишних пауз.** Ожидание вкладок kinozal и загрузки страниц опрашивает
состояние, а не спит фиксированное время; тяжёлые темы не ждут `complete`;
удалённые раздачи определяются сразу и запоминаются (`data/raw/_dead.json`).
* **Ничего не разбирается дважды.** Сбор пропускает уже сохранённые, размеченные и
удалённые раздачи и продолжает списки с места остановки. `build` запоминает
«подпись» файлов (`data/raw/_built.json`, `_meta_cache.json`): повторный запуск
разбирает только новое и изменённое (на 2800 релизах: 59 с → 2 с). Кэш сбрасывается
сам, если поднять `PARSE_VERSION` (collect.py) или `META_VERSION` (meta.py) после
правки парсеров; вручную: «Дополнительно» → 4 → «Пересобрать всё заново».
+27 -6
View File
@@ -10,10 +10,13 @@
регулярками** — модель учится воспроизводить эту разметку и обобщать её на
новые формулировки.
Снимок: 3286 строк, 3 источника — rutracker 1660, nnm 1549, kinozal 77.
После подготовки: 3284 строки + 458 аугментированных морфологических
вариантов («многоголосое», «дублированное» и т.п.), всего 3742 обучающих
примера. Файлы `*_entries.csv` / `*_releases.csv` — те же данные в нарезке
Размер (на момент последней сборки): ~41 тыс. строк из ~28 тыс. релизов, 3 источника
(rutracker, nnm, kinozal); HTML-дамп страниц — `data/raw` (~32 тыс. файлов, ≈2.6 ГБ, в git не
хранится), разобранные тексты — `data/pages`. Точные числа показывает «Статистика датасета» в
`tools/collect.py` (пункт 3) и отчёт `reports/data_audit.md` после `prepare_data`.
Ранние цифры этого документа (3286 строк) относились к первому снимку.
К строкам при подготовке добавляются аугментированные морфологические варианты
(«многоголосое», «дублированное» и т.п.). Файлы `*_entries.csv` / `*_releases.csv` — те же данные в нарезке
по источникам и по релизам; модель используется только `labels.csv`.
## Колонки labels.csv
@@ -40,9 +43,11 @@
`raw` берётся `role_raw` конкретной дорожки, чтобы одинаковый текст не
получал разные метки).
## Семантика translation_type (14 классов)
## Семантика translation_type (15 классов после пересборки; 14 в ранних моделях)
Составные метки «кто делал × сколько голосов»:
Составные метки «кто делал × сколько голосов». Набор классов не зашит в код: `prepare_data` берёт его
из данных (`label_maps.json`), поэтому модель, обученная до пересборки, знает 14 классов, а после —
ещё и `люб. дубляж`.
* префикс: `проф.` (профессиональная), `люб.` (любительская), `авт.`
(авторская), без префикса — в тексте не указано;
@@ -52,6 +57,22 @@
Все «*неизвестно»-варианты объединены в `неизвестно`; типы «оригинал» и
«субтитры» (1–2 строки-проскока генератора) удалены.
### Что изменено в разметке (парсеры `tools/harvest/harvest_core.py`, `PARSE_VERSION = 3`)
| Проблема в старых метках | Исправление |
|---|---|
| Любительский дубляж («Любительский (дублированный) Red Head Sound», «псевдодублированный») размечался как `проф. дубляж` (644 из 720 строк) | Новый класс `люб. дубляж` |
| Дубляж по имени студии: «многоголосый … Дубляжная», «Продубляж» получали тип «дубляж» | «дубляж» считается типом, только если это отдельное слово или «дублир…» |
| Несколько дорожек в одной строке с одной меткой («Профессиональный (двухголосый закадровый), авторский (одноголосый закадровый)»; 7.3% строк) | `split_multi_rows`: строка раскладывается на дорожки теми же правилами, что и сервис (`src/html_extract._split_entries`); «Многоголосый, профессиональный - LostFilm» остаётся одной дорожкой. Остатки старых данных `prepare_data` отбрасывает (счётчик в аудите) |
| Язык почти не размечался (122 строки `UKR`, 47 `ENG`): kinozal сопоставлял «Перевод» только с русскими аудиодорожками, а в строках `\|Дубляж, Le Doyen\|` студия читалась как одно имя | Сопоставление со всеми аудиодорожками; язык берётся из строки «Аудио»; `src/trackinfo.parse_audio_line` делит `\|Тип, Студия\|` |
| Дорожки, названные только в строках «Аудио» (украинский дубляж и т.п.) терялись | `audio_only_rows`: строки с блоком `\|…\|`, студией и определимым типом (оригинал, комментарии, источники вроде «Blu-ray CEE» не берутся) |
| Одни и те же короткие строки доминировали («Перевод: Дублированный» — 2727 копий) | `prepare_data --max-dup` (по умолчанию 40 копий одного текста и типа) |
| Дисбаланс классов | Веса классов в потерях типа: корень из обратной частоты, потолок 4 (`--no-class-weights` отключает; значение записывается в `run.json`) |
Для применения к уже собранным данным нужна переиндексация с пересчётом меток
(`python tools/collect.py` → пункт 2 → «Переиндексировать data/raw … y»): старые строки
`labels.csv` заменяются, копия прежнего файла сохраняется как `data/labels.bak_<время>.csv`.
## Аудит (reports/data_audit.md) и что с находками сделано
| Находка | Обработка |
+282
View File
@@ -0,0 +1,282 @@
# Docker: запуск grabber-ai на VPS и сборка в Gitea CI/CD
Контейнер запускает HTTP-сервис (`python -m src.service`): по ссылке на раздачу или по HTML определяет
дорожки озвучки (тип, студия, 18+, язык, роль, параметры аудио). Для трекеров за Cloudflare сервис
обращается к вашему **FlareSolverr**, адрес которого задаётся переменной окружения.
```
клиент ──HTTP──▶ grabber-ai (:8000) ──▶ трекер (rutracker / nnmclub / kinozal)
(grabber) │ ▲ при 403/503 или странице-заглушке
└─▶ FlareSolverr ──┘ (DUB_FLARESOLVERR_URL)
```
Содержание: [быстрый старт](#быстрый-старт) · [переменные окружения](#переменные-окружения) ·
[FlareSolverr](#flaresolverr) · [API](#api) · [модели](#модели-и-как-они-попадают-в-контейнер) ·
[CI/CD в Gitea](#cicd-в-gitea) · [VPS](#развёртывание-на-vps) · [проблемы](#если-что-то-не-работает)
## Что в репозитории
| Файл | Назначение |
|---|---|
| `Dockerfile` | образ: Python 3.12, torch (CPU), код сервиса, при наличии — модели |
| `requirements-service.txt` | зависимости сервиса (без sklearn/pandas/matplotlib/onnx — они нужны только обучению) |
| `docker-compose.yml`, `.env.example` | сервис + FlareSolverr, настройки через `.env` |
| `.dockerignore` | в контекст сборки попадают только `src/`, зависимости и `docker-models/` |
| `docker-models/models/` | сюда `tools/pack_models.py` кладёт модели для образа (в git не попадают) |
| `tools/pack_models.py` | упаковка `models/best` + `models/segmenter` для образа/CI |
| `.gitea/workflows/docker.yml` | сборка, проверка и публикация образа в реестр Gitea |
## Быстрый старт
Нужны Docker и модели (обучены и выбраны через `tools/collect.py`, пункт 7: `models/best`,
`models/segmenter`).
```bash
# 1. положить модели в контекст сборки (образ получится «с моделями»)
python tools/pack_models.py --stage docker-models
# 2. настройки
cp .env.example .env # впишите DUB_API_TOKEN (openssl rand -hex 24)
# 3. сборка и запуск вместе с FlareSolverr
docker compose up -d --build
# 4. проверка
curl http://localhost:8000/health
curl -X POST http://localhost:8000/predict \
-H "Authorization: Bearer <DUB_API_TOKEN>" -H "Content-Type: application/json" \
-d '{"url": "https://rutracker.org/forum/viewtopic.php?t=6894333"}'
```
Первый запуск контейнера грузит модель 5–15 с (`/health` отвечает после этого; healthcheck даёт на
старт до двух минут).
**Образ без моделей.** Если шаг 1 пропустить, образ соберётся пустым по моделям и при старте напишет,
какого каталога не хватает. Тогда смонтируйте модели с хоста — раскомментируйте `volumes` в
`docker-compose.yml`: `./models:/app/models:ro` (внутри нужны `models/best` и `models/segmenter`,
причём у `best` должен быть `config.json` — его создаёт `pack_models.py`; каталог из `pack_models.py
--stage` для этого и подходит: `-v ./docker-models/models:/app/models:ro`).
## Переменные окружения
Всё настраивается переменными (в `.env` для compose или `-e` у `docker run`). Аргументы командной
строки сервиса, если заданы, приоритетнее.
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `DUB_FLARESOLVERR_URL` | не задана (в compose `http://flaresolverr:8191`) | адрес FlareSolverr без `/v1`; пусто — Cloudflare не обходится |
| `DUB_API_TOKEN` | не задан | **необязателен.** Если задан, `/predict` требует токен (см. [API](#api)); если нет — API открыт всем, кто достучится до порта (для домашней сети допустимо, для доступа из интернета задайте токен). `/health` всегда открыт |
| `DUB_FETCH_TIMEOUT` | `60` | таймаут загрузки страницы, с. FlareSolverr решает challenge 10–40 с, прежние 15 с не хватало |
| `DUB_TORCH_THREADS` | все ядра | потоков torch; на VPS с несколькими другими сервисами ограничьте |
| `DUB_MODEL_DIR` | `/app/models/best` | каталог основной модели |
| `DUB_SEGMENTER_DIR` | `/app/models/segmenter` | каталог сегментатора страниц; нет каталога — работают правила |
| `DUB_HOST`, `DUB_PORT` | `0.0.0.0`, `8000` | адрес/порт внутри контейнера (наружу — `DUB_PUBLISH_PORT` в compose) |
| `DUB_DEVICE` | авто | `cpu` или `cuda` (в образе только CPU-сборка torch) |
| `DUB_IMAGE` | `grabber-ai:local` | (compose) какой образ запускать, напр. из реестра Gitea |
| `DUB_PUBLISH_PORT` | `8000` | (compose) порт на хосте |
В образе жёстко заданы `HF_HUB_OFFLINE=1` и `TRANSFORMERS_OFFLINE=1`: сервис ничего не скачивает с
Hugging Face. Все файлы моделей, включая архитектуру основы (`config.json`), лежат рядом с весами.
## FlareSolverr
Сервис сначала запрашивает страницу напрямую. Если трекер ответил 403/503 или вернул страницу-заглушку
(Cloudflare «Just a moment», форма входа), запрос повторяется через FlareSolverr
(`POST {DUB_FLARESOLVERR_URL}/v1`, `cmd=request.get`). Без FlareSolverr такие страницы дают ошибку 400
с подсказкой.
Три способа подключить:
1. **Вместе с compose (по умолчанию).** Сервис `flaresolverr` поднимается рядом, порт наружу не
публикуется, `grabber-ai` ходит к нему по `http://flaresolverr:8191`.
2. **Свой FlareSolverr на другом хосте.** В `.env`: `DUB_FLARESOLVERR_URL=http://192.168.0.24:8191`, и
запускайте только `docker compose up -d grabber-ai` (контейнер FlareSolverr не нужен). Адрес должен быть
достижим из контейнера: с VPS до домашней сети — через VPN/туннель, не открывайте FlareSolverr в интернет.
3. **`docker run`:**
```bash
docker run -d --name grabber-ai -p 8000:8000 \
-e DUB_FLARESOLVERR_URL=http://flaresolverr:8191 -e DUB_API_TOKEN=... \
--network <сеть_с_flaresolverr> grabber-ai:local
```
Проверить, что адрес подхватился: `GET /health` → `"flaresolverr": true`. Проверка связи:
`docker compose exec grabber-ai python -c "import urllib.request as u; print(u.urlopen('http://flaresolverr:8191/').read()[:80])"`.
Замечания:
* FlareSolverr нестабилен: бывают `Timeout after 15.0 seconds` и ответ ~40 с. Это ошибка 400/502 для
одного запроса — клиенту стоит повторить запрос.
* kinozal: `cookie` **необязателен**. Описание большинства раздач открывается без входа, и запрос
`{"url": "https://kinozal.me/details.php?id=…"}` работает как есть. Если сервис ответил `400` про форму
входа (раздача закрыта для гостей), передайте `cookie` в теле запроса (см. ниже): он уйдёт и напрямую, и
в FlareSolverr.
* Сервис ходит только на rutracker, nnmclub и kinozal (список разрешённых хостов в `src/fetch.py`, в том
числе для редиректов): произвольные адреса через `url` не загружаются.
## API
Базовый адрес — `http://<хост>:8000`. Документация Swagger: `/docs`.
### `GET /health`
Без токена. Пример ответа:
```json
{"status": "ok", "model": "/app/models/best", "device": "cpu", "classes": ["авт. двухголосый", "..."],
"segmenter": "model", "flaresolverr": true, "auth": true}
```
`segmenter`: `model` — страницы режет обученный сегментатор, `rules` — запасные правила (нет `models/segmenter`).
### `POST /predict`
Ровно одно из полей тела:
| Поле | Что делает |
|---|---|
| `url` | сервис загружает страницу раздачи (с FlareSolverr при необходимости) и разбирает её; ответ дополнен `tracker` |
| `html` | разбор страницы, которую вы уже скачали (все дорожки релиза) |
| `text` | одна строка-дорожка: `«Перевод: Дублированный \| MovieDalen»` |
Необязательно: `title` (заголовок раздачи для `html`), `cookie` (для `url`, строка `a=b; c=d`).
Токен (если задан `DUB_API_TOKEN`): `Authorization: Bearer <токен>` либо `X-API-Key: <токен>`.
```bash
curl -s -X POST http://localhost:8000/predict \
-H "Authorization: Bearer $DUB_API_TOKEN" -H "Content-Type: application/json" \
-d '{"url": "https://rutracker.org/forum/viewtopic.php?t=6894333"}'
```
Ответ: `{"tracks": [...], "studios": [...], "is_18plus": bool, "tracker": "...", ...}`. Поля дорожки
(тип, студия, 18+, язык, роль, аудио-параметры, эпизоды) описаны в
[integration.md](integration.md) и [inference.md](inference.md).
| Код | Когда |
|---|---|
| 200 | успех |
| 400 | не то число полей; страница недоступна/заглушка (Cloudflare без FlareSolverr, FlareSolverr не справился); ссылка не с поддерживаемого трекера |
| 401 | задан `DUB_API_TOKEN`, а токена нет или он неверный |
| 502 | сеть: страницу не удалось загрузить |
## Модели и как они попадают в контейнер
Модели весят сотни МБ (`models/best` ≈ 700 МБ, `models/segmenter` ≈ 115 МБ), в git они не лежат
(`models/*` в `.gitignore`). Варианты:
| Способ | Плюсы | Минусы |
|---|---|---|
| **А. Запечь в образ** (`pack_models.py --stage docker-models`, в CI — через `MODELS_URL`) | образ самодостаточен, версия модели = тег образа | образ ≈ 2 ГБ, смена модели = новая сборка и pull |
| **Б. Смонтировать с хоста** (`-v ./models:/app/models:ro`) | образ лёгкий, модель меняется без пересборки (перезапуск контейнера) | модель нужно положить на VPS отдельно |
Для CI удобнее А. Чтобы CI знал модели, их нужно один раз положить в место, откуда он их скачает.
### Упаковка и выкладка в Gitea (Generic Packages)
```bash
python tools/pack_models.py # -> models-bundle.tar.gz (≈ 750 МБ)
# выкладка в реестр пакетов Gitea (версия — любая строка, например дата или хэш модели)
curl --fail -H "Authorization: token <GITEA_TOKEN>" \
--upload-file models-bundle.tar.gz \
https://<gitea>/api/packages/<owner>/generic/grabber-ai-models/2026-10-28/models-bundle.tar.gz
```
Ссылка для скачивания (она же `MODELS_URL`):
`https://<gitea>/api/packages/<owner>/generic/grabber-ai-models/2026-10-28/models-bundle.tar.gz`.
Если у вашей Gitea ограничен размер пакета (`[packages] LIMIT_SIZE_GENERIC`, по умолчанию без лимита, но
nginx перед ней часто режет загрузки ~1 МБ), поднимите лимиты или используйте способ Б.
Новая модель: обучить → выбрать в меню (пункт 7) → `pack_models.py` → выложить под новой версией →
обновить секрет `MODELS_URL` → запустить workflow (Actions → docker → Run workflow).
## CI/CD в Gitea
Workflow `.gitea/workflows/docker.yml` собирает образ и публикует его в **реестр контейнеров Gitea**
(`<gitea-host>/<owner>/grabber-ai`).
### Что настроить один раз
1. **Actions и раннер.** В Gitea включены Actions (`[actions] ENABLED=true`), в репозитории или
организации зарегистрирован `act_runner`. Раннеру нужен Docker: самый простой вариант — раннер на
хосте с метками вида `ubuntu-latest:host` (команды `docker build` идут на Docker хоста) или раннер в
контейнере с примонтированным `/var/run/docker.sock`. Образ-раннер (`ubuntu-latest:docker://…`) без
доступа к Docker daemon собрать образ не сможет.
2. **Secrets** (Settings → Actions → Secrets):
| Секрет | Значение |
|---|---|
| `ACTION_PAT` | токен Gitea (Settings → Applications → Access Tokens) с правами `write:package` и `read:package`; им образ публикуется в реестр и скачиваются модели. Имена секретов не могут начинаться с `GITEA_` и `GITHUB_`, поэтому не `GITEA_TOKEN` |
| `REGISTRY_USER` | необязательно: владелец токена; по умолчанию — владелец репозитория (`denozord`) |
| `MODELS_URL` | необязательно: ссылка на `models-bundle.tar.gz`; нет — образ без моделей |
3. Адрес реестра — `git.shx.one` (HTTPS), он задан в workflow (`REGISTRY_HOST`); другой можно указать
переменной репозитория `REGISTRY_HOST`. Адрес берётся не из `GITHUB_SERVER_URL`: внутри раннера это
внутренний `http://192.168.x.x:3000` без HTTPS, и `docker login` падает с «server gave HTTP response
to HTTPS client». Если реестр всё же по HTTP, добавьте его в `insecure-registries` Docker
(`/etc/docker/daemon.json`).
### Что делает workflow
| Шаг | Действие |
|---|---|
| проверка | `compileall` кода сервиса |
| модели | при заданном `MODELS_URL` скачивает архив в `docker-models/`, проверяет `models/best/model.pt` |
| теги | `sha-<8 символов коммита>`; на тег `vX.Y.Z` — ещё и `vX.Y.Z`; на `main`/`master` — ещё и `latest` |
| сборка | `docker build` |
| проверка образа | если модели внутри — запускает контейнер и ждёт ответ `/health` (до 5 минут) |
| публикация | `docker login` и `docker push` всех тегов |
Запуск: пуш в `main`/`master`, тег `v*` или вручную. Релиз: `git tag v1.0.0 && git push --tags`.
## Развёртывание на VPS
1. Установить Docker и Compose, залогиниться в реестр Gitea:
```bash
docker login <gitea-host> -u <user> # токен с read:package
```
2. Положить на VPS `docker-compose.yml` и `.env` (из `.env.example`):
```
DUB_IMAGE=<gitea-host>/<owner>/grabber-ai:latest
DUB_API_TOKEN=<openssl rand -hex 24>
DUB_FLARESOLVERR_URL=http://flaresolverr:8191
```
В `docker-compose.yml` строка `build: .` на VPS не нужна — можно удалить.
3. `docker compose pull && docker compose up -d`.
4. Обновление: после новой сборки в CI — `docker compose pull && docker compose up -d`.
**Ресурсы.** CPU-инференс; модель ruBert-base в fp32 (~0,7 ГБ) + сегментатор + torch — ожидайте
порядка 1,5–2 ГБ памяти (точный замер на VPS не делался; в compose стоит лимит 3 ГБ). Скорость: разбор
страницы раздачи на моём ПК при 2 потоках CPU — около 0,9 с (на VPS зависит от процессора). Загрузка по
`url` добавляет время сети и, при Cloudflare, 10–40 с FlareSolverr. Нужны ≥ 2 vCPU и ≥ 3 ГБ RAM; на
1 ГБ контейнер не поместится — тогда обучите и используйте `tiny2` (≈ 117 МБ весов; для него
`DUB_MODEL_DIR` указывает на каталог этой модели).
**Безопасность.**
* Не публикуйте `:8000` без токена; лучше поставить перед сервисом reverse-proxy с TLS (Caddy:
`dub.example.com { reverse_proxy 127.0.0.1:8000 }` — сертификат выдаётся автоматически) и закрыть порт
файрволом. Токен по HTTP передаётся открытым текстом.
* FlareSolverr (`:8191`) наружу не открывайте: у него нет аутентификации, он умеет открывать любые URL.
* Контейнер работает от непривилегированного пользователя, модели монтируются только на чтение.
## Если что-то не работает
| Симптом | Причина и решение |
|---|---|
| Контейнер завершился: `нет модели: /app/models/best/model.pt` | образ без моделей и каталог не смонтирован — см. [модели](#модели-и-как-они-попадают-в-контейнер) |
| Ошибка про `config.json` / доступ к huggingface.co при старте | у `models/best` нет `config.json` (старый чекпоинт); соберите через `tools/pack_models.py` — он добавит его |
| `/predict` → 400 «страница недоступна: Cloudflare challenge … (задайте FlareSolverr)» | не задан `DUB_FLARESOLVERR_URL` или контейнер его не видит (`/health` → `flaresolverr`), проверьте сеть/имя сервиса |
| 400 `FlareSolverr: … Timeout` | FlareSolverr не успел решить challenge; повторите запрос, увеличьте `DUB_FETCH_TIMEOUT`, перезапустите FlareSolverr |
| 401 | не передан токен или он отличается от `DUB_API_TOKEN` |
| Контейнер `unhealthy` в первые минуты | модель ещё грузится; `docker logs grabber-ai`. Если не проходит — не хватает памяти (`docker stats`) |
| CI: `Cannot connect to the Docker daemon` | у раннера нет доступа к Docker (см. «Что настроить один раз») |
| CI: `server gave HTTP response to HTTPS client` | workflow взял внутренний адрес Gitea; должен быть `REGISTRY_HOST` = `git.shx.one` (уже так в workflow) |
| CI: `unauthorized` при push | у `ACTION_PAT` нет `write:package` или токен принадлежит не владельцу/участник пространства имён репозитория |
| CI: образ без моделей, хотя ждали с ними | секрет `MODELS_URL` пуст или шаг скачивания пропущен — смотрите лог шага «Модели» |
## Что проверено, а что нет
Проверено на ПК разработчика: сервис запускается без sklearn/matplotlib/pandas/onnx; модель грузится
полностью офлайн (`HF_HUB_OFFLINE=1`) из каталога, собранного `pack_models.py`, и даёт те же ответы, что
и обычная загрузка; токен (Bearer / X-API-Key), `DUB_FETCH_TIMEOUT`, `/health`; YAML workflow и compose
разбираются; тесты проекта проходят.
**Не проверено: `docker build`, запуск контейнера и workflow в Gitea** — на машине разработчика Docker не
установлен, а Gitea-репозитория ещё нет. Первая сборка может потребовать правок (например, имя метки
раннера, доступность колёс torch для вашей архитектуры: используется
`https://download.pytorch.org/whl/cpu`, `torch==2.11.0`, Python 3.12, x86_64 и aarch64).
+5 -2
View File
@@ -24,7 +24,8 @@ HTML распознаётся по содержимому, так что `--html
```bash
python -m src.predict --url https://rutracker.org/forum/viewtopic.php?t=123
python -m src.predict --url https://kinozal.tv/details.php?id=1 --cookie "uid=..; pass=.."
python -m src.predict --url https://kinozal.tv/details.php?id=1 # kinozal без входа
python -m src.predict --url https://kinozal.tv/details.php?id=1 --cookie "uid=..; pass=.." # если раздача закрыта для гостей
```
Поддерживаются только rutracker / kinozal / nnm-club (allowlist доменов,
@@ -61,6 +62,8 @@ model.predict_file("page.txt")
## JSON-контракт
Полный справочник значений (15 классов `translation_type`, `lang`, `role`, `audio`, агрегат релиза, принцип работы) — в [api-reference.md](api-reference.md); ниже краткий пример.
```json
{
"tracks": [
@@ -81,7 +84,7 @@ model.predict_file("page.txt")
| Поле | Тип | Описание |
|---|---|---|
| `segment` | string | текст дорожки, по которому сделано предсказание (префикс снят) |
| `translation_type` | string | один из 14 классов (см. docs/dataset.md) |
| `translation_type` | string | один из 15 классов (см. docs/dataset.md) |
| `type_confidence` | float | softmax-вероятность класса |
| `studio_span` | string \| null | фрагмент текста, распознанный как имя автора |
| `studio` | string \| null | каноническое имя автора по словарю алиасов; null — автора в тексте нет |
+38 -3
View File
@@ -21,6 +21,8 @@ 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": [...]}`
@@ -32,6 +34,18 @@ API:
Ответ: `{"tracks": [ …контракт из docs/inference.md… ]}`.
Каждая дорожка помимо типа, студии и 18+ несёт (поля только добавляются, прежние не менялись):
| Поле | Что это |
|---|---|
| `lang`, `lang_confidence`, `lang_source` | язык дорожки (`RUS`, `UKR`, `ENG`, …); источник: `model` (языковая голова), `audio` (строка «Аудио»), `text` (слова в строке дорожки) или `null` |
| `role` | `перевод` / `оригинал` / `комментарии` (по строке «Аудио» и тексту, правилами) |
| `official` | в строке «Аудио» сказано «официальный» / «лицензионный» |
| `audio` | `{codec, channels, bitrate_kbps}` из строки «Аудио» (или `null`) |
Русский и украинский дубляж одной студии — две отдельные дорожки; так же раздельны обычная
дорожка студии и её дорожка 18+ (если на странице две строки «Аудио» с одной студией).
curl:
```bash
@@ -127,11 +141,12 @@ python -m src.predict --url <ссылка> --flaresolverr http://127.0.0.1:8191
### grabber (PHP)
kinozal требует авторизации, поэтому grabber скачивает страницу сам
Описание на kinozal часто открывается и без входа, и тогда достаточно `{"url": …}` (cookie необязателен). Для раздач,
закрытых для гостей, а также чтобы не хранить cookie в сервисе, grabber скачивает страницу сам
(`KinozalClient`) и отправляет сервису готовый HTML вместе с заголовком раздачи:
```php
$ch = curl_init('http://dub-ai:8000/predict');
$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'],
@@ -178,7 +193,7 @@ python -m src.export_onnx # -> models/best-onnx/ + reports/onnx_parit
```bash
cd examples/rust/onnx_local && cargo build
ORT_DYLIB_PATH="$(find ../../.venv -name onnxruntime.dll | head -1)" \
./target/debug/dub-ai-onnx-local.exe ../../models/best-onnx \
./target/debug/grabber-ai-onnx-local.exe ../../models/best-onnx \
"Перевод: Дублированный | MovieDalen"
```
@@ -204,3 +219,23 @@ node onnx_local.mjs "../../models/best-onnx" "Перевод: Дублирова
одну дорожку; для полной обработки страницы нужно сначала получить сегменты
(например, один вызов лёгкого 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), поэтому
обучаемый разбор можно делать после накопления данных сборщиком.
+1 -1
View File
@@ -65,7 +65,7 @@
| 1 baseline | сделано |
| 2 URL + агрегат релиза | сделано: `src/fetch.py`, `src/release.py`, `--url`, `POST /predict {"url"}` |
| 3 title-fallback | сделано (`ПМ (LostFilm)` → дорожка с `source: "title"`); заголовок берётся из HTML (`<h1>`/`<title>`), из текстового дампа (первая строка вида «… / год / …»), либо передаётся явно (`title`). Если на странице есть дорожка того же типа без студии, студия вписывается в неё (`source: page+title`), а не дублируется. **Эффект на e2e мал**: kinozal P/R 0.107 → 0.143, rutracker без изменений (0.826 / 0.984) — в разметке датасета студия из заголовка почти не нужна |
| 4 kinozal-вкладка | сделано: `fetch_html` для kinozal скачивает вкладки `showtab(id,N)` (`get_srv_details.php?id=…&pagesd=N`, кроме «Техданных» и «Скриншотов») и дописывает их в `#tabs`. Кодек-строки «Аудио N: … (MVO, LostFilm)» сегментатор отбрасывает (в разметке rutracker это не `raw`), поэтому они берутся правилом `codec_segments` как второстепенный источник (`source: codec` / `page+codec`): только дорожки со студией, студия-дубль (`same_studio`) пропускается. e2e test rutracker без изменений (P 0.988 / R 0.982); train-страницы P 0.975 → 0.951 |
| 4 kinozal-вкладка | сделано: `fetch_html` для kinozal скачивает вкладки `showtab(id,N)` (`get_srv_details.php?id=…&pagesd=N`, кроме «Техданных» и «Скриншотов») и дописывает их в `#tabs`. Кодек-строки «Аудио N: … (MVO, LostFilm)» сегментатор отбрасывает (в разметке rutracker это не `raw`), поэтому они берутся правилом `codec_segments` как второстепенный источник (`source: codec` / `page+codec`): только дорожки со студией, студия-дубль (`same_studio`) пропускается. Кодек-строки подключаются, только если у какой-то дорожки страницы нет студии. На test-сплите модели 29.10 (по 300 релизов), студии P/R без → с кодек-строками: rutracker 0.908/0.882 → 0.840/0.900 (почти все «ложные» — реальные студии, которых нет в эталоне), nnm 0.875/0.915 → 0.875/0.940, kinozal (текст из raw JSON) 0.816/0.845 → 0.800/0.863 |
| 5 false-positives сегментации | сделано: починена резка «Авторский, одноголосый» по запятой; постобработка спана (`refine_span`: до границ слова, мусор вроде `L,C,R,l,r`/кодеков → null). Остаток — промахи модели на `kbps \| DUB Автор` и особенности разметки |
| 9 переобучение | датасет вырос 3286 → 6864 строк; обе модели переобучены (см. ниже) |
| 6 tiny2 + int8 | сделано: 29.3 МБ, parity 99.8% тип / 99.2% спан / 100% 18+ (`reports/onnx_parity_tiny2_int8.md`) |
+28 -1
View File
@@ -10,7 +10,7 @@ torch 2.11.0+cu128).
## Установка (один раз)
```bat
cd C:\Users\shats\Dev\DUB-AI
cd C:\Users\shats\Dev\grabber-ai
python -m venv .venv
.venv\Scripts\python -m pip install --upgrade pip
@@ -46,6 +46,33 @@ CUDA, GroupKFold-сплиты, ранний stopping (patience 3), метрик
64, до 20 эпох; base — lr 3e-5, batch 32, до 10 эпох; AdamW, weight_decay
0.01, linear warmup 10%, grad clip 1.0, seed 42.
## Выбор основы и сравнение моделей (меню, пункты 2 и 7)
`python tools/collect.py` → «2 Переобучить»: после вопроса «сегментатор + основные модели» предлагается
список основ. Каждая обучается отдельным запуском в `models/runs/<время>_<профиль>/` (`python -m src.train
--runs`), **`models/best`, `reports/final_*` и `summary.md` при этом не меняются**.
| профиль | основа | замечания |
|---|---|---|
| `tiny2` | rubert-tiny2, 30M | самая быстрая, подходит для ONNX/CPU |
| `base` | ruBert-base, 178M | текущий рабочий выбор |
| `large` | ruBert-large, 427M | помещается в 16 ГБ (bf16, batch 16); обучение ~3× дольше base, инференс на CPU медленнее |
| `modern` | RuModernBERT-base, 149M | новая архитектура; ONNX-экспорт проекта (`token_type_ids`) её не принимает |
Кросс-валидация (5 прогонов) на итоговую модель не влияет, нужна только для оценки устойчивости; без неё
обучение примерно в 6 раз короче. Флаги CLI: `--folds 0`, `--lr`, `--batch`, `--epochs-override`, `--tag`.
Пункт «7 Модели» (и конец пункта 2) сравнивает **все** модели (`models/best`, запуски, `best_backup_*`,
`models/tiny2`) на **одной и той же текущей тестовой выборке**: каждая оценивается заново, в своём входном
формате (без «[RUS AC3 …]» для моделей, обученных без аудио-контекста). Цифры кэшируются в
`<модель>/eval.json` под отпечатком выборки. Таблица показывает joint exact с 95% интервалом и парное
сравнение с `models/best`: сколько строк новая модель починила и сколько сломала, значима ли разница
(знаковый критерий). Разница в пределах шума так и помечается. Звёздочка у модели означает, что она обучена
на другом разбиении и часть текущих тестовых строк могла видеть — её цифры завышены. Выбранная модель
копируется в `models/best` (прежняя уходит в `models/best_backup_*`, откат — тем же пунктом); перед заменой
выводятся предупреждения (другая основа нужна в кэше Hugging Face на машине сервиса, вес, ONNX).
Запуски и архивные копии можно удалять из того же меню (каждая 0.1–1.4 ГБ).
## Переобучение на свежем датасете
Сборщик (`deepseek-harness/default-workspace/dataset.py`) дописывает