package providers import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/url" "strconv" "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() if err := t.get(ctx, u, &resp); err != nil { return nil, err } 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 } // get calls the API with a token, logging in again once if it expired. func (t *tvdb) get(ctx context.Context, u string, out any) error { for attempt := 0; ; attempt++ { tok, err := t.token(ctx, attempt > 0) if err != nil { return err } err = t.r.getJSON(ctx, u, map[string]string{"Authorization": "Bearer " + tok}, out) if isStatus(err, 401) && attempt == 0 { continue } return err } } // Lookup refreshes one series by its TheTVDB ID, preferring English titles // and overviews. func (t *tvdb) Lookup(ctx context.Context, providerID string) (Result, error) { if _, err := strconv.ParseInt(providerID, 10, 64); err != nil { return Result{}, ErrGone } var s struct { Data struct { Name string `json:"name"` Slug string `json:"slug"` Image string `json:"image"` Year string `json:"year"` FirstAired string `json:"firstAired"` Overview string `json:"overview"` OriginalNetwork struct { Name string `json:"name"` } `json:"originalNetwork"` LatestNetwork struct { Name string `json:"name"` } `json:"latestNetwork"` } `json:"data"` } err := t.get(ctx, "https://api4.thetvdb.com/v4/series/"+providerID+"/extended?short=true", &s) if isStatus(err, 404) { return Result{}, ErrGone } if err != nil { return Result{}, err } d := s.Data res := Result{ Provider: "tvdb", ProviderID: providerID, Title: d.Name, Subtitle: d.OriginalNetwork.Name, Year: d.Year, ImageURL: d.Image, ExternalURL: "https://thetvdb.com/series/" + d.Slug, Overview: truncate(d.Overview, maxDescription), } if res.Subtitle == "" { res.Subtitle = d.LatestNetwork.Name } if res.Year == "" { res.Year = yearOf(d.FirstAired) } var eng struct { Data struct { Name string `json:"name"` Overview string `json:"overview"` } `json:"data"` } if err := t.get(ctx, "https://api4.thetvdb.com/v4/series/"+providerID+"/translations/eng", &eng); err == nil { if eng.Data.Name != "" { res.Title = eng.Data.Name } if eng.Data.Overview != "" { res.Overview = truncate(eng.Data.Overview, maxDescription) } } return res, nil }