Добавил обновление библиотеки jellyfin после добавления медиа

This commit is contained in:
2026-06-15 07:33:21 +03:00
parent fff0960915
commit 093211c9c7
9 changed files with 283 additions and 4 deletions
+12
View File
@@ -17,6 +17,7 @@ type Config struct {
Storage Storage `toml:"storage"`
LLM LLM `toml:"llm"`
Metadata Metadata `toml:"metadata"`
Jellyfin Jellyfin `toml:"jellyfin"`
Worker Worker `toml:"worker"`
Recognition Recognition `toml:"recognition"`
Telegram Telegram `toml:"telegram"`
@@ -78,6 +79,16 @@ type MetadataProvider struct {
Timeout Duration `toml:"timeout"`
}
// Jellyfin — пересканирование медиатеки после раскладки (опц.). Включается
// конфигом; без него скан не дёргается.
type Jellyfin struct {
Enabled bool `toml:"enabled"`
URL string `toml:"url"`
APIKey string `toml:"api_key"`
Proxy string `toml:"proxy"` // опц. HTTP-прокси
Timeout Duration `toml:"timeout"`
}
// Worker — параметры фонового цикла.
type Worker struct {
PollInterval Duration `toml:"poll_interval"`
@@ -155,6 +166,7 @@ func Default() *Config {
TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
},
Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)},
Worker: Worker{
PollInterval: Duration(5 * time.Second),
StuckAfter: Duration(time.Hour),
+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")
}
}
+24
View File
@@ -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
+23
View File
@@ -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 не трогаем — он продолжает