404 lines
12 KiB
Go
404 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
type TMDBProvider struct {
|
|
apiKey string
|
|
baseURL string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewTMDBProvider(apiKey string) *TMDBProvider {
|
|
return &TMDBProvider{
|
|
apiKey: apiKey,
|
|
baseURL: "https://api.themoviedb.org/3",
|
|
httpClient: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
type tmdbSearchResponse struct {
|
|
Page int `json:"page"`
|
|
TotalResults int `json:"total_results"`
|
|
Results []tmdbSearchItem `json:"results"`
|
|
}
|
|
|
|
type tmdbSearchItem struct {
|
|
ID int `json:"id"`
|
|
Title string `json:"title"`
|
|
Name string `json:"name"`
|
|
OriginalTitle string `json:"original_title"`
|
|
OriginalName string `json:"original_name"`
|
|
Overview string `json:"overview"`
|
|
ReleaseDate string `json:"release_date"`
|
|
FirstAirDate string `json:"first_air_date"`
|
|
PosterPath string `json:"poster_path"`
|
|
BackdropPath string `json:"backdrop_path"`
|
|
VoteAverage float64 `json:"vote_average"`
|
|
MediaType string `json:"media_type"`
|
|
}
|
|
|
|
type tmdbMovieDetail struct {
|
|
ID int `json:"id"`
|
|
Title string `json:"title"`
|
|
OriginalTitle string `json:"original_title"`
|
|
Overview string `json:"overview"`
|
|
ReleaseDate string `json:"release_date"`
|
|
PosterPath string `json:"poster_path"`
|
|
BackdropPath string `json:"backdrop_path"`
|
|
VoteAverage float64 `json:"vote_average"`
|
|
Runtime int `json:"runtime"`
|
|
Genres []tmdbGenre `json:"genres"`
|
|
ExternalIDs tmdbExternalIDs `json:"external_ids"`
|
|
}
|
|
|
|
type tmdbTVDetail struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
OriginalName string `json:"original_name"`
|
|
Overview string `json:"overview"`
|
|
FirstAirDate string `json:"first_air_date"`
|
|
PosterPath string `json:"poster_path"`
|
|
BackdropPath string `json:"backdrop_path"`
|
|
VoteAverage float64 `json:"vote_average"`
|
|
NumberOfSeasons int `json:"number_of_seasons"`
|
|
NumberOfEpisodes int `json:"number_of_episodes"`
|
|
Genres []tmdbGenre `json:"genres"`
|
|
ExternalIDs tmdbExternalIDs `json:"external_ids"`
|
|
}
|
|
|
|
type tmdbGenre struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type tmdbExternalIDs struct {
|
|
IMDbID string `json:"imdb_id"`
|
|
TVDBID string `json:"tvdb_id"`
|
|
}
|
|
|
|
type TMDBFullDetail struct {
|
|
ID int `json:"id"`
|
|
Title string `json:"title"`
|
|
Name string `json:"name"`
|
|
Overview string `json:"overview"`
|
|
PosterPath string `json:"poster_path"`
|
|
BackdropPath string `json:"backdrop_path"`
|
|
ReleaseDate string `json:"release_date"`
|
|
FirstAirDate string `json:"first_air_date"`
|
|
VoteAverage float64 `json:"vote_average"`
|
|
Genres []tmdbGenre `json:"genres"`
|
|
ExternalIDs tmdbExtIDs `json:"external_ids"`
|
|
NumberOfSeasons int `json:"number_of_seasons"`
|
|
NumberOfEpisodes int `json:"number_of_episodes"`
|
|
Runtime int `json:"runtime"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type tmdbExtIDs struct {
|
|
IMDbID string `json:"imdb_id"`
|
|
TVDBID string `json:"tvdb_id"`
|
|
}
|
|
|
|
type tmdbImage struct {
|
|
FilePath string `json:"file_path"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
AspectRatio float64 `json:"aspect_ratio"`
|
|
VoteAverage float64 `json:"vote_average"`
|
|
}
|
|
|
|
type tmdbImagesResponse struct {
|
|
Backdrops []tmdbImage `json:"backdrops"`
|
|
Posters []tmdbImage `json:"posters"`
|
|
}
|
|
|
|
func (p *TMDBProvider) Name() string {
|
|
return "tmdb"
|
|
}
|
|
|
|
func (p *TMDBProvider) fetchTMDB(ctx context.Context, url string, result interface{}) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch tmdb: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("tmdb api returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
|
|
return fmt.Errorf("decode tmdb response: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func parseTMDBYear(dateStr string) *int {
|
|
if dateStr == "" {
|
|
return nil
|
|
}
|
|
if len(dateStr) < 4 {
|
|
return nil
|
|
}
|
|
year, err := strconv.Atoi(dateStr[:4])
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &year
|
|
}
|
|
|
|
func (p *TMDBProvider) Search(ctx context.Context, query string, opts SearchOptions) ([]MetadataSearchResult, error) {
|
|
url := fmt.Sprintf("%s/search/multi?api_key=%s&query=%s", p.baseURL, p.apiKey, query)
|
|
if opts.Page > 0 {
|
|
url += fmt.Sprintf("&page=%d", opts.Page)
|
|
}
|
|
if opts.Year != nil {
|
|
url += fmt.Sprintf("&year=%d", *opts.Year)
|
|
}
|
|
|
|
var resp tmdbSearchResponse
|
|
if err := p.fetchTMDB(ctx, url, &resp); err != nil {
|
|
return nil, fmt.Errorf("search tmdb: %w", err)
|
|
}
|
|
|
|
var results []MetadataSearchResult
|
|
for _, item := range resp.Results {
|
|
if item.MediaType != "movie" && item.MediaType != "tv" {
|
|
continue
|
|
}
|
|
|
|
title := item.Title
|
|
origTitle := item.OriginalTitle
|
|
dateStr := item.ReleaseDate
|
|
mediaType := "movie"
|
|
|
|
if item.MediaType == "tv" {
|
|
title = item.Name
|
|
origTitle = item.OriginalName
|
|
dateStr = item.FirstAirDate
|
|
mediaType = "series"
|
|
}
|
|
|
|
year := parseTMDBYear(dateStr)
|
|
|
|
externalIDs, _ := json.Marshal(map[string]string{
|
|
"tmdb": strconv.Itoa(item.ID),
|
|
})
|
|
|
|
overview := item.Overview
|
|
|
|
results = append(results, MetadataSearchResult{
|
|
ProviderID: strconv.Itoa(item.ID),
|
|
Title: title,
|
|
Year: year,
|
|
MediaType: mediaType,
|
|
Overview: overview,
|
|
OriginalTitle: origTitle,
|
|
ExternalIDs: externalIDs,
|
|
})
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func (p *TMDBProvider) GetDetails(ctx context.Context, id string) (*MetadataDetails, error) {
|
|
movieURL := fmt.Sprintf("%s/movie/%s?api_key=%s&append_to_response=external_ids", p.baseURL, id, p.apiKey)
|
|
|
|
var movieDetail tmdbMovieDetail
|
|
err := p.fetchTMDB(ctx, movieURL, &movieDetail)
|
|
if err == nil {
|
|
ratings, _ := json.Marshal(map[string]float64{
|
|
"tmdb": movieDetail.VoteAverage,
|
|
})
|
|
|
|
extIDs := map[string]string{
|
|
"tmdb": strconv.Itoa(movieDetail.ID),
|
|
}
|
|
if movieDetail.ExternalIDs.IMDbID != "" {
|
|
extIDs["imdb"] = movieDetail.ExternalIDs.IMDbID
|
|
}
|
|
if movieDetail.ExternalIDs.TVDBID != "" {
|
|
extIDs["tvdb"] = movieDetail.ExternalIDs.TVDBID
|
|
}
|
|
extIDsJSON, _ := json.Marshal(extIDs)
|
|
|
|
var genreNames []string
|
|
for _, g := range movieDetail.Genres {
|
|
genreNames = append(genreNames, g.Name)
|
|
}
|
|
|
|
metadata, _ := json.Marshal(map[string]interface{}{
|
|
"runtime": movieDetail.Runtime,
|
|
"genres": genreNames,
|
|
"release_date": movieDetail.ReleaseDate,
|
|
})
|
|
|
|
overview := movieDetail.Overview
|
|
origTitle := movieDetail.OriginalTitle
|
|
|
|
return &MetadataDetails{
|
|
ProviderID: id,
|
|
Title: movieDetail.Title,
|
|
OriginalTitle: &origTitle,
|
|
Overview: &overview,
|
|
Year: parseTMDBYear(movieDetail.ReleaseDate),
|
|
Ratings: ratings,
|
|
ExternalIDs: extIDsJSON,
|
|
Metadata: metadata,
|
|
}, nil
|
|
}
|
|
|
|
tvURL := fmt.Sprintf("%s/tv/%s?api_key=%s&append_to_response=external_ids", p.baseURL, id, p.apiKey)
|
|
|
|
var tvDetail tmdbTVDetail
|
|
if err := p.fetchTMDB(ctx, tvURL, &tvDetail); err != nil {
|
|
slog.Error("tmdb get details failed for both movie and tv", "id", id, "error", err)
|
|
return nil, fmt.Errorf("get tmdb details: %w", err)
|
|
}
|
|
|
|
ratings, _ := json.Marshal(map[string]float64{
|
|
"tmdb": tvDetail.VoteAverage,
|
|
})
|
|
|
|
extIDs := map[string]string{
|
|
"tmdb": strconv.Itoa(tvDetail.ID),
|
|
}
|
|
if tvDetail.ExternalIDs.IMDbID != "" {
|
|
extIDs["imdb"] = tvDetail.ExternalIDs.IMDbID
|
|
}
|
|
if tvDetail.ExternalIDs.TVDBID != "" {
|
|
extIDs["tvdb"] = tvDetail.ExternalIDs.TVDBID
|
|
}
|
|
extIDsJSON, _ := json.Marshal(extIDs)
|
|
|
|
var genreNames []string
|
|
for _, g := range tvDetail.Genres {
|
|
genreNames = append(genreNames, g.Name)
|
|
}
|
|
|
|
metadata, _ := json.Marshal(map[string]interface{}{
|
|
"number_of_seasons": tvDetail.NumberOfSeasons,
|
|
"number_of_episodes": tvDetail.NumberOfEpisodes,
|
|
"genres": genreNames,
|
|
"first_air_date": tvDetail.FirstAirDate,
|
|
})
|
|
|
|
overview := tvDetail.Overview
|
|
origTitle := tvDetail.OriginalName
|
|
|
|
return &MetadataDetails{
|
|
ProviderID: id,
|
|
Title: tvDetail.Name,
|
|
OriginalTitle: &origTitle,
|
|
Overview: &overview,
|
|
Year: parseTMDBYear(tvDetail.FirstAirDate),
|
|
Ratings: ratings,
|
|
ExternalIDs: extIDsJSON,
|
|
Metadata: metadata,
|
|
}, nil
|
|
}
|
|
|
|
func (p *TMDBProvider) GetImages(ctx context.Context, id string) ([]ImageResult, error) {
|
|
var images []ImageResult
|
|
|
|
movieURL := fmt.Sprintf("%s/movie/%s/images?api_key=%s", p.baseURL, id, p.apiKey)
|
|
var movieImages tmdbImagesResponse
|
|
err := p.fetchTMDB(ctx, movieURL, &movieImages)
|
|
if err != nil {
|
|
tvURL := fmt.Sprintf("%s/tv/%s/images?api_key=%s", p.baseURL, id, p.apiKey)
|
|
var tvImages tmdbImagesResponse
|
|
if err := p.fetchTMDB(ctx, tvURL, &tvImages); err != nil {
|
|
return nil, fmt.Errorf("get tmdb images: %w", err)
|
|
}
|
|
movieImages = tvImages
|
|
}
|
|
|
|
for _, img := range movieImages.Posters {
|
|
images = append(images, ImageResult{
|
|
URL: fmt.Sprintf("https://image.tmdb.org/t/p/original%s", img.FilePath),
|
|
Type: "poster",
|
|
Width: img.Width,
|
|
Height: img.Height,
|
|
})
|
|
}
|
|
|
|
for _, img := range movieImages.Backdrops {
|
|
images = append(images, ImageResult{
|
|
URL: fmt.Sprintf("https://image.tmdb.org/t/p/original%s", img.FilePath),
|
|
Type: "backdrop",
|
|
Width: img.Width,
|
|
Height: img.Height,
|
|
})
|
|
}
|
|
|
|
return images, nil
|
|
}
|
|
|
|
// Trending fetches trending items from TMDB for the given media type ("movie" or "tv").
|
|
func (p *TMDBProvider) Trending(ctx context.Context, mediaType string, page int) ([]tmdbSearchItem, error) {
|
|
tmdbType := mediaType
|
|
if tmdbType == "series" {
|
|
tmdbType = "tv"
|
|
}
|
|
url := fmt.Sprintf("%s/trending/%s/week?api_key=%s&page=%d", p.baseURL, tmdbType, p.apiKey, page)
|
|
|
|
var resp tmdbSearchResponse
|
|
if err := p.fetchTMDB(ctx, url, &resp); err != nil {
|
|
return nil, fmt.Errorf("fetch trending: %w", err)
|
|
}
|
|
return resp.Results, nil
|
|
}
|
|
|
|
// Popular fetches popular items from TMDB for the given media type ("movie" or "tv").
|
|
func (p *TMDBProvider) Popular(ctx context.Context, mediaType string, page int) ([]tmdbSearchItem, error) {
|
|
tmdbType := mediaType
|
|
if tmdbType == "series" {
|
|
tmdbType = "tv"
|
|
}
|
|
url := fmt.Sprintf("%s/%s/popular?api_key=%s&page=%d", p.baseURL, tmdbType, p.apiKey, page)
|
|
|
|
var resp tmdbSearchResponse
|
|
if err := p.fetchTMDB(ctx, url, &resp); err != nil {
|
|
return nil, fmt.Errorf("fetch popular: %w", err)
|
|
}
|
|
return resp.Results, nil
|
|
}
|
|
|
|
// GetMovieDetails fetches full movie details from TMDB including credits and external IDs.
|
|
func (p *TMDBProvider) GetMovieDetails(ctx context.Context, id string) (*TMDBFullDetail, error) {
|
|
url := fmt.Sprintf("%s/movie/%s?api_key=%s&append_to_response=external_ids,credits", p.baseURL, id, p.apiKey)
|
|
|
|
var detail TMDBFullDetail
|
|
if err := p.fetchTMDB(ctx, url, &detail); err != nil {
|
|
return nil, fmt.Errorf("fetch movie details: %w", err)
|
|
}
|
|
return &detail, nil
|
|
}
|
|
|
|
// GetTVDetails fetches full TV show details from TMDB including credits and external IDs.
|
|
func (p *TMDBProvider) GetTVDetails(ctx context.Context, id string) (*TMDBFullDetail, error) {
|
|
url := fmt.Sprintf("%s/tv/%s?api_key=%s&append_to_response=external_ids,credits", p.baseURL, id, p.apiKey)
|
|
|
|
var detail TMDBFullDetail
|
|
if err := p.fetchTMDB(ctx, url, &detail); err != nil {
|
|
return nil, fmt.Errorf("fetch tv details: %w", err)
|
|
}
|
|
return &detail, nil
|
|
}
|