Files
unified-media-manager/internal/service/tvdb.go
2026-04-24 10:45:19 -07:00

309 lines
7.6 KiB
Go

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
}