package anime import ( "context" "errors" "seanime/internal/api/anilist" "seanime/internal/api/metadata" "seanime/internal/hook" "seanime/internal/platforms/anilist_platform" "seanime/internal/platforms/platform" "sort" "github.com/samber/lo" "github.com/sourcegraph/conc/pool" ) type ( // Entry is a container for all data related to a media. // It is the primary data structure used by the frontend. Entry struct { MediaId int `json:"mediaId"` Media *anilist.BaseAnime `json:"media"` EntryListData *EntryListData `json:"listData"` EntryLibraryData *EntryLibraryData `json:"libraryData"` EntryDownloadInfo *EntryDownloadInfo `json:"downloadInfo,omitempty"` Episodes []*Episode `json:"episodes"` NextEpisode *Episode `json:"nextEpisode"` LocalFiles []*LocalFile `json:"localFiles"` AnidbId int `json:"anidbId"` CurrentEpisodeCount int `json:"currentEpisodeCount"` IsNakamaEntry bool `json:"_isNakamaEntry"` NakamaLibraryData *NakamaEntryLibraryData `json:"nakamaLibraryData,omitempty"` } // EntryListData holds the details of the AniList entry. EntryListData struct { Progress int `json:"progress,omitempty"` Score float64 `json:"score,omitempty"` Status *anilist.MediaListStatus `json:"status,omitempty"` Repeat int `json:"repeat,omitempty"` StartedAt string `json:"startedAt,omitempty"` CompletedAt string `json:"completedAt,omitempty"` } ) type ( // NewEntryOptions is a constructor for Entry. NewEntryOptions struct { MediaId int LocalFiles []*LocalFile // All local files AnimeCollection *anilist.AnimeCollection Platform platform.Platform MetadataProvider metadata.Provider IsSimulated bool // If the account is simulated } ) // NewEntry creates a new Entry based on the media id and a list of local files. // A Entry is a container for all data related to a media. // It is the primary data structure used by the frontend. // // It has the following properties: // - EntryListData: Details of the AniList entry (if any) // - EntryLibraryData: Details of the local files (if any) // - EntryDownloadInfo: Details of the download status // - Episodes: List of episodes (if any) // - NextEpisode: Next episode to watch (if any) // - LocalFiles: List of local files (if any) // - AnidbId: AniDB id // - CurrentEpisodeCount: Current episode count func NewEntry(ctx context.Context, opts *NewEntryOptions) (*Entry, error) { // Create new Entry entry := new(Entry) entry.MediaId = opts.MediaId reqEvent := new(AnimeEntryRequestedEvent) reqEvent.MediaId = opts.MediaId reqEvent.LocalFiles = opts.LocalFiles reqEvent.AnimeCollection = opts.AnimeCollection reqEvent.Entry = entry err := hook.GlobalHookManager.OnAnimeEntryRequested().Trigger(reqEvent) if err != nil { return nil, err } opts.MediaId = reqEvent.MediaId // Override the media ID opts.LocalFiles = reqEvent.LocalFiles // Override the local files opts.AnimeCollection = reqEvent.AnimeCollection // Override the anime collection entry = reqEvent.Entry // Override the entry // Default prevented, return the modified entry if reqEvent.DefaultPrevented { event := new(AnimeEntryEvent) event.Entry = reqEvent.Entry err = hook.GlobalHookManager.OnAnimeEntry().Trigger(event) if err != nil { return nil, err } if event.Entry == nil { return nil, errors.New("no entry was returned") } return event.Entry, nil } if opts.AnimeCollection == nil || opts.Platform == nil { return nil, errors.New("missing arguments when creating media entry") } // +---------------------+ // | AniList entry | // +---------------------+ // Get the Anilist List entry anilistEntry, found := opts.AnimeCollection.GetListEntryFromAnimeId(opts.MediaId) // Set the media // If the Anilist List entry does not exist, fetch the media from AniList if !found { // If the Anilist entry does not exist, instantiate one with zero values anilistEntry = &anilist.AnimeListEntry{} // Fetch the media fetchedMedia, err := opts.Platform.GetAnime(ctx, opts.MediaId) // DEVNOTE: Maybe cache it? if err != nil { return nil, err } entry.Media = fetchedMedia } else { animeEvent := new(anilist_platform.GetAnimeEvent) animeEvent.Anime = anilistEntry.Media err := hook.GlobalHookManager.OnGetAnime().Trigger(animeEvent) if err != nil { return nil, err } entry.Media = animeEvent.Anime } // If the account is simulated and the media was in the library, we will still fetch // the media from AniList to ensure we have the latest data if opts.IsSimulated && found { // Fetch the media fetchedMedia, err := opts.Platform.GetAnime(ctx, opts.MediaId) // DEVNOTE: Maybe cache it? if err != nil { return nil, err } entry.Media = fetchedMedia } entry.CurrentEpisodeCount = entry.Media.GetCurrentEpisodeCount() // +---------------------+ // | Local files | // +---------------------+ // Get the entry's local files lfs := GetLocalFilesFromMediaId(opts.LocalFiles, opts.MediaId) entry.LocalFiles = lfs // Returns empty slice if no local files are found libraryData, _ := NewEntryLibraryData(&NewEntryLibraryDataOptions{ EntryLocalFiles: lfs, MediaId: entry.Media.ID, CurrentProgress: anilistEntry.GetProgressSafe(), }) entry.EntryLibraryData = libraryData // +---------------------+ // | Animap | // +---------------------+ // Fetch AniDB data and cache it for 30 minutes animeMetadata, err := opts.MetadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, opts.MediaId) if err != nil { // +---------------- Start // +---------------------+ // | Without Animap | // +---------------------+ // If Animap data is not found, we will still create the Entry without it simpleAnimeEntry, err := NewSimpleEntry(ctx, &NewSimpleAnimeEntryOptions{ MediaId: opts.MediaId, LocalFiles: opts.LocalFiles, AnimeCollection: opts.AnimeCollection, Platform: opts.Platform, }) if err != nil { return nil, err } event := &AnimeEntryEvent{ Entry: &Entry{ MediaId: simpleAnimeEntry.MediaId, Media: simpleAnimeEntry.Media, EntryListData: simpleAnimeEntry.EntryListData, EntryLibraryData: simpleAnimeEntry.EntryLibraryData, EntryDownloadInfo: nil, Episodes: simpleAnimeEntry.Episodes, NextEpisode: simpleAnimeEntry.NextEpisode, LocalFiles: simpleAnimeEntry.LocalFiles, AnidbId: 0, CurrentEpisodeCount: simpleAnimeEntry.CurrentEpisodeCount, }, } err = hook.GlobalHookManager.OnAnimeEntry().Trigger(event) if err != nil { return nil, err } return event.Entry, nil // +--------------- End } entry.AnidbId = animeMetadata.GetMappings().AnidbId // Instantiate EntryListData // If the media exist in the user's anime list, add the details if found { entry.EntryListData = NewEntryListData(anilistEntry) } // +---------------------+ // | Episodes | // +---------------------+ // Create episode entities entry.hydrateEntryEpisodeData(anilistEntry, animeMetadata, opts.MetadataProvider) event := &AnimeEntryEvent{ Entry: entry, } err = hook.GlobalHookManager.OnAnimeEntry().Trigger(event) if err != nil { return nil, err } return event.Entry, nil } //---------------------------------------------------------------------------------------------------------------------- // hydrateEntryEpisodeData // AniZipData, Media and LocalFiles should be defined func (e *Entry) hydrateEntryEpisodeData( anilistEntry *anilist.AnimeListEntry, animeMetadata *metadata.AnimeMetadata, metadataProvider metadata.Provider, ) { if animeMetadata.Episodes == nil && len(animeMetadata.Episodes) == 0 { return } // +---------------------+ // | Discrepancy | // +---------------------+ // We offset the progress number by 1 if there is a discrepancy progressOffset := 0 if FindDiscrepancy(e.Media, animeMetadata) == DiscrepancyAniListCountsEpisodeZero { progressOffset = 1 _, ok := lo.Find(e.LocalFiles, func(lf *LocalFile) bool { return lf.Metadata.Episode == 0 }) // Remove the offset if episode 0 is not found if !ok { progressOffset = 0 } } // +---------------------+ // | Episodes | // +---------------------+ p := pool.NewWithResults[*Episode]() for _, lf := range e.LocalFiles { p.Go(func() *Episode { return NewEpisode(&NewEpisodeOptions{ LocalFile: lf, OptionalAniDBEpisode: "", AnimeMetadata: animeMetadata, Media: e.Media, ProgressOffset: progressOffset, IsDownloaded: true, MetadataProvider: metadataProvider, }) }) } episodes := p.Wait() // Sort by progress number sort.Slice(episodes, func(i, j int) bool { return episodes[i].EpisodeNumber < episodes[j].EpisodeNumber }) e.Episodes = episodes // +---------------------+ // | Download Info | // +---------------------+ info, err := NewEntryDownloadInfo(&NewEntryDownloadInfoOptions{ LocalFiles: e.LocalFiles, AnimeMetadata: animeMetadata, Progress: anilistEntry.Progress, Status: anilistEntry.Status, Media: e.Media, MetadataProvider: metadataProvider, }) if err == nil { e.EntryDownloadInfo = info } nextEp, found := e.FindNextEpisode() if found { e.NextEpisode = nextEp } } func NewEntryListData(anilistEntry *anilist.AnimeListEntry) *EntryListData { return &EntryListData{ Progress: anilistEntry.GetProgressSafe(), Score: anilistEntry.GetScoreSafe(), Status: anilistEntry.Status, Repeat: anilistEntry.GetRepeatSafe(), StartedAt: anilist.FuzzyDateToString(anilistEntry.StartedAt), CompletedAt: anilist.FuzzyDateToString(anilistEntry.CompletedAt), } } //---------------------------------------------------------------------------------------------------------------------- type Discrepancy int const ( DiscrepancyAniListCountsEpisodeZero Discrepancy = iota DiscrepancyAniListCountsSpecials DiscrepancyAniDBHasMore DiscrepancyNone ) // FindDiscrepancy returns the discrepancy between the AniList and AniDB episode counts. // It returns DiscrepancyAniListCountsEpisodeZero if AniList most likely has episode 0 as part of the main count. // It returns DiscrepancyAniListCountsSpecials if there is a discrepancy between the AniList and AniDB episode counts and specials are included in the AniList count. // It returns DiscrepancyAniDBHasMore if the AniDB episode count is greater than the AniList episode count. // It returns DiscrepancyNone if there is no discrepancy. func FindDiscrepancy(media *anilist.BaseAnime, animeMetadata *metadata.AnimeMetadata) Discrepancy { if media == nil || animeMetadata == nil || animeMetadata.Episodes == nil { return DiscrepancyNone } _, aniDBHasS1 := animeMetadata.Episodes["S1"] _, aniDBHasS2 := animeMetadata.Episodes["S2"] difference := media.GetCurrentEpisodeCount() - animeMetadata.GetMainEpisodeCount() if difference == 0 { return DiscrepancyNone } if difference < 0 { return DiscrepancyAniDBHasMore } if difference == 1 && aniDBHasS1 { return DiscrepancyAniListCountsEpisodeZero } if difference > 1 && aniDBHasS1 && aniDBHasS2 { return DiscrepancyAniListCountsSpecials } return DiscrepancyNone }