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

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
}