diff options
| author | Bobby <[email protected]> | 2026-02-05 15:56:01 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-02-05 15:56:01 +0530 |
| commit | 111ddd8b5fca2612256a7bd31781c149f10f83d8 (patch) | |
| tree | 23256435338a22f31f3dc52331ae2f04705ec1f8 | |
| parent | b0f01eea9d61aa4d05b0fe253c8a32e35fa95e28 (diff) | |
| download | metachan-111ddd8b5fca2612256a7bd31781c149f10f83d8.tar.xz metachan-111ddd8b5fca2612256a7bd31781c149f10f83d8.zip | |
Refactor Jikan API types: remove unused structures and add HTTP client configuration
| -rw-r--r-- | controllers/anime.go | 493 | ||||
| -rw-r--r-- | controllers/errors.go | 43 | ||||
| -rw-r--r-- | controllers/health.go | 2 | ||||
| -rw-r--r-- | repositories/anime.go | 1107 | ||||
| -rw-r--r-- | repositories/mapping.go | 43 | ||||
| -rw-r--r-- | repositories/tasks.go | 85 | ||||
| -rw-r--r-- | repositories/types.go | 5 | ||||
| -rw-r--r-- | router/router.go | 56 | ||||
| -rw-r--r-- | services/anime/helpers.go | 30 | ||||
| -rw-r--r-- | services/anime/service.go | 303 | ||||
| -rw-r--r-- | tasks/anisync.task.go | 11 | ||||
| -rw-r--r-- | tasks/manager.go | 95 | ||||
| -rw-r--r-- | tasks/producersync.task.go | 264 | ||||
| -rw-r--r-- | tasks/tasks.go | 80 | ||||
| -rw-r--r-- | types/anime.go | 47 | ||||
| -rw-r--r-- | types/anisync.go | 17 | ||||
| -rw-r--r-- | types/http.go | 17 | ||||
| -rw-r--r-- | types/jikan.go | 218 | ||||
| -rw-r--r-- | types/mapping.go | 20 | ||||
| -rw-r--r-- | types/server.go | 17 | ||||
| -rw-r--r-- | types/task_manager.go | 10 | ||||
| -rw-r--r-- | utils/api/jikan/jikan.go | 784 | ||||
| -rw-r--r-- | utils/api/jikan/types.go | 247 |
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 } |
