Files
2026-06-14 19:37:09 +03:00

116 lines
3.4 KiB
Go

package metadata
import (
"context"
"fmt"
"log/slog"
"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
log *slog.Logger
}
// NewTVMaze собирает клиент TVMaze (ключ не нужен). logger nil → slog.Default().
func NewTVMaze(cfg TVMazeConfig, logger *slog.Logger) (*TVMaze, error) {
hc, err := newHTTPClient(cfg.Proxy, cfg.Timeout)
if err != nil {
return nil, err
}
base := cfg.BaseURL
if base == "" {
base = tvmazeDefaultBaseURL
}
if logger == nil {
logger = slog.Default()
}
return &TVMaze{baseURL: strings.TrimRight(base, "/"), hc: hc, log: logger}, 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)
t.log.Debug("tvmaze: search", "title", q.Title)
if err := getJSON(ctx, t.hc, t.log, rawURL, nil, &resp); err != nil {
return nil, fmt.Errorf("tvmaze search: %w", err)
}
t.log.Debug("tvmaze: search done", "title", q.Title, "results", len(resp))
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, t.log, 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
}