aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--controllers/anime.go46
-rw-r--r--database/anime.go17
-rw-r--r--router/router.go1
-rw-r--r--services/anime/service.go87
-rw-r--r--types/anime.go44
-rw-r--r--utils/api/jikan/jikan.go20
-rw-r--r--utils/api/jikan/types.go51
7 files changed, 244 insertions, 22 deletions
diff --git a/controllers/anime.go b/controllers/anime.go
index c410939..2027d72 100644
--- a/controllers/anime.go
+++ b/controllers/anime.go
@@ -1,6 +1,7 @@
package controllers
import (
+ "fmt"
"metachan/database"
"metachan/entities"
animeService "metachan/services/anime"
@@ -179,3 +180,48 @@ func GetGenres(c *fiber.Ctx) error {
"genres": genres,
})
}
+
+// GetAnimeByGenre retrieves paginated anime list for a specific genre
+func GetAnimeByGenre(c *fiber.Ctx) error {
+ genreID := mappers.ForceInt(c.Params("id"))
+ page := mappers.ForceInt(c.Query("page", "1"))
+ limit := mappers.ForceInt(c.Query("limit", "12"))
+
+ if limit > 100 {
+ limit = 100
+ }
+ if limit < 1 {
+ limit = 12
+ }
+ if page < 1 {
+ page = 1
+ }
+
+ logger.Log(fmt.Sprintf("Fetching anime for genre %d, page %d, limit %d", genreID, page, limit), logger.LogOptions{
+ Level: logger.Debug,
+ Prefix: "GenreController",
+ })
+
+ service := getAnimeService()
+ animeList, pagination, err := service.GetAnimeByGenre(genreID, page, limit)
+ if err != nil {
+ logger.Log("Failed to fetch anime by genre: "+err.Error(), logger.LogOptions{
+ Level: logger.Error,
+ Prefix: "GenreController",
+ })
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+ "error": "Failed to retrieve anime for genre",
+ })
+ }
+
+ return c.JSON(fiber.Map{
+ "pagination": fiber.Map{
+ "current_page": pagination.CurrentPage,
+ "has_next_page": pagination.HasNextPage,
+ "last_visible_page": pagination.LastVisiblePage,
+ "total": pagination.Items.Total,
+ "per_page": pagination.Items.PerPage,
+ },
+ "data": animeList,
+ })
+}
diff --git a/database/anime.go b/database/anime.go
index 5c06cad..891896e 100644
--- a/database/anime.go
+++ b/database/anime.go
@@ -64,6 +64,23 @@ func SaveAnimeToDatabase(animeData *types.Anime) error {
var existingAnime entities.Anime
result := tx.Where("mal_id = ?", animeData.MALID).First(&existingAnime)
if result.Error == nil {
+ // Delete all related records first to avoid UNIQUE constraint errors
+ tx.Where("anime_id = ?", existingAnime.ID).Delete(&entities.AnimeSingleEpisode{})
+ tx.Where("anime_id = ?", existingAnime.ID).Delete(&entities.AnimeCharacter{})
+ tx.Where("anime_id = ?", existingAnime.ID).Delete(&entities.ScheduleEpisode{})
+ tx.Where("anime_id = ?", existingAnime.ID).Delete(&entities.AnimeGenre{})
+ tx.Where("anime_id = ?", existingAnime.ID).Delete(&entities.AnimeProducer{})
+ tx.Where("anime_id = ?", existingAnime.ID).Delete(&entities.AnimeStudio{})
+ tx.Where("anime_id = ?", existingAnime.ID).Delete(&entities.AnimeLicensor{})
+ tx.Where("anime_id = ?", existingAnime.ID).Delete(&entities.AnimeSeason{})
+ tx.Where("anime_id = ?", existingAnime.ID).Delete(&entities.AnimeImages{})
+ tx.Where("anime_id = ?", existingAnime.ID).Delete(&entities.AnimeLogos{})
+ tx.Where("anime_id = ?", existingAnime.ID).Delete(&entities.AnimeCovers{})
+ tx.Where("anime_id = ?", existingAnime.ID).Delete(&entities.AnimeScores{})
+ tx.Where("anime_id = ?", existingAnime.ID).Delete(&entities.AiringStatus{})
+ tx.Where("anime_id = ?", existingAnime.ID).Delete(&entities.AnimeBroadcast{})
+
+ // Now delete the anime itself
if err := tx.Delete(&existingAnime).Error; err != nil {
tx.Rollback()
return err
diff --git a/router/router.go b/router/router.go
index 0a71947..14fa23e 100644
--- a/router/router.go
+++ b/router/router.go
@@ -13,6 +13,7 @@ func Initialize(router *fiber.App) {
// Anime routes
animeRouter := router.Group("/a")
animeRouter.Get("/genres", controllers.GetGenres)
+ animeRouter.Get("/genres/:id", controllers.GetAnimeByGenre)
animeRouter.Get("/:id", controllers.GetAnime)
animeRouter.Get("/:id/episodes", controllers.GetAnimeEpisodes)
animeRouter.Get("/:id/episodes/:episodeId", controllers.GetAnimeEpisode)
diff --git a/services/anime/service.go b/services/anime/service.go
index 553c6fd..a22752b 100644
--- a/services/anime/service.go
+++ b/services/anime/service.go
@@ -532,6 +532,93 @@ func (s *Service) GetAnimeDetails(mapping *entities.AnimeMapping) (*types.Anime,
return s.GetAnimeDetailsWithSource(mapping, "api")
}
+// GetAnimeByGenre fetches anime list by genre with pagination
+func (s *Service) GetAnimeByGenre(genreID int, page int, limit int) ([]types.Anime, struct {
+ LastVisiblePage int `json:"last_visible_page"`
+ HasNextPage bool `json:"has_next_page"`
+ CurrentPage int `json:"current_page"`
+ Items struct {
+ Count int `json:"count"`
+ Total int `json:"total"`
+ PerPage int `json:"per_page"`
+ } `json:"items"`
+}, error) {
+ // Fetch anime list from Jikan
+ response, err := s.jikanClient.GetAnimeByGenre(genreID, page, limit)
+ if err != nil {
+ return nil, struct {
+ LastVisiblePage int `json:"last_visible_page"`
+ HasNextPage bool `json:"has_next_page"`
+ CurrentPage int `json:"current_page"`
+ Items struct {
+ Count int `json:"count"`
+ Total int `json:"total"`
+ PerPage int `json:"per_page"`
+ } `json:"items"`
+ }{}, fmt.Errorf("failed to fetch anime by genre: %w", err)
+ }
+
+ animeList := make([]types.Anime, 0, len(response.Data))
+ stalenessThreshold := 7 * 24 * time.Hour // 7 days
+
+ // Process each anime - check DB first, fetch only if missing/stale
+ for _, item := range response.Data {
+ // Try to get from database first
+ cachedAnime, err := database.GetAnimeByMALID(item.MALID)
+ if err == nil && cachedAnime != nil {
+ // Check if data is fresh (updated within last 7 days)
+ var dbAnime entities.Anime
+ if dbErr := database.DB.Where("mal_id = ?", item.MALID).First(&dbAnime).Error; dbErr == nil {
+ if time.Since(dbAnime.LastUpdated) < stalenessThreshold {
+ // Data is fresh, use cached version
+ cachedAnime.Seasons = nil
+ cachedAnime.Episodes.Episodes = nil
+ cachedAnime.NextAiringEpisode = types.AnimeAiringEpisode{}
+ cachedAnime.AiringSchedule = nil
+ cachedAnime.Characters = nil
+ animeList = append(animeList, *cachedAnime)
+ continue
+ }
+ }
+ }
+
+ // Data is missing or stale, fetch from API
+ mapping, err := database.GetAnimeMappingViaMALID(item.MALID)
+ if err != nil {
+ mapping = &entities.AnimeMapping{MAL: item.MALID}
+ }
+
+ fullAnime, err := s.GetAnimeDetailsWithSource(mapping, "genre_listing")
+ if err != nil {
+ logger.Log(fmt.Sprintf("Failed to fetch full anime for MAL ID %d: %v", item.MALID, err), logger.LogOptions{
+ Level: logger.Error,
+ Prefix: "AnimeService",
+ })
+ // If fetch fails but we have cached data (even if stale), use it
+ if cachedAnime != nil {
+ cachedAnime.Seasons = nil
+ cachedAnime.Episodes.Episodes = nil
+ cachedAnime.NextAiringEpisode = types.AnimeAiringEpisode{}
+ cachedAnime.AiringSchedule = nil
+ cachedAnime.Characters = nil
+ animeList = append(animeList, *cachedAnime)
+ }
+ continue
+ }
+
+ // Clear fields not needed in genre listing (omitempty will handle JSON exclusion)
+ fullAnime.Seasons = nil
+ fullAnime.Episodes.Episodes = nil
+ fullAnime.NextAiringEpisode = types.AnimeAiringEpisode{}
+ fullAnime.AiringSchedule = nil
+ fullAnime.Characters = nil
+
+ animeList = append(animeList, *fullAnime)
+ }
+
+ return animeList, response.Pagination, nil
+}
+
// GetEpisodeStreaming fetches streaming sources for a specific episode
func (s *Service) GetEpisodeStreaming(title string, episodeNumber int, episodeID string, animeID uint) (*types.AnimeStreaming, error) {
// Try to get from database first
diff --git a/types/anime.go b/types/anime.go
index 0f79ef9..cad6558 100644
--- a/types/anime.go
+++ b/types/anime.go
@@ -10,18 +10,18 @@ type AnimeTitles struct {
// AnimeMappings contains IDs from various anime databases/services
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"`
+ AniDB int `json:"anidb,omitempty"`
+ Anilist int `json:"anilist,omitempty"`
+ AnimeCountdown int `json:"anime_countdown,omitempty"`
+ AnimePlanet string `json:"anime_planet,omitempty"`
+ AniSearch int `json:"ani_search,omitempty"`
+ IMDB string `json:"imdb,omitempty"`
+ Kitsu int `json:"kitsu,omitempty"`
+ LiveChart int `json:"live_chart,omitempty"`
+ NotifyMoe string `json:"notify_moe,omitempty"`
+ Simkl int `json:"simkl,omitempty"`
+ TMDB int `json:"tmdb,omitempty"`
+ TVDB int `json:"tvdb,omitempty"`
}
//
@@ -69,7 +69,7 @@ type AnimeEpisodes struct {
Aired int `json:"aired"`
Subbed int `json:"subbed"` // Count of subbed episodes
Dubbed int `json:"dubbed"` // Count of dubbed episodes
- Episodes []AnimeSingleEpisode `json:"episodes"`
+ Episodes []AnimeSingleEpisode `json:"episodes,omitempty"`
}
//
@@ -230,11 +230,11 @@ type Anime struct {
Airing bool `json:"airing"`
Status string `json:"status"`
AiringStatus AiringStatus `json:"airing_status"`
- Duration string `json:"duration"`
+ Duration string `json:"duration,omitempty"`
Images AnimeImages `json:"images"`
- Logos AnimeLogos `json:"logos"`
- Covers AnimeImages `json:"covers"`
- Color string `json:"color"`
+ Logos AnimeLogos `json:"logos,omitempty"`
+ Covers AnimeImages `json:"covers,omitempty"`
+ Color string `json:"color,omitempty"`
Genres []AnimeGenres `json:"genres"`
Scores AnimeScores `json:"scores"`
Season string `json:"season"`
@@ -243,10 +243,10 @@ type Anime struct {
Producers []AnimeProducer `json:"producers"`
Studios []AnimeStudio `json:"studios"`
Licensors []AnimeLicensor `json:"licensors"`
- Seasons []AnimeSeason `json:"seasons"`
+ Seasons []AnimeSeason `json:"seasons,omitempty"`
Episodes AnimeEpisodes `json:"episodes"`
- NextAiringEpisode AnimeAiringEpisode `json:"next_airing_episode"`
- AiringSchedule []AnimeAiringEpisode `json:"airing_schedule"`
- Characters []AnimeCharacter `json:"characters"`
- Mappings AnimeMappings `json:"mappings"`
+ NextAiringEpisode AnimeAiringEpisode `json:"next_airing_episode,omitempty"`
+ AiringSchedule []AnimeAiringEpisode `json:"airing_schedule,omitempty"`
+ Characters []AnimeCharacter `json:"characters,omitempty"`
+ Mappings AnimeMappings `json:"mappings,omitempty"`
}
diff --git a/utils/api/jikan/jikan.go b/utils/api/jikan/jikan.go
index 1e45c74..4f34dc3 100644
--- a/utils/api/jikan/jikan.go
+++ b/utils/api/jikan/jikan.go
@@ -280,3 +280,23 @@ func (c *JikanClient) GetAnimeGenres() (*JikanGenresResponse, error) {
return &genresResponse, nil
}
+
+// GetAnimeByGenre fetches paginated anime list for a specific genre
+func (c *JikanClient) GetAnimeByGenre(genreID int, page int, limit int) (*JikanAnimeListResponse, error) {
+ apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime?genres=%d&page=%d&limit=%d", genreID, page, limit)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ bodyBytes, err := c.makeRequest(ctx, apiURL)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get anime by genre: %w", err)
+ }
+
+ var listResponse JikanAnimeListResponse
+ if err := json.Unmarshal(bodyBytes, &listResponse); err != nil {
+ return nil, fmt.Errorf("failed to decode anime list response: %w", err)
+ }
+
+ return &listResponse, nil
+}
diff --git a/utils/api/jikan/types.go b/utils/api/jikan/types.go
index 393be56..6e7d7f7 100644
--- a/utils/api/jikan/types.go
+++ b/utils/api/jikan/types.go
@@ -27,6 +27,57 @@ type JikanGenresResponse struct {
Data []JikanGenre `json:"data"`
}
+// JikanAnimeListItem represents a single anime in list responses
+type JikanAnimeListItem struct {
+ MALID int `json:"mal_id"`
+ URL string `json:"url"`
+ 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"`
+ Synopsis string `json:"synopsis"`
+ 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"`
+ Season string `json:"season"`
+ Year int `json:"year"`
+ Images struct {
+ JPG struct {
+ ImageURL string `json:"image_url"`
+ SmallImageURL string `json:"small_image_url"`
+ LargeImageURL string `json:"large_image_url"`
+ } `json:"jpg"`
+ } `json:"images"`
+ Genres []JikanGenericMALStructure `json:"genres"`
+ ExplicitGenres []JikanGenericMALStructure `json:"explicit_genres"`
+ Producers []JikanGenericMALStructure `json:"producers"`
+ Licensors []JikanGenericMALStructure `json:"licensors"`
+ Studios []JikanGenericMALStructure `json:"studios"`
+}
+
+// JikanAnimeListResponse represents paginated anime list response
+type JikanAnimeListResponse struct {
+ Pagination struct {
+ LastVisiblePage int `json:"last_visible_page"`
+ HasNextPage bool `json:"has_next_page"`
+ CurrentPage int `json:"current_page"`
+ Items struct {
+ Count int `json:"count"`
+ Total int `json:"total"`
+ PerPage int `json:"per_page"`
+ } `json:"items"`
+ } `json:"pagination"`
+ Data []JikanAnimeListItem `json:"data"`
+}
+
// JikanAnimeResponse represents the main anime response from Jikan API
type JikanAnimeResponse struct {
Data struct {