aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-02-06 17:45:55 +0530
committerBobby <[email protected]>2026-02-06 17:45:55 +0530
commit2da45b9fbf74d365951e37a4152f30e76caaeb98 (patch)
tree8fc9b5f02d37194c136e7f757f1e37c21825094c
parent8cb229a3bc07387067ca76a34c05b44ff7a3039a (diff)
downloadmetachan-2da45b9fbf74d365951e37a4152f30e76caaeb98.tar.xz
metachan-2da45b9fbf74d365951e37a4152f30e76caaeb98.zip
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.
-rw-r--r--config/config.go2
-rw-r--r--controllers/health.go4
-rw-r--r--entities/anime.go75
-rw-r--r--entities/episode.go31
-rw-r--r--entities/genre.go2
-rw-r--r--entities/seasons.go35
-rw-r--r--metachan/main.go2
-rw-r--r--repositories/anime.go1079
-rw-r--r--repositories/mapping.go12
-rw-r--r--repositories/tasks.go23
-rw-r--r--services/anime.go445
-rw-r--r--services/anime/helpers.go1108
-rw-r--r--services/anime/service.go2152
-rw-r--r--tasks/anifetch.task.go121
-rw-r--r--tasks/anisync.task.go112
-rw-r--r--tasks/aniupdate.task.go223
-rw-r--r--tasks/genresync.task.go64
-rw-r--r--tasks/helpers.go12
-rw-r--r--tasks/manager.go155
-rw-r--r--tasks/producersync.task.go282
-rw-r--r--tasks/tasks.go38
-rw-r--r--types/tasks.go18
-rw-r--r--utils/api/malsync/malsync.go2
-rw-r--r--utils/logger/logger.go4
24 files changed, 2489 insertions, 3512 deletions
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)
}