Compare commits

...

9 Commits

29 changed files with 1048 additions and 192 deletions
+5 -2
View File
@@ -3,6 +3,9 @@
# CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o jellybit ./cmd/jellybit # CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o jellybit ./cmd/jellybit
# distroless/static несёт CA-сертификаты (HTTPS к LLM/TMDB). Пользователь # distroless/static несёт CA-сертификаты (HTTPS к LLM/TMDB). Пользователь
# задаётся в compose (user: "1000:1000"). # задаётся в compose (user: "1000:1000").
#
# Тома (см. compose): /config (ro, рендерится плейбуком — восстановимо при
# деплое) + /data (SQLite, бекапить-и-не-терять).
FROM gcr.io/distroless/static-debian12 FROM gcr.io/distroless/static-debian12
COPY jellybit /usr/local/bin/jellybit COPY jellybit /usr/local/bin/jellybit
@@ -10,8 +13,8 @@ COPY jellybit /usr/local/bin/jellybit
EXPOSE 8080 EXPOSE 8080
# В distroless нет shell/curl — проверку делает сам бинарь (порт берёт из # В distroless нет shell/curl — проверку делает сам бинарь (порт берёт из
# /data/config.toml). compose может переопределить параметры healthcheck. # /config/config.toml — дефолтный путь). compose может переопределить параметры.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["/usr/local/bin/jellybit", "healthcheck"] 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 — источник остаётся в 5. Файлы **хардлинкаются** в библиотеку Jellyfin — источник остаётся в
раздаче, место на диске не дублируется. раздаче, место на диске не дублируется.
6. После раскладки сервис (опц.) просит Jellyfin пересканировать
медиатеку, чтобы новые файлы быстрее появились в проигрывателе.
При высокой уверенности раскладка выполняется автоматически, иначе — При высокой уверенности раскладка выполняется автоматически, иначе —
уходит на подтверждение человеку. уходит на подтверждение человеку.
@@ -93,6 +95,7 @@ jellybit recognize <infohash> --dry-run [--context "..."] --config ./config.toml
бинарь: `jellybit healthcheck` (GET `/healthz` по порту из конфига, exit 0/1). бинарь: `jellybit healthcheck` (GET `/healthz` по порту из конфига, exit 0/1).
Контейнер: `user 1000:1000`, порт `8080` на хост, mount `/srv/media` (единая Контейнер: `user 1000:1000`, порт `8080` на хост, mount `/srv/media` (единая
песочница для хардлинков) + data-том с `config.toml`/SQLite; к qBittorrent — песочница для хардлинков) + том `/config` (ro, `config.toml`, восстановим при
по сети Docker. Конкретная деплой-обвязка (плейбук, секреты) держится в деплое) + data-том `/data` (SQLite, бекапить); к qBittorrent — по сети Docker.
отдельном приватном репозитории и в комплект не входит. Конкретная деплой-обвязка (плейбук, секреты) держится в отдельном приватном
репозитории и в комплект не входит.
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// нет shell/curl: docker зовёт сам бинарь. // нет shell/curl: docker зовёт сам бинарь.
func runHealthcheck(args []string) error { func runHealthcheck(args []string) error {
fs := flag.NewFlagSet("healthcheck", flag.ContinueOnError) 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 { if err := fs.Parse(args); err != nil {
return err return err
} }
+1 -1
View File
@@ -23,7 +23,7 @@ import (
// Только чтение: ни записи в БД, ни хардлинков. // Только чтение: ни записи в БД, ни хардлинков.
func runRecognize(args []string) error { func runRecognize(args []string) error {
fs := flag.NewFlagSet("recognize", flag.ContinueOnError) 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, "только показать план, без изменений (единственный режим)") dryRun := fs.Bool("dry-run", true, "только показать план, без изменений (единственный режим)")
contextStr := fs.String("context", "", "доп. текстовый контекст для распознавания") contextStr := fs.String("context", "", "доп. текстовый контекст для распознавания")
if err := fs.Parse(args); err != nil { 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/config"
"git.vakhrushev.me/av/jellybit/internal/httpapi" "git.vakhrushev.me/av/jellybit/internal/httpapi"
"git.vakhrushev.me/av/jellybit/internal/ingest" "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/layout"
"git.vakhrushev.me/av/jellybit/internal/llm" "git.vakhrushev.me/av/jellybit/internal/llm"
"git.vakhrushev.me/av/jellybit/internal/logging" "git.vakhrushev.me/av/jellybit/internal/logging"
@@ -32,7 +33,7 @@ import (
// воркер (фоном) → HTTP-сервер; останавливается по SIGINT/SIGTERM. // воркер (фоном) → HTTP-сервер; останавливается по SIGINT/SIGTERM.
func runServe(args []string) error { func runServe(args []string) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError) 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 { if err := fs.Parse(args); err != nil {
return err return err
} }
@@ -117,6 +118,25 @@ func runServe(args []string) error {
MagnetTimeout: cfg.Worker.MagnetTimeout.Std(), MagnetTimeout: cfg.Worker.MagnetTimeout.Std(),
}, logger) }, 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{ router, err := httpapi.NewRouter(httpapi.Deps{
Logger: logger, Logger: logger,
Ingestor: ingestor, Ingestor: ingestor,
+7
View File
@@ -45,6 +45,13 @@ enabled = false # без ключа; только сери
proxy = "" proxy = ""
timeout = "10s" timeout = "10s"
[jellyfin]
enabled = false # включить пересканирование медиатеки после раскладки
url = "http://jellyfin:8096" # по имени сервиса в общей docker-сети
api_key = "" # API-ключ Jellyfin (Dashboard → API Keys)
proxy = "" # опц. HTTP-прокси
timeout = "10s"
[worker] [worker]
poll_interval = "5s" poll_interval = "5s"
stuck_after = "1h" 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 | [Docker как единица деплоя](ADR-2026-06-13-docker-deploy.md) | — |
| 2026-06-13 | [Хардлинки вместо копирования и симлинков](ADR-2026-06-13-hardlinks.md) | — | | 2026-06-13 | [Хардлинки вместо копирования и симлинков](ADR-2026-06-13-hardlinks.md) | — |
| 2026-06-13 | [Go и доставка одним бинарём](ADR-2026-06-13-go-single-binary.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) — общее устройство: компоненты, - [architecture.md](architecture.md) — общее устройство: компоненты,
поток, машина состояний, хранилище, конфигурация. транспорты, хранилище, раскладка, деплой.
- [workflow.md](workflow.md) — жизненный цикл загрузки: машина состояний,
переходы, сопоставление состояний qBittorrent.
- [recognition.md](recognition.md) — распознавание контента и модель - [recognition.md](recognition.md) — распознавание контента и модель
уверенности. уверенности.
- [review-ux.md](review-ux.md) — ревью раскладки человеком: UI/UX-сценарии - [review-ux.md](review-ux.md) — ревью раскладки человеком: UI/UX-сценарии
+58 -131
View File
@@ -39,44 +39,18 @@ qBittorrent, определяет содержимое (фильм или сер
| `metadata` | интерфейс баз метаданных + TMDB/TVDB/TVMaze (опц.) | | `metadata` | интерфейс баз метаданных + TMDB/TVDB/TVMaze (опц.) |
| `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo | | `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo |
| `store` | SQLite: загрузки, распознавание, подсказки, ссылки | | `store` | SQLite: загрузки, распознавание, подсказки, ссылки |
| `httpapi` | REST + веб-UI (server-rendered, htmx) | | `httpapi` | REST + веб-UI (server-rendered, POST-формы с redirect) |
| `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги | | `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги |
| `jellyfin` | триггер пересканирования медиатеки после раскладки (опц.) |
| `config` | загрузка TOML-конфига | | `config` | загрузка TOML-конфига |
## Поток и машина состояний ## Поток и машина состояний
``` Жизненный цикл загрузки (ingest → downloading → … → done/reverted),
ingest → downloading → completed → recognizing ──┬─ авто ────────────────→ linking → done полный граф состояний с переходами и сопоставление состояний qBittorrent —
│ │ │ └─ review ⇄ recognizing ─→ linking → done в отдельной спецификации [workflow.md](workflow.md). Ключевое: переходами
│ │ └─ moving/checking (ещё не готов) владеет `worker`, он же сериализует команды транспортов под per-download
│ └─ stuck (не качается дольше таймаута) блокировкой, а состояние персистентно в SQLite.
└─ 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 с БД и
продолжает.
## Транспорты ## Транспорты
@@ -105,8 +79,10 @@ SQLite. Схема покрывает приём, цикл ревью и отк
`idempotency_key`, состояние, `error_code`/`error_msg`, тайминги. `idempotency_key`, состояние, `error_code`/`error_msg`, тайминги.
(infohash может появиться позже приёма — для magnet без метаданных.) (infohash может появиться позже приёма — для magnet без метаданных.)
- `recognition` — попытки распознавания: `download_id`, `attempt_no`, - `recognition` — попытки распознавания: `download_id`, `attempt_no`,
`is_current`, тип, название, год, `provider` (`tmdb|tvdb|none`), `is_current`, тип, название, год, `provider` (`tmdb|tvdb|tvmaze|none`),
`provider_id`, `confidence`, причины-не-авто, сырой ответ LLM. `provider_id`, `confidence`, причины-не-авто, сырой ответ LLM и
структурированный `plan` (каноничный JSON `recognize.Plan` — файл →
роль/сезон/серия для превью и применения).
- `hint` — накопленные подсказки человека (`download_id`, текст, время). - `hint` — накопленные подсказки человека (`download_id`, текст, время).
- `override` — запиненные ручные правки полей (перераспознавание не - `override` — запиненные ручные правки полей (перераспознавание не
затирает). затирает).
@@ -129,75 +105,19 @@ qBittorrent. Идемпотентность — **только для актив
## Конфигурация ## Конфигурация
TOML. В репозитории — `config.example.toml` с placeholder'ами; реальный TOML. Полный список параметров с комментариями — в
`config.toml` рендерится при деплое Ansible-шаблоном из переменных umbar [`config.example.toml`](../../config.example.toml) (источник истины, не
(секреты — `vars/secrets.yml` под ansible-vault), на диске **0600**, дублируем его здесь). Реальный `config.toml` рендерится при деплое
владелец `1000:1000`, не коммитится. Пример: Ansible-шаблоном из переменных umbar (секреты — `vars/secrets.yml` под
ansible-vault), на диске **0600**, владелец `1000:1000`, не коммитится.
```toml Структура секций: `[qbittorrent]` (доступ + категория/тег для push/pull),
[qbittorrent] `[paths]` (хост-пути песочницы), `[storage]` (путь к SQLite), `[llm]`
url = "http://qbit:8989" # по имени сервиса в общей docker-сети (провайдер распознавания, см. [recognition.md](recognition.md)),
username = "admin" `[metadata.tmdb|tvdb|tvmaze]` (опц. базы), `[jellyfin]` (опц.
password = "" пересканирование), `[worker]` (интервал поллинга и таймауты, см.
category = "jellybit" [workflow.md](workflow.md)), `[recognition]` (порог уверенности),
savepath = "/srv/media/downloads" # куда qBit кладёт загрузки (задаём при добавлении) `[telegram]`, `[http]`, `[log]`.
# Обычно пусто: все медиа-приложения монтируют /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"
```
## Логирование ## Логирование
@@ -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` по конвенциям `layout` создаёт хардлинки в `paths.movies`/`paths.series` по конвенциям
@@ -242,8 +142,13 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
доводит начатое (идемпотентно) либо откатывается. доводит начатое (идемпотентно) либо откатывается.
- **Undo** удаляет только ссылки своего `apply_batch_id` и только если - **Undo** удаляет только ссылки своего `apply_batch_id` и только если
путь под `paths.movies`/`series` — источник недосягаем. путь под `paths.movies`/`series` — источник недосягаем.
- **Одна ФС обязательна.** `link(2)` через границы ФС даёт `EXDEV` - **Хардлинк предпочтителен, но есть фолбэк.** По построению источник и
падаем с понятной ошибкой; по построению этого не должно случаться. цель — на одной ФС (единая песочница `/srv/media`), и `link(2)` проходит.
Если ФС всё же не поддерживает жёсткие ссылки или они между разными ФС
(`EXDEV`/`ENOTSUP`/`EOPNOTSUPP`/`EPERM`), `layout` **не падает**, а
копирует файл (через временный файл + атомарный `rename`) и пишет в лог
`Warn` (статус ссылки — `copied`): задача доходит до конца ценой
дублирования места. Источник при этом всё равно не трогаем.
### Пути и контейнеры — единая песочница `/srv/media` ### Пути и контейнеры — единая песочница `/srv/media`
@@ -266,10 +171,27 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
- **qBit** — `savepath=/srv/media/downloads`, temp `/srv/media/incomplete`. - **qBit** — `savepath=/srv/media/downloads`, temp `/srv/media/incomplete`.
- **jellybit** — читает `downloads`, пишет в `movies`/`series`; свой - **jellybit** — читает `downloads`, пишет в `movies`/`series`; свой
SQLite/конфиг — отдельным mount'ом `/srv/applications/jellybit/data`. SQLite — отдельным mount'ом `/srv/applications/jellybit/data`, конфиг —
отдельным `/srv/applications/jellybit/config`.
- **Jellyfin** — библиотеки указывают на `movies`/`series` (не на корень - **Jellyfin** — библиотеки указывают на `movies`/`series` (не на корень
`/srv/media`, иначе в индекс попадут downloads/incomplete). `/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 Jellybit работает в **docker** — в одной среде с qBittorrent и Jellyfin
@@ -290,10 +212,13 @@ Jellybit работает в **docker** — в одной среде с qBittorr
- **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь - **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь
umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника. umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника.
- **mount `/srv/media`** (единая песочница) — для хардлинков и move - **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 - **mount данных** `/srv/applications/jellybit/data``/data`: SQLite
(`/data/jellybit.db`) и `config.toml`. Без него редеплой стёр бы всё (`/data/jellybit.db`). Бекапить-и-не-терять — без него редеплой стёр бы
in-flight состояние. всё in-flight состояние.
- **healthcheck** на `/healthz`. - **healthcheck** на `/healthz`.
Разделение ответственности: Разделение ответственности:
@@ -332,6 +257,9 @@ Dockerfile .dockerignore config.example.toml
задач (повторная закачка спустя время → новая задача). задач (повторная закачка спустя время → новая задача).
- Состояние — на persistent-томе `/srv/applications/jellybit/data`. - Состояние — на persistent-томе `/srv/applications/jellybit/data`.
- Детект завершения — поллинг; webhook — на будущее (drafts/ideas). - Детект завершения — поллинг; webhook — на будущее (drafts/ideas).
- Пересканирование Jellyfin после раскладки — `POST /Library/Refresh` (скан
всех библиотек, инкрементальный), неблокирующе на входе в `done`; опц.,
включается `[jellyfin]`.
- Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF. - Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
- Авто-раскладка требует подтверждённого матча в базе; иначе review. - Авто-раскладка требует подтверждённого матча в базе; иначе review.
- Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей). - Веб-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 — же inode → готово; другой файл → коллизия → review). Инварианты и undo —
в [architecture.md](architecture.md) → «Раскладка файлов». в [architecture.md](architecture.md) → «Раскладка файлов».
Требование: целевой и исходный каталоги — на одной ФС/одном mount'е Желательно: целевой и исходный каталоги — на одной ФС/одном mount'е
(внутри контейнера это обеспечивает единая песочница `/srv/media`). (внутри контейнера это обеспечивает единая песочница `/srv/media`), тогда
работает дешёвый хардлинк. Если хардлинк невозможен (разные ФС или ФС без
поддержки жёстких ссылок), `layout` не падает, а копирует файл с
предупреждением в лог — см. architecture.md → «Раскладка файлов».
## Крайние случаи ## Крайние случаи
+19 -13
View File
@@ -5,7 +5,8 @@
По доступным сигналам определить: фильм или сериал; каноническое название По доступным сигналам определить: фильм или сериал; каноническое название
и год; для сериала — сезон(ы) и соответствие файлов сериям; при включённых и год; для сериала — сезон(ы) и соответствие файлов сериям; при включённых
базах — провайдер и его id. На выходе — план раскладки, оценка уверенности базах — провайдер и его id. На выходе — план раскладки, оценка уверенности
и решение «авто или review». и решение «авто или review» (как оно встраивается в машину состояний —
[workflow.md](workflow.md), состояния `recognizing`/`linking`/`review`).
## Сигналы ## Сигналы
@@ -33,8 +34,10 @@
пред-парс, возвращает структурированный план в нашей схеме. Хорошо пред-парс, возвращает структурированный план в нашей схеме. Хорошо
берёт русские релиз-имена. Длинный список файлов усекаем/семплируем под берёт русские релиз-имена. Длинный список файлов усекаем/семплируем под
контекст модели. контекст модели.
3. **Сверка с базой** (если включена TMDB/TVDB): ищем по названию+году, 3. **Сверка с базой** (если включена TMDB/TVDB/TVMaze): ищем по
берём официальный id и каноническое имя, собираем кандидатов. названию+году, берём официальный id и каноническое имя, собираем
кандидатов. TVMaze — без ключа, только сериалы; внешний id
(TVDB/IMDb) из `externals` идёт в имя папки.
4. **Оценка уверенности** и решение: авто или review. 4. **Оценка уверенности** и решение: авто или review.
## Структура ответа LLM (предварительная) ## Структура ответа LLM (предварительная)
@@ -54,8 +57,8 @@ notes пояснения, неоднозначности
Сезон/серия — **на файле**: так выражаются мультисезонные паки, Сезон/серия — **на файле**: так выражаются мультисезонные паки,
спецвыпуски и смешанные раскладки; отдельного скалярного `season` нет. спецвыпуски и смешанные раскладки; отдельного скалярного `season` нет.
`provider_hint` — только подсказка для поиска; итоговые `provider` `provider_hint` — только подсказка для поиска; итоговые `provider`
(`tmdb|tvdb|none`) и `provider_id` появляются после сверки с базой и (`tmdb|tvdb|tvmaze|none`) и `provider_id` появляются после сверки с базой
хранятся отдельно. и хранятся отдельно.
## Провайдер LLM ## Провайдер LLM
@@ -67,22 +70,25 @@ notes пояснения, неоднозначности
Chat Completions API (`base_url` + `api_key` + `model`). Подходят Chat Completions API (`base_url` + `api_key` + `model`). Подходят
локальные серверы (LM Studio, llama.cpp, Ollama) и облачные совместимые локальные серверы (LM Studio, llama.cpp, Ollama) и облачные совместимые
провайдеры (DeepSeek, Qwen и др.). провайдеры (DeepSeek, Qwen и др.).
- **Структурированный вывод надёжно:** просим JSON по схеме - **Структурированный вывод надёжно:** просим JSON-режим
(`response_format` со схемой где поддерживается; иначе json-режим или (`response_format: {"type":"json_object"}`) — это поддерживают и мелкие
tool-call); на приёме срезаем ```-ограждения и извлекаем JSON, локальные модели, в отличие от строгих JSON Schema. На приёме срезаем
**валидируем в Go**, ретраим со схемой-в-промпте до `llm.max_retries`; ```-ограждения и извлекаем JSON, **валидируем в Go** против нашей схемы;
если так и не распарсилось — уходим в **review** (не в `failed`) с при ошибке разбора ретраим, передавая модели саму ошибку и схему в
причиной «ответ LLM не разобран». Серверы заметно различаются по промпте, до `llm.max_retries`. Если так и не распарсилось — уходим в
поддержке строгих схем, особенно мелкие локальные модели. **review** (не в `failed`) с причиной «ответ LLM не разобран».
- Новые типы (напр. нативный `anthropic`) добавляются, не трогая - Новые типы (напр. нативный `anthropic`) добавляются, не трогая
`recognize`. `recognize`.
## Модель уверенности ## Модель уверенности
Почему авто только при матче в базе, а не по самооценке LLM —
[ADR-2026-06-13-auto-link-requires-db-match](../adr/ADR-2026-06-13-auto-link-requires-db-match.md).
Авто-раскладка — только если выполнено **всё**: Авто-раскладка — только если выполнено **всё**:
1. **Подтверждённый матч в базе** — единственный сильный результат 1. **Подтверждённый матч в базе** — единственный сильный результат
TMDB/TVDB по названию+году, давший `provider_id`. **Нет матча (или TMDB/TVDB/TVMaze по названию+году, давший `provider_id`. **Нет матча (или
база выключена) → всегда review.** Это и закрывает основной кейс база выключена) → всегда review.** Это и закрывает основной кейс
(рус/аниме часто отсутствуют в базах), и снимает риск «LLM придумал». (рус/аниме часто отсутствуют в базах), и снимает риск «LLM придумал».
2. **Структурная валидация** без предупреждений: 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). [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 по - **«Позже»** паркует загрузку в `deferred` (вернётся в review по
действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo** действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo**
после применения → `reverted` (удаляет только ссылки своего батча, под после применения → `reverted` (удаляет только ссылки своего батча, под
`media`). Полная карта состояний — в [architecture.md](architecture.md). `media`). Полная карта состояний — в [workflow.md](workflow.md).
- После отката или отклонения доступна **«Привязать заново»**: перезапускает
распознавание для той же раздачи (`reverted`/`cancelled → recognizing`) и
снова приводит в review — раскладка всегда требует ручного подтверждения,
авто не делаем. Нужна, когда распознали неверно: откатил/отклонил,
перепривязал, поправил и применил.
- В самом ревью, помимо **«Уточнить»** (подсказка + перераспознавание), есть
**«Распознать заново»** — повторный прогон распознавания без новой подсказки
(контекст и прежние подсказки уже учтены). Полезно, когда модель один раз
споткнулась на разовой ошибке.
## Объём по версиям ## Объём по версиям
- **Ф3 (первая версия):** подсказка + перераспознавание; из ручного — - **Ф3 (готово):** в вебе — подсказка + перераспознавание, «Распознать
переключатель типа, выбор кандидата базы, пометка файла «игнор». Undo — заново», переключатель типа, выбор кандидата базы / ручной ввод id /
есть. «без базы», пометка файла «игнор», «Применить»/«Отклонить»/«Позже»,
- **Ф5:** полный редактор маппинга «файл → серия», ручной режим, Undo и «Привязать заново». В Telegram — подтверждение с reply-подсказкой
подтверждение в 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"` Storage Storage `toml:"storage"`
LLM LLM `toml:"llm"` LLM LLM `toml:"llm"`
Metadata Metadata `toml:"metadata"` Metadata Metadata `toml:"metadata"`
Jellyfin Jellyfin `toml:"jellyfin"`
Worker Worker `toml:"worker"` Worker Worker `toml:"worker"`
Recognition Recognition `toml:"recognition"` Recognition Recognition `toml:"recognition"`
Telegram Telegram `toml:"telegram"` Telegram Telegram `toml:"telegram"`
@@ -78,6 +79,16 @@ type MetadataProvider struct {
Timeout Duration `toml:"timeout"` 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 — параметры фонового цикла. // Worker — параметры фонового цикла.
type Worker struct { type Worker struct {
PollInterval Duration `toml:"poll_interval"` PollInterval Duration `toml:"poll_interval"`
@@ -155,6 +166,7 @@ func Default() *Config {
TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)}, TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)}, TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
}, },
Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)},
Worker: Worker{ Worker: Worker{
PollInterval: Duration(5 * time.Second), PollInterval: Duration(5 * time.Second),
StuckAfter: Duration(time.Hour), StuckAfter: Duration(time.Hour),
+23 -2
View File
@@ -13,6 +13,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -85,6 +86,7 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Get("/review/{id}", s.handleReview) r.Get("/review/{id}", s.handleReview)
r.Post("/ui/downloads/{id}/apply", s.handleApply) r.Post("/ui/downloads/{id}/apply", s.handleApply)
r.Post("/ui/downloads/{id}/refine", s.handleRefine) 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}/type", s.handleSetType)
r.Post("/ui/downloads/{id}/ignore", s.handleIgnore) r.Post("/ui/downloads/{id}/ignore", s.handleIgnore)
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate) 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}/nobase", s.handleNoBase)
r.Post("/ui/downloads/{id}/defer", s.handleDefer) r.Post("/ui/downloads/{id}/defer", s.handleDefer)
r.Post("/ui/downloads/{id}/undo", s.handleUndo) r.Post("/ui/downloads/{id}/undo", s.handleUndo)
r.Post("/ui/downloads/{id}/relink", s.handleRelink)
// REST API. // REST API.
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
@@ -126,6 +129,7 @@ type downloadView struct {
Terminal bool Terminal bool
Reviewable bool // review/deferred — есть экран ревью Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку Undoable bool // done — можно откатить раскладку
Relinkable bool // reverted/cancelled — можно перепривязать заново
} }
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -304,6 +308,7 @@ func toView(d store.Download) downloadView {
Terminal: d.State.IsTerminal(), Terminal: d.State.IsTerminal(),
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred, Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
Undoable: d.State == store.StateDone, 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()} 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 { func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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) next.ServeHTTP(ww, r)
logger.Info("http request", logger.Log(r.Context(), requestLogLevel(r), "http request",
"method", r.Method, "method", r.Method,
"path", r.URL.Path, "path", r.URL.Path,
"status", ww.Status(), "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
}
}
+49 -11
View File
@@ -183,17 +183,19 @@ func (e ingestErr) Error() string { return string(e) }
// --- Ревью --- // --- Ревью ---
type fakeReviewer struct { type fakeReviewer struct {
data *worker.ReviewData data *worker.ReviewData
applyErr error applyErr error
refined map[int64]string refined map[int64]string
typed map[int64]string typed map[int64]string
ignored map[int64]string ignored map[int64]string
chosen map[int64]int64 chosen map[int64]int64
providerSet map[int64]string providerSet map[int64]string
applied []int64 applied []int64
deferred []int64 deferred []int64
undone []int64 undone []int64
cleared []int64 relinked []int64
rerecognized []int64
cleared []int64
} }
func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) { func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) {
@@ -235,6 +237,14 @@ func (f *fakeReviewer) Undo(_ context.Context, id int64) error {
f.undone = append(f.undone, id) f.undone = append(f.undone, id)
return nil 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 { func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID int64) error {
if f.chosen == nil { if f.chosen == nil {
f.chosen = map[int64]int64{} f.chosen = map[int64]int64{}
@@ -444,3 +454,31 @@ func TestUndoAndDefer(t *testing.T) {
t.Errorf("undo=%v defer=%v", rv.undone, rv.deferred) 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 IgnoreFile(ctx context.Context, id int64, src string) error
Defer(ctx context.Context, id int64) error Defer(ctx context.Context, id int64) error
Undo(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 ChooseCandidate(ctx context.Context, id, candidateID int64) error
SetProviderID(ctx context.Context, id int64, provider, providerID string) error SetProviderID(ctx context.Context, id int64, provider, providerID string) error
ClearProvider(ctx context.Context, id int64) 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) { func (s *server) handleSetType(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error { s.reviewAction(w, r, func(ctx context.Context, id int64) error {
_ = r.ParseForm() _ = r.ParseForm()
@@ -233,6 +241,22 @@ func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther) 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 — общий помощник: выполнить действие и вернуться на страницу // reviewAction — общий помощник: выполнить действие и вернуться на страницу
// ревью (с ошибкой в ?err при неудаче). // ревью (с ошибкой в ?err при неудаче).
func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) error) { 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)", "provider_hint": "строка для поиска в базе (НЕ id)",
"files": [ "files": [
{ {
"src": "путь файла РОВНО как в списке ниже", "src": "путь файла из списка ниже, БЕЗ размера в скобках в конце строки",
"role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore", "role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore",
"season": число или null, "season": число или null,
"episode": число или null "episode": число или null
@@ -50,7 +50,8 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore". - "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
- Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode. - Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode.
- Для фильма ровно один основной видеофайл role "main". - Для фильма ровно один основной видеофайл role "main".
- Поле src копируй ДОСЛОВНО из списка файлов; не выдумывай и не нормализуй пути. - Поле src — это путь файла из списка, скопированный дословно, но БЕЗ размера
«(…)» в конце строки; не выдумывай и не нормализуй пути.
- Внешние субтитры — role "subtitle".` - Внешние субтитры — role "subtitle".`
const systemPrompt = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента, const systemPrompt = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента,
@@ -133,14 +134,15 @@ func writeFileList(b *strings.Builder, files []File, maxFiles int) {
} }
b.WriteString("Файлы (") b.WriteString("Файлы (")
b.WriteString(strconv.Itoa(n)) b.WriteString(strconv.Itoa(n))
b.WriteString(", поле src — это точные пути отсюда):\n") b.WriteString("). В src копируй ТОЛЬКО путь — текст после номера и до размера ")
b.WriteString("в скобках; размер «(…)» в конце строки в src НЕ включай:\n")
for i := 0; i < shown; i++ { for i := 0; i < shown; i++ {
b.WriteString(strconv.Itoa(i + 1)) b.WriteString(strconv.Itoa(i + 1))
b.WriteString(". [") b.WriteString(". ")
b.WriteString(humanSize(files[i].Size))
b.WriteString("] ")
b.WriteString(files[i].Path) b.WriteString(files[i].Path)
b.WriteByte('\n') b.WriteString(" (")
b.WriteString(humanSize(files[i].Size))
b.WriteString(")\n")
} }
if shown < n { if shown < n {
b.WriteString("… и ещё ") b.WriteString("… и ещё ")
+71 -7
View File
@@ -21,10 +21,11 @@ import (
const ( const (
ovrMediaType = "media_type" ovrMediaType = "media_type"
ovrIgnoredFiles = "ignored_files" ovrIgnoredFiles = "ignored_files"
ovrProvider = "provider" // выбранная база ("none" = без базы) ovrProvider = "provider" // выбранная база ("none" = без базы)
ovrProviderID = "provider_id" // id в выбранной базе ovrProviderID = "provider_id" // id в выбранной базе
ovrTitle = "title" // запиненное каноническое название ovrTitle = "title" // запиненное каноническое название
ovrYear = "year" // запиненный год ovrYear = "year" // запиненный год
ovrForceReview = "force_review" // ручная перепривязка: не авто-раскладывать
) )
// recognizePending распознаёт завершённые загрузки и перезапускает те, что // recognizePending распознаёт завершённые загрузки и перезапускает те, что
@@ -178,9 +179,13 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
} }
// Авто-раскладка при подтверждённом матче и чистой валидации (Ф4); // Авто-раскладка при подтверждённом матче и чистой валидации (Ф4);
// иначе — review. Раскладчик может быть не сконфигурирован. // иначе — review. Раскладчик может быть не сконфигурирован. При ручной
if res.Decision.Auto && w.layouter != nil { // перепривязке (force_review) авто-раскладку не делаем — нужно явное
plan := applyOverrides(res.Plan, w.overridesOrNil(ctx, id)) // подтверждение человеком.
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, "", "") w.transition(ctx, *d, store.StateLinking, "", "")
if err := w.linkPlan(ctx, d, plan, tag, savePath); err != nil { if err := w.linkPlan(ctx, d, plan, tag, savePath); err != nil {
w.log.Warn("recognize: auto-apply failed, left for review", 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 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 добавляет подсказку и отправляет задачу на перераспознавание. // Refine добавляет подсказку и отправляет задачу на перераспознавание.
func (w *Worker) Refine(ctx context.Context, id int64, hint string) error { func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
hint = strings.TrimSpace(hint) hint = strings.TrimSpace(hint)
+152
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. // memStore — полноценный in-memory store для тестов Ф3.
type memStore struct { type memStore struct {
downloads map[int64]*store.Download downloads map[int64]*store.Download
@@ -114,6 +256,16 @@ func (m *memStore) ExistsByInfohash(_ context.Context, infohash string) (bool, e
return false, nil 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) { func (m *memStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
id := int64(len(m.downloads) + 1) id := int64(len(m.downloads) + 1)
cp := *d cp := *d
+24
View File
@@ -33,6 +33,7 @@ type Store interface {
// Discovery (усыновление раздач по категории/тегу). // Discovery (усыновление раздач по категории/тегу).
ExistsByInfohash(ctx context.Context, infohash string) (bool, error) 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) CreateDownload(ctx context.Context, d *store.Download) (int64, error)
// Ф3: распознавание, ревью, раскладка. // Ф3: распознавание, ревью, раскладка.
@@ -86,6 +87,13 @@ type Notifier interface {
Notify(ctx context.Context, downloadID int64, event NotifyEvent) Notify(ctx context.Context, downloadID int64, event NotifyEvent)
} }
// Scanner — триггер пересканирования медиатеки Jellyfin. Вызывается
// неблокирующе после успешной раскладки, чтобы новые файлы быстрее появились
// в проигрывателе.
type Scanner interface {
RefreshLibraries(ctx context.Context) error
}
// Config — параметры воркера. // Config — параметры воркера.
type Config struct { type Config struct {
Category string Category string
@@ -110,11 +118,15 @@ type Worker struct {
now func() time.Time // подменяется в тестах now func() time.Time // подменяется в тестах
newID func() string // генератор apply_batch_id (подменяется в тестах) newID func() string // генератор apply_batch_id (подменяется в тестах)
notifier Notifier // опц. исходящие пинги notifier Notifier // опц. исходящие пинги
scanner Scanner // опц. пересканирование Jellyfin
} }
// SetNotifier подключает исходящие пинги (до запуска Run). // SetNotifier подключает исходящие пинги (до запуска Run).
func (w *Worker) SetNotifier(n Notifier) { w.notifier = n } 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-ступеней // New собирает воркер. recognizer/layouter могут быть nil (Ф1 без Ф3-ступеней
// распознавания и раскладки) — тогда completed-задачи не двигаются дальше. // распознавания и раскладки) — тогда completed-задачи не двигаются дальше.
func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker { 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) 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 не трогаем — он продолжает // Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
+10
View File
@@ -60,6 +60,16 @@ func (f *fakeStore) ExistsByInfohash(_ context.Context, infohash string) (bool,
return false, nil 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) { func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
id := int64(len(f.downloads) + 1) id := int64(len(f.downloads) + 1)
cp := *d cp := *d
+5
View File
@@ -69,6 +69,11 @@
<button type="submit">Откатить</button> <button type="submit">Откатить</button>
</form> </form>
{{end}} {{end}}
{{if .Relinkable}}
<form method="post" action="/ui/downloads/{{.ID}}/relink">
<button type="submit">Привязать заново</button>
</form>
{{end}}
{{if not .Terminal}} {{if not .Terminal}}
<form method="post" action="/ui/downloads/{{.ID}}/cancel"> <form method="post" action="/ui/downloads/{{.ID}}/cancel">
<button type="submit">Отклонить</button> <button type="submit">Отклонить</button>
+4
View File
@@ -150,6 +150,10 @@
<p><textarea name="hint" rows="2" placeholder="подсказка для распознавания, напр. «это второй сезон, рус+англ дорожки»"></textarea></p> <p><textarea name="hint" rows="2" placeholder="подсказка для распознавания, напр. «это второй сезон, рус+англ дорожки»"></textarea></p>
<button type="submit">🔁 Уточнить</button> <button type="submit">🔁 Уточнить</button>
</form> </form>
<form method="post" action="/ui/downloads/{{.ID}}/rerecognize" style="margin-top:.4rem">
<button type="submit">🔄 Распознать заново</button>
<small>(без новой подсказки — по уже накопленному контексту)</small>
</form>
{{if .Hints}} {{if .Hints}}
<p><small>Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}</small></p> <p><small>Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}</small></p>
{{end}} {{end}}