Добавил обновление библиотеки jellyfin после добавления медиа
This commit is contained in:
@@ -26,6 +26,8 @@ Arr-стек (prowlarr/radarr/sonarr) плохо ложится на русск
|
|||||||
фильм/сериал и нужная раскладка.
|
фильм/сериал и нужная раскладка.
|
||||||
5. Файлы **хардлинкаются** в библиотеку Jellyfin — источник остаётся в
|
5. Файлы **хардлинкаются** в библиотеку Jellyfin — источник остаётся в
|
||||||
раздаче, место на диске не дублируется.
|
раздаче, место на диске не дублируется.
|
||||||
|
6. После раскладки сервис (опц.) просит Jellyfin пересканировать
|
||||||
|
медиатеку, чтобы новые файлы быстрее появились в проигрывателе.
|
||||||
|
|
||||||
При высокой уверенности раскладка выполняется автоматически, иначе —
|
При высокой уверенности раскладка выполняется автоматически, иначе —
|
||||||
уходит на подтверждение человеку.
|
уходит на подтверждение человеку.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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-конфига |
|
||||||
|
|
||||||
## Поток и машина состояний
|
## Поток и машина состояний
|
||||||
@@ -68,8 +69,9 @@ 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** — «Позже», «Отклонить»,
|
||||||
ошибка (ретраибельна), не качается дольше таймаута.
|
ошибка (ретраибельна), не качается дольше таймаута.
|
||||||
|
|
||||||
@@ -177,6 +179,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 +279,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 +357,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 +368,4 @@ Dockerfile .dockerignore config.example.toml
|
|||||||
|
|
||||||
## Открытые вопросы
|
## Открытые вопросы
|
||||||
|
|
||||||
- Конфигурация Jellyfin (URL + API-ключ) и триггер скана — когда Jellyfin
|
- (пока нет)
|
||||||
будет развёрнут в umbar (сейчас его там нет).
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
// memStore — полноценный in-memory store для тестов Ф3.
|
||||||
type memStore struct {
|
type memStore struct {
|
||||||
downloads map[int64]*store.Download
|
downloads map[int64]*store.Download
|
||||||
|
|||||||
@@ -86,6 +86,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 +117,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 +271,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 не трогаем — он продолжает
|
||||||
|
|||||||
Reference in New Issue
Block a user