diff options
| author | Bobby <[email protected]> | 2026-02-03 14:50:13 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-02-03 14:50:13 +0530 |
| commit | e4d65624c49c11db7da46d05f5e4caeec79bd955 (patch) | |
| tree | 57d7e68bffa0592129d1a75af9f05896bb6f926d | |
| parent | ed36e0308c7cd3a6197c899cb16bfe65cc5194b4 (diff) | |
| download | metachan-e4d65624c49c11db7da46d05f5e4caeec79bd955.tar.xz metachan-e4d65624c49c11db7da46d05f5e4caeec79bd955.zip | |
Add genre-based anime retrieval with pagination and related database updates
| -rw-r--r-- | controllers/anime.go | 46 | ||||
| -rw-r--r-- | database/anime.go | 17 | ||||
| -rw-r--r-- | router/router.go | 1 | ||||
| -rw-r--r-- | services/anime/service.go | 87 | ||||
| -rw-r--r-- | types/anime.go | 44 | ||||
| -rw-r--r-- | utils/api/jikan/jikan.go | 20 | ||||
| -rw-r--r-- | utils/api/jikan/types.go | 51 |
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 { |
