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 }