From 05ede20893c28105f1d3ac7f302a07b4579bef43 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Mon, 6 Jul 2026 07:36:29 +0200 Subject: [PATCH] feat: add tvmaze api client --- integrations/tvmaze/client.go | 164 ++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 integrations/tvmaze/client.go diff --git a/integrations/tvmaze/client.go b/integrations/tvmaze/client.go new file mode 100644 index 00000000..acbd72c8 --- /dev/null +++ b/integrations/tvmaze/client.go @@ -0,0 +1,164 @@ +package tvmaze + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "unicode" +) + +const baseURL = "https://api.tvmaze.com" + +type Client struct { + httpClient *http.Client + baseURL string +} + +type searchResult struct { + Show show `json:"show"` +} + +type show struct { + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} + +type episode struct { + Name string `json:"name"` +} + +func NewClient() *Client { + return &Client{ + httpClient: &http.Client{Timeout: 5 * time.Second}, + baseURL: baseURL, + } +} + +func (c *Client) Name() string { + return "TVmaze" +} + +func (c *Client) ResolveEpisodeProviderID(ctx context.Context, _ int, titleCandidates []string) (string, error) { + matches := map[int]struct{}{} + for _, candidate := range uniqueTitleCandidates(titleCandidates, 4) { + results, err := c.search(ctx, candidate) + if err != nil { + return "", err + } + addExactMatches(matches, results, normalizeTitle(candidate)) + if len(matches) == 1 { + for id := range matches { + return strconv.Itoa(id), nil + } + } + } + + if len(matches) > 1 { + return "", errors.New("tvmaze: multiple exact show matches") + } + return "", errors.New("tvmaze: no exact show match") +} + +func uniqueTitleCandidates(candidates []string, limit int) []string { + seen := map[string]struct{}{} + unique := make([]string, 0, min(len(candidates), limit)) + for _, candidate := range candidates { + normalized := normalizeTitle(candidate) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + unique = append(unique, candidate) + if len(unique) == limit { + break + } + } + return unique +} + +func addExactMatches(matches map[int]struct{}, results []searchResult, normalizedTitle string) { + for _, result := range results { + if result.Show.ID <= 0 || normalizeTitle(result.Show.Name) != normalizedTitle { + continue + } + if result.Show.Type != "" && !strings.EqualFold(result.Show.Type, "Animation") { + continue + } + matches[result.Show.ID] = struct{}{} + } +} + +func (c *Client) GetEpisodeTitlesByProviderID(ctx context.Context, providerID string) (map[int]string, error) { + var episodes []episode + if err := c.getJSON(ctx, "/shows/"+url.PathEscape(providerID)+"/episodes", &episodes); err != nil { + return nil, err + } + + titles := make(map[int]string, len(episodes)) + for i, item := range episodes { + title := strings.TrimSpace(item.Name) + if title != "" { + titles[i+1] = title + } + } + if len(titles) == 0 { + return nil, errors.New("tvmaze: show has no episode titles") + } + return titles, nil +} + +func (c *Client) search(ctx context.Context, title string) ([]searchResult, error) { + var results []searchResult + err := c.getJSON(ctx, "/search/shows?q="+url.QueryEscape(title), &results) + return results, err +} + +func (c *Client) getJSON(ctx context.Context, path string, target any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.apiURL()+path, nil) + if err != nil { + return fmt.Errorf("tvmaze: create request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "mal/1.0") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("tvmaze: request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("tvmaze: status %d", resp.StatusCode) + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(target); err != nil { + return fmt.Errorf("tvmaze: decode response: %w", err) + } + return nil +} + +func (c *Client) apiURL() string { + if c.baseURL != "" { + return c.baseURL + } + return baseURL +} + +func normalizeTitle(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsNumber(r) { + return unicode.ToLower(r) + } + return -1 + }, strings.TrimSpace(value)) +}