From 7419bcb12569dc9260c542b17d206808836ed2b9 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 14 Jun 2026 15:29:04 +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=B5=D1=89=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B0=D0=B9=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=20TVMaze?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/jellybit/serve.go | 11 +++ config.example.toml | 5 ++ docs/specs/architecture.md | 2 +- internal/config/config.go | 8 +- internal/metadata/integration_test.go | 39 ++++++++++ internal/metadata/metadata.go | 9 ++- internal/metadata/tvmaze.go | 108 ++++++++++++++++++++++++++ internal/metadata/tvmaze_test.go | 86 ++++++++++++++++++++ internal/recognize/metadata.go | 23 +++++- internal/recognize/metadata_test.go | 24 ++++++ internal/worker/review.go | 2 + internal/worker/review_test.go | 1 + 12 files changed, 309 insertions(+), 9 deletions(-) create mode 100644 internal/metadata/tvmaze.go create mode 100644 internal/metadata/tvmaze_test.go diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go index 3d52cad..3785689 100644 --- a/cmd/jellybit/serve.go +++ b/cmd/jellybit/serve.go @@ -160,6 +160,17 @@ func runServe(args []string) error { // сериалов Jellyfin привычнее tvdbid, поэтому TVDB идёт первым. func metadataProviders(cfg *config.Config) ([]metadata.Provider, error) { var out []metadata.Provider + // TVMaze без ключа и покрывает сериалы — ставим первым. + if cfg.Metadata.TVMaze.Enabled { + p, err := metadata.NewTVMaze(metadata.TVMazeConfig{ + Proxy: cfg.Metadata.TVMaze.Proxy, + Timeout: cfg.Metadata.TVMaze.Timeout.Std(), + }) + if err != nil { + return nil, fmt.Errorf("tvmaze provider: %w", err) + } + out = append(out, p) + } if cfg.Metadata.TVDB.Enabled { p, err := metadata.NewTVDB(metadata.TVDBConfig{ APIKey: cfg.Metadata.TVDB.APIKey, diff --git a/config.example.toml b/config.example.toml index 8223fbf..4ed981c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -40,6 +40,11 @@ api_key = "" proxy = "" timeout = "10s" +[metadata.tvmaze] +enabled = false # без ключа; только сериалы, тег [tvdbid-…] из externals +proxy = "" +timeout = "10s" + [worker] poll_interval = "5s" stuck_after = "1h" diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 225c0f1..48cb573 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -36,7 +36,7 @@ qBittorrent, определяет содержимое (фильм или сер | `worker` | владелец машины состояний; поллинг, сериализация команд | | `recognize` | пред-парс имени + вызов LLM + модель уверенности | | `llm` | провайдер LLM за интерфейсом (дискриминатор `type`) | -| `metadata` | интерфейс баз метаданных + TMDB/TVDB (опц.) | +| `metadata` | интерфейс баз метаданных + TMDB/TVDB/TVMaze (опц.) | | `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo | | `store` | SQLite: загрузки, распознавание, подсказки, ссылки | | `httpapi` | REST + веб-UI (server-rendered, htmx) | diff --git a/internal/config/config.go b/internal/config/config.go index 30b2c4b..31bc0b0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -59,11 +59,13 @@ type LLM struct { // Metadata — внешние базы метаданных (опциональны). type Metadata struct { - TMDB MetadataProvider `toml:"tmdb"` - TVDB MetadataProvider `toml:"tvdb"` + TMDB MetadataProvider `toml:"tmdb"` + TVDB MetadataProvider `toml:"tvdb"` + TVMaze MetadataProvider `toml:"tvmaze"` // без ключа, только сериалы } -// MetadataProvider — настройки одного провайдера метаданных. +// MetadataProvider — настройки одного провайдера метаданных. У keyless-баз +// (TVMaze) поле api_key не используется. type MetadataProvider struct { Enabled bool `toml:"enabled"` APIKey string `toml:"api_key"` diff --git a/internal/metadata/integration_test.go b/internal/metadata/integration_test.go index 8ae0052..f0763a2 100644 --- a/internal/metadata/integration_test.go +++ b/internal/metadata/integration_test.go @@ -9,6 +9,45 @@ import ( "git.vakhrushev.me/av/jellybit/internal/metadata" ) +// TestIntegration_TVMaze бьётся в реальный TVMaze (без ключа). Сетевой, по +// умолчанию пропускается; включается флагом: +// +// JELLYBIT_LIVE=1 go test ./internal/metadata/ -run Integration -v +func TestIntegration_TVMaze(t *testing.T) { + if os.Getenv("JELLYBIT_LIVE") == "" { + t.Skip("set JELLYBIT_LIVE=1 to run network tests") + } + c, err := metadata.NewTVMaze(metadata.TVMazeConfig{Timeout: 20 * time.Second}) + if err != nil { + t.Fatalf("NewTVMaze: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cands, err := c.Search(ctx, metadata.Query{Type: metadata.Series, Title: "Fargo", Year: 2014}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(cands) == 0 { + t.Fatal("ожидался хотя бы один кандидат") + } + top := cands[0] + t.Logf("top: id=%s title=%q year=%d tag=%s/%s", + top.ID, top.Title, top.Year, top.TagProvider, top.TagID) + if top.TagProvider != "tvdb" || top.TagID == "" { + t.Errorf("ожидался TVDB-тег из externals, got %s/%s", top.TagProvider, top.TagID) + } + + counts, err := c.SeasonEpisodeCounts(ctx, top.ID) + if err != nil { + t.Fatalf("SeasonEpisodeCounts: %v", err) + } + t.Logf("season counts: %v", counts) + if counts[1] == 0 { + t.Error("ожидались серии в первом сезоне") + } +} + // TestIntegration_TVDB бьётся в реальный TheTVDB v4. По умолчанию // пропускается; включается ключом: // diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go index 61913c4..5a47be4 100644 --- a/internal/metadata/metadata.go +++ b/internal/metadata/metadata.go @@ -25,12 +25,19 @@ type Query struct { } // Candidate — результат поиска: официальный id и каноническое имя. +// +// ID — нативный id провайдера (по нему запрашиваются SeasonEpisodeCounts). +// TagProvider/TagID — опц. внешний id для имени папки Jellyfin: напр. TVMaze +// ищет без ключа, но отдаёт TVDB/IMDb-id во внешних ссылках, и тег ставим +// привычный ([tvdbid-…]). Пусто → тег берётся из Provider/ID. type Candidate struct { - Provider string // "tmdb" | "tvdb" + Provider string // "tmdb" | "tvdb" | "tvmaze" ID string Title string OriginalTitle string Year int + TagProvider string // напр. "tvdb"/"imdb" (опц.) + TagID string } // Provider — одна база метаданных. diff --git a/internal/metadata/tvmaze.go b/internal/metadata/tvmaze.go new file mode 100644 index 0000000..c483c0d --- /dev/null +++ b/internal/metadata/tvmaze.go @@ -0,0 +1,108 @@ +package metadata + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const tvmazeDefaultBaseURL = "https://api.tvmaze.com" + +// TVMazeConfig — настройки клиента TVMaze. +type TVMazeConfig struct { + Proxy string + Timeout time.Duration + BaseURL string // пусто → api.tvmaze.com; задаётся в тестах +} + +// TVMaze — клиент TVMaze. Открытое API без ключа (лимит ~20 запросов/10с). +// Покрывает только сериалы; для фильмов поиск возвращает пусто. В externals +// отдаёт TVDB/IMDb-id, который используем как тег папки Jellyfin. +type TVMaze struct { + baseURL string + hc *http.Client +} + +// NewTVMaze собирает клиент TVMaze (ключ не нужен). +func NewTVMaze(cfg TVMazeConfig) (*TVMaze, error) { + hc, err := newHTTPClient(cfg.Proxy, cfg.Timeout) + if err != nil { + return nil, err + } + base := cfg.BaseURL + if base == "" { + base = tvmazeDefaultBaseURL + } + return &TVMaze{baseURL: strings.TrimRight(base, "/"), hc: hc}, nil +} + +func (t *TVMaze) Name() string { return "tvmaze" } + +type tvmazeShow struct { + ID int `json:"id"` + Name string `json:"name"` + Premiered string `json:"premiered"` + Externals struct { + TheTVDB int `json:"thetvdb"` + IMDb string `json:"imdb"` + } `json:"externals"` +} + +// Search ищет сериал по названию. Фильмы TVMaze не покрывает — для них +// возвращает пусто. Год не сужаем в запросе (TVMaze не фильтрует по году), +// отбор по году делает вызывающий. +func (t *TVMaze) Search(ctx context.Context, q Query) ([]Candidate, error) { + if q.Type != Series { + return nil, nil + } + var resp []struct { + Show tvmazeShow `json:"show"` + } + rawURL := t.baseURL + "/search/shows?q=" + url.QueryEscape(q.Title) + if err := getJSON(ctx, t.hc, rawURL, nil, &resp); err != nil { + return nil, fmt.Errorf("tvmaze search: %w", err) + } + + out := make([]Candidate, 0, len(resp)) + for _, r := range resp { + s := r.Show + c := Candidate{ + Provider: "tvmaze", + ID: strconv.Itoa(s.ID), + Title: s.Name, + Year: yearOf(s.Premiered), + } + // Тег папки — привычный TVDB-id, если есть; иначе IMDb. + switch { + case s.Externals.TheTVDB != 0: + c.TagProvider, c.TagID = "tvdb", strconv.Itoa(s.Externals.TheTVDB) + case s.Externals.IMDb != "": + c.TagProvider, c.TagID = "imdb", s.Externals.IMDb + } + out = append(out, c) + } + return out, nil +} + +type tvmazeEpisode struct { + Season int `json:"season"` + Number int `json:"number"` +} + +// SeasonEpisodeCounts считает число серий по сезонам (нативный id TVMaze). +func (t *TVMaze) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) { + var eps []tvmazeEpisode + rawURL := t.baseURL + "/shows/" + url.PathEscape(id) + "/episodes" + if err := getJSON(ctx, t.hc, rawURL, nil, &eps); err != nil { + return nil, fmt.Errorf("tvmaze episodes %s: %w", id, err) + } + out := map[int]int{} + for _, e := range eps { + out[e.Season]++ + } + return out, nil +} diff --git a/internal/metadata/tvmaze_test.go b/internal/metadata/tvmaze_test.go new file mode 100644 index 0000000..645ce63 --- /dev/null +++ b/internal/metadata/tvmaze_test.go @@ -0,0 +1,86 @@ +package metadata + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func newTVMaze(t *testing.T, url string) *TVMaze { + t.Helper() + c, err := NewTVMaze(TVMazeConfig{BaseURL: url}) + if err != nil { + t.Fatalf("NewTVMaze: %v", err) + } + return c +} + +func TestTVMaze_SearchSeries(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/search/shows" || r.URL.Query().Get("q") != "Fargo" { + t.Errorf("request = %s?%s", r.URL.Path, r.URL.RawQuery) + } + _, _ = w.Write([]byte(`[ + {"score":0.9,"show":{"id":1,"name":"Fargo","premiered":"2014-04-15", + "externals":{"thetvdb":269613,"imdb":"tt2802850"}}}, + {"score":0.1,"show":{"id":2,"name":"Other","premiered":"2010-01-01", + "externals":{"thetvdb":0,"imdb":"tt999"}}} + ]`)) + })) + defer srv.Close() + + got, err := newTVMaze(t, srv.URL).Search(context.Background(), Query{Type: Series, Title: "Fargo", Year: 2014}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d candidates", len(got)) + } + c := got[0] + if c.Provider != "tvmaze" || c.ID != "1" || c.Title != "Fargo" || c.Year != 2014 { + t.Errorf("candidate = %+v", c) + } + // TVDB-id из externals → тег папки. + if c.TagProvider != "tvdb" || c.TagID != "269613" { + t.Errorf("tag = %s/%s, want tvdb/269613", c.TagProvider, c.TagID) + } + // Без thetvdb → фолбэк на imdb. + if got[1].TagProvider != "imdb" || got[1].TagID != "tt999" { + t.Errorf("fallback tag = %s/%s", got[1].TagProvider, got[1].TagID) + } +} + +func TestTVMaze_SearchMovieEmpty(t *testing.T) { + // Для фильмов TVMaze не вызывается вовсе. + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Error("movie search must not hit the network") + })) + defer srv.Close() + + got, err := newTVMaze(t, srv.URL).Search(context.Background(), Query{Type: Movie, Title: "X"}) + if err != nil || got != nil { + t.Errorf("movie search = %v, %v; want nil, nil", got, err) + } +} + +func TestTVMaze_SeasonEpisodeCounts(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/shows/1/episodes" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = w.Write([]byte(`[ + {"season":1,"number":1},{"season":1,"number":2}, + {"season":2,"number":1} + ]`)) + })) + defer srv.Close() + + counts, err := newTVMaze(t, srv.URL).SeasonEpisodeCounts(context.Background(), "1") + if err != nil { + t.Fatalf("SeasonEpisodeCounts: %v", err) + } + if counts[1] != 2 || counts[2] != 1 { + t.Errorf("counts = %v", counts) + } +} diff --git a/internal/recognize/metadata.go b/internal/recognize/metadata.go index 486b4e7..cd51c73 100644 --- a/internal/recognize/metadata.go +++ b/internal/recognize/metadata.go @@ -39,16 +39,31 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match { continue } c := strong[0] - match := &Match{Provider: c.Provider, ProviderID: c.ID, Title: c.Title, Year: c.Year} + + // Число серий тянем по нативному id провайдера. + var counts map[int]int if mt == metadata.Series { - if counts, cerr := p.SeasonEpisodeCounts(ctx, c.ID); cerr == nil { - match.SeasonEpisodeCounts = counts + if got, cerr := p.SeasonEpisodeCounts(ctx, c.ID); cerr == nil { + counts = got } else { r.log.Warn("recognize: episode counts failed", "provider", p.Name(), "id", c.ID, "err", cerr) } } - return match + + // Провенанс и тег папки — по внешнему id, если провайдер его дал + // (TVMaze отдаёт TVDB/IMDb-id); иначе по самому провайдеру. + prov, pid := c.Provider, c.ID + if c.TagProvider != "" { + prov, pid = c.TagProvider, c.TagID + } + return &Match{ + Provider: prov, + ProviderID: pid, + Title: c.Title, + Year: c.Year, + SeasonEpisodeCounts: counts, + } } return nil } diff --git a/internal/recognize/metadata_test.go b/internal/recognize/metadata_test.go index 35145ad..7675b49 100644 --- a/internal/recognize/metadata_test.go +++ b/internal/recognize/metadata_test.go @@ -88,6 +88,30 @@ func TestMatchMetadata_OriginalTitle(t *testing.T) { } } +func TestMatchMetadata_TagFromExternal(t *testing.T) { + // TVMaze-стиль: нативный id для счёта серий, внешний TVDB-id для тега. + p := &fakeProvider{ + name: "tvmaze", + candidates: []metadata.Candidate{ + {Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014, TagProvider: "tvdb", TagID: "269613"}, + }, + counts: map[int]int{1: 10}, + } + r := recognizerWith(p) + m := r.matchMetadata(context.Background(), + Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}) + if m == nil { + t.Fatal("expected match") + } + // Провенанс/тег — внешний TVDB-id, а не нативный tvmaze. + if m.Provider != "tvdb" || m.ProviderID != "269613" { + t.Errorf("match provider = %s/%s, want tvdb/269613", m.Provider, m.ProviderID) + } + if m.SeasonEpisodeCounts[1] != 10 { + t.Errorf("counts not fetched by native id: %+v", m.SeasonEpisodeCounts) + } +} + func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) { p := &fakeProvider{ candidates: []metadata.Candidate{{ID: "60622", Title: "Fargo", Year: 2014}}, diff --git a/internal/worker/review.go b/internal/worker/review.go index ba1ec7b..a428058 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -515,6 +515,8 @@ func providerTag(provider, id string) string { return "tmdbid-" + id case "tvdb": return "tvdbid-" + id + case "imdb": + return "imdbid-" + id default: return "" } diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index 59f9974..ff9fe28 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -591,6 +591,7 @@ func TestProviderTag(t *testing.T) { cases := []struct{ provider, id, want string }{ {"tmdb", "603", "tmdbid-603"}, {"tvdb", "123", "tvdbid-123"}, + {"imdb", "tt2802850", "imdbid-tt2802850"}, {"none", "", ""}, {"tmdb", "", ""}, {"weird", "1", ""},