aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-01-20 18:13:48 +0530
committerBobby <[email protected]>2026-01-20 18:13:48 +0530
commited36e0308c7cd3a6197c899cb16bfe65cc5194b4 (patch)
tree4c0edabf651fc9b28648760920982c938fdf3d19
parentdf6cf3edcbb560e7615ad13d8daf4843507eb11e (diff)
downloadmetachan-ed36e0308c7cd3a6197c899cb16bfe65cc5194b4.tar.xz
metachan-ed36e0308c7cd3a6197c899cb16bfe65cc5194b4.zip
Implement genre synchronization from MAL via Jikan API and add genre retrieval endpoint
-rw-r--r--controllers/anime.go18
-rw-r--r--database/anime.go54
-rw-r--r--entities/anime.go259
-rw-r--r--router/router.go1
-rw-r--r--tasks/genre_sync.task.go82
-rw-r--r--tasks/tasks.go14
-rw-r--r--utils/api/jikan/jikan.go20
-rw-r--r--utils/api/jikan/types.go13
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 {