diff options
| author | Bobby <[email protected]> | 2025-05-06 22:41:12 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2025-05-06 22:41:12 +0530 |
| commit | 8cc063b4f65c2181fde4c567f2cdc82a75cca9bf (patch) | |
| tree | ed313f5f694491467e727a9e604531c9dc0a3ecf | |
| parent | 8fa6d4dd33abe412bb09af949810ee0a1f9678bf (diff) | |
| download | metachan-8cc063b4f65c2181fde4c567f2cdc82a75cca9bf.tar.xz metachan-8cc063b4f65c2181fde4c567f2cdc82a75cca9bf.zip | |
additional data and seasons
| -rw-r--r-- | database/anime.go | 9 | ||||
| -rw-r--r-- | types/anime.go | 118 | ||||
| -rw-r--r-- | utils/anime/anime.go | 135 | ||||
| -rw-r--r-- | utils/anime/jikan.go | 115 | ||||
| -rw-r--r-- | utils/anime/tvdb.go | 163 |
5 files changed, 519 insertions, 21 deletions
diff --git a/database/anime.go b/database/anime.go index 9f2c26f..44382bf 100644 --- a/database/anime.go +++ b/database/anime.go @@ -9,3 +9,12 @@ func GetAnimeMappingViaMALID(malID int) (*entities.AnimeMapping, error) { } return &mapping, nil } + +// GetAnimeMappingsByTVDBID retrieves all anime mappings that share the same TVDB ID +func GetAnimeMappingsByTVDBID(tvdbID int) ([]entities.AnimeMapping, error) { + var mappings []entities.AnimeMapping + if err := DB.Where("tvdb = ?", tvdbID).Find(&mappings).Error; err != nil { + return nil, err + } + return mappings, nil +} diff --git a/types/anime.go b/types/anime.go index 4e45bfb..2a54131 100644 --- a/types/anime.go +++ b/types/anime.go @@ -54,27 +54,107 @@ type AnimeLogos struct { Original string `json:"original,omitempty"` } -// type AnimeSeason struct { -// MALID int `json:"mal_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"` -// Mappings AnimeMappings `json:"mappings"` +type AnimeImages struct { + Small string `json:"small,omitempty"` + Large string `json:"large,omitempty"` + Original string `json:"original,omitempty"` +} + +type AnimeGenres struct { + Name string `json:"name"` + GenreID int `json:"genre_id"` + URL string `json:"url"` +} + +type AnimeProducer struct { + Name string `json:"name"` + ProducerID int `json:"producer_id"` + URL string `json:"url"` +} + +type AnimeLicensor struct { + Name string `json:"name"` + ProducerID int `json:"producer_id"` + URL string `json:"url"` +} + +type AnimeStudio struct { + Name string `json:"name"` + StudioID int `json:"studio_id"` + URL string `json:"url"` +} + +type AiringStatusDates struct { + Day int `json:"day"` + Month int `json:"month"` + Year int `json:"year"` + String string `json:"string"` +} + +type AiringStatus struct { + From AiringStatusDates `json:"from"` + To AiringStatusDates `json:"to"` + String string `json:"string"` +} + +type AnimeScores struct { + 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"` +} + +type AnimeBroadcast struct { + Day string `json:"day"` + Time string `json:"time"` + Timezone string `json:"timezone"` + String string `json:"string"` +} + +type AnimeSeason struct { + MALID int `json:"id"` + Titles AnimeTitles `json:"titles"` + Synopsis string `json:"synopsis"` + Type AniSyncType `json:"type"` + Source string `json:"source"` + Airing bool `json:"airing"` + Status string `json:"status"` + AiringStatus AiringStatus `json:"airing_status"` + Duration string `json:"duration"` + Images AnimeImages `json:"images"` + Scores AnimeScores `json:"scores"` + Season string `json:"season"` + Year int `json:"year"` + Current bool `json:"current"` +} 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"` - Logos AnimeLogos `json:"logos"` - Episodes AnimeEpisodes `json:"episodes"` - Mappings AnimeMappings `json:"mappings"` + MALID int `json:"id"` + Titles AnimeTitles `json:"titles"` + Synopsis string `json:"synopsis"` + Type AniSyncType `json:"type"` + Source string `json:"source"` + Airing bool `json:"airing"` + Status string `json:"status"` + AiringStatus AiringStatus `json:"airing_status"` + Duration string `json:"duration"` + Images AnimeImages `json:"images"` + Logos AnimeLogos `json:"logos"` + Covers AnimeImages `json:"covers"` + Color string `json:"color"` + Genres []AnimeGenres `json:"genres"` + Scores AnimeScores `json:"scores"` + Season string `json:"season"` + Year int `json:"year"` + Broadcast AnimeBroadcast `json:"broadcast"` + Producers []AnimeProducer `json:"producers"` + Studios []AnimeStudio `json:"studios"` + Licensors []AnimeLicensor `json:"licensors"` + Seasons []AnimeSeason `json:"seasons"` + Episodes AnimeEpisodes `json:"episodes"` + Mappings AnimeMappings `json:"mappings"` } type JikanPagination struct { diff --git a/utils/anime/anime.go b/utils/anime/anime.go index 885c9f9..ab10323 100644 --- a/utils/anime/anime.go +++ b/utils/anime/anime.go @@ -10,7 +10,7 @@ import ( func GetAnimeDetails(animeMapping *entities.AnimeMapping) (*types.Anime, error) { malID := animeMapping.MAL - anime, err := getAnimeViaJikan(malID) + anime, err := getFullAnimeViaJikan(malID) if err != nil { return nil, fmt.Errorf("failed to get anime details: %w", err) } @@ -46,6 +46,28 @@ func GetAnimeDetails(animeMapping *entities.AnimeMapping) (*types.Anime, error) logos = types.AnimeLogos{} } + // Get seasons information if TVDB ID is available + var seasons []types.AnimeSeason + if animeMapping.TVDB != 0 { + // Get all mappings for this TVDB ID (representing different seasons) + seasonMappings, err := FindSeasonMappings(animeMapping.TVDB) + if err != nil { + logger.Log(fmt.Sprintf("Failed to find season mappings: %v", err), types.LogOptions{ + Level: types.Warn, + Prefix: "AnimeAPI", + }) + } else if len(seasonMappings) > 0 { + // Process the season mappings to get season details + seasons, err = GetAnimeSeason(&seasonMappings, malID) + if err != nil { + logger.Log(fmt.Sprintf("Failed to get anime seasons: %v", err), types.LogOptions{ + Level: types.Warn, + Prefix: "AnimeAPI", + }) + } + } + } + animeDetails := &types.Anime{ MALID: malID, Titles: types.AnimeTitles{ @@ -57,9 +79,57 @@ func GetAnimeDetails(animeMapping *entities.AnimeMapping) (*types.Anime, error) Synopsis: anime.Data.Synopsis, Type: types.AniSyncType(animeMapping.Type), Source: anime.Data.Source, + Airing: anime.Data.Airing, Status: anime.Data.Status, + AiringStatus: types.AiringStatus{ + From: types.AiringStatusDates{ + Day: anime.Data.Aired.Prop.From.Day, + Month: anime.Data.Aired.Prop.From.Month, + Year: anime.Data.Aired.Prop.From.Year, + String: anime.Data.Aired.From, + }, + To: types.AiringStatusDates{ + Day: anime.Data.Aired.Prop.To.Day, + Month: anime.Data.Aired.Prop.To.Month, + Year: anime.Data.Aired.Prop.To.Year, + String: anime.Data.Aired.To, + }, + String: anime.Data.Aired.String, + }, Duration: anime.Data.Duration, - Logos: logos, + Images: types.AnimeImages{ + Small: anime.Data.Images.JPG.SmallImageURL, + Large: anime.Data.Images.JPG.LargeImageURL, + Original: anime.Data.Images.JPG.ImageURL, + }, + Logos: logos, + Covers: types.AnimeImages{ + Small: anilistAnime.Data.Media.CoverImage.Medium, + Large: anilistAnime.Data.Media.CoverImage.Large, + Original: anilistAnime.Data.Media.CoverImage.ExtraLarge, + }, + Color: anilistAnime.Data.Media.CoverImage.Color, + Genres: generateGenres(anime.Data.Genres, anime.Data.ExplicitGenres), + Scores: types.AnimeScores{ + Score: anime.Data.Score, + ScoredBy: anime.Data.ScoredBy, + Rank: anime.Data.Rank, + Popularity: anime.Data.Popularity, + Members: anime.Data.Members, + Favorites: anime.Data.Favorites, + }, + Season: anime.Data.Season, + Year: anime.Data.Year, + Broadcast: types.AnimeBroadcast{ + Day: anime.Data.Broadcast.Day, + Time: anime.Data.Broadcast.Time, + Timezone: anime.Data.Broadcast.Timezone, + String: anime.Data.Broadcast.String, + }, + Producers: generateProducers(anime.Data.Producers), + Studios: generateStudios(anime.Data.Studios), + Licensors: generateLicensors(anime.Data.Licensors), + Seasons: seasons, // Add seasons information Episodes: types.AnimeEpisodes{ Total: getEpisodeCount(anime, anilistAnime), Aired: len(episodes.Data), @@ -90,3 +160,64 @@ func getEpisodeCount(malAnime *types.JikanAnimeResponse, anilistAnime *types.Ani return episodes } + +func generateGenres(genres, explicitGenres []types.JikanGenericMALStructure) []types.AnimeGenres { + animeGenres := make([]types.AnimeGenres, len(genres)+len(explicitGenres)) + counter := 0 + + for _, genre := range genres { + animeGenres[counter] = types.AnimeGenres{ + Name: genre.Name, + GenreID: genre.MALID, + URL: genre.URL, + } + counter++ + } + + for _, genre := range explicitGenres { + animeGenres[counter] = types.AnimeGenres{ + Name: genre.Name, + GenreID: genre.MALID, + URL: genre.URL, + } + counter++ + } + + return animeGenres +} + +func generateStudios(genericPLS []types.JikanGenericMALStructure) []types.AnimeStudio { + studios := make([]types.AnimeStudio, len(genericPLS)) + for i, studio := range genericPLS { + studios[i] = types.AnimeStudio{ + Name: studio.Name, + StudioID: studio.MALID, + URL: studio.URL, + } + } + return studios +} + +func generateProducers(genericPLS []types.JikanGenericMALStructure) []types.AnimeProducer { + producers := make([]types.AnimeProducer, len(genericPLS)) + for i, producer := range genericPLS { + producers[i] = types.AnimeProducer{ + Name: producer.Name, + ProducerID: producer.MALID, + URL: producer.URL, + } + } + return producers +} + +func generateLicensors(genericPLS []types.JikanGenericMALStructure) []types.AnimeLicensor { + licensors := make([]types.AnimeLicensor, len(genericPLS)) + for i, licensor := range genericPLS { + licensors[i] = types.AnimeLicensor{ + Name: licensor.Name, + ProducerID: licensor.MALID, + URL: licensor.URL, + } + } + return licensors +} diff --git a/utils/anime/jikan.go b/utils/anime/jikan.go index d51a3ca..c30a871 100644 --- a/utils/anime/jikan.go +++ b/utils/anime/jikan.go @@ -155,6 +155,121 @@ func waitForJikanRequest() { } func getAnimeViaJikan(malID int) (*types.JikanAnimeResponse, error) { + apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d", 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() + + if resp.StatusCode == http.StatusTooManyRequests { + if retries < maxRetries { + retries++ + backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) + 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) + } + // 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, &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 getFullAnimeViaJikan(malID int) (*types.JikanAnimeResponse, error) { apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/full", malID) maxRetries := 3 baseBackoff := 1 * time.Second diff --git a/utils/anime/tvdb.go b/utils/anime/tvdb.go new file mode 100644 index 0000000..4206c24 --- /dev/null +++ b/utils/anime/tvdb.go @@ -0,0 +1,163 @@ +package anime + +import ( + "fmt" + "metachan/database" + "metachan/entities" + "metachan/types" + "metachan/utils/logger" + "sort" + "strings" +) + +// GetAnimeSeason prepares season information for an anime +func GetAnimeSeason(mappings *[]entities.AnimeMapping, currentMALID int) ([]types.AnimeSeason, error) { + var seasons []types.AnimeSeason + + for _, mapping := range *mappings { + // Fetch basic anime info from Jikan API + animeDetails, err := getAnimeViaJikan(mapping.MAL) + if err != nil { + logger.Log(fmt.Sprintf("Failed to get anime details for MAL ID %d: %v", mapping.MAL, err), types.LogOptions{ + Level: types.Warn, + Prefix: "AnimeSeason", + }) + continue + } + + // Build the season object + season := types.AnimeSeason{ + MALID: mapping.MAL, + Titles: types.AnimeTitles{ + English: animeDetails.Data.TitleEnglish, + Japanese: animeDetails.Data.TitleJapanese, + Romaji: animeDetails.Data.Title, + Synonyms: animeDetails.Data.TitleSynonyms, + }, + Synopsis: animeDetails.Data.Synopsis, + Type: types.AniSyncType(mapping.Type), + Source: animeDetails.Data.Source, + Airing: animeDetails.Data.Airing, + Status: animeDetails.Data.Status, + AiringStatus: types.AiringStatus{ + From: types.AiringStatusDates{ + Day: animeDetails.Data.Aired.Prop.From.Day, + Month: animeDetails.Data.Aired.Prop.From.Month, + Year: animeDetails.Data.Aired.Prop.From.Year, + String: animeDetails.Data.Aired.From, + }, + To: types.AiringStatusDates{ + Day: animeDetails.Data.Aired.Prop.To.Day, + Month: animeDetails.Data.Aired.Prop.To.Month, + Year: animeDetails.Data.Aired.Prop.To.Year, + String: animeDetails.Data.Aired.To, + }, + String: animeDetails.Data.Aired.String, + }, + Duration: animeDetails.Data.Duration, + Images: types.AnimeImages{ + Small: animeDetails.Data.Images.JPG.SmallImageURL, + Large: animeDetails.Data.Images.JPG.LargeImageURL, + Original: animeDetails.Data.Images.JPG.ImageURL, + }, + Scores: types.AnimeScores{ + Score: animeDetails.Data.Score, + ScoredBy: animeDetails.Data.ScoredBy, + Rank: animeDetails.Data.Rank, + Popularity: animeDetails.Data.Popularity, + Members: animeDetails.Data.Members, + Favorites: animeDetails.Data.Favorites, + }, + Season: animeDetails.Data.Season, + Year: animeDetails.Data.Year, + Current: mapping.MAL == currentMALID, // Mark if this is the current season + } + + seasons = append(seasons, season) + } + + // Sort seasons chronologically by air date + if len(seasons) > 1 { + sortSeasonsByAirDate(&seasons) + logger.Log(fmt.Sprintf("Found and sorted %d seasons for anime", len(seasons)), types.LogOptions{ + Level: types.Info, + Prefix: "AnimeSeason", + }) + } + + return seasons, nil +} + +// sortSeasonsByAirDate sorts the seasons array chronologically by air date +func sortSeasonsByAirDate(seasons *[]types.AnimeSeason) { + // Use a slice instead of a pointer to slice to make the code cleaner + s := *seasons + + // Sort by air date using the structured fields (year, month, day) + sort.Slice(s, func(i, j int) bool { + // Compare years first + if s[i].AiringStatus.From.Year != s[j].AiringStatus.From.Year { + return s[i].AiringStatus.From.Year < s[j].AiringStatus.From.Year + } + + // If years are equal, compare months + if s[i].AiringStatus.From.Month != s[j].AiringStatus.From.Month { + return s[i].AiringStatus.From.Month < s[j].AiringStatus.From.Month + } + + // If months are equal, compare days + if s[i].AiringStatus.From.Day != s[j].AiringStatus.From.Day { + return s[i].AiringStatus.From.Day < s[j].AiringStatus.From.Day + } + + // If all date fields are equal, use season as a tiebreaker + if s[i].Season != s[j].Season { + seasonOrder := map[string]int{ + "winter": 0, + "spring": 1, + "summer": 2, + "fall": 3, + "": 4, // Unknown season comes last + } + + seasonI := strings.ToLower(s[i].Season) + seasonJ := strings.ToLower(s[j].Season) + + return seasonOrder[seasonI] < seasonOrder[seasonJ] + } + + // If everything is equal, preserve original order (stable sort) + return i < j + }) + + // Update the original slice + *seasons = s +} + +// FindSeasonMappings finds all anime mappings that belong to the same series based on TVDB ID +func FindSeasonMappings(tvdbID int) ([]entities.AnimeMapping, error) { + logger.Log(fmt.Sprintf("Finding season mappings for TVDB ID %d", tvdbID), types.LogOptions{ + Level: types.Debug, + Prefix: "TVDB", + }) + + // Use our database function to find all mappings with the same TVDB ID + mappings, err := database.GetAnimeMappingsByTVDBID(tvdbID) + if err != nil { + return nil, fmt.Errorf("failed to get season mappings: %w", err) + } + + if len(mappings) == 0 { + logger.Log(fmt.Sprintf("No season mappings found for TVDB ID %d", tvdbID), types.LogOptions{ + Level: types.Debug, + Prefix: "TVDB", + }) + } else { + logger.Log(fmt.Sprintf("Found %d season mappings for TVDB ID %d", len(mappings), tvdbID), types.LogOptions{ + Level: types.Info, + Prefix: "TVDB", + }) + } + + return mappings, nil +} |
