aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-02-05 15:56:01 +0530
committerBobby <[email protected]>2026-02-05 15:56:01 +0530
commit111ddd8b5fca2612256a7bd31781c149f10f83d8 (patch)
tree23256435338a22f31f3dc52331ae2f04705ec1f8
parentb0f01eea9d61aa4d05b0fe253c8a32e35fa95e28 (diff)
downloadmetachan-111ddd8b5fca2612256a7bd31781c149f10f83d8.tar.xz
metachan-111ddd8b5fca2612256a7bd31781c149f10f83d8.zip
Refactor Jikan API types: remove unused structures and add HTTP client configuration
-rw-r--r--controllers/anime.go493
-rw-r--r--controllers/errors.go43
-rw-r--r--controllers/health.go2
-rw-r--r--repositories/anime.go1107
-rw-r--r--repositories/mapping.go43
-rw-r--r--repositories/tasks.go85
-rw-r--r--repositories/types.go5
-rw-r--r--router/router.go56
-rw-r--r--services/anime/helpers.go30
-rw-r--r--services/anime/service.go303
-rw-r--r--tasks/anisync.task.go11
-rw-r--r--tasks/manager.go95
-rw-r--r--tasks/producersync.task.go264
-rw-r--r--tasks/tasks.go80
-rw-r--r--types/anime.go47
-rw-r--r--types/anisync.go17
-rw-r--r--types/http.go17
-rw-r--r--types/jikan.go218
-rw-r--r--types/mapping.go20
-rw-r--r--types/server.go17
-rw-r--r--types/task_manager.go10
-rw-r--r--utils/api/jikan/jikan.go784
-rw-r--r--utils/api/jikan/types.go247
23 files changed, 3204 insertions, 790 deletions
diff --git a/controllers/anime.go b/controllers/anime.go
index 2027d72..728db7f 100644
--- a/controllers/anime.go
+++ b/controllers/anime.go
@@ -1,227 +1,338 @@
package controllers
import (
- "fmt"
- "metachan/database"
- "metachan/entities"
- animeService "metachan/services/anime"
- "metachan/utils/logger"
- "metachan/utils/mappers"
+ "errors"
+ "metachan/enums"
+ "metachan/repositories"
+ "metachan/utils/meta"
"github.com/gofiber/fiber/v2"
)
-// animeServiceInstance is a singleton instance of the anime service
-var animeServiceInstance *animeService.Service
-
-// getAnimeService returns the anime service instance, creating it if necessary
-func getAnimeService() *animeService.Service {
- if animeServiceInstance == nil {
- animeServiceInstance = animeService.NewService()
- }
- return animeServiceInstance
-}
-
-// GetAnimeByMALID fetches anime details by MAL ID
func GetAnime(c *fiber.Ctx) error {
- mapping, err := getAnimeMapping(c)
- if err != nil {
- return err
+ id := meta.Request(c).MustHave().Param("id")
+ provider := meta.Request(c).Default("mal").Query("provider")
+
+ switch provider {
+ case "mal", "anilist":
+ default:
+ return BadRequest(c, errors.New("invalid provider"))
}
- service := getAnimeService()
- anime, err := service.GetAnimeDetails(mapping)
+ anime, err := repositories.GetAnime(enums.MappingType(provider), id)
if err != nil {
- logger.Log("Failed to fetch anime details: "+err.Error(), logger.LogOptions{
- Level: logger.Error,
- Prefix: "AnimeAPI",
- })
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
- "error": "Failed to fetch anime details",
- })
+ return NotFound(c, errors.New("anime not found"))
}
return c.JSON(anime)
}
-// GetAnimeEpisodesByMALID fetches anime episodes by MAL ID
-func GetAnimeEpisodes(c *fiber.Ctx) error {
- mapping, err := getAnimeMapping(c)
- if err != nil {
- return err
- }
+// -- Old Code Below --
- service := getAnimeService()
- anime, err := service.GetAnimeDetails(mapping)
- if err != nil {
- logger.Log("Failed to fetch anime episodes: "+err.Error(), logger.LogOptions{
- Level: logger.Error,
- Prefix: "AnimeAPI",
- })
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
- "error": "Failed to fetch anime episodes",
- })
- }
+// import (
+// "fmt"
+// "metachan/database"
+// "metachan/entities"
+// animeService "metachan/services/anime"
+// "metachan/utils/logger"
+// "metachan/utils/mappers"
- // Return only the episodes data
- return c.JSON(anime.Episodes)
-}
+// "github.com/gofiber/fiber/v2"
+// )
-// GetAnimeEpisode fetches a single episode by anime ID and episode ID
-func GetAnimeEpisode(c *fiber.Ctx) error {
- mapping, err := getAnimeMapping(c)
- if err != nil {
- return err
- }
+// // animeServiceInstance is a singleton instance of the anime service
+// var animeServiceInstance *animeService.Service
- episodeID := c.Params("episodeId")
- if episodeID == "" {
- return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
- "error": "Episode ID is required",
- })
- }
+// // getAnimeService returns the anime service instance, creating it if necessary
+// func getAnimeService() *animeService.Service {
+// if animeServiceInstance == nil {
+// animeServiceInstance = animeService.NewService()
+// }
+// return animeServiceInstance
+// }
- service := getAnimeService()
- anime, err := service.GetAnimeDetails(mapping)
- if err != nil {
- logger.Log("Failed to fetch anime details: "+err.Error(), logger.LogOptions{
- Level: logger.Error,
- Prefix: "AnimeAPI",
- })
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
- "error": "Failed to fetch anime details",
- })
- }
+// // GetAnimeByMALID fetches anime details by MAL ID
+// func GetAnime(c *fiber.Ctx) error {
+// mapping, err := getAnimeMapping(c)
+// if err != nil {
+// return err
+// }
- // Find the episode with matching ID
- for i, episode := range anime.Episodes.Episodes {
- if episode.ID == episodeID {
- // Fetch streaming sources for this specific episode
- episodeNumber := i + 1
- streaming, err := service.GetEpisodeStreaming(anime.Titles.Romaji, episodeNumber, episode.ID, uint(anime.MALID))
- if err != nil {
- logger.Log("Failed to fetch streaming sources: "+err.Error(), logger.LogOptions{
- Level: logger.Warn,
- Prefix: "AnimeAPI",
- })
- // Continue without streaming data
- } else {
- episode.Streaming = streaming
- }
- return c.JSON(episode)
- }
- }
+// service := getAnimeService()
+// anime, err := service.GetAnimeDetails(mapping)
+// if err != nil {
+// logger.Log("Failed to fetch anime details: "+err.Error(), logger.LogOptions{
+// Level: logger.Error,
+// Prefix: "AnimeAPI",
+// })
+// return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+// "error": "Failed to fetch anime details",
+// })
+// }
- return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
- "error": "Episode not found",
- })
-}
+// return c.JSON(anime)
+// }
-func GetAnimeCharacters(c *fiber.Ctx) error {
- mapping, err := getAnimeMapping(c)
- if err != nil {
- return err
- }
+// // GetAnimeEpisodesByMALID fetches anime episodes by MAL ID
+// func GetAnimeEpisodes(c *fiber.Ctx) error {
+// mapping, err := getAnimeMapping(c)
+// if err != nil {
+// return err
+// }
- service := getAnimeService()
- anime, err := service.GetAnimeDetails(mapping)
- if err != nil {
- logger.Log("Failed to fetch anime characters: "+err.Error(), logger.LogOptions{
- Level: logger.Error,
- Prefix: "AnimeAPI",
- })
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
- "error": "Failed to fetch anime characters",
- })
- }
+// service := getAnimeService()
+// anime, err := service.GetAnimeDetails(mapping)
+// if err != nil {
+// logger.Log("Failed to fetch anime episodes: "+err.Error(), logger.LogOptions{
+// Level: logger.Error,
+// Prefix: "AnimeAPI",
+// })
+// return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+// "error": "Failed to fetch anime episodes",
+// })
+// }
- return c.JSON(anime.Characters)
-}
+// // Return only the episodes data
+// return c.JSON(anime.Episodes)
+// }
-func getAnimeMapping(c *fiber.Ctx) (*entities.AnimeMapping, error) {
- isAnilist := c.Query("provider") == "anilist"
- malID := c.Params("id")
- if malID == "" {
- return nil, c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
- "error": "Query parameter MAL ID is required",
- })
- }
+// // GetAnimeEpisode fetches a single episode by anime ID and episode ID
+// func GetAnimeEpisode(c *fiber.Ctx) error {
+// mapping, err := getAnimeMapping(c)
+// if err != nil {
+// return err
+// }
- var mapping *entities.AnimeMapping
- var err error
- if isAnilist {
- mapping, err = database.GetAnimeMappingViaAnilistID(mappers.ForceInt(malID))
- } else {
- mapping, err = database.GetAnimeMappingViaMALID(mappers.ForceInt(malID))
- }
- if err != nil || mapping.MAL == 0 {
- return nil, c.Status(fiber.StatusNotFound).JSON(fiber.Map{
- "error": "Anime mapping not found",
- })
- }
+// episodeID := c.Params("episodeId")
+// if episodeID == "" {
+// return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+// "error": "Episode ID is required",
+// })
+// }
- return mapping, nil
-}
+// service := getAnimeService()
+// anime, err := service.GetAnimeDetails(mapping)
+// if err != nil {
+// logger.Log("Failed to fetch anime details: "+err.Error(), logger.LogOptions{
+// Level: logger.Error,
+// Prefix: "AnimeAPI",
+// })
+// return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+// "error": "Failed to fetch anime details",
+// })
+// }
-// 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",
- })
- }
+// // Find the episode with matching ID
+// for i, episode := range anime.Episodes.Episodes {
+// if episode.ID == episodeID {
+// // Fetch streaming sources for this specific episode
+// episodeNumber := i + 1
+// streaming, err := service.GetEpisodeStreaming(anime.Titles.Romaji, episodeNumber, episode.ID, uint(anime.MALID))
+// if err != nil {
+// logger.Log("Failed to fetch streaming sources: "+err.Error(), logger.LogOptions{
+// Level: logger.Warn,
+// Prefix: "AnimeAPI",
+// })
+// // Continue without streaming data
+// } else {
+// episode.Streaming = streaming
+// }
+// return c.JSON(episode)
+// }
+// }
- return c.JSON(fiber.Map{
- "genres": genres,
- })
-}
+// return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
+// "error": "Episode not found",
+// })
+// }
-// 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"))
+// func GetAnimeCharacters(c *fiber.Ctx) error {
+// mapping, err := getAnimeMapping(c)
+// if err != nil {
+// return err
+// }
- if limit > 100 {
- limit = 100
- }
- if limit < 1 {
- limit = 12
- }
- if page < 1 {
- page = 1
- }
+// service := getAnimeService()
+// anime, err := service.GetAnimeDetails(mapping)
+// if err != nil {
+// logger.Log("Failed to fetch anime characters: "+err.Error(), logger.LogOptions{
+// Level: logger.Error,
+// Prefix: "AnimeAPI",
+// })
+// return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+// "error": "Failed to fetch anime characters",
+// })
+// }
- logger.Log(fmt.Sprintf("Fetching anime for genre %d, page %d, limit %d", genreID, page, limit), logger.LogOptions{
- Level: logger.Debug,
- Prefix: "GenreController",
- })
+// return c.JSON(anime.Characters)
+// }
- 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",
- })
- }
+// func getAnimeMapping(c *fiber.Ctx) (*entities.AnimeMapping, error) {
+// isAnilist := c.Query("provider") == "anilist"
+// malID := c.Params("id")
+// if malID == "" {
+// return nil, c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+// "error": "Query parameter MAL ID is required",
+// })
+// }
- 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,
- })
-}
+// var mapping *entities.AnimeMapping
+// var err error
+// if isAnilist {
+// mapping, err = database.GetAnimeMappingViaAnilistID(mappers.ForceInt(malID))
+// } else {
+// mapping, err = database.GetAnimeMappingViaMALID(mappers.ForceInt(malID))
+// }
+// if err != nil || mapping.MAL == 0 {
+// return nil, c.Status(fiber.StatusNotFound).JSON(fiber.Map{
+// "error": "Anime mapping not found",
+// })
+// }
+
+// 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,
+// })
+// }
+
+// // 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 := min(mappers.ForceInt(c.Query("limit", "12")), 100)
+// if limit < 1 {
+// limit = 25
+// }
+// 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,
+// })
+// }
+
+// // GetProducers retrieves all producers with counts
+// func GetProducers(c *fiber.Ctx) error {
+// page := mappers.ForceInt(c.Query("page", "1"))
+// limit := min(mappers.ForceInt(c.Query("limit", "25")), 100)
+// if limit < 1 {
+// limit = 25
+// }
+// if page < 1 {
+// page = 1
+// }
+
+// producers, pagination, err := database.GetAllProducers(page, limit)
+// if err != nil {
+// logger.Log("Failed to get producers: "+err.Error(), logger.LogOptions{
+// Level: logger.Error,
+// Prefix: "Controller",
+// })
+// return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+// "error": "Failed to retrieve producers",
+// })
+// }
+
+// return c.JSON(fiber.Map{
+// "pagination": pagination,
+// "producers": producers,
+// })
+// }
+
+// func GetProducer(c *fiber.Ctx) error {
+// producerID := mappers.ForceInt(c.Params("id"))
+
+// producer, err := database.GetProducerByID(producerID)
+// if err != nil {
+// logger.Log("Failed to get producer: "+err.Error(), logger.LogOptions{
+// Level: logger.Error,
+// Prefix: "Controller",
+// })
+// return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
+// "error": "Producer not found",
+// })
+// }
+
+// return c.JSON(producer)
+// }
+
+// // GetAnimeByProducer retrieves paginated anime list for a specific producer
+// func GetAnimeByProducer(c *fiber.Ctx) error {
+// producerID := mappers.ForceInt(c.Params("id"))
+// page := mappers.ForceInt(c.Query("page", "1"))
+// limit := min(mappers.ForceInt(c.Query("limit", "12")), 100)
+// if limit < 1 {
+// limit = 25
+// }
+// if page < 1 {
+// page = 1
+// }
+
+// logger.Log(fmt.Sprintf("Fetching anime for producer %d, page %d, limit %d", producerID, page, limit), logger.LogOptions{
+// Level: logger.Debug,
+// Prefix: "ProducerController",
+// })
+
+// service := getAnimeService()
+// animeList, pagination, err := service.GetAnimeByProducer(producerID, page, limit)
+// if err != nil {
+// logger.Log("Failed to fetch anime by producer: "+err.Error(), logger.LogOptions{
+// Level: logger.Error,
+// Prefix: "ProducerController",
+// })
+// return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+// "error": "Failed to retrieve anime for producer",
+// })
+// }
+
+// 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/controllers/errors.go b/controllers/errors.go
new file mode 100644
index 0000000..04cad29
--- /dev/null
+++ b/controllers/errors.go
@@ -0,0 +1,43 @@
+package controllers
+
+import (
+ "metachan/utils/shortcuts"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func BadRequest(c *fiber.Ctx, err error) error {
+ return shortcuts.Response(c, fiber.Map{
+ "error": err.Error(),
+ }).As(fiber.StatusBadRequest)
+}
+
+func Unauthorized(c *fiber.Ctx, err error) error {
+ return shortcuts.Response(c, fiber.Map{
+ "error": err.Error(),
+ }).As(fiber.StatusUnauthorized)
+}
+
+func Forbidden(c *fiber.Ctx, err error) error {
+ return shortcuts.Response(c, fiber.Map{
+ "error": err.Error(),
+ }).As(fiber.StatusForbidden)
+}
+
+func NotFound(c *fiber.Ctx, err error) error {
+ return shortcuts.Response(c, fiber.Map{
+ "error": err.Error(),
+ }).As(fiber.StatusNotFound)
+}
+
+func InternalServerError(c *fiber.Ctx, err error) error {
+ return shortcuts.Response(c, fiber.Map{
+ "error": "Internal Server Error",
+ }).As(fiber.StatusInternalServerError)
+}
+
+func DefaultError(c *fiber.Ctx, err error) error {
+ return shortcuts.Response(c, fiber.Map{
+ "error": err.Error(),
+ }).As(fiber.StatusInternalServerError)
+}
diff --git a/controllers/health.go b/controllers/health.go
index 3f12bce..80eeb1a 100644
--- a/controllers/health.go
+++ b/controllers/health.go
@@ -11,7 +11,7 @@ import (
func HealthStatus(c *fiber.Ctx) error {
// Check if the database is connected
- databaseStatus := database.DatabaseConnectionStatus()
+ databaseStatus := database.GetConnectionStatus()
// Get the memory stats
memoryStats := stats.GetMemoryStats()
diff --git a/repositories/anime.go b/repositories/anime.go
new file mode 100644
index 0000000..bdd4f30
--- /dev/null
+++ b/repositories/anime.go
@@ -0,0 +1,1107 @@
+package repositories
+
+import (
+ "errors"
+ "metachan/database"
+ "metachan/entities"
+ "metachan/enums"
+ "metachan/utils/logger"
+)
+
+func GetAnime[T idType](maptype enums.MappingType, id T) (entities.Anime, error) {
+ var anime entities.Anime
+
+ mapping, err := GetAnimeMapping(maptype, id)
+ if err != nil {
+ logger.Errorf("Anime", "Failed to get anime mapping: %v", err)
+ return entities.Anime{}, errors.New("anime not found")
+ }
+
+ result := database.DB.
+ Preload("Mapping").
+ Preload("Title").
+ Preload("Images").
+ Preload("Logos").
+ Preload("Scores").
+ Preload("AiringStatus").
+ Preload("AiringStatus.From").
+ Preload("AiringStatus.To").
+ Preload("Broadcast").
+ Preload("NextAiring").
+ Preload("Genres").
+ Preload("Producers").
+ Preload("Producers.Image").
+ Preload("Producers.Titles").
+ Preload("Producers.ExternalURLs").
+ Preload("Studios").
+ Preload("Studios.Image").
+ Preload("Studios.Titles").
+ Preload("Studios.ExternalURLs").
+ Preload("Licensors").
+ Preload("Licensors.Image").
+ Preload("Licensors.Titles").
+ Preload("Licensors.ExternalURLs").
+ Preload("Episodes").
+ Preload("Episodes.Title").
+ Preload("Characters").
+ Preload("Characters.VoiceActors").
+ Preload("Schedule").
+ Preload("Seasons").
+ Preload("Seasons.Title").
+ Preload("Seasons.Images").
+ Preload("Seasons.Scores").
+ Preload("Seasons.AiringStatus").
+ Preload("Seasons.AiringStatus.From").
+ Preload("Seasons.AiringStatus.To").
+ Where("mapping_id = ?", mapping.ID).
+ First(&anime)
+
+ if result.Error != nil {
+ logger.Errorf("Anime", "Failed to get anime details: %v", result.Error)
+ return entities.Anime{}, errors.New("anime not found")
+ }
+
+ return anime, nil
+}
+
+// --- Moved from database/anime.go ---
+// import (
+// "fmt"
+// "metachan/entities"
+// "metachan/types"
+// "strings"
+// "time"
+
+// "gorm.io/gorm"
+// )
+
+// func GetAnimeByMALID(malID int) (*types.Anime, error) {
+// var anime entities.Anime
+// result := DB.Preload("Images").
+// Preload("Logos").
+// Preload("Covers").
+// Preload("Scores").
+// Preload("AiringStatus").
+// Preload("AiringStatus.From").
+// Preload("AiringStatus.To").
+// Preload("Broadcast").
+// Preload("Genres").
+// Preload("Producers.Producer.Titles").
+// Preload("Studios.Producer.Titles").
+// Preload("Licensors.Producer.Titles").
+// Preload("Episodes").
+// Preload("Episodes.Titles").
+// Preload("Characters").
+// Preload("Characters.VoiceActors").
+// Preload("AiringSchedule").
+// Preload("NextAiringEpisode").
+// Preload("Seasons").
+// Preload("Seasons.Images").
+// Preload("Seasons.Scores").
+// Preload("Seasons.AiringStatus").
+// Preload("Seasons.AiringStatus.From").
+// Preload("Seasons.AiringStatus.To").
+// Where("mal_id = ?", malID).First(&anime)
+
+// if result.Error != nil {
+// return nil, result.Error
+// }
+
+// return ConvertToTypesAnime(&anime), nil
+// }
+
+// func SaveAnimeToDatabase(animeData *types.Anime) error {
+// if animeData == nil {
+// return fmt.Errorf("anime data is nil")
+// }
+
+// var tx *gorm.DB = DB.Begin()
+// if tx.Error != nil {
+// return tx.Error
+// }
+
+// defer func() {
+// if r := recover(); r != nil {
+// tx.Rollback()
+// }
+// }()
+
+// 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
+// }
+// }
+
+// anime := &entities.Anime{
+// MALID: animeData.MALID,
+// TitleRomaji: animeData.Titles.Romaji,
+// TitleEnglish: animeData.Titles.English,
+// TitleJapanese: animeData.Titles.Japanese,
+// TitleSynonyms: strings.Join(animeData.Titles.Synonyms, ","),
+// Synopsis: animeData.Synopsis,
+// Type: string(animeData.Type),
+// Source: animeData.Source,
+// Airing: animeData.Airing,
+// Status: animeData.Status,
+// Duration: animeData.Duration,
+// Color: animeData.Color,
+// Season: animeData.Season,
+// Year: animeData.Year,
+// SubbedCount: animeData.Episodes.Subbed,
+// DubbedCount: animeData.Episodes.Dubbed,
+// TotalEpisodes: animeData.Episodes.Total,
+// AiredEpisodes: animeData.Episodes.Aired,
+// LastUpdated: time.Now(),
+// }
+
+// // Save images
+// if animeData.Images.Small != "" || animeData.Images.Large != "" || animeData.Images.Original != "" {
+// anime.Images = &entities.AnimeImages{
+// Small: animeData.Images.Small,
+// Large: animeData.Images.Large,
+// Original: animeData.Images.Original,
+// }
+// }
+
+// // Save logos
+// if animeData.Logos.Small != "" || animeData.Logos.Medium != "" || animeData.Logos.Large != "" {
+// anime.Logos = &entities.AnimeLogos{
+// Small: animeData.Logos.Small,
+// Medium: animeData.Logos.Medium,
+// Large: animeData.Logos.Large,
+// XLarge: animeData.Logos.XLarge,
+// Original: animeData.Logos.Original,
+// }
+// }
+
+// // Save covers
+// if animeData.Covers.Small != "" || animeData.Covers.Large != "" || animeData.Covers.Original != "" {
+// anime.Covers = &entities.AnimeCovers{
+// Small: animeData.Covers.Small,
+// Large: animeData.Covers.Large,
+// Original: animeData.Covers.Original,
+// }
+// }
+
+// // Save scores
+// if animeData.Scores.Score > 0 || animeData.Scores.ScoredBy > 0 {
+// anime.Scores = &entities.AnimeScores{
+// Score: animeData.Scores.Score,
+// ScoredBy: animeData.Scores.ScoredBy,
+// Rank: animeData.Scores.Rank,
+// Popularity: animeData.Scores.Popularity,
+// Members: animeData.Scores.Members,
+// Favorites: animeData.Scores.Favorites,
+// }
+// }
+
+// // Save airing status
+// if animeData.AiringStatus.String != "" {
+// airingStatus := &entities.AiringStatus{
+// String: animeData.AiringStatus.String,
+// }
+
+// if animeData.AiringStatus.From.Year > 0 {
+// airingStatus.From = &entities.AiringStatusDates{
+// Day: animeData.AiringStatus.From.Day,
+// Month: animeData.AiringStatus.From.Month,
+// Year: animeData.AiringStatus.From.Year,
+// String: animeData.AiringStatus.From.String,
+// }
+// }
+
+// if animeData.AiringStatus.To.Year > 0 {
+// airingStatus.To = &entities.AiringStatusDates{
+// Day: animeData.AiringStatus.To.Day,
+// Month: animeData.AiringStatus.To.Month,
+// Year: animeData.AiringStatus.To.Year,
+// String: animeData.AiringStatus.To.String,
+// }
+// }
+
+// anime.AiringStatus = airingStatus
+// }
+
+// // Save broadcast info
+// if animeData.Broadcast.String != "" {
+// anime.Broadcast = &entities.AnimeBroadcast{
+// Day: animeData.Broadcast.Day,
+// Time: animeData.Broadcast.Time,
+// Timezone: animeData.Broadcast.Timezone,
+// String: animeData.Broadcast.String,
+// }
+// }
+
+// // Save genres - link to master genres instead of creating duplicates
+// if len(animeData.Genres) > 0 {
+// 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)
+// }
+// }
+
+// // Save producers - link to existing producers in unified producer table
+// if len(animeData.Producers) > 0 {
+// anime.Producers = make([]entities.AnimeProducer, 0)
+// for _, producer := range animeData.Producers {
+// // Find producer in unified producer table
+// var existingProducer entities.Producer
+// if err := DB.Where("mal_id = ?", producer.MALID).First(&existingProducer).Error; err == nil {
+// // Link anime to existing producer
+// anime.Producers = append(anime.Producers, entities.AnimeProducer{
+// ProducerID: existingProducer.ID,
+// })
+// }
+// }
+// }
+
+// // Save studios - link to existing producers in unified producer table
+// if len(animeData.Studios) > 0 {
+// anime.Studios = make([]entities.AnimeStudio, 0)
+// for _, studio := range animeData.Studios {
+// // Find producer in unified producer table
+// var existingProducer entities.Producer
+// if err := DB.Where("mal_id = ?", studio.MALID).First(&existingProducer).Error; err == nil {
+// // Link anime to existing producer (as studio)
+// anime.Studios = append(anime.Studios, entities.AnimeStudio{
+// ProducerID: existingProducer.ID,
+// })
+// }
+// }
+// }
+
+// // Save licensors - link to existing producers in unified producer table
+// if len(animeData.Licensors) > 0 {
+// anime.Licensors = make([]entities.AnimeLicensor, 0)
+// for _, licensor := range animeData.Licensors {
+// // Find producer in unified producer table
+// var existingProducer entities.Producer
+// if err := DB.Where("mal_id = ?", licensor.MALID).First(&existingProducer).Error; err == nil {
+// // Link anime to existing producer (as licensor)
+// anime.Licensors = append(anime.Licensors, entities.AnimeLicensor{
+// ProducerID: existingProducer.ID,
+// })
+// }
+// }
+// }
+
+// // Save seasons
+// if len(animeData.Seasons) > 0 {
+// anime.Seasons = make([]entities.AnimeSeason, len(animeData.Seasons))
+// for i, season := range animeData.Seasons {
+// animeSeason := entities.AnimeSeason{
+// MALID: season.MALID,
+// TitleRomaji: season.Titles.Romaji,
+// TitleEnglish: season.Titles.English,
+// TitleJapanese: season.Titles.Japanese,
+// TitleSynonyms: strings.Join(season.Titles.Synonyms, ","),
+// Synopsis: season.Synopsis,
+// Type: string(season.Type),
+// Source: season.Source,
+// Airing: season.Airing,
+// Status: season.Status,
+// Duration: season.Duration,
+// Season: season.Season,
+// Year: season.Year,
+// Current: season.Current,
+// }
+
+// // Save season images
+// if season.Images.Small != "" || season.Images.Large != "" {
+// animeSeason.Images = &entities.AnimeImages{
+// Small: season.Images.Small,
+// Large: season.Images.Large,
+// Original: season.Images.Original,
+// }
+// }
+
+// // Save season scores
+// if season.Scores.Score > 0 {
+// animeSeason.Scores = &entities.AnimeScores{
+// Score: season.Scores.Score,
+// ScoredBy: season.Scores.ScoredBy,
+// Rank: season.Scores.Rank,
+// Popularity: season.Scores.Popularity,
+// Members: season.Scores.Members,
+// Favorites: season.Scores.Favorites,
+// }
+// }
+
+// anime.Seasons[i] = animeSeason
+// }
+// }
+
+// if len(animeData.Episodes.Episodes) > 0 {
+// anime.Episodes = make([]entities.AnimeSingleEpisode, len(animeData.Episodes.Episodes))
+// for i, episode := range animeData.Episodes.Episodes {
+// titles := &entities.EpisodeTitles{
+// English: episode.Titles.English,
+// Japanese: episode.Titles.Japanese,
+// Romaji: episode.Titles.Romaji,
+// }
+
+// anime.Episodes[i] = entities.AnimeSingleEpisode{
+// EpisodeID: episode.ID,
+// Description: episode.Description,
+// Aired: episode.Aired,
+// Score: episode.Score,
+// Filler: episode.Filler,
+// Recap: episode.Recap,
+// ForumURL: episode.ForumURL,
+// URL: episode.URL,
+// ThumbnailURL: episode.ThumbnailURL,
+// Titles: titles,
+// }
+// }
+// }
+
+// // Save characters data
+// if len(animeData.Characters) > 0 {
+// anime.Characters = make([]entities.AnimeCharacter, len(animeData.Characters))
+// for i, character := range animeData.Characters {
+// anime.Characters[i] = entities.AnimeCharacter{
+// MALID: character.MALID,
+// URL: character.URL,
+// ImageURL: character.ImageURL,
+// Name: character.Name,
+// Role: character.Role,
+// }
+
+// // Save voice actors for this character
+// if len(character.VoiceActors) > 0 {
+// anime.Characters[i].VoiceActors = make([]entities.AnimeVoiceActor, len(character.VoiceActors))
+// for j, va := range character.VoiceActors {
+// anime.Characters[i].VoiceActors[j] = entities.AnimeVoiceActor{
+// MALID: va.MALID,
+// URL: va.URL,
+// Image: va.Image,
+// Name: va.Name,
+// Language: va.Language,
+// }
+// }
+// }
+// }
+// }
+
+// // Save airing schedule data
+// if len(animeData.AiringSchedule) > 0 {
+// anime.AiringSchedule = make([]entities.ScheduleEpisode, len(animeData.AiringSchedule))
+// for i, schedule := range animeData.AiringSchedule {
+// anime.AiringSchedule[i] = entities.ScheduleEpisode{
+// AiringAt: schedule.AiringAt,
+// Episode: schedule.Episode,
+// IsNext: false, // We'll set this based on next airing episode if available
+// }
+// }
+// }
+
+// // Set next airing episode data
+// if animeData.NextAiringEpisode.Episode > 0 {
+// anime.NextAiringEpisode = &entities.NextEpisode{
+// AiringAt: animeData.NextAiringEpisode.AiringAt,
+// Episode: animeData.NextAiringEpisode.Episode,
+// }
+
+// // Mark the next airing episode in the schedule as IsNext
+// for i := range anime.AiringSchedule {
+// if anime.AiringSchedule[i].Episode == animeData.NextAiringEpisode.Episode &&
+// anime.AiringSchedule[i].AiringAt == animeData.NextAiringEpisode.AiringAt {
+// anime.AiringSchedule[i].IsNext = true
+// break
+// }
+// }
+// }
+
+// if err := tx.Create(anime).Error; err != nil {
+// tx.Rollback()
+// return err
+// }
+
+// return tx.Commit().Error
+// }
+
+// func ConvertToTypesAnime(anime *entities.Anime) *types.Anime {
+// if anime == nil {
+// return nil
+// }
+
+// result := &types.Anime{
+// MALID: anime.MALID,
+// Titles: types.AnimeTitles{
+// Romaji: anime.TitleRomaji,
+// English: anime.TitleEnglish,
+// Japanese: anime.TitleJapanese,
+// Synonyms: strings.Split(anime.TitleSynonyms, ","),
+// },
+// Synopsis: anime.Synopsis,
+// Type: types.AniSyncType(anime.Type),
+// Source: anime.Source,
+// Airing: anime.Airing,
+// Status: anime.Status,
+// Duration: anime.Duration,
+// Color: anime.Color,
+// Season: anime.Season,
+// Year: anime.Year,
+// Episodes: types.AnimeEpisodes{
+// Total: anime.TotalEpisodes,
+// Aired: anime.AiredEpisodes,
+// Subbed: anime.SubbedCount,
+// Dubbed: anime.DubbedCount,
+// },
+// }
+
+// // Convert images
+// if anime.Images != nil {
+// result.Images = types.AnimeImages{
+// Small: anime.Images.Small,
+// Large: anime.Images.Large,
+// Original: anime.Images.Original,
+// }
+// }
+
+// // Convert logos
+// if anime.Logos != nil {
+// result.Logos = types.AnimeLogos{
+// Small: anime.Logos.Small,
+// Medium: anime.Logos.Medium,
+// Large: anime.Logos.Large,
+// XLarge: anime.Logos.XLarge,
+// Original: anime.Logos.Original,
+// }
+// }
+
+// // Convert covers
+// if anime.Covers != nil {
+// result.Covers = types.AnimeImages{
+// Small: anime.Covers.Small,
+// Large: anime.Covers.Large,
+// Original: anime.Covers.Original,
+// }
+// }
+
+// // Convert scores
+// if anime.Scores != nil {
+// result.Scores = types.AnimeScores{
+// Score: anime.Scores.Score,
+// ScoredBy: anime.Scores.ScoredBy,
+// Rank: anime.Scores.Rank,
+// Popularity: anime.Scores.Popularity,
+// Members: anime.Scores.Members,
+// Favorites: anime.Scores.Favorites,
+// }
+// }
+
+// // Convert airing status
+// if anime.AiringStatus != nil {
+// result.AiringStatus = types.AiringStatus{
+// String: anime.AiringStatus.String,
+// }
+
+// if anime.AiringStatus.From != nil {
+// result.AiringStatus.From = types.AiringStatusDates{
+// Day: anime.AiringStatus.From.Day,
+// Month: anime.AiringStatus.From.Month,
+// Year: anime.AiringStatus.From.Year,
+// String: anime.AiringStatus.From.String,
+// }
+// }
+
+// if anime.AiringStatus.To != nil {
+// result.AiringStatus.To = types.AiringStatusDates{
+// Day: anime.AiringStatus.To.Day,
+// Month: anime.AiringStatus.To.Month,
+// Year: anime.AiringStatus.To.Year,
+// String: anime.AiringStatus.To.String,
+// }
+// }
+// }
+
+// // Convert broadcast
+// if anime.Broadcast != nil {
+// result.Broadcast = types.AnimeBroadcast{
+// Day: anime.Broadcast.Day,
+// Time: anime.Broadcast.Time,
+// Timezone: anime.Broadcast.Timezone,
+// String: anime.Broadcast.String,
+// }
+// }
+
+// // Convert genres
+// if len(anime.Genres) > 0 {
+// result.Genres = make([]types.AnimeGenres, len(anime.Genres))
+// for i, genre := range anime.Genres {
+// result.Genres[i] = types.AnimeGenres{
+// Name: genre.Name,
+// GenreID: genre.GenreID,
+// URL: genre.URL,
+// }
+// }
+// }
+
+// // Convert producers - load from unified producer table
+// if len(anime.Producers) > 0 {
+// result.Producers = make([]types.AnimeProducer, 0)
+// for _, animeProducer := range anime.Producers {
+// if animeProducer.Producer != nil {
+// // Get primary title
+// var titles []entities.ProducerTitle
+// DB.Where("producer_id = ?", animeProducer.Producer.ID).Find(&titles)
+
+// primaryName := "Unknown"
+// for _, title := range titles {
+// if title.Type == "Default" {
+// primaryName = title.Title
+// break
+// }
+// }
+// if primaryName == "Unknown" && len(titles) > 0 {
+// primaryName = titles[0].Title
+// }
+
+// result.Producers = append(result.Producers, types.AnimeProducer{
+// Name: primaryName,
+// MALID: animeProducer.Producer.MALID,
+// URL: animeProducer.Producer.URL,
+// })
+// }
+// }
+// }
+
+// // Convert studios - load from unified producer table
+// if len(anime.Studios) > 0 {
+// result.Studios = make([]types.AnimeProducer, 0)
+// for _, animeStudio := range anime.Studios {
+// if animeStudio.Producer != nil {
+// // Get primary title
+// var titles []entities.ProducerTitle
+// DB.Where("producer_id = ?", animeStudio.Producer.ID).Find(&titles)
+
+// primaryName := "Unknown"
+// for _, title := range titles {
+// if title.Type == "Default" {
+// primaryName = title.Title
+// break
+// }
+// }
+// if primaryName == "Unknown" && len(titles) > 0 {
+// primaryName = titles[0].Title
+// }
+
+// result.Studios = append(result.Studios, types.AnimeProducer{
+// Name: primaryName,
+// MALID: animeStudio.Producer.MALID,
+// URL: animeStudio.Producer.URL,
+// })
+// }
+// }
+// }
+
+// // Convert licensors - load from unified producer table
+// if len(anime.Licensors) > 0 {
+// result.Licensors = make([]types.AnimeProducer, 0)
+// for _, animeLicensor := range anime.Licensors {
+// if animeLicensor.Producer != nil {
+// // Get primary title
+// var titles []entities.ProducerTitle
+// DB.Where("producer_id = ?", animeLicensor.Producer.ID).Find(&titles)
+
+// primaryName := "Unknown"
+// for _, title := range titles {
+// if title.Type == "Default" {
+// primaryName = title.Title
+// break
+// }
+// }
+// if primaryName == "Unknown" && len(titles) > 0 {
+// primaryName = titles[0].Title
+// }
+
+// result.Licensors = append(result.Licensors, types.AnimeProducer{
+// Name: primaryName,
+// MALID: animeLicensor.Producer.MALID,
+// URL: animeLicensor.Producer.URL,
+// })
+// }
+// }
+// }
+
+// // Convert seasons
+// if len(anime.Seasons) > 0 {
+// result.Seasons = make([]types.AnimeSeason, len(anime.Seasons))
+// for i, season := range anime.Seasons {
+// result.Seasons[i] = types.AnimeSeason{
+// MALID: season.MALID,
+// Titles: types.AnimeTitles{
+// Romaji: season.TitleRomaji,
+// English: season.TitleEnglish,
+// Japanese: season.TitleJapanese,
+// Synonyms: strings.Split(season.TitleSynonyms, ","),
+// },
+// Synopsis: season.Synopsis,
+// Type: types.AniSyncType(season.Type),
+// Source: season.Source,
+// Airing: season.Airing,
+// Status: season.Status,
+// Duration: season.Duration,
+// Season: season.Season,
+// Year: season.Year,
+// Current: season.Current,
+// }
+
+// // Convert season images
+// if season.Images != nil {
+// result.Seasons[i].Images = types.AnimeImages{
+// Small: season.Images.Small,
+// Large: season.Images.Large,
+// Original: season.Images.Original,
+// }
+// }
+
+// // Convert season scores
+// if season.Scores != nil {
+// result.Seasons[i].Scores = types.AnimeScores{
+// Score: season.Scores.Score,
+// ScoredBy: season.Scores.ScoredBy,
+// Rank: season.Scores.Rank,
+// Popularity: season.Scores.Popularity,
+// Members: season.Scores.Members,
+// Favorites: season.Scores.Favorites,
+// }
+// }
+
+// // Convert season airing status
+// if season.AiringStatus != nil {
+// result.Seasons[i].AiringStatus = types.AiringStatus{
+// String: season.AiringStatus.String,
+// }
+
+// if season.AiringStatus.From != nil {
+// result.Seasons[i].AiringStatus.From = types.AiringStatusDates{
+// Day: season.AiringStatus.From.Day,
+// Month: season.AiringStatus.From.Month,
+// Year: season.AiringStatus.From.Year,
+// String: season.AiringStatus.From.String,
+// }
+// }
+
+// if season.AiringStatus.To != nil {
+// result.Seasons[i].AiringStatus.To = types.AiringStatusDates{
+// Day: season.AiringStatus.To.Day,
+// Month: season.AiringStatus.To.Month,
+// Year: season.AiringStatus.To.Year,
+// String: season.AiringStatus.To.String,
+// }
+// }
+// }
+// }
+// }
+
+// if len(anime.Episodes) > 0 {
+// result.Episodes.Episodes = make([]types.AnimeSingleEpisode, len(anime.Episodes))
+// for i, episode := range anime.Episodes {
+// episodeData := types.AnimeSingleEpisode{
+// ID: episode.EpisodeID,
+// Description: episode.Description,
+// Aired: episode.Aired,
+// Score: episode.Score,
+// Filler: episode.Filler,
+// Recap: episode.Recap,
+// ForumURL: episode.ForumURL,
+// URL: episode.URL,
+// ThumbnailURL: episode.ThumbnailURL,
+// }
+
+// if episode.Titles != nil {
+// episodeData.Titles = types.EpisodeTitles{
+// English: episode.Titles.English,
+// Japanese: episode.Titles.Japanese,
+// Romaji: episode.Titles.Romaji,
+// }
+// }
+
+// result.Episodes.Episodes[i] = episodeData
+// }
+// }
+
+// // Convert characters
+// if len(anime.Characters) > 0 {
+// result.Characters = make([]types.AnimeCharacter, len(anime.Characters))
+// for i, character := range anime.Characters {
+// result.Characters[i] = types.AnimeCharacter{
+// MALID: character.MALID,
+// URL: character.URL,
+// ImageURL: character.ImageURL,
+// Name: character.Name,
+// Role: character.Role,
+// }
+
+// // Convert voice actors for this character
+// if len(character.VoiceActors) > 0 {
+// result.Characters[i].VoiceActors = make([]types.AnimeVoiceActor, len(character.VoiceActors))
+// for j, va := range character.VoiceActors {
+// result.Characters[i].VoiceActors[j] = types.AnimeVoiceActor{
+// MALID: va.MALID,
+// URL: va.URL,
+// Image: va.Image,
+// Name: va.Name,
+// Language: va.Language,
+// }
+// }
+// }
+// }
+// }
+
+// // Convert airing schedule
+// if len(anime.AiringSchedule) > 0 {
+// result.AiringSchedule = make([]types.AnimeAiringEpisode, len(anime.AiringSchedule))
+// for i, schedule := range anime.AiringSchedule {
+// result.AiringSchedule[i] = types.AnimeAiringEpisode{
+// AiringAt: schedule.AiringAt,
+// Episode: schedule.Episode,
+// }
+// }
+// }
+
+// // Convert next airing episode
+// if anime.NextAiringEpisode != nil {
+// result.NextAiringEpisode = types.AnimeAiringEpisode{
+// AiringAt: anime.NextAiringEpisode.AiringAt,
+// Episode: anime.NextAiringEpisode.Episode,
+// }
+// }
+
+// var mapping entities.AnimeMapping
+// if err := DB.Where("mal = ?", anime.MALID).First(&mapping).Error; err == nil {
+// result.Mappings = types.AnimeMappings{
+// AniDB: mapping.AniDB,
+// Anilist: mapping.Anilist,
+// AnimeCountdown: mapping.AnimeCountdown,
+// AnimePlanet: mapping.AnimePlanet,
+// AniSearch: mapping.AniSearch,
+// IMDB: mapping.IMDB,
+// Kitsu: mapping.Kitsu,
+// LiveChart: mapping.LiveChart,
+// NotifyMoe: mapping.NotifyMoe,
+// Simkl: mapping.Simkl,
+// TMDB: mapping.TMDB,
+// TVDB: mapping.TVDB,
+// }
+// }
+
+// return result
+// }
+
+// func GetAnimeMappingViaMALID(malID int) (*entities.AnimeMapping, error) {
+// var mapping entities.AnimeMapping
+// result := DB.Where("mal = ?", malID).First(&mapping)
+// if result.Error != nil {
+// return nil, result.Error
+// }
+// return &mapping, nil
+// }
+
+// func GetAnimeMappingViaAnilistID(anilistID int) (*entities.AnimeMapping, error) {
+// var mapping entities.AnimeMapping
+// result := DB.Where("anilist = ?", anilistID).First(&mapping)
+// if result.Error != nil {
+// return nil, result.Error
+// }
+// return &mapping, nil
+// }
+
+// func GetAnimeMappingsByTVDBID(tvdbID int) ([]entities.AnimeMapping, error) {
+// var mappings []entities.AnimeMapping
+// result := DB.Where("tvdb = ?", tvdbID).Find(&mappings)
+// if result.Error != nil {
+// return nil, result.Error
+// }
+// return mappings, nil
+// }
+
+// // GetEpisodeStreaming retrieves cached streaming data for an episode
+// func GetEpisodeStreaming(episodeID string, animeID uint) (*entities.EpisodeStreaming, error) {
+// var streaming entities.EpisodeStreaming
+// result := DB.Preload("SubSources").
+// Preload("DubSources").
+// Where("episode_id = ? AND anime_id = ?", episodeID, animeID).
+// First(&streaming)
+
+// if result.Error != nil {
+// return nil, result.Error
+// }
+
+// // Check if data is stale (older than 7 days)
+// if time.Since(streaming.LastFetch) > 7*24*time.Hour {
+// return nil, fmt.Errorf("streaming data is stale")
+// }
+
+// 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
+// }
+
+// // GetAllProducers retrieves all master producers (AnimeID = 0) with MAL counts
+// func GetAllProducers(page, limit int) ([]types.AnimeProducer, map[string]interface{}, error) {
+// var results []entities.Producer
+// var total int64
+
+// // Count total producers
+// if err := DB.Model(&entities.Producer{}).Count(&total).Error; err != nil {
+// return nil, nil, err
+// }
+
+// // Calculate pagination
+// offset := (page - 1) * limit
+// lastPage := int((total + int64(limit) - 1) / int64(limit))
+
+// // Fetch producers with pagination
+// err := DB.Preload("Titles").Preload("Images").Preload("ExternalURLs").
+// Order("favorites DESC, count DESC, id ASC").
+// Limit(limit).
+// Offset(offset).
+// Find(&results).Error
+
+// if err != nil {
+// return nil, nil, err
+// }
+
+// // Convert to response format
+// producers := make([]types.AnimeProducer, len(results))
+// for i, r := range results {
+// // Get all titles
+// titles := make([]types.ProducerTitle, len(r.Titles))
+// for j, title := range r.Titles {
+// titles[j] = types.ProducerTitle{
+// Type: title.Type,
+// Title: title.Title,
+// }
+// }
+
+// var images *types.ProducerImages
+// if r.Images != nil && r.Images.ImageURL != "" {
+// images = &types.ProducerImages{
+// JPG: types.ProducerJPGImage{
+// ImageURL: r.Images.ImageURL,
+// },
+// }
+// }
+
+// // Get external URLs
+// externalURLs := make([]types.ProducerExternalURL, len(r.ExternalURLs))
+// for j, ext := range r.ExternalURLs {
+// externalURLs[j] = types.ProducerExternalURL{
+// Name: ext.Name,
+// URL: ext.URL,
+// }
+// }
+
+// producers[i] = types.AnimeProducer{
+// MALID: r.MALID,
+// URL: r.URL,
+// Titles: titles,
+// Images: images,
+// Favorites: r.Favorites,
+// Count: r.Count,
+// Established: r.Established,
+// About: r.About,
+// External: externalURLs,
+// }
+// }
+
+// pagination := map[string]interface{}{
+// "current_page": page,
+// "per_page": limit,
+// "total": total,
+// "last_page": lastPage,
+// "has_next_page": page < lastPage,
+// }
+
+// return producers, pagination, nil
+// }
+
+// func GetProducerByID(malID int) (types.AnimeProducer, error) {
+// var producer entities.Producer
+
+// err := DB.Preload("Titles").Preload("Images").Preload("ExternalURLs").
+// Where("mal_id = ?", malID).
+// First(&producer).Error
+
+// if err != nil {
+// return types.AnimeProducer{}, err
+// }
+
+// // Get all titles
+// titles := make([]types.ProducerTitle, len(producer.Titles))
+// for j, title := range producer.Titles {
+// titles[j] = types.ProducerTitle{
+// Type: title.Type,
+// Title: title.Title,
+// }
+// }
+
+// var images *types.ProducerImages
+// if producer.Images != nil && producer.Images.ImageURL != "" {
+// images = &types.ProducerImages{
+// JPG: types.ProducerJPGImage{
+// ImageURL: producer.Images.ImageURL,
+// },
+// }
+// }
+
+// // Get external URLs
+// externalURLs := make([]types.ProducerExternalURL, len(producer.ExternalURLs))
+// for j, ext := range producer.ExternalURLs {
+// externalURLs[j] = types.ProducerExternalURL{
+// Name: ext.Name,
+// URL: ext.URL,
+// }
+// }
+
+// return types.AnimeProducer{
+// MALID: producer.MALID,
+// URL: producer.URL,
+// Titles: titles,
+// Images: images,
+// Favorites: producer.Favorites,
+// Count: producer.Count,
+// Established: producer.Established,
+// About: producer.About,
+// External: externalURLs,
+// }, nil
+// }
+
+// // SaveEpisodeStreaming saves streaming data to the database
+// func SaveEpisodeStreaming(episodeID string, animeID uint, subSources, dubSources []types.AnimeStreamingSource) error {
+// tx := DB.Begin()
+// if tx.Error != nil {
+// return tx.Error
+// }
+
+// defer func() {
+// if r := recover(); r != nil {
+// tx.Rollback()
+// }
+// }()
+
+// // Delete existing streaming data for this episode
+// var existing entities.EpisodeStreaming
+// if err := tx.Where("episode_id = ? AND anime_id = ?", episodeID, animeID).First(&existing).Error; err == nil {
+// if err := tx.Delete(&existing).Error; err != nil {
+// tx.Rollback()
+// return err
+// }
+// }
+
+// // Create new streaming record
+// streaming := &entities.EpisodeStreaming{
+// EpisodeID: episodeID,
+// AnimeID: animeID,
+// LastFetch: time.Now(),
+// }
+
+// // Save the main record first
+// if err := tx.Create(streaming).Error; err != nil {
+// tx.Rollback()
+// return err
+// }
+
+// // Save sub sources
+// for _, source := range subSources {
+// subSource := entities.EpisodeStreamingSource{
+// EpisodeStreamingID: streaming.ID,
+// URL: source.URL,
+// Server: source.Server,
+// Type: source.Type,
+// }
+// if err := tx.Create(&subSource).Error; err != nil {
+// tx.Rollback()
+// return err
+// }
+// streaming.SubSources = append(streaming.SubSources, subSource)
+// }
+
+// // Save dub sources
+// for _, source := range dubSources {
+// dubSource := entities.EpisodeStreamingSource{
+// EpisodeStreamingID: streaming.ID,
+// URL: source.URL,
+// Server: source.Server,
+// Type: source.Type,
+// }
+// if err := tx.Create(&dubSource).Error; err != nil {
+// tx.Rollback()
+// return err
+// }
+// streaming.DubSources = append(streaming.DubSources, dubSource)
+// }
+
+// return tx.Commit().Error
+// }
diff --git a/repositories/mapping.go b/repositories/mapping.go
new file mode 100644
index 0000000..e797bc3
--- /dev/null
+++ b/repositories/mapping.go
@@ -0,0 +1,43 @@
+package repositories
+
+import (
+ "errors"
+ "fmt"
+ "metachan/database"
+ "metachan/entities"
+ "metachan/enums"
+ "metachan/utils/logger"
+
+ "gorm.io/gorm/clause"
+)
+
+func GetAnimeMapping[T idType](maptype enums.MappingType, id T) (entities.Mapping, error) {
+ var mapping entities.Mapping
+
+ result := database.DB.Where(fmt.Sprintf("%s = ?", maptype), id).First(&mapping)
+
+ if result.Error != nil {
+ logger.Errorf("Mapping", "Failed to get mapping for %s with ID %v: %v", maptype, id, result.Error)
+ return entities.Mapping{}, errors.New("mapping not found")
+ }
+
+ return mapping, nil
+}
+
+func CreateOrUpdateMapping(mapping *entities.Mapping) error {
+ result := database.DB.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "mal"}},
+ DoUpdates: clause.AssignmentColumns([]string{
+ "ani_db", "anilist", "anime_countdown", "anime_planet",
+ "ani_search", "imdb", "kitsu", "live_chart", "notify_moe",
+ "simkl", "tmdb", "tvdb", "type", "mal_anilist_composite",
+ }),
+ }).Create(mapping)
+
+ if result.Error != nil {
+ logger.Errorf("Mapping", "Failed to create or update mapping: %v", result.Error)
+ return errors.New("failed to create or update mapping")
+ }
+
+ return nil
+}
diff --git a/repositories/tasks.go b/repositories/tasks.go
new file mode 100644
index 0000000..2dfecb2
--- /dev/null
+++ b/repositories/tasks.go
@@ -0,0 +1,85 @@
+package repositories
+
+import (
+ "errors"
+ "metachan/database"
+ "metachan/entities"
+ "metachan/utils/logger"
+)
+
+func GetTaskStatus(taskName string) (entities.TaskStatus, error) {
+ var taskStatus entities.TaskStatus
+
+ result := database.DB.Where("task_name = ?", taskName).First(&taskStatus)
+
+ if result.Error != nil {
+ logger.Errorf("Task", "Failed to get task status for %s: %v", taskName, result.Error)
+
+ return entities.TaskStatus{}, errors.New("task status not found")
+ }
+
+ return taskStatus, nil
+}
+
+func SetTaskStatus(task *entities.TaskStatus) error {
+ result := database.DB.Save(task)
+
+ if result.Error != nil {
+ logger.Errorf("Task", "Failed to set task status for %s: %v", task.TaskName, result.Error)
+
+ return errors.New("failed to set task status")
+ }
+
+ return nil
+}
+
+// -- Moved to database/tasks.go --
+// import (
+// "metachan/entities"
+// "time"
+// )
+
+// // MarkTaskComplete marks a task as completed and updates its last run time
+// func MarkTaskComplete(taskName string) error {
+// var taskStatus entities.TaskStatus
+
+// result := DB.Where("task_name = ?", taskName).First(&taskStatus)
+// if result.Error != nil {
+// // Create new task status if it doesn't exist
+// taskStatus = entities.TaskStatus{
+// TaskName: taskName,
+// IsCompleted: true,
+// LastRunAt: time.Now(),
+// }
+// return DB.Create(&taskStatus).Error
+// }
+
+// // Update existing task status
+// taskStatus.IsCompleted = true
+// taskStatus.LastRunAt = time.Now()
+// return DB.Save(&taskStatus).Error
+// }
+
+// // IsTaskComplete checks if a task has been completed
+// func IsTaskComplete(taskName string) bool {
+// var taskStatus entities.TaskStatus
+// result := DB.Where("task_name = ? AND is_completed = ?", taskName, true).First(&taskStatus)
+// return result.Error == nil
+// }
+
+// // GetTaskLastRun returns the last run time for a task
+// func GetTaskLastRun(taskName string) *time.Time {
+// var taskStatus entities.TaskStatus
+// result := DB.Where("task_name = ?", taskName).First(&taskStatus)
+// if result.Error != nil {
+// return nil
+// }
+// return &taskStatus.LastRunAt
+// }
+
+// // ResetTaskStatus resets a task's completion status (useful for periodic tasks)
+// func ResetTaskStatus(taskName string) error {
+// return DB.Model(&entities.TaskStatus{}).
+// Where("task_name = ?", taskName).
+// Update("is_completed", false).Error
+// }
diff --git a/repositories/types.go b/repositories/types.go
new file mode 100644
index 0000000..ccc04f2
--- /dev/null
+++ b/repositories/types.go
@@ -0,0 +1,5 @@
+package repositories
+
+type idType interface {
+ ~int | ~string
+}
diff --git a/router/router.go b/router/router.go
index 14fa23e..426934f 100644
--- a/router/router.go
+++ b/router/router.go
@@ -11,18 +11,46 @@ func Initialize(router *fiber.App) {
router.Get("/health", controllers.HealthStatus)
// 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)
- animeRouter.Get("/:id/characters", controllers.GetAnimeCharacters)
-
- // 404 Default
- router.Use(func(c *fiber.Ctx) error {
- return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
- "error": "Not Found",
- })
- })
+ // 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)
+ // animeRouter.Get("/:id/characters", controllers.GetAnimeCharacters)
+
+ // // Producer routes
+ // producerRouter := router.Group("/producers")
+ // producerRouter.Get("/", controllers.GetProducers)
+ // producerRouter.Get("/:id", controllers.GetProducer)
+ // producerRouter.Get("/:id/anime", controllers.GetAnimeByProducer)
+
+ // // 404 Default
+ // router.Use(func(c *fiber.Ctx) error {
+ // return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
+ // "error": "Not Found",
+ // })
+ // })
+}
+
+func ErrorHandler(ctx *fiber.Ctx, err error) error {
+ code := fiber.StatusInternalServerError
+ if e, ok := err.(*fiber.Error); ok {
+ code = e.Code
+ }
+
+ switch code {
+ case fiber.StatusBadRequest:
+ return controllers.BadRequest(ctx, err)
+ case fiber.StatusUnauthorized:
+ return controllers.Unauthorized(ctx, err)
+ case fiber.StatusForbidden:
+ return controllers.Forbidden(ctx, err)
+ case fiber.StatusNotFound:
+ return controllers.NotFound(ctx, err)
+ case fiber.StatusInternalServerError:
+ return controllers.InternalServerError(ctx, err)
+ default:
+ return controllers.DefaultError(ctx, err)
+ }
}
diff --git a/services/anime/helpers.go b/services/anime/helpers.go
index 2e735b0..c8bf51d 100644
--- a/services/anime/helpers.go
+++ b/services/anime/helpers.go
@@ -160,14 +160,14 @@ func generateGenres(genres, explicitGenres []jikan.JikanGenericMALStructure) []t
}
// generateStudios converts Jikan studio structures to our format
-func generateStudios(studios []jikan.JikanGenericMALStructure) []types.AnimeStudio {
- var animeStudios []types.AnimeStudio
+func generateStudios(studios []jikan.JikanGenericMALStructure) []types.AnimeProducer {
+ var animeStudios []types.AnimeProducer
for _, studio := range studios {
- animeStudios = append(animeStudios, types.AnimeStudio{
- Name: studio.Name,
- StudioID: studio.MALID,
- URL: studio.URL,
+ animeStudios = append(animeStudios, types.AnimeProducer{
+ Name: studio.Name,
+ MALID: studio.MALID,
+ URL: studio.URL,
})
}
@@ -180,9 +180,9 @@ func generateProducers(producers []jikan.JikanGenericMALStructure) []types.Anime
for _, producer := range producers {
animeProducers = append(animeProducers, types.AnimeProducer{
- Name: producer.Name,
- ProducerID: producer.MALID,
- URL: producer.URL,
+ Name: producer.Name,
+ MALID: producer.MALID,
+ URL: producer.URL,
})
}
@@ -190,14 +190,14 @@ func generateProducers(producers []jikan.JikanGenericMALStructure) []types.Anime
}
// generateLicensors converts Jikan licensor structures to our format
-func generateLicensors(licensors []jikan.JikanGenericMALStructure) []types.AnimeLicensor {
- var animeLicensors []types.AnimeLicensor
+func generateLicensors(licensors []jikan.JikanGenericMALStructure) []types.AnimeProducer {
+ var animeLicensors []types.AnimeProducer
for _, licensor := range licensors {
- animeLicensors = append(animeLicensors, types.AnimeLicensor{
- Name: licensor.Name,
- ProducerID: licensor.MALID,
- URL: licensor.URL,
+ animeLicensors = append(animeLicensors, types.AnimeProducer{
+ Name: licensor.Name,
+ MALID: licensor.MALID,
+ URL: licensor.URL,
})
}
diff --git a/services/anime/service.go b/services/anime/service.go
index ff7d0a6..15c0adc 100644
--- a/services/anime/service.go
+++ b/services/anime/service.go
@@ -299,27 +299,55 @@ func (s *Service) GetAnimeDetailsWithSource(mapping *entities.AnimeMapping, sour
})
var err error
+
+ // Try primary title first
subCount, dubCount, err = s.streamingClient.GetStreamingCounts(searchTitle)
- // If the first title fails, try with English title
+ // If primary title fails, try with English title
if err != nil && anime.Data.TitleEnglish != "" {
englishTitle := strings.TrimPrefix(anime.Data.TitleEnglish, "English: ")
+ logger.Log(fmt.Sprintf("Retrying with English title: %s", englishTitle), logger.LogOptions{
+ Level: logger.Debug,
+ Prefix: "AnimeAPI",
+ })
subCount, dubCount, err = s.streamingClient.GetStreamingCounts(englishTitle)
}
- // If English title fails, try with Japanese title
- if err != nil && anime.Data.TitleJapanese != "" {
- subCount, dubCount, err = s.streamingClient.GetStreamingCounts(anime.Data.TitleJapanese)
+ // If English title fails, try with Romaji title from Anilist
+ if err != nil && anilistAnime != nil && anilistAnime.Data.Media.Title.Romaji != "" {
+ romajiTitle := anilistAnime.Data.Media.Title.Romaji
+ logger.Log(fmt.Sprintf("Retrying with Romaji title: %s", romajiTitle), logger.LogOptions{
+ Level: logger.Debug,
+ Prefix: "AnimeAPI",
+ })
+ subCount, dubCount, err = s.streamingClient.GetStreamingCounts(romajiTitle)
+ }
- // Log the final error if all attempts failed
- if err != nil {
- logger.Log(fmt.Sprintf("Failed to fetch streaming counts after all attempts: %v", err), logger.LogOptions{
- Level: logger.Warn,
+ // If Romaji fails, try synonyms
+ if err != nil && len(anime.Data.TitleSynonyms) > 0 {
+ for _, synonym := range anime.Data.TitleSynonyms {
+ if synonym == "" {
+ continue
+ }
+ logger.Log(fmt.Sprintf("Retrying with synonym: %s", synonym), logger.LogOptions{
+ Level: logger.Debug,
Prefix: "AnimeAPI",
})
+ subCount, dubCount, err = s.streamingClient.GetStreamingCounts(synonym)
+ if err == nil {
+ break // Found a match
+ }
}
}
+ // Log the final error if all attempts failed
+ if err != nil {
+ logger.Log(fmt.Sprintf("Failed to fetch streaming counts after all attempts: %v", err), logger.LogOptions{
+ Level: logger.Warn,
+ Prefix: "AnimeAPI",
+ })
+ }
+
logger.Log(fmt.Sprintf("Streaming count check took %s. Subbed: %d, Dubbed: %d",
time.Since(startStreamingCheck), subCount, dubCount), logger.LogOptions{
Level: logger.Debug,
@@ -619,6 +647,265 @@ func (s *Service) GetAnimeByGenre(genreID int, page int, limit int) ([]types.Ani
return animeList, response.Pagination, nil
}
+// GetAnimeByProducer fetches anime list by producer with pagination
+func (s *Service) GetAnimeByProducer(producerID 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.GetAnimeByProducer(producerID, 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 producer: %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, "producer_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 producer 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
+}
+
+func (s *Service) GetAnimeByStudio(studioID 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.GetAnimeByStudio(studioID, 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 studio: %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, "studio_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 studio 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
+}
+
+func (s *Service) GetAnimeByLicensor(licensorID 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.GetAnimeByLicensor(licensorID, 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 licensor: %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, "licensor_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 licensor 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/tasks/anisync.task.go b/tasks/anisync.task.go
index aa94c6e..dc98d3e 100644
--- a/tasks/anisync.task.go
+++ b/tasks/anisync.task.go
@@ -83,18 +83,21 @@ func AniSync() error {
}
}
- // Calculate time remaining (after processing at least 10 items for accuracy)
- timeRemaining := ""
+ // Calculate progress and ETA
+ progress := float64(processed+1) / float64(itemsToSync) * 100
+ eta := ""
if processed >= 10 {
elapsed := time.Since(startTime)
avgTimePerItem := elapsed / time.Duration(processed)
remainingItems := itemsToSync - processed
remaining := time.Duration(remainingItems) * avgTimePerItem
- timeRemaining = fmt.Sprintf(" - ETA: %s", formatDuration(remaining))
+ eta = formatDuration(remaining)
+ } else {
+ eta = "calculating..."
}
// Fetch full anime details
- logger.Log(fmt.Sprintf("[%d/%d] Synchronising MAL ID %d...%s", processed+1, itemsToSync, mapping.MAL, timeRemaining), logger.LogOptions{
+ logger.Log(fmt.Sprintf("[%d/%d] Synchronising MAL ID %d - %.1f%% | ETA: %s", processed+1, itemsToSync, mapping.MAL, progress, eta), logger.LogOptions{
Level: logger.Info,
Prefix: "AniSync",
})
diff --git a/tasks/manager.go b/tasks/manager.go
index 7470542..af86b17 100644
--- a/tasks/manager.go
+++ b/tasks/manager.go
@@ -2,6 +2,7 @@ package tasks
import (
"fmt"
+ "metachan/database"
"metachan/entities"
"metachan/types"
"metachan/utils/logger"
@@ -99,7 +100,13 @@ func (tm *TaskManager) StartTask(taskName string) {
go func() {
// Execute immediately if due
if shouldExec {
- if err := task.Execute(); err != nil {
+ // Check dependencies before executing
+ if !tm.checkDependencies(task) {
+ logger.Log(fmt.Sprintf("Task %s dependencies not met, skipping execution", taskName), logger.LogOptions{
+ Level: logger.Warn,
+ Prefix: "TaskManager",
+ })
+ } else if err := task.Execute(); err != nil {
tm.logTaskExecution(taskName, "error", err.Error())
logger.Log(fmt.Sprintf("Task %s execution failed: %v", taskName, err), logger.LogOptions{
Level: logger.Error,
@@ -108,6 +115,15 @@ func (tm *TaskManager) StartTask(taskName string) {
} else {
task.LastRun = time.Now()
tm.logTaskExecution(taskName, "success", "Task executed successfully")
+
+ // Mark task as complete
+ if err := database.MarkTaskComplete(taskName); err != nil {
+ logger.Log(fmt.Sprintf("Failed to mark task %s as complete: %v", taskName, err), logger.LogOptions{
+ Level: logger.Warn,
+ Prefix: "TaskManager",
+ })
+ }
+
logger.Log(fmt.Sprintf("Task %s executed successfully", taskName), logger.LogOptions{
Level: logger.Success,
Prefix: "TaskManager",
@@ -133,7 +149,13 @@ func (tm *TaskManager) StartTask(taskName string) {
// Wait for initial delay before first execution
select {
case <-time.After(initialDelay):
- if err := task.Execute(); err != nil {
+ // Check dependencies before executing
+ if !tm.checkDependencies(task) {
+ logger.Log(fmt.Sprintf("Task %s dependencies not met, skipping execution", taskName), logger.LogOptions{
+ Level: logger.Warn,
+ Prefix: "TaskManager",
+ })
+ } else if err := task.Execute(); err != nil {
tm.logTaskExecution(taskName, "error", err.Error())
logger.Log(fmt.Sprintf("Task %s execution failed: %v", taskName, err), logger.LogOptions{
Level: logger.Error,
@@ -142,6 +164,15 @@ func (tm *TaskManager) StartTask(taskName string) {
} else {
task.LastRun = time.Now()
tm.logTaskExecution(taskName, "success", "Task executed successfully")
+
+ // Mark task as complete
+ if err := database.MarkTaskComplete(taskName); err != nil {
+ logger.Log(fmt.Sprintf("Failed to mark task %s as complete: %v", taskName, err), logger.LogOptions{
+ Level: logger.Warn,
+ Prefix: "TaskManager",
+ })
+ }
+
logger.Log(fmt.Sprintf("Task %s executed successfully", taskName), logger.LogOptions{
Level: logger.Success,
Prefix: "TaskManager",
@@ -171,7 +202,13 @@ func (tm *TaskManager) StartTask(taskName string) {
for {
select {
case <-ticker.C:
- if err := task.Execute(); err != nil {
+ // Check dependencies before executing
+ if !tm.checkDependencies(task) {
+ logger.Log(fmt.Sprintf("Task %s dependencies not met, skipping execution", taskName), logger.LogOptions{
+ Level: logger.Warn,
+ Prefix: "TaskManager",
+ })
+ } else if err := task.Execute(); err != nil {
tm.logTaskExecution(taskName, "error", err.Error())
logger.Log(fmt.Sprintf("Task %s execution failed: %v", taskName, err), logger.LogOptions{
Level: logger.Error,
@@ -180,6 +217,15 @@ func (tm *TaskManager) StartTask(taskName string) {
} else {
task.LastRun = time.Now()
tm.logTaskExecution(taskName, "success", "Task executed successfully")
+
+ // Mark task as complete
+ if err := database.MarkTaskComplete(taskName); err != nil {
+ logger.Log(fmt.Sprintf("Failed to mark task %s as complete: %v", taskName, err), logger.LogOptions{
+ Level: logger.Warn,
+ Prefix: "TaskManager",
+ })
+ }
+
logger.Log(fmt.Sprintf("Task %s executed successfully", taskName), logger.LogOptions{
Level: logger.Success,
Prefix: "TaskManager",
@@ -213,21 +259,6 @@ func (tm *TaskManager) StopTask(taskName string) {
}
}
-// UpdateTaskLastRun manually updates a task's LastRun time
-func (tm *TaskManager) UpdateTaskLastRun(taskName string, lastRun time.Time) {
- tm.Mutex.Lock()
- defer tm.Mutex.Unlock()
-
- if task, exists := tm.Tasks[taskName]; exists {
- task.LastRun = lastRun
- tm.Tasks[taskName] = task
- logger.Log(fmt.Sprintf("Updated task %s LastRun: %v", taskName, lastRun), logger.LogOptions{
- Level: logger.Debug,
- Prefix: "TaskManager",
- })
- }
-}
-
func (tm *TaskManager) StartAllTasks() {
tm.Mutex.Lock()
var taskNames []string
@@ -259,6 +290,25 @@ func (tm *TaskManager) StopAllTasks() {
}
}
+// checkDependencies verifies all task dependencies are complete
+func (tm *TaskManager) checkDependencies(task types.Task) bool {
+ if len(task.Dependencies) == 0 {
+ return true
+ }
+
+ for _, depName := range task.Dependencies {
+ if !database.IsTaskComplete(depName) {
+ logger.Log(fmt.Sprintf("Dependency %s not completed for task %s", depName, task.Name), logger.LogOptions{
+ Level: logger.Debug,
+ Prefix: "TaskManager",
+ })
+ return false
+ }
+ }
+
+ return true
+}
+
func (tm *TaskManager) GetTaskStatus(taskName string) *types.TaskStatus {
tm.Mutex.Lock()
task, registered := tm.Tasks[taskName]
@@ -277,15 +327,6 @@ func (tm *TaskManager) GetTaskStatus(taskName string) *types.TaskStatus {
if task.Interval > 0 {
next := logEntry.ExecutedAt.Add(task.Interval)
nextRun = &next
- } else if task.TriggeredBy != "" {
- // For manual tasks triggered by another task, use parent task's next run
- var parentLog entities.TaskLog
- if err := tm.Database.Where("task_name = ?", task.TriggeredBy).Order("executed_at desc").First(&parentLog).Error; err == nil {
- if parentTask, exists := tm.Tasks[task.TriggeredBy]; exists && parentTask.Interval > 0 {
- next := parentLog.ExecutedAt.Add(parentTask.Interval)
- nextRun = &next
- }
- }
}
} else if err != gorm.ErrRecordNotFound {
logger.Log(fmt.Sprintf("Error fetching task log for %s: %v", taskName, err), logger.LogOptions{
diff --git a/tasks/producersync.task.go b/tasks/producersync.task.go
new file mode 100644
index 0000000..e211eb3
--- /dev/null
+++ b/tasks/producersync.task.go
@@ -0,0 +1,264 @@
+package tasks
+
+import (
+ "fmt"
+ "metachan/database"
+ "metachan/entities"
+ "metachan/utils/api/jikan"
+ "metachan/utils/logger"
+ "time"
+)
+
+func ProducerSync() error {
+ logger.Log("Starting producer sync (includes studios and licensors)...", logger.LogOptions{
+ Level: logger.Info,
+ Prefix: "ProducerSync",
+ })
+
+ client := jikan.NewJikanClient()
+ page := 1
+ totalFetched := 0
+ var totalPages int
+ var totalProducers int
+ startTime := time.Now()
+
+ for {
+ logger.Log(fmt.Sprintf("Fetching producers page %d...", page), logger.LogOptions{
+ Level: logger.Info,
+ Prefix: "ProducerSync",
+ })
+
+ response, err := client.GetAnimeProducers(page)
+ if err != nil {
+ logger.Log(fmt.Sprintf("Failed to fetch producers page %d: %v", page, err), logger.LogOptions{
+ Level: logger.Error,
+ Prefix: "ProducerSync",
+ })
+ // If we fetched at least one page, continue with what we have
+ if page > 1 {
+ break
+ }
+ return err
+ }
+
+ if len(response.Data) == 0 {
+ break
+ }
+
+ // Set total pages from first response
+ if page == 1 {
+ totalPages = response.Pagination.LastVisiblePage
+ totalProducers = totalPages * len(response.Data)
+ logger.Log(fmt.Sprintf("Total pages: %d, Estimated producers: %d", totalPages, totalProducers), logger.LogOptions{
+ Level: logger.Info,
+ Prefix: "ProducerSync",
+ })
+ }
+
+ // Process each producer
+ for _, producerData := range response.Data {
+ // Check if producer already exists
+ var existingProducer entities.Producer
+ result := database.DB.Where("mal_id = ?", producerData.MALID).First(&existingProducer)
+
+ if result.Error != nil {
+ // Producer doesn't exist, create new
+ producer := entities.Producer{
+ MALID: producerData.MALID,
+ URL: producerData.URL,
+ Favorites: producerData.Favorites,
+ Count: producerData.Count,
+ Established: producerData.Established,
+ About: producerData.About,
+ }
+
+ // Create producer in database
+ if err := database.DB.Create(&producer).Error; err != nil {
+ logger.Log(fmt.Sprintf("Failed to create producer %d: %v", producerData.MALID, err), logger.LogOptions{
+ Level: logger.Error,
+ Prefix: "ProducerSync",
+ })
+ continue
+ }
+
+ // Add titles
+ for _, title := range producerData.Titles {
+ producerTitle := entities.ProducerTitle{
+ ProducerID: producer.ID,
+ Type: title.Type,
+ Title: title.Title,
+ }
+ if err := database.DB.Create(&producerTitle).Error; err != nil {
+ logger.Log(fmt.Sprintf("Failed to create producer title for %d: %v", producerData.MALID, err), logger.LogOptions{
+ Level: logger.Error,
+ Prefix: "ProducerSync",
+ })
+ }
+ }
+
+ // Add image
+ if producerData.Images.JPG.ImageURL != "" {
+ producerImage := entities.ProducerImage{
+ ProducerID: producer.ID,
+ ImageURL: producerData.Images.JPG.ImageURL,
+ }
+ if err := database.DB.Create(&producerImage).Error; err != nil {
+ logger.Log(fmt.Sprintf("Failed to create producer image for %d: %v", producerData.MALID, err), logger.LogOptions{
+ Level: logger.Error,
+ Prefix: "ProducerSync",
+ })
+ }
+ }
+
+ // Fetch and add external URLs
+ time.Sleep(350 * time.Millisecond) // Rate limiting
+ externalResp, err := client.GetProducerExternal(producerData.MALID)
+ if err != nil {
+ logger.Log(fmt.Sprintf("Failed to fetch external URLs for producer %d: %v", producerData.MALID, err), logger.LogOptions{
+ Level: logger.Error,
+ Prefix: "ProducerSync",
+ })
+ } else {
+ for _, ext := range externalResp.Data {
+ producerExt := entities.ProducerExternalURL{
+ ProducerID: producer.ID,
+ Name: ext.Name,
+ URL: ext.URL,
+ }
+ if err := database.DB.Create(&producerExt).Error; err != nil {
+ logger.Log(fmt.Sprintf("Failed to create external URL for producer %d: %v", producerData.MALID, err), logger.LogOptions{
+ Level: logger.Error,
+ Prefix: "ProducerSync",
+ })
+ }
+ }
+ }
+
+ // Get primary title (default or first available)
+ primaryTitle := "Unknown"
+ for _, title := range producerData.Titles {
+ if title.Type == "Default" {
+ primaryTitle = title.Title
+ break
+ }
+ }
+ if primaryTitle == "Unknown" && len(producerData.Titles) > 0 {
+ primaryTitle = producerData.Titles[0].Title
+ }
+
+ logger.Log(fmt.Sprintf("Created producer: %s (ID: %d, Count: %d)", primaryTitle, producerData.MALID, producerData.Count), logger.LogOptions{
+ Level: logger.Success,
+ Prefix: "ProducerSync",
+ })
+ } else {
+ // Producer exists, update it
+ existingProducer.URL = producerData.URL
+ existingProducer.Favorites = producerData.Favorites
+ existingProducer.Count = producerData.Count
+ existingProducer.Established = producerData.Established
+ existingProducer.About = producerData.About
+
+ if err := database.DB.Save(&existingProducer).Error; err != nil {
+ logger.Log(fmt.Sprintf("Failed to update producer %d: %v", producerData.MALID, err), logger.LogOptions{
+ Level: logger.Error,
+ Prefix: "ProducerSync",
+ })
+ continue
+ }
+
+ // Delete and recreate titles
+ database.DB.Where("producer_id = ?", existingProducer.ID).Delete(&entities.ProducerTitle{})
+ for _, title := range producerData.Titles {
+ producerTitle := entities.ProducerTitle{
+ ProducerID: existingProducer.ID,
+ Type: title.Type,
+ Title: title.Title,
+ }
+ database.DB.Create(&producerTitle)
+ }
+
+ // Update image
+ var existingImage entities.ProducerImage
+ if database.DB.Where("producer_id = ?", existingProducer.ID).First(&existingImage).Error == nil {
+ existingImage.ImageURL = producerData.Images.JPG.ImageURL
+ database.DB.Save(&existingImage)
+ } else if producerData.Images.JPG.ImageURL != "" {
+ producerImage := entities.ProducerImage{
+ ProducerID: existingProducer.ID,
+ ImageURL: producerData.Images.JPG.ImageURL,
+ }
+ database.DB.Create(&producerImage)
+ }
+
+ // Update external URLs
+ time.Sleep(350 * time.Millisecond) // Rate limiting
+ externalResp, err := client.GetProducerExternal(producerData.MALID)
+ if err != nil {
+ logger.Log(fmt.Sprintf("Failed to fetch external URLs for producer %d: %v", producerData.MALID, err), logger.LogOptions{
+ Level: logger.Error,
+ Prefix: "ProducerSync",
+ })
+ } else {
+ database.DB.Where("producer_id = ?", existingProducer.ID).Delete(&entities.ProducerExternalURL{})
+ for _, ext := range externalResp.Data {
+ producerExt := entities.ProducerExternalURL{
+ ProducerID: existingProducer.ID,
+ Name: ext.Name,
+ URL: ext.URL,
+ }
+ database.DB.Create(&producerExt)
+ }
+ }
+
+ primaryTitle := "Unknown"
+ for _, title := range producerData.Titles {
+ if title.Type == "Default" {
+ primaryTitle = title.Title
+ break
+ }
+ }
+ if primaryTitle == "Unknown" && len(producerData.Titles) > 0 {
+ primaryTitle = producerData.Titles[0].Title
+ }
+
+ logger.Log(fmt.Sprintf("Updated producer: %s (ID: %d, Count: %d)", primaryTitle, producerData.MALID, producerData.Count), logger.LogOptions{
+ Level: logger.Success,
+ Prefix: "ProducerSync",
+ })
+ }
+
+ totalFetched++
+
+ // Progress update every 10 producers
+ if totalFetched%10 == 0 && totalProducers > 0 {
+ progress := float64(totalFetched) / float64(totalProducers) * 100
+ elapsed := time.Since(startTime)
+ avgTimePerProducer := elapsed / time.Duration(totalFetched)
+ remaining := totalProducers - totalFetched
+ eta := avgTimePerProducer * time.Duration(remaining)
+
+ logger.Log(fmt.Sprintf("Progress: %d/%d - %.1f%% | ETA: %v", totalFetched, totalProducers, progress, eta.Round(time.Second)), logger.LogOptions{
+ Level: logger.Info,
+ Prefix: "ProducerSync",
+ })
+ }
+
+ time.Sleep(350 * time.Millisecond) // Rate limiting between producers
+ }
+
+ // Check if there's more data
+ if !response.Pagination.HasNextPage {
+ break
+ }
+
+ page++
+ time.Sleep(1 * time.Second) // Additional delay between pages
+ }
+
+ logger.Log(fmt.Sprintf("Producer sync completed successfully. Total: %d producers", totalFetched), logger.LogOptions{
+ Level: logger.Success,
+ Prefix: "ProducerSync",
+ })
+
+ return nil
+}
diff --git a/tasks/tasks.go b/tasks/tasks.go
index fb20d09..31d017d 100644
--- a/tasks/tasks.go
+++ b/tasks/tasks.go
@@ -2,6 +2,7 @@ package tasks
import (
"fmt"
+ "metachan/config"
"metachan/database"
"metachan/types"
"metachan/utils/logger"
@@ -20,79 +21,76 @@ func init() {
Database: database.DB,
}
- // Register AniFetch task (weekly) - fetches anime mappings from Fribb list
+ // Register ProducerSync task (every 7 days) - runs first to populate unified producer table
err := GlobalTaskManager.RegisterTask(types.Task{
- Name: "AnimeFetch",
+ Name: "ProducerSync",
Interval: 7 * 24 * time.Hour,
- Execute: func() error {
- // Run AniFetch first
- if err := AniFetch(); err != nil {
- return err
- }
- // After AniFetch completes, trigger AniSync in background
- go func() {
- if err := AniSync(); err != nil {
- logger.Log(fmt.Sprintf("AniSync failed: %v", err), logger.LogOptions{
- Level: logger.Error,
- Prefix: "TaskManager",
- })
- GlobalTaskManager.logTaskExecution("AnimeSync", "error", err.Error())
- } else {
- // Update AnimeSync's LastRun after successful completion
- animeSyncEndTime := time.Now()
- GlobalTaskManager.UpdateTaskLastRun("AnimeSync", animeSyncEndTime)
- GlobalTaskManager.logTaskExecution("AnimeSync", "success", "Task executed successfully")
- }
- }()
- return nil
- },
+ Execute: ProducerSync,
})
if err != nil {
- logger.Log(fmt.Sprintf("Failed to register AnimeFetch task: %v", err), logger.LogOptions{
+ logger.Log(fmt.Sprintf("Failed to register ProducerSync task: %v", err), logger.LogOptions{
Level: logger.Error,
Prefix: "TaskManager",
})
}
- // Register AnimeSync task (triggered automatically after AnimeFetch completes)
+ // Register GenreSync task (every 7 days)
err = GlobalTaskManager.RegisterTask(types.Task{
- Name: "AnimeSync",
- Interval: 0, // Manual-only - runs after AnimeFetch
- Execute: AniSync,
- TriggeredBy: "AnimeFetch",
+ Name: "GenreSync",
+ Interval: 7 * 24 * time.Hour,
+ Execute: GenreSync,
})
if err != nil {
- logger.Log(fmt.Sprintf("Failed to register AnimeSync task: %v", err), logger.LogOptions{
+ logger.Log(fmt.Sprintf("Failed to register GenreSync task: %v", err), logger.LogOptions{
Level: logger.Error,
Prefix: "TaskManager",
})
}
- // Register AnimeUpdate task (every 15 minutes)
+ // Register AniFetch task (weekly) - fetches anime mappings from Fribb list
+ // Depends on ProducerSync and GenreSync completing first
err = GlobalTaskManager.RegisterTask(types.Task{
- Name: "AnimeUpdate",
- Interval: 15 * time.Minute,
- Execute: AnimeUpdate,
+ Name: "AnimeFetch",
+ Interval: 7 * 24 * time.Hour,
+ Dependencies: []string{"ProducerSync", "GenreSync"},
+ Execute: AniFetch,
})
if err != nil {
- logger.Log(fmt.Sprintf("Failed to register AnimeUpdate task: %v", err), logger.LogOptions{
+ logger.Log(fmt.Sprintf("Failed to register AnimeFetch task: %v", err), logger.LogOptions{
Level: logger.Error,
Prefix: "TaskManager",
})
}
- // Register GenreSync task (every 7 days)
+ // Register AnimeSync task (runs after AnimeFetch completes) - only if enabled in config
+ if config.Config.AniSync {
+ err = GlobalTaskManager.RegisterTask(types.Task{
+ Name: "AnimeSync",
+ Interval: 0, // Manual-only - waits for AnimeFetch dependency
+ Execute: AniSync,
+ Dependencies: []string{"AnimeFetch"},
+ })
+
+ if err != nil {
+ logger.Log(fmt.Sprintf("Failed to register AnimeSync task: %v", err), logger.LogOptions{
+ Level: logger.Error,
+ Prefix: "TaskManager",
+ })
+ }
+ }
+
+ // Register AnimeUpdate task (every 15 minutes)
err = GlobalTaskManager.RegisterTask(types.Task{
- Name: "GenreSync",
- Interval: 7 * 24 * time.Hour,
- Execute: GenreSync,
+ Name: "AnimeUpdate",
+ Interval: 15 * time.Minute,
+ Execute: AnimeUpdate,
})
if err != nil {
- logger.Log(fmt.Sprintf("Failed to register GenreSync task: %v", err), logger.LogOptions{
+ logger.Log(fmt.Sprintf("Failed to register AnimeUpdate task: %v", err), logger.LogOptions{
Level: logger.Error,
Prefix: "TaskManager",
})
diff --git a/types/anime.go b/types/anime.go
index cad6558..5d23470 100644
--- a/types/anime.go
+++ b/types/anime.go
@@ -103,25 +103,40 @@ type AnimeGenres struct {
URL string `json:"url"`
}
-// AnimeProducer contains producer information
+// AnimeProducer contains full producer/studio/licensor information
type AnimeProducer struct {
- Name string `json:"name"`
- ProducerID int `json:"producer_id"`
- URL string `json:"url"`
+ MALID int `json:"mal_id"`
+ URL string `json:"url"`
+ Name string `json:"name,omitempty"`
+ Titles []ProducerTitle `json:"titles,omitempty"`
+ Images *ProducerImages `json:"images,omitempty"`
+ Favorites int `json:"favorites,omitempty"`
+ Count int `json:"count,omitempty"`
+ Established string `json:"established,omitempty"`
+ About string `json:"about,omitempty"`
+ External []ProducerExternalURL `json:"external,omitempty"`
}
-// AnimeLicensor contains licensor information
-type AnimeLicensor struct {
- Name string `json:"name"`
- ProducerID int `json:"producer_id"`
- URL string `json:"url"`
+// ProducerTitle represents a producer title variant
+type ProducerTitle struct {
+ Type string `json:"type"`
+ Title string `json:"title"`
}
-// AnimeStudio contains studio information
-type AnimeStudio struct {
- Name string `json:"name"`
- StudioID int `json:"studio_id"`
- URL string `json:"url"`
+// ProducerImages represents producer image URLs
+type ProducerImages struct {
+ JPG ProducerJPGImage `json:"jpg"`
+}
+
+// ProducerJPGImage represents JPG image variant
+type ProducerJPGImage struct {
+ ImageURL string `json:"image_url"`
+}
+
+// ProducerExternalURL represents an external URL for a producer
+type ProducerExternalURL struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
}
//
@@ -241,8 +256,8 @@ type Anime struct {
Year int `json:"year"`
Broadcast AnimeBroadcast `json:"broadcast"`
Producers []AnimeProducer `json:"producers"`
- Studios []AnimeStudio `json:"studios"`
- Licensors []AnimeLicensor `json:"licensors"`
+ Studios []AnimeProducer `json:"studios"`
+ Licensors []AnimeProducer `json:"licensors"`
Seasons []AnimeSeason `json:"seasons,omitempty"`
Episodes AnimeEpisodes `json:"episodes"`
NextAiringEpisode AnimeAiringEpisode `json:"next_airing_episode,omitempty"`
diff --git a/types/anisync.go b/types/anisync.go
index f83d19e..ca79bed 100644
--- a/types/anisync.go
+++ b/types/anisync.go
@@ -10,20 +10,3 @@ const (
AniSyncONA AniSyncType = "ONA"
AniSyncUNKNOWN AniSyncType = "UNKNOWN"
)
-
-type AniSyncMapping struct {
- AniDB int `json:"anidb_id"`
- Anilist int `json:"anilist_id"`
- AnimeCountdown int `json:"animecountdown_id"`
- AnimePlanet any `json:"anime-planet_id"`
- AniSearch int `json:"anisearch_id"`
- IMDB string `json:"imdb_id"`
- Kitsu int `json:"kitsu_id"`
- LiveChart int `json:"livechart_id"`
- MAL int `json:"mal_id"`
- NotifyMoe string `json:"notify.moe_id"`
- Simkl int `json:"simkl_id"`
- TMDB any `json:"themoviedb_id"`
- TVDB int `json:"thetvdb_id"`
- Type AniSyncType `json:"type"`
-}
diff --git a/types/http.go b/types/http.go
new file mode 100644
index 0000000..1dd877d
--- /dev/null
+++ b/types/http.go
@@ -0,0 +1,17 @@
+package types
+
+type HTTPParam struct {
+ Key string
+ Value string
+}
+
+type Request struct {
+ Path string
+ Method string
+ Query []HTTPParam
+ Params []HTTPParam
+ Headers []HTTPParam
+ QueryString string
+ IP string
+ URL string
+}
diff --git a/types/jikan.go b/types/jikan.go
new file mode 100644
index 0000000..a91c14a
--- /dev/null
+++ b/types/jikan.go
@@ -0,0 +1,218 @@
+package types
+
+type JikanGenericPaginationEntity struct {
+ LastVisiblePage int `json:"last_visible_page"`
+ HasNextPage bool `json:"has_next_page"`
+}
+
+type JikanExtendedPaginationItems struct {
+ Count int `json:"count"`
+ Total int `json:"total"`
+ PerPage int `json:"per_page"`
+}
+
+type JikanExtendedPagination struct {
+ LastVisiblePage int `json:"last_visible_page"`
+ HasNextPage bool `json:"has_next_page"`
+ CurrentPage int `json:"current_page"`
+ Items JikanExtendedPaginationItems `json:"items"`
+}
+
+type JikanGenericTitleEntity struct {
+ Type string `json:"type"`
+ Title string `json:"title"`
+}
+
+type JikanGenericImageSizeEntity struct {
+ ImageURL string `json:"image_url,omitempty"`
+ SmallImageURL string `json:"small_image_url,omitempty"`
+ MediumImageURL string `json:"medium_image_url,omitempty"`
+ LargeImageURL string `json:"large_image_url,omitempty"`
+ MaximumImageURL string `json:"maximum_image_url,omitempty"`
+}
+
+type JikanGenericImageEntity struct {
+ JPG JikanGenericImageSizeEntity `json:"jpg"`
+ WebP JikanGenericImageSizeEntity `json:"webp,omitempty"`
+}
+
+type JikanGenericRelatedEntity struct {
+ MALID int `json:"mal_id"`
+ Type string `json:"type"`
+ URL string `json:"url"`
+ Name string `json:"name"`
+}
+
+type JikanGenericDate struct {
+ Day int `json:"day"`
+ Month int `json:"month"`
+ Year int `json:"year"`
+}
+
+type JikanGenericSchedule struct {
+ From JikanGenericDate `json:"from"`
+ To JikanGenericDate `json:"to"`
+}
+
+type JikanGenericRelation struct {
+ Relation string `json:"relation"`
+ Entry []JikanGenericRelatedEntity `json:"entry"`
+}
+
+type JikanGenericURL struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+}
+
+type JikanAiringSchedule struct {
+ From string `json:"from"`
+ To string `json:"to"`
+ Prop JikanGenericSchedule `json:"prop"`
+ String string `json:"string"`
+}
+
+type JikanBroadcastSchedule struct {
+ Day string `json:"day"`
+ Time string `json:"time"`
+ Timezone string `json:"timezone"`
+ String string `json:"string"`
+}
+
+type JikanAnimeTrailer struct {
+ YoutubeID string `json:"youtube_id"`
+ URL string `json:"url"`
+ EmbedURL string `json:"embed_url"`
+ Images JikanGenericImageEntity `json:"images"`
+}
+
+type JikanAnimeTheme struct {
+ Openings []string `json:"openings"`
+ Endings []string `json:"endings"`
+}
+
+type JikanSingleAnime 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 JikanGenericImageEntity `json:"images"`
+ Genres []JikanGenericRelatedEntity `json:"genres"`
+ ExplicitGenres []JikanGenericRelatedEntity `json:"explicit_genres"`
+ Producers []JikanGenericRelatedEntity `json:"producers"`
+ Licensors []JikanGenericRelatedEntity `json:"licensors"`
+ Studios []JikanGenericRelatedEntity `json:"studios"`
+}
+
+type JikanFullSingleAnime struct {
+ JikanSingleAnime
+ Trailer JikanAnimeTrailer `json:"trailer"`
+ Approved bool `json:"approved"`
+ Titles []JikanGenericTitleEntity `json:"titles"`
+ Aired JikanAiringSchedule `json:"aired"`
+ Duration string `json:"duration"`
+ Rating string `json:"rating"`
+ Background string `json:"background"`
+ Broadcast JikanBroadcastSchedule `json:"broadcast"`
+ Themes []JikanGenericRelatedEntity `json:"themes"`
+ Demographics []JikanGenericRelatedEntity `json:"demographics"`
+ Relations []JikanGenericRelation `json:"relations"`
+ Theme JikanAnimeTheme `json:"theme"`
+ External []JikanGenericURL `json:"external"`
+ Streaming []JikanGenericURL `json:"streaming"`
+}
+
+type JikanAnimeResponse struct {
+ Data JikanFullSingleAnime `json:"data"`
+}
+
+type JikanAnimeSingleEpisode struct {
+ MALID int `json:"mal_id"`
+ URL string `json:"url"`
+ Title string `json:"title"`
+ TitleJapanese string `json:"title_japanese"`
+ TitleRomaji string `json:"title_romaji"`
+ Aired string `json:"aired"`
+ Score float64 `json:"score"`
+ Filler bool `json:"filler"`
+ Recap bool `json:"recap"`
+ ForumURL string `json:"forum_url"`
+}
+
+type JikanAnimeEpisodeResponse struct {
+ Pagination JikanGenericPaginationEntity `json:"pagination"`
+ Data []JikanAnimeSingleEpisode `json:"data"`
+}
+
+type JikanSingleCharacter struct {
+ MALID int `json:"mal_id"`
+ URL string `json:"url"`
+ Images JikanGenericImageEntity `json:"images"`
+ Name string `json:"name"`
+ Role string `json:"role"`
+ VoiceActors []JikanVoiceActor `json:"voice_actors"`
+}
+
+type JikanVoiceActor struct {
+ MALID int `json:"mal_id"`
+ URL string `json:"url"`
+ Images JikanGenericImageEntity `json:"images"`
+ Name string `json:"name"`
+ Language string `json:"language"`
+}
+
+type JikanAnimeCharacterResponse struct {
+ Data []JikanSingleCharacter `json:"data"`
+}
+
+type JikanGenre struct {
+ MALID int `json:"mal_id"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Count int `json:"count"`
+}
+
+type JikanGenresResponse struct {
+ Data []JikanGenre `json:"data"`
+}
+
+type JikanAnimeSearchResponse struct {
+ Pagination JikanExtendedPagination `json:"pagination"`
+ Data []JikanSingleAnime `json:"data"`
+}
+
+type JikanSingleProducer struct {
+ MALID int `json:"mal_id"`
+ URL string `json:"url"`
+ Titles []JikanGenericTitleEntity `json:"titles"`
+ Images JikanGenericImageEntity `json:"images"`
+ Favorites int `json:"favorites"`
+ Count int `json:"count"`
+ Established string `json:"established"`
+ About string `json:"about"`
+ External []JikanGenericURL `json:"external,omitempty"`
+}
+
+type JikanProducersResponse struct {
+ Data []JikanSingleProducer `json:"data"`
+ Pagination JikanGenericPaginationEntity `json:"pagination"`
+}
+
+type JikanSingleProducerResponse struct {
+ Data JikanSingleProducer `json:"data"`
+}
diff --git a/types/mapping.go b/types/mapping.go
new file mode 100644
index 0000000..b63db1d
--- /dev/null
+++ b/types/mapping.go
@@ -0,0 +1,20 @@
+package types
+
+import "metachan/enums"
+
+type MappingResponse struct {
+ AniDB int `json:"anidb_id"`
+ Anilist int `json:"anilist_id"`
+ AnimeCountdown int `json:"animecountdown_id"`
+ AnimePlanet any `json:"anime-planet_id"`
+ AniSearch int `json:"anisearch_id"`
+ IMDB string `json:"imdb_id"`
+ Kitsu int `json:"kitsu_id"`
+ LiveChart int `json:"livechart_id"`
+ MAL int `json:"mal_id"`
+ NotifyMoe string `json:"notify.moe_id"`
+ Simkl int `json:"simkl_id"`
+ TMDB any `json:"themoviedb_id"`
+ TVDB int `json:"thetvdb_id"`
+ Type enums.MappingAnimeType `json:"type"`
+}
diff --git a/types/server.go b/types/server.go
index 5bb7065..a6d3e9e 100644
--- a/types/server.go
+++ b/types/server.go
@@ -1,14 +1,5 @@
package types
-type DatabaseDriver string
-
-const (
- SQLite DatabaseDriver = "sqlite"
- MySQL DatabaseDriver = "mysql"
- Postgres DatabaseDriver = "postgres"
- SQLServer DatabaseDriver = "sqlserver"
-)
-
type TMDBConfig struct {
APIKey string
ReadAccessToken string
@@ -17,11 +8,3 @@ type TMDBConfig struct {
type TVDBConfig struct {
APIKey string
}
-
-type ServerConfig struct {
- DatabaseDriver DatabaseDriver
- DataSourceName string
- Port int
- TMDB TMDBConfig
- TVDB TVDBConfig
-}
diff --git a/types/task_manager.go b/types/task_manager.go
index 1e7b157..9ce6e56 100644
--- a/types/task_manager.go
+++ b/types/task_manager.go
@@ -5,11 +5,11 @@ import (
)
type Task struct {
- Name string
- Interval time.Duration
- Execute func() error
- LastRun time.Time
- TriggeredBy string // Name of parent task that triggers this task (for manual tasks)
+ Name string
+ Interval time.Duration
+ Execute func() error
+ LastRun time.Time
+ Dependencies []string // List of task names that must complete before this task runs
}
type TaskStatus struct {
diff --git a/utils/api/jikan/jikan.go b/utils/api/jikan/jikan.go
index 4f34dc3..e94223e 100644
--- a/utils/api/jikan/jikan.go
+++ b/utils/api/jikan/jikan.go
@@ -3,300 +3,692 @@ package jikan
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"math"
+ "metachan/types"
+ "metachan/utils/logger"
"metachan/utils/ratelimit"
"net/http"
- "os/exec"
"strconv"
"time"
)
+const (
+ jikanAPIBaseURL = "https://api.jikan.moe/v4"
+ rateLimitPerSec = 3
+ rateLimitPerMin = 60
+ contextTimeout = 60 * time.Second
+)
+
var (
- // Global Jikan rate limiters
- jikanPerSecLimiter = ratelimit.NewRateLimiter(3, time.Second)
- jikanPerMinLimiter = ratelimit.NewRateLimiter(60, time.Minute)
- jikanLimiter = ratelimit.NewMultiLimiter(jikanPerSecLimiter, jikanPerMinLimiter)
+ rateLimiter = ratelimit.NewMultiLimiter(
+ ratelimit.NewRateLimiter(rateLimitPerSec, time.Second),
+ ratelimit.NewRateLimiter(rateLimitPerMin, time.Minute),
+ )
+ clientInstance = &client{
+ httpClient: &http.Client{
+ Timeout: 15 * time.Second,
+ },
+ maxRetries: 3,
+ backoff: 1 * time.Second,
+ }
)
-// JikanClient provides methods to interact with the Jikan API
-type JikanClient struct {
- client *http.Client
- maxRetries int
- baseBackoff time.Duration
+func (c *client) getBackOffDuration(attempt int) time.Duration {
+ return time.Duration(float64(c.backoff) * math.Pow(2, float64(attempt-1)))
}
-// NewJikanClient creates a new Jikan API client
-func NewJikanClient() *JikanClient {
- return &JikanClient{
- client: &http.Client{
- Timeout: 15 * time.Second,
- },
- maxRetries: 3,
- baseBackoff: 1 * time.Second,
+func (c *client) getRetryAfterDuration(resp *http.Response) time.Duration {
+ if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
+ if seconds, err := strconv.Atoi(retryAfter); err == nil {
+ return time.Duration(seconds) * time.Second
+ }
}
+ return c.backoff
}
-// WaitForRateLimit waits until a request can be made according to rate limiting rules
-func (c *JikanClient) WaitForRateLimit() {
- jikanLimiter.Wait()
+func (c *client) handleRetry(retries *int, url string, reason string, retryAfter time.Duration) bool {
+ *retries++
+ if *retries >= c.maxRetries {
+ return false
+ }
+
+ backoffDuration := c.getBackOffDuration(*retries)
+ if retryAfter > backoffDuration {
+ backoffDuration = retryAfter
+ }
+
+ logger.Warnf("JikanClient", "%s for %s (attempt %d/%d)", reason, url, *retries, c.maxRetries)
+ time.Sleep(backoffDuration)
+ return true
}
-// makeRequest makes an HTTP request with retries and proper error handling
-func (c *JikanClient) makeRequest(ctx context.Context, url string) ([]byte, error) {
- var bodyBytes []byte
- var statusCode int
+func (c *client) makeRequest(ctx context.Context, url string) ([]byte, error) {
+ var response *http.Response
+ var retries int
- retries := 0
- for retries <= c.maxRetries {
- // Wait for rate limiter before attempting request
- c.WaitForRateLimit()
+ for retries < c.maxRetries {
+ rateLimiter.Wait()
- // Create the request with timeout context
- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ request, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
+ logger.Errorf("JikanClient", "Failed to create request: %v", err)
+ return nil, errors.New("failed to create request to Jikan API")
}
- // Execute the request
- resp, err := c.client.Do(req)
+ response, err = c.httpClient.Do(request)
if err != nil {
- if retries < c.maxRetries {
- retries++
- backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(2, float64(retries-1)))
- time.Sleep(backoffTime)
- continue
+ if !c.handleRetry(&retries, url, fmt.Sprintf("Request failed: %v", err), 0) {
+ logger.Errorf("JikanClient", "All retries exhausted for request to %s: %v", url, err)
+ return nil, errors.New("failed to make request to Jikan API after max retries")
}
- return nil, fmt.Errorf("failed to execute request after %d retries: %w", c.maxRetries, err)
+ continue
}
- defer resp.Body.Close()
-
- statusCode = resp.StatusCode
-
- // Handle rate limiting with exponential backoff
- if statusCode == http.StatusTooManyRequests {
- if retries < c.maxRetries {
- retries++
- backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(1.5, float64(retries-1)))
- // Respect Retry-After header if available
- if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
- if seconds, err := strconv.Atoi(retryAfter); err == nil {
- backoffTime = time.Duration(seconds) * time.Second
- }
- }
+ defer response.Body.Close()
- time.Sleep(backoffTime)
- continue
+ switch response.StatusCode {
+ case http.StatusTooManyRequests:
+ retryAfter := c.getRetryAfterDuration(response)
+ if !c.handleRetry(&retries, url, "Rate limited", retryAfter) {
+ logger.Errorf("JikanClient", "All retries exhausted for request to %s", url)
+ return nil, errors.New("failed to make request to Jikan API after max retries")
}
- return nil, fmt.Errorf("rate limited after %d retries", c.maxRetries)
- } else if statusCode != http.StatusOK {
- if retries < c.maxRetries {
- retries++
- backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(2, float64(retries-1)))
- time.Sleep(backoffTime)
- continue
- }
- return nil, fmt.Errorf("request failed with status: %d", statusCode)
- }
+ case http.StatusOK:
+ bytes, err := io.ReadAll(response.Body)
- // 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 < c.maxRetries {
- retries++
- backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(2, float64(retries-1)))
- time.Sleep(backoffTime)
- continue
+ if err != nil {
+ logger.Errorf("JikanClient", "Failed to read response body from %s: %v", url, err)
+ return nil, errors.New("failed to read response from Jikan API")
}
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
- // Success, break the retry loop
- return bodyBytes, nil
+ return bytes, nil
+ default:
+ retries++
+ backoffDuration := c.getBackOffDuration(retries)
+
+ logger.Warnf("JikanClient", "Request to %s returned status %d (attempt %d/%d)", url, response.StatusCode, retries, c.maxRetries)
+
+ time.Sleep(backoffDuration)
+ }
}
- return nil, fmt.Errorf("exhausted all retries with status code: %d", statusCode)
+ logger.Errorf("JikanClient", "All retries exhausted for request to %s", url)
+ return nil, errors.New("failed to make request to Jikan API after max retries")
}
-// GetAnime fetches basic anime information by MAL ID
-func (c *JikanClient) GetAnime(malID int) (*JikanAnimeResponse, error) {
- apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d", malID)
+func GetAnimeByMALID(id int) (*types.JikanAnimeResponse, error) {
+ url := fmt.Sprintf("%s/anime/%d/full", jikanAPIBaseURL, id)
+ ctx, cancel := context.WithTimeout(context.Background(), contextTimeout)
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
- bodyBytes, err := c.makeRequest(ctx, apiURL)
+ bytes, err := clientInstance.makeRequest(ctx, url)
if err != nil {
- return nil, fmt.Errorf("failed to get anime data: %w", err)
+ logger.Errorf("JikanClient", "GetAnimeByMALID failed for ID %d: %v", id, err)
+ return nil, errors.New("failed to fetch anime data from Jikan API")
}
- var animeResponse JikanAnimeResponse
- if err := json.Unmarshal(bodyBytes, &animeResponse); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
+ var response types.JikanAnimeResponse
+ if err := json.Unmarshal(bytes, &response); err != nil {
+ logger.Errorf("JikanClient", "Failed to unmarshal response for ID %d: %v", id, err)
+ return nil, errors.New("failed to parse anime data from Jikan API")
}
- if animeResponse.Data.MALID == 0 {
- return nil, fmt.Errorf("no data found for MAL ID %d", malID)
+ return &response, nil
+}
+
+func GetAnimeEpisodesByMALID(id int) (*types.JikanAnimeEpisodeResponse, error) {
+ url := fmt.Sprintf("%s/anime/%d/episodes", jikanAPIBaseURL, id)
+
+ page := 1
+ hasNextPage := true
+
+ response := &types.JikanAnimeEpisodeResponse{
+ Pagination: types.JikanGenericPaginationEntity{},
+ Data: []types.JikanAnimeSingleEpisode{},
}
- return &animeResponse, nil
-}
+ for hasNextPage {
+ ctx, cancel := context.WithTimeout(context.Background(), contextTimeout)
+ pageURL := fmt.Sprintf("%s?page=%d", url, page)
-// GetFullAnime fetches detailed anime information by MAL ID
-func (c *JikanClient) GetFullAnime(malID int) (*JikanAnimeResponse, error) {
- apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/full", malID)
+ bytes, err := clientInstance.makeRequest(ctx, pageURL)
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
+ cancel()
- bodyBytes, err := c.makeRequest(ctx, apiURL)
- if err != nil {
- // Fallback to curl if HTTP client fails
- var curlErr error
- bodyBytes, curlErr = c.makeRequestWithCurl(apiURL)
- if curlErr != nil {
- return nil, fmt.Errorf("failed to get anime full data via HTTP (%w) and curl (%v)", err, curlErr)
+ if err != nil {
+ logger.Errorf("JikanClient", "GetAnimeEpisodesByMALID failed for ID %d on page %d: %v", id, page, err)
+ return nil, errors.New("failed to fetch anime episodes from Jikan API")
}
+
+ var pageResponse types.JikanAnimeEpisodeResponse
+
+ if err := json.Unmarshal(bytes, &pageResponse); err != nil {
+ logger.Errorf("JikanClient", "Failed to unmarshal episodes response for ID %d on page %d: %v", id, page, err)
+ return nil, errors.New("failed to parse anime episodes from Jikan API")
+ }
+
+ if response.Pagination.LastVisiblePage == 0 {
+ response.Pagination = pageResponse.Pagination
+ }
+
+ response.Data = append(response.Data, pageResponse.Data...)
+ hasNextPage = pageResponse.Pagination.HasNextPage
+ page++
}
- var animeResponse JikanAnimeResponse
- if err := json.Unmarshal(bodyBytes, &animeResponse); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
+ return response, nil
+}
+
+func GetAnimeCharactersByMALID(id int) (*types.JikanAnimeCharacterResponse, error) {
+ url := fmt.Sprintf("%s/anime/%d/characters", jikanAPIBaseURL, id)
+ ctx, cancel := context.WithTimeout(context.Background(), contextTimeout)
+
+ defer cancel()
+
+ bytes, err := clientInstance.makeRequest(ctx, url)
+ if err != nil {
+ logger.Errorf("JikanClient", "GetAnimeCharactersByMALID failed for ID %d: %v", id, err)
+ return nil, errors.New("failed to fetch anime characters from Jikan API")
}
- if animeResponse.Data.MALID == 0 {
- return nil, fmt.Errorf("no data found for MAL ID %d", malID)
+ var response types.JikanAnimeCharacterResponse
+ if err := json.Unmarshal(bytes, &response); err != nil {
+ logger.Errorf("JikanClient", "Failed to unmarshal characters response for ID %d: %v", id, err)
+ return nil, errors.New("failed to parse anime characters from Jikan API")
}
- return &animeResponse, nil
+ return &response, nil
}
-// makeRequestWithCurl uses curl as a fallback when Go HTTP client fails
-func (c *JikanClient) makeRequestWithCurl(url string) ([]byte, error) {
- c.WaitForRateLimit()
+func GetAnimeGenres() (*types.JikanGenresResponse, error) {
+ url := fmt.Sprintf("%s/genres/anime", jikanAPIBaseURL)
+ ctx, cancel := context.WithTimeout(context.Background(), contextTimeout)
+
+ defer cancel()
- cmd := exec.Command("curl", "-s", "-H", "Accept: application/json", url)
- output, err := cmd.Output()
+ bytes, err := clientInstance.makeRequest(ctx, url)
if err != nil {
- return nil, fmt.Errorf("curl command failed: %w", err)
+ logger.Errorf("JikanClient", "GetAnimeGenres failed: %v", err)
+ return nil, errors.New("failed to fetch anime genres from Jikan API")
}
- return output, nil
+ var response types.JikanGenresResponse
+ if err := json.Unmarshal(bytes, &response); err != nil {
+ logger.Errorf("JikanClient", "Failed to unmarshal genres response: %v", err)
+ return nil, errors.New("failed to parse anime genres from Jikan API")
+ }
+
+ return &response, nil
}
-// GetAnimeEpisodes fetches all episodes for an anime by MAL ID
-func (c *JikanClient) GetAnimeEpisodes(malID int) (*JikanAnimeEpisodeResponse, error) {
- result := JikanAnimeEpisodeResponse{
- Data: []JikanAnimeEpisode{},
+func GetAnimeByGenre(genreID int, page int, limit int) (*types.JikanAnimeSearchResponse, error) {
+ url := fmt.Sprintf("%s/anime?genres=%d&page=%d&limit=%d", jikanAPIBaseURL, genreID, page, limit)
+ ctx, cancel := context.WithTimeout(context.Background(), contextTimeout)
+
+ defer cancel()
+
+ bytes, err := clientInstance.makeRequest(ctx, url)
+ if err != nil {
+ logger.Errorf("JikanClient", "GetAnimeByGenre failed for genre %d: %v", genreID, err)
+ return nil, errors.New("failed to fetch anime by genre from Jikan API")
}
- maxPages := 25 // Safety limit to avoid excessive requests
- page := 1
- maxAttempts := 15 // Maximum number of attempts across all pages
- totalAttempts := 0
+ var response types.JikanAnimeSearchResponse
+ if err := json.Unmarshal(bytes, &response); err != nil {
+ logger.Errorf("JikanClient", "Failed to unmarshal anime by genre response for genre %d: %v", genreID, err)
+ return nil, errors.New("failed to parse anime by genre from Jikan API")
+ }
- for page <= maxPages && totalAttempts < maxAttempts {
- apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/episodes?page=%d", malID, page)
+ return &response, nil
+}
- ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+func GetAnimeProducers() (*types.JikanProducersResponse, error) {
+ url := fmt.Sprintf("%s/producers", jikanAPIBaseURL)
+ ctx, cancel := context.WithTimeout(context.Background(), contextTimeout)
+ page := 1
+ hasNextPage := true
- totalAttempts++
+ response := &types.JikanProducersResponse{
+ Data: []types.JikanSingleProducer{},
+ Pagination: types.JikanGenericPaginationEntity{},
+ }
- bodyBytes, err := c.makeRequest(ctx, apiURL)
- cancel()
+ defer cancel()
+ for hasNextPage {
+ pageURL := fmt.Sprintf("%s?page=%d", url, page)
+
+ bytes, err := clientInstance.makeRequest(ctx, pageURL)
if err != nil {
- // If we have some episodes already, return them rather than failing
- if len(result.Data) > 0 {
- result.Pagination.HasNextPage = false
- break
- }
- return nil, fmt.Errorf("failed to get anime episodes page %d: %w", page, err)
+ logger.Errorf("JikanClient", "GetAnimeProducers failed on page %d: %v", page, err)
+ return nil, errors.New("failed to fetch anime producers from Jikan API")
}
- var pageResponse JikanAnimeEpisodeResponse
- if err := json.Unmarshal(bodyBytes, &pageResponse); err != nil {
- // Return what we have if we got some pages successfully
- if len(result.Data) > 0 {
- result.Pagination.HasNextPage = false
- break
- }
- return nil, fmt.Errorf("failed to decode episodes response: %w", err)
+ var pageResponse types.JikanProducersResponse
+ if err := json.Unmarshal(bytes, &pageResponse); err != nil {
+ logger.Errorf("JikanClient", "Failed to unmarshal producers response on page %d: %v", page, err)
+ return nil, errors.New("failed to parse anime producers from Jikan API")
}
- // Append episodes from this page
- result.Data = append(result.Data, pageResponse.Data...)
- result.Pagination = pageResponse.Pagination
-
- // Check if we need to fetch more pages
- if !pageResponse.Pagination.HasNextPage {
- break
+ if response.Pagination.LastVisiblePage == 0 {
+ response.Pagination = pageResponse.Pagination
}
+ response.Data = append(response.Data, pageResponse.Data...)
+ hasNextPage = pageResponse.Pagination.HasNextPage
page++
}
- return &result, nil
+ return response, nil
}
-// GetAnimeCharacters fetches all characters for an anime by MAL ID
-func (c *JikanClient) GetAnimeCharacters(malID int) (*JikanAnimeCharacterResponse, error) {
- apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/characters", malID)
+func GetProducerByID(producerID int) (*types.JikanSingleProducerResponse, error) {
+ url := fmt.Sprintf("%s/producers/%d/full", jikanAPIBaseURL, producerID)
+ ctx, cancel := context.WithTimeout(context.Background(), contextTimeout)
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
-
- bodyBytes, err := c.makeRequest(ctx, apiURL)
+ bytes, err := clientInstance.makeRequest(ctx, url)
if err != nil {
- return nil, fmt.Errorf("failed to get anime characters: %w", err)
+ logger.Errorf("JikanClient", "GetProducerByID failed for ID %d: %v", producerID, err)
+ return nil, errors.New("failed to fetch producer data from Jikan API")
}
- var characterResponse JikanAnimeCharacterResponse
- if err := json.Unmarshal(bodyBytes, &characterResponse); err != nil {
- return nil, fmt.Errorf("failed to decode characters response: %w", err)
+ var response types.JikanSingleProducerResponse
+ if err := json.Unmarshal(bytes, &response); err != nil {
+ logger.Errorf("JikanClient", "Failed to unmarshal producer response for ID %d: %v", producerID, err)
+ return nil, errors.New("failed to parse producer data from Jikan API")
}
-
- return &characterResponse, nil
+ return &response, nil
}
-// GetAnimeGenres fetches all anime genres from MAL
-func (c *JikanClient) GetAnimeGenres() (*JikanGenresResponse, error) {
- apiURL := "https://api.jikan.moe/v4/genres/anime"
+// var (
+// // Global Jikan rate limiters
+// jikanPerSecLimiter = ratelimit.NewRateLimiter(3, time.Second)
+// jikanPerMinLimiter = ratelimit.NewRateLimiter(60, time.Minute)
+// jikanLimiter = ratelimit.NewMultiLimiter(jikanPerSecLimiter, jikanPerMinLimiter)
+// )
+
+// // JikanClient provides methods to interact with the Jikan API
+// type JikanClient struct {
+// client *http.Client
+// maxRetries int
+// baseBackoff time.Duration
+// }
+
+// // NewJikanClient creates a new Jikan API client
+// func NewJikanClient() *JikanClient {
+// return &JikanClient{
+// client: &http.Client{
+// Timeout: 15 * time.Second,
+// },
+// maxRetries: 3,
+// baseBackoff: 1 * time.Second,
+// }
+// }
+
+// // WaitForRateLimit waits until a request can be made according to rate limiting rules
+// func (c *JikanClient) WaitForRateLimit() {
+// jikanLimiter.Wait()
+// }
+
+// // makeRequest makes an HTTP request with retries and proper error handling
+// func (c *JikanClient) makeRequest(ctx context.Context, url string) ([]byte, error) {
+// var bodyBytes []byte
+// var statusCode int
+
+// retries := 0
+// for retries <= c.maxRetries {
+// // Wait for rate limiter before attempting request
+// c.WaitForRateLimit()
+
+// // Create the request with timeout context
+// req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+// if err != nil {
+// return nil, fmt.Errorf("failed to create request: %w", err)
+// }
+
+// // Execute the request
+// resp, err := c.client.Do(req)
+// if err != nil {
+// if retries < c.maxRetries {
+// retries++
+// backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(2, float64(retries-1)))
+// time.Sleep(backoffTime)
+// continue
+// }
+// return nil, fmt.Errorf("failed to execute request after %d retries: %w", c.maxRetries, err)
+// }
+// defer resp.Body.Close()
+
+// statusCode = resp.StatusCode
+
+// // Handle rate limiting with exponential backoff
+// if statusCode == http.StatusTooManyRequests {
+// if retries < c.maxRetries {
+// retries++
+// backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(1.5, float64(retries-1)))
+
+// // Respect Retry-After header if available
+// if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
+// if seconds, err := strconv.Atoi(retryAfter); err == nil {
+// backoffTime = time.Duration(seconds) * time.Second
+// }
+// }
+
+// time.Sleep(backoffTime)
+// continue
+// }
+// return nil, fmt.Errorf("rate limited after %d retries", c.maxRetries)
+// } else if statusCode != http.StatusOK {
+// if retries < c.maxRetries {
+// retries++
+// backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(2, float64(retries-1)))
+// time.Sleep(backoffTime)
+// continue
+// }
+// return nil, fmt.Errorf("request failed with status: %d", statusCode)
+// }
+
+// // 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 < c.maxRetries {
+// retries++
+// backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(2, float64(retries-1)))
+// time.Sleep(backoffTime)
+// continue
+// }
+// return nil, fmt.Errorf("failed to read response body: %w", err)
+// }
+
+// // Success, break the retry loop
+// return bodyBytes, nil
+// }
+
+// return nil, fmt.Errorf("exhausted all retries with status code: %d", statusCode)
+// }
+
+// // GetAnime fetches basic anime information by MAL ID
+// func (c *JikanClient) GetAnime(malID int) (*JikanAnimeResponse, error) {
+// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d", malID)
+
+// 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 data: %w", err)
+// }
+
+// var animeResponse JikanAnimeResponse
+// if err := json.Unmarshal(bodyBytes, &animeResponse); err != nil {
+// return nil, fmt.Errorf("failed to decode response: %w", err)
+// }
+
+// if animeResponse.Data.MALID == 0 {
+// return nil, fmt.Errorf("no data found for MAL ID %d", malID)
+// }
+
+// return &animeResponse, nil
+// }
+
+// // GetFullAnime fetches detailed anime information by MAL ID
+// func (c *JikanClient) GetFullAnime(malID int) (*JikanAnimeResponse, error) {
+// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/full", malID)
+
+// ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+// defer cancel()
+
+// bodyBytes, err := c.makeRequest(ctx, apiURL)
+// if err != nil {
+// // Fallback to curl if HTTP client fails
+// var curlErr error
+// bodyBytes, curlErr = c.makeRequestWithCurl(apiURL)
+// if curlErr != nil {
+// return nil, fmt.Errorf("failed to get anime full data via HTTP (%w) and curl (%v)", err, curlErr)
+// }
+// }
+
+// var animeResponse JikanAnimeResponse
+// if err := json.Unmarshal(bodyBytes, &animeResponse); err != nil {
+// return nil, fmt.Errorf("failed to decode response: %w", err)
+// }
+
+// if animeResponse.Data.MALID == 0 {
+// return nil, fmt.Errorf("no data found for MAL ID %d", malID)
+// }
+
+// return &animeResponse, nil
+// }
+
+// // makeRequestWithCurl uses curl as a fallback when Go HTTP client fails
+// func (c *JikanClient) makeRequestWithCurl(url string) ([]byte, error) {
+// c.WaitForRateLimit()
+
+// cmd := exec.Command("curl", "-s", "-H", "Accept: application/json", url)
+// output, err := cmd.Output()
+// if err != nil {
+// return nil, fmt.Errorf("curl command failed: %w", err)
+// }
+
+// return output, nil
+// }
+
+// // GetAnimeEpisodes fetches all episodes for an anime by MAL ID
+// func (c *JikanClient) GetAnimeEpisodes(malID int) (*JikanAnimeEpisodeResponse, error) {
+// result := JikanAnimeEpisodeResponse{
+// Data: []JikanAnimeEpisode{},
+// }
+
+// maxPages := 25 // Safety limit to avoid excessive requests
+// page := 1
+// maxAttempts := 15 // Maximum number of attempts across all pages
+// totalAttempts := 0
+
+// for page <= maxPages && totalAttempts < maxAttempts {
+// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/episodes?page=%d", malID, page)
+
+// ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+
+// totalAttempts++
+
+// bodyBytes, err := c.makeRequest(ctx, apiURL)
+// cancel()
+
+// if err != nil {
+// // If we have some episodes already, return them rather than failing
+// if len(result.Data) > 0 {
+// result.Pagination.HasNextPage = false
+// break
+// }
+// return nil, fmt.Errorf("failed to get anime episodes page %d: %w", page, err)
+// }
+
+// var pageResponse JikanAnimeEpisodeResponse
+// if err := json.Unmarshal(bodyBytes, &pageResponse); err != nil {
+// // Return what we have if we got some pages successfully
+// if len(result.Data) > 0 {
+// result.Pagination.HasNextPage = false
+// break
+// }
+// return nil, fmt.Errorf("failed to decode episodes response: %w", err)
+// }
+
+// // Append episodes from this page
+// result.Data = append(result.Data, pageResponse.Data...)
+// result.Pagination = pageResponse.Pagination
+
+// // Check if we need to fetch more pages
+// if !pageResponse.Pagination.HasNextPage {
+// break
+// }
+
+// page++
+// }
+
+// return &result, nil
+// }
+
+// // GetAnimeCharacters fetches all characters for an anime by MAL ID
+// func (c *JikanClient) GetAnimeCharacters(malID int) (*JikanAnimeCharacterResponse, error) {
+// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/characters", malID)
+
+// 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 characters: %w", err)
+// }
+
+// var characterResponse JikanAnimeCharacterResponse
+// if err := json.Unmarshal(bodyBytes, &characterResponse); err != nil {
+// return nil, fmt.Errorf("failed to decode characters response: %w", err)
+// }
+
+// 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
+// }
+
+// // 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
+// }
+
+// // GetAnimeProducers fetches all producers from Jikan API (paginated)
+// func (c *JikanClient) GetAnimeProducers(page int) (*JikanProducersFullResponse, error) {
+// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/producers?page=%d", page)
+
+// 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 producers: %w", err)
+// }
+
+// var response JikanProducersFullResponse
+// if err := json.Unmarshal(bodyBytes, &response); err != nil {
+// return nil, fmt.Errorf("failed to decode producers response: %w", err)
+// }
+
+// return &response, nil
+// }
+
+// // GetProducerExternal fetches external URLs for a specific producer
+// func (c *JikanClient) GetProducerExternal(producerID int) (*JikanProducerExternalResponse, error) {
+// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/producers/%d/external", producerID)
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
+// 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)
- }
+// bodyBytes, err := c.makeRequest(ctx, apiURL)
+// if err != nil {
+// return nil, fmt.Errorf("failed to get producer external: %w", err)
+// }
- var genresResponse JikanGenresResponse
- if err := json.Unmarshal(bodyBytes, &genresResponse); err != nil {
- return nil, fmt.Errorf("failed to decode genres response: %w", err)
- }
+// var response JikanProducerExternalResponse
+// if err := json.Unmarshal(bodyBytes, &response); err != nil {
+// return nil, fmt.Errorf("failed to decode producer external response: %w", err)
+// }
- return &genresResponse, nil
-}
+// return &response, 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)
+// // GetAnimeByProducer fetches paginated anime list by producer ID
+// func (c *JikanClient) GetAnimeByProducer(producerID int, page int, limit int) (*JikanAnimeListResponse, error) {
+// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime?producers=%d&page=%d&limit=%d", producerID, page, limit)
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
+// 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)
- }
+// bodyBytes, err := c.makeRequest(ctx, apiURL)
+// if err != nil {
+// return nil, fmt.Errorf("failed to get anime by producer: %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)
- }
+// 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
-}
+// return &listResponse, nil
+// }
+
+// // GetAnimeByStudio fetches paginated anime list by studio ID (uses producers endpoint)
+// func (c *JikanClient) GetAnimeByStudio(studioID int, page int, limit int) (*JikanAnimeListResponse, error) {
+// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime?producers=%d&page=%d&limit=%d", studioID, 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 studio: %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
+// }
+
+// // GetAnimeByLicensor fetches paginated anime list by licensor ID (uses producers endpoint)
+// func (c *JikanClient) GetAnimeByLicensor(licensorID int, page int, limit int) (*JikanAnimeListResponse, error) {
+// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime?producers=%d&page=%d&limit=%d", licensorID, 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 licensor: %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 6e7d7f7..3326f89 100644
--- a/utils/api/jikan/types.go
+++ b/utils/api/jikan/types.go
@@ -1,241 +1,12 @@
package jikan
-// JikanPagination represents the pagination data in Jikan API responses
-type JikanPagination struct {
- LastVisiblePage int `json:"last_visible_page"`
- HasNextPage bool `json:"has_next_page"`
-}
-
-// JikanGenericMALStructure represents a common structure for various MAL entities
-type JikanGenericMALStructure struct {
- MALID int `json:"mal_id"`
- Type string `json:"type"`
- URL string `json:"url"`
- 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"`
-}
-
-// 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 {
- MALID int `json:"mal_id"`
- URL string `json:"url"`
- Images struct {
- JPG struct {
- ImageURL string `json:"image_url"`
- SmallImageURL string `json:"small_image_url"`
- LargeImageURL string `json:"large_image_url"`
- } `json:"jpg"`
- WebP struct {
- ImageURL string `json:"image_url"`
- SmallImageURL string `json:"small_image_url"`
- LargeImageURL string `json:"large_image_url"`
- } `json:"webp"`
- } `json:"images"`
- Trailer struct {
- YoutubeID string `json:"youtube_id"`
- URL string `json:"url"`
- EmbedURL string `json:"embed_url"`
- Images struct {
- ImageURL string `json:"image_url"`
- SmallImageURL string `json:"small_image_url"`
- MediumImageURL string `json:"medium_image_url"`
- LargeImageURL string `json:"large_image_url"`
- MaximumImageURL string `json:"maximum_image_url"`
- } `json:"images"`
- } `json:"trailer"`
- Approved bool `json:"approved"`
- Titles []struct {
- Type string `json:"type"`
- Title string `json:"title"`
- } `json:"titles"`
- 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"`
- Aired struct {
- From string `json:"from"`
- To string `json:"to"`
- Prop struct {
- From struct {
- Day int `json:"day"`
- Month int `json:"month"`
- Year int `json:"year"`
- } `json:"from"`
- To struct {
- Day int `json:"day"`
- Month int `json:"month"`
- Year int `json:"year"`
- } `json:"to"`
- } `json:"prop"`
- String string `json:"string"`
- } `json:"aired"`
- Duration string `json:"duration"`
- Rating string `json:"rating"`
- 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"`
- Synopsis string `json:"synopsis"`
- Background string `json:"background"`
- Season string `json:"season"`
- Year int `json:"year"`
- Broadcast struct {
- Day string `json:"day"`
- Time string `json:"time"`
- Timezone string `json:"timezone"`
- String string `json:"string"`
- } `json:"broadcast"`
- Producers []JikanGenericMALStructure `json:"producers"`
- Licensors []JikanGenericMALStructure `json:"licensors"`
- Studios []JikanGenericMALStructure `json:"studios"`
- Genres []JikanGenericMALStructure `json:"genres"`
- ExplicitGenres []JikanGenericMALStructure `json:"explicit_genres"`
- Themes []JikanGenericMALStructure `json:"themes"`
- Demographics []JikanGenericMALStructure `json:"demographics"`
- Relations []struct {
- Relation string `json:"relation"`
- Entry []JikanGenericMALStructure `json:"entry"`
- } `json:"relations"`
- Theme struct {
- Openings []string `json:"openings"`
- Endings []string `json:"endings"`
- } `json:"theme"`
- External []struct {
- Name string `json:"name"`
- URL string `json:"url"`
- } `json:"external"`
- Streaming []struct {
- Name string `json:"name"`
- URL string `json:"url"`
- } `json:"streaming"`
- } `json:"data"`
-}
-
-// JikanAnimeEpisode represents an episode from Jikan API
-type JikanAnimeEpisode struct {
- MALID int `json:"mal_id"`
- URL string `json:"url"`
- Title string `json:"title"`
- TitleJapanese string `json:"title_japanese"`
- TitleRomaji string `json:"title_romaji"`
- Aired string `json:"aired"`
- Score float64 `json:"score"`
- Filler bool `json:"filler"`
- Recap bool `json:"recap"`
- ForumURL string `json:"forum_url"`
-}
-
-// JikanAnimeEpisodeResponse represents the episodes response from Jikan API
-type JikanAnimeEpisodeResponse struct {
- Pagination JikanPagination `json:"pagination"`
- Data []JikanAnimeEpisode `json:"data"`
-}
-
-// JikanAnimeCharacterResponse represents the characters response from Jikan API
-type JikanAnimeCharacterResponse struct {
- Data []struct {
- Character struct {
- MALID int `json:"mal_id"`
- URL string `json:"url"`
- Images struct {
- JPG struct {
- ImageURL string `json:"image_url"`
- SmallImageURL string `json:"small_image_url"`
- } `json:"jpg"`
- WebP struct {
- ImageURL string `json:"image_url"`
- SmallImageURL string `json:"small_image_url"`
- } `json:"webp"`
- } `json:"images"`
- Name string `json:"name"`
- } `json:"character"`
- Role string `json:"role"`
- VoiceActors []struct {
- Person struct {
- MALID int `json:"mal_id"`
- URL string `json:"url"`
- Images struct {
- JPG struct {
- ImageURL string `json:"image_url"`
- } `json:"jpg"`
- WebP struct {
- ImageURL string `json:"image_url"`
- } `json:"webp"`
- } `json:"images"`
- Name string `json:"name"`
- } `json:"person"`
- Language string `json:"language"`
- } `json:"voice_actors"`
- } `json:"data"`
+import (
+ "net/http"
+ "time"
+)
+
+type client struct {
+ httpClient *http.Client
+ maxRetries int
+ backoff time.Duration
}