From df6cf3edcbb560e7615ad13d8daf4843507eb11e Mon Sep 17 00:00:00 2001 From: Bobby <30593201+luciferreeves@users.noreply.github.com> Date: Tue, 20 Jan 2026 18:00:43 +0530 Subject: Add TVDB integration for episode retrieval and configuration setup --- utils/api/tmdb/tmdb.go | 203 ++++++++++++++++++++++++++++++++++++++++++++++++ utils/api/tmdb/types.go | 27 +++++++ utils/api/tvdb/tvdb.go | 185 ++++++++++++++++++++++++++++++++++++++++++- utils/api/tvdb/types.go | 43 ++++++++++ 4 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 utils/api/tvdb/types.go (limited to 'utils') 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"` +} -- cgit v1.2.3