468 lines
15 KiB
Go
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
|
|
}
|