aboutsummaryrefslogtreecommitdiff
path: root/utils
diff options
context:
space:
mode:
authorBobby <[email protected]>2025-04-18 19:28:01 +0530
committerBobby <[email protected]>2025-04-18 19:28:01 +0530
commit5dc2f648cf3d9634cfb8763cebc6f1dec4042914 (patch)
tree0a5e84fd9915f6f4e93f4fe026d04c0dd4a5db3c /utils
parent5c7536ec347c4c51172960da0c3f5857642fd223 (diff)
downloadmetachan-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.go731
-rw-r--r--utils/anime/jikan.go148
-rw-r--r--utils/anime/tmdb.go426
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
+}