aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2025-05-06 22:41:12 +0530
committerBobby <[email protected]>2025-05-06 22:41:12 +0530
commit8cc063b4f65c2181fde4c567f2cdc82a75cca9bf (patch)
treeed313f5f694491467e727a9e604531c9dc0a3ecf
parent8fa6d4dd33abe412bb09af949810ee0a1f9678bf (diff)
downloadmetachan-8cc063b4f65c2181fde4c567f2cdc82a75cca9bf.tar.xz
metachan-8cc063b4f65c2181fde4c567f2cdc82a75cca9bf.zip
additional data and seasons
-rw-r--r--database/anime.go9
-rw-r--r--types/anime.go118
-rw-r--r--utils/anime/anime.go135
-rw-r--r--utils/anime/jikan.go115
-rw-r--r--utils/anime/tvdb.go163
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
+}