309 lines
7.6 KiB
Go
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
|
|
}
|