Добавил поиск метаданных по каталогам
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
// Package metadata — интерфейс баз метаданных и клиенты TMDB/TVDB (опц.).
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф4 (см. docs/specs/architecture.md).
|
||||
package metadata
|
||||
@@ -0,0 +1,89 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultTimeout = 10 * time.Second
|
||||
|
||||
// newHTTPClient собирает http.Client с опциональным прокси и таймаутом.
|
||||
func newHTTPClient(proxy string, timeout time.Duration) (*http.Client, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
transport := http.DefaultTransport
|
||||
if proxy != "" {
|
||||
u, err := url.Parse(proxy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("metadata: parse proxy %q: %w", proxy, err)
|
||||
}
|
||||
transport = &http.Transport{Proxy: http.ProxyURL(u)}
|
||||
}
|
||||
return &http.Client{Timeout: timeout, Transport: transport}, nil
|
||||
}
|
||||
|
||||
const maxBody = 4 << 20 // 4 MiB — потолок на тело ответа
|
||||
|
||||
// getJSON выполняет GET и декодирует JSON-ответ в out. headers — опц.
|
||||
// дополнительные заголовки (напр. Authorization).
|
||||
func getJSON(ctx context.Context, hc *http.Client, rawURL string, headers map[string]string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("metadata: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
return doJSON(hc, req, out)
|
||||
}
|
||||
|
||||
// postJSON выполняет POST с JSON-телом и декодирует ответ.
|
||||
func postJSON(ctx context.Context, hc *http.Client, rawURL string, body, out any) error {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("metadata: marshal body: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("metadata: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
return doJSON(hc, req, out)
|
||||
}
|
||||
|
||||
func doJSON(hc *http.Client, req *http.Request, out any) error {
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("metadata: request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("metadata: read body: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("metadata: status %d: %s", resp.StatusCode, snippet(raw))
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("metadata: decode: %w (body: %s)", err, snippet(raw))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func snippet(b []byte) string {
|
||||
const max = 200
|
||||
if len(b) > max {
|
||||
return string(b[:max]) + "…"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package metadata_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||
)
|
||||
|
||||
// TestIntegration_TVDB бьётся в реальный TheTVDB v4. По умолчанию
|
||||
// пропускается; включается ключом:
|
||||
//
|
||||
// TVDB_API_KEY=... go test ./internal/metadata/ -run Integration -v
|
||||
func TestIntegration_TVDB(t *testing.T) {
|
||||
key := os.Getenv("TVDB_API_KEY")
|
||||
if key == "" {
|
||||
t.Skip("set TVDB_API_KEY to run")
|
||||
}
|
||||
c, err := metadata.NewTVDB(metadata.TVDBConfig{APIKey: key, Timeout: 20 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTVDB: %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)
|
||||
}
|
||||
t.Logf("candidates (%d):", len(cands))
|
||||
for i, cd := range cands {
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
t.Logf(" id=%s title=%q year=%d", cd.ID, cd.Title, cd.Year)
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
t.Fatal("ожидался хотя бы один кандидат для Fargo")
|
||||
}
|
||||
|
||||
// Берём первого с непустым id и тянем число серий по сезонам.
|
||||
id := cands[0].ID
|
||||
counts, err := c.SeasonEpisodeCounts(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("SeasonEpisodeCounts(%s): %v", id, err)
|
||||
}
|
||||
t.Logf("season episode counts for id=%s: %v", id, counts)
|
||||
if len(counts) == 0 {
|
||||
t.Error("ожидались данные о числе серий по сезонам")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Package metadata — интерфейс баз метаданных и клиенты TMDB/TVDB.
|
||||
//
|
||||
// Базы опциональны (включаются конфигом). Их роль — подтвердить распознавание
|
||||
// официальным id и каноническим именем: при единичном сильном матче по
|
||||
// названию+году раскладка делается автоматически, иначе уходит в review
|
||||
// (см. docs/specs/recognition.md → «Модель уверенности»). Каждый клиент
|
||||
// ходит наружу через опциональный HTTP-прокси с таймаутом.
|
||||
package metadata
|
||||
|
||||
import "context"
|
||||
|
||||
// MediaType — вид контента в запросе к базе.
|
||||
type MediaType string
|
||||
|
||||
const (
|
||||
Movie MediaType = "movie"
|
||||
Series MediaType = "series"
|
||||
)
|
||||
|
||||
// Query — запрос поиска в базе.
|
||||
type Query struct {
|
||||
Type MediaType
|
||||
Title string // каноническое название или provider_hint
|
||||
Year int // 0 — без ограничения по году
|
||||
}
|
||||
|
||||
// Candidate — результат поиска: официальный id и каноническое имя.
|
||||
type Candidate struct {
|
||||
Provider string // "tmdb" | "tvdb"
|
||||
ID string
|
||||
Title string
|
||||
OriginalTitle string
|
||||
Year int
|
||||
}
|
||||
|
||||
// Provider — одна база метаданных.
|
||||
type Provider interface {
|
||||
// Name — идентификатор провайдера ("tmdb"/"tvdb"), он же префикс тега.
|
||||
Name() string
|
||||
// Search ищет кандидатов по названию (и году, если задан).
|
||||
Search(ctx context.Context, q Query) ([]Candidate, error)
|
||||
// SeasonEpisodeCounts возвращает число серий по сезонам для сериала
|
||||
// (ключ — номер сезона). Нужен для валидации полноты сезон-пака.
|
||||
SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tmdbDefaultBaseURL = "https://api.themoviedb.org/3"
|
||||
|
||||
// TMDBConfig — настройки клиента TMDB.
|
||||
type TMDBConfig struct {
|
||||
APIKey string
|
||||
Proxy string
|
||||
Timeout time.Duration
|
||||
BaseURL string // пусто → api.themoviedb.org; задаётся в тестах
|
||||
}
|
||||
|
||||
// TMDB — клиент The Movie Database (API v3, авторизация по api_key).
|
||||
type TMDB struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
// NewTMDB собирает клиент TMDB.
|
||||
func NewTMDB(cfg TMDBConfig) (*TMDB, error) {
|
||||
if cfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("metadata: tmdb api_key required")
|
||||
}
|
||||
hc, err := newHTTPClient(cfg.Proxy, cfg.Timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := cfg.BaseURL
|
||||
if base == "" {
|
||||
base = tmdbDefaultBaseURL
|
||||
}
|
||||
return &TMDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc}, nil
|
||||
}
|
||||
|
||||
func (t *TMDB) Name() string { return "tmdb" }
|
||||
|
||||
type tmdbSearchResp struct {
|
||||
Results []struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"` // movie
|
||||
OriginalTitle string `json:"original_title"` // movie
|
||||
Name string `json:"name"` // tv
|
||||
OriginalName string `json:"original_name"` // tv
|
||||
ReleaseDate string `json:"release_date"` // movie
|
||||
FirstAirDate string `json:"first_air_date"` // tv
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
// Search ищет фильм/сериал по названию и году.
|
||||
func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
||||
var path string
|
||||
params := url.Values{"api_key": {t.apiKey}, "query": {q.Title}, "include_adult": {"false"}}
|
||||
switch q.Type {
|
||||
case Movie:
|
||||
path = "/search/movie"
|
||||
if q.Year > 0 {
|
||||
params.Set("year", strconv.Itoa(q.Year))
|
||||
}
|
||||
case Series:
|
||||
path = "/search/tv"
|
||||
if q.Year > 0 {
|
||||
params.Set("first_air_date_year", strconv.Itoa(q.Year))
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("metadata: tmdb: неизвестный тип %q", q.Type)
|
||||
}
|
||||
|
||||
var resp tmdbSearchResp
|
||||
if err := getJSON(ctx, t.hc, t.baseURL+path+"?"+params.Encode(), nil, &resp); err != nil {
|
||||
return nil, fmt.Errorf("tmdb search: %w", err)
|
||||
}
|
||||
|
||||
out := make([]Candidate, 0, len(resp.Results))
|
||||
for _, r := range resp.Results {
|
||||
title, orig, date := r.Title, r.OriginalTitle, r.ReleaseDate
|
||||
if q.Type == Series {
|
||||
title, orig, date = r.Name, r.OriginalName, r.FirstAirDate
|
||||
}
|
||||
out = append(out, Candidate{
|
||||
Provider: "tmdb",
|
||||
ID: strconv.Itoa(r.ID),
|
||||
Title: title,
|
||||
OriginalTitle: orig,
|
||||
Year: yearOf(date),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type tmdbTVResp struct {
|
||||
Seasons []struct {
|
||||
SeasonNumber int `json:"season_number"`
|
||||
EpisodeCount int `json:"episode_count"`
|
||||
} `json:"seasons"`
|
||||
}
|
||||
|
||||
// SeasonEpisodeCounts возвращает число серий по сезонам сериала.
|
||||
func (t *TMDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
|
||||
params := url.Values{"api_key": {t.apiKey}}
|
||||
var resp tmdbTVResp
|
||||
if err := getJSON(ctx, t.hc, t.baseURL+"/tv/"+url.PathEscape(id)+"?"+params.Encode(), nil, &resp); err != nil {
|
||||
return nil, fmt.Errorf("tmdb tv %s: %w", id, err)
|
||||
}
|
||||
out := make(map[int]int, len(resp.Seasons))
|
||||
for _, s := range resp.Seasons {
|
||||
out[s.SeasonNumber] = s.EpisodeCount
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// yearOf достаёт год из даты вида "1999-03-31".
|
||||
func yearOf(date string) int {
|
||||
if len(date) < 4 {
|
||||
return 0
|
||||
}
|
||||
y, err := strconv.Atoi(date[:4])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return y
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTMDB(t *testing.T, url string) *TMDB {
|
||||
t.Helper()
|
||||
c, err := NewTMDB(TMDBConfig{APIKey: "k", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTMDB: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestTMDB_SearchMovie(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/search/movie" {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if q.Get("api_key") != "k" || q.Get("query") != "The Matrix" || q.Get("year") != "1999" {
|
||||
t.Errorf("query = %v", q)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"results":[
|
||||
{"id":603,"title":"The Matrix","original_title":"The Matrix","release_date":"1999-03-31"}
|
||||
]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := newTMDB(t, srv.URL).Search(context.Background(), Query{Type: Movie, Title: "The Matrix", Year: 1999})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d candidates", len(got))
|
||||
}
|
||||
c := got[0]
|
||||
if c.Provider != "tmdb" || c.ID != "603" || c.Title != "The Matrix" || c.Year != 1999 {
|
||||
t.Errorf("candidate = %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTMDB_SearchSeries(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/search/tv" {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("first_air_date_year") != "2015" {
|
||||
t.Errorf("year param = %q", r.URL.Query().Get("first_air_date_year"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"results":[
|
||||
{"id":60622,"name":"Fargo","original_name":"Fargo","first_air_date":"2014-04-15"}
|
||||
]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := newTMDB(t, srv.URL).Search(context.Background(), Query{Type: Series, Title: "Fargo", Year: 2015})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "60622" || got[0].Title != "Fargo" || got[0].Year != 2014 {
|
||||
t.Errorf("candidate = %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTMDB_SeasonEpisodeCounts(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/tv/60622" {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"seasons":[
|
||||
{"season_number":0,"episode_count":2},
|
||||
{"season_number":1,"episode_count":10},
|
||||
{"season_number":2,"episode_count":10}
|
||||
]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
counts, err := newTMDB(t, srv.URL).SeasonEpisodeCounts(context.Background(), "60622")
|
||||
if err != nil {
|
||||
t.Fatalf("SeasonEpisodeCounts: %v", err)
|
||||
}
|
||||
if counts[1] != 10 || counts[2] != 10 || counts[0] != 2 {
|
||||
t.Errorf("counts = %v", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTMDB_ErrorStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"status_message":"invalid key"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := newTMDB(t, srv.URL).Search(context.Background(), Query{Type: Movie, Title: "X"})
|
||||
if err == nil {
|
||||
t.Fatal("want error on 401")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTMDB_RequiresKey(t *testing.T) {
|
||||
if _, err := NewTMDB(TMDBConfig{}); err == nil {
|
||||
t.Fatal("want error without api_key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tvdbDefaultBaseURL = "https://api4.thetvdb.com/v4"
|
||||
|
||||
// TVDBConfig — настройки клиента TheTVDB.
|
||||
type TVDBConfig struct {
|
||||
APIKey string
|
||||
Proxy string
|
||||
Timeout time.Duration
|
||||
BaseURL string // пусто → api4.thetvdb.com; задаётся в тестах
|
||||
}
|
||||
|
||||
// TVDB — клиент TheTVDB (API v4). Токен получается логином по apikey и
|
||||
// кэшируется; при 401 выполняется повторный логин. Формы ответов сверены с
|
||||
// живым API v4 (см. integration_test.go).
|
||||
type TVDB struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
hc *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
}
|
||||
|
||||
// NewTVDB собирает клиент TVDB.
|
||||
func NewTVDB(cfg TVDBConfig) (*TVDB, error) {
|
||||
if cfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("metadata: tvdb api_key required")
|
||||
}
|
||||
hc, err := newHTTPClient(cfg.Proxy, cfg.Timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := cfg.BaseURL
|
||||
if base == "" {
|
||||
base = tvdbDefaultBaseURL
|
||||
}
|
||||
return &TVDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc}, nil
|
||||
}
|
||||
|
||||
func (t *TVDB) Name() string { return "tvdb" }
|
||||
|
||||
// login получает и кэширует bearer-токен.
|
||||
func (t *TVDB) login(ctx context.Context) (string, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.token != "" {
|
||||
return t.token, nil
|
||||
}
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := postJSON(ctx, t.hc, t.baseURL+"/login",
|
||||
map[string]string{"apikey": t.apiKey}, &resp); err != nil {
|
||||
return "", fmt.Errorf("tvdb login: %w", err)
|
||||
}
|
||||
if resp.Data.Token == "" {
|
||||
return "", fmt.Errorf("tvdb login: empty token")
|
||||
}
|
||||
t.token = resp.Data.Token
|
||||
return t.token, nil
|
||||
}
|
||||
|
||||
// get делает авторизованный GET; при 401 один раз перелогинивается.
|
||||
func (t *TVDB) get(ctx context.Context, path string, out any) error {
|
||||
token, err := t.login(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status, raw, err := t.rawGet(ctx, path, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == http.StatusUnauthorized {
|
||||
t.mu.Lock()
|
||||
t.token = "" // сбрасываем протухший токен
|
||||
t.mu.Unlock()
|
||||
if token, err = t.login(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if status, raw, err = t.rawGet(ctx, path, token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("tvdb: status %d: %s", status, snippet(raw))
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("tvdb: decode: %w (body: %s)", err, snippet(raw))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TVDB) rawGet(ctx context.Context, path, token string) (int, []byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("tvdb: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := t.hc.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("tvdb: request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("tvdb: read body: %w", err)
|
||||
}
|
||||
return resp.StatusCode, raw, nil
|
||||
}
|
||||
|
||||
type tvdbSearchResp struct {
|
||||
Data []struct {
|
||||
TVDBID string `json:"tvdb_id"`
|
||||
Name string `json:"name"`
|
||||
Year string `json:"year"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// Search ищет сериал/фильм по названию и году.
|
||||
func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
||||
typ := "series"
|
||||
if q.Type == Movie {
|
||||
typ = "movie"
|
||||
}
|
||||
params := url.Values{"query": {q.Title}, "type": {typ}}
|
||||
if q.Year > 0 {
|
||||
params.Set("year", strconv.Itoa(q.Year))
|
||||
}
|
||||
var resp tvdbSearchResp
|
||||
if err := t.get(ctx, "/search?"+params.Encode(), &resp); err != nil {
|
||||
return nil, fmt.Errorf("tvdb search: %w", err)
|
||||
}
|
||||
out := make([]Candidate, 0, len(resp.Data))
|
||||
for _, r := range resp.Data {
|
||||
if r.TVDBID == "" {
|
||||
continue
|
||||
}
|
||||
year, _ := strconv.Atoi(r.Year)
|
||||
out = append(out, Candidate{
|
||||
Provider: "tvdb",
|
||||
ID: r.TVDBID,
|
||||
Title: r.Name,
|
||||
Year: year,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type tvdbExtendedResp struct {
|
||||
Data struct {
|
||||
Episodes []struct {
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
} `json:"episodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// SeasonEpisodeCounts считает число серий по сезонам из расширенных данных.
|
||||
func (t *TVDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
|
||||
var resp tvdbExtendedResp
|
||||
if err := t.get(ctx, "/series/"+url.PathEscape(id)+"/extended?meta=episodes&short=true", &resp); err != nil {
|
||||
return nil, fmt.Errorf("tvdb series %s: %w", id, err)
|
||||
}
|
||||
out := map[int]int{}
|
||||
for _, e := range resp.Data.Episodes {
|
||||
out[e.SeasonNumber]++
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeTVDB — стенд v4: /login выдаёт токен, остальное требует Bearer.
|
||||
func fakeTVDB(t *testing.T, logins *atomic.Int32) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["apikey"] != "k" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if logins != nil {
|
||||
logins.Add(1)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"status":"success","data":{"token":"tok"}}`))
|
||||
})
|
||||
authed := func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer tok" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
mux.HandleFunc("/search", authed(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("type") != "series" || r.URL.Query().Get("query") != "Fargo" {
|
||||
t.Errorf("query = %v", r.URL.Query())
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":[{"tvdb_id":"269613","name":"Fargo","year":"2014"}]}`))
|
||||
}))
|
||||
mux.HandleFunc("/series/269613/extended", authed(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":{"episodes":[
|
||||
{"seasonNumber":1},{"seasonNumber":1},{"seasonNumber":2}
|
||||
]}}`))
|
||||
}))
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func newTVDB(t *testing.T, url string) *TVDB {
|
||||
t.Helper()
|
||||
c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTVDB: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestTVDB_SearchAndLoginCached(t *testing.T) {
|
||||
var logins atomic.Int32
|
||||
srv := fakeTVDB(t, &logins)
|
||||
c := newTVDB(t, srv.URL)
|
||||
|
||||
got, err := c.Search(context.Background(), Query{Type: Series, Title: "Fargo", Year: 2014})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "269613" || got[0].Provider != "tvdb" || got[0].Year != 2014 {
|
||||
t.Fatalf("candidate = %+v", got)
|
||||
}
|
||||
// Второй запрос переиспользует токен — повторного логина нет.
|
||||
if _, err := c.Search(context.Background(), Query{Type: Series, Title: "Fargo"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if logins.Load() != 1 {
|
||||
t.Errorf("logins = %d, want 1 (token cached)", logins.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTVDB_SeasonEpisodeCounts(t *testing.T) {
|
||||
srv := fakeTVDB(t, nil)
|
||||
counts, err := newTVDB(t, srv.URL).SeasonEpisodeCounts(context.Background(), "269613")
|
||||
if err != nil {
|
||||
t.Fatalf("SeasonEpisodeCounts: %v", err)
|
||||
}
|
||||
if counts[1] != 2 || counts[2] != 1 {
|
||||
t.Errorf("counts = %v", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTVDB_ReloginOn401(t *testing.T) {
|
||||
var logins atomic.Int32
|
||||
var token atomic.Value
|
||||
token.Store("tok")
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
logins.Add(1)
|
||||
_, _ = w.Write([]byte(`{"data":{"token":"tok"}}`))
|
||||
})
|
||||
var firstCall atomic.Bool
|
||||
mux.HandleFunc("/search", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// Первый авторизованный запрос отдаёт 401 (токен «протух»).
|
||||
if firstCall.CompareAndSwap(false, true) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":[{"tvdb_id":"1","name":"X","year":"2000"}]}`))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
c := newTVDB(t, srv.URL)
|
||||
got, err := c.Search(context.Background(), Query{Type: Series, Title: "X"})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d", len(got))
|
||||
}
|
||||
if logins.Load() != 2 {
|
||||
t.Errorf("logins = %d, want 2 (initial + relogin)", logins.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTVDB_RequiresKey(t *testing.T) {
|
||||
if _, err := NewTVDB(TVDBConfig{}); err == nil {
|
||||
t.Fatal("want error without api_key")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user