node build fixed
This commit is contained in:
467
seanime-2.9.10/internal/library/anime/collection.go
Normal file
467
seanime-2.9.10/internal/library/anime/collection.go
Normal file
@@ -0,0 +1,467 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user