refactor tmdb provider by responsibility

This commit is contained in:
ShukeBta
2026-06-26 20:00:42 +08:00
parent bc5eed4dec
commit a28438fac6
6 changed files with 418 additions and 390 deletions
-390
View File
@@ -20,8 +20,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strings"
"time" "time"
"go.uber.org/zap" "go.uber.org/zap"
@@ -120,29 +118,6 @@ func (t *TMDbProvider) resolveBaseURL(ctx context.Context) string {
return base return base
} }
// Match describes a successful metadata match. The same struct is reused
// across providers; provider-specific IDs sit side-by-side so the scraper
// orchestrator can write them all into a single update.
type Match struct {
TMDbID int `json:"tmdb_id"`
BangumiID int `json:"bangumi_id"`
DoubanID string `json:"douban_id,omitempty"`
TheTVDBID string `json:"thetvdb_id,omitempty"`
MediaType string `json:"media_type,omitempty"`
Title string `json:"title"`
OriginalName string `json:"original_name,omitempty"`
Overview string `json:"overview"`
PosterURL string `json:"poster_url"`
BackdropURL string `json:"backdrop_url"`
Year int `json:"year"`
Rating float32 `json:"rating"`
Languages []string `json:"languages,omitempty"`
Countries []string `json:"countries,omitempty"`
Genres []string `json:"genres,omitempty"`
NSFW bool `json:"nsfw,omitempty"`
SearchKeyword string `json:"-"`
}
func (t *TMDbProvider) getJSON(ctx context.Context, url string, out any) error { func (t *TMDbProvider) getJSON(ctx context.Context, url string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
@@ -158,368 +133,3 @@ func (t *TMDbProvider) getJSON(ctx context.Context, url string, out any) error {
} }
return json.NewDecoder(resp.Body).Decode(out) return json.NewDecoder(resp.Body).Decode(out)
} }
func (t *TMDbProvider) GetMovieMatch(ctx context.Context, tmdbID int) (*Match, error) {
if tmdbID <= 0 {
return nil, nil
}
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return nil, nil
}
base := t.resolveBaseURL(ctx)
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("language", "zh-CN")
u := base + "/movie/" + fmt.Sprint(tmdbID) + "?" + q.Encode()
var r struct {
ID int `json:"id"`
Title string `json:"title"`
OriginalTitle string `json:"original_title"`
OriginalLanguage string `json:"original_language"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date"`
VoteAverage float32 `json:"vote_average"`
Genres []struct {
Name string `json:"name"`
} `json:"genres"`
ProductionCountries []struct {
Iso3166_1 string `json:"iso_3166_1"`
} `json:"production_countries"`
SpokenLanguages []struct {
Iso639_1 string `json:"iso_639_1"`
} `json:"spoken_languages"`
}
if err := t.getJSON(ctx, u, &r); err != nil {
return nil, err
}
m := &Match{
TMDbID: r.ID,
MediaType: "movie",
Title: r.Title,
OriginalName: r.OriginalTitle,
Overview: r.Overview,
Rating: r.VoteAverage,
Languages: nonEmptyStrings(r.OriginalLanguage),
}
if m.Title == "" {
m.Title = r.OriginalTitle
}
if r.PosterPath != "" {
m.PosterURL = t.imgCDN + "/w500" + r.PosterPath
}
if r.BackdropPath != "" {
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
}
if len(r.ReleaseDate) >= 4 {
_, _ = fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year)
}
for _, g := range r.Genres {
m.Genres = append(m.Genres, g.Name)
}
for _, c := range r.ProductionCountries {
m.Countries = append(m.Countries, c.Iso3166_1)
}
for _, l := range r.SpokenLanguages {
m.Languages = append(m.Languages, l.Iso639_1)
}
m.Genres = deduplicate(m.Genres)
m.Countries = deduplicate(m.Countries)
m.Languages = deduplicate(m.Languages)
return m, nil
}
func (t *TMDbProvider) GetTVMatch(ctx context.Context, tmdbID int) (*Match, error) {
if tmdbID <= 0 {
return nil, nil
}
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return nil, nil
}
base := t.resolveBaseURL(ctx)
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("language", "zh-CN")
u := base + "/tv/" + fmt.Sprint(tmdbID) + "?" + q.Encode()
var r struct {
ID int `json:"id"`
Name string `json:"name"`
OriginalName string `json:"original_name"`
OriginalLanguage string `json:"original_language"`
OriginCountry []string `json:"origin_country"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"`
VoteAverage float32 `json:"vote_average"`
Genres []struct {
Name string `json:"name"`
} `json:"genres"`
SpokenLanguages []struct {
Iso639_1 string `json:"iso_639_1"`
} `json:"spoken_languages"`
}
if err := t.getJSON(ctx, u, &r); err != nil {
return nil, err
}
m := &Match{
TMDbID: r.ID,
MediaType: "tv",
Title: r.Name,
OriginalName: r.OriginalName,
Overview: r.Overview,
Rating: r.VoteAverage,
Languages: nonEmptyStrings(r.OriginalLanguage),
Countries: deduplicate(r.OriginCountry),
}
if m.Title == "" {
m.Title = r.OriginalName
}
if r.PosterPath != "" {
m.PosterURL = t.imgCDN + "/w500" + r.PosterPath
}
if r.BackdropPath != "" {
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
}
if len(r.FirstAirDate) >= 4 {
_, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year)
}
for _, g := range r.Genres {
m.Genres = append(m.Genres, g.Name)
}
for _, l := range r.SpokenLanguages {
m.Languages = append(m.Languages, l.Iso639_1)
}
m.Genres = deduplicate(m.Genres)
m.Languages = deduplicate(m.Languages)
return m, nil
}
// TMDbEpisodeDetails holds per-episode metadata from /tv/{id}/season/{season}/episode/{episode}.
type TMDbEpisodeDetails struct {
Name string
Overview string
StillURL string
AirYear int
Rating float32
Runtime int
}
func (t *TMDbProvider) GetTVEpisodeDetails(ctx context.Context, tmdbID, season, episode int) (*TMDbEpisodeDetails, error) {
if tmdbID <= 0 || episode <= 0 {
return nil, nil
}
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return nil, nil
}
base := t.resolveBaseURL(ctx)
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("language", "zh-CN")
u := base + "/tv/" + fmt.Sprint(tmdbID) + "/season/" + fmt.Sprint(season) + "/episode/" + fmt.Sprint(episode) + "?" + q.Encode()
var r struct {
Name string `json:"name"`
Overview string `json:"overview"`
StillPath string `json:"still_path"`
AirDate string `json:"air_date"`
VoteAverage float32 `json:"vote_average"`
Runtime int `json:"runtime"`
}
if err := t.getJSON(ctx, u, &r); err != nil {
return nil, err
}
details := &TMDbEpisodeDetails{
Name: r.Name,
Overview: r.Overview,
Rating: r.VoteAverage,
Runtime: r.Runtime,
}
if r.StillPath != "" {
details.StillURL = t.imgCDN + "/w500" + r.StillPath
}
if len(r.AirDate) >= 4 {
_, _ = fmt.Sscanf(r.AirDate[:4], "%d", &details.AirYear)
}
return details, nil
}
// TMDbDetails holds extended metadata from the /movie/{id} or /tv/{id} endpoints.
type TMDbDetails struct {
Languages []string `json:"languages"`
Countries []string `json:"countries"`
Genres []string `json:"genres"`
}
// GetDetails fetches extended metadata for a TMDb ID.
// It calls /movie/{id} or /tv/{id} with append_to_response=genres
// and extracts languages, production countries, and genres.
// mediaType should be "movie" or "tv".
func (t *TMDbProvider) GetDetails(ctx context.Context, tmdbID int, mediaType string) (*TMDbDetails, error) {
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return nil, fmt.Errorf("tmdb: no API key available")
}
base := t.resolveBaseURL(ctx)
path := "/movie/" + fmt.Sprint(tmdbID)
if mediaType == "tv" {
path = "/tv/" + fmt.Sprint(tmdbID)
}
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("language", "zh-CN")
q.Set("append_to_response", "genres")
u := base + path + "?" + q.Encode()
// Response structs for /movie/{id} and /tv/{id}
type genre struct {
Name string `json:"name"`
}
type movieResult struct {
OriginalLanguage string `json:"original_language"`
ProductionCountries []struct {
Iso3166_1 string `json:"iso_3166_1"`
} `json:"production_countries"`
SpokenLanguages []struct {
Iso639_1 string `json:"iso_639_1"`
} `json:"spoken_languages"`
Genres []genre `json:"genres"`
}
type tvResult struct {
OriginCountry []string `json:"origin_country"`
SpokenLanguages []struct {
Iso639_1 string `json:"iso_639_1"`
} `json:"spoken_languages"`
Genres []genre `json:"genres"`
}
var (
languages []string
countries []string
genres []string
)
if mediaType == "tv" {
var r tvResult
if err := t.getJSON(ctx, u, &r); err != nil {
return nil, err
}
// Spoken languages
for _, l := range r.SpokenLanguages {
languages = append(languages, l.Iso639_1)
}
// Origin countries
countries = append(countries, r.OriginCountry...)
// Genres
for _, g := range r.Genres {
genres = append(genres, g.Name)
}
} else {
var r movieResult
if err := t.getJSON(ctx, u, &r); err != nil {
return nil, err
}
// Original language
if r.OriginalLanguage != "" {
languages = append(languages, r.OriginalLanguage)
}
// Spoken languages
for _, l := range r.SpokenLanguages {
languages = append(languages, l.Iso639_1)
}
// Production countries
for _, c := range r.ProductionCountries {
countries = append(countries, c.Iso3166_1)
}
// Genres
for _, g := range r.Genres {
genres = append(genres, g.Name)
}
}
// Deduplicate
languages = deduplicate(languages)
countries = deduplicate(countries)
genres = deduplicate(genres)
t.log.Debug("tmdb: getDetails",
zap.Int("tmdb_id", tmdbID),
zap.String("type", mediaType),
zap.Strings("languages", languages),
zap.Strings("countries", countries),
zap.Strings("genres", genres),
)
return &TMDbDetails{
Languages: languages,
Countries: countries,
Genres: genres,
}, nil
}
func (t *TMDbProvider) GetTVEpisodeCount(ctx context.Context, tmdbID int) (int, error) {
if tmdbID <= 0 {
return 0, nil
}
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return 0, nil
}
base := t.resolveBaseURL(ctx)
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("language", "zh-CN")
u := base + "/tv/" + fmt.Sprint(tmdbID) + "?" + q.Encode()
var r struct {
NumberOfEpisodes int `json:"number_of_episodes"`
}
if err := t.getJSON(ctx, u, &r); err != nil {
return 0, err
}
return r.NumberOfEpisodes, nil
}
// deduplicate removes duplicates from a string slice.
func deduplicate(s []string) []string {
if len(s) == 0 {
return s
}
seen := make(map[string]bool, len(s))
out := make([]string, 0, len(s))
for _, v := range s {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
func nonEmptyStrings(values ...string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
out = append(out, value)
}
}
return out
}
func genreIDStrings(ids []int) []string {
if len(ids) == 0 {
return nil
}
out := make([]string, 0, len(ids))
for _, id := range ids {
if id > 0 {
out = append(out, fmt.Sprint(id))
}
}
return out
}
+117
View File
@@ -0,0 +1,117 @@
package service
import (
"context"
"fmt"
"net/url"
"go.uber.org/zap"
)
// GetDetails fetches extended metadata for a TMDb ID.
// It calls /movie/{id} or /tv/{id} with append_to_response=genres
// and extracts languages, production countries, and genres.
// mediaType should be "movie" or "tv".
func (t *TMDbProvider) GetDetails(ctx context.Context, tmdbID int, mediaType string) (*TMDbDetails, error) {
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return nil, fmt.Errorf("tmdb: no API key available")
}
base := t.resolveBaseURL(ctx)
path := "/movie/" + fmt.Sprint(tmdbID)
if mediaType == "tv" {
path = "/tv/" + fmt.Sprint(tmdbID)
}
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("language", "zh-CN")
q.Set("append_to_response", "genres")
u := base + path + "?" + q.Encode()
// Response structs for /movie/{id} and /tv/{id}
type genre struct {
Name string `json:"name"`
}
type movieResult struct {
OriginalLanguage string `json:"original_language"`
ProductionCountries []struct {
Iso3166_1 string `json:"iso_3166_1"`
} `json:"production_countries"`
SpokenLanguages []struct {
Iso639_1 string `json:"iso_639_1"`
} `json:"spoken_languages"`
Genres []genre `json:"genres"`
}
type tvResult struct {
OriginCountry []string `json:"origin_country"`
SpokenLanguages []struct {
Iso639_1 string `json:"iso_639_1"`
} `json:"spoken_languages"`
Genres []genre `json:"genres"`
}
var (
languages []string
countries []string
genres []string
)
if mediaType == "tv" {
var r tvResult
if err := t.getJSON(ctx, u, &r); err != nil {
return nil, err
}
// Spoken languages
for _, l := range r.SpokenLanguages {
languages = append(languages, l.Iso639_1)
}
// Origin countries
countries = append(countries, r.OriginCountry...)
// Genres
for _, g := range r.Genres {
genres = append(genres, g.Name)
}
} else {
var r movieResult
if err := t.getJSON(ctx, u, &r); err != nil {
return nil, err
}
// Original language
if r.OriginalLanguage != "" {
languages = append(languages, r.OriginalLanguage)
}
// Spoken languages
for _, l := range r.SpokenLanguages {
languages = append(languages, l.Iso639_1)
}
// Production countries
for _, c := range r.ProductionCountries {
countries = append(countries, c.Iso3166_1)
}
// Genres
for _, g := range r.Genres {
genres = append(genres, g.Name)
}
}
// Deduplicate
languages = deduplicate(languages)
countries = deduplicate(countries)
genres = deduplicate(genres)
t.log.Debug("tmdb: getDetails",
zap.Int("tmdb_id", tmdbID),
zap.String("type", mediaType),
zap.Strings("languages", languages),
zap.Strings("countries", countries),
zap.Strings("genres", genres),
)
return &TMDbDetails{
Languages: languages,
Countries: countries,
Genres: genres,
}, nil
}
+68
View File
@@ -0,0 +1,68 @@
package service
import (
"context"
"fmt"
"net/url"
)
func (t *TMDbProvider) GetTVEpisodeDetails(ctx context.Context, tmdbID, season, episode int) (*TMDbEpisodeDetails, error) {
if tmdbID <= 0 || episode <= 0 {
return nil, nil
}
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return nil, nil
}
base := t.resolveBaseURL(ctx)
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("language", "zh-CN")
u := base + "/tv/" + fmt.Sprint(tmdbID) + "/season/" + fmt.Sprint(season) + "/episode/" + fmt.Sprint(episode) + "?" + q.Encode()
var r struct {
Name string `json:"name"`
Overview string `json:"overview"`
StillPath string `json:"still_path"`
AirDate string `json:"air_date"`
VoteAverage float32 `json:"vote_average"`
Runtime int `json:"runtime"`
}
if err := t.getJSON(ctx, u, &r); err != nil {
return nil, err
}
details := &TMDbEpisodeDetails{
Name: r.Name,
Overview: r.Overview,
Rating: r.VoteAverage,
Runtime: r.Runtime,
}
if r.StillPath != "" {
details.StillURL = t.imgCDN + "/w500" + r.StillPath
}
if len(r.AirDate) >= 4 {
_, _ = fmt.Sscanf(r.AirDate[:4], "%d", &details.AirYear)
}
return details, nil
}
func (t *TMDbProvider) GetTVEpisodeCount(ctx context.Context, tmdbID int) (int, error) {
if tmdbID <= 0 {
return 0, nil
}
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return 0, nil
}
base := t.resolveBaseURL(ctx)
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("language", "zh-CN")
u := base + "/tv/" + fmt.Sprint(tmdbID) + "?" + q.Encode()
var r struct {
NumberOfEpisodes int `json:"number_of_episodes"`
}
if err := t.getJSON(ctx, u, &r); err != nil {
return 0, err
}
return r.NumberOfEpisodes, nil
}
+46
View File
@@ -0,0 +1,46 @@
package service
import (
"fmt"
"strings"
)
// deduplicate removes duplicates from a string slice.
func deduplicate(s []string) []string {
if len(s) == 0 {
return s
}
seen := make(map[string]bool, len(s))
out := make([]string, 0, len(s))
for _, v := range s {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
func nonEmptyStrings(values ...string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
out = append(out, value)
}
}
return out
}
func genreIDStrings(ids []int) []string {
if len(ids) == 0 {
return nil
}
out := make([]string, 0, len(ids))
for _, id := range ids {
if id > 0 {
out = append(out, fmt.Sprint(id))
}
}
return out
}
+146
View File
@@ -0,0 +1,146 @@
package service
import (
"context"
"fmt"
"net/url"
)
func (t *TMDbProvider) GetMovieMatch(ctx context.Context, tmdbID int) (*Match, error) {
if tmdbID <= 0 {
return nil, nil
}
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return nil, nil
}
base := t.resolveBaseURL(ctx)
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("language", "zh-CN")
u := base + "/movie/" + fmt.Sprint(tmdbID) + "?" + q.Encode()
var r struct {
ID int `json:"id"`
Title string `json:"title"`
OriginalTitle string `json:"original_title"`
OriginalLanguage string `json:"original_language"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date"`
VoteAverage float32 `json:"vote_average"`
Genres []struct {
Name string `json:"name"`
} `json:"genres"`
ProductionCountries []struct {
Iso3166_1 string `json:"iso_3166_1"`
} `json:"production_countries"`
SpokenLanguages []struct {
Iso639_1 string `json:"iso_639_1"`
} `json:"spoken_languages"`
}
if err := t.getJSON(ctx, u, &r); err != nil {
return nil, err
}
m := &Match{
TMDbID: r.ID,
MediaType: "movie",
Title: r.Title,
OriginalName: r.OriginalTitle,
Overview: r.Overview,
Rating: r.VoteAverage,
Languages: nonEmptyStrings(r.OriginalLanguage),
}
if m.Title == "" {
m.Title = r.OriginalTitle
}
if r.PosterPath != "" {
m.PosterURL = t.imgCDN + "/w500" + r.PosterPath
}
if r.BackdropPath != "" {
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
}
if len(r.ReleaseDate) >= 4 {
_, _ = fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year)
}
for _, g := range r.Genres {
m.Genres = append(m.Genres, g.Name)
}
for _, c := range r.ProductionCountries {
m.Countries = append(m.Countries, c.Iso3166_1)
}
for _, l := range r.SpokenLanguages {
m.Languages = append(m.Languages, l.Iso639_1)
}
m.Genres = deduplicate(m.Genres)
m.Countries = deduplicate(m.Countries)
m.Languages = deduplicate(m.Languages)
return m, nil
}
func (t *TMDbProvider) GetTVMatch(ctx context.Context, tmdbID int) (*Match, error) {
if tmdbID <= 0 {
return nil, nil
}
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return nil, nil
}
base := t.resolveBaseURL(ctx)
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("language", "zh-CN")
u := base + "/tv/" + fmt.Sprint(tmdbID) + "?" + q.Encode()
var r struct {
ID int `json:"id"`
Name string `json:"name"`
OriginalName string `json:"original_name"`
OriginalLanguage string `json:"original_language"`
OriginCountry []string `json:"origin_country"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"`
VoteAverage float32 `json:"vote_average"`
Genres []struct {
Name string `json:"name"`
} `json:"genres"`
SpokenLanguages []struct {
Iso639_1 string `json:"iso_639_1"`
} `json:"spoken_languages"`
}
if err := t.getJSON(ctx, u, &r); err != nil {
return nil, err
}
m := &Match{
TMDbID: r.ID,
MediaType: "tv",
Title: r.Name,
OriginalName: r.OriginalName,
Overview: r.Overview,
Rating: r.VoteAverage,
Languages: nonEmptyStrings(r.OriginalLanguage),
Countries: deduplicate(r.OriginCountry),
}
if m.Title == "" {
m.Title = r.OriginalName
}
if r.PosterPath != "" {
m.PosterURL = t.imgCDN + "/w500" + r.PosterPath
}
if r.BackdropPath != "" {
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
}
if len(r.FirstAirDate) >= 4 {
_, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year)
}
for _, g := range r.Genres {
m.Genres = append(m.Genres, g.Name)
}
for _, l := range r.SpokenLanguages {
m.Languages = append(m.Languages, l.Iso639_1)
}
m.Genres = deduplicate(m.Genres)
m.Languages = deduplicate(m.Languages)
return m, nil
}
+41
View File
@@ -0,0 +1,41 @@
package service
// Match describes a successful metadata match. The same struct is reused
// across providers; provider-specific IDs sit side-by-side so the scraper
// orchestrator can write them all into a single update.
type Match struct {
TMDbID int `json:"tmdb_id"`
BangumiID int `json:"bangumi_id"`
DoubanID string `json:"douban_id,omitempty"`
TheTVDBID string `json:"thetvdb_id,omitempty"`
MediaType string `json:"media_type,omitempty"`
Title string `json:"title"`
OriginalName string `json:"original_name,omitempty"`
Overview string `json:"overview"`
PosterURL string `json:"poster_url"`
BackdropURL string `json:"backdrop_url"`
Year int `json:"year"`
Rating float32 `json:"rating"`
Languages []string `json:"languages,omitempty"`
Countries []string `json:"countries,omitempty"`
Genres []string `json:"genres,omitempty"`
NSFW bool `json:"nsfw,omitempty"`
SearchKeyword string `json:"-"`
}
// TMDbEpisodeDetails holds per-episode metadata from /tv/{id}/season/{season}/episode/{episode}.
type TMDbEpisodeDetails struct {
Name string
Overview string
StillURL string
AirYear int
Rating float32
Runtime int
}
// TMDbDetails holds extended metadata from the /movie/{id} or /tv/{id} endpoints.
type TMDbDetails struct {
Languages []string `json:"languages"`
Countries []string `json:"countries"`
Genres []string `json:"genres"`
}