diff options
| author | Bobby <[email protected]> | 2025-04-18 19:28:01 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2025-04-18 19:28:01 +0530 |
| commit | 5dc2f648cf3d9634cfb8763cebc6f1dec4042914 (patch) | |
| tree | 0a5e84fd9915f6f4e93f4fe026d04c0dd4a5db3c /utils | |
| parent | 5c7536ec347c4c51172960da0c3f5857642fd223 (diff) | |
| download | metachan-5dc2f648cf3d9634cfb8763cebc6f1dec4042914.tar.xz metachan-5dc2f648cf3d9634cfb8763cebc6f1dec4042914.zip | |
basic anime details with episodes; added jikan and anilist as metadata fetchers; added tmdb for fetching episode descriptions
Diffstat (limited to 'utils')
| -rw-r--r-- | utils/anime/anime.go | 731 | ||||
| -rw-r--r-- | utils/anime/jikan.go | 148 | ||||
| -rw-r--r-- | utils/anime/tmdb.go | 426 |
3 files changed, 1305 insertions, 0 deletions
diff --git a/utils/anime/anime.go b/utils/anime/anime.go new file mode 100644 index 0000000..0b079b9 --- /dev/null +++ b/utils/anime/anime.go @@ -0,0 +1,731 @@ +package anime + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math" + "metachan/entities" + "metachan/types" + "metachan/utils/logger" + "net/http" + "strconv" + "strings" + "time" +) + +func GetAnimeDetails(animeMapping *entities.AnimeMapping) (*types.Anime, error) { + malID := animeMapping.MAL + + anime, err := getAnimeViaJikan(malID) + if err != nil { + return nil, fmt.Errorf("failed to get anime details: %w", err) + } + var anilistAnime *types.AnilistAnimeResponse + if animeMapping.Anilist != 0 { + anilistAnime, err = getAnimeViaAnilist(animeMapping.Anilist) + if err != nil { + return nil, fmt.Errorf("failed to get anime details from Anilist: %w", err) + } + } + + episodes, err := getAnimeEpisodesViaJikan(malID) + if err != nil { + return nil, fmt.Errorf("failed to get anime episodes: %w", err) + } + + episodeData, err := generateEpisodeDataWithDescriptions( + episodes.Data, + anime.Data.Title, + anime.Data.TitleEnglish, + animeMapping.TMDB, + ) + + animeDetails := &types.Anime{ + MALID: malID, + Titles: types.AnimeTitles{ + Romaji: anime.Data.Title, + English: anime.Data.TitleEnglish, + Japanese: anime.Data.TitleJapanese, + Synonyms: anime.Data.TitleSynonyms, + }, + Synopsis: anime.Data.Synopsis, + Type: types.AniSyncType(animeMapping.Type), + Source: anime.Data.Source, + Status: anime.Data.Status, + Duration: anime.Data.Duration, + Episodes: types.AnimeEpisodes{ + Total: getEpisodeCount(anime, anilistAnime), + Aired: len(episodes.Data), + Episodes: episodeData, + }, + Mappings: types.AnimeMappings{ + AniDB: animeMapping.AniDB, + Anilist: animeMapping.Anilist, + AnimeCountdown: animeMapping.AnimeCountdown, + AnimePlanet: animeMapping.AnimePlanet, + AniSearch: animeMapping.AniSearch, + IMDB: animeMapping.IMDB, + Kitsu: animeMapping.Kitsu, + LiveChart: animeMapping.LiveChart, + NotifyMoe: animeMapping.NotifyMoe, + Simkl: animeMapping.Simkl, + TMDB: animeMapping.TMDB, + TVDB: animeMapping.TVDB, + }, + } + return animeDetails, nil +} + +func getAnimeViaJikan(malID int) (*types.JikanAnimeResponse, error) { + apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/full", malID) + maxRetries := 3 + baseBackoff := 1 * time.Second + + var animeResponse types.JikanAnimeResponse + success := false + retries := 0 + + for !success && retries <= maxRetries { + // Use rate limiter before making the request + logger.Log(fmt.Sprintf("Waiting for rate limiter before requesting anime %d details", malID), types.LogOptions{ + Level: types.Debug, + Prefix: "AnimeAPI", + }) + WaitForJikanRequest() + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + client := &http.Client{ + Timeout: 10 * time.Second, // Add timeout to prevent hanging requests + } + resp, err := client.Do(req) + + if err != nil { + if retries < maxRetries { + retries++ + backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) + logger.Log(fmt.Sprintf("Request error for anime details, retrying in %v (retry %d/%d): %v", + backoffTime, retries, maxRetries, err), types.LogOptions{ + Level: types.Warn, + Prefix: "AnimeAPI", + }) + time.Sleep(backoffTime) + continue + } + return nil, fmt.Errorf("failed to execute request after %d retries: %w", maxRetries, err) + } + + defer resp.Body.Close() + + // Handle rate limiting with exponential backoff + if resp.StatusCode == http.StatusTooManyRequests { + if retries < maxRetries { + retries++ + backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) + + // If we have a Retry-After header, respect it + if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { + if seconds, err := strconv.Atoi(retryAfter); err == nil { + backoffTime = time.Duration(seconds) * time.Second + } + } + + logger.Log(fmt.Sprintf("Rate limited on anime details, backing off for %v (retry %d/%d)", + backoffTime, retries, maxRetries), types.LogOptions{ + Level: types.Warn, + Prefix: "AnimeAPI", + }) + time.Sleep(backoffTime) + continue + } + return nil, fmt.Errorf("failed to get anime data: rate limited after %d retries", maxRetries) + } else if resp.StatusCode != http.StatusOK { + if retries < maxRetries { + retries++ + backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) + logger.Log(fmt.Sprintf("HTTP error %d for anime details, retrying in %v (retry %d/%d)", + resp.StatusCode, backoffTime, retries, maxRetries), types.LogOptions{ + Level: types.Warn, + Prefix: "AnimeAPI", + }) + time.Sleep(backoffTime) + continue + } + return nil, fmt.Errorf("failed to get anime data: %s", resp.Status) + } + + if err := json.NewDecoder(resp.Body).Decode(&animeResponse); err != nil { + if retries < maxRetries { + retries++ + backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) + logger.Log(fmt.Sprintf("JSON decode error for anime details, retrying in %v (retry %d/%d): %v", + backoffTime, retries, maxRetries, err), types.LogOptions{ + Level: types.Warn, + Prefix: "AnimeAPI", + }) + time.Sleep(backoffTime) + continue + } + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + success = true + } + + if !success { + return nil, fmt.Errorf("failed to fetch anime details after maximum retries") + } + + if animeResponse.Data.MALID == 0 { + return nil, fmt.Errorf("no data found for MAL ID %d", malID) + } + + return &animeResponse, nil +} + +func getAnimeViaAnilist(anilistID int) (*types.AnilistAnimeResponse, error) { + graphQLQuery := fmt.Sprintf(`query { + Media(id: %d) { + id + idMal + title { + romaji + english + native + userPreferred + } + type + format + status + description + startDate { + year + month + day + } + endDate { + year + month + day + } + season + seasonYear + episodes + duration + chapters + volumes + countryOfOrigin + isLicensed + source + hashtag + trailer { + id + site + thumbnail + } + updatedAt + coverImage { + extraLarge + large + medium + color + } + bannerImage + genres + synonyms + averageScore + meanScore + popularity + isLocked + trending + favourites + tags { + id + name + description + category + rank + isGeneralSpoiler + isMediaSpoiler + isAdult + } + relations { + edges { + id + relationType + node { + id + title { + romaji + english + native + userPreferred + } + format + type + status + coverImage { + extraLarge + large + medium + color + } + bannerImage + } + } + } + characters { + edges { + role + node { + id + name { + first + last + middle + full + native + userPreferred + } + image { + large + medium + } + description + age + } + } + } + staff { + edges { + role + node { + id + name { + first + middle + last + full + native + userPreferred + } + image { + large + medium + } + description + primaryOccupations + gender + age + languageV2 + } + } + } + studios { + edges { + isMain + node { + id + name + } + } + } + isAdult + nextAiringEpisode { + id + airingAt + timeUntilAiring + episode + } + airingSchedule { + nodes { + id + episode + airingAt + timeUntilAiring + } + } + trends { + nodes { + date + trending + popularity + inProgress + } + } + externalLinks { + id + url + site + } + streamingEpisodes { + title + thumbnail + url + site + } + rankings { + id + rank + type + format + year + season + allTime + context + } + stats { + scoreDistribution { + score + amount + } + statusDistribution { + status + amount + } + } + siteUrl + } + }`, anilistID) + + // Remove debug print that can cause issues with large queries + // fmt.Printf("GraphQL Query: %s\n", graphQLQuery) + + apiURL := "https://graphql.anilist.co" + + // Escape quotes in the query to make valid JSON + escapedQuery := strings.Replace(graphQLQuery, `"`, `\"`, -1) + escapedQuery = strings.Replace(escapedQuery, "\n", " ", -1) + escapedQuery = strings.Replace(escapedQuery, "\t", "", -1) + + // Create the JSON payload + jsonData := []byte(fmt.Sprintf(`{"query": "%s"}`, escapedQuery)) + + // Create a request with the proper body + req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // Read error response body for better debugging + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to get anime data: %s - %s", resp.Status, string(bodyBytes)) + } + + var anilistResponse types.AnilistAnimeResponse + if err := json.NewDecoder(resp.Body).Decode(&anilistResponse); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + if anilistResponse.Data.Media.ID == 0 { + return nil, fmt.Errorf("no data found for Anilist ID %d", anilistID) + } + return &anilistResponse, nil +} + +func getAnimeEpisodesViaJikan(malId int) (*types.JikanAnimeEpisodeResponse, error) { + apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/episodes", malId) + var allEpisodes []types.JikanAnimeEpisode + page := 1 + var lastVisiblePage int + + maxRetries := 3 + baseBackoff := 1 * time.Second + maxAttempts := 15 // Maximum number of attempts across all pages to prevent infinite loops + + logger.Log(fmt.Sprintf("Fetching episodes for anime %d", malId), types.LogOptions{ + Level: types.Info, + Prefix: "AnimeAPI", + }) + + totalAttempts := 0 + for { + if totalAttempts >= maxAttempts { + logger.Log(fmt.Sprintf("Reached maximum total attempts (%d) for anime %d. Returning collected episodes so far.", + maxAttempts, malId), types.LogOptions{ + Level: types.Warn, + Prefix: "AnimeAPI", + }) + break + } + + var pageResponse types.JikanAnimeEpisodeResponse + success := false + retries := 0 + + for !success && retries <= maxRetries { + totalAttempts++ + + // Use rate limiter before making the request + logger.Log(fmt.Sprintf("Waiting for rate limiter before requesting page %d for anime %d", page, malId), types.LogOptions{ + Level: types.Debug, + Prefix: "AnimeAPI", + }) + WaitForJikanRequest() + + pageURL := fmt.Sprintf("%s?page=%d", apiURL, page) + req, err := http.NewRequest("GET", pageURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Add a context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + req = req.WithContext(ctx) + + client := &http.Client{ + Timeout: 15 * time.Second, // Add timeout to prevent hanging requests + } + + resp, err := client.Do(req) + cancel() // Cancel the context regardless of the outcome + + if err != nil { + if retries < maxRetries { + retries++ + backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) + logger.Log(fmt.Sprintf("Request error, retrying in %v (retry %d/%d): %v", + backoffTime, retries, maxRetries, err), types.LogOptions{ + Level: types.Warn, + Prefix: "AnimeAPI", + }) + time.Sleep(backoffTime) + continue + } + return nil, fmt.Errorf("failed to execute request after %d retries: %w", maxRetries, err) + } + + defer resp.Body.Close() + + // Handle rate limiting with exponential backoff + if resp.StatusCode == http.StatusTooManyRequests { + if retries < maxRetries { + retries++ + + // Start with a reasonable base backoff + backoffTime := time.Duration(float64(baseBackoff) * math.Pow(1.5, float64(retries-1))) + + // Respect Retry-After header if available + if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { + if seconds, err := strconv.Atoi(retryAfter); err == nil { + backoffTime = time.Duration(seconds) * time.Second + } + } + + logger.Log(fmt.Sprintf("Rate limited, backing off for %v (retry %d/%d)", + backoffTime, retries, maxRetries), types.LogOptions{ + Level: types.Warn, + Prefix: "AnimeAPI", + }) + time.Sleep(backoffTime) + continue + } + + // If we've reached maximum retries and still getting rate limited, + // return what we have so far rather than failing completely + if len(allEpisodes) > 0 { + logger.Log(fmt.Sprintf("Rate limited after maximum retries. Returning %d episodes collected so far.", + len(allEpisodes)), types.LogOptions{ + Level: types.Warn, + Prefix: "AnimeAPI", + }) + + return &types.JikanAnimeEpisodeResponse{ + Pagination: types.JikanPagination{ + LastVisiblePage: lastVisiblePage, + HasNextPage: false, + }, + Data: allEpisodes, + }, nil + } + + return nil, fmt.Errorf("failed to get anime episodes (page %d): rate limited after %d retries", page, maxRetries) + } else if resp.StatusCode != http.StatusOK { + if retries < maxRetries { + retries++ + backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) + logger.Log(fmt.Sprintf("HTTP error %d, retrying in %v (retry %d/%d)", + resp.StatusCode, backoffTime, retries, maxRetries), types.LogOptions{ + Level: types.Warn, + Prefix: "AnimeAPI", + }) + time.Sleep(backoffTime) + continue + } + return nil, fmt.Errorf("failed to get anime episodes (page %d): %s", page, resp.Status) + } + + // Limit response body size to prevent memory issues + bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit + if err != nil { + if retries < maxRetries { + retries++ + backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) + logger.Log(fmt.Sprintf("Error reading response body, retrying in %v (retry %d/%d): %v", + backoffTime, retries, maxRetries, err), types.LogOptions{ + Level: types.Warn, + Prefix: "AnimeAPI", + }) + time.Sleep(backoffTime) + continue + } + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if err := json.Unmarshal(bodyBytes, &pageResponse); err != nil { + if retries < maxRetries { + retries++ + backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) + logger.Log(fmt.Sprintf("JSON decode error, retrying in %v (retry %d/%d): %v", + backoffTime, retries, maxRetries, err), types.LogOptions{ + Level: types.Warn, + Prefix: "AnimeAPI", + }) + time.Sleep(backoffTime) + continue + } + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + success = true + } + + if !success { + // If we've collected some episodes, return them instead of completely failing + if len(allEpisodes) > 0 { + logger.Log(fmt.Sprintf("Failed to fetch page %d after maximum retries. Returning %d episodes collected so far.", + page, len(allEpisodes)), types.LogOptions{ + Level: types.Warn, + Prefix: "AnimeAPI", + }) + + return &types.JikanAnimeEpisodeResponse{ + Pagination: types.JikanPagination{ + LastVisiblePage: page - 1, + HasNextPage: false, + }, + Data: allEpisodes, + }, nil + } + + return nil, fmt.Errorf("failed to fetch page %d after maximum retries", page) + } + + // Convert and append episodes from this page + for _, episode := range pageResponse.Data { + allEpisodes = append(allEpisodes, types.JikanAnimeEpisode{ + MALID: episode.MALID, + URL: episode.URL, + Title: episode.Title, + TitleJapanese: episode.TitleJapanese, + TitleRomaji: episode.TitleRomaji, + Aired: episode.Aired, + Score: episode.Score, + Filler: episode.Filler, + Recap: episode.Recap, + ForumURL: episode.ForumURL, + }) + } + + // Update pagination info + lastVisiblePage = pageResponse.Pagination.LastVisiblePage + + logger.Log(fmt.Sprintf("Fetched page %d/%d with %d episodes", + page, lastVisiblePage, len(pageResponse.Data)), types.LogOptions{ + Level: types.Debug, + Prefix: "AnimeAPI", + }) + + // Check if there are more pages + if !pageResponse.Pagination.HasNextPage || page >= lastVisiblePage { + break + } + + // Safety check - don't fetch more than a reasonable number of pages + if page >= 25 { + logger.Log(fmt.Sprintf("Reached maximum page limit (25) for anime %d. Returning collected episodes so far.", + malId), types.LogOptions{ + Level: types.Warn, + Prefix: "AnimeAPI", + }) + break + } + + // No need for explicit waiting between pages anymore + // The rate limiter will handle the pacing automatically + page++ + } + + logger.Log(fmt.Sprintf("Completed fetching all %d episodes for anime %d", + len(allEpisodes), malId), types.LogOptions{ + Level: types.Success, + Prefix: "AnimeAPI", + }) + + // Return the complete response with all collected episodes + return &types.JikanAnimeEpisodeResponse{ + Pagination: types.JikanPagination{ + LastVisiblePage: lastVisiblePage, + HasNextPage: false, + }, + Data: allEpisodes, + }, nil +} + +func getEpisodeCount(malAnime *types.JikanAnimeResponse, anilistAnime *types.AnilistAnimeResponse) int { + streamingScheduleLength := len(anilistAnime.Data.Media.AiringSchedule.Nodes) + episodes := max(malAnime.Data.Episodes, anilistAnime.Data.Media.Episodes) + episodes = max(episodes, streamingScheduleLength) + + return episodes +} + +func generateEpisodeData(episodes []types.JikanAnimeEpisode) ([]types.AnimeSingleEpisode, error) { + var AnimeEpisodes []types.AnimeSingleEpisode + + for _, episode := range episodes { + AnimeEpisodes = append(AnimeEpisodes, types.AnimeSingleEpisode{ + Titles: types.EpisodeTitles{ + English: episode.Title, + Japanese: episode.TitleJapanese, + Romaji: episode.TitleRomaji, + }, + Aired: episode.Aired, + Score: episode.Score, + Filler: episode.Filler, + Recap: episode.Recap, + ForumURL: episode.ForumURL, + URL: episode.URL, + Description: "No description available", + }) + } + return AnimeEpisodes, nil +} + +func generateEpisodeDataWithDescriptions(episodes []types.JikanAnimeEpisode, title string, alternativeTitle string, tmdbID int) ([]types.AnimeSingleEpisode, error) { + // First create basic episode data + basicEpisodes, err := generateEpisodeData(episodes) + if err != nil { + return nil, fmt.Errorf("failed to generate basic episode data: %w", err) + } + + // Then enrich with descriptions - this won't fail, just return original episodes if there's an issue + enrichedEpisodes := AttachEpisodeDescriptions(title, basicEpisodes, alternativeTitle, tmdbID) + return enrichedEpisodes, nil +} diff --git a/utils/anime/jikan.go b/utils/anime/jikan.go new file mode 100644 index 0000000..0cfe79e --- /dev/null +++ b/utils/anime/jikan.go @@ -0,0 +1,148 @@ +package anime + +import ( + "metachan/types" + "metachan/utils/logger" + "sync" + "time" +) + +// JikanRateLimiter manages request rate limiting for the Jikan API +// Jikan allows maximum 3 requests per second, 60 requests per minute +type JikanRateLimiter struct { + mu sync.Mutex + lastRequests []time.Time + perSecRequests int // Max requests per second + perSecWindow time.Duration + perMinRequests int // Max requests per minute + perMinWindow time.Duration +} + +var ( + // Global Jikan rate limiter instance with conservative settings + jikanLimiter = &JikanRateLimiter{ + lastRequests: make([]time.Time, 0, 60), + perSecRequests: 3, // More conservative than the stated 3/sec + perSecWindow: time.Second, + perMinRequests: 60, // More conservative than the stated 60/min + perMinWindow: time.Minute, + } +) + +// Wait blocks until a request can be made according to rate limiting rules +func (r *JikanRateLimiter) Wait() { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now() + + // Clean up old requests + r.cleanupOldRequests(now) + + // Count requests in the current windows + secWindowRequests := 0 + minWindowRequests := 0 + + for _, t := range r.lastRequests { + if now.Sub(t) < r.perSecWindow { + secWindowRequests++ + } + if now.Sub(t) < r.perMinWindow { + minWindowRequests++ + } + } + + logger.Log( + "Rate limit check - Second: "+ + time.Duration(secWindowRequests).String()+"/"+time.Duration(r.perSecRequests).String()+ + " - Minutes: "+time.Duration(minWindowRequests).String()+"/"+time.Duration(r.perMinRequests).String(), + types.LogOptions{ + Level: types.Debug, + Prefix: "JikanAPI", + }, + ) + + // Calculate necessary delay + var delay time.Duration + + // Check per-second limit + if secWindowRequests >= r.perSecRequests && len(r.lastRequests) > 0 { + // Find the oldest request within the second window + var oldestInSecWindow time.Time + foundInSecWindow := false + + for _, t := range r.lastRequests { + if now.Sub(t) < r.perSecWindow { + if !foundInSecWindow || t.Before(oldestInSecWindow) { + oldestInSecWindow = t + foundInSecWindow = true + } + } + } + + if foundInSecWindow { + // Calculate when we can make the next request + secDelay := r.perSecWindow - now.Sub(oldestInSecWindow) + 200*time.Millisecond // Add buffer + if secDelay > 0 { + delay = secDelay + } + } + } + + // Check per-minute limit + if minWindowRequests >= r.perMinRequests && len(r.lastRequests) > 0 { + // Find the oldest request within the minute window + var oldestInMinWindow time.Time + foundInMinWindow := false + + for _, t := range r.lastRequests { + if now.Sub(t) < r.perMinWindow { + if !foundInMinWindow || t.Before(oldestInMinWindow) { + oldestInMinWindow = t + foundInMinWindow = true + } + } + } + + if foundInMinWindow { + // Calculate when we can make the next request + minDelay := r.perMinWindow - now.Sub(oldestInMinWindow) + 200*time.Millisecond // Add buffer + if minDelay > delay { + delay = minDelay + } + } + } + + // If we need to wait, do so + if delay > 0 { + // Log and sleep + r.mu.Unlock() // Unlock while sleeping + logger.Log("Rate limiting Jikan API request - waiting "+delay.String(), types.LogOptions{ + Level: types.Info, + Prefix: "JikanAPI", + }) + time.Sleep(delay) + r.mu.Lock() // Lock again before modifying state + now = time.Now() // Update current time + } + + // Record this request with current time + r.lastRequests = append(r.lastRequests, now) +} + +// cleanupOldRequests removes requests older than the longest window +func (r *JikanRateLimiter) cleanupOldRequests(now time.Time) { + validRequests := make([]time.Time, 0, len(r.lastRequests)) + for _, t := range r.lastRequests { + // Keep requests that are within our longest time window (per minute) + if now.Sub(t) < r.perMinWindow { + validRequests = append(validRequests, t) + } + } + r.lastRequests = validRequests +} + +// WaitForJikanRequest is a convenience function to access the global rate limiter +func WaitForJikanRequest() { + jikanLimiter.Wait() +} diff --git a/utils/anime/tmdb.go b/utils/anime/tmdb.go new file mode 100644 index 0000000..d4d22a0 --- /dev/null +++ b/utils/anime/tmdb.go @@ -0,0 +1,426 @@ +package anime + +import ( + "encoding/json" + "fmt" + "math" + "metachan/config" + "metachan/types" + "metachan/utils/logger" + "net/http" + "strings" + "time" +) + +// 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) ([]types.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), types.LogOptions{ + Level: types.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") + + // Create HTTP client with timeout + client := &http.Client{ + Timeout: 10 * time.Second, + } + + // Execute request + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %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 types.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 []types.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 []types.TMDBShowResult + var otherResults []types.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), types.LogOptions{ + Level: types.Warn, + Prefix: "TMDB", + }) + } else { + logger.Log(fmt.Sprintf("Found %d TMDB shows for: %s", len(filteredResults), query), types.LogOptions{ + Level: types.Debug, + Prefix: "TMDB", + }) + } + + return filteredResults, nil +} + +// getTVShowDetails gets details for a TV show from TMDB +func getTVShowDetails(showID int) (*types.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") + + // Create HTTP client with timeout + client := &http.Client{ + Timeout: 10 * time.Second, + } + + // Execute request + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %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 types.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) (*types.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") + + // Create HTTP client with timeout + client := &http.Client{ + Timeout: 10 * time.Second, + } + + // Execute request + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %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 types.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 []types.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), types.LogOptions{ + Level: types.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), types.LogOptions{ + Level: types.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 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", types.LogOptions{ + Level: types.Warn, + Prefix: "TMDB", + }) + return episodes + } + + if len(episodes) == 0 { + return episodes + } + + logger.Log(fmt.Sprintf("Enriching episodes for: %s", title), types.LogOptions{ + Level: types.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), types.LogOptions{ + Level: types.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), types.LogOptions{ + Level: types.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), types.LogOptions{ + Level: types.Warn, + Prefix: "TMDB", + }) + return episodes + } + + if len(shows) == 0 { + logger.Log(fmt.Sprintf("No TV shows found for: %s", title), types.LogOptions{ + Level: types.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), types.LogOptions{ + Level: types.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), types.LogOptions{ + Level: types.Warn, + Prefix: "TMDB", + }) + return episodes + } + + // Enrich episodes with descriptions + tmdbEpisodes := seasonDetails.Episodes + enrichedEpisodes := make([]types.AnimeSingleEpisode, len(episodes)) + copy(enrichedEpisodes, episodes) + + 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" + } + } else { + enrichedEpisodes[i].Description = "No description available" + } + } + + logger.Log(fmt.Sprintf("Successfully enriched %d episodes with descriptions for: %s", + len(enrichedEpisodes), title), types.LogOptions{ + Level: types.Success, + Prefix: "TMDB", + }) + + return enrichedEpisodes +} |
