package providers import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/url" "sync" "time" ) // tvdb searches TheTVDB (API v4). type tvdb struct { r *Registry key string pin string } type tvdbTokens struct { mu sync.Mutex credKey string token string expires time.Time } func (t *tvdb) Name() string { return "tvdb" } func (t *tvdb) Label() string { return "TheTVDB" } func (t *tvdb) token(ctx context.Context, fresh bool) (string, error) { c := t.r.tvdb c.mu.Lock() defer c.mu.Unlock() credKey := t.key + "\x00" + t.pin if !fresh && c.credKey == credKey && c.token != "" && time.Now().Before(c.expires) { return c.token, nil } body := map[string]string{"apikey": t.key} if t.pin != "" { body["pin"] = t.pin } b, _ := json.Marshal(body) req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api4.thetvdb.com/v4/login", bytes.NewReader(b)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") var resp struct { Data struct { Token string `json:"token"` } `json:"data"` } if err := t.r.do(req, &resp); err != nil { if isStatus(err, 401) { return "", fmt.Errorf("TheTVDB rejected the API key/PIN; check them under Settings → Integrations") } return "", err } // Tokens are valid for a month; refresh daily to be safe. c.credKey, c.token, c.expires = credKey, resp.Data.Token, time.Now().Add(24*time.Hour) return c.token, nil } func (t *tvdb) Search(ctx context.Context, query string) ([]Result, error) { var resp struct { Data []struct { TVDBID string `json:"tvdb_id"` Name string `json:"name"` Year string `json:"year"` FirstAired string `json:"first_air_time"` ImageURL string `json:"image_url"` Thumbnail string `json:"thumbnail"` Overview string `json:"overview"` Network string `json:"network"` Slug string `json:"slug"` Translations map[string]string `json:"translations"` Overviews map[string]string `json:"overviews"` } `json:"data"` } u := "https://api4.thetvdb.com/v4/search?" + url.Values{"query": {query}, "type": {"series"}, "limit": {"20"}}.Encode() for attempt := 0; ; attempt++ { tok, err := t.token(ctx, attempt > 0) if err != nil { return nil, err } err = t.r.getJSON(ctx, u, map[string]string{"Authorization": "Bearer " + tok}, &resp) if isStatus(err, 401) && attempt == 0 { continue } if err != nil { return nil, err } break } out := make([]Result, 0, len(resp.Data)) for _, s := range resp.Data { name := s.Name if eng := s.Translations["eng"]; eng != "" { name = eng } overview := s.Overview if eng := s.Overviews["eng"]; eng != "" { overview = eng } year := s.Year if year == "" { year = yearOf(s.FirstAired) } img := s.ImageURL if img == "" { img = s.Thumbnail } ext := "https://thetvdb.com/?tab=series&id=" + s.TVDBID if s.Slug != "" { ext = "https://thetvdb.com/series/" + s.Slug } out = append(out, Result{ Provider: "tvdb", ProviderID: s.TVDBID, Title: name, Subtitle: s.Network, Year: year, ImageURL: img, ExternalURL: ext, Overview: truncate(overview, maxDescription), }) } return out, nil }