Compare commits

..

10 Commits

29 changed files with 1104 additions and 196 deletions
+5 -2
View File
@@ -3,6 +3,9 @@
# CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o jellybit ./cmd/jellybit
# distroless/static несёт CA-сертификаты (HTTPS к LLM/TMDB). Пользователь
# задаётся в compose (user: "1000:1000").
#
# Тома (см. compose): /config (ro, рендерится плейбуком — восстановимо при
# деплое) + /data (SQLite, бекапить-и-не-терять).
FROM gcr.io/distroless/static-debian12
COPY jellybit /usr/local/bin/jellybit
@@ -10,8 +13,8 @@ COPY jellybit /usr/local/bin/jellybit
EXPOSE 8080
# В distroless нет shell/curl — проверку делает сам бинарь (порт берёт из
# /data/config.toml). compose может переопределить параметры healthcheck.
# /config/config.toml — дефолтный путь). compose может переопределить параметры.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["/usr/local/bin/jellybit", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/data/config.toml"]
ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/config/config.toml"]
+6 -3
View File
@@ -26,6 +26,8 @@ Arr-стек (prowlarr/radarr/sonarr) плохо ложится на русск
фильм/сериал и нужная раскладка.
5. Файлы **хардлинкаются** в библиотеку Jellyfin — источник остаётся в
раздаче, место на диске не дублируется.
6. После раскладки сервис (опц.) просит Jellyfin пересканировать
медиатеку, чтобы новые файлы быстрее появились в проигрывателе.
При высокой уверенности раскладка выполняется автоматически, иначе —
уходит на подтверждение человеку.
@@ -93,6 +95,7 @@ jellybit recognize <infohash> --dry-run [--context "..."] --config ./config.toml
бинарь: `jellybit healthcheck` (GET `/healthz` по порту из конфига, exit 0/1).
Контейнер: `user 1000:1000`, порт `8080` на хост, mount `/srv/media` (единая
песочница для хардлинков) + data-том с `config.toml`/SQLite; к qBittorrent —
по сети Docker. Конкретная деплой-обвязка (плейбук, секреты) держится в
отдельном приватном репозитории и в комплект не входит.
песочница для хардлинков) + том `/config` (ro, `config.toml`, восстановим при
деплое) + data-том `/data` (SQLite, бекапить); к qBittorrent — по сети Docker.
Конкретная деплой-обвязка (плейбук, секреты) держится в отдельном приватном
репозитории и в комплект не входит.
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// нет shell/curl: docker зовёт сам бинарь.
func runHealthcheck(args []string) error {
fs := flag.NewFlagSet("healthcheck", flag.ContinueOnError)
configPath := fs.String("config", "/data/config.toml", "путь к config.toml")
configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
if err := fs.Parse(args); err != nil {
return err
}
+1 -1
View File
@@ -23,7 +23,7 @@ import (
// Только чтение: ни записи в БД, ни хардлинков.
func runRecognize(args []string) error {
fs := flag.NewFlagSet("recognize", flag.ContinueOnError)
configPath := fs.String("config", "/data/config.toml", "путь к config.toml")
configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
dryRun := fs.Bool("dry-run", true, "только показать план, без изменений (единственный режим)")
contextStr := fs.String("context", "", "доп. текстовый контекст для распознавания")
if err := fs.Parse(args); err != nil {
+21 -1
View File
@@ -17,6 +17,7 @@ import (
"git.vakhrushev.me/av/jellybit/internal/config"
"git.vakhrushev.me/av/jellybit/internal/httpapi"
"git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/jellyfin"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/llm"
"git.vakhrushev.me/av/jellybit/internal/logging"
@@ -32,7 +33,7 @@ import (
// воркер (фоном) → HTTP-сервер; останавливается по SIGINT/SIGTERM.
func runServe(args []string) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
configPath := fs.String("config", "/data/config.toml", "путь к config.toml")
configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
if err := fs.Parse(args); err != nil {
return err
}
@@ -117,6 +118,25 @@ func runServe(args []string) error {
MagnetTimeout: cfg.Worker.MagnetTimeout.Std(),
}, logger)
// Пересканирование Jellyfin после раскладки (опц.). Недоступность Jellyfin
// не валит сервис — скан просто не сработает (залогируется в воркере).
if cfg.Jellyfin.Enabled {
if cfg.Jellyfin.URL == "" || cfg.Jellyfin.APIKey == "" {
return fmt.Errorf("jellyfin enabled, but url or api_key is empty")
}
jf, jerr := jellyfin.New(jellyfin.Config{
URL: cfg.Jellyfin.URL,
APIKey: cfg.Jellyfin.APIKey,
Proxy: cfg.Jellyfin.Proxy,
Timeout: cfg.Jellyfin.Timeout.Std(),
}, logger)
if jerr != nil {
return fmt.Errorf("jellyfin client: %w", jerr)
}
wrk.SetScanner(jf)
logger.Info("jellyfin rescan enabled", "url", cfg.Jellyfin.URL)
}
router, err := httpapi.NewRouter(httpapi.Deps{
Logger: logger,
Ingestor: ingestor,
+7
View File
@@ -45,6 +45,13 @@ enabled = false # без ключа; только сери
proxy = ""
timeout = "10s"
[jellyfin]
enabled = false # включить пересканирование медиатеки после раскладки
url = "http://jellyfin:8096" # по имени сервиса в общей docker-сети
api_key = "" # API-ключ Jellyfin (Dashboard → API Keys)
proxy = "" # опц. HTTP-прокси
timeout = "10s"
[worker]
poll_interval = "5s"
stuck_after = "1h"
@@ -0,0 +1,69 @@
# Авто-раскладка только при подтверждённом матче в метабазе
- Дата: 2026-06-13
## Контекст
jellybit распознаёт содержимое релиза через LLM по **недоверенным**
сигналам: имя торрента, текстовый контекст человека, распарсенное
сообщение бота — всё управляется извне и может содержать инъекции. По
результату распознавания нужно решить: разложить файлы хардлинками
автоматически или отправить на ревью человеку. Цена ошибки авто-раскладки
реальна — мусор в библиотеке Jellyfin под неверным названием/папкой,
возможно поверх чужого. Хочется максимум авто, но не ценой тихих ошибок.
Силы и ограничения:
- LLM хорошо разбирает русские и релиз-имена, но галлюцинирует, а его
самооценка (`confidence`) плохо откалибрована и тривиально поддаётся
инъекции из тех же недоверенных сигналов.
- Внешние базы (TMDB/TVDB/TVMaze) дают **независимый** авторитетный сигнал:
каноническое имя + `provider_id`. Но русские релизы и аниме часто в них
отсутствуют.
- Безопасность раскладки уже держится на валидации пути, не на промпте
(см. [recognition.md](../specs/recognition.md)); решение «авто vs review» —
второй слой защиты, на уровне доверия результату.
## Рассмотренные варианты
- **Гейт по самооценке LLM (`confidence ≥ порог`).** Просто и даёт
максимум авто. Но `confidence` не откалибрована и инъектируема —
«уверенный» неверный ответ прошёл бы молча. Небезопасно.
- **LLM + структурная валидация, без обязательной базы.** Ловит часть
ошибок (число файлов у фильма, дыры/дубли в нумерации S·E), но не ловит
«правильную структуру под неверным названием». Недостаточно как
единственный гейт авто.
- **Авто только при подтверждённом матче в базе + валидация +
согласованность сигналов.** Независимый авторитет снимает риск «LLM
придумал». Цена — рус/аниме (нет в базах) всегда идут в review, но это и
так нужный кейс.
## Решение
Авто-раскладку делаем, только если выполнено **всё**: (1) единственный
сильный матч в метабазе по названию+году, давший `provider_id`;
(2) структурная валидация без предупреждений; (3) пред-парс (`go-ptn`) и
LLM не противоречат по типу/названию/году. Нет матча или база выключена →
**всегда review**. Самооценку LLM учитываем лишь как вспомогательный
сигнал, не как гейт.
Почему так: безопасность держится на **независимой** проверке (база), а не
на доверии к выходу LLM, построенному из недоверенных данных. Это разом
закрывает основной кейс (рус/аниме отсутствуют в базах → человек
подтверждает) и убирает целый класс тихих ошибок «модель уверенно
ошиблась». Review здесь — не наказание, а штатный режим для всего, что
база не подтвердила (петля «догадка → подсказка → перераспознавание», см.
[review-ux.md](../specs/review-ux.md)). Полная модель уверенности — в
[recognition.md](../specs/recognition.md).
## Последствия
- `+` Нет тихих авто-ошибок раскладки: всё неподтверждённое видит человек.
- `+` `provider_id` из базы заодно даёт каноническое имя папки
(`[tmdbid-…]`) — Jellyfin не путает русские названия.
- `` Рус/аниме и всё, чего нет в базах, всегда требует ручного
подтверждения — авто там недоступно by design.
- `` Без включённых TMDB/TVDB/TVMaze авто-раскладки нет вовсе: сервис
работает в режиме «распознал → review».
- Делает цикл ревью критичным: если он неудобен, ручное подтверждение
станет узким местом — поэтому review-ux вынесен в отдельную спеку.
+1
View File
@@ -56,6 +56,7 @@
| Дата | Запись | Статус |
| ---------- | ---------------------------------------------------------------- | ------ |
| 2026-06-13 | [Авто-раскладка только при матче в метабазе](ADR-2026-06-13-auto-link-requires-db-match.md) | — |
| 2026-06-13 | [Docker как единица деплоя](ADR-2026-06-13-docker-deploy.md) | — |
| 2026-06-13 | [Хардлинки вместо копирования и симлинков](ADR-2026-06-13-hardlinks.md) | — |
| 2026-06-13 | [Go и доставка одним бинарём](ADR-2026-06-13-go-single-binary.md) | — |
+3 -1
View File
@@ -15,7 +15,9 @@
## Записи
- [architecture.md](architecture.md) — общее устройство: компоненты,
поток, машина состояний, хранилище, конфигурация.
транспорты, хранилище, раскладка, деплой.
- [workflow.md](workflow.md) — жизненный цикл загрузки: машина состояний,
переходы, сопоставление состояний qBittorrent.
- [recognition.md](recognition.md) — распознавание контента и модель
уверенности.
- [review-ux.md](review-ux.md) — ревью раскладки человеком: UI/UX-сценарии
+58 -131
View File
@@ -39,44 +39,18 @@ qBittorrent, определяет содержимое (фильм или сер
| `metadata` | интерфейс баз метаданных + TMDB/TVDB/TVMaze (опц.) |
| `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo |
| `store` | SQLite: загрузки, распознавание, подсказки, ссылки |
| `httpapi` | REST + веб-UI (server-rendered, htmx) |
| `httpapi` | REST + веб-UI (server-rendered, POST-формы с redirect) |
| `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги |
| `jellyfin` | триггер пересканирования медиатеки после раскладки (опц.) |
| `config` | загрузка TOML-конфига |
## Поток и машина состояний
```
ingest → downloading → completed → recognizing ──┬─ авто ────────────────→ linking → done
│ │ │ └─ review ⇄ recognizing ─→ linking → done
│ │ └─ moving/checking (ещё не готов)
│ └─ stuck (не качается дольше таймаута)
└─ failed ⇄ retry
done → undo → reverted
review → «Позже» → deferred → review
любой → «Отклонить» → cancelled
```
- **ingest** — приняли источник + контекст, отдали в qBittorrent
(категория `jellybit`), записали в БД с ключом идемпотентности.
- **downloading / completed** — `worker` поллит qBittorrent по категории
(`worker.poll_interval`, 5 с). Готовность — только когда файлы на месте
(не `moving`/`checking*`), см. «Завершение в qBittorrent».
- **recognizing** — `recognize` строит план и оценку уверенности
([recognition.md](recognition.md)). Невалидный/непарсящийся ответ LLM →
review (не failed).
- **review** — план уходит человеку ([review-ux.md](review-ux.md)); цикл
`review ⇄ recognizing` — перераспознавание по подсказке.
- **linking** — `layout` создаёт хардлинки; идемпотентно, батчем.
- **done** — опционально дёргаем скан Jellyfin; доступен **undo**
`reverted` (убрать созданные ссылки).
- **deferred / cancelled / failed / stuck** — «Позже», «Отклонить»,
ошибка (ретраибельна), не качается дольше таймаута.
Все переходы и команды идут через `worker` под per-download блокировкой —
два транспорта не гонятся за одно состояние. Состояние персистентно в
SQLite; на старте `worker` сверяет категорию qBittorrent с БД и
продолжает.
Жизненный цикл загрузки (ingest → downloading → … → done/reverted),
полный граф состояний с переходами и сопоставление состояний qBittorrent —
в отдельной спецификации [workflow.md](workflow.md). Ключевое: переходами
владеет `worker`, он же сериализует команды транспортов под per-download
блокировкой, а состояние персистентно в SQLite.
## Транспорты
@@ -105,8 +79,10 @@ SQLite. Схема покрывает приём, цикл ревью и отк
`idempotency_key`, состояние, `error_code`/`error_msg`, тайминги.
(infohash может появиться позже приёма — для magnet без метаданных.)
- `recognition` — попытки распознавания: `download_id`, `attempt_no`,
`is_current`, тип, название, год, `provider` (`tmdb|tvdb|none`),
`provider_id`, `confidence`, причины-не-авто, сырой ответ LLM.
`is_current`, тип, название, год, `provider` (`tmdb|tvdb|tvmaze|none`),
`provider_id`, `confidence`, причины-не-авто, сырой ответ LLM и
структурированный `plan` (каноничный JSON `recognize.Plan` — файл →
роль/сезон/серия для превью и применения).
- `hint` — накопленные подсказки человека (`download_id`, текст, время).
- `override` — запиненные ручные правки полей (перераспознавание не
затирает).
@@ -129,75 +105,19 @@ qBittorrent. Идемпотентность — **только для актив
## Конфигурация
TOML. В репозитории — `config.example.toml` с placeholder'ами; реальный
`config.toml` рендерится при деплое Ansible-шаблоном из переменных umbar
(секреты — `vars/secrets.yml` под ansible-vault), на диске **0600**,
владелец `1000:1000`, не коммитится. Пример:
TOML. Полный список параметров с комментариями — в
[`config.example.toml`](../../config.example.toml) (источник истины, не
дублируем его здесь). Реальный `config.toml` рендерится при деплое
Ansible-шаблоном из переменных umbar (секреты — `vars/secrets.yml` под
ansible-vault), на диске **0600**, владелец `1000:1000`, не коммитится.
```toml
[qbittorrent]
url = "http://qbit:8989" # по имени сервиса в общей docker-сети
username = "admin"
password = ""
category = "jellybit"
savepath = "/srv/media/downloads" # куда qBit кладёт загрузки (задаём при добавлении)
# Обычно пусто: все медиа-приложения монтируют /srv/media:/srv/media идентично,
# поэтому путь из API уже = хост-путь. path_map — фолбэк, если пути разойдутся:
# самый длинный совпавший префикс save_path (ключ) меняется на хост-префикс
# (значение), совпадение — по границам сегментов. Напр. {"/data" = "/srv/media"}.
path_map = {}
[paths]
# хост-пути (видны внутри контейнера через mount /srv/media)
downloads = "/srv/media/downloads"
movies = "/srv/media/movies"
series = "/srv/media/series"
[llm]
# type — дискриминатор реализации; пока поддерживается "openai-compat"
type = "openai-compat"
# LLM на хосте (LM Studio) из bridged-контейнера — через host.docker.internal,
# не 127.0.0.1; либо вынести LLM в контейнер общей сети.
base_url = "http://host.docker.internal:1234/v1"
api_key = ""
model = "qwen2.5-32b-instruct"
proxy = "" # опц. HTTP-прокси (для удалённых эндпоинтов)
timeout = "120s"
max_retries = 3 # непарсящийся ответ после ретраев → review
[metadata.tmdb]
enabled = false # включается ключом; без матча авто не делаем
api_key = ""
proxy = "" # опц. HTTP-прокси для доступа к базе
timeout = "10s"
[metadata.tvdb]
enabled = false
api_key = ""
proxy = ""
timeout = "10s"
[worker]
poll_interval = "5s" # как часто опрашивать qBittorrent
stuck_after = "1h" # не качается дольше → stuck
magnet_timeout = "30m" # magnet без метаданных дольше → failed
[recognition]
auto_confidence_threshold = 0.85
[telegram]
enabled = false
token = ""
allowed_user_ids = [] # пусто = запрет всем (fail-closed)
[http]
listen = ":8080"
trusted_subnets = [] # зарезервировано, ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN)
[log]
level = "info"
format = "json"
```
Структура секций: `[qbittorrent]` (доступ + категория/тег для push/pull),
`[paths]` (хост-пути песочницы), `[storage]` (путь к SQLite), `[llm]`
(провайдер распознавания, см. [recognition.md](recognition.md)),
`[metadata.tmdb|tvdb|tvmaze]` (опц. базы), `[jellyfin]` (опц.
пересканирование), `[worker]` (интервал поллинга и таймауты, см.
[workflow.md](workflow.md)), `[recognition]` (порог уверенности),
`[telegram]`, `[http]`, `[log]`.
## Логирование
@@ -205,26 +125,6 @@ format = "json"
Каждая загрузка проходит со сквозным идентификатором; решения
распознавания (почему авто/ревью) и операции с файлами логируются явно.
## Завершение в qBittorrent
`worker` опрашивает qBittorrent по категории и сопоставляет состояния:
- **готово к раскладке:** `uploading`/`stalledUP`/`pausedUP`/`queuedUP`/
`forcedUP` — и **только** когда нет `moving`/`checkingUP`.
- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`queuedDL`/
`checkingDL`/`forcedDL`.
- **застряло:** `metaDL` дольше `magnet_timeout`, `stalledDL` дольше
`stuck_after``stuck`/`failed`.
- **ошибка:** `error`/`missingFiles``failed`.
Пути файлов берём из API (`save_path` + относительные имена из
`/torrents/files`, уже включающие корневую папку торрента), не из
константы (обычно это уже хост-путь). «Incomplete»-каталог в
qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы
там, по завершении qBit переносит их в `/srv/media/downloads` (состояние
`moving` — дожидаемся окончания переноса и только потом берём финальный
путь).
## Раскладка файлов
`layout` создаёт хардлинки в `paths.movies`/`paths.series` по конвенциям
@@ -242,8 +142,13 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
доводит начатое (идемпотентно) либо откатывается.
- **Undo** удаляет только ссылки своего `apply_batch_id` и только если
путь под `paths.movies`/`series` — источник недосягаем.
- **Одна ФС обязательна.** `link(2)` через границы ФС даёт `EXDEV`
падаем с понятной ошибкой; по построению этого не должно случаться.
- **Хардлинк предпочтителен, но есть фолбэк.** По построению источник и
цель — на одной ФС (единая песочница `/srv/media`), и `link(2)` проходит.
Если ФС всё же не поддерживает жёсткие ссылки или они между разными ФС
(`EXDEV`/`ENOTSUP`/`EOPNOTSUPP`/`EPERM`), `layout` **не падает**, а
копирует файл (через временный файл + атомарный `rename`) и пишет в лог
`Warn` (статус ссылки — `copied`): задача доходит до конца ценой
дублирования места. Источник при этом всё равно не трогаем.
### Пути и контейнеры — единая песочница `/srv/media`
@@ -266,10 +171,27 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
- **qBit** — `savepath=/srv/media/downloads`, temp `/srv/media/incomplete`.
- **jellybit** — читает `downloads`, пишет в `movies`/`series`; свой
SQLite/конфиг — отдельным mount'ом `/srv/applications/jellybit/data`.
SQLite — отдельным mount'ом `/srv/applications/jellybit/data`, конфиг —
отдельным `/srv/applications/jellybit/config`.
- **Jellyfin** — библиотеки указывают на `movies`/`series` (не на корень
`/srv/media`, иначе в индекс попадут downloads/incomplete).
## Пересканирование Jellyfin
После успешной раскладки (вход в `done`) `worker` неблокирующе просит Jellyfin
пересканировать медиатеку, чтобы новые файлы быстрее появились в проигрывателе.
Включается конфигом `[jellyfin]` (по умолчанию выключено); без него скан не
дёргается.
- **Один вызов — `POST /Library/Refresh`** (скан всех библиотек). Скан
инкрементальный, поэтому полный дёшев; точечный скан конкретной папки не
делаем — сложнее и не в духе сервиса («минимум компонентов»).
- **Авторизация** — API-ключ Jellyfin в заголовке `X-Emby-Token`.
- **Неблокирующе и вне `w.mu`** (как пинги Telegram): вызов уходит в сеть в
отдельной горутине с фоновым контекстом. Недоступность Jellyfin не влияет на
состояние задачи — ошибка лишь логируется (`Warn`).
- **Адресация** — по имени сервиса в общей docker-сети (`http://jellyfin:8096`).
## Деплой
Jellybit работает в **docker** — в одной среде с qBittorrent и Jellyfin
@@ -290,10 +212,13 @@ Jellybit работает в **docker** — в одной среде с qBittorr
- **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь
umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника.
- **mount `/srv/media`** (единая песочница) — для хардлинков и move
(см. «Пути и контейнеры»); `/srv/applications/jellybit/data` — отдельно.
(см. «Пути и контейнеры»); каталоги jellybit — отдельно.
- **mount конфига** `/srv/applications/jellybit/config``/config` (ro):
`config.toml` (0600). Восстановим при деплое (рендерит плейбук umbar) —
бекапить не нужно.
- **mount данных** `/srv/applications/jellybit/data``/data`: SQLite
(`/data/jellybit.db`) и `config.toml`. Без него редеплой стёр бы всё
in-flight состояние.
(`/data/jellybit.db`). Бекапить-и-не-терять — без него редеплой стёр бы
всё in-flight состояние.
- **healthcheck** на `/healthz`.
Разделение ответственности:
@@ -332,6 +257,9 @@ Dockerfile .dockerignore config.example.toml
задач (повторная закачка спустя время → новая задача).
- Состояние — на persistent-томе `/srv/applications/jellybit/data`.
- Детект завершения — поллинг; webhook — на будущее (drafts/ideas).
- Пересканирование Jellyfin после раскладки — `POST /Library/Refresh` (скан
всех библиотек, инкрементальный), неблокирующе на входе в `done`; опц.,
включается `[jellyfin]`.
- Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
- Авто-раскладка требует подтверждённого матча в базе; иначе review.
- Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей).
@@ -340,5 +268,4 @@ Dockerfile .dockerignore config.example.toml
## Открытые вопросы
- Конфигурация Jellyfin (URL + API-ключ) и триггер скана — когда Jellyfin
будет развёрнут в umbar (сейчас его там нет).
- (пока нет)
+5 -2
View File
@@ -51,8 +51,11 @@ inode общий — диск не дублируется.
же inode → готово; другой файл → коллизия → review). Инварианты и undo —
в [architecture.md](architecture.md) → «Раскладка файлов».
Требование: целевой и исходный каталоги — на одной ФС/одном mount'е
(внутри контейнера это обеспечивает единая песочница `/srv/media`).
Желательно: целевой и исходный каталоги — на одной ФС/одном mount'е
(внутри контейнера это обеспечивает единая песочница `/srv/media`), тогда
работает дешёвый хардлинк. Если хардлинк невозможен (разные ФС или ФС без
поддержки жёстких ссылок), `layout` не падает, а копирует файл с
предупреждением в лог — см. architecture.md → «Раскладка файлов».
## Крайние случаи
+19 -13
View File
@@ -5,7 +5,8 @@
По доступным сигналам определить: фильм или сериал; каноническое название
и год; для сериала — сезон(ы) и соответствие файлов сериям; при включённых
базах — провайдер и его id. На выходе — план раскладки, оценка уверенности
и решение «авто или review».
и решение «авто или review» (как оно встраивается в машину состояний —
[workflow.md](workflow.md), состояния `recognizing`/`linking`/`review`).
## Сигналы
@@ -33,8 +34,10 @@
пред-парс, возвращает структурированный план в нашей схеме. Хорошо
берёт русские релиз-имена. Длинный список файлов усекаем/семплируем под
контекст модели.
3. **Сверка с базой** (если включена TMDB/TVDB): ищем по названию+году,
берём официальный id и каноническое имя, собираем кандидатов.
3. **Сверка с базой** (если включена TMDB/TVDB/TVMaze): ищем по
названию+году, берём официальный id и каноническое имя, собираем
кандидатов. TVMaze — без ключа, только сериалы; внешний id
(TVDB/IMDb) из `externals` идёт в имя папки.
4. **Оценка уверенности** и решение: авто или review.
## Структура ответа LLM (предварительная)
@@ -54,8 +57,8 @@ notes пояснения, неоднозначности
Сезон/серия — **на файле**: так выражаются мультисезонные паки,
спецвыпуски и смешанные раскладки; отдельного скалярного `season` нет.
`provider_hint` — только подсказка для поиска; итоговые `provider`
(`tmdb|tvdb|none`) и `provider_id` появляются после сверки с базой и
хранятся отдельно.
(`tmdb|tvdb|tvmaze|none`) и `provider_id` появляются после сверки с базой
и хранятся отдельно.
## Провайдер LLM
@@ -67,22 +70,25 @@ notes пояснения, неоднозначности
Chat Completions API (`base_url` + `api_key` + `model`). Подходят
локальные серверы (LM Studio, llama.cpp, Ollama) и облачные совместимые
провайдеры (DeepSeek, Qwen и др.).
- **Структурированный вывод надёжно:** просим JSON по схеме
(`response_format` со схемой где поддерживается; иначе json-режим или
tool-call); на приёме срезаем ```-ограждения и извлекаем JSON,
**валидируем в Go**, ретраим со схемой-в-промпте до `llm.max_retries`;
если так и не распарсилось — уходим в **review** (не в `failed`) с
причиной «ответ LLM не разобран». Серверы заметно различаются по
поддержке строгих схем, особенно мелкие локальные модели.
- **Структурированный вывод надёжно:** просим JSON-режим
(`response_format: {"type":"json_object"}`) — это поддерживают и мелкие
локальные модели, в отличие от строгих JSON Schema. На приёме срезаем
```-ограждения и извлекаем JSON, **валидируем в Go** против нашей схемы;
при ошибке разбора ретраим, передавая модели саму ошибку и схему в
промпте, до `llm.max_retries`. Если так и не распарсилось — уходим в
**review** (не в `failed`) с причиной «ответ LLM не разобран».
- Новые типы (напр. нативный `anthropic`) добавляются, не трогая
`recognize`.
## Модель уверенности
Почему авто только при матче в базе, а не по самооценке LLM —
[ADR-2026-06-13-auto-link-requires-db-match](../adr/ADR-2026-06-13-auto-link-requires-db-match.md).
Авто-раскладка — только если выполнено **всё**:
1. **Подтверждённый матч в базе** — единственный сильный результат
TMDB/TVDB по названию+году, давший `provider_id`. **Нет матча (или
TMDB/TVDB/TVMaze по названию+году, давший `provider_id`. **Нет матча (или
база выключена) → всегда review.** Это и закрывает основной кейс
(рус/аниме часто отсутствуют в базах), и снимает риск «LLM придумал».
2. **Структурная валидация** без предупреждений:
+28 -10
View File
@@ -2,7 +2,8 @@
Что происходит, когда система не уверена в распознавании и не
раскладывает файлы автоматически. Когда именно наступает ревью — см.
[recognition.md](recognition.md); конвенции целевых имён
[recognition.md](recognition.md); место состояния `review` в общем потоке
[workflow.md](workflow.md); конвенции целевых имён —
[jellyfin-layout.md](jellyfin-layout.md).
Главный принцип: ревью — это **петля «догадка → подсказка человека →
@@ -82,9 +83,13 @@ Fargo.S02.2015.WEB-DL.1080p.rus.eng 🟡 review
- **🔁 Уточнить** → бот просит подсказку ответом → перераспознаёт →
редактирует то же сообщение новым планом. Петля коррекции прямо в чате.
- **🔢 Выбрать в базе** → кнопки по кандидатам (название · год · id).
- Точечное переназначение файлов в чат не помещается → **🌐 Открыть в
вебе** (deep-link на ту же страницу).
- Точечное переназначение файлов и выбор кандидата базы в чат не
помещаются → **🌐 В вебе** (deep-link на ту же страницу, строится из
`telegram.web_base_url`).
> Реально в боте сейчас: ✅ Применить, 📺↔🎬 Тип, 🔁 Уточнить, 🕗 Позже,
> 🌐 В вебе, ❌ Отклонить. Кнопки «🔢 Выбрать в базе» в чате пока нет —
> выбор кандидата и ручной ввод id делаются в вебе.
## Разделение труда
@@ -122,12 +127,25 @@ Telegram = одобрить / подсказать / выбрать кандид
- **«Позже»** паркует загрузку в `deferred` (вернётся в review по
действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo**
после применения → `reverted` (удаляет только ссылки своего батча, под
`media`). Полная карта состояний — в [architecture.md](architecture.md).
`media`). Полная карта состояний — в [workflow.md](workflow.md).
- После отката или отклонения доступна **«Привязать заново»**: перезапускает
распознавание для той же раздачи (`reverted`/`cancelled → recognizing`) и
снова приводит в review — раскладка всегда требует ручного подтверждения,
авто не делаем. Нужна, когда распознали неверно: откатил/отклонил,
перепривязал, поправил и применил.
- В самом ревью, помимо **«Уточнить»** (подсказка + перераспознавание), есть
**«Распознать заново»** — повторный прогон распознавания без новой подсказки
(контекст и прежние подсказки уже учтены). Полезно, когда модель один раз
споткнулась на разовой ошибке.
## Объём по версиям
- **Ф3 (первая версия):** подсказка + перераспознавание; из ручного —
переключатель типа, выбор кандидата базы, пометка файла «игнор». Undo —
есть.
- **Ф5:** полный редактор маппинга «файл → серия», ручной режим,
подтверждение в Telegram с reply-подсказкой и эскалацией в веб.
- **Ф3 (готово):** в вебе — подсказка + перераспознавание, «Распознать
заново», переключатель типа, выбор кандидата базы / ручной ввод id /
«без базы», пометка файла «игнор», «Применить»/«Отклонить»/«Позже»,
Undo и «Привязать заново». В Telegram — подтверждение с reply-подсказкой
(«Уточнить»), переключатель типа, «Позже»/«Отклонить» и эскалация в веб;
пинги о входе в review и готовности.
- **Ф5 (на будущее):** полный редактор маппинга «файл → серия»
(правка S·E, «нумеровать подряд»), ручной режим при полном провале LLM,
выбор кандидата базы и ввод id прямо в Telegram.
+121
View File
@@ -0,0 +1,121 @@
# Жизненный цикл загрузки и машина состояний
Как загрузка проходит путь от приёма источника до разложенных файлов:
состояния, переходы и то, что их вызывает. Кто владеет переходами и общее
устройство — в [architecture.md](architecture.md); детали распознавания —
в [recognition.md](recognition.md); действия человека в ревью — в
[review-ux.md](review-ux.md).
## Граф состояний
```mermaid
stateDiagram-v2
[*] --> downloading: ingest (источник отдан в qBittorrent)
downloading --> completed: файлы на месте
downloading --> stuck: stalledDL дольше stuck_after
downloading --> failed: metaDL дольше magnet_timeout / error
completed --> recognizing
recognizing --> linking: авто (матч в базе + валидация)
recognizing --> review: нужно подтверждение / ответ LLM не разобран
review --> linking: Применить
review --> recognizing: Уточнить / Распознать заново
review --> deferred: Позже
review --> cancelled: Отклонить
deferred --> review: любое действие (та же поверхность)
linking --> done
linking --> review: коллизия цели
linking --> failed: ошибка ФС
done --> reverted: Undo
reverted --> recognizing: Привязать заново
cancelled --> recognizing: Привязать заново
stuck --> downloading: Retry
failed --> downloading: Retry
done --> [*]
cancelled --> [*]
reverted --> [*]
note right of cancelled
«Отклонить» доступно из любого
нетерминального состояния
end note
```
Условно-терминальные состояния — `done`, `cancelled`, `failed`,
`reverted`: задача в них останавливается, но из `failed`/`stuck` есть
**Retry**, а из `reverted`/`cancelled`**Привязать заново**. `stuck`
восстановимо ретраем.
## Состояния и переходы
- **ingest → downloading** — приняли источник + контекст, отдали в
qBittorrent (категория `qbittorrent.category`), записали в БД с ключом
идемпотентности. См. [architecture.md](architecture.md) → «Транспорты».
- **downloading / completed** — `worker` поллит qBittorrent
(`worker.poll_interval`, 5 с). Готовность — только когда файлы на месте
(не `moving`/`checking*`), см. «Завершение в qBittorrent» ниже.
- **recognizing** — `recognize` строит план и оценку уверенности
([recognition.md](recognition.md)). Невалидный/непарсящийся ответ LLM →
review (не failed).
- **review** — план уходит человеку ([review-ux.md](review-ux.md)); цикл
`review ⇄ recognizing` — перераспознавание по подсказке. «Уточнить» —
подсказка + перераспознавание; «Распознать заново» — повторный прогон
без новой подсказки, по уже накопленному контексту и подсказкам.
- **deferred** — «Позже» паркует задачу; принимает те же команды, что и
`review`, и возвращается в поверхность ревью по любому действию.
- **linking** — `layout` создаёт хардлинки; идемпотентно, батчем. Коллизия
цели возвращает в review, ошибка ФС → failed. См.
[architecture.md](architecture.md) → «Раскладка файлов».
- **done** — при входе неблокирующе дёргаем пересканирование Jellyfin
(опц., см. [architecture.md](architecture.md) → «Пересканирование
Jellyfin»); доступен **Undo**`reverted` (убрать созданные ссылки).
- **stuck / failed / cancelled** — не качается дольше таймаута; ошибка
(ретраибельна); «Отклонить».
- **reverted / cancelled → recognizing** — «Привязать заново»: после
отката или отклонения можно перезапустить распознавание для той же
раздачи. Перепривязка всегда идёт через review с ручным подтверждением
(авто-раскладку не делаем) и требует, чтобы раздача всё ещё была в
qBittorrent.
Все переходы и команды идут через `worker` под per-download блокировкой —
два транспорта не гонятся за одно состояние. Состояние персистентно в
SQLite; `worker` периодически сверяет qBittorrent с БД и **усыновляет**
раздачи с нашей категорией (`qbittorrent.category`) **или** тегом
(`qbittorrent.tag`), которых ещё нет в БД, заводя для них задачу в
состоянии `downloading`. Категория ставится на добавляемые нами раздачи
(push, задаёт savepath); тег позволяет подхватить уже существующую
раздачу, не трогая её категорию и файлы (pull).
## Завершение в qBittorrent
`worker` опрашивает qBittorrent и сопоставляет его состояния с нашими:
- **готово к раскладке:** `uploading`/`stalledUP`/`pausedUP`/`stoppedUP`/
`queuedUP`/`forcedUP` (имена `paused*`/`stopped*` различаются между qBit
v4 и v5 — поддержаны оба).
- **переходное, ждём:** `moving`/`checkingUP`/`checkingResumeData`/
`allocating` — остаёмся в `downloading`, пока qBit не закончит перенос/
проверку (готовность не объявляем, даже если флаги «UP»).
- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`forcedMetaDL`/
`queuedDL`/`checkingDL`/`forcedDL`/`pausedDL`/`stoppedDL`.
- **застряло/ошибка по таймауту:** `metaDL`/`forcedMetaDL` дольше
`magnet_timeout``failed`; `stalledDL` дольше `stuck_after``stuck`
(восстановимо ретраем). Возраст считаем от создания задачи.
- **ошибка:** `error`/`missingFiles``failed`.
Пути файлов берём из API (`save_path` + относительные имена из
`/torrents/files`, уже включающие корневую папку торрента), не из
константы (обычно это уже хост-путь). «Incomplete»-каталог в
qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы
там, по завершении qBit переносит их в `/srv/media/downloads` (состояние
`moving` — дожидаемся окончания переноса и только потом берём финальный
путь). Подробнее о путях и песочнице — [architecture.md](architecture.md)
→ «Пути и контейнеры».
+129
View File
@@ -0,0 +1,129 @@
# TODO
Конкретные задачи на будущее, ранжированные по приоритету. Это не план
реализации (он — в [drafts/roadmap.md](drafts/roadmap.md)) и не свалка
идей ([drafts/ideas.md](drafts/ideas.md)): сюда попадает то, что уже решили
сделать, но ещё не сделали. Принятое и реализованное переезжает в
`docs/specs`/`docs/adr`.
Приоритет — грубая оценка «ценность / стоимость», не обязательство к
порядку.
## Высокий
### Проблема второго сезона
Если первый сезон сериала уже разложен, а мы добавляем второй/третий/…,
распознавание должно привязать новый сезон к **тому же** названию и папке,
а не завести рядом почти одинаковую вторую папку. Ключ — стабильный
`provider_id`: один и тот же `[tvdbid-…]` → одна папка сериала, новые
`Season NN` доливаются внутрь. Нужно: при матче учитывать уже существующие
в библиотеке сериалы (или прошлые распознавания с тем же провайдер-id) и
склонять LLM/выбор кандидата к согласованности с ними.
Связано: [recognition.md](specs/recognition.md) (модель уверенности,
матч в базе), [jellyfin-layout.md](specs/jellyfin-layout.md) (папка
сериала с провайдер-id).
### Название из контекста при добавлении в qBittorrent
При создании magnet-загрузки передавать в qBittorrent человекочитаемое имя
из контекста (если оно есть), чтобы в списке qBit не было безликих
`rutracker-topic-6852853`. Небольшая задача с заметной отдачей в
повседневной эксплуатации.
Связано: [architecture.md](specs/architecture.md) → «Транспорты», пакет
`ingest`/`qbt`.
### Рассинхрон состояния с реальностью (удалённый торрент / файлы)
Состояние jellybit может разойтись с тем, что реально лежит на диске.
Несколько сценариев разной остроты:
- **Жёсткий — удалён источник.** Раздачу удаляют (вручную или авто по
достижении seed limit), и qBittorrent стирает скачанные файлы. Тогда
хардлинк в библиотеке становится **последней** ссылкой на inode, и
обычный `undo` (`unlink` цели + чистка пустых каталогов) сотрёт
единственную копию насовсем — прямая потеря данных. Инвариант «источник
неприкосновенен» молчаливо перестаёт держаться: источника уже нет.
- **Мягкий — удалена цель.** Файлы убрали из библиотеки Jellyfin (вручную
или из самого Jellyfin), а jellybit по-прежнему числит загрузку в
`done`. Состояние врёт: ссылок уже нет, а сервис думает, что всё
разложено.
Нужно продумать сверку записанного состояния (`file_link`, состояние
загрузки) с фактом на ФС:
- как `worker` реагирует на исчезновение раздачи из qBittorrent
(состояние/пометка загрузки);
- как `undo` защищается, когда источник недоступен — например,
отказываться удалять, если у целевого файла счётчик ссылок == 1 (нет
второй копии) или исходный путь не существует, и явно об этом сообщать.
Откат снимает **лишний** хардлинк, а не последнюю копию файла;
- как ловить пропажу целевых файлов и отражать её в состоянии (напр.
периодическая сверка или проверка при показе — «разложено, но файлов
нет»), чтобы можно было осознанно перепривязать/переразложить.
Связано: [ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md),
[architecture.md](specs/architecture.md) → «Раскладка файлов» (undo,
инвариант источника), [workflow.md](specs/workflow.md) (`done → reverted`).
## Средний
### Машина состояний на go-библиотеке
Сейчас FSM реализована вручную в `worker`. Выбрать подходящую go-библиотеку
для описания воркфлоу/машины состояний и перевести переходы на неё — ради
декларативности, проверяемости переходов и единого места правды. Кандидаты
для оценки: `looplab/fsm`, `qmuntal/stateless` (и аналоги). Граф и переходы
уже формализованы — переносим один в один.
Связано: [workflow.md](specs/workflow.md) (текущий граф состояний).
### Привязка уведомлений к источнику в ботах (мульти-бот)
Уведомления и запросы подтверждения должен получать тот, кто прислал
загрузку: автор сообщения о новой раздаче — адресат пингов и ревью по ней.
Транспортов-ботов может быть несколько (Telegram, в перспективе Matrix и
др.); каждый адресует «своему» отправителю. Веб-интерфейс остаётся
**единым для всех** и точкой правды по функциональности (боты — тонкие
адаптеры над тем же ядром). Нужно: хранить у загрузки источник/транспорт и
идентификатор отправителя, маршрутизировать пинги по нему.
Связано: [review-ux.md](specs/review-ux.md) (разделение труда транспортов,
веб = точные правки), [architecture.md](specs/architecture.md) →
«Транспорты».
### Добавление торрентов файлом/ссылкой — «единое окно»
Поддержать источники помимо magnet: `.torrent`-файл и URL (отдаём их в
qBittorrent, без исходящих запросов на пользовательский URL — SSRF
исключён). Идеал — одно поле «единого окна»: кидаем туда текст или файл, а
сервис сам разбирает, что это (magnet / ссылка / .torrent / сообщение
бота), и заводит загрузку.
Связано: [architecture.md](specs/architecture.md) → «Транспорты»
(`source_type = magnet|torrent|url` уже в схеме), пакет `ingest` (сейчас
поддержан только magnet).
## Низкий
### Многоступенчатая верификация привязки (тема для размышления)
Идея: несколько раз извлекать данные из раздачи и контекста разными
промптами, искать в метабазах, затем сводить результаты в общий вердикт
(голосование/консенсус) — выше точность ценой нескольких вызовов LLM и
запросов к базам. Требует проработки: когда включать, как мерджить
расхождения, стоимость/латентность.
Связано: [recognition.md](specs/recognition.md) (конвейер и модель
уверенности).
### Современный Web-UI как PWA
Переделать веб-интерфейс в современное PWA-приложение (устанавливаемое,
отзывчивое, удобное с телефона). Текущий server-rendered UI функционален,
поэтому это улучшение, а не блокер; большой объём работы.
Связано: [review-ux.md](specs/review-ux.md) (веб = точные правки),
пакет `httpapi`.
+12
View File
@@ -17,6 +17,7 @@ type Config struct {
Storage Storage `toml:"storage"`
LLM LLM `toml:"llm"`
Metadata Metadata `toml:"metadata"`
Jellyfin Jellyfin `toml:"jellyfin"`
Worker Worker `toml:"worker"`
Recognition Recognition `toml:"recognition"`
Telegram Telegram `toml:"telegram"`
@@ -78,6 +79,16 @@ type MetadataProvider struct {
Timeout Duration `toml:"timeout"`
}
// Jellyfin — пересканирование медиатеки после раскладки (опц.). Включается
// конфигом; без него скан не дёргается.
type Jellyfin struct {
Enabled bool `toml:"enabled"`
URL string `toml:"url"`
APIKey string `toml:"api_key"`
Proxy string `toml:"proxy"` // опц. HTTP-прокси
Timeout Duration `toml:"timeout"`
}
// Worker — параметры фонового цикла.
type Worker struct {
PollInterval Duration `toml:"poll_interval"`
@@ -155,6 +166,7 @@ func Default() *Config {
TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
},
Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)},
Worker: Worker{
PollInterval: Duration(5 * time.Second),
StuckAfter: Duration(time.Hour),
+23 -2
View File
@@ -13,6 +13,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -85,6 +86,7 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Get("/review/{id}", s.handleReview)
r.Post("/ui/downloads/{id}/apply", s.handleApply)
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
r.Post("/ui/downloads/{id}/rerecognize", s.handleRerecognize)
r.Post("/ui/downloads/{id}/type", s.handleSetType)
r.Post("/ui/downloads/{id}/ignore", s.handleIgnore)
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
@@ -92,6 +94,7 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Post("/ui/downloads/{id}/nobase", s.handleNoBase)
r.Post("/ui/downloads/{id}/defer", s.handleDefer)
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
r.Post("/ui/downloads/{id}/relink", s.handleRelink)
// REST API.
r.Route("/api", func(r chi.Router) {
@@ -126,6 +129,7 @@ type downloadView struct {
Terminal bool
Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку
Relinkable bool // reverted/cancelled — можно перепривязать заново
}
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -304,6 +308,7 @@ func toView(d store.Download) downloadView {
Terminal: d.State.IsTerminal(),
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
Undoable: d.State == store.StateDone,
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled,
}
}
@@ -336,7 +341,9 @@ func errJSON(err error) map[string]string {
return map[string]string{"error": err.Error()}
}
// requestLogger пишет структурированный лог по каждому запросу.
// requestLogger пишет структурированный лог по каждому запросу. Частые
// служебные запросы (healthcheck, GET-страницы веб-UI с авто-рефрешем) пишем
// на DEBUG, чтобы не зашумлять INFO; мутации и REST API остаются на INFO.
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -345,7 +352,7 @@ func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
next.ServeHTTP(ww, r)
logger.Info("http request",
logger.Log(r.Context(), requestLogLevel(r), "http request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
@@ -356,3 +363,17 @@ func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
})
}
}
// requestLogLevel понижает уровень для частых служебных запросов: healthcheck
// и GET-страницы веб-UI (список авто-рефрешится каждые 5 с). Мутации и REST
// API (`/api/...`) остаются на INFO.
func requestLogLevel(r *http.Request) slog.Level {
switch {
case r.URL.Path == "/healthz":
return slog.LevelDebug
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api"):
return slog.LevelDebug
default:
return slog.LevelInfo
}
}
+38
View File
@@ -193,6 +193,8 @@ type fakeReviewer struct {
applied []int64
deferred []int64
undone []int64
relinked []int64
rerecognized []int64
cleared []int64
}
@@ -235,6 +237,14 @@ func (f *fakeReviewer) Undo(_ context.Context, id int64) error {
f.undone = append(f.undone, id)
return nil
}
func (f *fakeReviewer) Relink(_ context.Context, id int64) error {
f.relinked = append(f.relinked, id)
return nil
}
func (f *fakeReviewer) Rerecognize(_ context.Context, id int64) error {
f.rerecognized = append(f.rerecognized, id)
return nil
}
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID int64) error {
if f.chosen == nil {
f.chosen = map[int64]int64{}
@@ -444,3 +454,31 @@ func TestUndoAndDefer(t *testing.T) {
t.Errorf("undo=%v defer=%v", rv.undone, rv.deferred)
}
}
func TestRelink(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
cl := noRedirectClient()
if _, err := cl.Post(srv.URL+"/ui/downloads/1/relink", "", nil); err != nil {
t.Fatal(err)
}
if len(rv.relinked) != 1 || rv.relinked[0] != 1 {
t.Errorf("relinked = %v, want [1]", rv.relinked)
}
}
func TestRerecognize(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
cl := noRedirectClient()
if _, err := cl.Post(srv.URL+"/ui/downloads/1/rerecognize", "", nil); err != nil {
t.Fatal(err)
}
if len(rv.rerecognized) != 1 || rv.rerecognized[0] != 1 {
t.Errorf("rerecognized = %v, want [1]", rv.rerecognized)
}
}
@@ -0,0 +1,27 @@
package httpapi
import (
"log/slog"
"net/http/httptest"
"testing"
)
func TestRequestLogLevel(t *testing.T) {
cases := []struct {
method, path string
want slog.Level
}{
{"GET", "/healthz", slog.LevelDebug}, // healthcheck — тихо
{"GET", "/", slog.LevelDebug}, // список (авто-рефреш)
{"GET", "/review/1", slog.LevelDebug}, // страница ревью
{"GET", "/api/downloads", slog.LevelInfo}, // REST API — на INFO
{"POST", "/ui/downloads/1/apply", slog.LevelInfo}, // мутация — на INFO
{"POST", "/api/downloads", slog.LevelInfo}, // приём — на INFO
}
for _, c := range cases {
r := httptest.NewRequest(c.method, c.path, nil)
if got := requestLogLevel(r); got != c.want {
t.Errorf("%s %s: level=%v, want %v", c.method, c.path, got, c.want)
}
}
}
+24
View File
@@ -20,6 +20,8 @@ type Reviewer interface {
IgnoreFile(ctx context.Context, id int64, src string) error
Defer(ctx context.Context, id int64) error
Undo(ctx context.Context, id int64) error
Relink(ctx context.Context, id int64) error
Rerecognize(ctx context.Context, id int64) error
ChooseCandidate(ctx context.Context, id, candidateID int64) error
SetProviderID(ctx context.Context, id int64, provider, providerID string) error
ClearProvider(ctx context.Context, id int64) error
@@ -165,6 +167,12 @@ func (s *server) handleRefine(w http.ResponseWriter, r *http.Request) {
})
}
func (s *server) handleRerecognize(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
return s.deps.Reviewer.Rerecognize(ctx, id)
})
}
func (s *server) handleSetType(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
_ = r.ParseForm()
@@ -233,6 +241,22 @@ func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// handleRelink повторно привязывает откатанную задачу: перезапускает
// распознавание, задача пройдёт recognizing → review для подтверждения.
func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
if err := s.deps.Reviewer.Relink(r.Context(), id); err != nil {
s.deps.Logger.Warn("review action failed", "action", "relink", "id", id, "err", err)
redirectErr(w, r, err.Error())
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// reviewAction — общий помощник: выполнить действие и вернуться на страницу
// ревью (с ошибкой в ?err при неудаче).
func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) error) {
+93
View File
@@ -0,0 +1,93 @@
// Package jellyfin — минимальный клиент Jellyfin для пересканирования
// медиатеки после успешной раскладки. Единственная задача: дёрнуть скан
// всех библиотек (POST /Library/Refresh), чтобы новые хардлинки быстрее
// появились в проигрывателе. В духе сервиса — без зоопарка вызовов.
package jellyfin
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
)
const defaultTimeout = 10 * time.Second
// Config — подключение к Jellyfin.
type Config struct {
URL string
APIKey string
Proxy string // опц. HTTP-прокси
Timeout time.Duration
}
// Client — клиент Jellyfin API.
type Client struct {
base string
apiKey string
hc *http.Client
log *slog.Logger
}
// New собирает клиент с опц. прокси. logger nil → slog.Default().
func New(cfg Config, logger *slog.Logger) (*Client, error) {
base, err := url.Parse(strings.TrimRight(cfg.URL, "/"))
if err != nil {
return nil, fmt.Errorf("jellyfin: parse url %q: %w", cfg.URL, err)
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaultTimeout
}
transport := http.DefaultTransport
if cfg.Proxy != "" {
pu, perr := url.Parse(cfg.Proxy)
if perr != nil {
return nil, fmt.Errorf("jellyfin: parse proxy %q: %w", cfg.Proxy, perr)
}
// Клонируем дефолтный транспорт (dial/TLS-таймауты, keep-alive), а не
// собираем голый — как в metadata-клиенте.
t := http.DefaultTransport.(*http.Transport).Clone()
t.Proxy = http.ProxyURL(pu)
transport = t
}
if logger == nil {
logger = slog.Default()
}
return &Client{
base: base.String(),
apiKey: cfg.APIKey,
hc: &http.Client{Timeout: timeout, Transport: transport},
log: logger,
}, nil
}
// RefreshLibraries запускает скан всех библиотек Jellyfin
// (POST /Library/Refresh). Скан инкрементальный — полный дёшев, поэтому
// точечный скан конкретной папки не делаем (сложнее, не в духе сервиса).
// Ответ при успехе — 204 No Content.
func (c *Client) RefreshLibraries(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/Library/Refresh", nil)
if err != nil {
return fmt.Errorf("jellyfin: build request: %w", err)
}
req.Header.Set("X-Emby-Token", c.apiKey)
start := time.Now()
resp, err := c.hc.Do(req)
if err != nil {
return fmt.Errorf("jellyfin: refresh: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("jellyfin: refresh: status %d body %q",
resp.StatusCode, strings.TrimSpace(string(body)))
}
c.log.Info("jellyfin: library refresh triggered", "duration", time.Since(start))
return nil
}
+71
View File
@@ -0,0 +1,71 @@
package jellyfin
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestRefreshLibraries_OK(t *testing.T) {
var gotPath, gotToken, gotMethod string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotToken = r.Header.Get("X-Emby-Token")
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c, err := New(Config{URL: srv.URL, APIKey: "secret"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := c.RefreshLibraries(context.Background()); err != nil {
t.Fatalf("RefreshLibraries: %v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("method = %q, want POST", gotMethod)
}
if gotPath != "/Library/Refresh" {
t.Errorf("path = %q, want /Library/Refresh", gotPath)
}
if gotToken != "secret" {
t.Errorf("token = %q, want secret", gotToken)
}
}
func TestRefreshLibraries_TrimsTrailingSlash(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c, err := New(Config{URL: srv.URL + "/", APIKey: "k"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := c.RefreshLibraries(context.Background()); err != nil {
t.Fatalf("RefreshLibraries: %v", err)
}
if gotPath != "/Library/Refresh" {
t.Errorf("path = %q, want /Library/Refresh (без двойного слеша)", gotPath)
}
}
func TestRefreshLibraries_ErrorStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
c, err := New(Config{URL: srv.URL, APIKey: "bad"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := c.RefreshLibraries(context.Background()); err == nil {
t.Fatal("ожидали ошибку на 401, получили nil")
}
}
+9 -7
View File
@@ -36,7 +36,7 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
"provider_hint": "строка для поиска в базе (НЕ id)",
"files": [
{
"src": "путь файла РОВНО как в списке ниже",
"src": "путь файла из списка ниже, БЕЗ размера в скобках в конце строки",
"role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore",
"season": число или null,
"episode": число или null
@@ -50,7 +50,8 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
- Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode.
- Для фильма ровно один основной видеофайл role "main".
- Поле src копируй ДОСЛОВНО из списка файлов; не выдумывай и не нормализуй пути.
- Поле src — это путь файла из списка, скопированный дословно, но БЕЗ размера
«(…)» в конце строки; не выдумывай и не нормализуй пути.
- Внешние субтитры — role "subtitle".`
const systemPrompt = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента,
@@ -133,14 +134,15 @@ func writeFileList(b *strings.Builder, files []File, maxFiles int) {
}
b.WriteString("Файлы (")
b.WriteString(strconv.Itoa(n))
b.WriteString(", поле src — это точные пути отсюда):\n")
b.WriteString("). В src копируй ТОЛЬКО путь — текст после номера и до размера ")
b.WriteString("в скобках; размер «(…)» в конце строки в src НЕ включай:\n")
for i := 0; i < shown; i++ {
b.WriteString(strconv.Itoa(i + 1))
b.WriteString(". [")
b.WriteString(humanSize(files[i].Size))
b.WriteString("] ")
b.WriteString(". ")
b.WriteString(files[i].Path)
b.WriteByte('\n')
b.WriteString(" (")
b.WriteString(humanSize(files[i].Size))
b.WriteString(")\n")
}
if shown < n {
b.WriteString("… и ещё ")
+72 -5
View File
@@ -25,6 +25,7 @@ const (
ovrProviderID = "provider_id" // id в выбранной базе
ovrTitle = "title" // запиненное каноническое название
ovrYear = "year" // запиненный год
ovrForceReview = "force_review" // ручная перепривязка: не авто-раскладывать
)
// recognizePending распознаёт завершённые загрузки и перезапускает те, что
@@ -178,9 +179,13 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
}
// Авто-раскладка при подтверждённом матче и чистой валидации (Ф4);
// иначе — review. Раскладчик может быть не сконфигурирован.
if res.Decision.Auto && w.layouter != nil {
plan := applyOverrides(res.Plan, w.overridesOrNil(ctx, id))
// иначе — review. Раскладчик может быть не сконфигурирован. При ручной
// перепривязке (force_review) авто-раскладку не делаем — нужно явное
// подтверждение человеком.
overrides := w.overridesOrNil(ctx, id)
forceReview := overrides[ovrForceReview] == "1"
if res.Decision.Auto && !forceReview && w.layouter != nil {
plan := applyOverrides(res.Plan, overrides)
w.transition(ctx, *d, store.StateLinking, "", "")
if err := w.linkPlan(ctx, d, plan, tag, savePath); err != nil {
w.log.Warn("recognize: auto-apply failed, left for review",
@@ -281,6 +286,65 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
return nil
}
// Relink повторно привязывает откатанную (reverted) или отклонённую
// (cancelled) задачу: возвращает её на распознавание, и поллинг-цикл
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная
// перепривязка всегда проходит через ревью с подтверждением (force_review).
// Источник (раздача в qBittorrent) для этого должен быть на месте.
func (w *Worker) Relink(ctx context.Context, id int64) error {
w.mu.Lock()
defer w.mu.Unlock()
d, err := w.store.GetDownload(ctx, id)
if err != nil {
return fmt.Errorf("relink: %w", err)
}
if d.State != store.StateReverted && d.State != store.StateCancelled {
return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled)", id, d.State)
}
if !d.Infohash.Valid {
return fmt.Errorf("relink: download %d has no infohash", id)
}
// Раздача должна ещё быть в qBittorrent — без неё распознавать нечего.
if _, ok, terr := w.torrentByInfohash(ctx, d.Infohash.String); terr != nil {
return fmt.Errorf("relink: %w", terr)
} else if !ok {
return fmt.Errorf("relink: торрент не найден в qBittorrent")
}
// Вернуть задачу в активную обработку можно, только если другой активной
// задачи на этот infohash нет (partial unique index по idempotency_key).
active, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String)
if err != nil {
return fmt.Errorf("relink: %w", err)
}
if active != nil {
return fmt.Errorf("relink: для этого торрента уже есть активная задача #%d", active.ID)
}
// Ручная перепривязка — всегда с подтверждением, без авто-раскладки.
if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil {
return fmt.Errorf("relink: %w", err)
}
w.transition(ctx, *d, store.StateRecognizing, "", "")
w.log.Info("relink: re-recognizing download", "download_id", id, "from", d.State)
return nil
}
// Rerecognize перезапускает распознавание для задачи в review/deferred без
// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг-
// цикл проведёт задачу recognizing → review заново.
func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
w.mu.Lock()
defer w.mu.Unlock()
d, err := w.requireReviewable(ctx, id, "rerecognize")
if err != nil {
return err
}
w.log.Info("review: re-recognizing without hint", "download_id", id)
w.transition(ctx, *d, store.StateRecognizing, "", "")
return nil
}
// Refine добавляет подсказку и отправляет задачу на перераспознавание.
func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
hint = strings.TrimSpace(hint)
@@ -743,9 +807,12 @@ func mapRole(r recognize.FileRole) (layout.Role, bool) {
}
}
// torrentByInfohash ищет торрент категории по infohash (v1/v2/hash).
// torrentByInfohash ищет торрент по infohash (v1/v2/hash). Листаем ВСЕ
// торренты (а не только свою категорию): раздача могла быть усыновлена по
// тегу и иметь чужую/пустую категорию — фильтр по категории её бы потерял
// (как и в Poll, см. там же).
func (w *Worker) torrentByInfohash(ctx context.Context, infohash string) (qbt.Torrent, bool, error) {
torrents, err := w.qbt.Torrents(ctx, w.cfg.Category)
torrents, err := w.qbt.Torrents(ctx, "")
if err != nil {
return qbt.Torrent{}, false, err
}
+188
View File
@@ -73,6 +73,148 @@ func TestNotifier_FiresOnDone(t *testing.T) {
}
}
// recordingScanner ловит вызовы пересканирования Jellyfin (RefreshLibraries
// асинхронен — через канал).
type recordingScanner struct{ ch chan struct{} }
func (s *recordingScanner) RefreshLibraries(_ context.Context) error {
s.ch <- struct{}{}
return nil
}
func TestScanner_FiresOnDone(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
s := &recordingScanner{ch: make(chan struct{}, 4)}
f.w.SetScanner(s)
if err := f.w.Apply(context.Background(), 1); err != nil {
t.Fatalf("Apply: %v", err)
}
select {
case <-s.ch:
case <-time.After(2 * time.Second):
t.Fatal("пересканирование Jellyfin не запустилось")
}
}
func revertedDownload(id int64) *store.Download {
d := completedDownload(id)
d.State = store.StateReverted
return d
}
func TestRelink_RevertedToRecognizing(t *testing.T) {
st := newMemStore()
st.put(revertedDownload(1))
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
if err := w.Relink(context.Background(), 1); err != nil {
t.Fatalf("Relink: %v", err)
}
if st.downloads[1].State != store.StateRecognizing {
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
}
if st.overrides[1][ovrForceReview] != "1" {
t.Errorf("force_review override = %q, want 1", st.overrides[1][ovrForceReview])
}
}
func TestRelink_CancelledToRecognizing(t *testing.T) {
st := newMemStore()
d := revertedDownload(1)
d.State = store.StateCancelled
st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
if err := w.Relink(context.Background(), 1); err != nil {
t.Fatalf("Relink: %v", err)
}
if st.downloads[1].State != store.StateRecognizing {
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
}
if st.overrides[1][ovrForceReview] != "1" {
t.Errorf("force_review override = %q, want 1", st.overrides[1][ovrForceReview])
}
}
func TestRelink_RejectsActiveState(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1)) // не reverted/cancelled
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.Relink(context.Background(), 1); err == nil {
t.Fatal("ожидали ошибку для не-reverted/cancelled задачи, получили nil")
}
}
func TestRerecognize_ReviewToRecognizing(t *testing.T) {
st := newMemStore()
d := completedDownload(1)
d.State = store.StateReview
st.put(d)
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
if err := w.Rerecognize(context.Background(), 1); err != nil {
t.Fatalf("Rerecognize: %v", err)
}
if st.downloads[1].State != store.StateRecognizing {
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
}
}
func TestRerecognize_RejectsNonReview(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1)) // completed, не review/deferred
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
if err := w.Rerecognize(context.Background(), 1); err == nil {
t.Fatal("ожидали ошибку для не-review задачи, получили nil")
}
}
func TestRelink_TorrentMissing(t *testing.T) {
st := newMemStore()
st.put(revertedDownload(1))
qb := &fakeQbt{torrents: nil} // раздачи в qBittorrent нет
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.Relink(context.Background(), 1); err == nil {
t.Fatal("ожидали ошибку при отсутствии торрента, получили nil")
}
if st.downloads[1].State != store.StateReverted {
t.Errorf("state = %q, want reverted (без изменений)", st.downloads[1].State)
}
}
// TestRelink_ForceReviewSkipsAuto проверяет, что после перепривязки даже
// уверенный матч не уходит в авто-раскладку, а ждёт подтверждения в review.
func TestRelink_ForceReviewSkipsAuto(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
// Готовим состояние «как после Relink»: reverted, force_review выставлен.
f.st.downloads[1].State = store.StateReverted
_ = f.st.SetOverride(context.Background(), 1, ovrForceReview, "1")
auto := seriesResult()
auto.Decision.Auto = true
auto.Match = &recognize.Match{Provider: "tvdb", ProviderID: "42"}
f.w.recognizer = &fakeRecognizer{result: auto}
if err := f.w.Relink(context.Background(), 1); err != nil {
t.Fatalf("Relink: %v", err)
}
f.w.recognizeOne(context.Background(), 1)
if f.st.downloads[1].State != store.StateReview {
t.Fatalf("state = %q, want review (авто-раскладка не должна сработать)", f.st.downloads[1].State)
}
if len(f.st.links) != 0 {
t.Errorf("file_links = %d, want 0 (ничего не линковали)", len(f.st.links))
}
}
// memStore — полноценный in-memory store для тестов Ф3.
type memStore struct {
downloads map[int64]*store.Download
@@ -114,6 +256,16 @@ func (m *memStore) ExistsByInfohash(_ context.Context, infohash string) (bool, e
return false, nil
}
func (m *memStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) {
for _, d := range m.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() {
cp := *d
return &cp, nil
}
}
return nil, nil
}
func (m *memStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
id := int64(len(m.downloads) + 1)
cp := *d
@@ -344,6 +496,42 @@ func TestRecognizeOne_CompletedToReview(t *testing.T) {
}
}
// TestRecognizeOne_FindsTagAdoptedTorrent — регрессия: раздача, усыновлённая
// по тегу, имеет чужую (или пустую) категорию. Поиск по infohash при
// распознавании обязан её найти; раньше фильтр по w.cfg.Category её терял и
// распознавание падало с «torrent not found in qBittorrent».
func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1))
qb := &fakeQbt{
torrents: []qbt.Torrent{{
Hash: ihTest, Name: "ThePitt", SavePath: "/d",
Category: "movies", Tags: "jellybit", // тег наш, категория чужая
}},
files: []qbt.File{{Name: "ThePitt/e1.mkv", Size: 100}, {Name: "ThePitt/e2.mkv", Size: 100}},
}
rec := &fakeRecognizer{result: seriesResult()}
w := testWorkerWith(st, qb, rec, nil)
w.recognizeOne(context.Background(), 1)
if st.downloads[1].State != store.StateReview {
t.Fatalf("state = %q, want review", st.downloads[1].State)
}
// Recognizer вернул бы Title="Show" только если торрент найден по infohash;
// при потере (фильтр по категории) был бы пустой план с причиной «not found».
cur, _ := st.GetCurrentRecognition(context.Background(), 1)
if cur == nil || cur.Title.String != "Show" {
t.Fatalf("recognizer did not run on found torrent (title=%q): torrent must be found by infohash despite foreign category",
func() string {
if cur == nil {
return "<nil>"
}
return cur.Title.String
}())
}
}
func TestRecognizeOne_DiscardsWhenStateChanged(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1))
+24
View File
@@ -33,6 +33,7 @@ type Store interface {
// Discovery (усыновление раздач по категории/тегу).
ExistsByInfohash(ctx context.Context, infohash string) (bool, error)
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
CreateDownload(ctx context.Context, d *store.Download) (int64, error)
// Ф3: распознавание, ревью, раскладка.
@@ -86,6 +87,13 @@ type Notifier interface {
Notify(ctx context.Context, downloadID int64, event NotifyEvent)
}
// Scanner — триггер пересканирования медиатеки Jellyfin. Вызывается
// неблокирующе после успешной раскладки, чтобы новые файлы быстрее появились
// в проигрывателе.
type Scanner interface {
RefreshLibraries(ctx context.Context) error
}
// Config — параметры воркера.
type Config struct {
Category string
@@ -110,11 +118,15 @@ type Worker struct {
now func() time.Time // подменяется в тестах
newID func() string // генератор apply_batch_id (подменяется в тестах)
notifier Notifier // опц. исходящие пинги
scanner Scanner // опц. пересканирование Jellyfin
}
// SetNotifier подключает исходящие пинги (до запуска Run).
func (w *Worker) SetNotifier(n Notifier) { w.notifier = n }
// SetScanner подключает пересканирование Jellyfin (до запуска Run).
func (w *Worker) SetScanner(s Scanner) { w.scanner = s }
// New собирает воркер. recognizer/layouter могут быть nil (Ф1 без Ф3-ступеней
// распознавания и раскладки) — тогда completed-задачи не двигаются дальше.
func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker {
@@ -260,6 +272,18 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
go w.notifier.Notify(context.Background(), d.ID, EventDone)
}
}
// Раскладка завершена — просим Jellyfin пересканировать библиотеку, чтобы
// новые файлы быстрее появились в проигрывателе. Тоже неблокирующе и вне
// w.mu; недоступность Jellyfin не влияет на состояние задачи.
if w.scanner != nil && state == store.StateDone {
id := d.ID
go func() {
if err := w.scanner.RefreshLibraries(context.Background()); err != nil {
w.log.Warn("jellyfin: library refresh failed", "download_id", id, "err", err)
}
}()
}
}
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
+24 -1
View File
@@ -60,6 +60,16 @@ func (f *fakeStore) ExistsByInfohash(_ context.Context, infohash string) (bool,
return false, nil
}
func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) {
for _, d := range f.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() {
cp := *d
return &cp, nil
}
}
return nil, nil
}
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
id := int64(len(f.downloads) + 1)
cp := *d
@@ -117,9 +127,22 @@ type fakeQbt struct {
files []qbt.File
}
func (f *fakeQbt) Torrents(_ context.Context, _ string) ([]qbt.Torrent, error) {
// Torrents имитирует /torrents/info: пустая категория — все торренты, иначе
// только торренты этой категории (как реальный qBittorrent). Это важно для
// регрессии: раздача, усыновлённая по тегу, имеет чужую категорию и не должна
// теряться при поиске по infohash.
func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, error) {
if category == "" {
return f.torrents, nil
}
var out []qbt.Torrent
for _, t := range f.torrents {
if t.Category == category {
out = append(out, t)
}
}
return out, nil
}
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
f.added = append(f.added, ar)
+5
View File
@@ -69,6 +69,11 @@
<button type="submit">Откатить</button>
</form>
{{end}}
{{if .Relinkable}}
<form method="post" action="/ui/downloads/{{.ID}}/relink">
<button type="submit">Привязать заново</button>
</form>
{{end}}
{{if not .Terminal}}
<form method="post" action="/ui/downloads/{{.ID}}/cancel">
<button type="submit">Отклонить</button>
+4
View File
@@ -150,6 +150,10 @@
<p><textarea name="hint" rows="2" placeholder="подсказка для распознавания, напр. «это второй сезон, рус+англ дорожки»"></textarea></p>
<button type="submit">🔁 Уточнить</button>
</form>
<form method="post" action="/ui/downloads/{{.ID}}/rerecognize" style="margin-top:.4rem">
<button type="submit">🔄 Распознать заново</button>
<small>(без новой подсказки — по уже накопленному контексту)</small>
</form>
{{if .Hints}}
<p><small>Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}</small></p>
{{end}}