diff options
| author | Bobby <[email protected]> | 2026-01-20 18:13:48 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-01-20 18:13:48 +0530 |
| commit | ed36e0308c7cd3a6197c899cb16bfe65cc5194b4 (patch) | |
| tree | 4c0edabf651fc9b28648760920982c938fdf3d19 | |
| parent | df6cf3edcbb560e7615ad13d8daf4843507eb11e (diff) | |
| download | metachan-ed36e0308c7cd3a6197c899cb16bfe65cc5194b4.tar.xz metachan-ed36e0308c7cd3a6197c899cb16bfe65cc5194b4.zip | |
Implement genre synchronization from MAL via Jikan API and add genre retrieval endpoint
| -rw-r--r-- | controllers/anime.go | 18 | ||||
| -rw-r--r-- | database/anime.go | 54 | ||||
| -rw-r--r-- | entities/anime.go | 259 | ||||
| -rw-r--r-- | router/router.go | 1 | ||||
| -rw-r--r-- | tasks/genre_sync.task.go | 82 | ||||
| -rw-r--r-- | tasks/tasks.go | 14 | ||||
| -rw-r--r-- | utils/api/jikan/jikan.go | 20 | ||||
| -rw-r--r-- | utils/api/jikan/types.go | 13 |
8 files changed, 199 insertions, 262 deletions
diff --git a/controllers/anime.go b/controllers/anime.go index 753effb..c410939 100644 --- a/controllers/anime.go +++ b/controllers/anime.go @@ -161,3 +161,21 @@ func getAnimeMapping(c *fiber.Ctx) (*entities.AnimeMapping, error) { return mapping, nil } + +// GetGenres retrieves all genres from the database +func GetGenres(c *fiber.Ctx) error { + genres, err := database.GetAllGenres() + if err != nil { + logger.Log("Failed to get genres: "+err.Error(), logger.LogOptions{ + Level: logger.Error, + Prefix: "Controller", + }) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to retrieve genres", + }) + } + + return c.JSON(fiber.Map{ + "genres": genres, + }) +} diff --git a/database/anime.go b/database/anime.go index e2da1f9..5c06cad 100644 --- a/database/anime.go +++ b/database/anime.go @@ -170,15 +170,35 @@ func SaveAnimeToDatabase(animeData *types.Anime) error { } } - // Save genres + // Save genres - link to master genres instead of creating duplicates if len(animeData.Genres) > 0 { - anime.Genres = make([]entities.AnimeGenre, len(animeData.Genres)) - for i, genre := range animeData.Genres { - anime.Genres[i] = entities.AnimeGenre{ + anime.Genres = make([]entities.AnimeGenre, 0, len(animeData.Genres)) + for _, genre := range animeData.Genres { + // Check if master genre exists + var masterGenre entities.AnimeGenre + err := DB.Where("genre_id = ? AND anime_id = 0", genre.GenreID).First(&masterGenre).Error + + // Create anime-specific genre link + animeGenre := entities.AnimeGenre{ Name: genre.Name, GenreID: genre.GenreID, URL: genre.URL, + Count: 0, // Count is only for master genres } + + // If master genre doesn't exist, the link will still work + // Genre sync will create the master genre later + if err != nil { + // Master genre doesn't exist yet, that's okay + animeGenre.Name = genre.Name + animeGenre.URL = genre.URL + } else { + // Use data from master genre for consistency + animeGenre.Name = masterGenre.Name + animeGenre.URL = masterGenre.URL + } + + anime.Genres = append(anime.Genres, animeGenre) } } @@ -720,6 +740,32 @@ func GetEpisodeStreaming(episodeID string, animeID uint) (*entities.EpisodeStrea return &streaming, nil } +// GetAllGenres retrieves all master genres (AnimeID = 0) with MAL counts +func GetAllGenres() ([]map[string]interface{}, error) { + var results []entities.AnimeGenre + + err := DB.Where("anime_id = 0"). + Order("count DESC, name ASC"). + Find(&results).Error + + if err != nil { + return nil, err + } + + // Convert to map format + genres := make([]map[string]interface{}, len(results)) + for i, r := range results { + genres[i] = map[string]interface{}{ + "id": r.GenreID, + "name": r.Name, + "url": r.URL, + "count": r.Count, + } + } + + return genres, nil +} + // SaveEpisodeStreaming saves streaming data to the database func SaveEpisodeStreaming(episodeID string, animeID uint, subSources, dubSources []types.AnimeStreamingSource) error { tx := DB.Begin() diff --git a/entities/anime.go b/entities/anime.go index 737cae4..34f3b04 100644 --- a/entities/anime.go +++ b/entities/anime.go @@ -147,6 +147,7 @@ type AnimeGenre struct { Name string GenreID int URL string + Count int `gorm:"default:0"` // Total count from MAL (only set when AnimeID=0 for master genres) } type AnimeProducer struct { @@ -258,264 +259,6 @@ type NextEpisode struct { Episode int } -// CachedAnime represents the main cached anime entity in database -type CachedAnime struct { - gorm.Model - MALID int `gorm:"uniqueIndex"` - TitleRomaji string - TitleEnglish string - TitleJapanese string - TitleSynonyms string // Comma-separated list of synonyms - Synopsis string `gorm:"type:text"` - Type string - Source string - Airing bool - Status string - Duration string - Color string - Season string - Year int - SubbedCount int - DubbedCount int - TotalEpisodes int - AiredEpisodes int - LastUpdated time.Time - - // Relationships - Images *CachedAnimeImages `gorm:"foreignKey:AnimeID"` - Logos *CachedAnimeLogos `gorm:"foreignKey:AnimeID"` - Covers *CachedAnimeCovers `gorm:"foreignKey:AnimeID"` - Scores *CachedAnimeScores `gorm:"foreignKey:AnimeID"` - AiringStatus *CachedAiringStatus `gorm:"foreignKey:AnimeID"` - Broadcast *CachedAnimeBroadcast `gorm:"foreignKey:AnimeID"` - NextAiringEpisode *CachedNextEpisode `gorm:"foreignKey:AnimeID"` - - // One-to-many relationships - Genres []CachedAnimeGenre `gorm:"foreignKey:AnimeID"` - Producers []CachedAnimeProducer `gorm:"foreignKey:AnimeID"` - Studios []CachedAnimeStudio `gorm:"foreignKey:AnimeID"` - Licensors []CachedAnimeLicensor `gorm:"foreignKey:AnimeID"` - Episodes []CachedAnimeSingleEpisode `gorm:"foreignKey:AnimeID"` - Characters []CachedAnimeCharacter `gorm:"foreignKey:AnimeID"` - AiringSchedule []CachedScheduleEpisode `gorm:"foreignKey:AnimeID;constraint:OnDelete:CASCADE"` - Seasons []CachedAnimeSeason `gorm:"foreignKey:ParentAnimeID"` -} - -// CachedAnimeImages for storing anime images -type CachedAnimeImages struct { - gorm.Model - AnimeID uint - Small string - Large string - Original string -} - -// CachedAnimeCovers for storing anime cover images -type CachedAnimeCovers struct { - gorm.Model - AnimeID uint - Small string - Large string - Original string -} - -// CachedAnimeLogos for storing anime logos -type CachedAnimeLogos struct { - gorm.Model - AnimeID uint - Small string - Medium string - Large string - XLarge string - Original string -} - -// CachedAnimeScores for storing anime scores and popularity data -type CachedAnimeScores struct { - gorm.Model - AnimeID uint - Score float64 - ScoredBy int - Rank int - Popularity int - Members int - Favorites int -} - -// CachedAiringStatusDates for storing airing date information -type CachedAiringStatusDates struct { - gorm.Model - Day int - Month int - Year int - String string -} - -// CachedAiringStatus for storing anime airing status -type CachedAiringStatus struct { - gorm.Model - AnimeID uint - FromID *uint - ToID *uint - String string - - From *CachedAiringStatusDates `gorm:"foreignKey:FromID"` - To *CachedAiringStatusDates `gorm:"foreignKey:ToID"` -} - -// CachedAnimeBroadcast for storing broadcast information -type CachedAnimeBroadcast struct { - gorm.Model - AnimeID uint - Day string - Time string - Timezone string - String string -} - -// CachedAnimeGenre for storing anime genres -type CachedAnimeGenre struct { - gorm.Model - AnimeID uint - Name string - GenreID int - URL string -} - -// CachedAnimeProducer for storing anime producers -type CachedAnimeProducer struct { - gorm.Model - AnimeID uint - Name string - ProducerID int - URL string -} - -// CachedAnimeStudio for storing anime studios -type CachedAnimeStudio struct { - gorm.Model - AnimeID uint - Name string - StudioID int - URL string -} - -// CachedAnimeLicensor for storing anime licensors -type CachedAnimeLicensor struct { - gorm.Model - AnimeID uint - Name string - ProducerID int - URL string -} - -// CachedAiringEpisode for storing information about airing episodes -type CachedAiringEpisode struct { - gorm.Model - AnimeID uint - AiringAt int - Episode int - IsNext bool `gorm:"index"` // true if this is the next airing episode -} - -// CachedEpisodeTitles for episode title variants -type CachedEpisodeTitles struct { - gorm.Model - EpisodeID uint - English string - Japanese string - Romaji string -} - -// CachedAnimeSingleEpisode for storing individual episode details -type CachedAnimeSingleEpisode struct { - gorm.Model - EpisodeID string `gorm:"uniqueIndex;size:32"` - AnimeID uint - TitlesID uint - Description string `gorm:"type:text"` - Aired string - Score float64 - Filler bool - Recap bool - ForumURL string - URL string - ThumbnailURL string - - Titles *CachedEpisodeTitles `gorm:"foreignKey:TitlesID"` -} - -// CachedAnimeSeason for storing anime seasons -type CachedAnimeSeason struct { - gorm.Model - ParentAnimeID uint `gorm:"index"` - MALID int `gorm:"index"` - TitleRomaji string - TitleEnglish string - TitleJapanese string - TitleSynonyms string // Comma-separated - Synopsis string `gorm:"type:text"` - Type string - Source string - Airing bool - Status string - Duration string - Season string - Year int - Current bool - - // Relationships - fixing the foreign key references - ImagesID *uint - ScoresID *uint - AiringStatusID *uint - - // Define proper relationships - Images *CachedAnimeImages `gorm:"foreignKey:ImagesID"` - Scores *CachedAnimeScores `gorm:"foreignKey:ScoresID"` - AiringStatus *CachedAiringStatus `gorm:"foreignKey:AiringStatusID"` -} - -// CachedAnimeCharacter for storing character information -type CachedAnimeCharacter struct { - gorm.Model - AnimeID uint - MALID int - URL string - ImageURL string - Name string - Role string - - // Voice actors - VoiceActors []CachedAnimeVoiceActor `gorm:"foreignKey:CharacterID"` -} - -// CachedAnimeVoiceActor for storing voice actor information -type CachedAnimeVoiceActor struct { - gorm.Model - CharacterID uint - MALID int - URL string - Image string - Name string - Language string -} - -// CachedNextEpisode for storing the next airing episode information -type CachedNextEpisode struct { - gorm.Model - AnimeID uint - AiringAt int - Episode int -} - -// CachedScheduleEpisode for storing information about scheduled episodes -type CachedScheduleEpisode struct { - gorm.Model - AnimeID uint - AiringAt int - Episode int -} - // EpisodeStreamingSource stores individual streaming sources for episodes type EpisodeStreamingSource struct { gorm.Model diff --git a/router/router.go b/router/router.go index 4358477..0a71947 100644 --- a/router/router.go +++ b/router/router.go @@ -12,6 +12,7 @@ func Initialize(router *fiber.App) { // Anime routes animeRouter := router.Group("/a") + animeRouter.Get("/genres", controllers.GetGenres) animeRouter.Get("/:id", controllers.GetAnime) animeRouter.Get("/:id/episodes", controllers.GetAnimeEpisodes) animeRouter.Get("/:id/episodes/:episodeId", controllers.GetAnimeEpisode) diff --git a/tasks/genre_sync.task.go b/tasks/genre_sync.task.go new file mode 100644 index 0000000..e240833 --- /dev/null +++ b/tasks/genre_sync.task.go @@ -0,0 +1,82 @@ +package tasks + +import ( + "fmt" + "metachan/database" + "metachan/entities" + "metachan/utils/api/jikan" + "metachan/utils/logger" +) + +// GenreSync synchronizes genre data from MAL via Jikan API +func GenreSync() error { + logger.Log("Starting Genre Sync from MAL", logger.LogOptions{ + Level: logger.Info, + Prefix: "GenreSync", + }) + + // Create Jikan client + client := jikan.NewJikanClient() + + // Wait for rate limit + client.WaitForRateLimit() + + // Fetch genres from Jikan API + genresResponse, err := client.GetAnimeGenres() + if err != nil { + logger.Log(fmt.Sprintf("Failed to fetch genres from MAL: %v", err), logger.LogOptions{ + Level: logger.Error, + Prefix: "GenreSync", + }) + return err + } + + logger.Log(fmt.Sprintf("Fetched %d genres from MAL", len(genresResponse.Data)), logger.LogOptions{ + Level: logger.Info, + Prefix: "GenreSync", + }) + + // Update or create genres in database + for _, genre := range genresResponse.Data { + // Create a genre entry with AnimeID = 0 to indicate it's a master genre + genreEntity := entities.AnimeGenre{ + AnimeID: 0, // Master genre, not tied to specific anime + GenreID: genre.MALID, + Name: genre.Name, + URL: genre.URL, + Count: genre.Count, + } + + // Update or create + var existing entities.AnimeGenre + result := database.DB.Where("genre_id = ? AND anime_id = 0", genre.MALID).First(&existing) + + if result.Error == nil { + // Update existing + existing.Name = genre.Name + existing.URL = genre.URL + existing.Count = genre.Count + if err := database.DB.Save(&existing).Error; err != nil { + logger.Log(fmt.Sprintf("Failed to update genre %s: %v", genre.Name, err), logger.LogOptions{ + Level: logger.Warn, + Prefix: "GenreSync", + }) + } + } else { + // Create new + if err := database.DB.Create(&genreEntity).Error; err != nil { + logger.Log(fmt.Sprintf("Failed to create genre %s: %v", genre.Name, err), logger.LogOptions{ + Level: logger.Warn, + Prefix: "GenreSync", + }) + } + } + } + + logger.Log("Genre Sync completed successfully", logger.LogOptions{ + Level: logger.Success, + Prefix: "GenreSync", + }) + + return nil +} diff --git a/tasks/tasks.go b/tasks/tasks.go index 29054e8..b6b913a 100644 --- a/tasks/tasks.go +++ b/tasks/tasks.go @@ -47,4 +47,18 @@ func init() { Prefix: "TaskManager", }) } + + // Register GenreSync task (every 7 days) + err = GlobalTaskManager.RegisterTask(types.Task{ + Name: "GenreSync", + Interval: 7 * 24 * time.Hour, + Execute: GenreSync, + }) + + if err != nil { + logger.Log(fmt.Sprintf("Failed to register GenreSync task: %v", err), logger.LogOptions{ + Level: logger.Error, + Prefix: "TaskManager", + }) + } } diff --git a/utils/api/jikan/jikan.go b/utils/api/jikan/jikan.go index 3a7e796..1e45c74 100644 --- a/utils/api/jikan/jikan.go +++ b/utils/api/jikan/jikan.go @@ -260,3 +260,23 @@ func (c *JikanClient) GetAnimeCharacters(malID int) (*JikanAnimeCharacterRespons return &characterResponse, nil } + +// GetAnimeGenres fetches all anime genres from MAL +func (c *JikanClient) GetAnimeGenres() (*JikanGenresResponse, error) { + apiURL := "https://api.jikan.moe/v4/genres/anime" + + 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 genres: %w", err) + } + + var genresResponse JikanGenresResponse + if err := json.Unmarshal(bodyBytes, &genresResponse); err != nil { + return nil, fmt.Errorf("failed to decode genres response: %w", err) + } + + return &genresResponse, nil +} diff --git a/utils/api/jikan/types.go b/utils/api/jikan/types.go index cbcb543..393be56 100644 --- a/utils/api/jikan/types.go +++ b/utils/api/jikan/types.go @@ -14,6 +14,19 @@ type JikanGenericMALStructure struct { Name string `json:"name"` } +// JikanGenre represents a genre from Jikan genres API +type JikanGenre struct { + MALID int `json:"mal_id"` + Name string `json:"name"` + URL string `json:"url"` + Count int `json:"count"` +} + +// JikanGenresResponse represents the genres response from Jikan API +type JikanGenresResponse struct { + Data []JikanGenre `json:"data"` +} + // JikanAnimeResponse represents the main anime response from Jikan API type JikanAnimeResponse struct { Data struct { |
