Compare commits
10 Commits
0e69a86a89
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
1e8000429a
|
|||
|
70b3c7ae14
|
|||
|
d727966f29
|
|||
|
d149cb7481
|
|||
|
b1f97c105a
|
|||
|
157f626c2e
|
|||
|
e297f0fb84
|
|||
|
16a82572e7
|
|||
|
093211c9c7
|
|||
|
fff0960915
|
+5
-2
@@ -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"]
|
||||||
|
|||||||
@@ -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.
|
||||||
отдельном приватном репозитории и в комплект не входит.
|
Конкретная деплой-обвязка (плейбук, секреты) держится в отдельном приватном
|
||||||
|
репозитории и в комплект не входит.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 вынесен в отдельную спеку.
|
||||||
@@ -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) | — |
|
||||||
|
|||||||
@@ -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
@@ -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 (сейчас его там нет).
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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`.
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("… и ещё ")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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) {
|
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 {
|
if err != nil {
|
||||||
return qbt.Torrent{}, false, err
|
return qbt.Torrent{}, false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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) {
|
func TestRecognizeOne_DiscardsWhenStateChanged(t *testing.T) {
|
||||||
st := newMemStore()
|
st := newMemStore()
|
||||||
st.put(completedDownload(1))
|
st.put(completedDownload(1))
|
||||||
|
|||||||
@@ -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 не трогаем — он продолжает
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -117,8 +127,21 @@ type fakeQbt struct {
|
|||||||
files []qbt.File
|
files []qbt.File
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeQbt) Torrents(_ context.Context, _ string) ([]qbt.Torrent, error) {
|
// Torrents имитирует /torrents/info: пустая категория — все торренты, иначе
|
||||||
return f.torrents, nil
|
// только торренты этой категории (как реальный 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 {
|
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user