package anime import ( "cmp" "context" "fmt" "seanime/internal/api/anilist" "seanime/internal/api/metadata" "seanime/internal/hook" "seanime/internal/platforms/platform" "seanime/internal/util/result" "slices" "time" "github.com/rs/zerolog" "github.com/samber/lo" ) var episodeCollectionCache = result.NewBoundedCache[int, *EpisodeCollection](10) var EpisodeCollectionFromLocalFilesCache = result.NewBoundedCache[int, *EpisodeCollection](10) type ( // EpisodeCollection represents a collection of episodes. EpisodeCollection struct { HasMappingError bool `json:"hasMappingError"` Episodes []*Episode `json:"episodes"` Metadata *metadata.AnimeMetadata `json:"metadata"` } ) type NewEpisodeCollectionOptions struct { // AnimeMetadata can be nil, if not provided, it will be fetched from the metadata provider. AnimeMetadata *metadata.AnimeMetadata Media *anilist.BaseAnime MetadataProvider metadata.Provider Logger *zerolog.Logger } // NewEpisodeCollection creates a new episode collection by leveraging EntryDownloadInfo. // The returned EpisodeCollection is cached for 6 hours. // // AnimeMetadata is optional, if not provided, it will be fetched from the metadata provider. // // Note: This is used by Torrent and Debrid streaming func NewEpisodeCollection(opts NewEpisodeCollectionOptions) (ec *EpisodeCollection, err error) { if opts.Logger == nil { opts.Logger = lo.ToPtr(zerolog.Nop()) } if opts.Media == nil { return nil, fmt.Errorf("cannont create episode collectiom, media is nil") } if opts.MetadataProvider == nil { return nil, fmt.Errorf("cannot create episode collection, metadata provider is nil") } if ec, ok := episodeCollectionCache.Get(opts.Media.ID); ok { opts.Logger.Debug().Msg("torrentstream: Using cached episode collection") return ec, nil } if opts.AnimeMetadata == nil { // Fetch the metadata opts.AnimeMetadata, err = opts.MetadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, opts.Media.ID) if err != nil { opts.AnimeMetadata = &metadata.AnimeMetadata{ Titles: make(map[string]string), Episodes: make(map[string]*metadata.EpisodeMetadata), EpisodeCount: 0, SpecialCount: 0, Mappings: &metadata.AnimeMappings{ AnilistId: opts.Media.GetID(), }, } opts.AnimeMetadata.Titles["en"] = opts.Media.GetTitleSafe() opts.AnimeMetadata.Titles["x-jat"] = opts.Media.GetRomajiTitleSafe() err = nil } } reqEvent := &AnimeEpisodeCollectionRequestedEvent{ Media: opts.Media, Metadata: opts.AnimeMetadata, EpisodeCollection: &EpisodeCollection{}, } err = hook.GlobalHookManager.OnAnimEpisodeCollectionRequested().Trigger(reqEvent) if err != nil { return nil, err } opts.Media = reqEvent.Media opts.AnimeMetadata = reqEvent.Metadata if reqEvent.DefaultPrevented { return reqEvent.EpisodeCollection, nil } ec = &EpisodeCollection{ HasMappingError: false, Episodes: make([]*Episode, 0), Metadata: opts.AnimeMetadata, } // +---------------------+ // | Download Info | // +---------------------+ info, err := NewEntryDownloadInfo(&NewEntryDownloadInfoOptions{ LocalFiles: nil, AnimeMetadata: opts.AnimeMetadata, Progress: lo.ToPtr(0), // Progress is 0 because we want the entire list Status: lo.ToPtr(anilist.MediaListStatusCurrent), Media: opts.Media, MetadataProvider: opts.MetadataProvider, }) if err != nil { opts.Logger.Error().Err(err).Msg("torrentstream: could not get media entry info") return nil, err } // As of v2.8.0, this should never happen, getMediaInfo always returns an anime metadata struct, even if it's not found // causing NewEntryDownloadInfo to return a valid list of episodes to download if info == nil || info.EpisodesToDownload == nil { opts.Logger.Debug().Msg("torrentstream: no episodes found from AniDB, using AniList") for epIdx := range opts.Media.GetCurrentEpisodeCount() { episodeNumber := epIdx + 1 mediaWrapper := opts.MetadataProvider.GetAnimeMetadataWrapper(opts.Media, nil) episodeMetadata := mediaWrapper.GetEpisodeMetadata(episodeNumber) episode := &Episode{ Type: LocalFileTypeMain, DisplayTitle: fmt.Sprintf("Episode %d", episodeNumber), EpisodeTitle: opts.Media.GetPreferredTitle(), EpisodeNumber: episodeNumber, AniDBEpisode: fmt.Sprintf("%d", episodeNumber), AbsoluteEpisodeNumber: episodeNumber, ProgressNumber: episodeNumber, LocalFile: nil, IsDownloaded: false, EpisodeMetadata: &EpisodeMetadata{ AnidbId: 0, Image: episodeMetadata.Image, AirDate: "", Length: 0, Summary: "", Overview: "", IsFiller: false, }, FileMetadata: nil, IsInvalid: false, MetadataIssue: "", BaseAnime: opts.Media, } ec.Episodes = append(ec.Episodes, episode) } ec.HasMappingError = true return } if len(info.EpisodesToDownload) == 0 { opts.Logger.Error().Msg("torrentstream: no episodes found") return nil, fmt.Errorf("no episodes found") } ec.Episodes = lo.Map(info.EpisodesToDownload, func(episode *EntryDownloadEpisode, i int) *Episode { return episode.Episode }) slices.SortStableFunc(ec.Episodes, func(i, j *Episode) int { return cmp.Compare(i.EpisodeNumber, j.EpisodeNumber) }) event := &AnimeEpisodeCollectionEvent{ EpisodeCollection: ec, } err = hook.GlobalHookManager.OnAnimeEpisodeCollection().Trigger(event) if err != nil { return nil, err } ec = event.EpisodeCollection episodeCollectionCache.SetT(opts.Media.ID, ec, time.Minute*10) return } func ClearEpisodeCollectionCache() { episodeCollectionCache.Clear() } ///////// type NewEpisodeCollectionFromLocalFilesOptions struct { LocalFiles []*LocalFile Media *anilist.BaseAnime AnimeCollection *anilist.AnimeCollection Platform platform.Platform MetadataProvider metadata.Provider Logger *zerolog.Logger } func NewEpisodeCollectionFromLocalFiles(ctx context.Context, opts NewEpisodeCollectionFromLocalFilesOptions) (*EpisodeCollection, error) { if opts.Logger == nil { opts.Logger = lo.ToPtr(zerolog.Nop()) } if ec, ok := EpisodeCollectionFromLocalFilesCache.Get(opts.Media.GetID()); ok { return ec, nil } // Make sure to keep the local files from the media only opts.LocalFiles = lo.Filter(opts.LocalFiles, func(lf *LocalFile, i int) bool { return lf.MediaId == opts.Media.GetID() }) // Create a new media entry entry, err := NewEntry(ctx, &NewEntryOptions{ MediaId: opts.Media.GetID(), LocalFiles: opts.LocalFiles, AnimeCollection: opts.AnimeCollection, Platform: opts.Platform, MetadataProvider: opts.MetadataProvider, }) if err != nil { return nil, fmt.Errorf("cannot play local file, could not create entry: %w", err) } // Should be cached if it exists animeMetadata, err := opts.MetadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, opts.Media.ID) if err != nil { animeMetadata = &metadata.AnimeMetadata{ Titles: make(map[string]string), Episodes: make(map[string]*metadata.EpisodeMetadata), EpisodeCount: 0, SpecialCount: 0, Mappings: &metadata.AnimeMappings{ AnilistId: opts.Media.GetID(), }, } animeMetadata.Titles["en"] = opts.Media.GetTitleSafe() animeMetadata.Titles["x-jat"] = opts.Media.GetRomajiTitleSafe() err = nil } ec := &EpisodeCollection{ HasMappingError: false, Episodes: entry.Episodes, Metadata: animeMetadata, } EpisodeCollectionFromLocalFilesCache.SetT(opts.Media.GetID(), ec, time.Hour*6) return ec, nil } ///////// func (ec *EpisodeCollection) FindEpisodeByNumber(episodeNumber int) (*Episode, bool) { for _, episode := range ec.Episodes { if episode.EpisodeNumber == episodeNumber { return episode, true } } return nil, false } func (ec *EpisodeCollection) FindEpisodeByAniDB(anidbEpisode string) (*Episode, bool) { for _, episode := range ec.Episodes { if episode.AniDBEpisode == anidbEpisode { return episode, true } } return nil, false } // GetMainLocalFiles returns the *main* local files. func (ec *EpisodeCollection) GetMainLocalFiles() ([]*Episode, bool) { ret := make([]*Episode, 0) for _, episode := range ec.Episodes { if episode.LocalFile == nil || episode.LocalFile.IsMain() { ret = append(ret, episode) } } if len(ret) == 0 { return nil, false } return ret, true } // FindNextEpisode returns the *main* local file whose episode number is after the given local file. func (ec *EpisodeCollection) FindNextEpisode(current *Episode) (*Episode, bool) { episodes, ok := ec.GetMainLocalFiles() if !ok { return nil, false } // Get the local file whose episode number is after the given local file var next *Episode for _, e := range episodes { if e.GetEpisodeNumber() == current.GetEpisodeNumber()+1 { next = e break } } if next == nil { return nil, false } return next, true }