aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.env.example4
-rw-r--r--config/config.go12
-rw-r--r--controllers/anime.go40
-rw-r--r--database/anime.go11
-rw-r--r--entities/anime.go33
-rw-r--r--entities/tasks.go30
-rw-r--r--router/router.go4
-rw-r--r--types/anime.go396
-rw-r--r--types/server.go6
-rw-r--r--types/tmdb.go54
-rw-r--r--utils/anime/anime.go731
-rw-r--r--utils/anime/jikan.go148
-rw-r--r--utils/anime/tmdb.go426
13 files changed, 1864 insertions, 31 deletions
diff --git a/.env.example b/.env.example
index 238c103..c401cdc 100644
--- a/.env.example
+++ b/.env.example
@@ -1,3 +1,5 @@
DB_DRIVER=sqlite # sqlite, mysql, postgres, sqlserver (case sensitive)
DSN=metachan.db
-PORT=3000 \ No newline at end of file
+PORT=3000
+TMDB_API_KEY=
+TMDB_READ_ACCESS_TOKEN= \ No newline at end of file
diff --git a/config/config.go b/config/config.go
index 212fc85..63b1e39 100644
--- a/config/config.go
+++ b/config/config.go
@@ -27,6 +27,10 @@ func init() {
DatabaseDriver: types.DatabaseDriver(getEnv("DB_DRIVER")),
DataSourceName: getEnv("DSN"),
Port: getIntEnv("PORT"),
+ TMDB: types.TMDBConfig{
+ APIKey: getEnv("TMDB_API_KEY"),
+ ReadAccessToken: getEnv("TMDB_READ_ACCESS_TOKEN"),
+ },
}
switch Config.DatabaseDriver {
@@ -43,6 +47,14 @@ func init() {
logger.Log("Invalid port or port not set", logOptions)
}
+ if Config.TMDB.APIKey == "" {
+ logger.Log("Invalid TMDB API key or TMDB API key not set", logOptions)
+ }
+
+ if Config.TMDB.ReadAccessToken == "" {
+ logger.Log("Invalid TMDB read access token or TMDB read access token not set", logOptions)
+ }
+
logOptions.Level = types.Success
logOptions.Fatal = false
logger.Log("Config initialized successfully", logOptions)
diff --git a/controllers/anime.go b/controllers/anime.go
new file mode 100644
index 0000000..67ffea1
--- /dev/null
+++ b/controllers/anime.go
@@ -0,0 +1,40 @@
+package controllers
+
+import (
+ "metachan/database"
+ "metachan/types"
+ "metachan/utils/anime"
+ "metachan/utils/logger"
+ "metachan/utils/mappers"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func GetAnimeByMALID(c *fiber.Ctx) error {
+ malID := c.Params("mal_id")
+ if malID == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "Query parameter MAL ID is required",
+ })
+ }
+
+ mapping, err := database.GetAnimeMappingViaMALID(mappers.ForceInt(malID))
+ if err != nil {
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
+ "error": "Anime not found",
+ })
+ }
+
+ anime, err := anime.GetAnimeDetails(mapping)
+ if err != nil {
+ logger.Log("Failed to fetch anime details: "+err.Error(), types.LogOptions{
+ Level: types.Error,
+ Prefix: "AnimeAPI",
+ })
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+ "error": "Failed to fetch anime details",
+ })
+ }
+
+ return c.JSON(anime)
+}
diff --git a/database/anime.go b/database/anime.go
new file mode 100644
index 0000000..9f2c26f
--- /dev/null
+++ b/database/anime.go
@@ -0,0 +1,11 @@
+package database
+
+import "metachan/entities"
+
+func GetAnimeMappingViaMALID(malID int) (*entities.AnimeMapping, error) {
+ var mapping entities.AnimeMapping
+ if err := DB.Where("mal = ?", malID).First(&mapping).Error; err != nil {
+ return nil, err
+ }
+ return &mapping, nil
+}
diff --git a/entities/anime.go b/entities/anime.go
new file mode 100644
index 0000000..707a4d9
--- /dev/null
+++ b/entities/anime.go
@@ -0,0 +1,33 @@
+package entities
+
+import "gorm.io/gorm"
+
+type MappingType string
+
+const (
+ m_SPECIAL MappingType = "SPECIAL"
+ m_TV MappingType = "TV"
+ m_OVA MappingType = "OVA"
+ m_MOVIE MappingType = "MOVIE"
+ m_ONA MappingType = "ONA"
+ m_UNKNOWN MappingType = "UNKNOWN"
+)
+
+type AnimeMapping struct {
+ gorm.Model
+ AniDB int
+ Anilist int
+ AnimeCountdown int
+ AnimePlanet string
+ AniSearch int
+ IMDB string
+ Kitsu int
+ LiveChart int
+ MAL int
+ NotifyMoe string
+ Simkl int
+ TMDB int
+ TVDB int
+ Type MappingType
+ MALAnilistComposite *string `gorm:"uniqueIndex"`
+}
diff --git a/entities/tasks.go b/entities/tasks.go
index 08395dc..06c1b65 100644
--- a/entities/tasks.go
+++ b/entities/tasks.go
@@ -13,33 +13,3 @@ type TaskLog struct {
Message string // error message if any
ExecutedAt time.Time
}
-
-type MappingType string
-
-const (
- m_SPECIAL MappingType = "SPECIAL"
- m_TV MappingType = "TV"
- m_OVA MappingType = "OVA"
- m_MOVIE MappingType = "MOVIE"
- m_ONA MappingType = "ONA"
- m_UNKNOWN MappingType = "UNKNOWN"
-)
-
-type AnimeMapping struct {
- gorm.Model
- AniDB int
- Anilist int
- AnimeCountdown int
- AnimePlanet string
- AniSearch int
- IMDB string
- Kitsu int
- LiveChart int
- MAL int
- NotifyMoe string
- Simkl int
- TMDB int
- TVDB int
- Type MappingType
- MALAnilistComposite *string `gorm:"uniqueIndex"`
-}
diff --git a/router/router.go b/router/router.go
index 1c48806..911ec63 100644
--- a/router/router.go
+++ b/router/router.go
@@ -10,6 +10,10 @@ func Initialize(router *fiber.App) {
// Health
router.Get("/health", controllers.HealthStatus)
+ // Anime routes
+ animeRouter := router.Group("/anime")
+ animeRouter.Get("/:mal_id", controllers.GetAnimeByMALID)
+
// 404 Default
router.Use(func(c *fiber.Ctx) error {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
diff --git a/types/anime.go b/types/anime.go
new file mode 100644
index 0000000..a644d7d
--- /dev/null
+++ b/types/anime.go
@@ -0,0 +1,396 @@
+package types
+
+type AnimeTitles struct {
+ English string `json:"english"`
+ Japanese string `json:"japanese"`
+ Romaji string `json:"romaji"`
+ Synonyms []string `json:"synonyms"`
+}
+
+type AnimeMappings struct {
+ AniDB int `json:"anidb"`
+ Anilist int `json:"anilist"`
+ AnimeCountdown int `json:"anime_countdown"`
+ AnimePlanet string `json:"anime_planet"`
+ AniSearch int `json:"ani_search"`
+ IMDB string `json:"imdb"`
+ Kitsu int `json:"kitsu"`
+ LiveChart int `json:"live_chart"`
+ NotifyMoe string `json:"notify_moe"`
+ Simkl int `json:"simkl"`
+ TMDB int `json:"tmdb"`
+ TVDB int `json:"tvdb"`
+}
+
+type EpisodeTitles struct {
+ English string `json:"english"`
+ Japanese string `json:"japanese"`
+ Romaji string `json:"romaji"`
+}
+
+type AnimeSingleEpisode struct {
+ Titles EpisodeTitles `json:"titles"`
+ Description string `json:"description"`
+ Aired string `json:"aired"`
+ Score float64 `json:"score"`
+ Filler bool `json:"filler"`
+ Recap bool `json:"recap"`
+ ForumURL string `json:"forum_url"`
+ URL string `json:"url"`
+}
+
+type AnimeEpisodes struct {
+ Total int `json:"total"`
+ Aired int `json:"aired"`
+ Episodes []AnimeSingleEpisode `json:"episodes"`
+}
+
+type Anime struct {
+ MALID int `json:"id"`
+ Titles AnimeTitles `json:"titles"`
+ Synopsis string `json:"synopsis"`
+ Type AniSyncType `json:"type"`
+ Source string `json:"source"`
+ Status string `json:"status"`
+ Duration string `json:"duration"`
+ Episodes AnimeEpisodes `json:"episodes"`
+ Mappings AnimeMappings `json:"mappings"`
+}
+
+type JikanPagination struct {
+ LastVisiblePage int `json:"last_visible_page"`
+ HasNextPage bool `json:"has_next_page"`
+}
+
+type JikanGenericMALStructure struct {
+ MALID int `json:"mal_id"`
+ Type string `json:"type"`
+ URL string `json:"url"`
+ Name string `json:"name"`
+}
+
+type JikanAnimeResponse struct {
+ Data struct {
+ MALID int `json:"mal_id"`
+ URL string `json:"url"`
+ Images struct {
+ JPG struct {
+ ImageURL string `json:"image_url"`
+ SmallImageURL string `json:"small_image_url"`
+ LargeImageURL string `json:"large_image_url"`
+ } `json:"jpg"`
+ WebP struct {
+ ImageURL string `json:"image_url"`
+ SmallImageURL string `json:"small_image_url"`
+ LargeImageURL string `json:"large_image_url"`
+ } `json:"webp"`
+ } `json:"images"`
+ Trailer struct {
+ YoutubeID string `json:"youtube_id"`
+ URL string `json:"url"`
+ EmbedURL string `json:"embed_url"`
+ Images struct {
+ ImageURL string `json:"image_url"`
+ SmallImageURL string `json:"small_image_url"`
+ MediumImageURL string `json:"medium_image_url"`
+ LargeImageURL string `json:"large_image_url"`
+ MaximumImageURL string `json:"maximum_image_url"`
+ } `json:"images"`
+ } `json:"trailer"`
+ Approved bool `json:"approved"`
+ Titles []struct {
+ Type string `json:"type"`
+ Title string `json:"title"`
+ } `json:"titles"`
+ Title string `json:"title"`
+ TitleEnglish string `json:"title_english"`
+ TitleJapanese string `json:"title_japanese"`
+ TitleSynonyms []string `json:"title_synonyms"`
+ Type string `json:"type"`
+ Source string `json:"source"`
+ Episodes int `json:"episodes"`
+ Status string `json:"status"`
+ Airing bool `json:"airing"`
+ Aired struct {
+ From string `json:"from"`
+ To string `json:"to"`
+ Prop struct {
+ From struct {
+ Day int `json:"day"`
+ Month int `json:"month"`
+ Year int `json:"year"`
+ } `json:"from"`
+ To struct {
+ Day int `json:"day"`
+ Month int `json:"month"`
+ Year int `json:"year"`
+ } `json:"to"`
+ } `json:"prop"`
+ String string `json:"string"`
+ } `json:"aired"`
+ Duration string `json:"duration"`
+ Rating string `json:"rating"`
+ Score float64 `json:"score"`
+ ScoredBy int `json:"scored_by"`
+ Rank int `json:"rank"`
+ Popularity int `json:"popularity"`
+ Members int `json:"members"`
+ Favorites int `json:"favorites"`
+ Synopsis string `json:"synopsis"`
+ Background string `json:"background"`
+ Season string `json:"season"`
+ Year int `json:"year"`
+ Broadcast struct {
+ Day string `json:"day"`
+ Time string `json:"time"`
+ Timezone string `json:"timezone"`
+ String string `json:"string"`
+ } `json:"broadcast"`
+ Producers []JikanGenericMALStructure `json:"producers"`
+ Licensors []JikanGenericMALStructure `json:"licensors"`
+ Studios []JikanGenericMALStructure `json:"studios"`
+ Genres []JikanGenericMALStructure `json:"genres"`
+ ExplicitGenres []JikanGenericMALStructure `json:"explicit_genres"`
+ Themes []JikanGenericMALStructure `json:"themes"`
+ Demographics []JikanGenericMALStructure `json:"demographics"`
+ Relations []struct {
+ Relation string `json:"relation"`
+ Entry []JikanGenericMALStructure `json:"entry"`
+ } `json:"relations"`
+ Theme struct {
+ Openings []string `json:"openings"`
+ Endings []string `json:"endings"`
+ } `json:"theme"`
+ External []struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ } `json:"external"`
+ Streaming []struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ } `json:"streaming"`
+ } `json:"data"`
+}
+
+type JikanAnimeEpisode struct {
+ MALID int `json:"mal_id"`
+ URL string `json:"url"`
+ Title string `json:"title"`
+ TitleJapanese string `json:"title_japanese"`
+ TitleRomaji string `json:"title_romaji"`
+ Aired string `json:"aired"`
+ Score float64 `json:"score"`
+ Filler bool `json:"filler"`
+ Recap bool `json:"recap"`
+ ForumURL string `json:"forum_url"`
+}
+
+type JikanAnimeEpisodeResponse struct {
+ Pagination JikanPagination `json:"pagination"`
+ Data []JikanAnimeEpisode `json:"data"`
+}
+
+type AnilistAnimeResponse struct {
+ Data struct {
+ Media struct {
+ ID int `json:"id"`
+ MALID int `json:"idMal"`
+ Title struct {
+ Romaji string `json:"romaji"`
+ English string `json:"english"`
+ Native string `json:"native"`
+ UserPreferred string `json:"userPreferred"`
+ } `json:"title"`
+ Type string `json:"type"`
+ Format string `json:"format"`
+ Status string `json:"status"`
+ Description string `json:"description"`
+ StartDate struct {
+ Year int `json:"year"`
+ Month int `json:"month"`
+ Day int `json:"day"`
+ } `json:"startDate"`
+ EndDate struct {
+ Year int `json:"year"`
+ Month int `json:"month"`
+ Day int `json:"day"`
+ } `json:"endDate"`
+ Season string `json:"season"`
+ SeasonYear int `json:"seasonYear"`
+ Episodes int `json:"episodes"`
+ Duration int `json:"duration"`
+ Chapters int `json:"chapters"`
+ Volumes int `json:"volumes"`
+ CountryOfOrigin string `json:"countryOfOrigin"`
+ IsLicensed bool `json:"isLicensed"`
+ Source string `json:"source"`
+ Hashtag string `json:"hashtag"`
+ Trailer struct {
+ ID string `json:"id"`
+ Site string `json:"site"`
+ Thumbnail string `json:"thumbnail"`
+ } `json:"trailer"`
+ CoverImage struct {
+ ExtraLarge string `json:"extraLarge"`
+ Large string `json:"large"`
+ Medium string `json:"medium"`
+ Color string `json:"color"`
+ } `json:"coverImage"`
+ BannerImage string `json:"bannerImage"`
+ Genres []string `json:"genres"`
+ Synonyms []string `json:"synonyms"`
+ AverageScore int `json:"averageScore"`
+ MeanScore int `json:"meanScore"`
+ Popularity int `json:"popularity"`
+ IsLocked bool `json:"isLocked"`
+ Trending int `json:"trending"`
+ Favorites int `json:"favorites"`
+ Tags []struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Category string `json:"category"`
+ Rank int `json:"rank"`
+ IsGeneralSpoiler bool `json:"isGeneralSpoiler"`
+ IsMediaSpoiler bool `json:"isMediaSpoiler"`
+ IsAdult bool `json:"isAdult"`
+ } `json:"tags"`
+ Relations struct {
+ Edges []struct {
+ ID int `json:"id"`
+ RelationType string `json:"relationType"`
+ Node struct {
+ ID int `json:"id"`
+ Title struct {
+ Romaji string `json:"romaji"`
+ English string `json:"english"`
+ Native string `json:"native"`
+ UserPreferred string `json:"userPreferred"`
+ } `json:"title"`
+ Format string `json:"format"`
+ Type string `json:"type"`
+ Status string `json:"status"`
+ CoverImage struct {
+ ExtraLarge string `json:"extraLarge"`
+ Large string `json:"large"`
+ Medium string `json:"medium"`
+ Color string `json:"color"`
+ } `json:"coverImage"`
+ BannerImage string `json:"bannerImage"`
+ } `json:"node"`
+ } `json:"edges"`
+ } `json:"relations"`
+ Characters struct {
+ Edges []struct {
+ Role string `json:"role"`
+ Node struct {
+ ID int `json:"id"`
+ Name struct {
+ First string `json:"first"`
+ Last string `json:"last"`
+ Middle string `json:"middle"`
+ Full string `json:"full"`
+ Native string `json:"native"`
+ UserPreferred string `json:"userPreferred"`
+ } `json:"name"`
+ Image struct {
+ Large string `json:"large"`
+ Medium string `json:"medium"`
+ } `json:"image"`
+ Description string `json:"description"`
+ Age string `json:"age"`
+ } `json:"node"`
+ } `json:"edges"`
+ } `json:"characters"`
+ Staff struct {
+ Edges []struct {
+ Role string `json:"role"`
+ Node struct {
+ ID int `json:"id"`
+ Name struct {
+ First string `json:"first"`
+ Last string `json:"last"`
+ Middle string `json:"middle"`
+ Full string `json:"full"`
+ Native string `json:"native"`
+ UserPreferred string `json:"userPreferred"`
+ } `json:"name"`
+ Image struct {
+ Large string `json:"large"`
+ Medium string `json:"medium"`
+ } `json:"image"`
+ Description string `json:"description"`
+ PrimaryOccupations []string `json:"primaryOccupations"`
+ Gender string `json:"gender"`
+ Age int `json:"age"`
+ LanguageV2 string `json:"languageV2"`
+ } `json:"node"`
+ } `json:"edges"`
+ } `json:"staff"`
+ Studios struct {
+ Edges []struct {
+ IsMain bool `json:"isMain"`
+ Node struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ } `json:"node"`
+ } `json:"edges"`
+ } `json:"studios"`
+ IsAdult bool `json:"isAdult"`
+ NextAiringEpisode struct {
+ ID int `json:"id"`
+ AiringAt int `json:"airingAt"`
+ TimeUntilAiring int `json:"timeUntilAiring"`
+ Episode int `json:"episode"`
+ } `json:"nextAiringEpisode"`
+ AiringSchedule struct {
+ Nodes []struct {
+ ID int `json:"id"`
+ Episode int `json:"episode"`
+ AiringAt int `json:"airingAt"`
+ TimeUntilAiring int `json:"timeUntilAiring"`
+ }
+ } `json:"nodes"`
+ Trends struct {
+ Nodes []struct {
+ Date int `json:"date"`
+ Trending int `json:"trending"`
+ Popularity int `json:"popularity"`
+ InProgress int `json:"inProgress"`
+ } `json:"nodes"`
+ } `json:"trends"`
+ ExternalLinks []struct {
+ ID int `json:"id"`
+ URL string `json:"url"`
+ Site string `json:"site"`
+ } `json:"externalLinks"`
+ StreamingEpisodes []struct {
+ Title string `json:"title"`
+ Thumbnail string `json:"thumbnail"`
+ URL string `json:"url"`
+ Site string `json:"site"`
+ } `json:"streamingEpisodes"`
+ Rankings []struct {
+ ID int `json:"id"`
+ Rank int `json:"rank"`
+ Type string `json:"type"`
+ Format string `json:"format"`
+ Year int `json:"year"`
+ Season string `json:"season"`
+ AllTime bool `json:"allTime"`
+ Context string `json:"context"`
+ } `json:"rankings"`
+ Stats struct {
+ ScoreDistribution []struct {
+ Score int `json:"score"`
+ Amount int `json:"amount"`
+ } `json:"scoreDistribution"`
+ StatusDistribution []struct {
+ Status string `json:"status"`
+ Amount int `json:"amount"`
+ } `json:"statusDistribution"`
+ } `json:"stats"`
+ SiteURL string `json:"siteUrl"`
+ } `json:"media"`
+ } `json:"data"`
+}
diff --git a/types/server.go b/types/server.go
index bc9d8a5..1c7af2b 100644
--- a/types/server.go
+++ b/types/server.go
@@ -9,8 +9,14 @@ const (
SQLServer DatabaseDriver = "sqlserver"
)
+type TMDBConfig struct {
+ APIKey string
+ ReadAccessToken string
+}
+
type ServerConfig struct {
DatabaseDriver DatabaseDriver
DataSourceName string
Port int
+ TMDB TMDBConfig
}
diff --git a/types/tmdb.go b/types/tmdb.go
new file mode 100644
index 0000000..092f6c1
--- /dev/null
+++ b/types/tmdb.go
@@ -0,0 +1,54 @@
+package types
+
+// 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"`
+}
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
+}