From 2da45b9fbf74d365951e37a4152f30e76caaeb98 Mon Sep 17 00:00:00 2001 From: Bobby <30593201+luciferreeves@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:45:55 +0530 Subject: Refactor task management and producer synchronization - Updated TaskManager to utilize repositories for database operations, improving separation of concerns. - Enhanced logging functionality by replacing logger.Log with logger.Infof, logger.Warnf, and logger.Errorf for better readability and consistency. - Simplified ProducerSync function by removing unnecessary pagination logic and directly fetching producer data. - Introduced helper functions for calculating progress and managing task statuses. - Added new service for fetching and saving anime data, integrating multiple data sources (Jikan, Anilist, MALsync, TMDB, TVDB, Aniskip). - Created new types for task management and improved overall code organization. - Removed deprecated database calls and replaced them with repository methods for better maintainability. --- config/config.go | 2 + controllers/health.go | 4 - entities/anime.go | 75 +- entities/episode.go | 31 +- entities/genre.go | 2 +- entities/seasons.go | 35 +- metachan/main.go | 2 - repositories/anime.go | 1079 +-------------------- repositories/mapping.go | 12 + repositories/tasks.go | 23 +- services/anime.go | 445 +++++++++ services/anime/helpers.go | 1108 +++++++++++----------- services/anime/service.go | 2152 +++++++++++++++++++++--------------------- tasks/anifetch.task.go | 121 +-- tasks/anisync.task.go | 112 +-- tasks/aniupdate.task.go | 223 ++--- tasks/genresync.task.go | 64 +- tasks/helpers.go | 12 + tasks/manager.go | 155 +-- tasks/producersync.task.go | 282 +----- tasks/tasks.go | 38 +- types/tasks.go | 18 + utils/api/malsync/malsync.go | 2 - utils/logger/logger.go | 4 +- 24 files changed, 2489 insertions(+), 3512 deletions(-) create mode 100644 services/anime.go create mode 100644 tasks/helpers.go create mode 100644 types/tasks.go diff --git a/config/config.go b/config/config.go index 48f76e9..6cf0090 100644 --- a/config/config.go +++ b/config/config.go @@ -15,6 +15,8 @@ var ( ) func init() { + logger.Init() + if err := godotenv.Load(); err != nil { logger.Infof("Config", "No .env file found. Environment variables will be used directly.") } diff --git a/controllers/health.go b/controllers/health.go index 80eeb1a..2aaf8c3 100644 --- a/controllers/health.go +++ b/controllers/health.go @@ -10,20 +10,16 @@ import ( ) func HealthStatus(c *fiber.Ctx) error { - // Check if the database is connected databaseStatus := database.GetConnectionStatus() - // Get the memory stats memoryStats := stats.GetMemoryStats() - // Get the task statuses taskStatuses := tasks.GlobalTaskManager.GetAllTaskStatuses() statusString := map[bool]string{ true: "healthy", false: "unhealthy", }[databaseStatus] - // Create the health status response healthStatus := types.HealthStatus{ Status: statusString, Timestamp: stats.GetCurrentTimestamp(), diff --git a/entities/anime.go b/entities/anime.go index 0ae8df7..9ad2523 100644 --- a/entities/anime.go +++ b/entities/anime.go @@ -8,38 +8,45 @@ import ( type Anime struct { gorm.Model - MALID int `gorm:"uniqueIndex" json:"id"` - TitleID uint `json:"title_id,omitempty"` - MappingID uint `json:"mapping_id,omitempty"` - Synopsis string `gorm:"type:text" json:"synopsis,omitempty"` - Type string `json:"type,omitempty"` - Source string `json:"source,omitempty"` - Airing bool `json:"airing,omitempty"` - Status string `json:"status,omitempty"` - Duration string `json:"duration,omitempty"` - Color string `json:"color,omitempty"` - Season string `json:"season,omitempty"` - Year int `json:"year,omitempty"` - SubbedCount int `json:"subbed_count,omitempty"` - DubbedCount int `json:"dubbed_count,omitempty"` - TotalEpisodes int `json:"total_episodes,omitempty"` - AiredEpisodes int `json:"aired_episodes,omitempty"` - LastUpdated time.Time `json:"last_updated,omitempty"` - Title *Title `gorm:"foreignKey:TitleID" json:"titles,omitempty"` - Mapping *Mapping `gorm:"foreignKey:MappingID" json:"mappings,omitempty"` - Images *Images `gorm:"foreignKey:AnimeID" json:"images,omitempty"` - Covers *Images `gorm:"foreignKey:AnimeID" json:"covers,omitempty"` - Logos *Logos `gorm:"foreignKey:AnimeID" json:"logos,omitempty"` - Scores *Scores `gorm:"foreignKey:AnimeID" json:"scores,omitempty"` - AiringStatus *AiringStatus `gorm:"foreignKey:AnimeID" json:"airing_status,omitempty"` - Broadcast *Broadcast `gorm:"foreignKey:AnimeID" json:"broadcast,omitempty"` - NextAiring *NextEpisode `gorm:"foreignKey:AnimeID" json:"next_airing_episode,omitempty"` - Genres []Genre `gorm:"many2many:anime_genres;" json:"genres,omitempty"` - Producers []Producer `gorm:"many2many:anime_producers;" json:"producers,omitempty"` - Studios []Producer `gorm:"many2many:anime_studios;" json:"studios,omitempty"` - Licensors []Producer `gorm:"many2many:anime_licensors;" json:"licensors,omitempty"` - Episodes []Episode `gorm:"foreignKey:AnimeID" json:"episodes,omitempty"` - Characters []Character `gorm:"foreignKey:AnimeID" json:"characters,omitempty"` - Schedule []EpisodeSchedule `gorm:"foreignKey:AnimeID;constraint:OnDelete:CASCADE" json:"airing_schedule,omitempty"` - Seasons []Season `gorm:"foreignKey:ParentAnimeID" json:"seasons,omitempty"` + MALID int `gorm:"uniqueIndex" json:"id"` + TitleID uint `json:"title_id,omitempty"` + MappingID uint `json:"mapping_id,omitempty"` + ImagesID *uint `json:"images_id,omitempty"` + CoversID *uint `json:"covers_id,omitempty"` + LogosID *uint `json:"logos_id,omitempty"` + ScoresID *uint `json:"scores_id,omitempty"` + AiringStatusID *uint `json:"airing_status_id,omitempty"` + BroadcastID *uint `json:"broadcast_id,omitempty"` + NextAiringID *uint `json:"next_airing_id,omitempty"` + Synopsis string `gorm:"type:text" json:"synopsis,omitempty"` + Type string `json:"type,omitempty"` + Source string `json:"source,omitempty"` + Airing bool `json:"airing,omitempty"` + Status string `json:"status,omitempty"` + Duration string `json:"duration,omitempty"` + Color string `json:"color,omitempty"` + Season string `json:"season,omitempty"` + Year int `json:"year,omitempty"` + SubbedCount int `json:"subbed_count,omitempty"` + DubbedCount int `json:"dubbed_count,omitempty"` + TotalEpisodes int `json:"total_episodes,omitempty"` + AiredEpisodes int `json:"aired_episodes,omitempty"` + LastUpdated time.Time `json:"last_updated,omitempty"` + Title *Title `gorm:"foreignKey:TitleID" json:"titles,omitempty"` + Mapping *Mapping `gorm:"foreignKey:MappingID" json:"mappings,omitempty"` + Images *Images `gorm:"foreignKey:ImagesID" json:"images,omitempty"` + Covers *Images `gorm:"foreignKey:CoversID" json:"covers,omitempty"` + Logos *Logos `gorm:"foreignKey:LogosID" json:"logos,omitempty"` + Scores *Scores `gorm:"foreignKey:ScoresID" json:"scores,omitempty"` + AiringStatus *AiringStatus `gorm:"foreignKey:AiringStatusID" json:"airing_status,omitempty"` + Broadcast *Broadcast `gorm:"foreignKey:BroadcastID" json:"broadcast,omitempty"` + NextAiring *NextEpisode `gorm:"foreignKey:NextAiringID" json:"next_airing_episode,omitempty"` + Genres []Genre `gorm:"many2many:anime_genres;" json:"genres,omitempty"` + Producers []Producer `gorm:"many2many:anime_producers;" json:"producers,omitempty"` + Studios []Producer `gorm:"many2many:anime_studios;" json:"studios,omitempty"` + Licensors []Producer `gorm:"many2many:anime_licensors;" json:"licensors,omitempty"` + Episodes []Episode `gorm:"foreignKey:AnimeID" json:"episodes,omitempty"` + Characters []Character `gorm:"foreignKey:AnimeID" json:"characters,omitempty"` + Schedule []EpisodeSchedule `gorm:"foreignKey:AnimeID;constraint:OnDelete:CASCADE" json:"airing_schedule,omitempty"` + Seasons []Season `gorm:"foreignKey:ParentAnimeID" json:"seasons,omitempty"` } diff --git a/entities/episode.go b/entities/episode.go index 8425f51..9eb13d2 100644 --- a/entities/episode.go +++ b/entities/episode.go @@ -8,21 +8,22 @@ import ( type Episode struct { gorm.Model - EpisodeID string `gorm:"uniqueIndex;size:32" json:"id"` - AnimeID uint `json:"anime_id,omitempty"` - TitleID uint `json:"title_id,omitempty"` - Description string `gorm:"type:text" json:"description,omitempty"` - Aired string `json:"aired,omitempty"` - Score float64 `json:"score,omitempty"` - Filler bool `json:"filler,omitempty"` - Recap bool `json:"recap,omitempty"` - ForumURL string `json:"forum_url,omitempty"` - URL string `json:"url,omitempty"` - ThumbnailURL string `json:"thumbnail_url,omitempty"` - EpisodeNumber int `json:"episode_number,omitempty"` - EpisodeLength float64 `json:"episode_length,omitempty"` - Title *Title `gorm:"foreignKey:TitleID" json:"titles,omitempty"` - StreamInfo *StreamInfo `gorm:"foreignKey:EpisodeID;references:EpisodeID" json:"streaming,omitempty"` + EpisodeID string `gorm:"uniqueIndex;size:32" json:"id"` + AnimeID uint `json:"anime_id,omitempty"` + TitleID uint `json:"title_id,omitempty"` + Description string `gorm:"type:text" json:"description,omitempty"` + Aired string `json:"aired,omitempty"` + Score float64 `json:"score,omitempty"` + Filler bool `json:"filler,omitempty"` + Recap bool `json:"recap,omitempty"` + ForumURL string `json:"forum_url,omitempty"` + URL string `json:"url,omitempty"` + ThumbnailURL string `json:"thumbnail_url,omitempty"` + EpisodeNumber int `json:"episode_number,omitempty"` + EpisodeLength float64 `json:"episode_length,omitempty"` + Title *Title `gorm:"foreignKey:TitleID" json:"titles,omitempty"` + StreamInfo *StreamInfo `gorm:"foreignKey:EpisodeID;references:EpisodeID" json:"streaming,omitempty"` + SkipTimes []EpisodeSkipTime `gorm:"foreignKey:EpisodeID;references:EpisodeID" json:"skip_times,omitempty"` } type EpisodeSkipTime struct { diff --git a/entities/genre.go b/entities/genre.go index f8763b3..ed906ca 100644 --- a/entities/genre.go +++ b/entities/genre.go @@ -5,7 +5,7 @@ import "gorm.io/gorm" type Genre struct { gorm.Model Name string `json:"name,omitempty"` - GenreID int `json:"genre_id,omitempty"` + GenreID int `gorm:"uniqueIndex" json:"genre_id,omitempty"` URL string `json:"url,omitempty"` Count int `gorm:"default:0" json:"count,omitempty"` Anime []Anime `gorm:"many2many:anime_genres;" json:"anime,omitempty"` diff --git a/entities/seasons.go b/entities/seasons.go index be22d82..1e5f776 100644 --- a/entities/seasons.go +++ b/entities/seasons.go @@ -4,20 +4,23 @@ import "gorm.io/gorm" type Season struct { gorm.Model - ParentAnimeID uint `json:"parent_anime_id,omitempty"` - MALID int `json:"mal_id,omitempty"` - TitleID uint `json:"title_id,omitempty"` - Synopsis string `gorm:"type:text" json:"synopsis,omitempty"` - Type string `json:"type,omitempty"` - Source string `json:"source,omitempty"` - Airing bool `json:"airing,omitempty"` - Status string `json:"status,omitempty"` - Duration string `json:"duration,omitempty"` - Season string `json:"season,omitempty"` - Year int `json:"year,omitempty"` - Current bool `json:"current,omitempty"` - Title *Title `gorm:"foreignKey:TitleID" json:"titles,omitempty"` - Images *Images `gorm:"foreignKey:AnimeID" json:"images,omitempty"` - Scores *Scores `gorm:"foreignKey:AnimeID" json:"scores,omitempty"` - AiringStatus *AiringStatus `gorm:"foreignKey:AnimeID" json:"airing_status,omitempty"` + ParentAnimeID uint `json:"parent_anime_id,omitempty"` + MALID int `json:"mal_id,omitempty"` + TitleID uint `json:"title_id,omitempty"` + ImagesID *uint `json:"images_id,omitempty"` + ScoresID *uint `json:"scores_id,omitempty"` + AiringStatusID *uint `json:"airing_status_id,omitempty"` + Synopsis string `gorm:"type:text" json:"synopsis,omitempty"` + Type string `json:"type,omitempty"` + Source string `json:"source,omitempty"` + Airing bool `json:"airing,omitempty"` + Status string `json:"status,omitempty"` + Duration string `json:"duration,omitempty"` + Season string `json:"season,omitempty"` + Year int `json:"year,omitempty"` + Current bool `json:"current,omitempty"` + Title *Title `gorm:"foreignKey:TitleID" json:"titles,omitempty"` + Images *Images `gorm:"foreignKey:ImagesID" json:"images,omitempty"` + Scores *Scores `gorm:"foreignKey:ScoresID" json:"scores,omitempty"` + AiringStatus *AiringStatus `gorm:"foreignKey:AiringStatusID" json:"airing_status,omitempty"` } diff --git a/metachan/main.go b/metachan/main.go index fb09783..43de34a 100644 --- a/metachan/main.go +++ b/metachan/main.go @@ -14,8 +14,6 @@ import ( ) func main() { - logger.Init() - tasks.GlobalTaskManager.StartAllTasks() app := fiber.New(fiber.Config{ diff --git a/repositories/anime.go b/repositories/anime.go index bdd4f30..04d3ab7 100644 --- a/repositories/anime.go +++ b/repositories/anime.go @@ -2,10 +2,14 @@ package repositories import ( "errors" + "fmt" "metachan/database" "metachan/entities" "metachan/enums" "metachan/utils/logger" + + "gorm.io/gorm" + "gorm.io/gorm/clause" ) func GetAnime[T idType](maptype enums.MappingType, id T) (entities.Anime, error) { @@ -43,6 +47,7 @@ func GetAnime[T idType](maptype enums.MappingType, id T) (entities.Anime, error) Preload("Licensors.ExternalURLs"). Preload("Episodes"). Preload("Episodes.Title"). + Preload("Episodes.SkipTimes"). Preload("Characters"). Preload("Characters.VoiceActors"). Preload("Schedule"). @@ -64,1044 +69,60 @@ func GetAnime[T idType](maptype enums.MappingType, id T) (entities.Anime, error) 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 -// } +func CreateOrUpdateAnime(anime *entities.Anime) error { + if anime == nil { + return fmt.Errorf("anime is nil") + } -// // 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 existingAnime entities.Anime + result := database.DB.Where("mal_id = ?", anime.MALID).First(&existingAnime) + if result.Error == nil { + anime.ID = existingAnime.ID + } -// var images *types.ProducerImages -// if producer.Images != nil && producer.Images.ImageURL != "" { -// images = &types.ProducerImages{ -// JPG: types.ProducerJPGImage{ -// ImageURL: producer.Images.ImageURL, -// }, -// } -// } + result = database.DB.Session(&gorm.Session{FullSaveAssociations: true}).Clauses(clause.OnConflict{ + UpdateAll: true, + }).Save(anime) -// // 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, -// } -// } + if result.Error != nil { + return fmt.Errorf("failed to save anime: %w", result.Error) + } -// 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 -// } + logger.Infof("Anime", "Saved anime (MAL ID: %d) with %d episodes, %d characters", anime.MALID, len(anime.Episodes), len(anime.Characters)) + return 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 -// } +func SaveEpisodeSkipTimes(episodeID string, skipTimes []entities.EpisodeSkipTime) error { + if len(skipTimes) == 0 { + return nil + } -// defer func() { -// if r := recover(); r != nil { -// tx.Rollback() -// } -// }() + database.DB.Where("episode_id = ?", episodeID).Delete(&entities.EpisodeSkipTime{}) -// // 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 -// } -// } + for i := range skipTimes { + skipTimes[i].EpisodeID = episodeID + if err := database.DB.Create(&skipTimes[i]).Error; err != nil { + return fmt.Errorf("failed to save skip time: %w", err) + } + } -// // Create new streaming record -// streaming := &entities.EpisodeStreaming{ -// EpisodeID: episodeID, -// AnimeID: animeID, -// LastFetch: time.Now(), -// } + return nil +} -// // Save the main record first -// if err := tx.Create(streaming).Error; err != nil { -// tx.Rollback() -// return err -// } +func GetAiringAnime() ([]entities.Anime, error) { + var anime []entities.Anime -// // 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) -// } + result := database.DB. + Where("airing = ?", true). + Preload("NextAiring"). + Preload("Schedule"). + Preload("Title"). + Find(&anime) -// // 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) -// } + if result.Error != nil { + logger.Errorf("Anime", "Failed to fetch airing anime: %v", result.Error) + return nil, errors.New("failed to fetch airing anime") + } -// return tx.Commit().Error -// } + return anime, nil +} diff --git a/repositories/mapping.go b/repositories/mapping.go index e797bc3..bbcda8d 100644 --- a/repositories/mapping.go +++ b/repositories/mapping.go @@ -41,3 +41,15 @@ func CreateOrUpdateMapping(mapping *entities.Mapping) error { return nil } + +func GetAllMappings() ([]entities.Mapping, error) { + var mappings []entities.Mapping + + result := database.DB.Find(&mappings) + if result.Error != nil { + logger.Errorf("Mapping", "Failed to fetch all mappings: %v", result.Error) + return nil, errors.New("failed to fetch mappings") + } + + return mappings, nil +} diff --git a/repositories/tasks.go b/repositories/tasks.go index 2dfecb2..6a52e7a 100644 --- a/repositories/tasks.go +++ b/repositories/tasks.go @@ -13,8 +13,6 @@ func GetTaskStatus(taskName string) (entities.TaskStatus, error) { 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") } @@ -33,6 +31,27 @@ func SetTaskStatus(task *entities.TaskStatus) error { return nil } +func GetLatestTaskLog(taskName string) (*entities.TaskLog, error) { + var taskLog entities.TaskLog + + result := database.DB.Where("task_name = ?", taskName).Order("executed_at desc").First(&taskLog) + if result.Error != nil { + return nil, result.Error + } + + return &taskLog, nil +} + +func CreateTaskLog(taskLog *entities.TaskLog) error { + result := database.DB.Create(taskLog) + if result.Error != nil { + logger.Errorf("Task", "Failed to create task log: %v", result.Error) + return errors.New("failed to create task log") + } + + return nil +} + // -- Moved to database/tasks.go -- // import ( // "metachan/entities" diff --git a/services/anime.go b/services/anime.go new file mode 100644 index 0000000..eb4f912 --- /dev/null +++ b/services/anime.go @@ -0,0 +1,445 @@ +package services + +import ( + "fmt" + "metachan/entities" + "metachan/enums" + "metachan/repositories" + "metachan/types" + "metachan/utils/api/anilist" + "metachan/utils/api/aniskip" + "metachan/utils/api/jikan" + "metachan/utils/api/malsync" + "metachan/utils/api/streaming" + "metachan/utils/api/tmdb" + "metachan/utils/api/tvdb" + "metachan/utils/logger" +) + +func GetAnime(mapping *entities.Mapping) (*entities.Anime, error) { + if mapping == nil { + logger.Errorf("AnimeService", "Mapping is nil") + return nil, fmt.Errorf("mapping is nil") + } + + malID := mapping.MAL + logger.Infof("AnimeService", "Fetching anime data for MAL ID: %d", malID) + + var anime *entities.Anime + existingAnime, err := repositories.GetAnime(enums.MAL, malID) + if err == nil { + logger.Infof("AnimeService", "Found existing anime in database, will update with fresh data") + anime = &existingAnime + } else { + logger.Infof("AnimeService", "Anime not found in database, creating new") + anime = &entities.Anime{ + MALID: malID, + Mapping: mapping, + } + } + + jikanAnime, err := jikan.GetAnimeByMALID(malID) + if err != nil { + logger.Errorf("AnimeService", "Failed to fetch anime from Jikan: %v", err) + return nil, fmt.Errorf("failed to fetch anime from Jikan: %w", err) + } + + jikanEpisodes, err := jikan.GetAnimeEpisodesByMALID(malID) + if err != nil { + logger.Errorf("AnimeService", "Failed to fetch episodes from Jikan: %v", err) + return nil, fmt.Errorf("failed to fetch episodes from Jikan: %w", err) + } + + jikanCharacters, err := jikan.GetAnimeCharactersByMALID(malID) + if err != nil { + logger.Warnf("AnimeService", "Failed to fetch characters from Jikan: %v", err) + } + + applyJikanData(anime, jikanAnime, jikanEpisodes, jikanCharacters) + + if mapping.Anilist > 0 { + anilistData, err := anilist.GetAnimeByAnilistID(mapping.Anilist) + if err != nil { + logger.Warnf("AnimeService", "Failed to fetch Anilist data: %v", err) + } else { + applyAnilistData(anime, anilistData) + } + } + + malSyncData, err := malsync.GetAnimeByMALID(malID) + if err != nil { + logger.Warnf("AnimeService", "Failed to fetch MALsync data: %v", err) + } else { + applyMALsyncData(anime, malSyncData) + } + + animeType := string(mapping.Type) + if (animeType == "MOVIE" || animeType == "Movie") && mapping.TMDB > 0 { + logger.Infof("AnimeService", "Enriching movie episode from TMDB") + if err := tmdb.EnrichEpisodeFromMovie(anime); err != nil { + logger.Warnf("AnimeService", "Failed to enrich movie from TMDB: %v", err) + } + } else { + if mapping.TVDB > 0 { + logger.Infof("AnimeService", "Enriching episodes from TVDB") + tvdbEpisodes, err := tvdb.GetSeriesEpisodes(mapping.TVDB) + if err == nil && len(tvdbEpisodes) > 0 { + tvdb.EnrichEpisodesFromTVDB(anime, tvdbEpisodes) + logger.Successf("AnimeService", "Successfully enriched %d episodes from TVDB", len(tvdbEpisodes)) + } else { + logger.Warnf("AnimeService", "Failed to fetch TVDB episodes: %v, falling back to TMDB", err) + applyTMDBData(anime) + } + } else { + applyTMDBData(anime) + } + } + + epSkipMap := make(map[string][]entities.EpisodeSkipTime) + if mapping.Anilist > 0 { + logger.Infof("AnimeService", "Enriching episodes with Aniskip data") + for i := range anime.Episodes { + episode := &anime.Episodes[i] + skipData, err := aniskip.GetSkipTimesForEpisode(malID, episode.EpisodeNumber) + if err != nil { + continue + } + skipTimes := applyAniskipData(episode, skipData) + if len(skipTimes) > 0 { + epSkipMap[episode.EpisodeID] = skipTimes + } + } + } + + applyStreamingData(anime) + + if err := saveAnime(anime, epSkipMap); err != nil { + logger.Errorf("AnimeService", "Failed to save anime to database: %v", err) + return nil, fmt.Errorf("failed to save anime to database: %w", err) + } + + logger.Successf("AnimeService", "Successfully fetched and saved anime (MAL ID: %d)", malID) + return anime, nil +} + +func applyTMDBData(anime *entities.Anime) { + if anime.Mapping != nil && anime.Mapping.TMDB > 0 { + logger.Infof("AnimeService", "Enriching episodes from TMDB") + if err := tmdb.AttachEpisodeDescriptions(anime); err != nil { + logger.Warnf("AnimeService", "Failed to enrich episodes from TMDB: %v", err) + } else { + logger.Successf("AnimeService", "Successfully enriched episodes from TMDB") + } + } +} + +func applyJikanData(anime *entities.Anime, jikanAnime *types.JikanAnimeResponse, jikanEpisodes *types.JikanAnimeEpisodeResponse, jikanCharacters *types.JikanAnimeCharacterResponse) { + anime.Synopsis = jikanAnime.Data.Synopsis + anime.Type = jikanAnime.Data.Type + anime.Source = jikanAnime.Data.Source + anime.Airing = jikanAnime.Data.Airing + anime.Status = jikanAnime.Data.Status + anime.Duration = jikanAnime.Data.Duration + anime.Season = jikanAnime.Data.Season + anime.Year = jikanAnime.Data.Year + + if jikanAnime.Data.Title != "" || jikanAnime.Data.TitleEnglish != "" || jikanAnime.Data.TitleJapanese != "" { + anime.Title = &entities.Title{ + Romaji: jikanAnime.Data.Title, + English: jikanAnime.Data.TitleEnglish, + Japanese: jikanAnime.Data.TitleJapanese, + Synonyms: jikanAnime.Data.TitleSynonyms, + } + } + + anime.Scores = &entities.Scores{ + Score: jikanAnime.Data.Score, + ScoredBy: jikanAnime.Data.ScoredBy, + Rank: jikanAnime.Data.Rank, + Popularity: jikanAnime.Data.Popularity, + Members: jikanAnime.Data.Members, + Favorites: jikanAnime.Data.Favorites, + } + + if jikanAnime.Data.Images.JPG.ImageURL != "" { + anime.Images = &entities.Images{ + Small: jikanAnime.Data.Images.JPG.SmallImageURL, + Large: jikanAnime.Data.Images.JPG.LargeImageURL, + Original: jikanAnime.Data.Images.JPG.ImageURL, + } + } + + if jikanAnime.Data.Aired.From != "" || jikanAnime.Data.Aired.To != "" { + anime.AiringStatus = &entities.AiringStatus{ + String: jikanAnime.Data.Aired.String, + } + if jikanAnime.Data.Aired.Prop.From.Year > 0 { + anime.AiringStatus.From = &entities.Date{ + Day: jikanAnime.Data.Aired.Prop.From.Day, + Month: jikanAnime.Data.Aired.Prop.From.Month, + Year: jikanAnime.Data.Aired.Prop.From.Year, + String: jikanAnime.Data.Aired.From, + } + } + if jikanAnime.Data.Aired.Prop.To.Year > 0 { + anime.AiringStatus.To = &entities.Date{ + Day: jikanAnime.Data.Aired.Prop.To.Day, + Month: jikanAnime.Data.Aired.Prop.To.Month, + Year: jikanAnime.Data.Aired.Prop.To.Year, + String: jikanAnime.Data.Aired.To, + } + } + } + + if jikanAnime.Data.Broadcast.Day != "" { + anime.Broadcast = &entities.Broadcast{ + Day: jikanAnime.Data.Broadcast.Day, + Time: jikanAnime.Data.Broadcast.Time, + Timezone: jikanAnime.Data.Broadcast.Timezone, + String: jikanAnime.Data.Broadcast.String, + } + } + + for _, jg := range jikanAnime.Data.Genres { + anime.Genres = append(anime.Genres, entities.Genre{ + GenreID: jg.MALID, + Name: jg.Name, + URL: jg.URL, + }) + } + + for _, jg := range jikanAnime.Data.ExplicitGenres { + anime.Genres = append(anime.Genres, entities.Genre{ + GenreID: jg.MALID, + Name: jg.Name, + URL: jg.URL, + }) + } + + for _, jp := range jikanAnime.Data.Producers { + anime.Producers = append(anime.Producers, entities.Producer{ + MALID: jp.MALID, + URL: jp.URL, + }) + } + + for _, js := range jikanAnime.Data.Studios { + anime.Studios = append(anime.Studios, entities.Producer{ + MALID: js.MALID, + URL: js.URL, + }) + } + + for _, jl := range jikanAnime.Data.Licensors { + anime.Licensors = append(anime.Licensors, entities.Producer{ + MALID: jl.MALID, + URL: jl.URL, + }) + } + + anime.TotalEpisodes = jikanAnime.Data.Episodes + anime.AiredEpisodes = len(jikanEpisodes.Data) + + for _, je := range jikanEpisodes.Data { + episode := entities.Episode{ + EpisodeNumber: je.MALID, + Aired: je.Aired, + Score: je.Score, + Filler: je.Filler, + Recap: je.Recap, + ForumURL: je.ForumURL, + } + + if je.Title != "" || je.TitleJapanese != "" || je.TitleRomaji != "" { + episode.Title = &entities.Title{ + English: je.Title, + Japanese: je.TitleJapanese, + Romaji: je.TitleRomaji, + } + } + + anime.Episodes = append(anime.Episodes, episode) + } + + if jikanCharacters != nil { + for _, jc := range jikanCharacters.Data { + character := entities.Character{ + MALID: jc.MALID, + Name: jc.Name, + Role: jc.Role, + URL: jc.URL, + ImageURL: jc.Images.JPG.ImageURL, + } + + if len(jc.VoiceActors) > 0 { + for _, va := range jc.VoiceActors { + if va.Language == "Japanese" { + character.VoiceActors = append(character.VoiceActors, entities.VoiceActor{ + MALID: va.MALID, + Name: va.Name, + Language: va.Language, + URL: va.URL, + Image: va.Images.JPG.ImageURL, + }) + } + } + } + + anime.Characters = append(anime.Characters, character) + } + } +} + +func applyAnilistData(anime *entities.Anime, anilistData *types.AnilistAnimeResponse) { + if anilistData == nil || anilistData.Data.Media.ID == 0 { + return + } + + media := anilistData.Data.Media + + if anime.Color == "" && media.CoverImage.Color != "" { + anime.Color = media.CoverImage.Color + } + + if anime.Covers == nil && (media.CoverImage.Medium != "" || media.CoverImage.Large != "" || media.CoverImage.ExtraLarge != "") { + anime.Covers = &entities.Images{ + Small: media.CoverImage.Medium, + Large: media.CoverImage.Large, + Original: media.CoverImage.ExtraLarge, + } + } + + if media.NextAiringEpisode.AiringAt > 0 { + anime.NextAiring = &entities.NextEpisode{ + Episode: media.NextAiringEpisode.Episode, + AiringAt: media.NextAiringEpisode.AiringAt, + } + } + + for _, ep := range media.AiringSchedule.Nodes { + anime.Schedule = append(anime.Schedule, entities.EpisodeSchedule{ + Episode: ep.Episode, + AiringAt: ep.AiringAt, + }) + } +} + +func applyMALsyncData(anime *entities.Anime, malSyncData *types.MalsyncAnimeResponse) { + if malSyncData == nil { + return + } + + if anime.Logos == nil { + anime.Logos = &entities.Logos{} + } + + for _, site := range malSyncData.Sites { + for _, entry := range site { + if entry.Image != "" { + anime.Logos.Original = entry.Image + break + } + } + if anime.Logos.Original != "" { + break + } + } +} + +func applyAniskipData(episode *entities.Episode, skipData []types.AniskipResult) []entities.EpisodeSkipTime { + if len(skipData) == 0 { + return nil + } + + skipTimes := make([]entities.EpisodeSkipTime, 0, len(skipData)) + for _, result := range skipData { + if result.EpisodeLength > 0 && episode.EpisodeLength != result.EpisodeLength { + episode.EpisodeLength = result.EpisodeLength + } + + skipTimes = append(skipTimes, entities.EpisodeSkipTime{ + SkipType: result.SkipType, + StartTime: result.Interval.StartTime, + EndTime: result.Interval.EndTime, + }) + } + + return skipTimes +} + +func applyStreamingData(anime *entities.Anime) { + if anime.Title == nil { + return + } + + searchTitle := anime.Title.Romaji + if searchTitle == "" { + searchTitle = anime.Title.English + } + if searchTitle == "" { + return + } + + logger.Infof("AnimeService", "Fetching streaming counts for: %s", searchTitle) + subCount, dubCount, err := streaming.GetStreamingCounts(searchTitle) + if err != nil { + if anime.Title.English != "" && anime.Title.English != searchTitle { + subCount, dubCount, err = streaming.GetStreamingCounts(anime.Title.English) + } + } + + if err != nil { + logger.Warnf("AnimeService", "Failed to fetch streaming counts: %v", err) + return + } + + anime.SubbedCount = subCount + anime.DubbedCount = dubCount + logger.Infof("AnimeService", "Streaming counts - Subbed: %d, Dubbed: %d", subCount, dubCount) +} + +func saveAnime(anime *entities.Anime, skipTimeMap map[string][]entities.EpisodeSkipTime) error { + if anime.Mapping != nil { + if err := repositories.CreateOrUpdateMapping(anime.Mapping); err != nil { + return fmt.Errorf("failed to save mapping: %w", err) + } + anime.MappingID = anime.Mapping.ID + } + + for i := range anime.Genres { + if err := repositories.CreateOrUpdateGenre(&anime.Genres[i]); err != nil { + logger.Warnf("AnimeService", "Failed to save genre: %v", err) + } + } + + for i := range anime.Producers { + if err := repositories.CreateOrUpdateProducer(&anime.Producers[i]); err != nil { + logger.Warnf("AnimeService", "Failed to save producer: %v", err) + } + } + + for i := range anime.Studios { + if err := repositories.CreateOrUpdateProducer(&anime.Studios[i]); err != nil { + logger.Warnf("AnimeService", "Failed to save studio: %v", err) + } + } + + for i := range anime.Licensors { + if err := repositories.CreateOrUpdateProducer(&anime.Licensors[i]); err != nil { + logger.Warnf("AnimeService", "Failed to save licensor: %v", err) + } + } + + if err := repositories.CreateOrUpdateAnime(anime); err != nil { + return fmt.Errorf("failed to save anime: %w", err) + } + + for episodeID, skipTimes := range skipTimeMap { + if err := repositories.SaveEpisodeSkipTimes(episodeID, skipTimes); err != nil { + logger.Warnf("AnimeService", "Failed to save skip times for episode %s: %v", episodeID, err) + } + } + + logger.Successf("AnimeService", "Saved anime with %d episodes, %d characters, %d skip time entries", len(anime.Episodes), len(anime.Characters), len(skipTimeMap)) + return nil +} diff --git a/services/anime/helpers.go b/services/anime/helpers.go index c8bf51d..061ffae 100644 --- a/services/anime/helpers.go +++ b/services/anime/helpers.go @@ -1,556 +1,556 @@ package anime -import ( - "crypto/md5" - "crypto/tls" - "fmt" - "metachan/types" - "metachan/utils/api/anilist" - "metachan/utils/api/jikan" - "metachan/utils/api/malsync" - "metachan/utils/api/tmdb" - "metachan/utils/api/tvdb" - "metachan/utils/logger" - "net/http" - "strings" - "time" -) - -func generateEpisodeID(malID int, episodeNumber int, titles types.EpisodeTitles) string { - var title string - if titles.English != "" { - title = titles.English - } else if titles.Romaji != "" { - title = titles.Romaji - } else { - title = titles.Japanese - } - - // Include MAL ID and episode number to ensure uniqueness across all anime - uniqueString := fmt.Sprintf("%d-%d-%s", malID, episodeNumber, title) - hash := md5.Sum([]byte(uniqueString)) - return fmt.Sprintf("%x", hash) -} - -func generateBasicEpisodes(malID int, episodes []jikan.JikanAnimeEpisode) []types.AnimeSingleEpisode { - var animeEpisodes []types.AnimeSingleEpisode - - for _, episode := range episodes { - titles := types.EpisodeTitles{ - English: episode.Title, - Japanese: episode.TitleJapanese, - Romaji: episode.TitleRomaji, - } - - animeEpisodes = append(animeEpisodes, types.AnimeSingleEpisode{ - ID: generateEpisodeID(malID, episode.MALID, titles), - Titles: titles, - Aired: episode.Aired, - Score: episode.Score, - Filler: episode.Filler, - Recap: episode.Recap, - ForumURL: episode.ForumURL, - URL: episode.URL, - Description: "No description available", - ThumbnailURL: "", - }) - } - return animeEpisodes -} - -// getEpisodeCount determines the highest episode count from different sources -func getEpisodeCount(malAnime *jikan.JikanAnimeResponse, anilistAnime *anilist.AnilistAnimeResponse) int { - if anilistAnime == nil { - return malAnime.Data.Episodes - } - - streamingScheduleLength := len(anilistAnime.Data.Media.AiringSchedule.Nodes) - episodes := max(malAnime.Data.Episodes, anilistAnime.Data.Media.Episodes) - episodes = max(episodes, streamingScheduleLength) - - return episodes -} - -// getEpisodeCountWithAiredFallback determines the total episode count, using aired episodes as fallback for long-running series -func getEpisodeCountWithAiredFallback(malAnime *jikan.JikanAnimeResponse, anilistAnime *anilist.AnilistAnimeResponse, airedCount int) int { - totalFromAPIs := getEpisodeCount(malAnime, anilistAnime) - - // For long-running series, if the aired count is significantly higher than API-reported total, - // use the aired count as a more accurate total (since APIs often report season/arc counts) - if airedCount > totalFromAPIs && airedCount > 100 { - // This indicates a long-running series where APIs might be reporting seasonal data - // For ongoing series, total should be at least as high as aired episodes - return airedCount - } - - // For normal series, use the maximum from APIs - return max(totalFromAPIs, airedCount) -} - -// sortSeasonsByAirDate sorts the seasons array chronologically by air date -func sortSeasonsByAirDate(seasons *[]types.AnimeSeason) { - // First, collect seasons with valid dates - seasonsWithDates := make([]types.AnimeSeason, 0) - seasonsWithoutDates := make([]types.AnimeSeason, 0) - - for _, season := range *seasons { - if season.AiringStatus.From.Year > 0 { - seasonsWithDates = append(seasonsWithDates, season) - } else { - seasonsWithoutDates = append(seasonsWithoutDates, season) - } - } - - // Sort seasons with dates - if len(seasonsWithDates) > 0 { - sortedSeasons := make([]types.AnimeSeason, len(seasonsWithDates)) - copy(sortedSeasons, seasonsWithDates) - - for i := 0; i < len(sortedSeasons)-1; i++ { - for j := i + 1; j < len(sortedSeasons); j++ { - a := sortedSeasons[i].AiringStatus.From - b := sortedSeasons[j].AiringStatus.From - - // Compare years - if a.Year > b.Year { - sortedSeasons[i], sortedSeasons[j] = sortedSeasons[j], sortedSeasons[i] - } else if a.Year == b.Year { - // Compare months if years are equal - if a.Month > b.Month { - sortedSeasons[i], sortedSeasons[j] = sortedSeasons[j], sortedSeasons[i] - } else if a.Month == b.Month { - // Compare days if months are equal - if a.Day > b.Day { - sortedSeasons[i], sortedSeasons[j] = sortedSeasons[j], sortedSeasons[i] - } - } - } - } - } - - // Combine sorted dates with no-date seasons - result := append(sortedSeasons, seasonsWithoutDates...) - *seasons = result - } -} - -// generateGenres converts Jikan genre structures to our format -func generateGenres(genres, explicitGenres []jikan.JikanGenericMALStructure) []types.AnimeGenres { - var animeGenres []types.AnimeGenres - - // Add regular genres - for _, genre := range genres { - animeGenres = append(animeGenres, types.AnimeGenres{ - Name: genre.Name, - GenreID: genre.MALID, - URL: genre.URL, - }) - } - - // Add explicit genres if any - for _, genre := range explicitGenres { - animeGenres = append(animeGenres, types.AnimeGenres{ - Name: genre.Name, - GenreID: genre.MALID, - URL: genre.URL, - }) - } - - return animeGenres -} - -// generateStudios converts Jikan studio structures to our format -func generateStudios(studios []jikan.JikanGenericMALStructure) []types.AnimeProducer { - var animeStudios []types.AnimeProducer - - for _, studio := range studios { - animeStudios = append(animeStudios, types.AnimeProducer{ - Name: studio.Name, - MALID: studio.MALID, - URL: studio.URL, - }) - } - - return animeStudios -} - -// generateProducers converts Jikan producer structures to our format -func generateProducers(producers []jikan.JikanGenericMALStructure) []types.AnimeProducer { - var animeProducers []types.AnimeProducer - - for _, producer := range producers { - animeProducers = append(animeProducers, types.AnimeProducer{ - Name: producer.Name, - MALID: producer.MALID, - URL: producer.URL, - }) - } - - return animeProducers -} - -// generateLicensors converts Jikan licensor structures to our format -func generateLicensors(licensors []jikan.JikanGenericMALStructure) []types.AnimeProducer { - var animeLicensors []types.AnimeProducer - - for _, licensor := range licensors { - animeLicensors = append(animeLicensors, types.AnimeProducer{ - Name: licensor.Name, - MALID: licensor.MALID, - URL: licensor.URL, - }) - } - - return animeLicensors -} - -// getAnimeCharacters processes character data from Jikan -func getAnimeCharacters(characterResponse *jikan.JikanAnimeCharacterResponse) []types.AnimeCharacter { - var characters []types.AnimeCharacter - - for _, entry := range characterResponse.Data { - character := types.AnimeCharacter{ - MALID: entry.Character.MALID, - URL: entry.Character.URL, - ImageURL: entry.Character.Images.JPG.ImageURL, - Name: entry.Character.Name, - Role: entry.Role, - } - - for _, va := range entry.VoiceActors { - character.VoiceActors = append(character.VoiceActors, types.AnimeVoiceActor{ - MALID: va.Person.MALID, - URL: va.Person.URL, - Image: va.Person.Images.JPG.ImageURL, - Name: va.Person.Name, - Language: va.Language, - }) - } - - characters = append(characters, character) - } - - return characters -} - -// getNextAiringEpisode extracts next airing episode data from AniList -func getNextAiringEpisode(anilistAnime *anilist.AnilistAnimeResponse) types.AnimeAiringEpisode { - if anilistAnime == nil || anilistAnime.Data.Media.ID == 0 { - return types.AnimeAiringEpisode{} - } - - // Get the current time to determine the next episode - currentTime := time.Now().Unix() - nextEpisode := anilistAnime.Data.Media.NextAiringEpisode - - // If AniList provides a valid next airing episode directly, use it - if nextEpisode.AiringAt > 0 && nextEpisode.Episode > 0 { - return types.AnimeAiringEpisode{ - AiringAt: nextEpisode.AiringAt, - Episode: nextEpisode.Episode, - } - } - - // If AniList doesn't provide a direct next episode, but we have airing schedule nodes - // Find the next episode that hasn't aired yet - if len(anilistAnime.Data.Media.AiringSchedule.Nodes) > 0 { - var nextAiringEpisode types.AnimeAiringEpisode - - for _, node := range anilistAnime.Data.Media.AiringSchedule.Nodes { - if int64(node.AiringAt) > currentTime { - // If this is the first future episode we've found, or it airs sooner than our current "next" - if nextAiringEpisode.AiringAt == 0 || node.AiringAt < nextAiringEpisode.AiringAt { - nextAiringEpisode.AiringAt = node.AiringAt - nextAiringEpisode.Episode = node.Episode - } - } - } - - // If we found a next episode - if nextAiringEpisode.AiringAt > 0 { - return nextAiringEpisode - } - } - - return types.AnimeAiringEpisode{} -} - -// getAnimeSchedule extracts airing schedule data from AniList -func getAnimeSchedule(anilistAnime *anilist.AnilistAnimeResponse) []types.AnimeAiringEpisode { - if anilistAnime == nil || anilistAnime.Data.Media.AiringSchedule.Nodes == nil { - return []types.AnimeAiringEpisode{} - } - - var schedule []types.AnimeAiringEpisode - - for _, node := range anilistAnime.Data.Media.AiringSchedule.Nodes { - schedule = append(schedule, types.AnimeAiringEpisode{ - AiringAt: node.AiringAt, - Episode: node.Episode, - }) - } - - return schedule -} - -// AttachEpisodeDescriptions enhances episode information with external data -// Imports the function from the anime utils to use in our service -var AttachEpisodeDescriptions = tmdb.AttachEpisodeDescriptions - -// extractLogosFromMALSync extracts logo images from MALSync data -func extractLogosFromMALSync(malSyncData *malsync.MALSyncAnimeResponse) types.AnimeLogos { - logos := types.AnimeLogos{} - - // Early return if no data - if malSyncData == nil { - return logos - } - - // Check if Crunchyroll data exists in the MALSync response - crunchyrollSites, exists := malSyncData.Sites["Crunchyroll"] - if !exists || len(crunchyrollSites) == 0 { - logger.Log("No Crunchyroll data found in MALSync response", logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - return logos - } - - // Get the Crunchyroll URL from any of the entries - crURL := "" - for _, site := range crunchyrollSites { - crURL = site.URL - break // Take the first URL - } - - if crURL == "" { - logger.Log("No valid Crunchyroll URL found", logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - return logos - } - - // Extract series ID from URL - seriesID := extractCrunchyrollSeriesID(crURL) - if seriesID == "" { - return logos - } - - // Define logo sizes - logoSizes := map[string]int{ - "Small": 320, - "Medium": 480, - "Large": 600, - "XLarge": 800, - "Original": 1200, - } - - // Generate logo URLs - logos.Small = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["Small"], seriesID) - logos.Medium = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["Medium"], seriesID) - logos.Large = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["Large"], seriesID) - logos.XLarge = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["XLarge"], seriesID) - logos.Original = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["Original"], seriesID) - - logger.Log(fmt.Sprintf("Successfully generated logo URLs for series ID: %s", seriesID), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - - return logos -} - -// extractCrunchyrollSeriesID extracts the series ID from a Crunchyroll URL -func extractCrunchyrollSeriesID(crURL string) string { - logger.Log(fmt.Sprintf("Attempting to extract series ID from URL: %s", crURL), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - - // Direct series URL format - if strings.Contains(crURL, "/series/") { - parts := strings.Split(crURL, "/series/") - if len(parts) < 2 { - logger.Log("URL contains /series/ but couldn't extract ID part", logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - return "" - } - - idParts := strings.Split(parts[1], "/") - if len(idParts) < 1 { - logger.Log("Couldn't extract ID from path segments", logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - return "" - } - - logger.Log(fmt.Sprintf("Found series ID directly in URL: %s", idParts[0]), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - return idParts[0] - } - - // Need to follow redirect to get series ID - logger.Log("URL doesn't contain /series/, following redirect...", logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - - // Create a transport that uses modern TLS settings - transport := &http.Transport{ - TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - }, - ForceAttemptHTTP2: true, - } - - client := &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { - // Don't follow redirects, just capture the Location header - return http.ErrUseLastResponse - }, - Timeout: 10 * time.Second, - Transport: transport, - } - - // Update HTTP to HTTPS for Crunchyroll URLs if needed - if strings.HasPrefix(crURL, "http://www.crunchyroll.com") { - crURL = strings.Replace(crURL, "http://", "https://", 1) - logger.Log(fmt.Sprintf("Updated URL to HTTPS: %s", crURL), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - } - - // Add User-Agent header to mimic a browser - req, err := http.NewRequest("GET", crURL, nil) - if err != nil { - logger.Log(fmt.Sprintf("Failed to create request for Crunchyroll URL: %v", err), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - return "" - } - - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") - req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml") - - resp, err := client.Do(req) - if err != nil { - logger.Log(fmt.Sprintf("Failed to get Crunchyroll redirect: %v", err), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - return "" - } - defer resp.Body.Close() - - // Log the status code and response headers for debugging - logger.Log(fmt.Sprintf("Crunchyroll response status: %d %s", resp.StatusCode, resp.Status), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - - for name, values := range resp.Header { - logger.Log(fmt.Sprintf("Header %s: %s", name, strings.Join(values, ", ")), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - } - - // Check for specific status codes for redirects - if resp.StatusCode != http.StatusMovedPermanently && - resp.StatusCode != http.StatusFound && - resp.StatusCode != http.StatusTemporaryRedirect && - resp.StatusCode != http.StatusPermanentRedirect { - logger.Log(fmt.Sprintf("Unexpected status code from Crunchyroll: %d", resp.StatusCode), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - - // If we got a 200 OK, maybe Crunchyroll served the page directly - // Try to extract the series ID from the URL itself as a fallback - if resp.StatusCode == http.StatusOK && strings.Contains(crURL, "crunchyroll.com") { - // For URLs like http://www.crunchyroll.com/fullmetal-alchemist-brotherhood - // Extract the last part as a potential identifier - urlParts := strings.Split(crURL, "/") - if len(urlParts) > 0 { - potentialId := urlParts[len(urlParts)-1] - logger.Log(fmt.Sprintf("Extracted potential series ID from original URL: %s", potentialId), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - return potentialId - } - } - return "" - } - - redirectURL := resp.Header.Get("Location") - if redirectURL == "" { - logger.Log("No redirect URL found in Crunchyroll response", logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - return "" - } - - logger.Log(fmt.Sprintf("Found redirect URL: %s", redirectURL), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - - // Extract series ID from redirect URL - if strings.Contains(redirectURL, "/series/") { - parts := strings.Split(redirectURL, "/series/") - if len(parts) < 2 { - return "" - } - - idParts := strings.Split(parts[1], "/") - if len(idParts) < 1 { - return "" - } - - logger.Log(fmt.Sprintf("Successfully extracted series ID from redirect: %s", idParts[0]), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - return idParts[0] - } - - // For multi-level redirects, try to follow one more time - if strings.Contains(redirectURL, "crunchyroll.com") { - logger.Log("Trying to follow one more redirect level...", logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - return extractCrunchyrollSeriesID(redirectURL) - } - - // As a fallback for older Crunchyroll URLs like fullmetal-alchemist-brotherhood - // Use the last path segment as the ID - urlParts := strings.Split(crURL, "/") - if len(urlParts) > 0 { - potentialId := urlParts[len(urlParts)-1] - logger.Log(fmt.Sprintf("Using fallback: extracted ID from original URL: %s", potentialId), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - return potentialId - } - - logger.Log("Could not extract series ID from Crunchyroll redirect URL", logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - return "" -} - -// FindSeasonMappings finds all anime mappings that belong to the same series based on TVDB ID -var FindSeasonMappings = tvdb.FindSeasonMappings +// import ( +// "crypto/md5" +// "crypto/tls" +// "fmt" +// "metachan/types" +// "metachan/utils/api/anilist" +// "metachan/utils/api/jikan" +// "metachan/utils/api/malsync" +// "metachan/utils/api/tmdb" +// "metachan/utils/api/tvdb" +// "metachan/utils/logger" +// "net/http" +// "strings" +// "time" +// ) + +// func generateEpisodeID(malID int, episodeNumber int, titles types.EpisodeTitles) string { +// var title string +// if titles.English != "" { +// title = titles.English +// } else if titles.Romaji != "" { +// title = titles.Romaji +// } else { +// title = titles.Japanese +// } + +// // Include MAL ID and episode number to ensure uniqueness across all anime +// uniqueString := fmt.Sprintf("%d-%d-%s", malID, episodeNumber, title) +// hash := md5.Sum([]byte(uniqueString)) +// return fmt.Sprintf("%x", hash) +// } + +// func generateBasicEpisodes(malID int, episodes []jikan.JikanAnimeEpisode) []types.AnimeSingleEpisode { +// var animeEpisodes []types.AnimeSingleEpisode + +// for _, episode := range episodes { +// titles := types.EpisodeTitles{ +// English: episode.Title, +// Japanese: episode.TitleJapanese, +// Romaji: episode.TitleRomaji, +// } + +// animeEpisodes = append(animeEpisodes, types.AnimeSingleEpisode{ +// ID: generateEpisodeID(malID, episode.MALID, titles), +// Titles: titles, +// Aired: episode.Aired, +// Score: episode.Score, +// Filler: episode.Filler, +// Recap: episode.Recap, +// ForumURL: episode.ForumURL, +// URL: episode.URL, +// Description: "No description available", +// ThumbnailURL: "", +// }) +// } +// return animeEpisodes +// } + +// // getEpisodeCount determines the highest episode count from different sources +// func getEpisodeCount(malAnime *jikan.JikanAnimeResponse, anilistAnime *anilist.AnilistAnimeResponse) int { +// if anilistAnime == nil { +// return malAnime.Data.Episodes +// } + +// streamingScheduleLength := len(anilistAnime.Data.Media.AiringSchedule.Nodes) +// episodes := max(malAnime.Data.Episodes, anilistAnime.Data.Media.Episodes) +// episodes = max(episodes, streamingScheduleLength) + +// return episodes +// } + +// // getEpisodeCountWithAiredFallback determines the total episode count, using aired episodes as fallback for long-running series +// func getEpisodeCountWithAiredFallback(malAnime *jikan.JikanAnimeResponse, anilistAnime *anilist.AnilistAnimeResponse, airedCount int) int { +// totalFromAPIs := getEpisodeCount(malAnime, anilistAnime) + +// // For long-running series, if the aired count is significantly higher than API-reported total, +// // use the aired count as a more accurate total (since APIs often report season/arc counts) +// if airedCount > totalFromAPIs && airedCount > 100 { +// // This indicates a long-running series where APIs might be reporting seasonal data +// // For ongoing series, total should be at least as high as aired episodes +// return airedCount +// } + +// // For normal series, use the maximum from APIs +// return max(totalFromAPIs, airedCount) +// } + +// // sortSeasonsByAirDate sorts the seasons array chronologically by air date +// func sortSeasonsByAirDate(seasons *[]types.AnimeSeason) { +// // First, collect seasons with valid dates +// seasonsWithDates := make([]types.AnimeSeason, 0) +// seasonsWithoutDates := make([]types.AnimeSeason, 0) + +// for _, season := range *seasons { +// if season.AiringStatus.From.Year > 0 { +// seasonsWithDates = append(seasonsWithDates, season) +// } else { +// seasonsWithoutDates = append(seasonsWithoutDates, season) +// } +// } + +// // Sort seasons with dates +// if len(seasonsWithDates) > 0 { +// sortedSeasons := make([]types.AnimeSeason, len(seasonsWithDates)) +// copy(sortedSeasons, seasonsWithDates) + +// for i := 0; i < len(sortedSeasons)-1; i++ { +// for j := i + 1; j < len(sortedSeasons); j++ { +// a := sortedSeasons[i].AiringStatus.From +// b := sortedSeasons[j].AiringStatus.From + +// // Compare years +// if a.Year > b.Year { +// sortedSeasons[i], sortedSeasons[j] = sortedSeasons[j], sortedSeasons[i] +// } else if a.Year == b.Year { +// // Compare months if years are equal +// if a.Month > b.Month { +// sortedSeasons[i], sortedSeasons[j] = sortedSeasons[j], sortedSeasons[i] +// } else if a.Month == b.Month { +// // Compare days if months are equal +// if a.Day > b.Day { +// sortedSeasons[i], sortedSeasons[j] = sortedSeasons[j], sortedSeasons[i] +// } +// } +// } +// } +// } + +// // Combine sorted dates with no-date seasons +// result := append(sortedSeasons, seasonsWithoutDates...) +// *seasons = result +// } +// } + +// // generateGenres converts Jikan genre structures to our format +// func generateGenres(genres, explicitGenres []jikan.JikanGenericMALStructure) []types.AnimeGenres { +// var animeGenres []types.AnimeGenres + +// // Add regular genres +// for _, genre := range genres { +// animeGenres = append(animeGenres, types.AnimeGenres{ +// Name: genre.Name, +// GenreID: genre.MALID, +// URL: genre.URL, +// }) +// } + +// // Add explicit genres if any +// for _, genre := range explicitGenres { +// animeGenres = append(animeGenres, types.AnimeGenres{ +// Name: genre.Name, +// GenreID: genre.MALID, +// URL: genre.URL, +// }) +// } + +// return animeGenres +// } + +// // generateStudios converts Jikan studio structures to our format +// func generateStudios(studios []jikan.JikanGenericMALStructure) []types.AnimeProducer { +// var animeStudios []types.AnimeProducer + +// for _, studio := range studios { +// animeStudios = append(animeStudios, types.AnimeProducer{ +// Name: studio.Name, +// MALID: studio.MALID, +// URL: studio.URL, +// }) +// } + +// return animeStudios +// } + +// // generateProducers converts Jikan producer structures to our format +// func generateProducers(producers []jikan.JikanGenericMALStructure) []types.AnimeProducer { +// var animeProducers []types.AnimeProducer + +// for _, producer := range producers { +// animeProducers = append(animeProducers, types.AnimeProducer{ +// Name: producer.Name, +// MALID: producer.MALID, +// URL: producer.URL, +// }) +// } + +// return animeProducers +// } + +// // generateLicensors converts Jikan licensor structures to our format +// func generateLicensors(licensors []jikan.JikanGenericMALStructure) []types.AnimeProducer { +// var animeLicensors []types.AnimeProducer + +// for _, licensor := range licensors { +// animeLicensors = append(animeLicensors, types.AnimeProducer{ +// Name: licensor.Name, +// MALID: licensor.MALID, +// URL: licensor.URL, +// }) +// } + +// return animeLicensors +// } + +// // getAnimeCharacters processes character data from Jikan +// func getAnimeCharacters(characterResponse *jikan.JikanAnimeCharacterResponse) []types.AnimeCharacter { +// var characters []types.AnimeCharacter + +// for _, entry := range characterResponse.Data { +// character := types.AnimeCharacter{ +// MALID: entry.Character.MALID, +// URL: entry.Character.URL, +// ImageURL: entry.Character.Images.JPG.ImageURL, +// Name: entry.Character.Name, +// Role: entry.Role, +// } + +// for _, va := range entry.VoiceActors { +// character.VoiceActors = append(character.VoiceActors, types.AnimeVoiceActor{ +// MALID: va.Person.MALID, +// URL: va.Person.URL, +// Image: va.Person.Images.JPG.ImageURL, +// Name: va.Person.Name, +// Language: va.Language, +// }) +// } + +// characters = append(characters, character) +// } + +// return characters +// } + +// // getNextAiringEpisode extracts next airing episode data from AniList +// func getNextAiringEpisode(anilistAnime *anilist.AnilistAnimeResponse) types.AnimeAiringEpisode { +// if anilistAnime == nil || anilistAnime.Data.Media.ID == 0 { +// return types.AnimeAiringEpisode{} +// } + +// // Get the current time to determine the next episode +// currentTime := time.Now().Unix() +// nextEpisode := anilistAnime.Data.Media.NextAiringEpisode + +// // If AniList provides a valid next airing episode directly, use it +// if nextEpisode.AiringAt > 0 && nextEpisode.Episode > 0 { +// return types.AnimeAiringEpisode{ +// AiringAt: nextEpisode.AiringAt, +// Episode: nextEpisode.Episode, +// } +// } + +// // If AniList doesn't provide a direct next episode, but we have airing schedule nodes +// // Find the next episode that hasn't aired yet +// if len(anilistAnime.Data.Media.AiringSchedule.Nodes) > 0 { +// var nextAiringEpisode types.AnimeAiringEpisode + +// for _, node := range anilistAnime.Data.Media.AiringSchedule.Nodes { +// if int64(node.AiringAt) > currentTime { +// // If this is the first future episode we've found, or it airs sooner than our current "next" +// if nextAiringEpisode.AiringAt == 0 || node.AiringAt < nextAiringEpisode.AiringAt { +// nextAiringEpisode.AiringAt = node.AiringAt +// nextAiringEpisode.Episode = node.Episode +// } +// } +// } + +// // If we found a next episode +// if nextAiringEpisode.AiringAt > 0 { +// return nextAiringEpisode +// } +// } + +// return types.AnimeAiringEpisode{} +// } + +// // getAnimeSchedule extracts airing schedule data from AniList +// func getAnimeSchedule(anilistAnime *anilist.AnilistAnimeResponse) []types.AnimeAiringEpisode { +// if anilistAnime == nil || anilistAnime.Data.Media.AiringSchedule.Nodes == nil { +// return []types.AnimeAiringEpisode{} +// } + +// var schedule []types.AnimeAiringEpisode + +// for _, node := range anilistAnime.Data.Media.AiringSchedule.Nodes { +// schedule = append(schedule, types.AnimeAiringEpisode{ +// AiringAt: node.AiringAt, +// Episode: node.Episode, +// }) +// } + +// return schedule +// } + +// // AttachEpisodeDescriptions enhances episode information with external data +// // Imports the function from the anime utils to use in our service +// var AttachEpisodeDescriptions = tmdb.AttachEpisodeDescriptions + +// // extractLogosFromMALSync extracts logo images from MALSync data +// func extractLogosFromMALSync(malSyncData *malsync.MALSyncAnimeResponse) types.AnimeLogos { +// logos := types.AnimeLogos{} + +// // Early return if no data +// if malSyncData == nil { +// return logos +// } + +// // Check if Crunchyroll data exists in the MALSync response +// crunchyrollSites, exists := malSyncData.Sites["Crunchyroll"] +// if !exists || len(crunchyrollSites) == 0 { +// logger.Log("No Crunchyroll data found in MALSync response", logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// return logos +// } + +// // Get the Crunchyroll URL from any of the entries +// crURL := "" +// for _, site := range crunchyrollSites { +// crURL = site.URL +// break // Take the first URL +// } + +// if crURL == "" { +// logger.Log("No valid Crunchyroll URL found", logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// return logos +// } + +// // Extract series ID from URL +// seriesID := extractCrunchyrollSeriesID(crURL) +// if seriesID == "" { +// return logos +// } + +// // Define logo sizes +// logoSizes := map[string]int{ +// "Small": 320, +// "Medium": 480, +// "Large": 600, +// "XLarge": 800, +// "Original": 1200, +// } + +// // Generate logo URLs +// logos.Small = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["Small"], seriesID) +// logos.Medium = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["Medium"], seriesID) +// logos.Large = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["Large"], seriesID) +// logos.XLarge = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["XLarge"], seriesID) +// logos.Original = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["Original"], seriesID) + +// logger.Log(fmt.Sprintf("Successfully generated logo URLs for series ID: %s", seriesID), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) + +// return logos +// } + +// // extractCrunchyrollSeriesID extracts the series ID from a Crunchyroll URL +// func extractCrunchyrollSeriesID(crURL string) string { +// logger.Log(fmt.Sprintf("Attempting to extract series ID from URL: %s", crURL), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) + +// // Direct series URL format +// if strings.Contains(crURL, "/series/") { +// parts := strings.Split(crURL, "/series/") +// if len(parts) < 2 { +// logger.Log("URL contains /series/ but couldn't extract ID part", logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// return "" +// } + +// idParts := strings.Split(parts[1], "/") +// if len(idParts) < 1 { +// logger.Log("Couldn't extract ID from path segments", logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// return "" +// } + +// logger.Log(fmt.Sprintf("Found series ID directly in URL: %s", idParts[0]), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// return idParts[0] +// } + +// // Need to follow redirect to get series ID +// logger.Log("URL doesn't contain /series/, following redirect...", logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) + +// // Create a transport that uses modern TLS settings +// transport := &http.Transport{ +// TLSClientConfig: &tls.Config{ +// MinVersion: tls.VersionTLS12, +// }, +// ForceAttemptHTTP2: true, +// } + +// client := &http.Client{ +// CheckRedirect: func(req *http.Request, via []*http.Request) error { +// // Don't follow redirects, just capture the Location header +// return http.ErrUseLastResponse +// }, +// Timeout: 10 * time.Second, +// Transport: transport, +// } + +// // Update HTTP to HTTPS for Crunchyroll URLs if needed +// if strings.HasPrefix(crURL, "http://www.crunchyroll.com") { +// crURL = strings.Replace(crURL, "http://", "https://", 1) +// logger.Log(fmt.Sprintf("Updated URL to HTTPS: %s", crURL), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// } + +// // Add User-Agent header to mimic a browser +// req, err := http.NewRequest("GET", crURL, nil) +// if err != nil { +// logger.Log(fmt.Sprintf("Failed to create request for Crunchyroll URL: %v", err), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// return "" +// } + +// req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") +// req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml") + +// resp, err := client.Do(req) +// if err != nil { +// logger.Log(fmt.Sprintf("Failed to get Crunchyroll redirect: %v", err), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// return "" +// } +// defer resp.Body.Close() + +// // Log the status code and response headers for debugging +// logger.Log(fmt.Sprintf("Crunchyroll response status: %d %s", resp.StatusCode, resp.Status), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) + +// for name, values := range resp.Header { +// logger.Log(fmt.Sprintf("Header %s: %s", name, strings.Join(values, ", ")), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// } + +// // Check for specific status codes for redirects +// if resp.StatusCode != http.StatusMovedPermanently && +// resp.StatusCode != http.StatusFound && +// resp.StatusCode != http.StatusTemporaryRedirect && +// resp.StatusCode != http.StatusPermanentRedirect { +// logger.Log(fmt.Sprintf("Unexpected status code from Crunchyroll: %d", resp.StatusCode), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) + +// // If we got a 200 OK, maybe Crunchyroll served the page directly +// // Try to extract the series ID from the URL itself as a fallback +// if resp.StatusCode == http.StatusOK && strings.Contains(crURL, "crunchyroll.com") { +// // For URLs like http://www.crunchyroll.com/fullmetal-alchemist-brotherhood +// // Extract the last part as a potential identifier +// urlParts := strings.Split(crURL, "/") +// if len(urlParts) > 0 { +// potentialId := urlParts[len(urlParts)-1] +// logger.Log(fmt.Sprintf("Extracted potential series ID from original URL: %s", potentialId), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// return potentialId +// } +// } +// return "" +// } + +// redirectURL := resp.Header.Get("Location") +// if redirectURL == "" { +// logger.Log("No redirect URL found in Crunchyroll response", logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// return "" +// } + +// logger.Log(fmt.Sprintf("Found redirect URL: %s", redirectURL), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) + +// // Extract series ID from redirect URL +// if strings.Contains(redirectURL, "/series/") { +// parts := strings.Split(redirectURL, "/series/") +// if len(parts) < 2 { +// return "" +// } + +// idParts := strings.Split(parts[1], "/") +// if len(idParts) < 1 { +// return "" +// } + +// logger.Log(fmt.Sprintf("Successfully extracted series ID from redirect: %s", idParts[0]), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// return idParts[0] +// } + +// // For multi-level redirects, try to follow one more time +// if strings.Contains(redirectURL, "crunchyroll.com") { +// logger.Log("Trying to follow one more redirect level...", logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// return extractCrunchyrollSeriesID(redirectURL) +// } + +// // As a fallback for older Crunchyroll URLs like fullmetal-alchemist-brotherhood +// // Use the last path segment as the ID +// urlParts := strings.Split(crURL, "/") +// if len(urlParts) > 0 { +// potentialId := urlParts[len(urlParts)-1] +// logger.Log(fmt.Sprintf("Using fallback: extracted ID from original URL: %s", potentialId), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// return potentialId +// } + +// logger.Log("Could not extract series ID from Crunchyroll redirect URL", logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// return "" +// } + +// // FindSeasonMappings finds all anime mappings that belong to the same series based on TVDB ID +// var FindSeasonMappings = tvdb.FindSeasonMappings diff --git a/services/anime/service.go b/services/anime/service.go index 15c0adc..830836c 100644 --- a/services/anime/service.go +++ b/services/anime/service.go @@ -1,1078 +1,1078 @@ package anime -import ( - "fmt" - "metachan/database" - "metachan/entities" - "metachan/types" - "metachan/utils/api/anilist" - "metachan/utils/api/jikan" - "metachan/utils/api/malsync" - "metachan/utils/api/streaming" - "metachan/utils/api/tmdb" - "metachan/utils/api/tvdb" - "metachan/utils/concurrency" - "metachan/utils/logger" - "strings" - "time" -) - -// Service provides high-level operations for anime data -type Service struct { - jikanClient *jikan.JikanClient - streamingClient *streaming.AllAnimeClient - anilistClient *anilist.AniListClient - malsyncClient *malsync.MALSyncClient -} - -// NewService creates a new anime service -func NewService() *Service { - return &Service{ - jikanClient: jikan.NewJikanClient(), - streamingClient: streaming.NewAllAnimeClient(), - anilistClient: anilist.NewAniListClient(), - malsyncClient: malsync.NewMALSyncClient(), - } -} - -// GetAnimeDetailsWithSource fetches comprehensive anime details with source information -func (s *Service) GetAnimeDetailsWithSource(mapping *entities.AnimeMapping, source string) (*types.Anime, error) { - if mapping == nil { - return nil, fmt.Errorf("anime mapping is nil") - } - - startTime := time.Now() - defer func() { - duration := time.Since(startTime) - logger.Log(fmt.Sprintf("GetAnimeDetails (%s) execution time: %s", source, duration), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - }() - - malID := mapping.MAL - - // For updater source, always fetch fresh data and skip cache - if source != "updater" { - // First, check if we have an existing version in the database - anime, err := database.GetAnimeByMALID(malID) - if err == nil { - logger.Log(fmt.Sprintf("Found existing anime data (MAL ID: %d), returning stored data", malID), logger.LogOptions{ - Level: logger.Info, - Prefix: "AnimeDB", - }) - - // Ensure mappings are attached properly - anime.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 anime, nil - } - } else { - logger.Log(fmt.Sprintf("Bypassing database check for anime (MAL ID: %d) - source: %s", malID, source), logger.LogOptions{ - Level: logger.Info, - Prefix: "AnimeAPI", - }) - } - - // Rest of the implementation is the same as GetAnimeDetails - logger.Log(fmt.Sprintf("No existing data for anime (MAL ID: %d), fetching fresh data", malID), logger.LogOptions{ - Level: logger.Info, - Prefix: "AnimeAPI", - }) - - // Create the different types of functions for proper Go generic type inference - animeFunc := func() (*jikan.JikanAnimeResponse, error) { - return s.jikanClient.GetFullAnime(malID) - } - - episodesFunc := func() (*jikan.JikanAnimeEpisodeResponse, error) { - return s.jikanClient.GetAnimeEpisodes(malID) - } - - charactersFunc := func() (*jikan.JikanAnimeCharacterResponse, error) { - return s.jikanClient.GetAnimeCharacters(malID) - } - - fetchStartTime := time.Now() - // Use separate results variables for each type - animeResult := concurrency.Parallel(animeFunc)[0] - episodesResult := concurrency.Parallel(episodesFunc)[0] - charactersResult := concurrency.Parallel(charactersFunc)[0] - logger.Log(fmt.Sprintf("Initial parallel API fetch time: %s", time.Since(fetchStartTime)), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - - // Extract results and handle errors - anime := animeResult.Value - if animeResult.Error != nil { - return nil, fmt.Errorf("failed to get anime details: %w", animeResult.Error) - } - - episodes := episodesResult.Value - if episodesResult.Error != nil { - return nil, fmt.Errorf("failed to get anime episodes: %w", episodesResult.Error) - } - - characterResponse := charactersResult.Value - if charactersResult.Error != nil { - return nil, fmt.Errorf("failed to get anime characters: %w", charactersResult.Error) - } - - // Get Anilist and MALSync data in parallel if available - var anilistAnime *anilist.AnilistAnimeResponse - var malSyncData *malsync.MALSyncAnimeResponse - - anilistStartTime := time.Now() - if mapping.Anilist != 0 { - // We need separate functions for each type for proper type inference - anilistFunc := func() (*anilist.AnilistAnimeResponse, error) { - return s.anilistClient.GetAnime(mapping.Anilist) - } - - malsyncFunc := func() (*malsync.MALSyncAnimeResponse, error) { - return s.malsyncClient.GetAnimeByMALID(malID) - } - - // Execute them separately to avoid type errors - anilistResult := concurrency.Parallel(anilistFunc)[0] - malsyncResult := concurrency.Parallel(malsyncFunc)[0] - - // Extract AniList result - if anilistResult.Error == nil { - anilistAnime = anilistResult.Value - logger.Log(fmt.Sprintf("Successfully fetched AniList data for ID %d", mapping.Anilist), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - } else { - logger.Log(fmt.Sprintf("Failed to fetch AniList data: %v", anilistResult.Error), logger.LogOptions{ - Level: logger.Warn, - Prefix: "AnimeAPI", - }) - } - - // Extract MALSync result - if malsyncResult.Error == nil { - malSyncData = malsyncResult.Value - } else { - logger.Log(fmt.Sprintf("Failed to fetch MALSync data: %v", malsyncResult.Error), logger.LogOptions{ - Level: logger.Warn, - Prefix: "AnimeAPI", - }) - } - } else { - logger.Log(fmt.Sprintf("No AniList ID available for MAL ID %d", malID), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - // If no AniList ID, just fetch MALSync data - malSyncData, _ = s.malsyncClient.GetAnimeByMALID(malID) - } - logger.Log(fmt.Sprintf("AniList and MALSync fetch time: %s", time.Since(anilistStartTime)), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - - // Process episode data in parallel with seasons and other operations - episodeDataChan := make(chan []types.AnimeSingleEpisode, 1) - subbedCountChan := make(chan int, 1) - dubbedCountChan := make(chan int, 1) - tmdbErrorChan := make(chan error, 1) - - episodeProcessingStartTime := time.Now() - go func() { - defer close(episodeDataChan) - defer close(subbedCountChan) - defer close(dubbedCountChan) - defer close(tmdbErrorChan) - - var enrichedEpisodes []types.AnimeSingleEpisode - var tmdbErr error - - // Check anime type - use different sources for movies vs TV shows - animeType := string(mapping.Type) - - if (animeType == "MOVIE" || animeType == "Movie") && mapping.TMDB != 0 { - // For movies with TMDB mapping, use TMDB to get movie details as a single episode - logger.Log(fmt.Sprintf("Detected movie type with TMDB ID %d, fetching from TMDB for: %s", mapping.TMDB, anime.Data.Title), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - - enrichedEpisodes, tmdbErr = tmdb.GetMovieAsEpisode( - anime.Data.Title, - anime.Data.TitleEnglish, - mapping.TMDB, - anime.Data.MALID, - anime.Data.TitleJapanese, - anime.Data.Score, - ) - if tmdbErr != nil { - logger.Log(fmt.Sprintf("Failed to get movie from TMDB: %v, falling back to basic episode", tmdbErr), logger.LogOptions{ - Level: logger.Warn, - Prefix: "AnimeAPI", - }) - // Fallback to basic episode generation - basicEpisodes := generateBasicEpisodes(anime.Data.MALID, episodes.Data) - enrichedEpisodes = basicEpisodes - } - } else { - // For TV shows, prefer TVDB over TMDB - var usedfallback bool - - if mapping.TVDB != 0 { - // Try TVDB first for TV shows - logger.Log(fmt.Sprintf("Using TVDB for TV show episodes (TVDB ID: %d)", mapping.TVDB), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - - tvdbEpisodes, tvdbErr := tvdb.GetSeriesEpisodes(mapping.TVDB) - if tvdbErr == nil && len(tvdbEpisodes) > 0 { - enrichedEpisodes = tvdb.ConvertTVDBEpisodesToAnimeEpisodes(tvdbEpisodes) - logger.Log(fmt.Sprintf("Successfully fetched %d episodes from TVDB", len(enrichedEpisodes)), logger.LogOptions{ - Level: logger.Success, - Prefix: "TVDB", - }) - } else { - logger.Log(fmt.Sprintf("TVDB fetch failed or returned no episodes: %v, falling back to TMDB", tvdbErr), logger.LogOptions{ - Level: logger.Warn, - Prefix: "TVDB", - }) - usedfallback = true - } - } else { - logger.Log("No TVDB ID available, using TMDB for episodes", logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - usedfallback = true - } - - // Fallback to TMDB if TVDB failed or wasn't available - if usedfallback { - basicEpisodes := generateBasicEpisodes(anime.Data.MALID, episodes.Data) - logger.Log(fmt.Sprintf("Generated basic episodes: %d", len(basicEpisodes)), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - - logger.Log(fmt.Sprintf("Starting TMDB enrichment for %d episodes", len(basicEpisodes)), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - enrichStart := time.Now() - - enrichedEpisodes, tmdbErr = AttachEpisodeDescriptions(anime.Data.Title, basicEpisodes, anime.Data.TitleEnglish, mapping.TMDB) - - logger.Log(fmt.Sprintf("TMDB enrichment execution time: %s", time.Since(enrichStart)), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - } - } - - tmdbErrorChan <- tmdbErr - - // Get subbed and dubbed episode counts in bulk with a single API call (much faster) - subCount, dubCount := 0, 0 - searchTitle := anime.Data.Title - - startStreamingCheck := time.Now() - logger.Log("Fetching streaming episode counts...", logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - - var err error - - // Try primary title first - subCount, dubCount, err = s.streamingClient.GetStreamingCounts(searchTitle) - - // 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 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) - } - - // 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, - Prefix: "AnimeAPI", - }) - - episodeDataChan <- enrichedEpisodes - subbedCountChan <- subCount - dubbedCountChan <- dubCount - }() - - // Get seasons information if TVDB ID is available - seasonsStartTime := time.Now() - var seasons []types.AnimeSeason - if mapping.TVDB != 0 { - logger.Log(fmt.Sprintf("Finding season mappings for TVDB ID %d", mapping.TVDB), logger.LogOptions{ - Level: logger.Debug, - Prefix: "TVDB", - }) - seasonMappings, err := tvdb.FindSeasonMappings(mapping.TVDB) - if err == nil && len(seasonMappings) > 0 { - logger.Log(fmt.Sprintf("Found %d season mappings for TVDB ID %d", len(seasonMappings), mapping.TVDB), logger.LogOptions{ - Level: logger.Debug, - Prefix: "TVDB", - }) - seasons = s.getSeasonDetails(&seasonMappings, malID) - } - } - logger.Log(fmt.Sprintf("Seasons fetch time: %s", time.Since(seasonsStartTime)), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - - // Get logos data from MALSync data - logos := extractLogosFromMALSync(malSyncData) - - // Extract character data - characters := getAnimeCharacters(characterResponse) - - // Extract episode count, next airing episode, and schedule - var nextAiringEpisode types.AnimeAiringEpisode - var schedule []types.AnimeAiringEpisode - - if anilistAnime != nil { - nextAiringEpisode = getNextAiringEpisode(anilistAnime) - schedule = getAnimeSchedule(anilistAnime) - } - - // Wait for episode data to complete - logger.Log(fmt.Sprintf("Waiting for episode data processing (started %s ago)", time.Since(episodeProcessingStartTime)), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - episodeWaitStartTime := time.Now() - episodeData := <-episodeDataChan - subbedCount := <-subbedCountChan - dubbedCount := <-dubbedCountChan - tmdbError := <-tmdbErrorChan - logger.Log(fmt.Sprintf("Episode data wait time: %s (total episode processing time: %s)", - time.Since(episodeWaitStartTime), - time.Since(episodeProcessingStartTime)), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - - // Assemble final anime details - animeDetails := &types.Anime{ - MALID: malID, - Titles: types.AnimeTitles{ - Romaji: anime.Data.Title, - English: anime.Data.TitleEnglish, - Japanese: anime.Data.TitleJapanese, - Synonyms: anime.Data.TitleSynonyms, - }, - Synopsis: anime.Data.Synopsis, - Type: types.AniSyncType(mapping.Type), - Source: anime.Data.Source, - Airing: anime.Data.Airing, - Status: anime.Data.Status, - AiringStatus: types.AiringStatus{ - From: types.AiringStatusDates{ - Day: anime.Data.Aired.Prop.From.Day, - Month: anime.Data.Aired.Prop.From.Month, - Year: anime.Data.Aired.Prop.From.Year, - String: anime.Data.Aired.From, - }, - To: types.AiringStatusDates{ - Day: anime.Data.Aired.Prop.To.Day, - Month: anime.Data.Aired.Prop.To.Month, - Year: anime.Data.Aired.Prop.To.Year, - String: anime.Data.Aired.To, - }, - String: anime.Data.Aired.String, - }, - Duration: anime.Data.Duration, - Images: types.AnimeImages{ - Small: anime.Data.Images.JPG.SmallImageURL, - Large: anime.Data.Images.JPG.LargeImageURL, - Original: anime.Data.Images.JPG.ImageURL, - }, - Logos: logos, - Covers: types.AnimeImages{}, - Color: "", - Genres: generateGenres(anime.Data.Genres, anime.Data.ExplicitGenres), - Scores: types.AnimeScores{ - Score: anime.Data.Score, - ScoredBy: anime.Data.ScoredBy, - Rank: anime.Data.Rank, - Popularity: anime.Data.Popularity, - Members: anime.Data.Members, - Favorites: anime.Data.Favorites, - }, - Season: anime.Data.Season, - Year: anime.Data.Year, - Broadcast: types.AnimeBroadcast{ - Day: anime.Data.Broadcast.Day, - Time: anime.Data.Broadcast.Time, - Timezone: anime.Data.Broadcast.Timezone, - String: anime.Data.Broadcast.String, - }, - Producers: generateProducers(anime.Data.Producers), - Studios: generateStudios(anime.Data.Studios), - Licensors: generateLicensors(anime.Data.Licensors), - Seasons: seasons, - Episodes: types.AnimeEpisodes{ - Total: getEpisodeCountWithAiredFallback(anime, anilistAnime, len(episodes.Data)), - Aired: len(episodes.Data), - Subbed: subbedCount, - Dubbed: dubbedCount, - Episodes: episodeData, - }, - NextAiringEpisode: nextAiringEpisode, - AiringSchedule: schedule, - Characters: characters, - 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, - }, - } - - // Add AniList cover images and color if available - if anilistAnime != nil && anilistAnime.Data.Media.ID > 0 { - logger.Log("Setting covers and color from AniList data", logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - - // Create debug logs for the data - coverImage := anilistAnime.Data.Media.CoverImage - - // Explicitly set the cover images, ensuring we don't have empty values - animeDetails.Covers = types.AnimeImages{ - Small: coverImage.Medium, - Large: coverImage.Large, - Original: coverImage.ExtraLarge, - } - - // For color, also make sure it's not empty - if coverImage.Color != "" { - animeDetails.Color = coverImage.Color - logger.Log(fmt.Sprintf("Set color to: %s", coverImage.Color), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - } - } else { - logger.Log("No valid AniList data available for covers and color", logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeAPI", - }) - } - - // Save the anime to database only if TMDB didn't fail - if tmdbError == nil { - go func() { - if err := database.SaveAnimeToDatabase(animeDetails); err != nil { - logger.Log(fmt.Sprintf("Failed to save anime to database: %v", err), logger.LogOptions{ - Level: logger.Error, - Prefix: "AnimeDB", - }) - } else { - logger.Log(fmt.Sprintf("Successfully saved anime (MAL ID: %d) to database", malID), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeDB", - }) - } - }() - } else { - logger.Log(fmt.Sprintf("Skipping anime database save due to TMDB error: %v", tmdbError), logger.LogOptions{ - Level: logger.Warn, - Prefix: "AnimeDB", - }) - } - - return animeDetails, nil -} - -// GetAnimeDetails fetches comprehensive anime details -func (s *Service) GetAnimeDetails(mapping *entities.AnimeMapping) (*types.Anime, error) { - return s.GetAnimeDetailsWithSource(mapping, "api") -} - -// GetAnimeByGenre fetches anime list by genre with pagination -func (s *Service) GetAnimeByGenre(genreID int, page int, limit int) ([]types.Anime, struct { - LastVisiblePage int `json:"last_visible_page"` - HasNextPage bool `json:"has_next_page"` - CurrentPage int `json:"current_page"` - Items struct { - Count int `json:"count"` - Total int `json:"total"` - PerPage int `json:"per_page"` - } `json:"items"` -}, error) { - // Fetch anime list from Jikan - response, err := s.jikanClient.GetAnimeByGenre(genreID, page, limit) - if err != nil { - return nil, struct { - LastVisiblePage int `json:"last_visible_page"` - HasNextPage bool `json:"has_next_page"` - CurrentPage int `json:"current_page"` - Items struct { - Count int `json:"count"` - Total int `json:"total"` - PerPage int `json:"per_page"` - } `json:"items"` - }{}, fmt.Errorf("failed to fetch anime by genre: %w", err) - } - - animeList := make([]types.Anime, 0, len(response.Data)) - stalenessThreshold := 7 * 24 * time.Hour // 7 days - - // Process each anime - check DB first, fetch only if missing/stale - for _, item := range response.Data { - // Try to get from database first - cachedAnime, err := database.GetAnimeByMALID(item.MALID) - if err == nil && cachedAnime != nil { - // Check if data is fresh (updated within last 7 days) - var dbAnime entities.Anime - if dbErr := database.DB.Where("mal_id = ?", item.MALID).First(&dbAnime).Error; dbErr == nil { - if time.Since(dbAnime.LastUpdated) < stalenessThreshold { - // Data is fresh, use cached version - cachedAnime.Seasons = nil - cachedAnime.Episodes.Episodes = nil - cachedAnime.NextAiringEpisode = types.AnimeAiringEpisode{} - cachedAnime.AiringSchedule = nil - cachedAnime.Characters = nil - animeList = append(animeList, *cachedAnime) - continue - } - } - } - - // Data is missing or stale, fetch from API - mapping, err := database.GetAnimeMappingViaMALID(item.MALID) - if err != nil { - mapping = &entities.AnimeMapping{MAL: item.MALID} - } - - fullAnime, err := s.GetAnimeDetailsWithSource(mapping, "genre_listing") - if err != nil { - logger.Log(fmt.Sprintf("Failed to fetch full anime for MAL ID %d: %v", item.MALID, err), logger.LogOptions{ - Level: logger.Error, - Prefix: "AnimeService", - }) - // If fetch fails but we have cached data (even if stale), use it - if cachedAnime != nil { - cachedAnime.Seasons = nil - cachedAnime.Episodes.Episodes = nil - cachedAnime.NextAiringEpisode = types.AnimeAiringEpisode{} - cachedAnime.AiringSchedule = nil - cachedAnime.Characters = nil - animeList = append(animeList, *cachedAnime) - } - continue - } - - // Clear fields not needed in genre listing (omitempty will handle JSON exclusion) - fullAnime.Seasons = nil - fullAnime.Episodes.Episodes = nil - fullAnime.NextAiringEpisode = types.AnimeAiringEpisode{} - fullAnime.AiringSchedule = nil - fullAnime.Characters = nil - - animeList = append(animeList, *fullAnime) - } - - return animeList, response.Pagination, nil -} - -// 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 - cached, err := database.GetEpisodeStreaming(episodeID, animeID) - if err == nil && cached != nil { - logger.Log(fmt.Sprintf("Using cached streaming data for episode %d", episodeNumber), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeService", - }) - - result := &types.AnimeStreaming{ - Sub: make([]types.AnimeStreamingSource, len(cached.SubSources)), - Dub: make([]types.AnimeStreamingSource, len(cached.DubSources)), - } - - for i, source := range cached.SubSources { - result.Sub[i] = types.AnimeStreamingSource{ - URL: source.URL, - Server: source.Server, - Type: source.Type, - } - } - - for i, source := range cached.DubSources { - result.Dub[i] = types.AnimeStreamingSource{ - URL: source.URL, - Server: source.Server, - Type: source.Type, - } - } - - return result, nil - } - - // If not in cache or stale, fetch from API - logger.Log(fmt.Sprintf("Fetching fresh streaming data for episode %d from API", episodeNumber), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeService", - }) - - streaming, err := s.streamingClient.GetStreamingSources(title, episodeNumber) - if err != nil { - return nil, fmt.Errorf("failed to get streaming sources: %w", err) - } - - // Convert streaming API type to types package type - result := &types.AnimeStreaming{ - Sub: make([]types.AnimeStreamingSource, len(streaming.Sub)), - Dub: make([]types.AnimeStreamingSource, len(streaming.Dub)), - } - - for i, source := range streaming.Sub { - result.Sub[i] = types.AnimeStreamingSource{ - URL: source.URL, - Server: source.Server, - Type: source.Type, - } - } - - for i, source := range streaming.Dub { - result.Dub[i] = types.AnimeStreamingSource{ - URL: source.URL, - Server: source.Server, - Type: source.Type, - } - } - - // Save to database for future requests - if err := database.SaveEpisodeStreaming(episodeID, animeID, result.Sub, result.Dub); err != nil { - logger.Log(fmt.Sprintf("Failed to cache streaming data: %v", err), logger.LogOptions{ - Level: logger.Warn, - Prefix: "AnimeService", - }) - } else { - logger.Log(fmt.Sprintf("Cached streaming data for episode %d", episodeNumber), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeService", - }) - } - - return result, nil -} - -// getSeasonDetails fetches details for anime seasons -func (s *Service) getSeasonDetails(mappings *[]entities.AnimeMapping, currentMALID int) []types.AnimeSeason { - // Helper function to fetch anime details for a single mapping - fetchSeason := func(mapping entities.AnimeMapping, isCurrent bool) (types.AnimeSeason, error) { - anime, err := s.jikanClient.GetAnime(mapping.MAL) - if err != nil { - return types.AnimeSeason{}, err - } - - return types.AnimeSeason{ - MALID: mapping.MAL, - Titles: types.AnimeTitles{ - English: anime.Data.TitleEnglish, - Japanese: anime.Data.TitleJapanese, - Romaji: anime.Data.Title, - Synonyms: anime.Data.TitleSynonyms, - }, - Synopsis: anime.Data.Synopsis, - Type: types.AniSyncType(mapping.Type), - Source: anime.Data.Source, - Airing: anime.Data.Airing, - Status: anime.Data.Status, - AiringStatus: types.AiringStatus{ - From: types.AiringStatusDates{ - Day: anime.Data.Aired.Prop.From.Day, - Month: anime.Data.Aired.Prop.From.Month, - Year: anime.Data.Aired.Prop.From.Year, - String: anime.Data.Aired.From, - }, - To: types.AiringStatusDates{ - Day: anime.Data.Aired.Prop.To.Day, - Month: anime.Data.Aired.Prop.To.Month, - Year: anime.Data.Aired.Prop.To.Year, - String: anime.Data.Aired.To, - }, - String: anime.Data.Aired.String, - }, - Duration: anime.Data.Duration, - Images: types.AnimeImages{ - Small: anime.Data.Images.JPG.SmallImageURL, - Large: anime.Data.Images.JPG.LargeImageURL, - Original: anime.Data.Images.JPG.ImageURL, - }, - Scores: types.AnimeScores{ - Score: anime.Data.Score, - ScoredBy: anime.Data.ScoredBy, - Rank: anime.Data.Rank, - Popularity: anime.Data.Popularity, - Members: anime.Data.Members, - Favorites: anime.Data.Favorites, - }, - Season: anime.Data.Season, - Year: anime.Data.Year, - Current: isCurrent, - }, nil - } - - // Fetch all seasons in parallel - seasonFunctions := make([]func() (types.AnimeSeason, error), len(*mappings)) - - for i, mapping := range *mappings { - mapping := mapping // Capture variable for closure - isCurrent := mapping.MAL == currentMALID - - seasonFunctions[i] = func() (types.AnimeSeason, error) { - return fetchSeason(mapping, isCurrent) - } - } - - // Execute in parallel - results := concurrency.Parallel(seasonFunctions...) - - // Extract successful results - var seasons []types.AnimeSeason - for _, result := range results { - if result.Error == nil { - seasons = append(seasons, result.Value) - } - } - - // Sort seasons chronologically by air date - if len(seasons) > 1 { - sortSeasonsByAirDate(&seasons) - } - - return seasons -} +// import ( +// "fmt" +// "metachan/database" +// "metachan/entities" +// "metachan/types" +// "metachan/utils/api/anilist" +// "metachan/utils/api/jikan" +// "metachan/utils/api/malsync" +// "metachan/utils/api/streaming" +// "metachan/utils/api/tmdb" +// "metachan/utils/api/tvdb" +// "metachan/utils/concurrency" +// "metachan/utils/logger" +// "strings" +// "time" +// ) + +// // Service provides high-level operations for anime data +// type Service struct { +// jikanClient *jikan.JikanClient +// streamingClient *streaming.AllAnimeClient +// anilistClient *anilist.AniListClient +// malsyncClient *malsync.MALSyncClient +// } + +// // NewService creates a new anime service +// func NewService() *Service { +// return &Service{ +// jikanClient: jikan.NewJikanClient(), +// streamingClient: streaming.NewAllAnimeClient(), +// anilistClient: anilist.NewAniListClient(), +// malsyncClient: malsync.NewMALSyncClient(), +// } +// } + +// // GetAnimeDetailsWithSource fetches comprehensive anime details with source information +// func (s *Service) GetAnimeDetailsWithSource(mapping *entities.AnimeMapping, source string) (*types.Anime, error) { +// if mapping == nil { +// return nil, fmt.Errorf("anime mapping is nil") +// } + +// startTime := time.Now() +// defer func() { +// duration := time.Since(startTime) +// logger.Log(fmt.Sprintf("GetAnimeDetails (%s) execution time: %s", source, duration), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// }() + +// malID := mapping.MAL + +// // For updater source, always fetch fresh data and skip cache +// if source != "updater" { +// // First, check if we have an existing version in the database +// anime, err := database.GetAnimeByMALID(malID) +// if err == nil { +// logger.Log(fmt.Sprintf("Found existing anime data (MAL ID: %d), returning stored data", malID), logger.LogOptions{ +// Level: logger.Info, +// Prefix: "AnimeDB", +// }) + +// // Ensure mappings are attached properly +// anime.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 anime, nil +// } +// } else { +// logger.Log(fmt.Sprintf("Bypassing database check for anime (MAL ID: %d) - source: %s", malID, source), logger.LogOptions{ +// Level: logger.Info, +// Prefix: "AnimeAPI", +// }) +// } + +// // Rest of the implementation is the same as GetAnimeDetails +// logger.Log(fmt.Sprintf("No existing data for anime (MAL ID: %d), fetching fresh data", malID), logger.LogOptions{ +// Level: logger.Info, +// Prefix: "AnimeAPI", +// }) + +// // Create the different types of functions for proper Go generic type inference +// animeFunc := func() (*jikan.JikanAnimeResponse, error) { +// return s.jikanClient.GetFullAnime(malID) +// } + +// episodesFunc := func() (*jikan.JikanAnimeEpisodeResponse, error) { +// return s.jikanClient.GetAnimeEpisodes(malID) +// } + +// charactersFunc := func() (*jikan.JikanAnimeCharacterResponse, error) { +// return s.jikanClient.GetAnimeCharacters(malID) +// } + +// fetchStartTime := time.Now() +// // Use separate results variables for each type +// animeResult := concurrency.Parallel(animeFunc)[0] +// episodesResult := concurrency.Parallel(episodesFunc)[0] +// charactersResult := concurrency.Parallel(charactersFunc)[0] +// logger.Log(fmt.Sprintf("Initial parallel API fetch time: %s", time.Since(fetchStartTime)), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) + +// // Extract results and handle errors +// anime := animeResult.Value +// if animeResult.Error != nil { +// return nil, fmt.Errorf("failed to get anime details: %w", animeResult.Error) +// } + +// episodes := episodesResult.Value +// if episodesResult.Error != nil { +// return nil, fmt.Errorf("failed to get anime episodes: %w", episodesResult.Error) +// } + +// characterResponse := charactersResult.Value +// if charactersResult.Error != nil { +// return nil, fmt.Errorf("failed to get anime characters: %w", charactersResult.Error) +// } + +// // Get Anilist and MALSync data in parallel if available +// var anilistAnime *anilist.AnilistAnimeResponse +// var malSyncData *malsync.MALSyncAnimeResponse + +// anilistStartTime := time.Now() +// if mapping.Anilist != 0 { +// // We need separate functions for each type for proper type inference +// anilistFunc := func() (*anilist.AnilistAnimeResponse, error) { +// return s.anilistClient.GetAnime(mapping.Anilist) +// } + +// malsyncFunc := func() (*malsync.MALSyncAnimeResponse, error) { +// return s.malsyncClient.GetAnimeByMALID(malID) +// } + +// // Execute them separately to avoid type errors +// anilistResult := concurrency.Parallel(anilistFunc)[0] +// malsyncResult := concurrency.Parallel(malsyncFunc)[0] + +// // Extract AniList result +// if anilistResult.Error == nil { +// anilistAnime = anilistResult.Value +// logger.Log(fmt.Sprintf("Successfully fetched AniList data for ID %d", mapping.Anilist), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// } else { +// logger.Log(fmt.Sprintf("Failed to fetch AniList data: %v", anilistResult.Error), logger.LogOptions{ +// Level: logger.Warn, +// Prefix: "AnimeAPI", +// }) +// } + +// // Extract MALSync result +// if malsyncResult.Error == nil { +// malSyncData = malsyncResult.Value +// } else { +// logger.Log(fmt.Sprintf("Failed to fetch MALSync data: %v", malsyncResult.Error), logger.LogOptions{ +// Level: logger.Warn, +// Prefix: "AnimeAPI", +// }) +// } +// } else { +// logger.Log(fmt.Sprintf("No AniList ID available for MAL ID %d", malID), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// // If no AniList ID, just fetch MALSync data +// malSyncData, _ = s.malsyncClient.GetAnimeByMALID(malID) +// } +// logger.Log(fmt.Sprintf("AniList and MALSync fetch time: %s", time.Since(anilistStartTime)), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) + +// // Process episode data in parallel with seasons and other operations +// episodeDataChan := make(chan []types.AnimeSingleEpisode, 1) +// subbedCountChan := make(chan int, 1) +// dubbedCountChan := make(chan int, 1) +// tmdbErrorChan := make(chan error, 1) + +// episodeProcessingStartTime := time.Now() +// go func() { +// defer close(episodeDataChan) +// defer close(subbedCountChan) +// defer close(dubbedCountChan) +// defer close(tmdbErrorChan) + +// var enrichedEpisodes []types.AnimeSingleEpisode +// var tmdbErr error + +// // Check anime type - use different sources for movies vs TV shows +// animeType := string(mapping.Type) + +// if (animeType == "MOVIE" || animeType == "Movie") && mapping.TMDB != 0 { +// // For movies with TMDB mapping, use TMDB to get movie details as a single episode +// logger.Log(fmt.Sprintf("Detected movie type with TMDB ID %d, fetching from TMDB for: %s", mapping.TMDB, anime.Data.Title), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) + +// enrichedEpisodes, tmdbErr = tmdb.GetMovieAsEpisode( +// anime.Data.Title, +// anime.Data.TitleEnglish, +// mapping.TMDB, +// anime.Data.MALID, +// anime.Data.TitleJapanese, +// anime.Data.Score, +// ) +// if tmdbErr != nil { +// logger.Log(fmt.Sprintf("Failed to get movie from TMDB: %v, falling back to basic episode", tmdbErr), logger.LogOptions{ +// Level: logger.Warn, +// Prefix: "AnimeAPI", +// }) +// // Fallback to basic episode generation +// basicEpisodes := generateBasicEpisodes(anime.Data.MALID, episodes.Data) +// enrichedEpisodes = basicEpisodes +// } +// } else { +// // For TV shows, prefer TVDB over TMDB +// var usedfallback bool + +// if mapping.TVDB != 0 { +// // Try TVDB first for TV shows +// logger.Log(fmt.Sprintf("Using TVDB for TV show episodes (TVDB ID: %d)", mapping.TVDB), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) + +// tvdbEpisodes, tvdbErr := tvdb.GetSeriesEpisodes(mapping.TVDB) +// if tvdbErr == nil && len(tvdbEpisodes) > 0 { +// enrichedEpisodes = tvdb.ConvertTVDBEpisodesToAnimeEpisodes(tvdbEpisodes) +// logger.Log(fmt.Sprintf("Successfully fetched %d episodes from TVDB", len(enrichedEpisodes)), logger.LogOptions{ +// Level: logger.Success, +// Prefix: "TVDB", +// }) +// } else { +// logger.Log(fmt.Sprintf("TVDB fetch failed or returned no episodes: %v, falling back to TMDB", tvdbErr), logger.LogOptions{ +// Level: logger.Warn, +// Prefix: "TVDB", +// }) +// usedfallback = true +// } +// } else { +// logger.Log("No TVDB ID available, using TMDB for episodes", logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// usedfallback = true +// } + +// // Fallback to TMDB if TVDB failed or wasn't available +// if usedfallback { +// basicEpisodes := generateBasicEpisodes(anime.Data.MALID, episodes.Data) +// logger.Log(fmt.Sprintf("Generated basic episodes: %d", len(basicEpisodes)), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) + +// logger.Log(fmt.Sprintf("Starting TMDB enrichment for %d episodes", len(basicEpisodes)), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// enrichStart := time.Now() + +// enrichedEpisodes, tmdbErr = AttachEpisodeDescriptions(anime.Data.Title, basicEpisodes, anime.Data.TitleEnglish, mapping.TMDB) + +// logger.Log(fmt.Sprintf("TMDB enrichment execution time: %s", time.Since(enrichStart)), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// } +// } + +// tmdbErrorChan <- tmdbErr + +// // Get subbed and dubbed episode counts in bulk with a single API call (much faster) +// subCount, dubCount := 0, 0 +// searchTitle := anime.Data.Title + +// startStreamingCheck := time.Now() +// logger.Log("Fetching streaming episode counts...", logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) + +// var err error + +// // Try primary title first +// subCount, dubCount, err = s.streamingClient.GetStreamingCounts(searchTitle) + +// // 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 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) +// } + +// // 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, +// Prefix: "AnimeAPI", +// }) + +// episodeDataChan <- enrichedEpisodes +// subbedCountChan <- subCount +// dubbedCountChan <- dubCount +// }() + +// // Get seasons information if TVDB ID is available +// seasonsStartTime := time.Now() +// var seasons []types.AnimeSeason +// if mapping.TVDB != 0 { +// logger.Log(fmt.Sprintf("Finding season mappings for TVDB ID %d", mapping.TVDB), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "TVDB", +// }) +// seasonMappings, err := tvdb.FindSeasonMappings(mapping.TVDB) +// if err == nil && len(seasonMappings) > 0 { +// logger.Log(fmt.Sprintf("Found %d season mappings for TVDB ID %d", len(seasonMappings), mapping.TVDB), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "TVDB", +// }) +// seasons = s.getSeasonDetails(&seasonMappings, malID) +// } +// } +// logger.Log(fmt.Sprintf("Seasons fetch time: %s", time.Since(seasonsStartTime)), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) + +// // Get logos data from MALSync data +// logos := extractLogosFromMALSync(malSyncData) + +// // Extract character data +// characters := getAnimeCharacters(characterResponse) + +// // Extract episode count, next airing episode, and schedule +// var nextAiringEpisode types.AnimeAiringEpisode +// var schedule []types.AnimeAiringEpisode + +// if anilistAnime != nil { +// nextAiringEpisode = getNextAiringEpisode(anilistAnime) +// schedule = getAnimeSchedule(anilistAnime) +// } + +// // Wait for episode data to complete +// logger.Log(fmt.Sprintf("Waiting for episode data processing (started %s ago)", time.Since(episodeProcessingStartTime)), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// episodeWaitStartTime := time.Now() +// episodeData := <-episodeDataChan +// subbedCount := <-subbedCountChan +// dubbedCount := <-dubbedCountChan +// tmdbError := <-tmdbErrorChan +// logger.Log(fmt.Sprintf("Episode data wait time: %s (total episode processing time: %s)", +// time.Since(episodeWaitStartTime), +// time.Since(episodeProcessingStartTime)), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) + +// // Assemble final anime details +// animeDetails := &types.Anime{ +// MALID: malID, +// Titles: types.AnimeTitles{ +// Romaji: anime.Data.Title, +// English: anime.Data.TitleEnglish, +// Japanese: anime.Data.TitleJapanese, +// Synonyms: anime.Data.TitleSynonyms, +// }, +// Synopsis: anime.Data.Synopsis, +// Type: types.AniSyncType(mapping.Type), +// Source: anime.Data.Source, +// Airing: anime.Data.Airing, +// Status: anime.Data.Status, +// AiringStatus: types.AiringStatus{ +// From: types.AiringStatusDates{ +// Day: anime.Data.Aired.Prop.From.Day, +// Month: anime.Data.Aired.Prop.From.Month, +// Year: anime.Data.Aired.Prop.From.Year, +// String: anime.Data.Aired.From, +// }, +// To: types.AiringStatusDates{ +// Day: anime.Data.Aired.Prop.To.Day, +// Month: anime.Data.Aired.Prop.To.Month, +// Year: anime.Data.Aired.Prop.To.Year, +// String: anime.Data.Aired.To, +// }, +// String: anime.Data.Aired.String, +// }, +// Duration: anime.Data.Duration, +// Images: types.AnimeImages{ +// Small: anime.Data.Images.JPG.SmallImageURL, +// Large: anime.Data.Images.JPG.LargeImageURL, +// Original: anime.Data.Images.JPG.ImageURL, +// }, +// Logos: logos, +// Covers: types.AnimeImages{}, +// Color: "", +// Genres: generateGenres(anime.Data.Genres, anime.Data.ExplicitGenres), +// Scores: types.AnimeScores{ +// Score: anime.Data.Score, +// ScoredBy: anime.Data.ScoredBy, +// Rank: anime.Data.Rank, +// Popularity: anime.Data.Popularity, +// Members: anime.Data.Members, +// Favorites: anime.Data.Favorites, +// }, +// Season: anime.Data.Season, +// Year: anime.Data.Year, +// Broadcast: types.AnimeBroadcast{ +// Day: anime.Data.Broadcast.Day, +// Time: anime.Data.Broadcast.Time, +// Timezone: anime.Data.Broadcast.Timezone, +// String: anime.Data.Broadcast.String, +// }, +// Producers: generateProducers(anime.Data.Producers), +// Studios: generateStudios(anime.Data.Studios), +// Licensors: generateLicensors(anime.Data.Licensors), +// Seasons: seasons, +// Episodes: types.AnimeEpisodes{ +// Total: getEpisodeCountWithAiredFallback(anime, anilistAnime, len(episodes.Data)), +// Aired: len(episodes.Data), +// Subbed: subbedCount, +// Dubbed: dubbedCount, +// Episodes: episodeData, +// }, +// NextAiringEpisode: nextAiringEpisode, +// AiringSchedule: schedule, +// Characters: characters, +// 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, +// }, +// } + +// // Add AniList cover images and color if available +// if anilistAnime != nil && anilistAnime.Data.Media.ID > 0 { +// logger.Log("Setting covers and color from AniList data", logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) + +// // Create debug logs for the data +// coverImage := anilistAnime.Data.Media.CoverImage + +// // Explicitly set the cover images, ensuring we don't have empty values +// animeDetails.Covers = types.AnimeImages{ +// Small: coverImage.Medium, +// Large: coverImage.Large, +// Original: coverImage.ExtraLarge, +// } + +// // For color, also make sure it's not empty +// if coverImage.Color != "" { +// animeDetails.Color = coverImage.Color +// logger.Log(fmt.Sprintf("Set color to: %s", coverImage.Color), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// } +// } else { +// logger.Log("No valid AniList data available for covers and color", logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeAPI", +// }) +// } + +// // Save the anime to database only if TMDB didn't fail +// if tmdbError == nil { +// go func() { +// if err := database.SaveAnimeToDatabase(animeDetails); err != nil { +// logger.Log(fmt.Sprintf("Failed to save anime to database: %v", err), logger.LogOptions{ +// Level: logger.Error, +// Prefix: "AnimeDB", +// }) +// } else { +// logger.Log(fmt.Sprintf("Successfully saved anime (MAL ID: %d) to database", malID), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeDB", +// }) +// } +// }() +// } else { +// logger.Log(fmt.Sprintf("Skipping anime database save due to TMDB error: %v", tmdbError), logger.LogOptions{ +// Level: logger.Warn, +// Prefix: "AnimeDB", +// }) +// } + +// return animeDetails, nil +// } + +// // GetAnimeDetails fetches comprehensive anime details +// func (s *Service) GetAnimeDetails(mapping *entities.AnimeMapping) (*types.Anime, error) { +// return s.GetAnimeDetailsWithSource(mapping, "api") +// } + +// // GetAnimeByGenre fetches anime list by genre with pagination +// func (s *Service) GetAnimeByGenre(genreID int, page int, limit int) ([]types.Anime, struct { +// LastVisiblePage int `json:"last_visible_page"` +// HasNextPage bool `json:"has_next_page"` +// CurrentPage int `json:"current_page"` +// Items struct { +// Count int `json:"count"` +// Total int `json:"total"` +// PerPage int `json:"per_page"` +// } `json:"items"` +// }, error) { +// // Fetch anime list from Jikan +// response, err := s.jikanClient.GetAnimeByGenre(genreID, page, limit) +// if err != nil { +// return nil, struct { +// LastVisiblePage int `json:"last_visible_page"` +// HasNextPage bool `json:"has_next_page"` +// CurrentPage int `json:"current_page"` +// Items struct { +// Count int `json:"count"` +// Total int `json:"total"` +// PerPage int `json:"per_page"` +// } `json:"items"` +// }{}, fmt.Errorf("failed to fetch anime by genre: %w", err) +// } + +// animeList := make([]types.Anime, 0, len(response.Data)) +// stalenessThreshold := 7 * 24 * time.Hour // 7 days + +// // Process each anime - check DB first, fetch only if missing/stale +// for _, item := range response.Data { +// // Try to get from database first +// cachedAnime, err := database.GetAnimeByMALID(item.MALID) +// if err == nil && cachedAnime != nil { +// // Check if data is fresh (updated within last 7 days) +// var dbAnime entities.Anime +// if dbErr := database.DB.Where("mal_id = ?", item.MALID).First(&dbAnime).Error; dbErr == nil { +// if time.Since(dbAnime.LastUpdated) < stalenessThreshold { +// // Data is fresh, use cached version +// cachedAnime.Seasons = nil +// cachedAnime.Episodes.Episodes = nil +// cachedAnime.NextAiringEpisode = types.AnimeAiringEpisode{} +// cachedAnime.AiringSchedule = nil +// cachedAnime.Characters = nil +// animeList = append(animeList, *cachedAnime) +// continue +// } +// } +// } + +// // Data is missing or stale, fetch from API +// mapping, err := database.GetAnimeMappingViaMALID(item.MALID) +// if err != nil { +// mapping = &entities.AnimeMapping{MAL: item.MALID} +// } + +// fullAnime, err := s.GetAnimeDetailsWithSource(mapping, "genre_listing") +// if err != nil { +// logger.Log(fmt.Sprintf("Failed to fetch full anime for MAL ID %d: %v", item.MALID, err), logger.LogOptions{ +// Level: logger.Error, +// Prefix: "AnimeService", +// }) +// // If fetch fails but we have cached data (even if stale), use it +// if cachedAnime != nil { +// cachedAnime.Seasons = nil +// cachedAnime.Episodes.Episodes = nil +// cachedAnime.NextAiringEpisode = types.AnimeAiringEpisode{} +// cachedAnime.AiringSchedule = nil +// cachedAnime.Characters = nil +// animeList = append(animeList, *cachedAnime) +// } +// continue +// } + +// // Clear fields not needed in genre listing (omitempty will handle JSON exclusion) +// fullAnime.Seasons = nil +// fullAnime.Episodes.Episodes = nil +// fullAnime.NextAiringEpisode = types.AnimeAiringEpisode{} +// fullAnime.AiringSchedule = nil +// fullAnime.Characters = nil + +// animeList = append(animeList, *fullAnime) +// } + +// return animeList, response.Pagination, nil +// } + +// // 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 +// cached, err := database.GetEpisodeStreaming(episodeID, animeID) +// if err == nil && cached != nil { +// logger.Log(fmt.Sprintf("Using cached streaming data for episode %d", episodeNumber), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeService", +// }) + +// result := &types.AnimeStreaming{ +// Sub: make([]types.AnimeStreamingSource, len(cached.SubSources)), +// Dub: make([]types.AnimeStreamingSource, len(cached.DubSources)), +// } + +// for i, source := range cached.SubSources { +// result.Sub[i] = types.AnimeStreamingSource{ +// URL: source.URL, +// Server: source.Server, +// Type: source.Type, +// } +// } + +// for i, source := range cached.DubSources { +// result.Dub[i] = types.AnimeStreamingSource{ +// URL: source.URL, +// Server: source.Server, +// Type: source.Type, +// } +// } + +// return result, nil +// } + +// // If not in cache or stale, fetch from API +// logger.Log(fmt.Sprintf("Fetching fresh streaming data for episode %d from API", episodeNumber), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeService", +// }) + +// streaming, err := s.streamingClient.GetStreamingSources(title, episodeNumber) +// if err != nil { +// return nil, fmt.Errorf("failed to get streaming sources: %w", err) +// } + +// // Convert streaming API type to types package type +// result := &types.AnimeStreaming{ +// Sub: make([]types.AnimeStreamingSource, len(streaming.Sub)), +// Dub: make([]types.AnimeStreamingSource, len(streaming.Dub)), +// } + +// for i, source := range streaming.Sub { +// result.Sub[i] = types.AnimeStreamingSource{ +// URL: source.URL, +// Server: source.Server, +// Type: source.Type, +// } +// } + +// for i, source := range streaming.Dub { +// result.Dub[i] = types.AnimeStreamingSource{ +// URL: source.URL, +// Server: source.Server, +// Type: source.Type, +// } +// } + +// // Save to database for future requests +// if err := database.SaveEpisodeStreaming(episodeID, animeID, result.Sub, result.Dub); err != nil { +// logger.Log(fmt.Sprintf("Failed to cache streaming data: %v", err), logger.LogOptions{ +// Level: logger.Warn, +// Prefix: "AnimeService", +// }) +// } else { +// logger.Log(fmt.Sprintf("Cached streaming data for episode %d", episodeNumber), logger.LogOptions{ +// Level: logger.Debug, +// Prefix: "AnimeService", +// }) +// } + +// return result, nil +// } + +// // getSeasonDetails fetches details for anime seasons +// func (s *Service) getSeasonDetails(mappings *[]entities.AnimeMapping, currentMALID int) []types.AnimeSeason { +// // Helper function to fetch anime details for a single mapping +// fetchSeason := func(mapping entities.AnimeMapping, isCurrent bool) (types.AnimeSeason, error) { +// anime, err := s.jikanClient.GetAnime(mapping.MAL) +// if err != nil { +// return types.AnimeSeason{}, err +// } + +// return types.AnimeSeason{ +// MALID: mapping.MAL, +// Titles: types.AnimeTitles{ +// English: anime.Data.TitleEnglish, +// Japanese: anime.Data.TitleJapanese, +// Romaji: anime.Data.Title, +// Synonyms: anime.Data.TitleSynonyms, +// }, +// Synopsis: anime.Data.Synopsis, +// Type: types.AniSyncType(mapping.Type), +// Source: anime.Data.Source, +// Airing: anime.Data.Airing, +// Status: anime.Data.Status, +// AiringStatus: types.AiringStatus{ +// From: types.AiringStatusDates{ +// Day: anime.Data.Aired.Prop.From.Day, +// Month: anime.Data.Aired.Prop.From.Month, +// Year: anime.Data.Aired.Prop.From.Year, +// String: anime.Data.Aired.From, +// }, +// To: types.AiringStatusDates{ +// Day: anime.Data.Aired.Prop.To.Day, +// Month: anime.Data.Aired.Prop.To.Month, +// Year: anime.Data.Aired.Prop.To.Year, +// String: anime.Data.Aired.To, +// }, +// String: anime.Data.Aired.String, +// }, +// Duration: anime.Data.Duration, +// Images: types.AnimeImages{ +// Small: anime.Data.Images.JPG.SmallImageURL, +// Large: anime.Data.Images.JPG.LargeImageURL, +// Original: anime.Data.Images.JPG.ImageURL, +// }, +// Scores: types.AnimeScores{ +// Score: anime.Data.Score, +// ScoredBy: anime.Data.ScoredBy, +// Rank: anime.Data.Rank, +// Popularity: anime.Data.Popularity, +// Members: anime.Data.Members, +// Favorites: anime.Data.Favorites, +// }, +// Season: anime.Data.Season, +// Year: anime.Data.Year, +// Current: isCurrent, +// }, nil +// } + +// // Fetch all seasons in parallel +// seasonFunctions := make([]func() (types.AnimeSeason, error), len(*mappings)) + +// for i, mapping := range *mappings { +// mapping := mapping // Capture variable for closure +// isCurrent := mapping.MAL == currentMALID + +// seasonFunctions[i] = func() (types.AnimeSeason, error) { +// return fetchSeason(mapping, isCurrent) +// } +// } + +// // Execute in parallel +// results := concurrency.Parallel(seasonFunctions...) + +// // Extract successful results +// var seasons []types.AnimeSeason +// for _, result := range results { +// if result.Error == nil { +// seasons = append(seasons, result.Value) +// } +// } + +// // Sort seasons chronologically by air date +// if len(seasons) > 1 { +// sortSeasonsByAirDate(&seasons) +// } + +// return seasons +// } diff --git a/tasks/anifetch.task.go b/tasks/anifetch.task.go index 894dd97..8d38da8 100644 --- a/tasks/anifetch.task.go +++ b/tasks/anifetch.task.go @@ -4,53 +4,43 @@ import ( "encoding/json" "fmt" "io" - "metachan/database" "metachan/entities" + "metachan/enums" + "metachan/repositories" "metachan/types" "metachan/utils/logger" "metachan/utils/mappers" "net/http" - - "gorm.io/gorm" ) -const fribbURL = "https://raw.githubusercontent.com/Fribb/anime-lists/master/anime-list-full.json" +const ( + fribbURL = "https://raw.githubusercontent.com/Fribb/anime-lists/master/anime-list-full.json" + batchSize = 1000 +) func AniFetch() error { - logger.Log("Starting Anime Fetch", logger.LogOptions{ - Level: logger.Info, - Prefix: "AniFetch", - }) + logger.Infof("AniFetch", "Starting Anime Fetch") response, err := http.Get(fribbURL) if err != nil { - logger.Log(fmt.Sprintf("Anime Fetch failed: %v", err), logger.LogOptions{ - Level: logger.Error, - Prefix: "AniFetch", - }) + logger.Errorf("AniFetch", "Anime Fetch failed: %v", err) return err } + defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { - logger.Log(fmt.Sprintf("Failed to read response body: %v", err), logger.LogOptions{ - Level: logger.Error, - Prefix: "AniFetch", - }) + logger.Errorf("AniFetch", "Failed to read response body: %v", err) return err } - var mappings []types.AniSyncMapping + var mappings []types.MappingResponse if err := json.Unmarshal(body, &mappings); err != nil { - logger.Log(fmt.Sprintf("Failed to unmarshal JSON: %v", err), logger.LogOptions{ - Level: logger.Error, - Prefix: "AniFetch", - }) + logger.Errorf("AniFetch", "Failed to unmarshal JSON: %v", err) return err } - batchSize := 1000 total := len(mappings) for i := 0; i < total; i += batchSize { @@ -61,21 +51,15 @@ func AniFetch() error { batch := mappings[i:end] processBatch(batch) - logger.Log(fmt.Sprintf("Processed %d/%d mappings", end, total), logger.LogOptions{ - Level: logger.Info, - Prefix: "AniFetch", - }) + logger.Infof("AniFetch", "Processed %d/%d mappings", end, total) } - logger.Log("Anime Fetch completed", logger.LogOptions{ - Level: logger.Success, - Prefix: "AniFetch", - }) + logger.Successf("AniFetch", "Anime Fetch completed") return nil } -func processBatch(mappings []types.AniSyncMapping) { +func processBatch(mappings []types.MappingResponse) { for _, mapping := range mappings { var composite *string if mapping.MAL != 0 && mapping.Anilist != 0 { @@ -83,61 +67,26 @@ func processBatch(mappings []types.AniSyncMapping) { composite = &comp } - var entity entities.AnimeMapping - if err := database.DB.Where("mal_anilist_composite = ?", composite).First(&entity).Error; err != nil { - if err == gorm.ErrRecordNotFound { - newEntity := entities.AnimeMapping{ - AniDB: mapping.AniDB, - Anilist: mapping.Anilist, - AnimeCountdown: mapping.AnimeCountdown, - AnimePlanet: mappers.ForceString(mapping.AnimePlanet), - AniSearch: mapping.AniSearch, - IMDB: mapping.IMDB, - Kitsu: mapping.Kitsu, - LiveChart: mapping.LiveChart, - MAL: mapping.MAL, - NotifyMoe: mapping.NotifyMoe, - Simkl: mapping.Simkl, - TMDB: mappers.ForceInt(mapping.TMDB), - TVDB: mapping.TVDB, - Type: entities.MappingType(mapping.Type), - MALAnilistComposite: composite, - } - if err := database.DB.Create(&newEntity).Error; err != nil { - logger.Log(fmt.Sprintf("Unable to process mapping %v: %v", mapping, err), logger.LogOptions{ - Level: logger.Warn, - Prefix: "AniFetch", - }) - } - } else { - logger.Log(fmt.Sprintf("Error fetching entity: %v", err), logger.LogOptions{ - Level: logger.Error, - Prefix: "AniFetch", - }) - } - } else { - // Update existing entity - entity.AniDB = mapping.AniDB - entity.Anilist = mapping.Anilist - entity.AnimeCountdown = mapping.AnimeCountdown - entity.AnimePlanet = mappers.ForceString(mapping.AnimePlanet) - entity.AniSearch = mapping.AniSearch - entity.IMDB = mapping.IMDB - entity.Kitsu = mapping.Kitsu - entity.LiveChart = mapping.LiveChart - entity.MAL = mapping.MAL - entity.NotifyMoe = mapping.NotifyMoe - entity.Simkl = mapping.Simkl - entity.TMDB = mappers.ForceInt(mapping.TMDB) - entity.TVDB = mapping.TVDB - entity.Type = entities.MappingType(mapping.Type) - entity.MALAnilistComposite = composite - if err := database.DB.Save(&entity).Error; err != nil { - logger.Log(fmt.Sprintf("Unable to update mapping %v: %v", mapping, err), logger.LogOptions{ - Level: logger.Warn, - Prefix: "AniFetch", - }) - } + entity := entities.Mapping{ + AniDB: mapping.AniDB, + Anilist: mapping.Anilist, + AnimeCountdown: mapping.AnimeCountdown, + AnimePlanet: mappers.ForceString(mapping.AnimePlanet), + AniSearch: mapping.AniSearch, + IMDB: mapping.IMDB, + Kitsu: mapping.Kitsu, + LiveChart: mapping.LiveChart, + MAL: mapping.MAL, + NotifyMoe: mapping.NotifyMoe, + Simkl: mapping.Simkl, + TMDB: mappers.ForceInt(mapping.TMDB), + TVDB: mapping.TVDB, + Type: enums.MappingAnimeType(mapping.Type), + MALAnilistComposite: composite, + } + + if err := repositories.CreateOrUpdateMapping(&entity); err != nil { + logger.Warnf("AniFetch", "Unable to process mapping %v: %v", mapping, err) } } } diff --git a/tasks/anisync.task.go b/tasks/anisync.task.go index dc98d3e..0a346ac 100644 --- a/tasks/anisync.task.go +++ b/tasks/anisync.task.go @@ -1,145 +1,69 @@ package tasks import ( - "fmt" - "metachan/database" - "metachan/entities" - "metachan/services/anime" + "metachan/enums" + "metachan/repositories" + "metachan/services" "metachan/utils/logger" "time" ) -// AniSync fetches full anime details for all anime in the database func AniSync() error { - logger.Log("Starting Anime Sync - Fetching full anime details", logger.LogOptions{ - Level: logger.Info, - Prefix: "AniSync", - }) + logger.Infof("AniSync", "Starting Anime Sync - Fetching full anime details") - // Get all anime mappings - var mappings []entities.AnimeMapping - if err := database.DB.Find(&mappings).Error; err != nil { - logger.Log(fmt.Sprintf("Failed to fetch anime mappings: %v", err), logger.LogOptions{ - Level: logger.Error, - Prefix: "AniSync", - }) + mappings, err := repositories.GetAllMappings() + if err != nil { + logger.Errorf("AniSync", "Failed to fetch anime mappings: %v", err) return err } total := len(mappings) - logger.Log(fmt.Sprintf("Found %d anime mappings", total), logger.LogOptions{ - Level: logger.Info, - Prefix: "AniSync", - }) + logger.Infof("AniSync", "Found %d anime mappings", total) - // Pre-count items needing sync for accurate ETA itemsToSync := 0 for _, mapping := range mappings { if mapping.MAL == 0 { continue } - var existingAnime entities.Anime - err := database.DB.Where("mal_id = ?", mapping.MAL).First(&existingAnime).Error + _, err := repositories.GetAnime(enums.MAL, mapping.MAL) if err == nil { - var episodeCount int64 - database.DB.Model(&entities.AnimeSingleEpisode{}).Where("anime_id = ?", existingAnime.ID).Count(&episodeCount) - if episodeCount > 0 { - continue - } + continue } itemsToSync++ } - logger.Log(fmt.Sprintf("Found %d anime to sync (%d already synced)", itemsToSync, total-itemsToSync), logger.LogOptions{ - Level: logger.Info, - Prefix: "AniSync", - }) + logger.Infof("AniSync", "Found %d anime to sync (%d already synced)", itemsToSync, total-itemsToSync) - animeService := anime.NewService() synced := 0 skipped := 0 startTime := time.Now() processed := 0 for _, mapping := range mappings { - // Skip if MAL ID is 0 (invalid) if mapping.MAL == 0 { skipped++ continue } - // Check if anime already exists in DB - var existingAnime entities.Anime - err := database.DB.Where("mal_id = ?", mapping.MAL).First(&existingAnime).Error - + _, err := repositories.GetAnime(enums.MAL, mapping.MAL) if err == nil { - // Check if anime has full details (has episodes) - var episodeCount int64 - database.DB.Model(&entities.AnimeSingleEpisode{}).Where("anime_id = ?", existingAnime.ID).Count(&episodeCount) - - if episodeCount > 0 { - skipped++ - continue - } + skipped++ + continue } - // 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 - eta = formatDuration(remaining) - } else { - eta = "calculating..." - } + progress, eta := calculateProgress(processed+1, itemsToSync, startTime) - // Fetch full anime details - 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", - }) + logger.Infof("AniSync", "[%d/%d] Synchronising MAL ID %d - %.1f%% | ETA: %v", processed+1, itemsToSync, mapping.MAL, progress, eta) - _, err = animeService.GetAnimeDetailsWithSource(&mapping, "anisync") + _, err = services.GetAnime(&mapping) if err != nil { - logger.Log(fmt.Sprintf("Failed to sync anime MAL ID %d: %v", mapping.MAL, err), logger.LogOptions{ - Level: logger.Warn, - Prefix: "AniSync", - }) + logger.Warnf("AniSync", "Failed to sync anime MAL ID %d: %v", mapping.MAL, err) continue } synced++ processed++ - - // Sleep to respect rate limits (1 second between requests) - time.Sleep(1 * time.Second) } - logger.Log(fmt.Sprintf("Anime Sync completed: %d synced, %d skipped", synced, skipped), logger.LogOptions{ - Level: logger.Success, - Prefix: "AniSync", - }) - return nil } - -// formatDuration converts duration to human-readable format -func formatDuration(d time.Duration) string { - d = d.Round(time.Second) - h := d / time.Hour - d -= h * time.Hour - m := d / time.Minute - d -= m * time.Minute - s := d / time.Second - - if h > 0 { - return fmt.Sprintf("%dh %dm", h, m) - } - if m > 0 { - return fmt.Sprintf("%dm %ds", m, s) - } - return fmt.Sprintf("%ds", s) -} diff --git a/tasks/aniupdate.task.go b/tasks/aniupdate.task.go index 38cc77a..e6d63a0 100644 --- a/tasks/aniupdate.task.go +++ b/tasks/aniupdate.task.go @@ -3,157 +3,106 @@ package tasks import ( "fmt" "metachan/config" - "metachan/database" "metachan/entities" - "metachan/services/anime" - "metachan/types" + "metachan/enums" + "metachan/repositories" + "metachan/services" "metachan/utils/logger" "sync" "time" ) -// Constants for anime update task const ( - // UpdaterSource identifies the source of the update request UpdaterSource = "updater" - // MaxConcurrentUpdates limits the number of concurrent anime updates MaxConcurrentUpdates = 5 - // MaxConcurrentSQLiteUpdates limits concurrent updates for SQLite to prevent locks MaxConcurrentSQLiteUpdates = 1 - // UpdateInterval defines how often an anime should be updated even without specific triggers UpdateInterval = 6 * time.Hour ) -// animeUpdateJob represents a single anime update job type animeUpdateJob struct { series entities.Anime reason string } -// AnimeUpdate checks for airing anime that need to be updated func AnimeUpdate() error { - logger.Log("Starting Anime Update Task", logger.LogOptions{ - Level: logger.Info, - Prefix: "AnimeUpdate", - }) - - // Find all currently airing anime - var airingSeries []entities.Anime - result := database.DB. - Where("airing = ?", true). - Preload("NextAiringEpisode"). - Preload("AiringSchedule"). - Find(&airingSeries) - - if result.Error != nil { - logger.Log(fmt.Sprintf("Failed to fetch airing anime: %v", result.Error), logger.LogOptions{ - Level: logger.Error, - Prefix: "AnimeUpdate", - }) - return result.Error + logger.Infof("AnimeUpdate", "Starting Anime Update Task") + + airingSeries, err := repositories.GetAiringAnime() + if err != nil { + logger.Errorf("AnimeUpdate", "Failed to fetch airing anime: %v", err) + return err } - logger.Log(fmt.Sprintf("Found %d airing anime series", len(airingSeries)), logger.LogOptions{ - Level: logger.Info, - Prefix: "AnimeUpdate", - }) + logger.Infof("AnimeUpdate", "Found %d airing anime series", len(airingSeries)) - // Get current timestamp currentTime := time.Now().Unix() - // Log the current time for debugging - logger.Log(fmt.Sprintf("Current timestamp: %d (%s)", - currentTime, time.Unix(currentTime, 0).Format(time.RFC3339)), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeUpdate", - }) + logger.Debugf("AnimeUpdate", "Current timestamp: %d (%s)", currentTime, time.Unix(currentTime, 0).Format(time.RFC3339)) - // Create a channel for jobs jobs := make(chan animeUpdateJob, len(airingSeries)) - // Determine max concurrency based on database type maxWorkers := MaxConcurrentUpdates - if config.Config.DatabaseDriver == types.SQLite { + if config.Database.Driver == "sqlite" { maxWorkers = MaxConcurrentSQLiteUpdates - logger.Log(fmt.Sprintf("Using reduced concurrency (%d workers) for SQLite database", - maxWorkers), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeUpdate", - }) + logger.Debugf("AnimeUpdate", "Using reduced concurrency (%d workers) for SQLite database", maxWorkers) } - // Create a wait group to wait for all workers to finish var wg sync.WaitGroup - // Create workers for i := 0; i < maxWorkers; i++ { wg.Add(1) go func(workerID int) { defer wg.Done() - animeService := anime.NewService() - logger.Log(fmt.Sprintf("Started worker #%d", workerID), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeUpdate", - }) + logger.Debugf("AnimeUpdate", "Started worker #%d", workerID) - // Process jobs from the channel for job := range jobs { - updateAnime(animeService, job.series, job.reason) + updateAnime(job.series, job.reason) } }(i) } - // Queue updates for anime that need it jobsQueued := 0 for _, series := range airingSeries { - // Check if we need to update this anime needsUpdate := false reason := "" - // Log details about this particular anime for debugging - logger.Log(fmt.Sprintf("Checking anime: %s (ID: %d)", - series.TitleRomaji, series.MALID), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeUpdate", - }) + title := "" + if series.Title != nil { + if series.Title.Romaji != "" { + title = series.Title.Romaji + } else if series.Title.English != "" { + title = series.Title.English + } + } + + logger.Debugf("AnimeUpdate", "Checking anime: %s (ID: %d)", title, series.MALID) - // If there's no next airing episode data, we should update - if series.NextAiringEpisode == nil || series.NextAiringEpisode.AiringAt == 0 { + if series.NextAiring == nil || series.NextAiring.AiringAt == 0 { needsUpdate = true reason = "missing next episode data" - } else if int64(series.NextAiringEpisode.AiringAt) <= currentTime { - // If the next episode should have aired already, update to get fresh data + } else if int64(series.NextAiring.AiringAt) <= currentTime { needsUpdate = true reason = "next episode already aired" } - // Check if the anime was last updated more than the update interval ago if !needsUpdate && !series.LastUpdated.IsZero() && time.Since(series.LastUpdated) > UpdateInterval { needsUpdate = true reason = fmt.Sprintf("regular update (last updated %s ago)", time.Since(series.LastUpdated).Round(time.Second)) } - // Log update decision if !needsUpdate { - logger.Log(fmt.Sprintf("Skipping update for %s (ID: %d) - no update needed. Next airing at: %d", - series.TitleRomaji, series.MALID, series.NextAiringEpisode.AiringAt), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeUpdate", - }) + logger.Debugf("AnimeUpdate", "Skipping update for %s (ID: %d) - no update needed. Next airing at: %d", + title, series.MALID, series.NextAiring.AiringAt) continue } - // Add the job to the queue - logger.Log(fmt.Sprintf("Queueing update for %s (ID: %d) - Reason: %s", - series.TitleRomaji, series.MALID, reason), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeUpdate", - }) + logger.Debugf("AnimeUpdate", "Queueing update for %s (ID: %d) - Reason: %s", + title, series.MALID, reason) jobs <- animeUpdateJob{ series: series, @@ -162,132 +111,96 @@ func AnimeUpdate() error { jobsQueued++ } - // Close the job channel to signal workers that no more jobs are coming close(jobs) - // Wait for all workers to finish wg.Wait() - logger.Log(fmt.Sprintf("Anime Update Task Completed - Processed %d anime", jobsQueued), logger.LogOptions{ - Level: logger.Success, - Prefix: "AnimeUpdate", - }) + logger.Successf("AnimeUpdate", "Anime Update Task Completed - Processed %d anime", jobsQueued) return nil } -// updateAnime updates a single anime series -func updateAnime(animeService *anime.Service, series entities.Anime, reason string) { - title := series.TitleRomaji - if series.TitleEnglish != "" { - title = series.TitleEnglish +func updateAnime(series entities.Anime, reason string) { + title := "" + if series.Title != nil { + if series.Title.English != "" { + title = series.Title.English + } else if series.Title.Romaji != "" { + title = series.Title.Romaji + } } - logger.Log(fmt.Sprintf("Updating anime: %s (MAL ID: %d) - %s", title, series.MALID, reason), logger.LogOptions{ - Level: logger.Info, - Prefix: "AnimeUpdate", - }) + logger.Infof("AnimeUpdate", "Updating anime: %s (MAL ID: %d) - %s", title, series.MALID, reason) - // Get anime mapping for the service call - mapping, err := database.GetAnimeMappingViaMALID(series.MALID) + mapping, err := repositories.GetAnimeMapping(enums.MAL, series.MALID) if err != nil { - logger.Log(fmt.Sprintf("Error getting anime mapping for %s (MAL ID: %d): %v", title, series.MALID, err), logger.LogOptions{ - Level: logger.Error, - Prefix: "AnimeUpdate", - }) + logger.Errorf("AnimeUpdate", "Error getting anime mapping for %s (MAL ID: %d): %v", title, series.MALID, err) return } - // Get updated anime data from API - updatedAnime, err := animeService.GetAnimeDetailsWithSource(mapping, "mal") + updatedAnime, err := services.GetAnime(&mapping) if err != nil { - logger.Log(fmt.Sprintf("Error getting updated anime data for %s (MAL ID: %d): %v", title, series.MALID, err), logger.LogOptions{ - Level: logger.Error, - Prefix: "AnimeUpdate", - }) + logger.Errorf("AnimeUpdate", "Error getting updated anime data for %s (MAL ID: %d): %v", title, series.MALID, err) return } - logger.Log(fmt.Sprintf("Successfully updated anime: %s (MAL ID: %d)", title, series.MALID), logger.LogOptions{ - Level: logger.Info, - Prefix: "AnimeUpdate", - }) + logger.Successf("AnimeUpdate", "Successfully updated anime: %s (MAL ID: %d)", title, series.MALID) - // Check if the updated anime data has significant changes that warrant saving if shouldSaveUpdate(&series, updatedAnime) { - // Check if anime is still airing if updatedAnime.Status != "RELEASING" && updatedAnime.Status != "AIRING" { - // Update the anime data to reflect that it's no longer airing updatedAnime.Airing = false } - if err := database.SaveAnimeToDatabase(updatedAnime); err != nil { - logger.Log(fmt.Sprintf("Error saving updated anime data for %s (MAL ID: %d): %v", title, series.MALID, err), logger.LogOptions{ - Level: logger.Error, - Prefix: "AnimeUpdate", - }) + if err := repositories.CreateOrUpdateAnime(updatedAnime); err != nil { + logger.Errorf("AnimeUpdate", "Error saving updated anime data for %s (MAL ID: %d): %v", title, series.MALID, err) } else { - logger.Log(fmt.Sprintf("Successfully saved updated data for %s (MAL ID: %d)", title, series.MALID), logger.LogOptions{ - Level: logger.Info, - Prefix: "AnimeUpdate", - }) + logger.Infof("AnimeUpdate", "Successfully saved updated data for %s (MAL ID: %d)", title, series.MALID) if !updatedAnime.Airing { - logger.Log(fmt.Sprintf("Anime %s (MAL ID: %d) is no longer airing. Status: %s", title, series.MALID, updatedAnime.Status), logger.LogOptions{ - Level: logger.Info, - Prefix: "AnimeUpdate", - }) + logger.Infof("AnimeUpdate", "Anime %s (MAL ID: %d) is no longer airing. Status: %s", title, series.MALID, updatedAnime.Status) } } } else { - logger.Log(fmt.Sprintf("No significant changes detected for %s (MAL ID: %d), skipping database update", title, series.MALID), logger.LogOptions{ - Level: logger.Debug, - Prefix: "AnimeUpdate", - }) + logger.Debugf("AnimeUpdate", "No significant changes detected for %s (MAL ID: %d), skipping database update", title, series.MALID) } } -// shouldSaveUpdate determines if the updated anime data has significant changes -// that warrant saving it to the database -func shouldSaveUpdate(oldAnime *entities.Anime, newAnime *types.Anime) bool { +func shouldSaveUpdate(oldAnime *entities.Anime, newAnime *entities.Anime) bool { if oldAnime == nil { return true } - // Convert old anime to types.Anime for easier comparison - oldAnimeConverted := database.ConvertToTypesAnime(oldAnime) - - // Check for changes in next airing episode - oldNextEp := oldAnimeConverted.NextAiringEpisode - newNextEp := newAnime.NextAiringEpisode + oldHasNext := oldAnime.NextAiring != nil && oldAnime.NextAiring.AiringAt > 0 + newHasNext := newAnime.NextAiring != nil && newAnime.NextAiring.AiringAt > 0 - // If next episode timestamp or number changed - if oldNextEp.AiringAt != newNextEp.AiringAt || oldNextEp.Episode != newNextEp.Episode { + if oldHasNext != newHasNext { return true } - // Check if sub/dub count changed - if oldAnimeConverted.Episodes.Subbed != newAnime.Episodes.Subbed || - oldAnimeConverted.Episodes.Dubbed != newAnime.Episodes.Dubbed { + if oldHasNext && newHasNext { + if oldAnime.NextAiring.AiringAt != newAnime.NextAiring.AiringAt || + oldAnime.NextAiring.Episode != newAnime.NextAiring.Episode { + return true + } + } + + if oldAnime.SubbedCount != newAnime.SubbedCount || + oldAnime.DubbedCount != newAnime.DubbedCount { return true } - // Check if airing status changed - if oldAnimeConverted.Airing != newAnime.Airing || - oldAnimeConverted.Status != newAnime.Status { + if oldAnime.Airing != newAnime.Airing || + oldAnime.Status != newAnime.Status { return true } - // Check if the total episode count has changed - if oldAnimeConverted.Episodes.Total != newAnime.Episodes.Total { + if oldAnime.TotalEpisodes != newAnime.TotalEpisodes { return true } - // Check if number of episodes in the airing schedule changed - if len(oldAnimeConverted.AiringSchedule) != len(newAnime.AiringSchedule) { + if len(oldAnime.Schedule) != len(newAnime.Schedule) { return true } - // No significant changes detected return false } diff --git a/tasks/genresync.task.go b/tasks/genresync.task.go index e240833..22e2712 100644 --- a/tasks/genresync.task.go +++ b/tasks/genresync.task.go @@ -1,82 +1,36 @@ package tasks import ( - "fmt" - "metachan/database" "metachan/entities" + "metachan/repositories" "metachan/utils/api/jikan" "metachan/utils/logger" ) -// GenreSync synchronizes genre data from MAL via Jikan API func GenreSync() error { - logger.Log("Starting Genre Sync from MAL", logger.LogOptions{ - Level: logger.Info, - Prefix: "GenreSync", - }) + logger.Infof("GenreSync", "Starting Genre Sync from MAL") - // Create Jikan client - client := jikan.NewJikanClient() - - // Wait for rate limit - client.WaitForRateLimit() - - // Fetch genres from Jikan API - genresResponse, err := client.GetAnimeGenres() + genresResponse, err := jikan.GetAnimeGenres() if err != nil { - logger.Log(fmt.Sprintf("Failed to fetch genres from MAL: %v", err), logger.LogOptions{ - Level: logger.Error, - Prefix: "GenreSync", - }) + logger.Errorf("GenreSync", "Failed to fetch genres from MAL: %v", err) return err } - logger.Log(fmt.Sprintf("Fetched %d genres from MAL", len(genresResponse.Data)), logger.LogOptions{ - Level: logger.Info, - Prefix: "GenreSync", - }) + logger.Infof("GenreSync", "Fetched %d genres from MAL", len(genresResponse.Data)) - // Update or create genres in database for _, genre := range genresResponse.Data { - // Create a genre entry with AnimeID = 0 to indicate it's a master genre - genreEntity := entities.AnimeGenre{ - AnimeID: 0, // Master genre, not tied to specific anime + genreEntity := entities.Genre{ GenreID: genre.MALID, Name: genre.Name, URL: genre.URL, Count: genre.Count, } - // Update or create - var existing entities.AnimeGenre - result := database.DB.Where("genre_id = ? AND anime_id = 0", genre.MALID).First(&existing) - - if result.Error == nil { - // Update existing - existing.Name = genre.Name - existing.URL = genre.URL - existing.Count = genre.Count - if err := database.DB.Save(&existing).Error; err != nil { - logger.Log(fmt.Sprintf("Failed to update genre %s: %v", genre.Name, err), logger.LogOptions{ - Level: logger.Warn, - Prefix: "GenreSync", - }) - } - } else { - // Create new - if err := database.DB.Create(&genreEntity).Error; err != nil { - logger.Log(fmt.Sprintf("Failed to create genre %s: %v", genre.Name, err), logger.LogOptions{ - Level: logger.Warn, - Prefix: "GenreSync", - }) - } + if err := repositories.CreateOrUpdateGenre(&genreEntity); err != nil { + logger.Warnf("GenreSync", "Failed to sync genre %s: %v", genre.Name, err) } } - logger.Log("Genre Sync completed successfully", logger.LogOptions{ - Level: logger.Success, - Prefix: "GenreSync", - }) - + logger.Successf("GenreSync", "Genre Sync completed successfully. Synced %d genres", len(genresResponse.Data)) return nil } diff --git a/tasks/helpers.go b/tasks/helpers.go new file mode 100644 index 0000000..b99a977 --- /dev/null +++ b/tasks/helpers.go @@ -0,0 +1,12 @@ +package tasks + +import "time" + +func calculateProgress(current, total int, startTime time.Time) (progress float64, eta time.Duration) { + progress = float64(current) / float64(total) * 100 + elapsed := time.Since(startTime) + avgTimePerItem := elapsed / time.Duration(current) + remaining := total - current + eta = avgTimePerItem * time.Duration(remaining) + return progress, eta.Round(time.Second) +} diff --git a/tasks/manager.go b/tasks/manager.go index af86b17..9bcf4f5 100644 --- a/tasks/manager.go +++ b/tasks/manager.go @@ -2,8 +2,8 @@ package tasks import ( "fmt" - "metachan/database" "metachan/entities" + "metachan/repositories" "metachan/types" "metachan/utils/logger" "sync" @@ -13,11 +13,10 @@ import ( ) type TaskManager struct { - Tasks map[string]types.Task - Tickers map[string]*time.Ticker - Done map[string]chan bool - Mutex sync.Mutex - Database *gorm.DB + Tasks map[string]types.Task + Tickers map[string]*time.Ticker + Done map[string]chan bool + Mutex sync.Mutex } func (tm *TaskManager) RegisterTask(task types.Task) error { @@ -29,22 +28,17 @@ func (tm *TaskManager) RegisterTask(task types.Task) error { } tm.Tasks[task.Name] = task - logger.Log(fmt.Sprintf("Task %s registered", task.Name), logger.LogOptions{ - Level: logger.Info, - Prefix: "TaskManager", - }) + logger.Infof("TaskManager", "Task %s registered", task.Name) return nil } func (tm *TaskManager) shouldExecuteTask(taskName string, interval time.Duration) (bool, error) { - var lastLog entities.TaskLog - - if err := tm.Database.Where("task_name = ?", taskName).Order("executed_at desc").First(&lastLog).Error; err != nil { + lastLog, err := repositories.GetLatestTaskLog(taskName) + if err != nil { if err == gorm.ErrRecordNotFound { return true, nil } - return false, err } @@ -60,11 +54,8 @@ func (tm *TaskManager) logTaskExecution(taskName, status, message string) { ExecutedAt: time.Now(), } - if err := tm.Database.Create(&logEntry).Error; err != nil { - logger.Log(fmt.Sprintf("Failed to log task execution for %s: %v", taskName, err), logger.LogOptions{ - Level: logger.Warn, - Prefix: "TaskManager", - }) + if err := repositories.CreateTaskLog(&logEntry); err != nil { + logger.Warnf("TaskManager", "Failed to log task execution for %s: %v", taskName, err) } } @@ -73,10 +64,7 @@ func (tm *TaskManager) StartTask(taskName string) { task, exists := tm.Tasks[taskName] tm.Mutex.Unlock() if !exists { - logger.Log(fmt.Sprintf("Task %s not found", taskName), logger.LogOptions{ - Level: logger.Warn, - Prefix: "TaskManager", - }) + logger.Warnf("TaskManager", "Task %s not found", taskName) return } @@ -85,10 +73,7 @@ func (tm *TaskManager) StartTask(taskName string) { shouldExec, err := tm.shouldExecuteTask(taskName, task.Interval) if err != nil { - logger.Log(fmt.Sprintf("Error checking execution condition for task %s: %v", taskName, err), logger.LogOptions{ - Level: logger.Error, - Prefix: "TaskManager", - }) + logger.Errorf("TaskManager", "Error checking execution condition for task %s: %v", taskName, err) return } @@ -102,81 +87,41 @@ func (tm *TaskManager) StartTask(taskName string) { if shouldExec { // 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", - }) + logger.Warnf("TaskManager", "Task %s dependencies not met, skipping execution", taskName) } 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, - Prefix: "TaskManager", - }) + logger.Errorf("TaskManager", "Task %s execution failed: %v", taskName, err) } 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", - }) + logger.Successf("TaskManager", "Task %s executed successfully", taskName) } } else { // Calculate time until next execution - var lastLog entities.TaskLog var initialDelay time.Duration = task.Interval - if err := tm.Database.Where("task_name = ?", taskName).Order("executed_at desc").First(&lastLog).Error; err == nil { + if lastLog, err := repositories.GetLatestTaskLog(taskName); err == nil { elapsed := time.Since(lastLog.ExecutedAt) if elapsed < task.Interval { initialDelay = task.Interval - elapsed } } - logger.Log(fmt.Sprintf("Task %s will run in %v", taskName, initialDelay), logger.LogOptions{ - Level: logger.Info, - Prefix: "TaskManager", - }) + logger.Infof("TaskManager", "Task %s will run in %v", taskName, initialDelay) // Wait for initial delay before first execution select { case <-time.After(initialDelay): // 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", - }) + logger.Warnf("TaskManager", "Task %s dependencies not met, skipping execution", taskName) } 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, - Prefix: "TaskManager", - }) + logger.Errorf("TaskManager", "Task %s execution failed: %v", taskName, err) } 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", - }) + logger.Successf("TaskManager", "Task %s executed successfully", taskName) } case <-doneChan: return @@ -185,10 +130,7 @@ func (tm *TaskManager) StartTask(taskName string) { // Skip ticker creation for manual-only tasks (interval = 0) if task.Interval == 0 { - logger.Log(fmt.Sprintf("Task %s is manual-only (no scheduled interval)", taskName), logger.LogOptions{ - Level: logger.Debug, - Prefix: "TaskManager", - }) + logger.Debugf("TaskManager", "Task %s is manual-only (no scheduled interval)", taskName) return } @@ -204,32 +146,14 @@ func (tm *TaskManager) StartTask(taskName string) { case <-ticker.C: // 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", - }) + logger.Warnf("TaskManager", "Task %s dependencies not met, skipping execution", taskName) } 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, - Prefix: "TaskManager", - }) + logger.Errorf("TaskManager", "Task %s execution failed: %v", taskName, err) } 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", - }) + logger.Successf("TaskManager", "Task %s executed successfully", taskName) } case <-doneChan: ticker.Stop() @@ -238,10 +162,7 @@ func (tm *TaskManager) StartTask(taskName string) { } }() - logger.Log(fmt.Sprintf("Task %s scheduled with interval %v", taskName, task.Interval), logger.LogOptions{ - Level: logger.Info, - Prefix: "TaskManager", - }) + logger.Infof("TaskManager", "Task %s scheduled with interval %v", taskName, task.Interval) } func (tm *TaskManager) StopTask(taskName string) { @@ -252,10 +173,7 @@ func (tm *TaskManager) StopTask(taskName string) { close(doneChan) delete(tm.Done, taskName) delete(tm.Tickers, taskName) - logger.Log(fmt.Sprintf("Task %s stopped", taskName), logger.LogOptions{ - Level: logger.Info, - Prefix: "TaskManager", - }) + logger.Infof("TaskManager", "Task %s stopped", taskName) } } @@ -283,10 +201,7 @@ func (tm *TaskManager) StopAllTasks() { ticker.Stop() delete(tm.Tickers, name) } - logger.Log(fmt.Sprintf("Task %s stopped", name), logger.LogOptions{ - Level: logger.Info, - Prefix: "TaskManager", - }) + logger.Infof("TaskManager", "Task %s stopped", name) } } @@ -297,11 +212,9 @@ func (tm *TaskManager) checkDependencies(task types.Task) bool { } 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", - }) + taskStatus, err := repositories.GetTaskStatus(depName) + if err != nil || !taskStatus.IsCompleted { + logger.Debugf("TaskManager", "Dependency %s not completed for task %s", depName, task.Name) return false } } @@ -316,9 +229,8 @@ func (tm *TaskManager) GetTaskStatus(taskName string) *types.TaskStatus { tm.Mutex.Unlock() var lastRun, nextRun *time.Time - var logEntry entities.TaskLog - if err := tm.Database.Where("task_name = ?", taskName).Order("executed_at desc").First(&logEntry).Error; err == nil { + if logEntry, err := repositories.GetLatestTaskLog(taskName); err == nil { lastRun = &logEntry.ExecutedAt if logEntry.Status == "error" { lastRun = nil @@ -329,10 +241,7 @@ func (tm *TaskManager) GetTaskStatus(taskName string) *types.TaskStatus { nextRun = &next } } else if err != gorm.ErrRecordNotFound { - logger.Log(fmt.Sprintf("Error fetching task log for %s: %v", taskName, err), logger.LogOptions{ - Level: logger.Error, - Prefix: "TaskManager", - }) + logger.Errorf("TaskManager", "Error fetching task log for %s: %v", taskName, err) } return &types.TaskStatus{ diff --git a/tasks/producersync.task.go b/tasks/producersync.task.go index e211eb3..59864ce 100644 --- a/tasks/producersync.task.go +++ b/tasks/producersync.task.go @@ -1,264 +1,78 @@ package tasks import ( - "fmt" - "metachan/database" "metachan/entities" + "metachan/repositories" "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", - }) + logger.Infof("ProducerSync", "Starting producer sync (includes studios and licensors)") - client := jikan.NewJikanClient() - page := 1 - totalFetched := 0 - var totalPages int - var totalProducers int - startTime := time.Now() + response, err := jikan.GetAnimeProducers() + if err != nil { + logger.Errorf("ProducerSync", "Failed to fetch producers: %v", err) + return err + } - for { - logger.Log(fmt.Sprintf("Fetching producers page %d...", page), logger.LogOptions{ - Level: logger.Info, - Prefix: "ProducerSync", - }) + total := len(response.Data) + logger.Infof("ProducerSync", "Fetched %d producers from MAL", total) + + startTime := time.Now() - response, err := client.GetAnimeProducers(page) + for i, producerData := range response.Data { + producerDetail, err := jikan.GetProducerByID(producerData.MALID) 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 + logger.Warnf("ProducerSync", "Failed to fetch details for producer %d: %v", producerData.MALID, err) + continue + } + + var imageID *uint + if producerDetail.Data.Images.JPG.ImageURL != "" { + image := entities.SimpleImage{ + ImageURL: producerDetail.Data.Images.JPG.ImageURL, + } + id, err := repositories.CreateOrUpdateSimpleImage(&image) + if err == nil { + imageID = &id } - return err } - if len(response.Data) == 0 { - break + producer := entities.Producer{ + MALID: producerDetail.Data.MALID, + URL: producerDetail.Data.URL, + Favorites: producerDetail.Data.Favorites, + Count: producerDetail.Data.Count, + Established: producerDetail.Data.Established, + About: producerDetail.Data.About, + ImageID: imageID, } - // 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", + for _, title := range producerDetail.Data.Titles { + producer.Titles = append(producer.Titles, entities.SimpleTitle{ + Type: title.Type, + Title: title.Title, }) } - // 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 + for _, ext := range producerDetail.Data.External { + producer.ExternalURLs = append(producer.ExternalURLs, entities.ExternalURL{ + Name: ext.Name, + URL: ext.URL, + }) } - // Check if there's more data - if !response.Pagination.HasNextPage { - break + if err := repositories.CreateOrUpdateProducer(&producer); err != nil { + logger.Warnf("ProducerSync", "Failed to sync producer %d: %v", producerData.MALID, err) + continue } - page++ - time.Sleep(1 * time.Second) // Additional delay between pages + progress, eta := calculateProgress(i+1, total, startTime) + logger.Infof("ProducerSync", "Progress: %d/%d (%.1f%%) | ETA: %v", i+1, total, progress, eta) } - logger.Log(fmt.Sprintf("Producer sync completed successfully. Total: %d producers", totalFetched), logger.LogOptions{ - Level: logger.Success, - Prefix: "ProducerSync", - }) - + logger.Successf("ProducerSync", "Producer sync completed. Total: %d producers", total) return nil } diff --git a/tasks/tasks.go b/tasks/tasks.go index 31d017d..8b19a19 100644 --- a/tasks/tasks.go +++ b/tasks/tasks.go @@ -1,9 +1,7 @@ package tasks import ( - "fmt" "metachan/config" - "metachan/database" "metachan/types" "metachan/utils/logger" "sync" @@ -14,11 +12,10 @@ var GlobalTaskManager *TaskManager func init() { GlobalTaskManager = &TaskManager{ - Tasks: make(map[string]types.Task), - Tickers: make(map[string]*time.Ticker), - Done: make(map[string]chan bool), - Mutex: sync.Mutex{}, - Database: database.DB, + Tasks: make(map[string]types.Task), + Tickers: make(map[string]*time.Ticker), + Done: make(map[string]chan bool), + Mutex: sync.Mutex{}, } // Register ProducerSync task (every 7 days) - runs first to populate unified producer table @@ -29,10 +26,7 @@ func init() { }) if err != nil { - logger.Log(fmt.Sprintf("Failed to register ProducerSync task: %v", err), logger.LogOptions{ - Level: logger.Error, - Prefix: "TaskManager", - }) + logger.Errorf("TaskManager", "Failed to register ProducerSync task: %v", err) } // Register GenreSync task (every 7 days) @@ -43,10 +37,7 @@ func init() { }) if err != nil { - logger.Log(fmt.Sprintf("Failed to register GenreSync task: %v", err), logger.LogOptions{ - Level: logger.Error, - Prefix: "TaskManager", - }) + logger.Errorf("TaskManager", "Failed to register GenreSync task: %v", err) } // Register AniFetch task (weekly) - fetches anime mappings from Fribb list @@ -59,14 +50,11 @@ func init() { }) if err != nil { - logger.Log(fmt.Sprintf("Failed to register AnimeFetch task: %v", err), logger.LogOptions{ - Level: logger.Error, - Prefix: "TaskManager", - }) + logger.Errorf("TaskManager", "Failed to register AnimeFetch task: %v", err) } // Register AnimeSync task (runs after AnimeFetch completes) - only if enabled in config - if config.Config.AniSync { + if config.Sync.AniSync { err = GlobalTaskManager.RegisterTask(types.Task{ Name: "AnimeSync", Interval: 0, // Manual-only - waits for AnimeFetch dependency @@ -75,10 +63,7 @@ func init() { }) if err != nil { - logger.Log(fmt.Sprintf("Failed to register AnimeSync task: %v", err), logger.LogOptions{ - Level: logger.Error, - Prefix: "TaskManager", - }) + logger.Errorf("TaskManager", "Failed to register AnimeSync task: %v", err) } } @@ -90,9 +75,6 @@ func init() { }) if err != nil { - logger.Log(fmt.Sprintf("Failed to register AnimeUpdate task: %v", err), logger.LogOptions{ - Level: logger.Error, - Prefix: "TaskManager", - }) + logger.Errorf("TaskManager", "Failed to register AnimeUpdate task: %v", err) } } diff --git a/types/tasks.go b/types/tasks.go new file mode 100644 index 0000000..10ba608 --- /dev/null +++ b/types/tasks.go @@ -0,0 +1,18 @@ +package types + +import "time" + +type Task struct { + Name string + Interval time.Duration + Execute func() error + LastRun time.Time + Dependencies []string +} + +type TaskStatus struct { + Registered bool + Running bool + LastRun *time.Time + NextRun *time.Time +} diff --git a/utils/api/malsync/malsync.go b/utils/api/malsync/malsync.go index 1ee479c..9509707 100644 --- a/utils/api/malsync/malsync.go +++ b/utils/api/malsync/malsync.go @@ -88,7 +88,6 @@ func (c *client) makeRequest(ctx context.Context, url string) ([]byte, error) { switch response.StatusCode { case http.StatusNotFound: - // Not found is not an error, return nil return nil, nil case http.StatusTooManyRequests: retryAfter := c.getRetryAfterDuration(response) @@ -131,7 +130,6 @@ func GetAnimeByMALID(malID int) (*types.MalsyncAnimeResponse, error) { return nil, errors.New("failed to fetch anime data from Malsync API") } - // Handle 404 case where makeRequest returns nil, nil if bytes == nil { return nil, nil } diff --git a/utils/logger/logger.go b/utils/logger/logger.go index 41e7f1e..f1c94e7 100644 --- a/utils/logger/logger.go +++ b/utils/logger/logger.go @@ -131,7 +131,7 @@ func log(levelLabel LogLevel, zapLevel zapcore.Level, prefix string, msg any) { message := fmt.Sprint(msg) colored := colorMessage(levelLabel, message) + fullMessage := formatPrefix(prefix) + colored - loggerInstance.Check(zapLevel, colored). - Write(zap.String("prefix", formatPrefix(prefix))) + loggerInstance.Log(zapLevel, fullMessage) } -- cgit v1.2.3