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