From 5dc2f648cf3d9634cfb8763cebc6f1dec4042914 Mon Sep 17 00:00:00 2001 From: Bobby Date: Fri, 18 Apr 2025 19:28:01 +0530 Subject: basic anime details with episodes; added jikan and anilist as metadata fetchers; added tmdb for fetching episode descriptions --- .env.example | 4 +- config/config.go | 12 + controllers/anime.go | 40 +++ database/anime.go | 11 + entities/anime.go | 33 +++ entities/tasks.go | 30 --- router/router.go | 4 + types/anime.go | 396 ++++++++++++++++++++++++++++ types/server.go | 6 + types/tmdb.go | 54 ++++ utils/anime/anime.go | 731 +++++++++++++++++++++++++++++++++++++++++++++++++++ utils/anime/jikan.go | 148 +++++++++++ utils/anime/tmdb.go | 426 ++++++++++++++++++++++++++++++ 13 files changed, 1864 insertions(+), 31 deletions(-) create mode 100644 controllers/anime.go create mode 100644 database/anime.go create mode 100644 entities/anime.go create mode 100644 types/anime.go create mode 100644 types/tmdb.go create mode 100644 utils/anime/anime.go create mode 100644 utils/anime/jikan.go create mode 100644 utils/anime/tmdb.go 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 +} -- cgit v1.2.3