From 093211c9c74611d98becd765779d197105747e74 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Mon, 15 Jun 2026 07:33:21 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B1=D0=B8=D0=B1=D0=BB=D0=B8=D0=BE=D1=82=D0=B5=D0=BA=D0=B8=20?= =?UTF-8?q?jellyfin=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BC=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + cmd/jellybit/serve.go | 20 +++++++ config.example.toml | 7 +++ docs/specs/architecture.md | 35 +++++++++-- internal/config/config.go | 12 ++++ internal/jellyfin/jellyfin.go | 93 ++++++++++++++++++++++++++++++ internal/jellyfin/jellyfin_test.go | 71 +++++++++++++++++++++++ internal/worker/review_test.go | 24 ++++++++ internal/worker/worker.go | 23 ++++++++ 9 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 internal/jellyfin/jellyfin.go create mode 100644 internal/jellyfin/jellyfin_test.go diff --git a/README.md b/README.md index f56016d..300ca47 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Arr-стек (prowlarr/radarr/sonarr) плохо ложится на русск фильм/сериал и нужная раскладка. 5. Файлы **хардлинкаются** в библиотеку Jellyfin — источник остаётся в раздаче, место на диске не дублируется. +6. После раскладки сервис (опц.) просит Jellyfin пересканировать + медиатеку, чтобы новые файлы быстрее появились в проигрывателе. При высокой уверенности раскладка выполняется автоматически, иначе — уходит на подтверждение человеку. diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go index 45e1274..856afc4 100644 --- a/cmd/jellybit/serve.go +++ b/cmd/jellybit/serve.go @@ -17,6 +17,7 @@ import ( "git.vakhrushev.me/av/jellybit/internal/config" "git.vakhrushev.me/av/jellybit/internal/httpapi" "git.vakhrushev.me/av/jellybit/internal/ingest" + "git.vakhrushev.me/av/jellybit/internal/jellyfin" "git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/llm" "git.vakhrushev.me/av/jellybit/internal/logging" @@ -117,6 +118,25 @@ func runServe(args []string) error { MagnetTimeout: cfg.Worker.MagnetTimeout.Std(), }, logger) + // Пересканирование Jellyfin после раскладки (опц.). Недоступность Jellyfin + // не валит сервис — скан просто не сработает (залогируется в воркере). + if cfg.Jellyfin.Enabled { + if cfg.Jellyfin.URL == "" || cfg.Jellyfin.APIKey == "" { + return fmt.Errorf("jellyfin enabled, but url or api_key is empty") + } + jf, jerr := jellyfin.New(jellyfin.Config{ + URL: cfg.Jellyfin.URL, + APIKey: cfg.Jellyfin.APIKey, + Proxy: cfg.Jellyfin.Proxy, + Timeout: cfg.Jellyfin.Timeout.Std(), + }, logger) + if jerr != nil { + return fmt.Errorf("jellyfin client: %w", jerr) + } + wrk.SetScanner(jf) + logger.Info("jellyfin rescan enabled", "url", cfg.Jellyfin.URL) + } + router, err := httpapi.NewRouter(httpapi.Deps{ Logger: logger, Ingestor: ingestor, diff --git a/config.example.toml b/config.example.toml index 56eef28..5c14c07 100644 --- a/config.example.toml +++ b/config.example.toml @@ -45,6 +45,13 @@ enabled = false # без ключа; только сери proxy = "" timeout = "10s" +[jellyfin] +enabled = false # включить пересканирование медиатеки после раскладки +url = "http://jellyfin:8096" # по имени сервиса в общей docker-сети +api_key = "" # API-ключ Jellyfin (Dashboard → API Keys) +proxy = "" # опц. HTTP-прокси +timeout = "10s" + [worker] poll_interval = "5s" stuck_after = "1h" diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 38e758a..d16be85 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -41,6 +41,7 @@ qBittorrent, определяет содержимое (фильм или сер | `store` | SQLite: загрузки, распознавание, подсказки, ссылки | | `httpapi` | REST + веб-UI (server-rendered, htmx) | | `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги | +| `jellyfin` | триггер пересканирования медиатеки после раскладки (опц.) | | `config` | загрузка TOML-конфига | ## Поток и машина состояний @@ -68,8 +69,9 @@ review → «Позже» → deferred → review - **review** — план уходит человеку ([review-ux.md](review-ux.md)); цикл `review ⇄ recognizing` — перераспознавание по подсказке. - **linking** — `layout` создаёт хардлинки; идемпотентно, батчем. -- **done** — опционально дёргаем скан Jellyfin; доступен **undo** → - `reverted` (убрать созданные ссылки). +- **done** — при входе неблокирующе дёргаем пересканирование Jellyfin (опц., + см. «Пересканирование Jellyfin»); доступен **undo** → `reverted` (убрать + созданные ссылки). - **deferred / cancelled / failed / stuck** — «Позже», «Отклонить», ошибка (ретраибельна), не качается дольше таймаута. @@ -177,6 +179,13 @@ api_key = "" proxy = "" timeout = "10s" +[jellyfin] +enabled = false # включить пересканирование медиатеки после раскладки +url = "http://jellyfin:8096" # по имени сервиса в общей docker-сети +api_key = "" # API-ключ Jellyfin (Dashboard → API Keys) +proxy = "" # опц. HTTP-прокси +timeout = "10s" + [worker] poll_interval = "5s" # как часто опрашивать qBittorrent stuck_after = "1h" # не качается дольше → stuck @@ -270,6 +279,22 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила: - **Jellyfin** — библиотеки указывают на `movies`/`series` (не на корень `/srv/media`, иначе в индекс попадут downloads/incomplete). +## Пересканирование Jellyfin + +После успешной раскладки (вход в `done`) `worker` неблокирующе просит Jellyfin +пересканировать медиатеку, чтобы новые файлы быстрее появились в проигрывателе. +Включается конфигом `[jellyfin]` (по умолчанию выключено); без него скан не +дёргается. + +- **Один вызов — `POST /Library/Refresh`** (скан всех библиотек). Скан + инкрементальный, поэтому полный дёшев; точечный скан конкретной папки не + делаем — сложнее и не в духе сервиса («минимум компонентов»). +- **Авторизация** — API-ключ Jellyfin в заголовке `X-Emby-Token`. +- **Неблокирующе и вне `w.mu`** (как пинги Telegram): вызов уходит в сеть в + отдельной горутине с фоновым контекстом. Недоступность Jellyfin не влияет на + состояние задачи — ошибка лишь логируется (`Warn`). +- **Адресация** — по имени сервиса в общей docker-сети (`http://jellyfin:8096`). + ## Деплой Jellybit работает в **docker** — в одной среде с qBittorrent и Jellyfin @@ -332,6 +357,9 @@ Dockerfile .dockerignore config.example.toml задач (повторная закачка спустя время → новая задача). - Состояние — на persistent-томе `/srv/applications/jellybit/data`. - Детект завершения — поллинг; webhook — на будущее (drafts/ideas). +- Пересканирование Jellyfin после раскладки — `POST /Library/Refresh` (скан + всех библиотек, инкрементальный), неблокирующе на входе в `done`; опц., + включается `[jellyfin]`. - Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF. - Авто-раскладка требует подтверждённого матча в базе; иначе review. - Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей). @@ -340,5 +368,4 @@ Dockerfile .dockerignore config.example.toml ## Открытые вопросы -- Конфигурация Jellyfin (URL + API-ключ) и триггер скана — когда Jellyfin - будет развёрнут в umbar (сейчас его там нет). +- (пока нет) diff --git a/internal/config/config.go b/internal/config/config.go index d915580..1ffb945 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { Storage Storage `toml:"storage"` LLM LLM `toml:"llm"` Metadata Metadata `toml:"metadata"` + Jellyfin Jellyfin `toml:"jellyfin"` Worker Worker `toml:"worker"` Recognition Recognition `toml:"recognition"` Telegram Telegram `toml:"telegram"` @@ -78,6 +79,16 @@ type MetadataProvider struct { Timeout Duration `toml:"timeout"` } +// Jellyfin — пересканирование медиатеки после раскладки (опц.). Включается +// конфигом; без него скан не дёргается. +type Jellyfin struct { + Enabled bool `toml:"enabled"` + URL string `toml:"url"` + APIKey string `toml:"api_key"` + Proxy string `toml:"proxy"` // опц. HTTP-прокси + Timeout Duration `toml:"timeout"` +} + // Worker — параметры фонового цикла. type Worker struct { PollInterval Duration `toml:"poll_interval"` @@ -155,6 +166,7 @@ func Default() *Config { TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)}, TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)}, }, + Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)}, Worker: Worker{ PollInterval: Duration(5 * time.Second), StuckAfter: Duration(time.Hour), diff --git a/internal/jellyfin/jellyfin.go b/internal/jellyfin/jellyfin.go new file mode 100644 index 0000000..54e9c0d --- /dev/null +++ b/internal/jellyfin/jellyfin.go @@ -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 +} diff --git a/internal/jellyfin/jellyfin_test.go b/internal/jellyfin/jellyfin_test.go new file mode 100644 index 0000000..83a5e61 --- /dev/null +++ b/internal/jellyfin/jellyfin_test.go @@ -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") + } +} diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index e461cf8..52de072 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -73,6 +73,30 @@ 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 не запустилось") + } +} + // memStore — полноценный in-memory store для тестов Ф3. type memStore struct { downloads map[int64]*store.Download diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 60acaf1..1f46c52 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -86,6 +86,13 @@ type Notifier interface { Notify(ctx context.Context, downloadID int64, event NotifyEvent) } +// Scanner — триггер пересканирования медиатеки Jellyfin. Вызывается +// неблокирующе после успешной раскладки, чтобы новые файлы быстрее появились +// в проигрывателе. +type Scanner interface { + RefreshLibraries(ctx context.Context) error +} + // Config — параметры воркера. type Config struct { Category string @@ -110,11 +117,15 @@ type Worker struct { now func() time.Time // подменяется в тестах newID func() string // генератор apply_batch_id (подменяется в тестах) notifier Notifier // опц. исходящие пинги + scanner Scanner // опц. пересканирование Jellyfin } // SetNotifier подключает исходящие пинги (до запуска Run). func (w *Worker) SetNotifier(n Notifier) { w.notifier = n } +// SetScanner подключает пересканирование Jellyfin (до запуска Run). +func (w *Worker) SetScanner(s Scanner) { w.scanner = s } + // New собирает воркер. recognizer/layouter могут быть nil (Ф1 без Ф3-ступеней // распознавания и раскладки) — тогда completed-задачи не двигаются дальше. func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker { @@ -260,6 +271,18 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S go w.notifier.Notify(context.Background(), d.ID, EventDone) } } + + // Раскладка завершена — просим Jellyfin пересканировать библиотеку, чтобы + // новые файлы быстрее появились в проигрывателе. Тоже неблокирующе и вне + // w.mu; недоступность Jellyfin не влияет на состояние задачи. + if w.scanner != nil && state == store.StateDone { + id := d.ID + go func() { + if err := w.scanner.RefreshLibraries(context.Background()); err != nil { + w.log.Warn("jellyfin: library refresh failed", "download_id", id, "err", err) + } + }() + } } // Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает