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 }