Sync from /srv/compose/unified-media-manager
This commit is contained in:
308
internal/service/tvdb.go
Normal file
308
internal/service/tvdb.go
Normal file
@@ -0,0 +1,308 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user