diff options
| author | Bobby <[email protected]> | 2026-01-20 18:00:43 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-01-20 18:00:43 +0530 |
| commit | df6cf3edcbb560e7615ad13d8daf4843507eb11e (patch) | |
| tree | 28856a2023a07b04b8ecdcadc581bd7ec03cfabc | |
| parent | 9df5dd0018d942ae1208308a60589e1124e6bc66 (diff) | |
| download | metachan-df6cf3edcbb560e7615ad13d8daf4843507eb11e.tar.xz metachan-df6cf3edcbb560e7615ad13d8daf4843507eb11e.zip | |
Add TVDB integration for episode retrieval and configuration setup
| -rw-r--r-- | config/config.go | 7 | ||||
| -rw-r--r-- | services/anime/helpers.go | 4 | ||||
| -rw-r--r-- | services/anime/service.go | 105 | ||||
| -rw-r--r-- | types/server.go | 5 | ||||
| -rw-r--r-- | utils/api/tmdb/tmdb.go | 203 | ||||
| -rw-r--r-- | utils/api/tmdb/types.go | 27 | ||||
| -rw-r--r-- | utils/api/tvdb/tvdb.go | 185 | ||||
| -rw-r--r-- | utils/api/tvdb/types.go | 43 |
8 files changed, 558 insertions, 21 deletions
diff --git a/config/config.go b/config/config.go index c70560d..b0e9eee 100644 --- a/config/config.go +++ b/config/config.go @@ -29,6 +29,9 @@ func init() { APIKey: getEnv("TMDB_API_KEY"), ReadAccessToken: getEnv("TMDB_READ_ACCESS_TOKEN"), }, + TVDB: types.TVDBConfig{ + APIKey: getEnv("TVDB_API_KEY"), + }, } switch Config.DatabaseDriver { @@ -53,6 +56,10 @@ func init() { logger.Log("Invalid TMDB read access token or TMDB read access token not set", logOptions) } + if Config.TVDB.APIKey == "" { + logger.Log("Invalid TVDB API key or TVDB API key not set", logOptions) + } + logOptions.Level = logger.Success logOptions.Fatal = false logger.Log("Config initialized successfully", logOptions) diff --git a/services/anime/helpers.go b/services/anime/helpers.go index 932dbc2..2c81116 100644 --- a/services/anime/helpers.go +++ b/services/anime/helpers.go @@ -9,7 +9,7 @@ import ( "metachan/utils/api/jikan" "metachan/utils/api/malsync" "metachan/utils/api/tmdb" - api "metachan/utils/api/tvdb" + "metachan/utils/api/tvdb" "metachan/utils/logger" "net/http" "strings" @@ -551,4 +551,4 @@ func extractCrunchyrollSeriesID(crURL string) string { } // FindSeasonMappings finds all anime mappings that belong to the same series based on TVDB ID -var FindSeasonMappings = api.FindSeasonMappings +var FindSeasonMappings = tvdb.FindSeasonMappings diff --git a/services/anime/service.go b/services/anime/service.go index 34a32df..553c6fd 100644 --- a/services/anime/service.go +++ b/services/anime/service.go @@ -9,6 +9,8 @@ import ( "metachan/utils/api/jikan" "metachan/utils/api/malsync" "metachan/utils/api/streaming" + "metachan/utils/api/tmdb" + "metachan/utils/api/tvdb" "metachan/utils/concurrency" "metachan/utils/logger" "strings" @@ -198,20 +200,92 @@ func (s *Service) GetAnimeDetailsWithSource(mapping *entities.AnimeMapping, sour defer close(dubbedCountChan) defer close(tmdbErrorChan) - basicEpisodes := generateBasicEpisodes(episodes.Data) - logger.Log(fmt.Sprintf("Generated basic episodes: %d", len(basicEpisodes)), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) + var enrichedEpisodes []types.AnimeSingleEpisode + var tmdbErr error - // Enrich episodes with TMDB data - logger.Log(fmt.Sprintf("Starting enrichEpisodes for %d episodes", len(basicEpisodes)), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - enrichStart := time.Now() + // Check anime type - use different sources for movies vs TV shows + animeType := string(mapping.Type) + + if (animeType == "MOVIE" || animeType == "Movie") && mapping.TMDB != 0 { + // For movies with TMDB mapping, use TMDB to get movie details as a single episode + logger.Log(fmt.Sprintf("Detected movie type with TMDB ID %d, fetching from TMDB for: %s", mapping.TMDB, anime.Data.Title), logger.LogOptions{ + Level: logger.Debug, + Prefix: "AnimeAPI", + }) + + enrichedEpisodes, tmdbErr = tmdb.GetMovieAsEpisode( + anime.Data.Title, + anime.Data.TitleEnglish, + mapping.TMDB, + anime.Data.MALID, + anime.Data.TitleJapanese, + anime.Data.Score, + ) + if tmdbErr != nil { + logger.Log(fmt.Sprintf("Failed to get movie from TMDB: %v, falling back to basic episode", tmdbErr), logger.LogOptions{ + Level: logger.Warn, + Prefix: "AnimeAPI", + }) + // Fallback to basic episode generation + basicEpisodes := generateBasicEpisodes(episodes.Data) + enrichedEpisodes = basicEpisodes + } + } else { + // For TV shows, prefer TVDB over TMDB + var usedfallback bool + + if mapping.TVDB != 0 { + // Try TVDB first for TV shows + logger.Log(fmt.Sprintf("Using TVDB for TV show episodes (TVDB ID: %d)", mapping.TVDB), logger.LogOptions{ + Level: logger.Debug, + Prefix: "AnimeAPI", + }) + + tvdbEpisodes, tvdbErr := tvdb.GetSeriesEpisodes(mapping.TVDB) + if tvdbErr == nil && len(tvdbEpisodes) > 0 { + enrichedEpisodes = tvdb.ConvertTVDBEpisodesToAnimeEpisodes(tvdbEpisodes) + logger.Log(fmt.Sprintf("Successfully fetched %d episodes from TVDB", len(enrichedEpisodes)), logger.LogOptions{ + Level: logger.Success, + Prefix: "TVDB", + }) + } else { + logger.Log(fmt.Sprintf("TVDB fetch failed or returned no episodes: %v, falling back to TMDB", tvdbErr), logger.LogOptions{ + Level: logger.Warn, + Prefix: "TVDB", + }) + usedfallback = true + } + } else { + logger.Log("No TVDB ID available, using TMDB for episodes", logger.LogOptions{ + Level: logger.Debug, + Prefix: "AnimeAPI", + }) + usedfallback = true + } + + // Fallback to TMDB if TVDB failed or wasn't available + if usedfallback { + basicEpisodes := generateBasicEpisodes(episodes.Data) + logger.Log(fmt.Sprintf("Generated basic episodes: %d", len(basicEpisodes)), logger.LogOptions{ + Level: logger.Debug, + Prefix: "AnimeAPI", + }) + + logger.Log(fmt.Sprintf("Starting TMDB enrichment for %d episodes", len(basicEpisodes)), logger.LogOptions{ + Level: logger.Debug, + Prefix: "AnimeAPI", + }) + enrichStart := time.Now() + + enrichedEpisodes, tmdbErr = AttachEpisodeDescriptions(anime.Data.Title, basicEpisodes, anime.Data.TitleEnglish, mapping.TMDB) + + logger.Log(fmt.Sprintf("TMDB enrichment execution time: %s", time.Since(enrichStart)), logger.LogOptions{ + Level: logger.Debug, + Prefix: "AnimeAPI", + }) + } + } - enrichedEpisodes, tmdbErr := AttachEpisodeDescriptions(anime.Data.Title, basicEpisodes, anime.Data.TitleEnglish, mapping.TMDB) tmdbErrorChan <- tmdbErr // Get subbed and dubbed episode counts in bulk with a single API call (much faster) @@ -252,11 +326,6 @@ func (s *Service) GetAnimeDetailsWithSource(mapping *entities.AnimeMapping, sour Prefix: "AnimeAPI", }) - logger.Log(fmt.Sprintf("enrichEpisodes execution time: %s", time.Since(enrichStart)), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - episodeDataChan <- enrichedEpisodes subbedCountChan <- subCount dubbedCountChan <- dubCount @@ -270,7 +339,7 @@ func (s *Service) GetAnimeDetailsWithSource(mapping *entities.AnimeMapping, sour Level: logger.Debug, Prefix: "TVDB", }) - seasonMappings, err := FindSeasonMappings(mapping.TVDB) + seasonMappings, err := tvdb.FindSeasonMappings(mapping.TVDB) if err == nil && len(seasonMappings) > 0 { logger.Log(fmt.Sprintf("Found %d season mappings for TVDB ID %d", len(seasonMappings), mapping.TVDB), logger.LogOptions{ Level: logger.Debug, diff --git a/types/server.go b/types/server.go index 1c7af2b..5bb7065 100644 --- a/types/server.go +++ b/types/server.go @@ -14,9 +14,14 @@ type TMDBConfig struct { ReadAccessToken string } +type TVDBConfig struct { + APIKey string +} + type ServerConfig struct { DatabaseDriver DatabaseDriver DataSourceName string Port int TMDB TMDBConfig + TVDB TVDBConfig } diff --git a/utils/api/tmdb/tmdb.go b/utils/api/tmdb/tmdb.go index c162fa6..d4a490c 100644 --- a/utils/api/tmdb/tmdb.go +++ b/utils/api/tmdb/tmdb.go @@ -1,6 +1,7 @@ package tmdb import ( + "crypto/md5" "encoding/json" "fmt" "math" @@ -524,3 +525,205 @@ func AttachEpisodeDescriptions(title string, episodes []types.AnimeSingleEpisode return enrichedEpisodes, nil } + +// searchMoviesByTitle searches for movies on TMDB by title +func searchMoviesByTitle(title string, alternativeTitle string) ([]TMDBMovieResult, error) { + if config.Config.TMDB.ReadAccessToken == "" { + return nil, fmt.Errorf("TMDB is not initialized") + } + + query := normalizeTitle(title) + if query == "" && alternativeTitle != "" { + query = normalizeTitle(alternativeTitle) + } + + logger.Log(fmt.Sprintf("Searching TMDB for movie: %s", query), logger.LogOptions{ + Level: logger.Debug, + Prefix: "TMDB", + }) + + apiURL := "https://api.themoviedb.org/3/search/movie" + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + q := req.URL.Query() + q.Add("query", query) + req.URL.RawQuery = q.Encode() + + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.Config.TMDB.ReadAccessToken)) + req.Header.Add("Accept", "application/json") + + resp, err := makeSimpleRequest(req) + if err != nil { + return nil, fmt.Errorf("failed to search movies: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("search failed with status: %d", resp.StatusCode) + } + + var searchResp TMDBMovieSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + logger.Log(fmt.Sprintf("Found %d movie results for: %s", len(searchResp.Results), query), logger.LogOptions{ + Level: logger.Debug, + Prefix: "TMDB", + }) + + return searchResp.Results, nil +} + +// getMovieDetails fetches details for a specific movie +func getMovieDetails(movieID int) (*TMDBMovieDetails, error) { + if config.Config.TMDB.ReadAccessToken == "" { + return nil, fmt.Errorf("TMDB is not initialized") + } + + apiURL := fmt.Sprintf("https://api.themoviedb.org/3/movie/%d", movieID) + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.Config.TMDB.ReadAccessToken)) + req.Header.Add("Accept", "application/json") + + resp, err := makeSimpleRequest(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch movie details: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("request failed with status: %d", resp.StatusCode) + } + + var movieDetails TMDBMovieDetails + if err := json.NewDecoder(resp.Body).Decode(&movieDetails); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &movieDetails, nil +} + +// GetMovieAsEpisode fetches movie details and returns it as a single episode +func GetMovieAsEpisode(title string, alternativeTitle string, tmdbID int, malID int, japaneseTitle string, animeScore float64) ([]types.AnimeSingleEpisode, error) { + logger.Log(fmt.Sprintf("Fetching movie episode data for: %s", title), logger.LogOptions{ + Level: logger.Debug, + Prefix: "TMDB", + }) + + var movieID int + var err error + + // If TMDB ID is provided, use it directly + if tmdbID > 0 { + movieID = tmdbID + logger.Log(fmt.Sprintf("Using provided TMDB movie ID: %d", movieID), logger.LogOptions{ + Level: logger.Debug, + Prefix: "TMDB", + }) + } else { + // Search for the movie + movies, err := searchMoviesByTitle(title, alternativeTitle) + if err != nil || len(movies) == 0 { + logger.Log(fmt.Sprintf("Failed to find movie on TMDB: %v", err), logger.LogOptions{ + Level: logger.Warn, + Prefix: "TMDB", + }) + return nil, fmt.Errorf("movie not found on TMDB") + } + + // Use the first result + movieID = movies[0].ID + logger.Log(fmt.Sprintf("Found TMDB movie ID: %d for title: %s", movieID, title), logger.LogOptions{ + Level: logger.Debug, + Prefix: "TMDB", + }) + } + + // Get movie details + movieDetails, err := getMovieDetails(movieID) + if err != nil { + logger.Log(fmt.Sprintf("Failed to fetch movie details: %v", err), logger.LogOptions{ + Level: logger.Warn, + Prefix: "TMDB", + }) + return nil, err + } + + // Convert movie to a single episode format + const tmdbImageBaseURL = "https://image.tmdb.org/t/p/" + const backdropSize = "w780" + + backdropURL := "" + if movieDetails.BackdropPath != "" { + backdropURL = tmdbImageBaseURL + backdropSize + movieDetails.BackdropPath + } else if movieDetails.PosterPath != "" { + backdropURL = tmdbImageBaseURL + backdropSize + movieDetails.PosterPath + } + + description := movieDetails.Overview + if description == "" { + description = "No description available" + } + + // Create titles structure with English title from TMDB and Japanese/Romaji from MAL + titles := types.EpisodeTitles{ + English: movieDetails.Title, + Japanese: japaneseTitle, + Romaji: title, + } + + // Calculate score out of 5 (half of MAL score out of 10), rounded to 2 decimal points + movieScore := float64(int((animeScore/2.0)*100)) / 100 + + // Generate MAL URLs + malURL := "" + forumURL := "" + if malID > 0 { + malURL = fmt.Sprintf("https://myanimelist.net/anime/%d", malID) + forumURL = fmt.Sprintf("https://myanimelist.net/anime/%d/forum", malID) + } + + episode := types.AnimeSingleEpisode{ + ID: generateEpisodeID(titles), + Titles: titles, + Description: description, + ThumbnailURL: backdropURL, + Aired: movieDetails.ReleaseDate, + Score: movieScore, + Filler: false, + Recap: false, + ForumURL: forumURL, + URL: malURL, + } + + logger.Log(fmt.Sprintf("Successfully created episode from movie: %s", title), logger.LogOptions{ + Level: logger.Success, + Prefix: "TMDB", + }) + + return []types.AnimeSingleEpisode{episode}, nil +} + +// generateEpisodeID creates a unique episode ID from titles +func generateEpisodeID(titles types.EpisodeTitles) string { + var title string + if titles.English != "" { + title = titles.English + } else if titles.Romaji != "" { + title = titles.Romaji + } else { + title = titles.Japanese + } + + // MD5 hash for ID generation to match Jikan episode IDs + hash := md5.Sum([]byte(title)) + return fmt.Sprintf("%x", hash) +} diff --git a/utils/api/tmdb/types.go b/utils/api/tmdb/types.go index 8743573..1ef8a27 100644 --- a/utils/api/tmdb/types.go +++ b/utils/api/tmdb/types.go @@ -52,3 +52,30 @@ type TMDBShowDetails struct { AirDate string `json:"air_date"` } `json:"seasons"` } + +// TMDBMovieResult represents a movie result from TMDB search +type TMDBMovieResult struct { + ID int `json:"id"` + Title string `json:"title"` + ReleaseDate string `json:"release_date"` + Adult bool `json:"adult"` +} + +// TMDBMovieSearchResponse represents the response from TMDB movie search API +type TMDBMovieSearchResponse struct { + Page int `json:"page"` + Results []TMDBMovieResult `json:"results"` + TotalPages int `json:"total_pages"` + TotalResults int `json:"total_results"` +} + +// TMDBMovieDetails represents a movie from TMDB +type TMDBMovieDetails struct { + ID int `json:"id"` + Title string `json:"title"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + ReleaseDate string `json:"release_date"` + Runtime int `json:"runtime"` +} diff --git a/utils/api/tvdb/tvdb.go b/utils/api/tvdb/tvdb.go index 230be54..dd83643 100644 --- a/utils/api/tvdb/tvdb.go +++ b/utils/api/tvdb/tvdb.go @@ -1,12 +1,195 @@ -package api +package tvdb import ( + "bytes" + "crypto/md5" + "encoding/json" "fmt" + "metachan/config" "metachan/database" "metachan/entities" + "metachan/types" "metachan/utils/logger" + "net/http" + "time" ) +var tvdbToken string +var tvdbTokenExpiry time.Time + +// authenticateTVDB authenticates with TVDB API and returns a token +func authenticateTVDB() (string, error) { + // Check if we have a valid token + if tvdbToken != "" && time.Now().Before(tvdbTokenExpiry) { + return tvdbToken, nil + } + + if config.Config.TVDB.APIKey == "" { + return "", fmt.Errorf("TVDB API key is not set") + } + + logger.Log("Authenticating with TVDB API", logger.LogOptions{ + Level: logger.Debug, + Prefix: "TVDB", + }) + + client := &http.Client{Timeout: 10 * time.Second} + + // Create request body with apikey + authBody := map[string]string{"apikey": config.Config.TVDB.APIKey} + jsonBody, err := json.Marshal(authBody) + if err != nil { + return "", fmt.Errorf("failed to marshal auth body: %w", err) + } + + req, err := http.NewRequest("POST", "https://api4.thetvdb.com/v4/login", bytes.NewBuffer(jsonBody)) + if err != nil { + return "", fmt.Errorf("failed to create auth request: %w", err) + } + + req.Header.Add("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to authenticate: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("authentication failed with status: %d", resp.StatusCode) + } + + var authResp TVDBAuthResponse + if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { + return "", fmt.Errorf("failed to decode auth response: %w", err) + } + + if authResp.Data.Token == "" { + return "", fmt.Errorf("no token received from TVDB") + } + + // Store token and set expiry (TVDB tokens typically last 30 days, but we'll refresh after 24 hours to be safe) + tvdbToken = authResp.Data.Token + tvdbTokenExpiry = time.Now().Add(24 * time.Hour) + + logger.Log("Successfully authenticated with TVDB", logger.LogOptions{ + Level: logger.Success, + Prefix: "TVDB", + }) + + return tvdbToken, nil +} + +// GetSeriesEpisodes fetches all episodes for a TVDB series +func GetSeriesEpisodes(tvdbID int) ([]TVDBEpisode, error) { + token, err := authenticateTVDB() + if err != nil { + return nil, fmt.Errorf("failed to authenticate with TVDB: %w", err) + } + + logger.Log(fmt.Sprintf("Fetching episodes for TVDB series %d", tvdbID), logger.LogOptions{ + Level: logger.Debug, + Prefix: "TVDB", + }) + + client := &http.Client{Timeout: 15 * time.Second} + + // TVDB v4 API endpoint for episodes + url := fmt.Sprintf("https://api4.thetvdb.com/v4/series/%d/episodes/default", tvdbID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Add("Authorization", "Bearer "+token) + req.Header.Add("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch episodes: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch episodes with status: %d", resp.StatusCode) + } + + var episodesResp TVDBEpisodesResponse + if err := json.NewDecoder(resp.Body).Decode(&episodesResp); err != nil { + return nil, fmt.Errorf("failed to decode episodes response: %w", err) + } + + logger.Log(fmt.Sprintf("Successfully fetched %d episodes from TVDB for series %d", len(episodesResp.Data.Episodes), tvdbID), logger.LogOptions{ + Level: logger.Success, + Prefix: "TVDB", + }) + + return episodesResp.Data.Episodes, nil +} + +// ConvertTVDBEpisodesToAnimeEpisodes converts TVDB episodes to anime episode format +func ConvertTVDBEpisodesToAnimeEpisodes(tvdbEpisodes []TVDBEpisode) []types.AnimeSingleEpisode { + var animeEpisodes []types.AnimeSingleEpisode + + const tvdbImageBaseURL = "https://artworks.thetvdb.com" + + for _, ep := range tvdbEpisodes { + // Generate episode ID from name + titles := types.EpisodeTitles{ + English: ep.Name, + Japanese: "", + Romaji: "", + } + + thumbnailURL := "" + if ep.Image != "" { + thumbnailURL = ep.Image + } + + description := ep.Overview + if description == "" { + description = "No description available" + } + + isRecap := false + if ep.FinaleType != nil && *ep.FinaleType == "recap" { + isRecap = true + } + + animeEpisodes = append(animeEpisodes, types.AnimeSingleEpisode{ + ID: generateEpisodeID(titles), + Titles: titles, + Description: description, + Aired: ep.Aired, + ThumbnailURL: thumbnailURL, + Score: 0, + Filler: false, + Recap: isRecap, + ForumURL: "", + URL: "", + }) + } + + return animeEpisodes +} + +// generateEpisodeID creates a unique episode ID from titles +func generateEpisodeID(titles types.EpisodeTitles) string { + var title string + if titles.English != "" { + title = titles.English + } else if titles.Romaji != "" { + title = titles.Romaji + } else { + title = titles.Japanese + } + + // MD5 hash for ID generation to match Jikan episode IDs + hash := md5.Sum([]byte(title)) + return fmt.Sprintf("%x", hash) +} + // FindSeasonMappings finds all anime mappings that belong to the same series based on TVDB ID func FindSeasonMappings(tvdbID int) ([]entities.AnimeMapping, error) { logger.Log(fmt.Sprintf("Finding season mappings for TVDB ID %d", tvdbID), logger.LogOptions{ diff --git a/utils/api/tvdb/types.go b/utils/api/tvdb/types.go new file mode 100644 index 0000000..4922041 --- /dev/null +++ b/utils/api/tvdb/types.go @@ -0,0 +1,43 @@ +package tvdb + +// TVDBAuthResponse represents the authentication response from TVDB +type TVDBAuthResponse struct { + Status string `json:"status"` + Data struct { + Token string `json:"token"` + } `json:"data"` +} + +// TVDBEpisode represents an episode from TVDB API v4 +type TVDBEpisode struct { + ID int `json:"id"` + SeriesID int `json:"seriesId"` + Name string `json:"name"` + Aired string `json:"aired"` + Runtime int `json:"runtime"` + NameTranslations []string `json:"nameTranslations"` + Overview string `json:"overview"` + OverviewTranslations []string `json:"overviewTranslations"` + Image string `json:"image"` + ImageType int `json:"imageType"` + IsMovie int `json:"isMovie"` + Number int `json:"number"` + AbsoluteNumber int `json:"absoluteNumber"` + SeasonNumber int `json:"seasonNumber"` + LastUpdated string `json:"lastUpdated"` + FinaleType *string `json:"finaleType"` + AirsBeforeSeason int `json:"airsBeforeSeason"` + AirsBeforeEpisode int `json:"airsBeforeEpisode"` + Year string `json:"year"` +} + +// TVDBEpisodesData represents the data container for episodes +type TVDBEpisodesData struct { + Episodes []TVDBEpisode `json:"episodes"` +} + +// TVDBEpisodesResponse represents the episodes response from TVDB API v4 +type TVDBEpisodesResponse struct { + Status string `json:"status"` + Data TVDBEpisodesData `json:"data"` +} |
