diff options
| author | Bobby <[email protected]> | 2025-05-09 02:25:54 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2025-05-09 02:25:54 +0530 |
| commit | cb41c834529f696c2f83adbb41e6b592c89495f3 (patch) | |
| tree | 5180a0c055c2b2f4b55a3374c83405d20e858d78 /utils/api/tmdb | |
| parent | 9ebde192fbde6d6d6be2d7d86485eca9a0c5e026 (diff) | |
| download | metachan-cb41c834529f696c2f83adbb41e6b592c89495f3.tar.xz metachan-cb41c834529f696c2f83adbb41e6b592c89495f3.zip | |
refactored types
Diffstat (limited to 'utils/api/tmdb')
| -rw-r--r-- | utils/api/tmdb/tmdb.go | 498 | ||||
| -rw-r--r-- | utils/api/tmdb/types.go | 54 |
2 files changed, 552 insertions, 0 deletions
diff --git a/utils/api/tmdb/tmdb.go b/utils/api/tmdb/tmdb.go new file mode 100644 index 0000000..1c5a87b --- /dev/null +++ b/utils/api/tmdb/tmdb.go @@ -0,0 +1,498 @@ +package tmdb + +import ( + "encoding/json" + "fmt" + "math" + "math/rand" + "metachan/config" + "metachan/types" + "metachan/utils/logger" + "net/http" + "strings" + "time" +) + +// makeRequestWithRetries executes an HTTP request with retries for handling temporary network failures +func makeRequestWithRetries(req *http.Request, maxRetries int) (*http.Response, error) { + client := &http.Client{ + Timeout: 10 * time.Second, + } + + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + // Exponential backoff with jitter for retries + backoffTime := time.Duration(math.Pow(1.5, float64(attempt))) * time.Second + + // Updated jitter calculation without using deprecated rand.Seed + jitter := time.Duration(rand.Int31n(500)) * time.Millisecond + + sleepTime := backoffTime + jitter + + logger.Log(fmt.Sprintf("TMDB request retry %d/%d after %v due to: %v", + attempt, maxRetries, sleepTime, lastErr), logger.LogOptions{ + Level: logger.Debug, + Prefix: "TMDB", + }) + + time.Sleep(sleepTime) + + // Create a fresh request to avoid any issues with reusing the same request + newReq, err := http.NewRequest(req.Method, req.URL.String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create new request for retry: %w", err) + } + + // Copy all headers from the original request + for key, values := range req.Header { + for _, value := range values { + newReq.Header.Add(key, value) + } + } + + // Set the new retry request as our active request + req = newReq + } + + resp, err := client.Do(req) + if err != nil { + lastErr = err + // Check if this is a network error that might be temporary + if strings.Contains(err.Error(), "connection reset by peer") || + strings.Contains(err.Error(), "EOF") || + strings.Contains(err.Error(), "connection refused") || + strings.Contains(err.Error(), "timeout") { + // These are retryable errors + continue + } + // Other errors are not retryable + return nil, err + } + + // If we got a server error (5xx), retry + if resp.StatusCode >= 500 && resp.StatusCode < 600 { + lastErr = fmt.Errorf("server error: %s", resp.Status) + resp.Body.Close() // Make sure we close the body before we retry + continue + } + + return resp, nil + } + + return nil, fmt.Errorf("failed after %d retries: %w", maxRetries, lastErr) +} + +// normalizeTitle cleans up the anime title for better matching with TMDB +func normalizeTitle(title string) string { + // Handle empty titles + if title == "" { + return "" + } + + // Remove common suffixes and prefixes + normalized := title + normalized = strings.Replace(normalized, "TV Animation", "", -1) + normalized = strings.Replace(normalized, ": Season", "", -1) + normalized = strings.Replace(normalized, "Season", "", -1) + normalized = strings.Replace(normalized, "Part", "", -1) + normalized = strings.Replace(normalized, "Cour", "", -1) + + // Handle patterns like "Dr. Stone: Stone Wars" -> "Dr. Stone" + if colonIndex := strings.Index(normalized, ":"); colonIndex > 0 { + normalized = normalized[:colonIndex] + } + + // Remove parentheses and text inside them + for { + openParen := strings.Index(normalized, "(") + if openParen == -1 { + break + } + closeParen := strings.Index(normalized, ")") + if closeParen == -1 || closeParen < openParen { + break + } + normalized = normalized[:openParen] + normalized[closeParen+1:] + } + + return strings.TrimSpace(normalized) +} + +// searchTVShowsByTitle searches for TV shows on TMDB by title +func searchTVShowsByTitle(title string, alternativeTitle string, isAdult bool, countryPriority string) ([]TMDBShowResult, error) { + if config.Config.TMDB.ReadAccessToken == "" { + return nil, fmt.Errorf("TMDB is not initialized") + } + + // Normalize the title + query := normalizeTitle(title) + if query == "" && alternativeTitle != "" { + query = normalizeTitle(alternativeTitle) + } + + logger.Log(fmt.Sprintf("Searching TMDB for TV show: %s", query), logger.LogOptions{ + Level: logger.Debug, + Prefix: "TMDB", + }) + + apiURL := "https://api.themoviedb.org/3/search/tv" + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Add query parameters + q := req.URL.Query() + q.Add("query", query) + req.URL.RawQuery = q.Encode() + + // Add headers + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.Config.TMDB.ReadAccessToken)) + req.Header.Add("Accept", "application/json") + + // Use our retry mechanism (3 retries) + resp, err := makeRequestWithRetries(req, 3) + if err != nil { + return nil, fmt.Errorf("failed to search TV shows: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to search TV shows: %s", resp.Status) + } + + // Parse response + var searchResponse TMDBSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&searchResponse); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + results := searchResponse.Results + + // Filter results if needed + var filteredResults []TMDBShowResult + for _, show := range results { + if (isAdult && show.Adult) || (!isAdult && !show.Adult) { + filteredResults = append(filteredResults, show) + } + } + + // Sort by country priority if specified + if countryPriority != "" && len(filteredResults) > 0 { + var prioritizedResults []TMDBShowResult + var otherResults []TMDBShowResult + + for _, show := range filteredResults { + hasPriority := false + for _, country := range show.OriginCountry { + if country == countryPriority { + hasPriority = true + break + } + } + + if hasPriority { + prioritizedResults = append(prioritizedResults, show) + } else { + otherResults = append(otherResults, show) + } + } + + // Combine the results with prioritized ones first + filteredResults = append(prioritizedResults, otherResults...) + } + + if len(filteredResults) == 0 { + logger.Log(fmt.Sprintf("No TMDB shows found for: %s", query), logger.LogOptions{ + Level: logger.Warn, + Prefix: "TMDB", + }) + } else { + logger.Log(fmt.Sprintf("Found %d TMDB shows for: %s", len(filteredResults), query), logger.LogOptions{ + Level: logger.Debug, + Prefix: "TMDB", + }) + } + + return filteredResults, nil +} + +// getTVShowDetails gets details for a TV show from TMDB +func getTVShowDetails(showID int) (*TMDBShowDetails, error) { + if config.Config.TMDB.ReadAccessToken == "" { + return nil, fmt.Errorf("TMDB is not initialized") + } + + apiURL := fmt.Sprintf("https://api.themoviedb.org/3/tv/%d", showID) + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Add headers + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.Config.TMDB.ReadAccessToken)) + req.Header.Add("Accept", "application/json") + + // Use our retry mechanism (3 retries) + resp, err := makeRequestWithRetries(req, 5) + if err != nil { + return nil, fmt.Errorf("failed to get TV show details: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get TV show details: %s", resp.Status) + } + + // Parse response + var showDetails TMDBShowDetails + if err := json.NewDecoder(resp.Body).Decode(&showDetails); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &showDetails, nil +} + +// getSeasonDetails gets details for a TV season from TMDB +func getSeasonDetails(showID, seasonNumber int) (*TMDBSeasonDetails, error) { + if config.Config.TMDB.ReadAccessToken == "" { + return nil, fmt.Errorf("TMDB is not initialized") + } + + apiURL := fmt.Sprintf("https://api.themoviedb.org/3/tv/%d/season/%d", showID, seasonNumber) + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Add headers + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.Config.TMDB.ReadAccessToken)) + req.Header.Add("Accept", "application/json") + + // Use our retry mechanism (3 retries) + resp, err := makeRequestWithRetries(req, 3) + if err != nil { + return nil, fmt.Errorf("failed to get season details: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get season details: %s", resp.Status) + } + + // Parse response + var seasonDetails TMDBSeasonDetails + if err := json.NewDecoder(resp.Body).Decode(&seasonDetails); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &seasonDetails, nil +} + +// findBestSeason finds the best matching season for an anime +func findBestSeason(shows []TMDBShowResult, title string, episodeCount int, airDate string) (int, int, error) { + for _, show := range shows { + showDetails, err := getTVShowDetails(show.ID) + if err != nil { + logger.Log(fmt.Sprintf("Failed to get details for show %d: %v", show.ID, err), logger.LogOptions{ + Level: logger.Warn, + Prefix: "TMDB", + }) + continue + } + + for _, season := range showDetails.Seasons { + // Skip season 0 (usually specials) + if season.SeasonNumber == 0 { + continue + } + + // Check if episode count matches (with some flexibility) + episodeCountMatches := season.EpisodeCount == episodeCount || + (episodeCount > 0 && season.EpisodeCount >= episodeCount-2 && + season.EpisodeCount <= episodeCount+2) + + // Check if air dates are close + airDateMatches := false + if airDate != "" && season.AirDate != "" { + // Simple year comparison + animeYear := airDate[:4] + seasonYear := season.AirDate[:4] + airDateMatches = animeYear == seasonYear + } + + // If either count or air date matches, consider it a potential match + if episodeCountMatches || airDateMatches { + logger.Log(fmt.Sprintf("Found matching season for \"%s\": Show ID %d, Season %d", + title, show.ID, season.SeasonNumber), logger.LogOptions{ + Level: logger.Info, + Prefix: "TMDB", + }) + return show.ID, season.SeasonNumber, nil + } + } + } + + return 0, 0, fmt.Errorf("could not find matching season for: %s", title) +} + +// AttachEpisodeDescriptions enriches anime episodes with descriptions and thumbnails from TMDB +func AttachEpisodeDescriptions(title string, episodes []types.AnimeSingleEpisode, alternativeTitle string, tmdbID int) []types.AnimeSingleEpisode { + if config.Config.TMDB.ReadAccessToken == "" { + logger.Log("TMDB is not configured, skipping episode description enrichment", logger.LogOptions{ + Level: logger.Warn, + Prefix: "TMDB", + }) + return episodes + } + + if len(episodes) == 0 { + return episodes + } + + logger.Log(fmt.Sprintf("Enriching episodes for: %s", title), logger.LogOptions{ + Level: logger.Info, + Prefix: "TMDB", + }) + + var showID int + var seasonNumber int + var err error + + // If we have a TMDB ID, use it directly + if tmdbID > 0 { + showID = tmdbID + + // Try to get show details and find the best season + showDetails, err := getTVShowDetails(showID) + if err != nil { + logger.Log(fmt.Sprintf("Failed to get TMDB show details for ID %d: %v", tmdbID, err), logger.LogOptions{ + Level: logger.Warn, + Prefix: "TMDB", + }) + return episodes + } + + // Find the best matching season - prefer the first season if we can't determine + seasonNumber = 1 + bestMatchScore := 0 + + for _, season := range showDetails.Seasons { + if season.SeasonNumber == 0 { + continue // Skip specials + } + + matchScore := 0 + + // Check episode count similarity + if math.Abs(float64(season.EpisodeCount-len(episodes))) <= 2 { + matchScore += 2 + } + + // Check air date if available + if len(episodes) > 0 && episodes[0].Aired != "" && season.AirDate != "" { + animeYear := episodes[0].Aired[:4] + seasonYear := season.AirDate[:4] + if animeYear == seasonYear { + matchScore += 1 + } + } + + if matchScore > bestMatchScore { + bestMatchScore = matchScore + seasonNumber = season.SeasonNumber + } + } + + logger.Log(fmt.Sprintf("Using TMDB ID %d with season %d", showID, seasonNumber), logger.LogOptions{ + Level: logger.Info, + Prefix: "TMDB", + }) + } else { + // Search for the TV show on TMDB if we don't have a direct ID + shows, err := searchTVShowsByTitle(title, alternativeTitle, false, "JP") + if err != nil { + logger.Log(fmt.Sprintf("Failed to search TV shows: %v", err), logger.LogOptions{ + Level: logger.Warn, + Prefix: "TMDB", + }) + return episodes + } + + if len(shows) == 0 { + logger.Log(fmt.Sprintf("No TV shows found for: %s", title), logger.LogOptions{ + Level: logger.Warn, + Prefix: "TMDB", + }) + return episodes + } + + // Find the best matching season + airDate := "" + if len(episodes) > 0 && episodes[0].Aired != "" { + airDate = episodes[0].Aired + } + + showID, seasonNumber, err = findBestSeason(shows, title, len(episodes), airDate) + if err != nil { + logger.Log(fmt.Sprintf("Failed to find best season: %v", err), logger.LogOptions{ + Level: logger.Warn, + Prefix: "TMDB", + }) + return episodes + } + } + + // Get season details with episode information + seasonDetails, err := getSeasonDetails(showID, seasonNumber) + if err != nil { + logger.Log(fmt.Sprintf("Failed to get season details: %v", err), logger.LogOptions{ + Level: logger.Warn, + Prefix: "TMDB", + }) + return episodes + } + + // Enrich episodes with descriptions and thumbnails + tmdbEpisodes := seasonDetails.Episodes + enrichedEpisodes := make([]types.AnimeSingleEpisode, len(episodes)) + copy(enrichedEpisodes, episodes) + + // The base URL for TMDB images + const tmdbImageBaseURL = "https://image.tmdb.org/t/p/" + const thumbnailSize = "w300" // Use w300 size for episode thumbnails + + for i := range enrichedEpisodes { + if i < len(tmdbEpisodes) { + // Only add description if it's not empty + if tmdbEpisodes[i].Overview != "" { + enrichedEpisodes[i].Description = tmdbEpisodes[i].Overview + } else { + enrichedEpisodes[i].Description = "No description available" + } + + // Add thumbnail URL if available + if tmdbEpisodes[i].StillPath != "" { + enrichedEpisodes[i].ThumbnailURL = tmdbImageBaseURL + thumbnailSize + tmdbEpisodes[i].StillPath + } + } else { + enrichedEpisodes[i].Description = "No description available" + } + } + + thumbnailCount := 0 + for _, ep := range enrichedEpisodes { + if ep.ThumbnailURL != "" { + thumbnailCount++ + } + } + + logger.Log(fmt.Sprintf("Successfully enriched %d episodes with descriptions and %d with thumbnails for: %s", + len(enrichedEpisodes), thumbnailCount, title), logger.LogOptions{ + Level: logger.Success, + Prefix: "TMDB", + }) + + return enrichedEpisodes +} diff --git a/utils/api/tmdb/types.go b/utils/api/tmdb/types.go new file mode 100644 index 0000000..8743573 --- /dev/null +++ b/utils/api/tmdb/types.go @@ -0,0 +1,54 @@ +package tmdb + +// TMDBShowResult represents a TV show result from TMDB search +type TMDBShowResult struct { + ID int `json:"id"` + Name string `json:"name"` + FirstAirDate string `json:"first_air_date"` + OriginCountry []string `json:"origin_country"` + Adult bool `json:"adult"` +} + +// TMDBSearchResponse represents the response from TMDB search API +type TMDBSearchResponse struct { + Page int `json:"page"` + Results []TMDBShowResult `json:"results"` + TotalPages int `json:"total_pages"` + TotalResults int `json:"total_results"` +} + +// TMDBEpisode represents a TV episode from TMDB +type TMDBEpisode struct { + ID int `json:"id"` + Name string `json:"name"` + Overview string `json:"overview"` + StillPath string `json:"still_path"` + AirDate string `json:"air_date"` + EpisodeNumber int `json:"episode_number"` + SeasonNumber int `json:"season_number"` +} + +// TMDBSeasonDetails represents a TV season from TMDB +type TMDBSeasonDetails struct { + ID int `json:"id"` + AirDate string `json:"air_date"` + EpisodeCount int `json:"episode_count"` + Name string `json:"name"` + Overview string `json:"overview"` + SeasonNumber int `json:"season_number"` + Episodes []TMDBEpisode `json:"episodes"` +} + +// TMDBShowDetails represents a TV show from TMDB +type TMDBShowDetails struct { + ID int `json:"id"` + Name string `json:"name"` + Overview string `json:"overview"` + Seasons []struct { + ID int `json:"id"` + Name string `json:"name"` + SeasonNumber int `json:"season_number"` + EpisodeCount int `json:"episode_count"` + AirDate string `json:"air_date"` + } `json:"seasons"` +} |
