package service import ( "bytes" "context" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" ) type TVDBProvider struct { apiKey string baseURL string httpClient *http.Client bearerToken string tokenExpiry time.Time lastRequest time.Time } func NewTVDBProvider(apiKey string) *TVDBProvider { return &TVDBProvider{ apiKey: apiKey, baseURL: "https://api4.thetvdb.com/v4", httpClient: &http.Client{ Timeout: 15 * time.Second, }, } } type tvdbLoginRequest struct { APIKey string `json:"apikey"` } type tvdbLoginResponse struct { Token string `json:"token"` } type tvdbSearchResponse struct { Data []tvdbSearchItem `json:"data"` } type tvdbSearchItem struct { ID string `json:"id"` Name string `json:"name"` Overview string `json:"overview"` FirstAired string `json:"first_aired"` Year string `json:"year"` ImageURL string `json:"image"` Type string `json:"type"` } type tvdbSeriesDetail struct { ID string `json:"id"` Name string `json:"name"` Overview string `json:"overview"` FirstAired string `json:"firstAired"` Status tvdbStatus `json:"status"` NumberOfSeasons int `json:"numberOfSeasons"` NumberOfEpisodes int `json:"numberOfEpisodes"` AverageRuntime int `json:"averageRuntime"` Ratings []tvdbRating `json:"ratings"` Image string `json:"image"` } type tvdbStatus struct { Name string `json:"name"` } type tvdbRating struct { Name string `json:"name"` Source string `json:"source"` Rating float64 `json:"rating"` } type tvdbEpisodesResponse struct { Data []tvdbEpisode `json:"data"` } type tvdbEpisode struct { ID string `json:"id"` Name string `json:"name"` Overview string `json:"overview"` AiredSeasonNumber int `json:"airedSeasonNumber"` AiredEpisodeNumber int `json:"airedEpisodeNumber"` AbsoluteNumber int `json:"absoluteNumber"` Runtime int `json:"runtime"` AiredAt string `json:"aired"` Thumbnail string `json:"thumbnail"` } type tvdbImagesResponse struct { Data []tvdbImage `json:"data"` } type tvdbImage struct { Image string `json:"image"` ImageType string `json:"type"` Resolution string `json:"resolution"` } func (p *TVDBProvider) rateLimit() { elapsed := time.Since(p.lastRequest) if elapsed < 250*time.Millisecond { time.Sleep(250*time.Millisecond - elapsed) } p.lastRequest = time.Now() } func (p *TVDBProvider) authenticate(ctx context.Context) error { if p.bearerToken != "" && time.Now().Before(p.tokenExpiry) { return nil } p.rateLimit() loginReq := tvdbLoginRequest{APIKey: p.apiKey} body, err := json.Marshal(loginReq) if err != nil { return fmt.Errorf("marshal login request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/login", bytes.NewReader(body)) if err != nil { return fmt.Errorf("create login request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := p.httpClient.Do(req) if err != nil { return fmt.Errorf("tvdb login: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("tvdb login failed: status %d", resp.StatusCode) } var loginResp tvdbLoginResponse if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { return fmt.Errorf("decode login response: %w", err) } p.bearerToken = loginResp.Token p.tokenExpiry = time.Now().Add(24 * time.Hour) return nil } func (p *TVDBProvider) fetchTVDB(ctx context.Context, url string, result interface{}) error { if err := p.authenticate(ctx); err != nil { return fmt.Errorf("tvdb auth: %w", err) } p.rateLimit() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return fmt.Errorf("create request: %w", err) } req.Header.Set("Authorization", "Bearer "+p.bearerToken) resp, err := p.httpClient.Do(req) if err != nil { return fmt.Errorf("fetch tvdb: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("tvdb api returned status %d", resp.StatusCode) } if err := json.NewDecoder(resp.Body).Decode(result); err != nil { return fmt.Errorf("decode tvdb response: %w", err) } return nil } func (p *TVDBProvider) Name() string { return "tvdb" } func (p *TVDBProvider) Search(ctx context.Context, query string, opts SearchOptions) ([]MetadataSearchResult, error) { url := fmt.Sprintf("%s/search?query=%s&type=series", p.baseURL, query) var resp tvdbSearchResponse if err := p.fetchTVDB(ctx, url, &resp); err != nil { return nil, fmt.Errorf("search tvdb: %w", err) } var results []MetadataSearchResult for _, item := range resp.Data { var year *int if item.FirstAired != "" && len(item.FirstAired) >= 4 { if y, err := strconv.Atoi(item.FirstAired[:4]); err == nil { year = &y } } results = append(results, MetadataSearchResult{ ProviderID: item.ID, Title: item.Name, Year: year, MediaType: "series", Overview: item.Overview, }) } return results, nil } func (p *TVDBProvider) GetDetails(ctx context.Context, id string) (*MetadataDetails, error) { seriesURL := fmt.Sprintf("%s/series/%s", p.baseURL, id) var detail tvdbSeriesDetail if err := p.fetchTVDB(ctx, seriesURL, &detail); err != nil { return nil, fmt.Errorf("get tvdb series: %w", err) } var year *int if detail.FirstAired != "" && len(detail.FirstAired) >= 4 { if y, err := strconv.Atoi(detail.FirstAired[:4]); err == nil { year = &y } } extIDs, _ := json.Marshal(map[string]string{"tvdb": id}) ratingsMap := make(map[string]float64) for _, r := range detail.Ratings { ratingsMap[r.Source] = r.Rating } ratings, _ := json.Marshal(ratingsMap) episodesURL := fmt.Sprintf("%s/series/%s/episodes", p.baseURL, id) var episodesResp tvdbEpisodesResponse var episodeList []map[string]interface{} if err := p.fetchTVDB(ctx, episodesURL, &episodesResp); err == nil { for _, ep := range episodesResp.Data { episodeList = append(episodeList, map[string]interface{}{ "season": ep.AiredSeasonNumber, "episode": ep.AiredEpisodeNumber, "absolute_number": ep.AbsoluteNumber, "title": ep.Name, "aired_date": ep.AiredAt, "runtime": ep.Runtime, }) } } metadata, _ := json.Marshal(map[string]interface{}{ "number_of_seasons": detail.NumberOfSeasons, "number_of_episodes": detail.NumberOfEpisodes, "average_runtime": detail.AverageRuntime, "status": detail.Status.Name, "episodes": episodeList, }) overview := detail.Overview return &MetadataDetails{ ProviderID: id, Title: detail.Name, Overview: &overview, Year: year, Ratings: ratings, ExternalIDs: extIDs, Metadata: metadata, }, nil } func (p *TVDBProvider) GetImages(ctx context.Context, id string) ([]ImageResult, error) { imagesURL := fmt.Sprintf("%s/series/%s/images", p.baseURL, id) var resp tvdbImagesResponse if err := p.fetchTVDB(ctx, imagesURL, &resp); err != nil { return nil, fmt.Errorf("get tvdb images: %w", err) } var images []ImageResult for _, img := range resp.Data { imgURL := img.Image if !strings.HasPrefix(imgURL, "http") { imgURL = "https://artworks.thetvdb.com" + imgURL } imgType := img.ImageType switch imgType { case "poster": imgType = "poster" case "fanart": imgType = "backdrop" case "season": imgType = "poster" case "series": imgType = "poster" } images = append(images, ImageResult{ URL: imgURL, Type: imgType, }) } return images, nil }