Compare commits

..

2 Commits

16 changed files with 489 additions and 14 deletions
+2
View File
@@ -26,6 +26,8 @@ Arr-стек (prowlarr/radarr/sonarr) плохо ложится на русск
фильм/сериал и нужная раскладка. фильм/сериал и нужная раскладка.
5. Файлы **хардлинкаются** в библиотеку Jellyfin — источник остаётся в 5. Файлы **хардлинкаются** в библиотеку Jellyfin — источник остаётся в
раздаче, место на диске не дублируется. раздаче, место на диске не дублируется.
6. После раскладки сервис (опц.) просит Jellyfin пересканировать
медиатеку, чтобы новые файлы быстрее появились в проигрывателе.
При высокой уверенности раскладка выполняется автоматически, иначе — При высокой уверенности раскладка выполняется автоматически, иначе —
уходит на подтверждение человеку. уходит на подтверждение человеку.
+20
View File
@@ -17,6 +17,7 @@ import (
"git.vakhrushev.me/av/jellybit/internal/config" "git.vakhrushev.me/av/jellybit/internal/config"
"git.vakhrushev.me/av/jellybit/internal/httpapi" "git.vakhrushev.me/av/jellybit/internal/httpapi"
"git.vakhrushev.me/av/jellybit/internal/ingest" "git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/jellyfin"
"git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/llm" "git.vakhrushev.me/av/jellybit/internal/llm"
"git.vakhrushev.me/av/jellybit/internal/logging" "git.vakhrushev.me/av/jellybit/internal/logging"
@@ -117,6 +118,25 @@ func runServe(args []string) error {
MagnetTimeout: cfg.Worker.MagnetTimeout.Std(), MagnetTimeout: cfg.Worker.MagnetTimeout.Std(),
}, logger) }, logger)
// Пересканирование Jellyfin после раскладки (опц.). Недоступность Jellyfin
// не валит сервис — скан просто не сработает (залогируется в воркере).
if cfg.Jellyfin.Enabled {
if cfg.Jellyfin.URL == "" || cfg.Jellyfin.APIKey == "" {
return fmt.Errorf("jellyfin enabled, but url or api_key is empty")
}
jf, jerr := jellyfin.New(jellyfin.Config{
URL: cfg.Jellyfin.URL,
APIKey: cfg.Jellyfin.APIKey,
Proxy: cfg.Jellyfin.Proxy,
Timeout: cfg.Jellyfin.Timeout.Std(),
}, logger)
if jerr != nil {
return fmt.Errorf("jellyfin client: %w", jerr)
}
wrk.SetScanner(jf)
logger.Info("jellyfin rescan enabled", "url", cfg.Jellyfin.URL)
}
router, err := httpapi.NewRouter(httpapi.Deps{ router, err := httpapi.NewRouter(httpapi.Deps{
Logger: logger, Logger: logger,
Ingestor: ingestor, Ingestor: ingestor,
+7
View File
@@ -45,6 +45,13 @@ enabled = false # без ключа; только сери
proxy = "" proxy = ""
timeout = "10s" timeout = "10s"
[jellyfin]
enabled = false # включить пересканирование медиатеки после раскладки
url = "http://jellyfin:8096" # по имени сервиса в общей docker-сети
api_key = "" # API-ключ Jellyfin (Dashboard → API Keys)
proxy = "" # опц. HTTP-прокси
timeout = "10s"
[worker] [worker]
poll_interval = "5s" poll_interval = "5s"
stuck_after = "1h" stuck_after = "1h"
+39 -7
View File
@@ -41,6 +41,7 @@ qBittorrent, определяет содержимое (фильм или сер
| `store` | SQLite: загрузки, распознавание, подсказки, ссылки | | `store` | SQLite: загрузки, распознавание, подсказки, ссылки |
| `httpapi` | REST + веб-UI (server-rendered, htmx) | | `httpapi` | REST + веб-UI (server-rendered, htmx) |
| `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги | | `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги |
| `jellyfin` | триггер пересканирования медиатеки после раскладки (опц.) |
| `config` | загрузка TOML-конфига | | `config` | загрузка TOML-конфига |
## Поток и машина состояний ## Поток и машина состояний
@@ -52,9 +53,10 @@ ingest → downloading → completed → recognizing ──┬─ авто ─
│ └─ stuck (не качается дольше таймаута) │ └─ stuck (не качается дольше таймаута)
└─ failed ⇄ retry └─ failed ⇄ retry
done → undo → reverted done → undo → reverted
review → «Позже» → deferred → review reverted → «Привязать заново» → recognizing (ручная перепривязка, всегда через review)
любой → «Отклонить» → cancelled review → «Позже» → deferred → review
любой → «Отклонить» → cancelled
``` ```
- **ingest** — приняли источник + контекст, отдали в qBittorrent - **ingest** — приняли источник + контекст, отдали в qBittorrent
@@ -68,10 +70,15 @@ review → «Позже» → deferred → review
- **review** — план уходит человеку ([review-ux.md](review-ux.md)); цикл - **review** — план уходит человеку ([review-ux.md](review-ux.md)); цикл
`review ⇄ recognizing` — перераспознавание по подсказке. `review ⇄ recognizing` — перераспознавание по подсказке.
- **linking** — `layout` создаёт хардлинки; идемпотентно, батчем. - **linking** — `layout` создаёт хардлинки; идемпотентно, батчем.
- **done** — опционально дёргаем скан Jellyfin; доступен **undo** - **done** — при входе неблокирующе дёргаем пересканирование Jellyfin (опц.,
`reverted` (убрать созданные ссылки). см. «Пересканирование Jellyfin»); доступен **undo** `reverted` (убрать
созданные ссылки).
- **deferred / cancelled / failed / stuck** — «Позже», «Отклонить», - **deferred / cancelled / failed / stuck** — «Позже», «Отклонить»,
ошибка (ретраибельна), не качается дольше таймаута. ошибка (ретраибельна), не качается дольше таймаута.
- **reverted → recognizing** — «Привязать заново»: после отката можно
перезапустить распознавание для той же раздачи. Перепривязка всегда идёт
через review с ручным подтверждением (авто-раскладку не делаем), и требует,
чтобы раздача всё ещё была в qBittorrent.
Все переходы и команды идут через `worker` под per-download блокировкой — Все переходы и команды идут через `worker` под per-download блокировкой —
два транспорта не гонятся за одно состояние. Состояние персистентно в два транспорта не гонятся за одно состояние. Состояние персистентно в
@@ -177,6 +184,13 @@ api_key = ""
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" # как часто опрашивать qBittorrent poll_interval = "5s" # как часто опрашивать qBittorrent
stuck_after = "1h" # не качается дольше → stuck stuck_after = "1h" # не качается дольше → stuck
@@ -270,6 +284,22 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
- **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
@@ -332,6 +362,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 +373,4 @@ Dockerfile .dockerignore config.example.toml
## Открытые вопросы ## Открытые вопросы
- Конфигурация Jellyfin (URL + API-ключ) и триггер скана — когда Jellyfin - (пока нет)
будет развёрнут в umbar (сейчас его там нет).
+4
View File
@@ -123,6 +123,10 @@ Telegram = одобрить / подсказать / выбрать кандид
действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo** действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo**
после применения → `reverted` (удаляет только ссылки своего батча, под после применения → `reverted` (удаляет только ссылки своего батча, под
`media`). Полная карта состояний — в [architecture.md](architecture.md). `media`). Полная карта состояний — в [architecture.md](architecture.md).
- После отката доступна **«Привязать заново»**: перезапускает распознавание
для той же раздачи (`reverted → recognizing`) и снова приводит в review —
раскладка всегда требует ручного подтверждения, авто не делаем. Нужна,
когда распознали неверно: откатил, перепривязал, поправил и применил.
## Объём по версиям ## Объём по версиям
+12
View File
@@ -17,6 +17,7 @@ type Config struct {
Storage Storage `toml:"storage"` Storage Storage `toml:"storage"`
LLM LLM `toml:"llm"` LLM LLM `toml:"llm"`
Metadata Metadata `toml:"metadata"` Metadata Metadata `toml:"metadata"`
Jellyfin Jellyfin `toml:"jellyfin"`
Worker Worker `toml:"worker"` Worker Worker `toml:"worker"`
Recognition Recognition `toml:"recognition"` Recognition Recognition `toml:"recognition"`
Telegram Telegram `toml:"telegram"` Telegram Telegram `toml:"telegram"`
@@ -78,6 +79,16 @@ type MetadataProvider struct {
Timeout Duration `toml:"timeout"` Timeout Duration `toml:"timeout"`
} }
// Jellyfin — пересканирование медиатеки после раскладки (опц.). Включается
// конфигом; без него скан не дёргается.
type Jellyfin struct {
Enabled bool `toml:"enabled"`
URL string `toml:"url"`
APIKey string `toml:"api_key"`
Proxy string `toml:"proxy"` // опц. HTTP-прокси
Timeout Duration `toml:"timeout"`
}
// Worker — параметры фонового цикла. // Worker — параметры фонового цикла.
type Worker struct { type Worker struct {
PollInterval Duration `toml:"poll_interval"` PollInterval Duration `toml:"poll_interval"`
@@ -155,6 +166,7 @@ func Default() *Config {
TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)}, TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)}, TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
}, },
Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)},
Worker: Worker{ Worker: Worker{
PollInterval: Duration(5 * time.Second), PollInterval: Duration(5 * time.Second),
StuckAfter: Duration(time.Hour), StuckAfter: Duration(time.Hour),
+3
View File
@@ -92,6 +92,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 +127,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 — можно перепривязать заново
} }
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -304,6 +306,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,
} }
} }
+19
View File
@@ -193,6 +193,7 @@ type fakeReviewer struct {
applied []int64 applied []int64
deferred []int64 deferred []int64
undone []int64 undone []int64
relinked []int64
cleared []int64 cleared []int64
} }
@@ -235,6 +236,10 @@ 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) 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 +449,17 @@ 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)
}
}
+17
View File
@@ -20,6 +20,7 @@ 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
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
@@ -233,6 +234,22 @@ func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
// handleRelink повторно привязывает откатанную задачу: перезапускает
// распознавание, задача пройдёт recognizing → review для подтверждения.
func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
if err := s.deps.Reviewer.Relink(r.Context(), id); err != nil {
s.deps.Logger.Warn("review action failed", "action", "relink", "id", id, "err", err)
redirectErr(w, r, err.Error())
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// reviewAction — общий помощник: выполнить действие и вернуться на страницу // reviewAction — общий помощник: выполнить действие и вернуться на страницу
// ревью (с ошибкой в ?err при неудаче). // ревью (с ошибкой в ?err при неудаче).
func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) error) { func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) error) {
+93
View File
@@ -0,0 +1,93 @@
// Package jellyfin — минимальный клиент Jellyfin для пересканирования
// медиатеки после успешной раскладки. Единственная задача: дёрнуть скан
// всех библиотек (POST /Library/Refresh), чтобы новые хардлинки быстрее
// появились в проигрывателе. В духе сервиса — без зоопарка вызовов.
package jellyfin
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
)
const defaultTimeout = 10 * time.Second
// Config — подключение к Jellyfin.
type Config struct {
URL string
APIKey string
Proxy string // опц. HTTP-прокси
Timeout time.Duration
}
// Client — клиент Jellyfin API.
type Client struct {
base string
apiKey string
hc *http.Client
log *slog.Logger
}
// New собирает клиент с опц. прокси. logger nil → slog.Default().
func New(cfg Config, logger *slog.Logger) (*Client, error) {
base, err := url.Parse(strings.TrimRight(cfg.URL, "/"))
if err != nil {
return nil, fmt.Errorf("jellyfin: parse url %q: %w", cfg.URL, err)
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaultTimeout
}
transport := http.DefaultTransport
if cfg.Proxy != "" {
pu, perr := url.Parse(cfg.Proxy)
if perr != nil {
return nil, fmt.Errorf("jellyfin: parse proxy %q: %w", cfg.Proxy, perr)
}
// Клонируем дефолтный транспорт (dial/TLS-таймауты, keep-alive), а не
// собираем голый — как в metadata-клиенте.
t := http.DefaultTransport.(*http.Transport).Clone()
t.Proxy = http.ProxyURL(pu)
transport = t
}
if logger == nil {
logger = slog.Default()
}
return &Client{
base: base.String(),
apiKey: cfg.APIKey,
hc: &http.Client{Timeout: timeout, Transport: transport},
log: logger,
}, nil
}
// RefreshLibraries запускает скан всех библиотек Jellyfin
// (POST /Library/Refresh). Скан инкрементальный — полный дёшев, поэтому
// точечный скан конкретной папки не делаем (сложнее, не в духе сервиса).
// Ответ при успехе — 204 No Content.
func (c *Client) RefreshLibraries(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/Library/Refresh", nil)
if err != nil {
return fmt.Errorf("jellyfin: build request: %w", err)
}
req.Header.Set("X-Emby-Token", c.apiKey)
start := time.Now()
resp, err := c.hc.Do(req)
if err != nil {
return fmt.Errorf("jellyfin: refresh: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("jellyfin: refresh: status %d body %q",
resp.StatusCode, strings.TrimSpace(string(body)))
}
c.log.Info("jellyfin: library refresh triggered", "duration", time.Since(start))
return nil
}
+71
View File
@@ -0,0 +1,71 @@
package jellyfin
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestRefreshLibraries_OK(t *testing.T) {
var gotPath, gotToken, gotMethod string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotToken = r.Header.Get("X-Emby-Token")
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c, err := New(Config{URL: srv.URL, APIKey: "secret"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := c.RefreshLibraries(context.Background()); err != nil {
t.Fatalf("RefreshLibraries: %v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("method = %q, want POST", gotMethod)
}
if gotPath != "/Library/Refresh" {
t.Errorf("path = %q, want /Library/Refresh", gotPath)
}
if gotToken != "secret" {
t.Errorf("token = %q, want secret", gotToken)
}
}
func TestRefreshLibraries_TrimsTrailingSlash(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c, err := New(Config{URL: srv.URL + "/", APIKey: "k"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := c.RefreshLibraries(context.Background()); err != nil {
t.Fatalf("RefreshLibraries: %v", err)
}
if gotPath != "/Library/Refresh" {
t.Errorf("path = %q, want /Library/Refresh (без двойного слеша)", gotPath)
}
}
func TestRefreshLibraries_ErrorStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
c, err := New(Config{URL: srv.URL, APIKey: "bad"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := c.RefreshLibraries(context.Background()); err == nil {
t.Fatal("ожидали ошибку на 401, получили nil")
}
}
+55 -7
View File
@@ -21,10 +21,11 @@ import (
const ( const (
ovrMediaType = "media_type" ovrMediaType = "media_type"
ovrIgnoredFiles = "ignored_files" ovrIgnoredFiles = "ignored_files"
ovrProvider = "provider" // выбранная база ("none" = без базы) ovrProvider = "provider" // выбранная база ("none" = без базы)
ovrProviderID = "provider_id" // id в выбранной базе ovrProviderID = "provider_id" // id в выбранной базе
ovrTitle = "title" // запиненное каноническое название ovrTitle = "title" // запиненное каноническое название
ovrYear = "year" // запиненный год ovrYear = "year" // запиненный год
ovrForceReview = "force_review" // ручная перепривязка: не авто-раскладывать
) )
// recognizePending распознаёт завершённые загрузки и перезапускает те, что // recognizePending распознаёт завершённые загрузки и перезапускает те, что
@@ -178,9 +179,13 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
} }
// Авто-раскладка при подтверждённом матче и чистой валидации (Ф4); // Авто-раскладка при подтверждённом матче и чистой валидации (Ф4);
// иначе — review. Раскладчик может быть не сконфигурирован. // иначе — review. Раскладчик может быть не сконфигурирован. При ручной
if res.Decision.Auto && w.layouter != nil { // перепривязке (force_review) авто-раскладку не делаем — нужно явное
plan := applyOverrides(res.Plan, w.overridesOrNil(ctx, id)) // подтверждение человеком.
overrides := w.overridesOrNil(ctx, id)
forceReview := overrides[ovrForceReview] == "1"
if res.Decision.Auto && !forceReview && w.layouter != nil {
plan := applyOverrides(res.Plan, overrides)
w.transition(ctx, *d, store.StateLinking, "", "") w.transition(ctx, *d, store.StateLinking, "", "")
if err := w.linkPlan(ctx, d, plan, tag, savePath); err != nil { if err := w.linkPlan(ctx, d, plan, tag, savePath); err != nil {
w.log.Warn("recognize: auto-apply failed, left for review", w.log.Warn("recognize: auto-apply failed, left for review",
@@ -281,6 +286,49 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
return nil return nil
} }
// Relink повторно привязывает откатанную задачу (reverted): возвращает её на
// распознавание, и поллинг-цикл перезапустит 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 {
return fmt.Errorf("relink: download %d is in state %s (expected reverted)", 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 reverted download", "download_id", id)
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)
+108
View File
@@ -73,6 +73,104 @@ 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_RejectsNonReverted(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1)) // не reverted
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 задачи, получили 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 +212,16 @@ func (m *memStore) ExistsByInfohash(_ context.Context, infohash string) (bool, e
return false, nil return false, nil
} }
func (m *memStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) {
for _, d := range m.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() {
cp := *d
return &cp, nil
}
}
return nil, nil
}
func (m *memStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) { func (m *memStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
id := int64(len(m.downloads) + 1) id := int64(len(m.downloads) + 1)
cp := *d cp := *d
+24
View File
@@ -33,6 +33,7 @@ type Store interface {
// Discovery (усыновление раздач по категории/тегу). // Discovery (усыновление раздач по категории/тегу).
ExistsByInfohash(ctx context.Context, infohash string) (bool, error) ExistsByInfohash(ctx context.Context, infohash string) (bool, error)
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
CreateDownload(ctx context.Context, d *store.Download) (int64, error) CreateDownload(ctx context.Context, d *store.Download) (int64, error)
// Ф3: распознавание, ревью, раскладка. // Ф3: распознавание, ревью, раскладка.
@@ -86,6 +87,13 @@ type Notifier interface {
Notify(ctx context.Context, downloadID int64, event NotifyEvent) Notify(ctx context.Context, downloadID int64, event NotifyEvent)
} }
// Scanner — триггер пересканирования медиатеки Jellyfin. Вызывается
// неблокирующе после успешной раскладки, чтобы новые файлы быстрее появились
// в проигрывателе.
type Scanner interface {
RefreshLibraries(ctx context.Context) error
}
// Config — параметры воркера. // Config — параметры воркера.
type Config struct { type Config struct {
Category string Category string
@@ -110,11 +118,15 @@ type Worker struct {
now func() time.Time // подменяется в тестах now func() time.Time // подменяется в тестах
newID func() string // генератор apply_batch_id (подменяется в тестах) newID func() string // генератор apply_batch_id (подменяется в тестах)
notifier Notifier // опц. исходящие пинги notifier Notifier // опц. исходящие пинги
scanner Scanner // опц. пересканирование Jellyfin
} }
// SetNotifier подключает исходящие пинги (до запуска Run). // SetNotifier подключает исходящие пинги (до запуска Run).
func (w *Worker) SetNotifier(n Notifier) { w.notifier = n } func (w *Worker) SetNotifier(n Notifier) { w.notifier = n }
// SetScanner подключает пересканирование Jellyfin (до запуска Run).
func (w *Worker) SetScanner(s Scanner) { w.scanner = s }
// New собирает воркер. recognizer/layouter могут быть nil (Ф1 без Ф3-ступеней // New собирает воркер. recognizer/layouter могут быть nil (Ф1 без Ф3-ступеней
// распознавания и раскладки) — тогда completed-задачи не двигаются дальше. // распознавания и раскладки) — тогда completed-задачи не двигаются дальше.
func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker { func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker {
@@ -260,6 +272,18 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
go w.notifier.Notify(context.Background(), d.ID, EventDone) go w.notifier.Notify(context.Background(), d.ID, EventDone)
} }
} }
// Раскладка завершена — просим Jellyfin пересканировать библиотеку, чтобы
// новые файлы быстрее появились в проигрывателе. Тоже неблокирующе и вне
// w.mu; недоступность Jellyfin не влияет на состояние задачи.
if w.scanner != nil && state == store.StateDone {
id := d.ID
go func() {
if err := w.scanner.RefreshLibraries(context.Background()); err != nil {
w.log.Warn("jellyfin: library refresh failed", "download_id", id, "err", err)
}
}()
}
} }
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает // Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
+10
View File
@@ -60,6 +60,16 @@ func (f *fakeStore) ExistsByInfohash(_ context.Context, infohash string) (bool,
return false, nil return false, nil
} }
func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) {
for _, d := range f.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() {
cp := *d
return &cp, nil
}
}
return nil, nil
}
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) { func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
id := int64(len(f.downloads) + 1) id := int64(len(f.downloads) + 1)
cp := *d cp := *d
+5
View File
@@ -69,6 +69,11 @@
<button type="submit">Откатить</button> <button type="submit">Откатить</button>
</form> </form>
{{end}} {{end}}
{{if .Relinkable}}
<form method="post" action="/ui/downloads/{{.ID}}/relink">
<button type="submit">Привязать заново</button>
</form>
{{end}}
{{if not .Terminal}} {{if not .Terminal}}
<form method="post" action="/ui/downloads/{{.ID}}/cancel"> <form method="post" action="/ui/downloads/{{.ID}}/cancel">
<button type="submit">Отклонить</button> <button type="submit">Отклонить</button>