Files
seanime-docker/seanime-2.9.10/internal/library/anime/collection.go
2025-09-20 14:08:38 +01:00

468 lines
15 KiB
Go

package anime
import (
"cmp"
"context"
"path/filepath"
"seanime/internal/api/anilist"
"seanime/internal/api/metadata"
"seanime/internal/hook"
"seanime/internal/platforms/platform"
"seanime/internal/util"
"slices"
"sort"
"github.com/samber/lo"
lop "github.com/samber/lo/parallel"
"github.com/sourcegraph/conc/pool"
)
type (
// LibraryCollection holds the main data for the library collection.
// It consists of:
// - ContinueWatchingList: a list of Episode for the "continue watching" feature.
// - Lists: a list of LibraryCollectionList (one for each status).
// - UnmatchedLocalFiles: a list of unmatched local files (Media id == 0). "Resolve unmatched" feature.
// - UnmatchedGroups: a list of UnmatchedGroup instances. Like UnmatchedLocalFiles, but grouped by directory. "Resolve unmatched" feature.
// - IgnoredLocalFiles: a list of ignored local files. (DEVNOTE: Unused for now)
// - UnknownGroups: a list of UnknownGroup instances. Group of files whose media is not in the user's AniList "Resolve unknown media" feature.
LibraryCollection struct {
ContinueWatchingList []*Episode `json:"continueWatchingList"`
Lists []*LibraryCollectionList `json:"lists"`
UnmatchedLocalFiles []*LocalFile `json:"unmatchedLocalFiles"`
UnmatchedGroups []*UnmatchedGroup `json:"unmatchedGroups"`
IgnoredLocalFiles []*LocalFile `json:"ignoredLocalFiles"`
UnknownGroups []*UnknownGroup `json:"unknownGroups"`
Stats *LibraryCollectionStats `json:"stats"`
Stream *StreamCollection `json:"stream,omitempty"` // Hydrated by the route handler
}
StreamCollection struct {
ContinueWatchingList []*Episode `json:"continueWatchingList"`
Anime []*anilist.BaseAnime `json:"anime"`
ListData map[int]*EntryListData `json:"listData"`
}
LibraryCollectionListType string
LibraryCollectionStats struct {
TotalEntries int `json:"totalEntries"`
TotalFiles int `json:"totalFiles"`
TotalShows int `json:"totalShows"`
TotalMovies int `json:"totalMovies"`
TotalSpecials int `json:"totalSpecials"`
TotalSize string `json:"totalSize"`
}
LibraryCollectionList struct {
Type anilist.MediaListStatus `json:"type"`
Status anilist.MediaListStatus `json:"status"`
Entries []*LibraryCollectionEntry `json:"entries"`
}
// LibraryCollectionEntry holds the data for a single entry in a LibraryCollectionList.
// It is a slimmed down version of Entry. It holds the media, media id, library data, and list data.
LibraryCollectionEntry struct {
Media *anilist.BaseAnime `json:"media"`
MediaId int `json:"mediaId"`
EntryLibraryData *EntryLibraryData `json:"libraryData"` // Library data
NakamaEntryLibraryData *NakamaEntryLibraryData `json:"nakamaLibraryData,omitempty"` // Library data from Nakama
EntryListData *EntryListData `json:"listData"` // AniList list data
}
// UnmatchedGroup holds the data for a group of unmatched local files.
UnmatchedGroup struct {
Dir string `json:"dir"`
LocalFiles []*LocalFile `json:"localFiles"`
Suggestions []*anilist.BaseAnime `json:"suggestions"`
}
// UnknownGroup holds the data for a group of local files whose media is not in the user's AniList.
// The client will use this data to suggest media to the user, so they can add it to their AniList.
UnknownGroup struct {
MediaId int `json:"mediaId"`
LocalFiles []*LocalFile `json:"localFiles"`
}
)
type (
// NewLibraryCollectionOptions is a struct that holds the data needed for creating a new LibraryCollection.
NewLibraryCollectionOptions struct {
AnimeCollection *anilist.AnimeCollection
LocalFiles []*LocalFile
Platform platform.Platform
MetadataProvider metadata.Provider
}
)
// NewLibraryCollection creates a new LibraryCollection.
func NewLibraryCollection(ctx context.Context, opts *NewLibraryCollectionOptions) (lc *LibraryCollection, err error) {
defer util.HandlePanicInModuleWithError("entities/collection/NewLibraryCollection", &err)
lc = new(LibraryCollection)
reqEvent := &AnimeLibraryCollectionRequestedEvent{
AnimeCollection: opts.AnimeCollection,
LocalFiles: opts.LocalFiles,
LibraryCollection: lc,
}
err = hook.GlobalHookManager.OnAnimeLibraryCollectionRequested().Trigger(reqEvent)
if err != nil {
return nil, err
}
opts.AnimeCollection = reqEvent.AnimeCollection // Override the anime collection
opts.LocalFiles = reqEvent.LocalFiles // Override the local files
lc = reqEvent.LibraryCollection // Override the library collection
if reqEvent.DefaultPrevented {
event := &AnimeLibraryCollectionEvent{
LibraryCollection: lc,
}
err = hook.GlobalHookManager.OnAnimeLibraryCollection().Trigger(event)
if err != nil {
return nil, err
}
return event.LibraryCollection, nil
}
// Get lists from collection
aniLists := opts.AnimeCollection.GetMediaListCollection().GetLists()
// Create lists
lc.hydrateCollectionLists(
opts.LocalFiles,
aniLists,
)
lc.hydrateStats(opts.LocalFiles)
// Add Continue Watching list
lc.hydrateContinueWatchingList(
ctx,
opts.LocalFiles,
opts.AnimeCollection,
opts.Platform,
opts.MetadataProvider,
)
lc.UnmatchedLocalFiles = lo.Filter(opts.LocalFiles, func(lf *LocalFile, index int) bool {
return lf.MediaId == 0 && !lf.Ignored
})
lc.IgnoredLocalFiles = lo.Filter(opts.LocalFiles, func(lf *LocalFile, index int) bool {
return lf.Ignored == true
})
slices.SortStableFunc(lc.IgnoredLocalFiles, func(i, j *LocalFile) int {
return cmp.Compare(i.GetPath(), j.GetPath())
})
lc.hydrateUnmatchedGroups()
// Event
event := &AnimeLibraryCollectionEvent{
LibraryCollection: lc,
}
hook.GlobalHookManager.OnAnimeLibraryCollection().Trigger(event)
lc = event.LibraryCollection
return
}
//----------------------------------------------------------------------------------------------------------------------
func (lc *LibraryCollection) hydrateCollectionLists(
localFiles []*LocalFile,
aniLists []*anilist.AnimeCollection_MediaListCollection_Lists,
) {
// Group local files by media id
groupedLfs := GroupLocalFilesByMediaID(localFiles)
// Get slice of media ids from local files
mIds := GetMediaIdsFromLocalFiles(localFiles)
foundIds := make([]int, 0)
for _, list := range aniLists {
entries := list.GetEntries()
for _, entry := range entries {
foundIds = append(foundIds, entry.Media.ID)
}
}
// Create a new LibraryCollectionList for each list
// This is done in parallel
p := pool.NewWithResults[*LibraryCollectionList]()
for _, list := range aniLists {
p.Go(func() *LibraryCollectionList {
// If the list has no status, return nil
// This occurs when there are custom lists (DEVNOTE: This shouldn't occur because we remove custom lists when the collection is fetched)
if list.Status == nil {
return nil
}
// For each list, get the entries
entries := list.GetEntries()
// For each entry, check if the media id is in the local files
// If it is, create a new LibraryCollectionEntry with the associated local files
p2 := pool.NewWithResults[*LibraryCollectionEntry]()
for _, entry := range entries {
p2.Go(func() *LibraryCollectionEntry {
if slices.Contains(mIds, entry.Media.ID) {
entryLfs, _ := groupedLfs[entry.Media.ID]
libraryData, _ := NewEntryLibraryData(&NewEntryLibraryDataOptions{
EntryLocalFiles: entryLfs,
MediaId: entry.Media.ID,
CurrentProgress: entry.GetProgressSafe(),
})
return &LibraryCollectionEntry{
MediaId: entry.Media.ID,
Media: entry.Media,
EntryLibraryData: libraryData,
EntryListData: &EntryListData{
Progress: entry.GetProgressSafe(),
Score: entry.GetScoreSafe(),
Status: entry.Status,
Repeat: entry.GetRepeatSafe(),
StartedAt: anilist.ToEntryStartDate(entry.StartedAt),
CompletedAt: anilist.ToEntryCompletionDate(entry.CompletedAt),
},
}
} else {
return nil
}
})
}
r := p2.Wait()
// Filter out nil entries
r = lo.Filter(r, func(item *LibraryCollectionEntry, index int) bool {
return item != nil
})
// Sort by title
sort.Slice(r, func(i, j int) bool {
return r[i].Media.GetTitleSafe() < r[j].Media.GetTitleSafe()
})
// Return a new LibraryEntries struct
return &LibraryCollectionList{
Type: getLibraryCollectionEntryFromListStatus(*list.Status),
Status: *list.Status,
Entries: r,
}
})
}
// Get the lists from the pool
lists := p.Wait()
// Filter out nil entries
lists = lo.Filter(lists, func(item *LibraryCollectionList, index int) bool {
return item != nil
})
// Merge repeating to current (no need to show repeating as a separate list)
repeatingList, ok := lo.Find(lists, func(item *LibraryCollectionList) bool {
return item.Status == anilist.MediaListStatusRepeating
})
if ok {
currentList, ok := lo.Find(lists, func(item *LibraryCollectionList) bool {
return item.Status == anilist.MediaListStatusCurrent
})
if len(repeatingList.Entries) > 0 && ok {
currentList.Entries = append(currentList.Entries, repeatingList.Entries...)
} else if len(repeatingList.Entries) > 0 {
newCurrentList := repeatingList
newCurrentList.Type = anilist.MediaListStatusCurrent
lists = append(lists, newCurrentList)
}
// Remove repeating from lists
lists = lo.Filter(lists, func(item *LibraryCollectionList, index int) bool {
return item.Status != anilist.MediaListStatusRepeating
})
}
// Lists
lc.Lists = lists
if lc.Lists == nil {
lc.Lists = make([]*LibraryCollectionList, 0)
}
// +---------------------+
// | Unknown media ids |
// +---------------------+
unknownIds := make([]int, 0)
for _, id := range mIds {
if id != 0 && !slices.Contains(foundIds, id) {
unknownIds = append(unknownIds, id)
}
}
lc.UnknownGroups = make([]*UnknownGroup, 0)
for _, id := range unknownIds {
lc.UnknownGroups = append(lc.UnknownGroups, &UnknownGroup{
MediaId: id,
LocalFiles: groupedLfs[id],
})
}
return
}
//----------------------------------------------------------------------------------------------------------------------
func (lc *LibraryCollection) hydrateStats(lfs []*LocalFile) {
stats := &LibraryCollectionStats{
TotalFiles: len(lfs),
TotalEntries: 0,
TotalShows: 0,
TotalMovies: 0,
TotalSpecials: 0,
TotalSize: "", // Will be set by the route handler
}
for _, list := range lc.Lists {
for _, entry := range list.Entries {
stats.TotalEntries++
if entry.Media.Format != nil {
if *entry.Media.Format == anilist.MediaFormatMovie {
stats.TotalMovies++
} else if *entry.Media.Format == anilist.MediaFormatSpecial || *entry.Media.Format == anilist.MediaFormatOva {
stats.TotalSpecials++
} else {
stats.TotalShows++
}
}
}
}
lc.Stats = stats
}
//----------------------------------------------------------------------------------------------------------------------
// hydrateContinueWatchingList creates a list of Episode for the "continue watching" feature.
// This should be called after the LibraryCollectionList's have been created.
func (lc *LibraryCollection) hydrateContinueWatchingList(
ctx context.Context,
localFiles []*LocalFile,
animeCollection *anilist.AnimeCollection,
platform platform.Platform,
metadataProvider metadata.Provider,
) {
// Get currently watching list
current, found := lo.Find(lc.Lists, func(item *LibraryCollectionList) bool {
return item.Status == anilist.MediaListStatusCurrent
})
// If no currently watching list is found, return an empty slice
if !found {
lc.ContinueWatchingList = make([]*Episode, 0) // Set empty slice
return
}
// Get media ids from current list
mIds := make([]int, len(current.Entries))
for i, entry := range current.Entries {
mIds[i] = entry.MediaId
}
// Create a new Entry for each media id
mEntryPool := pool.NewWithResults[*Entry]()
for _, mId := range mIds {
mEntryPool.Go(func() *Entry {
me, _ := NewEntry(ctx, &NewEntryOptions{
MediaId: mId,
LocalFiles: localFiles,
AnimeCollection: animeCollection,
Platform: platform,
MetadataProvider: metadataProvider,
})
return me
})
}
mEntries := mEntryPool.Wait()
mEntries = lo.Filter(mEntries, func(item *Entry, index int) bool {
return item != nil
}) // Filter out nil entries
// If there are no entries, return an empty slice
if len(mEntries) == 0 {
lc.ContinueWatchingList = make([]*Episode, 0) // Return empty slice
return
}
// Sort by progress
sort.Slice(mEntries, func(i, j int) bool {
return mEntries[i].EntryListData.Progress > mEntries[j].EntryListData.Progress
})
// Remove entries the user has watched all episodes of
mEntries = lop.Map(mEntries, func(mEntry *Entry, index int) *Entry {
if !mEntry.HasWatchedAll() {
return mEntry
}
return nil
})
mEntries = lo.Filter(mEntries, func(item *Entry, index int) bool {
return item != nil
})
// Get the next episode for each media entry
mEpisodes := lop.Map(mEntries, func(mEntry *Entry, index int) *Episode {
ep, ok := mEntry.FindNextEpisode()
if ok {
return ep
}
return nil
})
mEpisodes = lo.Filter(mEpisodes, func(item *Episode, index int) bool {
return item != nil
})
lc.ContinueWatchingList = mEpisodes
}
//----------------------------------------------------------------------------------------------------------------------
// hydrateUnmatchedGroups is a method of the LibraryCollection struct.
// It is responsible for grouping unmatched local files by their directory and creating UnmatchedGroup instances for each group.
func (lc *LibraryCollection) hydrateUnmatchedGroups() {
groups := make([]*UnmatchedGroup, 0)
// Group by directory
groupedLfs := lop.GroupBy(lc.UnmatchedLocalFiles, func(lf *LocalFile) string {
return filepath.Dir(lf.GetPath())
})
for key, value := range groupedLfs {
groups = append(groups, &UnmatchedGroup{
Dir: key,
LocalFiles: value,
Suggestions: make([]*anilist.BaseAnime, 0),
})
}
slices.SortStableFunc(groups, func(i, j *UnmatchedGroup) int {
return cmp.Compare(i.Dir, j.Dir)
})
// Assign the created groups
lc.UnmatchedGroups = groups
}
//----------------------------------------------------------------------------------------------------------------------
// getLibraryCollectionEntryFromListStatus maps anilist.MediaListStatus to LibraryCollectionListType.
func getLibraryCollectionEntryFromListStatus(st anilist.MediaListStatus) anilist.MediaListStatus {
if st == anilist.MediaListStatusRepeating {
return anilist.MediaListStatusCurrent
}
return st
}