Добавил поиск метаданных по каталогам

This commit is contained in:
2026-06-14 15:21:01 +03:00
parent 9c1b178e46
commit 5087f35861
21 changed files with 1435 additions and 72 deletions
-4
View File
@@ -1,4 +0,0 @@
// Package metadata — интерфейс баз метаданных и клиенты TMDB/TVDB (опц.).
//
// Заглушка: реализация в фазе Ф4 (см. docs/specs/architecture.md).
package metadata
+89
View File
@@ -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)
}
+53
View File
@@ -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("ожидались данные о числе серий по сезонам")
}
}
+45
View File
@@ -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)
}
+132
View File
@@ -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
}
+109
View File
@@ -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")
}
}
+185
View File
@@ -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
}
+132
View File
@@ -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")
}
}