node build fixed
This commit is contained in:
8
seanime-2.9.10/internal/library/anime/README.md
Normal file
8
seanime-2.9.10/internal/library/anime/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# anime
|
||||
|
||||
This package contains structs that represent the main data structures of the local anime library.
|
||||
Such as `LocalFile` and `LibraryEntry`.
|
||||
|
||||
### 🚫 Do not
|
||||
|
||||
- Do not import **database**.
|
||||
35
seanime-2.9.10/internal/library/anime/autodownloader_rule.go
Normal file
35
seanime-2.9.10/internal/library/anime/autodownloader_rule.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package anime
|
||||
|
||||
// DEVNOTE: The structs are defined in this file because they are imported by both the autodownloader package and the db package.
|
||||
// Defining them in the autodownloader package would create a circular dependency because the db package imports these structs.
|
||||
|
||||
const (
|
||||
AutoDownloaderRuleTitleComparisonContains AutoDownloaderRuleTitleComparisonType = "contains"
|
||||
AutoDownloaderRuleTitleComparisonLikely AutoDownloaderRuleTitleComparisonType = "likely"
|
||||
)
|
||||
|
||||
const (
|
||||
AutoDownloaderRuleEpisodeRecent AutoDownloaderRuleEpisodeType = "recent"
|
||||
AutoDownloaderRuleEpisodeSelected AutoDownloaderRuleEpisodeType = "selected"
|
||||
)
|
||||
|
||||
type (
|
||||
AutoDownloaderRuleTitleComparisonType string
|
||||
AutoDownloaderRuleEpisodeType string
|
||||
|
||||
// AutoDownloaderRule is a rule that is used to automatically download media.
|
||||
// The structs are sent to the client, thus adding `dbId` to facilitate mutations.
|
||||
AutoDownloaderRule struct {
|
||||
DbID uint `json:"dbId"` // Will be set when fetched from the database
|
||||
Enabled bool `json:"enabled"`
|
||||
MediaId int `json:"mediaId"`
|
||||
ReleaseGroups []string `json:"releaseGroups"`
|
||||
Resolutions []string `json:"resolutions"`
|
||||
ComparisonTitle string `json:"comparisonTitle"`
|
||||
TitleComparisonType AutoDownloaderRuleTitleComparisonType `json:"titleComparisonType"`
|
||||
EpisodeType AutoDownloaderRuleEpisodeType `json:"episodeType"`
|
||||
EpisodeNumbers []int `json:"episodeNumbers,omitempty"`
|
||||
Destination string `json:"destination"`
|
||||
AdditionalTerms []string `json:"additionalTerms"`
|
||||
}
|
||||
)
|
||||
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
|
||||
}
|
||||
95
seanime-2.9.10/internal/library/anime/collection_test.go
Normal file
95
seanime-2.9.10/internal/library/anime/collection_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package anime_test
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/platforms/anilist_platform"
|
||||
"seanime/internal/test_utils"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewLibraryCollection(t *testing.T) {
|
||||
test_utils.InitTestProvider(t, test_utils.Anilist())
|
||||
logger := util.NewLogger()
|
||||
metadataProvider := metadata.GetMockProvider(t)
|
||||
|
||||
anilistClient := anilist.TestGetMockAnilistClient()
|
||||
anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger)
|
||||
|
||||
animeCollection, err := anilistPlatform.GetAnimeCollection(t.Context(), false)
|
||||
|
||||
if assert.NoError(t, err) {
|
||||
|
||||
// Mock Anilist collection and local files
|
||||
// User is currently watching Sousou no Frieren and One Piece
|
||||
lfs := make([]*anime.LocalFile, 0)
|
||||
|
||||
// Sousou no Frieren
|
||||
// 7 episodes downloaded, 4 watched
|
||||
mediaId := 154587
|
||||
lfs = append(lfs, anime.MockHydratedLocalFiles(
|
||||
anime.MockGenerateHydratedLocalFileGroupOptions("E:/Anime", "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - %ep (1080p) [F02B9CEE].mkv", mediaId, []anime.MockHydratedLocalFileWrapperOptionsMetadata{
|
||||
{MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 3, MetadataAniDbEpisode: "3", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 4, MetadataAniDbEpisode: "4", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 5, MetadataAniDbEpisode: "5", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 6, MetadataAniDbEpisode: "6", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 7, MetadataAniDbEpisode: "7", MetadataType: anime.LocalFileTypeMain},
|
||||
}),
|
||||
)...)
|
||||
anilist.TestModifyAnimeCollectionEntry(animeCollection, mediaId, anilist.TestModifyAnimeCollectionEntryInput{
|
||||
Status: lo.ToPtr(anilist.MediaListStatusCurrent),
|
||||
Progress: lo.ToPtr(4), // Mock progress
|
||||
})
|
||||
|
||||
// One Piece
|
||||
// Downloaded 1070-1075 but only watched up until 1060
|
||||
mediaId = 21
|
||||
lfs = append(lfs, anime.MockHydratedLocalFiles(
|
||||
anime.MockGenerateHydratedLocalFileGroupOptions("E:/Anime", "E:\\Anime\\One Piece\\[SubsPlease] One Piece - %ep (1080p) [F02B9CEE].mkv", mediaId, []anime.MockHydratedLocalFileWrapperOptionsMetadata{
|
||||
{MetadataEpisode: 1070, MetadataAniDbEpisode: "1070", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 1071, MetadataAniDbEpisode: "1071", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 1072, MetadataAniDbEpisode: "1072", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 1073, MetadataAniDbEpisode: "1073", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 1074, MetadataAniDbEpisode: "1074", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 1075, MetadataAniDbEpisode: "1075", MetadataType: anime.LocalFileTypeMain},
|
||||
}),
|
||||
)...)
|
||||
anilist.TestModifyAnimeCollectionEntry(animeCollection, mediaId, anilist.TestModifyAnimeCollectionEntryInput{
|
||||
Status: lo.ToPtr(anilist.MediaListStatusCurrent),
|
||||
Progress: lo.ToPtr(1060), // Mock progress
|
||||
})
|
||||
|
||||
// Add unmatched local files
|
||||
mediaId = 0
|
||||
lfs = append(lfs, anime.MockHydratedLocalFiles(
|
||||
anime.MockGenerateHydratedLocalFileGroupOptions("E:/Anime", "E:\\Anime\\Unmatched\\[SubsPlease] Unmatched - %ep (1080p) [F02B9CEE].mkv", mediaId, []anime.MockHydratedLocalFileWrapperOptionsMetadata{
|
||||
{MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 3, MetadataAniDbEpisode: "3", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 4, MetadataAniDbEpisode: "4", MetadataType: anime.LocalFileTypeMain},
|
||||
}),
|
||||
)...)
|
||||
|
||||
libraryCollection, err := anime.NewLibraryCollection(t.Context(), &anime.NewLibraryCollectionOptions{
|
||||
AnimeCollection: animeCollection,
|
||||
LocalFiles: lfs,
|
||||
Platform: anilistPlatform,
|
||||
MetadataProvider: metadataProvider,
|
||||
})
|
||||
|
||||
if assert.NoError(t, err) {
|
||||
|
||||
assert.Equal(t, 1, len(libraryCollection.ContinueWatchingList)) // Only Sousou no Frieren is in the continue watching list
|
||||
assert.Equal(t, 4, len(libraryCollection.UnmatchedLocalFiles)) // 4 unmatched local files
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
377
seanime-2.9.10/internal/library/anime/entry.go
Normal file
377
seanime-2.9.10/internal/library/anime/entry.go
Normal file
@@ -0,0 +1,377 @@
|
||||
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
|
||||
}
|
||||
350
seanime-2.9.10/internal/library/anime/entry_download_info.go
Normal file
350
seanime-2.9.10/internal/library/anime/entry_download_info.go
Normal file
@@ -0,0 +1,350 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/hook"
|
||||
"strconv"
|
||||
|
||||
"github.com/samber/lo"
|
||||
"github.com/sourcegraph/conc/pool"
|
||||
)
|
||||
|
||||
type (
|
||||
// EntryDownloadInfo is instantiated by the Entry
|
||||
EntryDownloadInfo struct {
|
||||
EpisodesToDownload []*EntryDownloadEpisode `json:"episodesToDownload"`
|
||||
CanBatch bool `json:"canBatch"`
|
||||
BatchAll bool `json:"batchAll"`
|
||||
HasInaccurateSchedule bool `json:"hasInaccurateSchedule"`
|
||||
Rewatch bool `json:"rewatch"`
|
||||
AbsoluteOffset int `json:"absoluteOffset"`
|
||||
}
|
||||
|
||||
EntryDownloadEpisode struct {
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
AniDBEpisode string `json:"aniDBEpisode"`
|
||||
Episode *Episode `json:"episode"`
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
NewEntryDownloadInfoOptions struct {
|
||||
// Media's local files
|
||||
LocalFiles []*LocalFile
|
||||
AnimeMetadata *metadata.AnimeMetadata
|
||||
Media *anilist.BaseAnime
|
||||
Progress *int
|
||||
Status *anilist.MediaListStatus
|
||||
MetadataProvider metadata.Provider
|
||||
}
|
||||
)
|
||||
|
||||
// NewEntryDownloadInfo returns a list of episodes to download or episodes for the torrent/debrid streaming views
|
||||
// based on the options provided.
|
||||
func NewEntryDownloadInfo(opts *NewEntryDownloadInfoOptions) (*EntryDownloadInfo, error) {
|
||||
|
||||
reqEvent := &AnimeEntryDownloadInfoRequestedEvent{
|
||||
LocalFiles: opts.LocalFiles,
|
||||
AnimeMetadata: opts.AnimeMetadata,
|
||||
Media: opts.Media,
|
||||
Progress: opts.Progress,
|
||||
Status: opts.Status,
|
||||
EntryDownloadInfo: &EntryDownloadInfo{},
|
||||
}
|
||||
|
||||
err := hook.GlobalHookManager.OnAnimeEntryDownloadInfoRequested().Trigger(reqEvent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if reqEvent.DefaultPrevented {
|
||||
return reqEvent.EntryDownloadInfo, nil
|
||||
}
|
||||
|
||||
opts.LocalFiles = reqEvent.LocalFiles
|
||||
opts.AnimeMetadata = reqEvent.AnimeMetadata
|
||||
opts.Media = reqEvent.Media
|
||||
opts.Progress = reqEvent.Progress
|
||||
opts.Status = reqEvent.Status
|
||||
|
||||
if *opts.Media.Status == anilist.MediaStatusNotYetReleased {
|
||||
return &EntryDownloadInfo{}, nil
|
||||
}
|
||||
if opts.AnimeMetadata == nil {
|
||||
return nil, errors.New("could not get anime metadata")
|
||||
}
|
||||
currentEpisodeCount := opts.Media.GetCurrentEpisodeCount()
|
||||
if currentEpisodeCount == -1 && opts.AnimeMetadata != nil {
|
||||
currentEpisodeCount = opts.AnimeMetadata.GetCurrentEpisodeCount()
|
||||
}
|
||||
if currentEpisodeCount == -1 {
|
||||
return nil, errors.New("could not get current media episode count")
|
||||
}
|
||||
|
||||
// +---------------------+
|
||||
// | Discrepancy |
|
||||
// +---------------------+
|
||||
|
||||
// Whether AniList includes episode 0 as part of main episodes, but AniDB does not, however AniDB has "S1"
|
||||
discrepancy := FindDiscrepancy(opts.Media, opts.AnimeMetadata)
|
||||
|
||||
// AniList is the source of truth for episode numbers
|
||||
epSlice := newEpisodeSlice(currentEpisodeCount)
|
||||
|
||||
// Handle discrepancies
|
||||
if discrepancy != DiscrepancyNone {
|
||||
|
||||
// If AniList includes episode 0 as part of main episodes, but AniDB does not, however AniDB has "S1"
|
||||
if discrepancy == DiscrepancyAniListCountsEpisodeZero {
|
||||
// Add "S1" to the beginning of the episode slice
|
||||
epSlice.trimEnd(1)
|
||||
epSlice.prepend(0, "S1")
|
||||
}
|
||||
|
||||
// If AniList includes specials, but AniDB does not
|
||||
if discrepancy == DiscrepancyAniListCountsSpecials {
|
||||
diff := currentEpisodeCount - opts.AnimeMetadata.GetMainEpisodeCount()
|
||||
epSlice.trimEnd(diff)
|
||||
for i := 0; i < diff; i++ {
|
||||
epSlice.add(currentEpisodeCount-i, "S"+strconv.Itoa(i+1))
|
||||
}
|
||||
}
|
||||
|
||||
// If AniDB has more episodes than AniList
|
||||
if discrepancy == DiscrepancyAniDBHasMore {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Filter out episodes not aired
|
||||
if opts.Media.NextAiringEpisode != nil {
|
||||
epSlice.filter(func(item *episodeSliceItem, index int) bool {
|
||||
// e.g. if the next airing episode is 13, then filter out episodes 14 and above
|
||||
return index+1 < opts.Media.NextAiringEpisode.Episode
|
||||
})
|
||||
}
|
||||
|
||||
// Get progress, if the media isn't in the user's list, progress is 0
|
||||
// If the media is completed, set progress is 0
|
||||
progress := 0
|
||||
if opts.Progress != nil {
|
||||
progress = *opts.Progress
|
||||
}
|
||||
if opts.Status != nil {
|
||||
if *opts.Status == anilist.MediaListStatusCompleted {
|
||||
progress = 0
|
||||
}
|
||||
}
|
||||
|
||||
hasInaccurateSchedule := false
|
||||
if opts.Media.NextAiringEpisode == nil && *opts.Media.Status == anilist.MediaStatusReleasing {
|
||||
hasInaccurateSchedule = true
|
||||
}
|
||||
|
||||
// Filter out episodes already watched (index+1 is the progress number)
|
||||
toDownloadSlice := epSlice.filterNew(func(item *episodeSliceItem, index int) bool {
|
||||
return index+1 > progress
|
||||
})
|
||||
|
||||
// This slice contains episode numbers that are not downloaded
|
||||
// The source of truth is AniDB, but we will handle discrepancies
|
||||
lfsEpSlice := newEpisodeSlice(0)
|
||||
if opts.LocalFiles != nil {
|
||||
// Get all episode numbers of main local files
|
||||
for _, lf := range opts.LocalFiles {
|
||||
if lf.Metadata.Type == LocalFileTypeMain {
|
||||
lfsEpSlice.add(lf.Metadata.Episode, lf.Metadata.AniDBEpisode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out downloaded episodes
|
||||
toDownloadSlice.filter(func(item *episodeSliceItem, index int) bool {
|
||||
isDownloaded := false
|
||||
for _, lf := range opts.LocalFiles {
|
||||
if lf.Metadata.Type != LocalFileTypeMain {
|
||||
continue
|
||||
}
|
||||
// If the file episode number matches that of the episode slice item
|
||||
if lf.Metadata.Episode == item.episodeNumber {
|
||||
isDownloaded = true
|
||||
}
|
||||
// If the slice episode number is 0 and the file is a main S1
|
||||
if discrepancy == DiscrepancyAniListCountsEpisodeZero && item.episodeNumber == 0 && lf.Metadata.AniDBEpisode == "S1" {
|
||||
isDownloaded = true
|
||||
}
|
||||
}
|
||||
|
||||
return !isDownloaded
|
||||
})
|
||||
|
||||
// +---------------------+
|
||||
// | EntryEpisode |
|
||||
// +---------------------+
|
||||
|
||||
// Generate `episodesToDownload` based on `toDownloadSlice`
|
||||
|
||||
// DEVNOTE: The EntryEpisode generated has inaccurate progress numbers since not local files are passed in
|
||||
|
||||
progressOffset := 0
|
||||
if discrepancy == DiscrepancyAniListCountsEpisodeZero {
|
||||
progressOffset = 1
|
||||
}
|
||||
|
||||
p := pool.NewWithResults[*EntryDownloadEpisode]()
|
||||
for _, ep := range toDownloadSlice.getSlice() {
|
||||
p.Go(func() *EntryDownloadEpisode {
|
||||
str := new(EntryDownloadEpisode)
|
||||
str.EpisodeNumber = ep.episodeNumber
|
||||
str.AniDBEpisode = ep.aniDBEpisode
|
||||
// Create a new episode with a placeholder local file
|
||||
// We pass that placeholder local file so that all episodes are hydrated as main episodes for consistency
|
||||
str.Episode = NewEpisode(&NewEpisodeOptions{
|
||||
LocalFile: &LocalFile{
|
||||
ParsedData: &LocalFileParsedData{},
|
||||
ParsedFolderData: []*LocalFileParsedData{},
|
||||
Metadata: &LocalFileMetadata{
|
||||
Episode: ep.episodeNumber,
|
||||
Type: LocalFileTypeMain,
|
||||
AniDBEpisode: ep.aniDBEpisode,
|
||||
},
|
||||
},
|
||||
OptionalAniDBEpisode: str.AniDBEpisode,
|
||||
AnimeMetadata: opts.AnimeMetadata,
|
||||
Media: opts.Media,
|
||||
ProgressOffset: progressOffset,
|
||||
IsDownloaded: false,
|
||||
MetadataProvider: opts.MetadataProvider,
|
||||
})
|
||||
str.Episode.AniDBEpisode = ep.aniDBEpisode
|
||||
// Reset the local file to nil, since it's a placeholder
|
||||
str.Episode.LocalFile = nil
|
||||
return str
|
||||
})
|
||||
}
|
||||
episodesToDownload := p.Wait()
|
||||
|
||||
//--------------
|
||||
|
||||
canBatch := false
|
||||
if *opts.Media.GetStatus() == anilist.MediaStatusFinished && opts.Media.GetTotalEpisodeCount() > 0 {
|
||||
canBatch = true
|
||||
}
|
||||
batchAll := false
|
||||
if canBatch && lfsEpSlice.len() == 0 && progress == 0 {
|
||||
batchAll = true
|
||||
}
|
||||
rewatch := false
|
||||
if opts.Status != nil && *opts.Status == anilist.MediaListStatusCompleted {
|
||||
rewatch = true
|
||||
}
|
||||
|
||||
downloadInfo := &EntryDownloadInfo{
|
||||
EpisodesToDownload: episodesToDownload,
|
||||
CanBatch: canBatch,
|
||||
BatchAll: batchAll,
|
||||
Rewatch: rewatch,
|
||||
HasInaccurateSchedule: hasInaccurateSchedule,
|
||||
AbsoluteOffset: opts.AnimeMetadata.GetOffset(),
|
||||
}
|
||||
|
||||
event := &AnimeEntryDownloadInfoEvent{
|
||||
EntryDownloadInfo: downloadInfo,
|
||||
}
|
||||
err = hook.GlobalHookManager.OnAnimeEntryDownloadInfo().Trigger(event)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return event.EntryDownloadInfo, nil
|
||||
}
|
||||
|
||||
type episodeSliceItem struct {
|
||||
episodeNumber int
|
||||
aniDBEpisode string
|
||||
}
|
||||
|
||||
type episodeSlice []*episodeSliceItem
|
||||
|
||||
func newEpisodeSlice(episodeCount int) *episodeSlice {
|
||||
s := make([]*episodeSliceItem, 0)
|
||||
for i := 0; i < episodeCount; i++ {
|
||||
s = append(s, &episodeSliceItem{episodeNumber: i + 1, aniDBEpisode: strconv.Itoa(i + 1)})
|
||||
}
|
||||
ret := &episodeSlice{}
|
||||
ret.set(s)
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *episodeSlice) set(eps []*episodeSliceItem) {
|
||||
*s = eps
|
||||
}
|
||||
|
||||
func (s *episodeSlice) add(episodeNumber int, aniDBEpisode string) {
|
||||
*s = append(*s, &episodeSliceItem{episodeNumber: episodeNumber, aniDBEpisode: aniDBEpisode})
|
||||
}
|
||||
|
||||
func (s *episodeSlice) prepend(episodeNumber int, aniDBEpisode string) {
|
||||
*s = append([]*episodeSliceItem{{episodeNumber: episodeNumber, aniDBEpisode: aniDBEpisode}}, *s...)
|
||||
}
|
||||
|
||||
func (s *episodeSlice) trimEnd(n int) {
|
||||
*s = (*s)[:len(*s)-n]
|
||||
}
|
||||
|
||||
func (s *episodeSlice) trimStart(n int) {
|
||||
*s = (*s)[n:]
|
||||
}
|
||||
|
||||
func (s *episodeSlice) len() int {
|
||||
return len(*s)
|
||||
}
|
||||
|
||||
func (s *episodeSlice) get(index int) *episodeSliceItem {
|
||||
return (*s)[index]
|
||||
}
|
||||
|
||||
func (s *episodeSlice) getEpisodeNumber(episodeNumber int) *episodeSliceItem {
|
||||
for _, item := range *s {
|
||||
if item.episodeNumber == episodeNumber {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *episodeSlice) filter(filter func(*episodeSliceItem, int) bool) {
|
||||
*s = lo.Filter(*s, filter)
|
||||
}
|
||||
|
||||
func (s *episodeSlice) filterNew(filter func(*episodeSliceItem, int) bool) *episodeSlice {
|
||||
s2 := make(episodeSlice, 0)
|
||||
for i, item := range *s {
|
||||
if filter(item, i) {
|
||||
s2 = append(s2, item)
|
||||
}
|
||||
}
|
||||
return &s2
|
||||
}
|
||||
|
||||
func (s *episodeSlice) copy() *episodeSlice {
|
||||
s2 := make(episodeSlice, len(*s), cap(*s))
|
||||
for i, item := range *s {
|
||||
s2[i] = item
|
||||
}
|
||||
return &s2
|
||||
}
|
||||
|
||||
func (s *episodeSlice) getSlice() []*episodeSliceItem {
|
||||
return *s
|
||||
}
|
||||
|
||||
func (s *episodeSlice) print() {
|
||||
for i, item := range *s {
|
||||
fmt.Printf("(%d) %d -> %s\n", i, item.episodeNumber, item.aniDBEpisode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package anime_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/test_utils"
|
||||
"testing"
|
||||
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewEntryDownloadInfo(t *testing.T) {
|
||||
test_utils.InitTestProvider(t, test_utils.Anilist())
|
||||
|
||||
metadataProvider := metadata.GetMockProvider(t)
|
||||
|
||||
anilistClient := anilist.TestGetMockAnilistClient()
|
||||
animeCollection, err := anilistClient.AnimeCollection(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
localFiles []*anime.LocalFile
|
||||
mediaId int
|
||||
currentProgress int
|
||||
status anilist.MediaListStatus
|
||||
expectedEpisodeNumbersToDownload []struct {
|
||||
episodeNumber int
|
||||
aniDbEpisode string
|
||||
}
|
||||
}{
|
||||
{
|
||||
// AniList includes episode 0 as a main episode but AniDB lists it as a special S1
|
||||
// So we should expect to see episode 0 (S1) in the list of episodes to download
|
||||
name: "Mushoku Tensei: Jobless Reincarnation Season 2",
|
||||
localFiles: nil,
|
||||
mediaId: 146065,
|
||||
currentProgress: 0,
|
||||
status: anilist.MediaListStatusCurrent,
|
||||
expectedEpisodeNumbersToDownload: []struct {
|
||||
episodeNumber int
|
||||
aniDbEpisode string
|
||||
}{
|
||||
{episodeNumber: 0, aniDbEpisode: "S1"},
|
||||
{episodeNumber: 1, aniDbEpisode: "1"},
|
||||
{episodeNumber: 2, aniDbEpisode: "2"},
|
||||
{episodeNumber: 3, aniDbEpisode: "3"},
|
||||
{episodeNumber: 4, aniDbEpisode: "4"},
|
||||
{episodeNumber: 5, aniDbEpisode: "5"},
|
||||
{episodeNumber: 6, aniDbEpisode: "6"},
|
||||
{episodeNumber: 7, aniDbEpisode: "7"},
|
||||
{episodeNumber: 8, aniDbEpisode: "8"},
|
||||
{episodeNumber: 9, aniDbEpisode: "9"},
|
||||
{episodeNumber: 10, aniDbEpisode: "10"},
|
||||
{episodeNumber: 11, aniDbEpisode: "11"},
|
||||
{episodeNumber: 12, aniDbEpisode: "12"},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Same as above but progress of 1 should just eliminate episode 0 from the list and not episode 1
|
||||
name: "Mushoku Tensei: Jobless Reincarnation Season 2 - 2",
|
||||
localFiles: nil,
|
||||
mediaId: 146065,
|
||||
currentProgress: 1,
|
||||
status: anilist.MediaListStatusCurrent,
|
||||
expectedEpisodeNumbersToDownload: []struct {
|
||||
episodeNumber int
|
||||
aniDbEpisode string
|
||||
}{
|
||||
{episodeNumber: 1, aniDbEpisode: "1"},
|
||||
{episodeNumber: 2, aniDbEpisode: "2"},
|
||||
{episodeNumber: 3, aniDbEpisode: "3"},
|
||||
{episodeNumber: 4, aniDbEpisode: "4"},
|
||||
{episodeNumber: 5, aniDbEpisode: "5"},
|
||||
{episodeNumber: 6, aniDbEpisode: "6"},
|
||||
{episodeNumber: 7, aniDbEpisode: "7"},
|
||||
{episodeNumber: 8, aniDbEpisode: "8"},
|
||||
{episodeNumber: 9, aniDbEpisode: "9"},
|
||||
{episodeNumber: 10, aniDbEpisode: "10"},
|
||||
{episodeNumber: 11, aniDbEpisode: "11"},
|
||||
{episodeNumber: 12, aniDbEpisode: "12"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
anilistEntry, _ := animeCollection.GetListEntryFromAnimeId(tt.mediaId)
|
||||
|
||||
animeMetadata, err := metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, tt.mediaId)
|
||||
require.NoError(t, err)
|
||||
|
||||
info, err := anime.NewEntryDownloadInfo(&anime.NewEntryDownloadInfoOptions{
|
||||
LocalFiles: tt.localFiles,
|
||||
Progress: &tt.currentProgress,
|
||||
Status: &tt.status,
|
||||
Media: anilistEntry.Media,
|
||||
MetadataProvider: metadataProvider,
|
||||
AnimeMetadata: animeMetadata,
|
||||
})
|
||||
|
||||
if assert.NoError(t, err) && assert.NotNil(t, info) {
|
||||
|
||||
foundEpToDownload := make([]struct {
|
||||
episodeNumber int
|
||||
aniDbEpisode string
|
||||
}, 0)
|
||||
for _, ep := range info.EpisodesToDownload {
|
||||
foundEpToDownload = append(foundEpToDownload, struct {
|
||||
episodeNumber int
|
||||
aniDbEpisode string
|
||||
}{
|
||||
episodeNumber: ep.EpisodeNumber,
|
||||
aniDbEpisode: ep.AniDBEpisode,
|
||||
})
|
||||
}
|
||||
|
||||
assert.ElementsMatch(t, tt.expectedEpisodeNumbersToDownload, foundEpToDownload)
|
||||
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestNewEntryDownloadInfo2(t *testing.T) {
|
||||
test_utils.InitTestProvider(t, test_utils.Anilist())
|
||||
|
||||
mediaId := 21
|
||||
|
||||
metadataProvider := metadata.GetMockProvider(t)
|
||||
|
||||
anilistClient := anilist.TestGetMockAnilistClient()
|
||||
animeCollection, err := anilistClient.AnimeCollection(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
anilistEntry, _ := animeCollection.GetListEntryFromAnimeId(mediaId)
|
||||
|
||||
animeMetadata, err := metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, mediaId)
|
||||
require.NoError(t, err)
|
||||
|
||||
info, err := anime.NewEntryDownloadInfo(&anime.NewEntryDownloadInfoOptions{
|
||||
LocalFiles: nil,
|
||||
Progress: lo.ToPtr(0),
|
||||
Status: lo.ToPtr(anilist.MediaListStatusCurrent),
|
||||
Media: anilistEntry.Media,
|
||||
MetadataProvider: metadataProvider,
|
||||
AnimeMetadata: animeMetadata,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, info)
|
||||
|
||||
t.Log(len(info.EpisodesToDownload))
|
||||
assert.GreaterOrEqual(t, len(info.EpisodesToDownload), 1096)
|
||||
}
|
||||
251
seanime-2.9.10/internal/library/anime/entry_helper.go
Normal file
251
seanime-2.9.10/internal/library/anime/entry_helper.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package anime
|
||||
|
||||
import "github.com/samber/lo"
|
||||
|
||||
// HasWatchedAll returns true if all episodes have been watched.
|
||||
// Returns false if there are no downloaded episodes.
|
||||
func (e *Entry) HasWatchedAll() bool {
|
||||
// If there are no episodes, return nil
|
||||
latestEp, ok := e.FindLatestEpisode()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return e.GetCurrentProgress() >= latestEp.GetProgressNumber()
|
||||
|
||||
}
|
||||
|
||||
// FindNextEpisode returns the episode whose episode number is the same as the progress number + 1.
|
||||
// Returns false if there are no episodes or if there is no next episode.
|
||||
func (e *Entry) FindNextEpisode() (*Episode, bool) {
|
||||
eps, ok := e.FindMainEpisodes()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
ep, ok := lo.Find(eps, func(ep *Episode) bool {
|
||||
return ep.GetProgressNumber() == e.GetCurrentProgress()+1
|
||||
})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return ep, true
|
||||
}
|
||||
|
||||
// FindLatestEpisode returns the *main* episode with the highest episode number.
|
||||
// Returns false if there are no episodes.
|
||||
func (e *Entry) FindLatestEpisode() (*Episode, bool) {
|
||||
// If there are no episodes, return nil
|
||||
eps, ok := e.FindMainEpisodes()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
// Get the episode with the highest progress number
|
||||
latest := eps[0]
|
||||
for _, ep := range eps {
|
||||
if ep.GetProgressNumber() > latest.GetProgressNumber() {
|
||||
latest = ep
|
||||
}
|
||||
}
|
||||
return latest, true
|
||||
}
|
||||
|
||||
// FindLatestLocalFile returns the *main* local file with the highest episode number.
|
||||
// Returns false if there are no local files.
|
||||
func (e *Entry) FindLatestLocalFile() (*LocalFile, bool) {
|
||||
lfs, ok := e.FindMainLocalFiles()
|
||||
// If there are no local files, return nil
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
// Get the local file with the highest episode number
|
||||
latest := lfs[0]
|
||||
for _, lf := range lfs {
|
||||
if lf.GetEpisodeNumber() > latest.GetEpisodeNumber() {
|
||||
latest = lf
|
||||
}
|
||||
}
|
||||
return latest, true
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
// GetCurrentProgress returns the progress number.
|
||||
// If the media entry is not in any AniList list, returns 0.
|
||||
func (e *Entry) GetCurrentProgress() int {
|
||||
listData, ok := e.FindListData()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return listData.Progress
|
||||
}
|
||||
|
||||
// FindEpisodes returns the episodes.
|
||||
// Returns false if there are no episodes.
|
||||
func (e *Entry) FindEpisodes() ([]*Episode, bool) {
|
||||
if e.Episodes == nil {
|
||||
return nil, false
|
||||
}
|
||||
return e.Episodes, true
|
||||
}
|
||||
|
||||
// FindMainEpisodes returns the main episodes.
|
||||
// Returns false if there are no main episodes.
|
||||
func (e *Entry) FindMainEpisodes() ([]*Episode, bool) {
|
||||
if e.Episodes == nil {
|
||||
return nil, false
|
||||
}
|
||||
eps := make([]*Episode, 0)
|
||||
for _, ep := range e.Episodes {
|
||||
if ep.IsMain() {
|
||||
eps = append(eps, ep)
|
||||
}
|
||||
}
|
||||
return e.Episodes, true
|
||||
}
|
||||
|
||||
// FindLocalFiles returns the local files.
|
||||
// Returns false if there are no local files.
|
||||
func (e *Entry) FindLocalFiles() ([]*LocalFile, bool) {
|
||||
if !e.IsDownloaded() {
|
||||
return nil, false
|
||||
}
|
||||
return e.LocalFiles, true
|
||||
}
|
||||
|
||||
// FindMainLocalFiles returns *main* local files.
|
||||
// Returns false if there are no local files.
|
||||
func (e *Entry) FindMainLocalFiles() ([]*LocalFile, bool) {
|
||||
if !e.IsDownloaded() {
|
||||
return nil, false
|
||||
}
|
||||
lfs := make([]*LocalFile, 0)
|
||||
for _, lf := range e.LocalFiles {
|
||||
if lf.IsMain() {
|
||||
lfs = append(lfs, lf)
|
||||
}
|
||||
}
|
||||
if len(lfs) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return lfs, true
|
||||
}
|
||||
|
||||
// IsDownloaded returns true if there are local files.
|
||||
func (e *Entry) IsDownloaded() bool {
|
||||
if e.LocalFiles == nil {
|
||||
return false
|
||||
}
|
||||
return len(e.LocalFiles) > 0
|
||||
}
|
||||
|
||||
func (e *Entry) FindListData() (*EntryListData, bool) {
|
||||
if e.EntryListData == nil {
|
||||
return nil, false
|
||||
}
|
||||
return e.EntryListData, true
|
||||
}
|
||||
|
||||
func (e *Entry) IsInAnimeCollection() bool {
|
||||
_, ok := e.FindListData()
|
||||
return ok
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (e *SimpleEntry) GetCurrentProgress() int {
|
||||
listData, ok := e.FindListData()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return listData.Progress
|
||||
}
|
||||
|
||||
func (e *SimpleEntry) FindMainEpisodes() ([]*Episode, bool) {
|
||||
if e.Episodes == nil {
|
||||
return nil, false
|
||||
}
|
||||
eps := make([]*Episode, 0)
|
||||
for _, ep := range e.Episodes {
|
||||
if ep.IsMain() {
|
||||
eps = append(eps, ep)
|
||||
}
|
||||
}
|
||||
return e.Episodes, true
|
||||
}
|
||||
|
||||
func (e *SimpleEntry) FindNextEpisode() (*Episode, bool) {
|
||||
eps, ok := e.FindMainEpisodes()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
ep, ok := lo.Find(eps, func(ep *Episode) bool {
|
||||
return ep.GetProgressNumber() == e.GetCurrentProgress()+1
|
||||
})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return ep, true
|
||||
}
|
||||
|
||||
func (e *SimpleEntry) FindLatestEpisode() (*Episode, bool) {
|
||||
// If there are no episodes, return nil
|
||||
eps, ok := e.FindMainEpisodes()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
// Get the episode with the highest progress number
|
||||
latest := eps[0]
|
||||
for _, ep := range eps {
|
||||
if ep.GetProgressNumber() > latest.GetProgressNumber() {
|
||||
latest = ep
|
||||
}
|
||||
}
|
||||
return latest, true
|
||||
}
|
||||
|
||||
func (e *SimpleEntry) FindLatestLocalFile() (*LocalFile, bool) {
|
||||
lfs, ok := e.FindMainLocalFiles()
|
||||
// If there are no local files, return nil
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
// Get the local file with the highest episode number
|
||||
latest := lfs[0]
|
||||
for _, lf := range lfs {
|
||||
if lf.GetEpisodeNumber() > latest.GetEpisodeNumber() {
|
||||
latest = lf
|
||||
}
|
||||
}
|
||||
return latest, true
|
||||
}
|
||||
|
||||
func (e *SimpleEntry) FindMainLocalFiles() ([]*LocalFile, bool) {
|
||||
if e.LocalFiles == nil {
|
||||
return nil, false
|
||||
}
|
||||
if len(e.LocalFiles) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
lfs := make([]*LocalFile, 0)
|
||||
for _, lf := range e.LocalFiles {
|
||||
if lf.IsMain() {
|
||||
lfs = append(lfs, lf)
|
||||
}
|
||||
}
|
||||
if len(lfs) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return lfs, true
|
||||
}
|
||||
|
||||
func (e *SimpleEntry) FindListData() (*EntryListData, bool) {
|
||||
if e.EntryListData == nil {
|
||||
return nil, false
|
||||
}
|
||||
return e.EntryListData, true
|
||||
}
|
||||
|
||||
func (e *SimpleEntry) IsInAnimeCollection() bool {
|
||||
_, ok := e.FindListData()
|
||||
return ok
|
||||
}
|
||||
77
seanime-2.9.10/internal/library/anime/entry_library_data.go
Normal file
77
seanime-2.9.10/internal/library/anime/entry_library_data.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"seanime/internal/hook"
|
||||
"strings"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type (
|
||||
EntryLibraryData struct {
|
||||
AllFilesLocked bool `json:"allFilesLocked"`
|
||||
SharedPath string `json:"sharedPath"`
|
||||
UnwatchedCount int `json:"unwatchedCount"`
|
||||
MainFileCount int `json:"mainFileCount"`
|
||||
}
|
||||
|
||||
NakamaEntryLibraryData struct {
|
||||
UnwatchedCount int `json:"unwatchedCount"`
|
||||
MainFileCount int `json:"mainFileCount"`
|
||||
}
|
||||
|
||||
NewEntryLibraryDataOptions struct {
|
||||
EntryLocalFiles []*LocalFile
|
||||
MediaId int
|
||||
CurrentProgress int
|
||||
}
|
||||
)
|
||||
|
||||
// NewEntryLibraryData creates a new EntryLibraryData based on the media id and a list of local files related to the media.
|
||||
// It will return false if the list of local files is empty.
|
||||
func NewEntryLibraryData(opts *NewEntryLibraryDataOptions) (ret *EntryLibraryData, ok bool) {
|
||||
|
||||
reqEvent := new(AnimeEntryLibraryDataRequestedEvent)
|
||||
reqEvent.EntryLocalFiles = opts.EntryLocalFiles
|
||||
reqEvent.MediaId = opts.MediaId
|
||||
reqEvent.CurrentProgress = opts.CurrentProgress
|
||||
|
||||
err := hook.GlobalHookManager.OnAnimeEntryLibraryDataRequested().Trigger(reqEvent)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if reqEvent.EntryLocalFiles == nil || len(reqEvent.EntryLocalFiles) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
sharedPath := strings.Replace(reqEvent.EntryLocalFiles[0].Path, reqEvent.EntryLocalFiles[0].Name, "", 1)
|
||||
sharedPath = strings.TrimSuffix(strings.TrimSuffix(sharedPath, "\\"), "/")
|
||||
|
||||
ret = &EntryLibraryData{
|
||||
AllFilesLocked: lo.EveryBy(reqEvent.EntryLocalFiles, func(item *LocalFile) bool { return item.Locked }),
|
||||
SharedPath: sharedPath,
|
||||
}
|
||||
ok = true
|
||||
|
||||
lfw := NewLocalFileWrapper(reqEvent.EntryLocalFiles)
|
||||
lfwe, ok := lfw.GetLocalEntryById(reqEvent.MediaId)
|
||||
if !ok {
|
||||
return ret, true
|
||||
}
|
||||
|
||||
ret.UnwatchedCount = len(lfwe.GetUnwatchedLocalFiles(reqEvent.CurrentProgress))
|
||||
|
||||
mainLfs, ok := lfwe.GetMainLocalFiles()
|
||||
if !ok {
|
||||
return ret, true
|
||||
}
|
||||
ret.MainFileCount = len(mainLfs)
|
||||
|
||||
event := new(AnimeEntryLibraryDataEvent)
|
||||
event.EntryLibraryData = ret
|
||||
err = hook.GlobalHookManager.OnAnimeEntryLibraryData().Trigger(event)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return event.EntryLibraryData, true
|
||||
}
|
||||
148
seanime-2.9.10/internal/library/anime/entry_simple.go
Normal file
148
seanime-2.9.10/internal/library/anime/entry_simple.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/platforms/platform"
|
||||
"sort"
|
||||
|
||||
"github.com/sourcegraph/conc/pool"
|
||||
)
|
||||
|
||||
type (
|
||||
SimpleEntry struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
Media *anilist.BaseAnime `json:"media"`
|
||||
EntryListData *EntryListData `json:"listData"`
|
||||
EntryLibraryData *EntryLibraryData `json:"libraryData"`
|
||||
Episodes []*Episode `json:"episodes"`
|
||||
NextEpisode *Episode `json:"nextEpisode"`
|
||||
LocalFiles []*LocalFile `json:"localFiles"`
|
||||
CurrentEpisodeCount int `json:"currentEpisodeCount"`
|
||||
}
|
||||
|
||||
SimpleEntryListData struct {
|
||||
Progress int `json:"progress,omitempty"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
Status *anilist.MediaListStatus `json:"status,omitempty"`
|
||||
StartedAt string `json:"startedAt,omitempty"`
|
||||
CompletedAt string `json:"completedAt,omitempty"`
|
||||
}
|
||||
|
||||
NewSimpleAnimeEntryOptions struct {
|
||||
MediaId int
|
||||
LocalFiles []*LocalFile // All local files
|
||||
AnimeCollection *anilist.AnimeCollection
|
||||
Platform platform.Platform
|
||||
}
|
||||
)
|
||||
|
||||
func NewSimpleEntry(ctx context.Context, opts *NewSimpleAnimeEntryOptions) (*SimpleEntry, error) {
|
||||
|
||||
if opts.AnimeCollection == nil ||
|
||||
opts.Platform == nil {
|
||||
return nil, errors.New("missing arguments when creating simple media entry")
|
||||
}
|
||||
// Create new Entry
|
||||
entry := new(SimpleEntry)
|
||||
entry.MediaId = opts.MediaId
|
||||
|
||||
// +---------------------+
|
||||
// | 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 {
|
||||
entry.Media = anilistEntry.Media
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Instantiate EntryListData
|
||||
// If the media exist in the user's anime list, add the details
|
||||
if found {
|
||||
entry.EntryListData = &EntryListData{
|
||||
Progress: anilistEntry.GetProgressSafe(),
|
||||
Score: anilistEntry.GetScoreSafe(),
|
||||
Status: anilistEntry.Status,
|
||||
Repeat: anilistEntry.GetRepeatSafe(),
|
||||
StartedAt: anilist.ToEntryStartDate(anilistEntry.StartedAt),
|
||||
CompletedAt: anilist.ToEntryCompletionDate(anilistEntry.CompletedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// +---------------------+
|
||||
// | Episodes |
|
||||
// +---------------------+
|
||||
|
||||
// Create episode entities
|
||||
entry.hydrateEntryEpisodeData()
|
||||
|
||||
return entry, nil
|
||||
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
// hydrateEntryEpisodeData
|
||||
// AniZipData, Media and LocalFiles should be defined
|
||||
func (e *SimpleEntry) hydrateEntryEpisodeData() {
|
||||
|
||||
// +---------------------+
|
||||
// | Episodes |
|
||||
// +---------------------+
|
||||
|
||||
p := pool.NewWithResults[*Episode]()
|
||||
for _, lf := range e.LocalFiles {
|
||||
lf := lf
|
||||
p.Go(func() *Episode {
|
||||
return NewSimpleEpisode(&NewSimpleEpisodeOptions{
|
||||
LocalFile: lf,
|
||||
Media: e.Media,
|
||||
IsDownloaded: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
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
|
||||
|
||||
nextEp, found := e.FindNextEpisode()
|
||||
if found {
|
||||
e.NextEpisode = nextEp
|
||||
}
|
||||
|
||||
}
|
||||
116
seanime-2.9.10/internal/library/anime/entry_test.go
Normal file
116
seanime-2.9.10/internal/library/anime/entry_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package anime_test
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/platforms/anilist_platform"
|
||||
"seanime/internal/test_utils"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestNewAnimeEntry tests /library/entry endpoint.
|
||||
// /!\ MAKE SURE TO HAVE THE MEDIA ADDED TO YOUR LIST TEST ACCOUNT LISTS
|
||||
func TestNewAnimeEntry(t *testing.T) {
|
||||
test_utils.InitTestProvider(t, test_utils.Anilist())
|
||||
logger := util.NewLogger()
|
||||
metadataProvider := metadata.GetMockProvider(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mediaId int
|
||||
localFiles []*anime.LocalFile
|
||||
currentProgress int
|
||||
expectedNextEpisodeNumber int
|
||||
expectedNextEpisodeProgressNumber int
|
||||
}{
|
||||
{
|
||||
name: "Sousou no Frieren",
|
||||
mediaId: 154587,
|
||||
localFiles: anime.MockHydratedLocalFiles(
|
||||
anime.MockGenerateHydratedLocalFileGroupOptions("E:/Anime", "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - %ep (1080p) [F02B9CEE].mkv", 154587, []anime.MockHydratedLocalFileWrapperOptionsMetadata{
|
||||
{MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 3, MetadataAniDbEpisode: "3", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 4, MetadataAniDbEpisode: "4", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 5, MetadataAniDbEpisode: "5", MetadataType: anime.LocalFileTypeMain},
|
||||
}),
|
||||
),
|
||||
currentProgress: 4,
|
||||
expectedNextEpisodeNumber: 5,
|
||||
expectedNextEpisodeProgressNumber: 5,
|
||||
},
|
||||
{
|
||||
name: "Mushoku Tensei II Isekai Ittara Honki Dasu",
|
||||
mediaId: 146065,
|
||||
localFiles: anime.MockHydratedLocalFiles(
|
||||
anime.MockGenerateHydratedLocalFileGroupOptions("E:/Anime", "E:/Anime/Mushoku Tensei II Isekai Ittara Honki Dasu/[SubsPlease] Mushoku Tensei S2 - 00 (1080p) [9C362DC3].mkv", 146065, []anime.MockHydratedLocalFileWrapperOptionsMetadata{
|
||||
{MetadataEpisode: 0, MetadataAniDbEpisode: "S1", MetadataType: anime.LocalFileTypeMain}, // Special episode
|
||||
{MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 3, MetadataAniDbEpisode: "3", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 4, MetadataAniDbEpisode: "4", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 5, MetadataAniDbEpisode: "5", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 6, MetadataAniDbEpisode: "6", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 7, MetadataAniDbEpisode: "7", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 8, MetadataAniDbEpisode: "8", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 9, MetadataAniDbEpisode: "9", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 10, MetadataAniDbEpisode: "10", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 11, MetadataAniDbEpisode: "11", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 12, MetadataAniDbEpisode: "12", MetadataType: anime.LocalFileTypeMain},
|
||||
}),
|
||||
),
|
||||
currentProgress: 0,
|
||||
expectedNextEpisodeNumber: 0,
|
||||
expectedNextEpisodeProgressNumber: 1,
|
||||
},
|
||||
}
|
||||
|
||||
anilistClient := anilist.TestGetMockAnilistClient()
|
||||
anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger)
|
||||
animeCollection, err := anilistPlatform.GetAnimeCollection(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
anilist.TestModifyAnimeCollectionEntry(animeCollection, tt.mediaId, anilist.TestModifyAnimeCollectionEntryInput{
|
||||
Progress: lo.ToPtr(tt.currentProgress), // Mock progress
|
||||
})
|
||||
|
||||
entry, err := anime.NewEntry(t.Context(), &anime.NewEntryOptions{
|
||||
MediaId: tt.mediaId,
|
||||
LocalFiles: tt.localFiles,
|
||||
AnimeCollection: animeCollection,
|
||||
Platform: anilistPlatform,
|
||||
MetadataProvider: metadataProvider,
|
||||
})
|
||||
|
||||
if assert.NoErrorf(t, err, "Failed to get mock data") {
|
||||
|
||||
if assert.NoError(t, err) {
|
||||
|
||||
// Mock progress is 4
|
||||
nextEp, found := entry.FindNextEpisode()
|
||||
if assert.True(t, found, "did not find next episode") {
|
||||
assert.Equal(t, tt.expectedNextEpisodeNumber, nextEp.EpisodeNumber, "next episode number mismatch")
|
||||
assert.Equal(t, tt.expectedNextEpisodeProgressNumber, nextEp.ProgressNumber, "next episode progress number mismatch")
|
||||
}
|
||||
|
||||
t.Logf("Found %v episodes", len(entry.Episodes))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
361
seanime-2.9.10/internal/library/anime/episode.go
Normal file
361
seanime-2.9.10/internal/library/anime/episode.go
Normal file
@@ -0,0 +1,361 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
// Episode represents a single episode of a media entry.
|
||||
Episode struct {
|
||||
Type LocalFileType `json:"type"`
|
||||
DisplayTitle string `json:"displayTitle"` // e.g, Show: "Episode 1", Movie: "Violet Evergarden The Movie"
|
||||
EpisodeTitle string `json:"episodeTitle"` // e.g, "Shibuya Incident - Gate, Open"
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
AniDBEpisode string `json:"aniDBEpisode,omitempty"` // AniDB episode number
|
||||
AbsoluteEpisodeNumber int `json:"absoluteEpisodeNumber"`
|
||||
ProgressNumber int `json:"progressNumber"` // Usually the same as EpisodeNumber, unless there is a discrepancy between AniList and AniDB
|
||||
LocalFile *LocalFile `json:"localFile"`
|
||||
IsDownloaded bool `json:"isDownloaded"` // Is in the local files
|
||||
EpisodeMetadata *EpisodeMetadata `json:"episodeMetadata"` // (image, airDate, length, summary, overview)
|
||||
FileMetadata *LocalFileMetadata `json:"fileMetadata"` // (episode, aniDBEpisode, type...)
|
||||
IsInvalid bool `json:"isInvalid"` // No AniDB data
|
||||
MetadataIssue string `json:"metadataIssue,omitempty"` // Alerts the user that there is a discrepancy between AniList and AniDB
|
||||
BaseAnime *anilist.BaseAnime `json:"baseAnime,omitempty"`
|
||||
// IsNakamaEpisode indicates that this episode is from the Nakama host's anime library.
|
||||
IsNakamaEpisode bool `json:"_isNakamaEpisode"`
|
||||
}
|
||||
|
||||
// EpisodeMetadata represents the metadata of an Episode.
|
||||
// Metadata is fetched from Animap (AniDB) and, optionally, AniList (if Animap is not available).
|
||||
EpisodeMetadata struct {
|
||||
AnidbId int `json:"anidbId,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
AirDate string `json:"airDate,omitempty"`
|
||||
Length int `json:"length,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
IsFiller bool `json:"isFiller,omitempty"`
|
||||
HasImage bool `json:"hasImage,omitempty"` // Indicates if the episode has a real image
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
// NewEpisodeOptions hold data used to create a new Episode.
|
||||
NewEpisodeOptions struct {
|
||||
LocalFile *LocalFile
|
||||
AnimeMetadata *metadata.AnimeMetadata // optional
|
||||
Media *anilist.BaseAnime
|
||||
OptionalAniDBEpisode string
|
||||
// ProgressOffset will offset the ProgressNumber for a specific MAIN file
|
||||
// This is used when there is a discrepancy between AniList and AniDB
|
||||
// When this is -1, it means that a re-mapping of AniDB Episode is needed
|
||||
ProgressOffset int
|
||||
IsDownloaded bool
|
||||
MetadataProvider metadata.Provider // optional
|
||||
}
|
||||
|
||||
// NewSimpleEpisodeOptions hold data used to create a new Episode.
|
||||
// Unlike NewEpisodeOptions, this struct does not require Animap data. It is used to list episodes without AniDB metadata.
|
||||
NewSimpleEpisodeOptions struct {
|
||||
LocalFile *LocalFile
|
||||
Media *anilist.BaseAnime
|
||||
IsDownloaded bool
|
||||
}
|
||||
)
|
||||
|
||||
// NewEpisode creates a new episode entity.
|
||||
//
|
||||
// It is used to list existing local files as episodes
|
||||
// OR list non-downloaded episodes by passing the `OptionalAniDBEpisode` parameter.
|
||||
//
|
||||
// `AnimeMetadata` should be defined, but this is not always the case.
|
||||
// `LocalFile` is optional.
|
||||
func NewEpisode(opts *NewEpisodeOptions) *Episode {
|
||||
entryEp := new(Episode)
|
||||
entryEp.BaseAnime = opts.Media
|
||||
entryEp.DisplayTitle = ""
|
||||
entryEp.EpisodeTitle = ""
|
||||
|
||||
hydrated := false
|
||||
|
||||
// LocalFile exists
|
||||
if opts.LocalFile != nil {
|
||||
|
||||
aniDBEp := opts.LocalFile.Metadata.AniDBEpisode
|
||||
|
||||
// ProgressOffset is -1, meaning the hydrator mistakenly set AniDB episode to "S1" (due to torrent name) because the episode number is 0
|
||||
// The hydrator ASSUMES that AniDB will not include episode 0 as part of main episodes.
|
||||
// We will remap "S1" to "1" and offset other AniDB episodes by 1
|
||||
// e.g, ["S1", "1", "2", "3",...,"12"] -> ["1", "2", "3", "4",...,"13"]
|
||||
if opts.ProgressOffset == -1 && opts.LocalFile.GetType() == LocalFileTypeMain {
|
||||
if aniDBEp == "S1" {
|
||||
aniDBEp = "1"
|
||||
opts.ProgressOffset = 0
|
||||
} else {
|
||||
// e.g, "1" -> "2" etc...
|
||||
aniDBEp = metadata.OffsetAnidbEpisode(aniDBEp, opts.ProgressOffset)
|
||||
}
|
||||
entryEp.MetadataIssue = "forced_remapping"
|
||||
}
|
||||
|
||||
// Get the Animap episode
|
||||
foundAnimapEpisode := false
|
||||
var episodeMetadata *metadata.EpisodeMetadata
|
||||
if opts.AnimeMetadata != nil {
|
||||
episodeMetadata, foundAnimapEpisode = opts.AnimeMetadata.FindEpisode(aniDBEp)
|
||||
}
|
||||
|
||||
entryEp.IsDownloaded = true
|
||||
entryEp.FileMetadata = opts.LocalFile.GetMetadata()
|
||||
entryEp.Type = opts.LocalFile.GetType()
|
||||
entryEp.LocalFile = opts.LocalFile
|
||||
|
||||
// Set episode number and progress number
|
||||
switch opts.LocalFile.Metadata.Type {
|
||||
case LocalFileTypeMain:
|
||||
entryEp.EpisodeNumber = opts.LocalFile.GetEpisodeNumber()
|
||||
entryEp.ProgressNumber = opts.LocalFile.GetEpisodeNumber() + opts.ProgressOffset
|
||||
if foundAnimapEpisode {
|
||||
entryEp.AniDBEpisode = aniDBEp
|
||||
entryEp.AbsoluteEpisodeNumber = entryEp.EpisodeNumber + opts.AnimeMetadata.GetOffset()
|
||||
}
|
||||
case LocalFileTypeSpecial:
|
||||
entryEp.EpisodeNumber = opts.LocalFile.GetEpisodeNumber()
|
||||
entryEp.ProgressNumber = 0
|
||||
case LocalFileTypeNC:
|
||||
entryEp.EpisodeNumber = 0
|
||||
entryEp.ProgressNumber = 0
|
||||
}
|
||||
|
||||
// Set titles
|
||||
if len(entryEp.DisplayTitle) == 0 {
|
||||
switch opts.LocalFile.Metadata.Type {
|
||||
case LocalFileTypeMain:
|
||||
if foundAnimapEpisode {
|
||||
entryEp.AniDBEpisode = aniDBEp
|
||||
if *opts.Media.GetFormat() == anilist.MediaFormatMovie {
|
||||
entryEp.DisplayTitle = opts.Media.GetPreferredTitle()
|
||||
entryEp.EpisodeTitle = "Complete Movie"
|
||||
} else {
|
||||
entryEp.DisplayTitle = "Episode " + strconv.Itoa(opts.LocalFile.GetEpisodeNumber())
|
||||
entryEp.EpisodeTitle = episodeMetadata.GetTitle()
|
||||
}
|
||||
} else {
|
||||
if *opts.Media.GetFormat() == anilist.MediaFormatMovie {
|
||||
entryEp.DisplayTitle = opts.Media.GetPreferredTitle()
|
||||
entryEp.EpisodeTitle = "Complete Movie"
|
||||
} else {
|
||||
entryEp.DisplayTitle = "Episode " + strconv.Itoa(opts.LocalFile.GetEpisodeNumber())
|
||||
entryEp.EpisodeTitle = opts.LocalFile.GetParsedEpisodeTitle()
|
||||
}
|
||||
}
|
||||
hydrated = true // Hydrated
|
||||
case LocalFileTypeSpecial:
|
||||
if foundAnimapEpisode {
|
||||
entryEp.AniDBEpisode = aniDBEp
|
||||
episodeInt, found := metadata.ExtractEpisodeInteger(aniDBEp)
|
||||
if found {
|
||||
entryEp.DisplayTitle = "Special " + strconv.Itoa(episodeInt)
|
||||
} else {
|
||||
entryEp.DisplayTitle = "Special " + aniDBEp
|
||||
}
|
||||
entryEp.EpisodeTitle = episodeMetadata.GetTitle()
|
||||
} else {
|
||||
entryEp.DisplayTitle = "Special " + strconv.Itoa(opts.LocalFile.GetEpisodeNumber())
|
||||
}
|
||||
hydrated = true // Hydrated
|
||||
case LocalFileTypeNC:
|
||||
if foundAnimapEpisode {
|
||||
entryEp.AniDBEpisode = aniDBEp
|
||||
entryEp.DisplayTitle = episodeMetadata.GetTitle()
|
||||
entryEp.EpisodeTitle = ""
|
||||
} else {
|
||||
entryEp.DisplayTitle = opts.LocalFile.GetParsedTitle()
|
||||
entryEp.EpisodeTitle = ""
|
||||
}
|
||||
hydrated = true // Hydrated
|
||||
}
|
||||
} else {
|
||||
hydrated = true // Hydrated
|
||||
}
|
||||
|
||||
// Set episode metadata
|
||||
entryEp.EpisodeMetadata = NewEpisodeMetadata(opts.AnimeMetadata, episodeMetadata, opts.Media, opts.MetadataProvider)
|
||||
|
||||
} else if len(opts.OptionalAniDBEpisode) > 0 && opts.AnimeMetadata != nil {
|
||||
// No LocalFile, but AniDB episode is provided
|
||||
|
||||
// Get the Animap episode
|
||||
if episodeMetadata, foundAnimapEpisode := opts.AnimeMetadata.FindEpisode(opts.OptionalAniDBEpisode); foundAnimapEpisode {
|
||||
|
||||
entryEp.IsDownloaded = false
|
||||
entryEp.Type = LocalFileTypeMain
|
||||
if strings.HasPrefix(opts.OptionalAniDBEpisode, "S") {
|
||||
entryEp.Type = LocalFileTypeSpecial
|
||||
} else if strings.HasPrefix(opts.OptionalAniDBEpisode, "OP") || strings.HasPrefix(opts.OptionalAniDBEpisode, "ED") {
|
||||
entryEp.Type = LocalFileTypeNC
|
||||
}
|
||||
entryEp.EpisodeNumber = 0
|
||||
entryEp.ProgressNumber = 0
|
||||
|
||||
if episodeInt, ok := metadata.ExtractEpisodeInteger(opts.OptionalAniDBEpisode); ok {
|
||||
entryEp.EpisodeNumber = episodeInt
|
||||
entryEp.ProgressNumber = episodeInt + opts.ProgressOffset
|
||||
entryEp.AniDBEpisode = opts.OptionalAniDBEpisode
|
||||
entryEp.AbsoluteEpisodeNumber = entryEp.EpisodeNumber + opts.AnimeMetadata.GetOffset()
|
||||
switch entryEp.Type {
|
||||
case LocalFileTypeMain:
|
||||
if *opts.Media.GetFormat() == anilist.MediaFormatMovie {
|
||||
entryEp.DisplayTitle = opts.Media.GetPreferredTitle()
|
||||
entryEp.EpisodeTitle = "Complete Movie"
|
||||
} else {
|
||||
entryEp.DisplayTitle = "Episode " + strconv.Itoa(episodeInt)
|
||||
entryEp.EpisodeTitle = episodeMetadata.GetTitle()
|
||||
}
|
||||
case LocalFileTypeSpecial:
|
||||
entryEp.DisplayTitle = "Special " + strconv.Itoa(episodeInt)
|
||||
entryEp.EpisodeTitle = episodeMetadata.GetTitle()
|
||||
case LocalFileTypeNC:
|
||||
entryEp.DisplayTitle = opts.OptionalAniDBEpisode
|
||||
entryEp.EpisodeTitle = ""
|
||||
}
|
||||
hydrated = true
|
||||
}
|
||||
|
||||
// Set episode metadata
|
||||
entryEp.EpisodeMetadata = NewEpisodeMetadata(opts.AnimeMetadata, episodeMetadata, opts.Media, opts.MetadataProvider)
|
||||
} else {
|
||||
// No Local file, no Animap data
|
||||
// DEVNOTE: Non-downloaded, without any AniDB data. Don't handle this case.
|
||||
// Non-downloaded episodes are determined from AniDB data either way.
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// If for some reason the episode is not hydrated, set it as invalid
|
||||
if !hydrated {
|
||||
if opts.LocalFile != nil {
|
||||
entryEp.DisplayTitle = opts.LocalFile.GetParsedTitle()
|
||||
}
|
||||
entryEp.EpisodeTitle = ""
|
||||
entryEp.IsInvalid = true
|
||||
return entryEp
|
||||
}
|
||||
|
||||
return entryEp
|
||||
}
|
||||
|
||||
// NewEpisodeMetadata creates a new EpisodeMetadata from an Animap episode and AniList media.
|
||||
// If the Animap episode is nil, it will just set the image from the media.
|
||||
func NewEpisodeMetadata(
|
||||
animeMetadata *metadata.AnimeMetadata,
|
||||
episode *metadata.EpisodeMetadata,
|
||||
media *anilist.BaseAnime,
|
||||
metadataProvider metadata.Provider,
|
||||
) *EpisodeMetadata {
|
||||
md := new(EpisodeMetadata)
|
||||
|
||||
// No Animap data
|
||||
if episode == nil {
|
||||
md.Image = media.GetCoverImageSafe()
|
||||
return md
|
||||
}
|
||||
epInt, err := strconv.Atoi(episode.Episode)
|
||||
|
||||
if err == nil {
|
||||
aw := metadataProvider.GetAnimeMetadataWrapper(media, animeMetadata)
|
||||
epMetadata := aw.GetEpisodeMetadata(epInt)
|
||||
md.AnidbId = epMetadata.AnidbId
|
||||
md.Image = epMetadata.Image
|
||||
md.AirDate = epMetadata.AirDate
|
||||
md.Length = epMetadata.Length
|
||||
md.Summary = epMetadata.Summary
|
||||
md.Overview = epMetadata.Overview
|
||||
md.HasImage = epMetadata.HasImage
|
||||
md.IsFiller = false
|
||||
} else {
|
||||
md.Image = media.GetBannerImageSafe()
|
||||
}
|
||||
|
||||
return md
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// NewSimpleEpisode creates a Episode without AniDB metadata.
|
||||
func NewSimpleEpisode(opts *NewSimpleEpisodeOptions) *Episode {
|
||||
entryEp := new(Episode)
|
||||
entryEp.BaseAnime = opts.Media
|
||||
entryEp.DisplayTitle = ""
|
||||
entryEp.EpisodeTitle = ""
|
||||
entryEp.EpisodeMetadata = new(EpisodeMetadata)
|
||||
|
||||
hydrated := false
|
||||
|
||||
// LocalFile exists
|
||||
if opts.LocalFile != nil {
|
||||
|
||||
entryEp.IsDownloaded = true
|
||||
entryEp.FileMetadata = opts.LocalFile.GetMetadata()
|
||||
entryEp.Type = opts.LocalFile.GetType()
|
||||
entryEp.LocalFile = opts.LocalFile
|
||||
|
||||
// Set episode number and progress number
|
||||
switch opts.LocalFile.Metadata.Type {
|
||||
case LocalFileTypeMain:
|
||||
entryEp.EpisodeNumber = opts.LocalFile.GetEpisodeNumber()
|
||||
entryEp.ProgressNumber = opts.LocalFile.GetEpisodeNumber()
|
||||
hydrated = true // Hydrated
|
||||
case LocalFileTypeSpecial:
|
||||
entryEp.EpisodeNumber = opts.LocalFile.GetEpisodeNumber()
|
||||
entryEp.ProgressNumber = 0
|
||||
hydrated = true // Hydrated
|
||||
case LocalFileTypeNC:
|
||||
entryEp.EpisodeNumber = 0
|
||||
entryEp.ProgressNumber = 0
|
||||
hydrated = true // Hydrated
|
||||
}
|
||||
|
||||
// Set titles
|
||||
if len(entryEp.DisplayTitle) == 0 {
|
||||
switch opts.LocalFile.Metadata.Type {
|
||||
case LocalFileTypeMain:
|
||||
if *opts.Media.GetFormat() == anilist.MediaFormatMovie {
|
||||
entryEp.DisplayTitle = opts.Media.GetPreferredTitle()
|
||||
entryEp.EpisodeTitle = "Complete Movie"
|
||||
} else {
|
||||
entryEp.DisplayTitle = "Episode " + strconv.Itoa(opts.LocalFile.GetEpisodeNumber())
|
||||
entryEp.EpisodeTitle = opts.LocalFile.GetParsedEpisodeTitle()
|
||||
}
|
||||
|
||||
hydrated = true // Hydrated
|
||||
case LocalFileTypeSpecial:
|
||||
entryEp.DisplayTitle = "Special " + strconv.Itoa(opts.LocalFile.GetEpisodeNumber())
|
||||
hydrated = true // Hydrated
|
||||
case LocalFileTypeNC:
|
||||
entryEp.DisplayTitle = opts.LocalFile.GetParsedTitle()
|
||||
entryEp.EpisodeTitle = ""
|
||||
hydrated = true // Hydrated
|
||||
}
|
||||
}
|
||||
|
||||
entryEp.EpisodeMetadata.Image = opts.Media.GetCoverImageSafe()
|
||||
|
||||
}
|
||||
|
||||
if !hydrated {
|
||||
if opts.LocalFile != nil {
|
||||
entryEp.DisplayTitle = opts.LocalFile.GetParsedTitle()
|
||||
}
|
||||
entryEp.EpisodeTitle = ""
|
||||
entryEp.IsInvalid = true
|
||||
entryEp.MetadataIssue = "no_anidb_data"
|
||||
return entryEp
|
||||
}
|
||||
|
||||
entryEp.MetadataIssue = "no_anidb_data"
|
||||
return entryEp
|
||||
}
|
||||
309
seanime-2.9.10/internal/library/anime/episode_collection.go
Normal file
309
seanime-2.9.10/internal/library/anime/episode_collection.go
Normal file
@@ -0,0 +1,309 @@
|
||||
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
|
||||
}
|
||||
28
seanime-2.9.10/internal/library/anime/episode_helper.go
Normal file
28
seanime-2.9.10/internal/library/anime/episode_helper.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package anime
|
||||
|
||||
func (e *Episode) GetEpisodeNumber() int {
|
||||
if e == nil {
|
||||
return -1
|
||||
}
|
||||
return e.EpisodeNumber
|
||||
}
|
||||
func (e *Episode) GetProgressNumber() int {
|
||||
if e == nil {
|
||||
return -1
|
||||
}
|
||||
return e.ProgressNumber
|
||||
}
|
||||
|
||||
func (e *Episode) IsMain() bool {
|
||||
if e == nil || e.LocalFile == nil {
|
||||
return false
|
||||
}
|
||||
return e.LocalFile.IsMain()
|
||||
}
|
||||
|
||||
func (e *Episode) GetLocalFile() *LocalFile {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.LocalFile
|
||||
}
|
||||
167
seanime-2.9.10/internal/library/anime/hook_events.go
Normal file
167
seanime-2.9.10/internal/library/anime/hook_events.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/hook_resolver"
|
||||
)
|
||||
|
||||
/////////////////////////////
|
||||
// Anime Library Events
|
||||
/////////////////////////////
|
||||
|
||||
// AnimeEntryRequestedEvent is triggered when an anime entry is requested.
|
||||
// Prevent default to skip the default behavior and return the modified entry.
|
||||
// This event is triggered before [AnimeEntryEvent].
|
||||
// If the modified entry is nil, an error will be returned.
|
||||
type AnimeEntryRequestedEvent struct {
|
||||
hook_resolver.Event
|
||||
MediaId int `json:"mediaId"`
|
||||
LocalFiles []*LocalFile `json:"localFiles"`
|
||||
AnimeCollection *anilist.AnimeCollection `json:"animeCollection"`
|
||||
// Empty entry object, will be used if the hook prevents the default behavior
|
||||
Entry *Entry `json:"entry"`
|
||||
}
|
||||
|
||||
// AnimeEntryEvent is triggered when the media entry is being returned.
|
||||
// This event is triggered after [AnimeEntryRequestedEvent].
|
||||
type AnimeEntryEvent struct {
|
||||
hook_resolver.Event
|
||||
Entry *Entry `json:"entry"`
|
||||
}
|
||||
|
||||
// AnimeEntryFillerHydrationEvent is triggered when the filler data is being added to the media entry.
|
||||
// This event is triggered after [AnimeEntryEvent].
|
||||
// Prevent default to skip the filler data.
|
||||
type AnimeEntryFillerHydrationEvent struct {
|
||||
hook_resolver.Event
|
||||
Entry *Entry `json:"entry"`
|
||||
}
|
||||
|
||||
// AnimeEntryLibraryDataRequestedEvent is triggered when the app requests the library data for a media entry.
|
||||
// This is triggered before [AnimeEntryLibraryDataEvent].
|
||||
type AnimeEntryLibraryDataRequestedEvent struct {
|
||||
hook_resolver.Event
|
||||
EntryLocalFiles []*LocalFile `json:"entryLocalFiles"`
|
||||
MediaId int `json:"mediaId"`
|
||||
CurrentProgress int `json:"currentProgress"`
|
||||
}
|
||||
|
||||
// AnimeEntryLibraryDataEvent is triggered when the library data is being added to the media entry.
|
||||
// This is triggered after [AnimeEntryLibraryDataRequestedEvent].
|
||||
type AnimeEntryLibraryDataEvent struct {
|
||||
hook_resolver.Event
|
||||
EntryLibraryData *EntryLibraryData `json:"entryLibraryData"`
|
||||
}
|
||||
|
||||
// AnimeEntryManualMatchBeforeSaveEvent is triggered when the user manually matches local files to a media entry.
|
||||
// Prevent default to skip saving the local files.
|
||||
type AnimeEntryManualMatchBeforeSaveEvent struct {
|
||||
hook_resolver.Event
|
||||
// The media ID chosen by the user
|
||||
MediaId int `json:"mediaId"`
|
||||
// The paths of the local files that are being matched
|
||||
Paths []string `json:"paths"`
|
||||
// The local files that are being matched
|
||||
MatchedLocalFiles []*LocalFile `json:"matchedLocalFiles"`
|
||||
}
|
||||
|
||||
// MissingEpisodesRequestedEvent is triggered when the user requests the missing episodes for the entire library.
|
||||
// Prevent default to skip the default process and return the modified missing episodes.
|
||||
type MissingEpisodesRequestedEvent struct {
|
||||
hook_resolver.Event
|
||||
AnimeCollection *anilist.AnimeCollection `json:"animeCollection"`
|
||||
LocalFiles []*LocalFile `json:"localFiles"`
|
||||
SilencedMediaIds []int `json:"silencedMediaIds"`
|
||||
// Empty missing episodes object, will be used if the hook prevents the default behavior
|
||||
MissingEpisodes *MissingEpisodes `json:"missingEpisodes"`
|
||||
}
|
||||
|
||||
// MissingEpisodesEvent is triggered when the missing episodes are being returned.
|
||||
type MissingEpisodesEvent struct {
|
||||
hook_resolver.Event
|
||||
MissingEpisodes *MissingEpisodes `json:"missingEpisodes"`
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// Anime Collection Events
|
||||
/////////////////////////////
|
||||
|
||||
// AnimeLibraryCollectionRequestedEvent is triggered when the user requests the library collection.
|
||||
// Prevent default to skip the default process and return the modified library collection.
|
||||
// If the modified library collection is nil, an error will be returned.
|
||||
type AnimeLibraryCollectionRequestedEvent struct {
|
||||
hook_resolver.Event
|
||||
AnimeCollection *anilist.AnimeCollection `json:"animeCollection"`
|
||||
LocalFiles []*LocalFile `json:"localFiles"`
|
||||
// Empty library collection object, will be used if the hook prevents the default behavior
|
||||
LibraryCollection *LibraryCollection `json:"libraryCollection"`
|
||||
}
|
||||
|
||||
// AnimeLibraryCollectionEvent is triggered when the user requests the library collection.
|
||||
type AnimeLibraryCollectionEvent struct {
|
||||
hook_resolver.Event
|
||||
LibraryCollection *LibraryCollection `json:"libraryCollection"`
|
||||
}
|
||||
|
||||
// AnimeLibraryStreamCollectionRequestedEvent is triggered when the user requests the library stream collection.
|
||||
// This is called when the user enables "Include in library" for either debrid/online/torrent streamings.
|
||||
type AnimeLibraryStreamCollectionRequestedEvent struct {
|
||||
hook_resolver.Event
|
||||
AnimeCollection *anilist.AnimeCollection `json:"animeCollection"`
|
||||
LibraryCollection *LibraryCollection `json:"libraryCollection"`
|
||||
}
|
||||
|
||||
// AnimeLibraryStreamCollectionEvent is triggered when the library stream collection is being returned.
|
||||
type AnimeLibraryStreamCollectionEvent struct {
|
||||
hook_resolver.Event
|
||||
StreamCollection *StreamCollection `json:"streamCollection"`
|
||||
}
|
||||
|
||||
////////////////////////////////////////
|
||||
|
||||
// AnimeEntryDownloadInfoRequestedEvent is triggered when the app requests the download info for a media entry.
|
||||
// This is triggered before [AnimeEntryDownloadInfoEvent].
|
||||
type AnimeEntryDownloadInfoRequestedEvent struct {
|
||||
hook_resolver.Event
|
||||
LocalFiles []*LocalFile `json:"localFiles"`
|
||||
AnimeMetadata *metadata.AnimeMetadata
|
||||
Media *anilist.BaseAnime
|
||||
Progress *int
|
||||
Status *anilist.MediaListStatus
|
||||
// Empty download info object, will be used if the hook prevents the default behavior
|
||||
EntryDownloadInfo *EntryDownloadInfo `json:"entryDownloadInfo"`
|
||||
}
|
||||
|
||||
// AnimeEntryDownloadInfoEvent is triggered when the download info is being returned.
|
||||
type AnimeEntryDownloadInfoEvent struct {
|
||||
hook_resolver.Event
|
||||
EntryDownloadInfo *EntryDownloadInfo `json:"entryDownloadInfo"`
|
||||
}
|
||||
|
||||
/////////////////////////////////////
|
||||
|
||||
// AnimeEpisodeCollectionRequestedEvent is triggered when the episode collection is being requested.
|
||||
// Prevent default to skip the default behavior and return your own data.
|
||||
type AnimeEpisodeCollectionRequestedEvent struct {
|
||||
hook_resolver.Event
|
||||
Media *anilist.BaseAnime `json:"media"`
|
||||
Metadata *metadata.AnimeMetadata `json:"metadata"`
|
||||
// Empty episode collection object, will be used if the hook prevents the default behavior
|
||||
EpisodeCollection *EpisodeCollection `json:"episodeCollection"`
|
||||
}
|
||||
|
||||
// AnimeEpisodeCollectionEvent is triggered when the episode collection is being returned.
|
||||
type AnimeEpisodeCollectionEvent struct {
|
||||
hook_resolver.Event
|
||||
EpisodeCollection *EpisodeCollection `json:"episodeCollection"`
|
||||
}
|
||||
|
||||
/////////////////////////////////////
|
||||
|
||||
// AnimeScheduleItemsEvent is triggered when the schedule items are being returned.
|
||||
type AnimeScheduleItemsEvent struct {
|
||||
hook_resolver.Event
|
||||
AnimeCollection *anilist.AnimeCollection `json:"animeCollection"`
|
||||
Items []*ScheduleItem `json:"items"`
|
||||
}
|
||||
139
seanime-2.9.10/internal/library/anime/localfile.go
Normal file
139
seanime-2.9.10/internal/library/anime/localfile.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"seanime/internal/library/filesystem"
|
||||
|
||||
"github.com/5rahim/habari"
|
||||
)
|
||||
|
||||
const (
|
||||
LocalFileTypeMain LocalFileType = "main" // Main episodes that are trackable
|
||||
LocalFileTypeSpecial LocalFileType = "special" // OVA, ONA, etc.
|
||||
LocalFileTypeNC LocalFileType = "nc" // Opening, ending, etc.
|
||||
)
|
||||
|
||||
type (
|
||||
LocalFileType string
|
||||
// LocalFile represents a media file on the local filesystem.
|
||||
// It is used to store information about and state of the file, such as its path, name, and parsed data.
|
||||
LocalFile struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
ParsedData *LocalFileParsedData `json:"parsedInfo"`
|
||||
ParsedFolderData []*LocalFileParsedData `json:"parsedFolderInfo"`
|
||||
Metadata *LocalFileMetadata `json:"metadata"`
|
||||
Locked bool `json:"locked"`
|
||||
Ignored bool `json:"ignored"` // Unused for now
|
||||
MediaId int `json:"mediaId"`
|
||||
}
|
||||
|
||||
// LocalFileMetadata holds metadata related to a media episode.
|
||||
LocalFileMetadata struct {
|
||||
Episode int `json:"episode"`
|
||||
AniDBEpisode string `json:"aniDBEpisode"`
|
||||
Type LocalFileType `json:"type"`
|
||||
}
|
||||
|
||||
// LocalFileParsedData holds parsed data from a media file's name.
|
||||
// This data is used to identify the media file during the scanning process.
|
||||
LocalFileParsedData struct {
|
||||
Original string `json:"original"`
|
||||
Title string `json:"title,omitempty"`
|
||||
ReleaseGroup string `json:"releaseGroup,omitempty"`
|
||||
Season string `json:"season,omitempty"`
|
||||
SeasonRange []string `json:"seasonRange,omitempty"`
|
||||
Part string `json:"part,omitempty"`
|
||||
PartRange []string `json:"partRange,omitempty"`
|
||||
Episode string `json:"episode,omitempty"`
|
||||
EpisodeRange []string `json:"episodeRange,omitempty"`
|
||||
EpisodeTitle string `json:"episodeTitle,omitempty"`
|
||||
Year string `json:"year,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
// NewLocalFileS creates and returns a reference to a new LocalFile struct.
|
||||
// It will parse the file's name and its directory names to extract necessary information.
|
||||
// - opath: The full path to the file.
|
||||
// - dirPaths: The full paths to the directories that may contain the file. (Library root paths)
|
||||
func NewLocalFileS(opath string, dirPaths []string) *LocalFile {
|
||||
info := filesystem.SeparateFilePathS(opath, dirPaths)
|
||||
return newLocalFile(opath, info)
|
||||
}
|
||||
|
||||
// NewLocalFile creates and returns a reference to a new LocalFile struct.
|
||||
// It will parse the file's name and its directory names to extract necessary information.
|
||||
// - opath: The full path to the file.
|
||||
// - dirPath: The full path to the directory containing the file. (The library root path)
|
||||
func NewLocalFile(opath, dirPath string) *LocalFile {
|
||||
info := filesystem.SeparateFilePath(opath, dirPath)
|
||||
return newLocalFile(opath, info)
|
||||
}
|
||||
|
||||
func newLocalFile(opath string, info *filesystem.SeparatedFilePath) *LocalFile {
|
||||
// Parse filename
|
||||
fElements := habari.Parse(info.Filename)
|
||||
parsedInfo := NewLocalFileParsedData(info.Filename, fElements)
|
||||
|
||||
// Parse dir names
|
||||
parsedFolderInfo := make([]*LocalFileParsedData, 0)
|
||||
for _, dirname := range info.Dirnames {
|
||||
if len(dirname) > 0 {
|
||||
pElements := habari.Parse(dirname)
|
||||
parsed := NewLocalFileParsedData(dirname, pElements)
|
||||
parsedFolderInfo = append(parsedFolderInfo, parsed)
|
||||
}
|
||||
}
|
||||
|
||||
localFile := &LocalFile{
|
||||
Path: opath,
|
||||
Name: info.Filename,
|
||||
ParsedData: parsedInfo,
|
||||
ParsedFolderData: parsedFolderInfo,
|
||||
Metadata: &LocalFileMetadata{
|
||||
Episode: 0,
|
||||
AniDBEpisode: "",
|
||||
Type: "",
|
||||
},
|
||||
Locked: false,
|
||||
Ignored: false,
|
||||
MediaId: 0,
|
||||
}
|
||||
|
||||
return localFile
|
||||
}
|
||||
|
||||
// NewLocalFileParsedData Converts habari.Metadata into LocalFileParsedData, which is more suitable.
|
||||
func NewLocalFileParsedData(original string, elements *habari.Metadata) *LocalFileParsedData {
|
||||
i := new(LocalFileParsedData)
|
||||
i.Original = original
|
||||
i.Title = elements.FormattedTitle
|
||||
i.ReleaseGroup = elements.ReleaseGroup
|
||||
i.EpisodeTitle = elements.EpisodeTitle
|
||||
i.Year = elements.Year
|
||||
|
||||
if len(elements.SeasonNumber) > 0 {
|
||||
if len(elements.SeasonNumber) == 1 {
|
||||
i.Season = elements.SeasonNumber[0]
|
||||
} else {
|
||||
i.SeasonRange = elements.SeasonNumber
|
||||
}
|
||||
}
|
||||
|
||||
if len(elements.EpisodeNumber) > 0 {
|
||||
if len(elements.EpisodeNumber) == 1 {
|
||||
i.Episode = elements.EpisodeNumber[0]
|
||||
} else {
|
||||
i.EpisodeRange = elements.EpisodeNumber
|
||||
}
|
||||
}
|
||||
|
||||
if len(elements.PartNumber) > 0 {
|
||||
if len(elements.PartNumber) == 1 {
|
||||
i.Part = elements.PartNumber[0]
|
||||
} else {
|
||||
i.PartRange = elements.PartNumber
|
||||
}
|
||||
}
|
||||
|
||||
return i
|
||||
}
|
||||
448
seanime-2.9.10/internal/library/anime/localfile_helper.go
Normal file
448
seanime-2.9.10/internal/library/anime/localfile_helper.go
Normal file
@@ -0,0 +1,448 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/comparison"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/samber/lo"
|
||||
lop "github.com/samber/lo/parallel"
|
||||
)
|
||||
|
||||
//----------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
func (f *LocalFile) IsParsedEpisodeValid() bool {
|
||||
if f == nil || f.ParsedData == nil {
|
||||
return false
|
||||
}
|
||||
return len(f.ParsedData.Episode) > 0
|
||||
}
|
||||
|
||||
// GetEpisodeNumber returns the metadata episode number.
|
||||
// This requires the LocalFile to be hydrated.
|
||||
func (f *LocalFile) GetEpisodeNumber() int {
|
||||
if f.Metadata == nil {
|
||||
return -1
|
||||
}
|
||||
return f.Metadata.Episode
|
||||
}
|
||||
|
||||
func (f *LocalFile) GetParsedEpisodeTitle() string {
|
||||
if f.ParsedData == nil {
|
||||
return ""
|
||||
}
|
||||
return f.ParsedData.EpisodeTitle
|
||||
}
|
||||
|
||||
// HasBeenWatched returns whether the episode has been watched.
|
||||
// This only applies to main episodes.
|
||||
func (f *LocalFile) HasBeenWatched(progress int) bool {
|
||||
if f.Metadata == nil {
|
||||
return false
|
||||
}
|
||||
if f.GetEpisodeNumber() == 0 && progress == 0 {
|
||||
return false
|
||||
}
|
||||
return progress >= f.GetEpisodeNumber()
|
||||
}
|
||||
|
||||
// GetType returns the metadata type.
|
||||
// This requires the LocalFile to be hydrated.
|
||||
func (f *LocalFile) GetType() LocalFileType {
|
||||
return f.Metadata.Type
|
||||
}
|
||||
|
||||
// IsMain returns true if the metadata type is LocalFileTypeMain
|
||||
func (f *LocalFile) IsMain() bool {
|
||||
return f.Metadata.Type == LocalFileTypeMain
|
||||
}
|
||||
|
||||
// GetMetadata returns the file metadata.
|
||||
// This requires the LocalFile to be hydrated.
|
||||
func (f *LocalFile) GetMetadata() *LocalFileMetadata {
|
||||
return f.Metadata
|
||||
}
|
||||
|
||||
// GetAniDBEpisode returns the metadata AniDB episode number.
|
||||
// This requires the LocalFile to be hydrated.
|
||||
func (f *LocalFile) GetAniDBEpisode() string {
|
||||
return f.Metadata.AniDBEpisode
|
||||
}
|
||||
|
||||
func (f *LocalFile) IsLocked() bool {
|
||||
return f.Locked
|
||||
}
|
||||
|
||||
func (f *LocalFile) IsIgnored() bool {
|
||||
return f.Ignored
|
||||
}
|
||||
|
||||
// GetNormalizedPath returns the lowercase path of the LocalFile.
|
||||
// Use this for comparison.
|
||||
func (f *LocalFile) GetNormalizedPath() string {
|
||||
return util.NormalizePath(f.Path)
|
||||
}
|
||||
|
||||
func (f *LocalFile) GetPath() string {
|
||||
return f.Path
|
||||
}
|
||||
|
||||
func (f *LocalFile) HasSamePath(path string) bool {
|
||||
return f.GetNormalizedPath() == util.NormalizePath(path)
|
||||
}
|
||||
|
||||
// IsInDir returns true if the LocalFile is in the given directory.
|
||||
func (f *LocalFile) IsInDir(dirPath string) bool {
|
||||
dirPath = util.NormalizePath(dirPath)
|
||||
if !filepath.IsAbs(dirPath) {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(f.GetNormalizedPath(), dirPath)
|
||||
}
|
||||
|
||||
// IsAtRootOf returns true if the LocalFile is at the root of the given directory.
|
||||
func (f *LocalFile) IsAtRootOf(dirPath string) bool {
|
||||
dirPath = strings.TrimSuffix(util.NormalizePath(dirPath), "/")
|
||||
return filepath.ToSlash(filepath.Dir(f.GetNormalizedPath())) == dirPath
|
||||
}
|
||||
|
||||
func (f *LocalFile) Equals(lf *LocalFile) bool {
|
||||
return util.NormalizePath(f.Path) == util.NormalizePath(lf.Path)
|
||||
}
|
||||
|
||||
func (f *LocalFile) IsIncluded(lfs []*LocalFile) bool {
|
||||
for _, lf := range lfs {
|
||||
if f.Equals(lf) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
// buildTitle concatenates the given strings into a single string.
|
||||
func buildTitle(vals ...string) string {
|
||||
buf := bytes.NewBuffer([]byte{})
|
||||
for i, v := range vals {
|
||||
buf.WriteString(v)
|
||||
if i != len(vals)-1 {
|
||||
buf.WriteString(" ")
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// GetUniqueAnimeTitlesFromLocalFiles returns all parsed anime titles without duplicates, from a slice of LocalFile's.
|
||||
func GetUniqueAnimeTitlesFromLocalFiles(lfs []*LocalFile) []string {
|
||||
// Concurrently get title from each local file
|
||||
titles := lop.Map(lfs, func(file *LocalFile, index int) string {
|
||||
title := file.GetParsedTitle()
|
||||
// Some rudimentary exclusions
|
||||
for _, i := range []string{"SPECIALS", "SPECIAL", "EXTRA", "NC", "OP", "MOVIE", "MOVIES"} {
|
||||
if strings.ToUpper(title) == i {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return title
|
||||
})
|
||||
// Keep unique title and filter out empty ones
|
||||
titles = lo.Filter(lo.Uniq(titles), func(item string, index int) bool {
|
||||
return len(item) > 0
|
||||
})
|
||||
return titles
|
||||
}
|
||||
|
||||
// GetMediaIdsFromLocalFiles returns all media ids from a slice of LocalFile's.
|
||||
func GetMediaIdsFromLocalFiles(lfs []*LocalFile) []int {
|
||||
|
||||
// Group local files by media id
|
||||
groupedLfs := GroupLocalFilesByMediaID(lfs)
|
||||
|
||||
// Get slice of media ids from local files
|
||||
mIds := make([]int, len(groupedLfs))
|
||||
for key := range groupedLfs {
|
||||
if !slices.Contains(mIds, key) {
|
||||
mIds = append(mIds, key)
|
||||
}
|
||||
}
|
||||
|
||||
return mIds
|
||||
|
||||
}
|
||||
|
||||
// GetLocalFilesFromMediaId returns all local files with the given media id.
|
||||
func GetLocalFilesFromMediaId(lfs []*LocalFile, mId int) []*LocalFile {
|
||||
|
||||
return lo.Filter(lfs, func(item *LocalFile, _ int) bool {
|
||||
return item.MediaId == mId
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// GroupLocalFilesByMediaID returns a map of media id to local files.
|
||||
func GroupLocalFilesByMediaID(lfs []*LocalFile) (groupedLfs map[int][]*LocalFile) {
|
||||
groupedLfs = lop.GroupBy(lfs, func(item *LocalFile) int {
|
||||
return item.MediaId
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// IsLocalFileGroupValidEntry checks if there are any main episodes with valid episodes
|
||||
func IsLocalFileGroupValidEntry(lfs []*LocalFile) bool {
|
||||
// Check if there are any main episodes with valid parsed data
|
||||
flag := false
|
||||
for _, lf := range lfs {
|
||||
if lf.GetType() == LocalFileTypeMain && lf.IsParsedEpisodeValid() {
|
||||
flag = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return flag
|
||||
}
|
||||
|
||||
// FindLatestLocalFileFromGroup returns the "main" episode with the highest episode number.
|
||||
// Returns false if there are no episodes.
|
||||
func FindLatestLocalFileFromGroup(lfs []*LocalFile) (*LocalFile, bool) {
|
||||
// Check if there are any main episodes with valid parsed data
|
||||
if !IsLocalFileGroupValidEntry(lfs) {
|
||||
return nil, false
|
||||
}
|
||||
if lfs == nil || len(lfs) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
// Get the episode with the highest progress number
|
||||
latest, found := lo.Find(lfs, func(lf *LocalFile) bool {
|
||||
return lf.GetType() == LocalFileTypeMain && lf.IsParsedEpisodeValid()
|
||||
})
|
||||
if !found {
|
||||
return nil, false
|
||||
}
|
||||
for _, lf := range lfs {
|
||||
if lf.GetType() == LocalFileTypeMain && lf.GetEpisodeNumber() > latest.GetEpisodeNumber() {
|
||||
latest = lf
|
||||
}
|
||||
}
|
||||
if latest == nil || latest.GetType() != LocalFileTypeMain {
|
||||
return nil, false
|
||||
}
|
||||
return latest, true
|
||||
}
|
||||
|
||||
func (f *LocalFile) GetParsedData() *LocalFileParsedData {
|
||||
return f.ParsedData
|
||||
}
|
||||
|
||||
// GetParsedTitle returns the parsed title of the LocalFile. Falls back to the folder title if the file title is empty.
|
||||
func (f *LocalFile) GetParsedTitle() string {
|
||||
if len(f.ParsedData.Title) > 0 {
|
||||
return f.ParsedData.Title
|
||||
}
|
||||
if len(f.GetFolderTitle()) > 0 {
|
||||
return f.GetFolderTitle()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *LocalFile) GetFolderTitle() string {
|
||||
folderTitles := make([]string, 0)
|
||||
if f.ParsedFolderData != nil && len(f.ParsedFolderData) > 0 {
|
||||
// Go through each folder data and keep the ones with a title
|
||||
data := lo.Filter(f.ParsedFolderData, func(fpd *LocalFileParsedData, _ int) bool {
|
||||
return len(fpd.Title) > 0
|
||||
})
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
// Get the titles
|
||||
for _, v := range data {
|
||||
folderTitles = append(folderTitles, v.Title)
|
||||
}
|
||||
// If there are multiple titles, return the one closest to the end
|
||||
return folderTitles[len(folderTitles)-1]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetTitleVariations is used for matching.
|
||||
func (f *LocalFile) GetTitleVariations() []*string {
|
||||
|
||||
folderSeason := 0
|
||||
|
||||
// Get the season from the folder data
|
||||
if f.ParsedFolderData != nil && len(f.ParsedFolderData) > 0 {
|
||||
v, found := lo.Find(f.ParsedFolderData, func(fpd *LocalFileParsedData) bool {
|
||||
return len(fpd.Season) > 0
|
||||
})
|
||||
if found {
|
||||
if res, ok := util.StringToInt(v.Season); ok {
|
||||
folderSeason = res
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the season from the filename
|
||||
season := 0
|
||||
if len(f.ParsedData.Season) > 0 {
|
||||
if res, ok := util.StringToInt(f.ParsedData.Season); ok {
|
||||
season = res
|
||||
}
|
||||
}
|
||||
|
||||
part := 0
|
||||
|
||||
// Get the part from the folder data
|
||||
if f.ParsedFolderData != nil && len(f.ParsedFolderData) > 0 {
|
||||
v, found := lo.Find(f.ParsedFolderData, func(fpd *LocalFileParsedData) bool {
|
||||
return len(fpd.Part) > 0
|
||||
})
|
||||
if found {
|
||||
if res, ok := util.StringToInt(v.Season); ok {
|
||||
part = res
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Devnote: This causes issues when an episode title contains "Part"
|
||||
//// Get the part from the filename
|
||||
//if len(f.ParsedData.Part) > 0 {
|
||||
// if res, ok := util.StringToInt(f.ParsedData.Part); ok {
|
||||
// part = res
|
||||
// }
|
||||
//}
|
||||
|
||||
folderTitle := f.GetFolderTitle()
|
||||
|
||||
if comparison.ValueContainsIgnoredKeywords(folderTitle) {
|
||||
folderTitle = ""
|
||||
}
|
||||
|
||||
if len(f.ParsedData.Title) == 0 && len(folderTitle) == 0 {
|
||||
return make([]*string, 0)
|
||||
}
|
||||
|
||||
titleVariations := make([]string, 0)
|
||||
|
||||
bothTitles := len(f.ParsedData.Title) > 0 && len(folderTitle) > 0 // Both titles are present (filename and folder)
|
||||
noSeasonsOrParts := folderSeason == 0 && season == 0 && part == 0 // No seasons or parts are present
|
||||
bothTitlesSimilar := bothTitles && strings.Contains(folderTitle, f.ParsedData.Title) // The folder title contains the filename title
|
||||
eitherSeason := folderSeason > 0 || season > 0 // Either season is present
|
||||
eitherSeasonFirst := folderSeason == 1 || season == 1 // Either season is 1
|
||||
|
||||
// Part
|
||||
if part > 0 {
|
||||
if len(folderTitle) > 0 {
|
||||
titleVariations = append(titleVariations,
|
||||
buildTitle(folderTitle, "Part", strconv.Itoa(part)),
|
||||
buildTitle(folderTitle, "Part", util.IntegerToOrdinal(part)),
|
||||
buildTitle(folderTitle, "Cour", strconv.Itoa(part)),
|
||||
buildTitle(folderTitle, "Cour", util.IntegerToOrdinal(part)),
|
||||
)
|
||||
}
|
||||
if len(f.ParsedData.Title) > 0 {
|
||||
titleVariations = append(titleVariations,
|
||||
buildTitle(f.ParsedData.Title, "Part", strconv.Itoa(part)),
|
||||
buildTitle(f.ParsedData.Title, "Part", util.IntegerToOrdinal(part)),
|
||||
buildTitle(f.ParsedData.Title, "Cour", strconv.Itoa(part)),
|
||||
buildTitle(f.ParsedData.Title, "Cour", util.IntegerToOrdinal(part)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Title, no seasons, no parts, or season 1
|
||||
// e.g. "Bungou Stray Dogs"
|
||||
// e.g. "Bungou Stray Dogs Season 1"
|
||||
if noSeasonsOrParts || eitherSeasonFirst {
|
||||
if len(f.ParsedData.Title) > 0 { // Add filename title
|
||||
titleVariations = append(titleVariations, f.ParsedData.Title)
|
||||
}
|
||||
if len(folderTitle) > 0 { // Both titles are present and similar, add folder title
|
||||
titleVariations = append(titleVariations, folderTitle)
|
||||
}
|
||||
}
|
||||
|
||||
// Part & Season
|
||||
// e.g. "Spy x Family Season 1 Part 2"
|
||||
if part > 0 && eitherSeason {
|
||||
if len(folderTitle) > 0 {
|
||||
if season > 0 {
|
||||
titleVariations = append(titleVariations,
|
||||
buildTitle(folderTitle, "Season", strconv.Itoa(season), "Part", strconv.Itoa(part)),
|
||||
)
|
||||
} else if folderSeason > 0 {
|
||||
titleVariations = append(titleVariations,
|
||||
buildTitle(folderTitle, "Season", strconv.Itoa(folderSeason), "Part", strconv.Itoa(part)),
|
||||
)
|
||||
}
|
||||
}
|
||||
if len(f.ParsedData.Title) > 0 {
|
||||
if season > 0 {
|
||||
titleVariations = append(titleVariations,
|
||||
buildTitle(f.ParsedData.Title, "Season", strconv.Itoa(season), "Part", strconv.Itoa(part)),
|
||||
)
|
||||
} else if folderSeason > 0 {
|
||||
titleVariations = append(titleVariations,
|
||||
buildTitle(f.ParsedData.Title, "Season", strconv.Itoa(folderSeason), "Part", strconv.Itoa(part)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Season is present
|
||||
if eitherSeason {
|
||||
arr := make([]string, 0)
|
||||
|
||||
seas := folderSeason // Default to folder parsed season
|
||||
if season > 0 { // Use filename parsed season if present
|
||||
seas = season
|
||||
}
|
||||
|
||||
// Both titles are present
|
||||
if bothTitles {
|
||||
// Add both titles
|
||||
arr = append(arr, f.ParsedData.Title)
|
||||
arr = append(arr, folderTitle)
|
||||
if !bothTitlesSimilar { // Combine both titles if they are not similar
|
||||
arr = append(arr, fmt.Sprintf("%s %s", folderTitle, f.ParsedData.Title))
|
||||
}
|
||||
} else if len(folderTitle) > 0 { // Only folder title is present
|
||||
|
||||
arr = append(arr, folderTitle)
|
||||
|
||||
} else if len(f.ParsedData.Title) > 0 { // Only filename title is present
|
||||
|
||||
arr = append(arr, f.ParsedData.Title)
|
||||
|
||||
}
|
||||
|
||||
for _, t := range arr {
|
||||
titleVariations = append(titleVariations,
|
||||
buildTitle(t, "Season", strconv.Itoa(seas)),
|
||||
buildTitle(t, "S"+strconv.Itoa(seas)),
|
||||
buildTitle(t, util.IntegerToOrdinal(seas), "Season"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
titleVariations = lo.Uniq(titleVariations)
|
||||
|
||||
// If there are no title variations, use the folder title or the parsed title
|
||||
if len(titleVariations) == 0 {
|
||||
if len(folderTitle) > 0 {
|
||||
titleVariations = append(titleVariations, folderTitle)
|
||||
}
|
||||
if len(f.ParsedData.Title) > 0 {
|
||||
titleVariations = append(titleVariations, f.ParsedData.Title)
|
||||
}
|
||||
}
|
||||
|
||||
return lo.ToSlicePtr(titleVariations)
|
||||
|
||||
}
|
||||
329
seanime-2.9.10/internal/library/anime/localfile_helper_test.go
Normal file
329
seanime-2.9.10/internal/library/anime/localfile_helper_test.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package anime_test
|
||||
|
||||
import (
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"path/filepath"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/util"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalFile_GetNormalizedPath(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
filePath string
|
||||
libraryPath string
|
||||
expectedResult string
|
||||
}{
|
||||
{
|
||||
filePath: "E:\\Anime\\Bungou Stray Dogs 5th Season\\Bungou Stray Dogs\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
expectedResult: "e:/anime/bungou stray dogs 5th season/bungou stray dogs/[subsplease] bungou stray dogs - 61 (1080p) [f609b947].mkv",
|
||||
},
|
||||
{
|
||||
filePath: "E:\\Anime\\Shakugan No Shana\\Shakugan No Shana I\\Opening\\OP01.mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
expectedResult: "e:/anime/shakugan no shana/shakugan no shana i/opening/op01.mkv",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filePath, func(t *testing.T) {
|
||||
lf := anime.NewLocalFile(tt.filePath, tt.libraryPath)
|
||||
|
||||
if assert.NotNil(t, lf) {
|
||||
|
||||
if assert.Equal(t, tt.expectedResult, lf.GetNormalizedPath()) {
|
||||
spew.Dump(lf.GetNormalizedPath())
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestLocalFile_IsInDir(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
filePath string
|
||||
libraryPath string
|
||||
dir string
|
||||
expectedResult bool
|
||||
}{
|
||||
{
|
||||
filePath: "E:\\Anime\\Bungou Stray Dogs 5th Season\\Bungou Stray Dogs\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
dir: "E:/ANIME/Bungou Stray Dogs 5th Season",
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
filePath: "E:\\Anime\\Shakugan No Shana\\Shakugan No Shana I\\Opening\\OP01.mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
dir: "E:/ANIME/Shakugan No Shana",
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
filePath: "E:\\Anime\\Shakugan No Shana\\Shakugan No Shana I\\Opening\\OP01.mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
dir: "E:/ANIME/Shakugan No Shana I",
|
||||
expectedResult: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filePath, func(t *testing.T) {
|
||||
lf := anime.NewLocalFile(tt.filePath, tt.libraryPath)
|
||||
|
||||
if assert.NotNil(t, lf) {
|
||||
|
||||
if assert.Equal(t, tt.expectedResult, lf.IsInDir(tt.dir)) {
|
||||
spew.Dump(lf.IsInDir(tt.dir))
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestLocalFile_IsAtRootOf(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
filePath string
|
||||
libraryPath string
|
||||
dir string
|
||||
expectedResult bool
|
||||
}{
|
||||
{
|
||||
filePath: "E:\\Anime\\Bungou Stray Dogs 5th Season\\Bungou Stray Dogs\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
dir: "E:/ANIME/Bungou Stray Dogs 5th Season",
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
filePath: "E:\\Anime\\Shakugan No Shana\\Shakugan No Shana I\\Opening\\OP01.mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
dir: "E:/ANIME/Shakugan No Shana",
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
filePath: "E:\\Anime\\Shakugan No Shana\\Shakugan No Shana I\\Opening\\OP01.mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
dir: "E:/ANIME/Shakugan No Shana/Shakugan No Shana I/Opening",
|
||||
expectedResult: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filePath, func(t *testing.T) {
|
||||
lf := anime.NewLocalFile(tt.filePath, tt.libraryPath)
|
||||
|
||||
if assert.NotNil(t, lf) {
|
||||
|
||||
if !assert.Equal(t, tt.expectedResult, lf.IsAtRootOf(tt.dir)) {
|
||||
t.Log(filepath.Dir(lf.GetNormalizedPath()))
|
||||
t.Log(strings.TrimSuffix(util.NormalizePath(tt.dir), "/"))
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestLocalFile_Equals(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
filePath1 string
|
||||
filePath2 string
|
||||
libraryPath string
|
||||
expectedResult bool
|
||||
}{
|
||||
{
|
||||
filePath1: "E:\\Anime\\Bungou Stray Dogs 5th Season\\Bungou Stray Dogs\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv",
|
||||
filePath2: "E:/ANIME/Bungou Stray Dogs 5th Season/Bungou Stray Dogs/[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv",
|
||||
libraryPath: "E:/Anime",
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
filePath1: "E:\\Anime\\Bungou Stray Dogs 5th Season\\Bungou Stray Dogs\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv",
|
||||
filePath2: "E:\\Anime\\Bungou Stray Dogs 5th Season\\Bungou Stray Dogs\\[SubsPlease] Bungou Stray Dogs - 62 (1080p) [F609B947].mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
expectedResult: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filePath1, func(t *testing.T) {
|
||||
lf1 := anime.NewLocalFile(tt.filePath1, tt.libraryPath)
|
||||
lf2 := anime.NewLocalFile(tt.filePath2, tt.libraryPath)
|
||||
|
||||
if assert.NotNil(t, lf1) && assert.NotNil(t, lf2) {
|
||||
assert.Equal(t, tt.expectedResult, lf1.Equals(lf2))
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestLocalFile_GetTitleVariations(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
filePath string
|
||||
libraryPath string
|
||||
expectedTitles []string
|
||||
}{
|
||||
{
|
||||
filePath: "E:\\Anime\\Bungou Stray Dogs 5th Season\\Bungou Stray Dogs\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
expectedTitles: []string{
|
||||
"Bungou Stray Dogs 5th Season",
|
||||
"Bungou Stray Dogs Season 5",
|
||||
"Bungou Stray Dogs S5",
|
||||
},
|
||||
},
|
||||
{
|
||||
filePath: "E:\\Anime\\Shakugan No Shana\\Shakugan No Shana I\\Opening\\OP01.mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
expectedTitles: []string{
|
||||
"Shakugan No Shana I",
|
||||
},
|
||||
},
|
||||
{
|
||||
filePath: "E:\\ANIME\\Neon Genesis Evangelion Death & Rebirth\\[Anime Time] Neon Genesis Evangelion - Rebirth.mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
expectedTitles: []string{
|
||||
"Neon Genesis Evangelion - Rebirth",
|
||||
"Neon Genesis Evangelion Death & Rebirth",
|
||||
},
|
||||
},
|
||||
{
|
||||
filePath: "E:\\ANIME\\Omoi, Omoware, Furi, Furare\\[GJM] Love Me, Love Me Not (BD 1080p) [841C23CD].mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
expectedTitles: []string{
|
||||
"Love Me, Love Me Not",
|
||||
"Omoi, Omoware, Furi, Furare",
|
||||
},
|
||||
},
|
||||
{
|
||||
filePath: "E:\\ANIME\\Violet Evergarden Gaiden Eien to Jidou Shuki Ningyou\\Violet.Evergarden.Gaiden.2019.1080..Dual.Audio.BDRip.10.bits.DD.x265-EMBER.mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
expectedTitles: []string{
|
||||
"Violet Evergarden Gaiden Eien to Jidou Shuki Ningyou",
|
||||
"Violet Evergarden Gaiden 2019",
|
||||
},
|
||||
},
|
||||
{
|
||||
filePath: "E:\\ANIME\\Violet Evergarden S01+Movies+OVA 1080p Dual Audio BDRip 10 bits DD x265-EMBER\\01. Season 1 + OVA\\S01E01-'I Love You' and Auto Memory Dolls [F03E1F7A].mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
expectedTitles: []string{
|
||||
"Violet Evergarden",
|
||||
"Violet Evergarden S1",
|
||||
"Violet Evergarden Season 1",
|
||||
"Violet Evergarden 1st Season",
|
||||
},
|
||||
},
|
||||
{
|
||||
filePath: "E:\\ANIME\\Golden Kamuy 4th Season\\[Judas] Golden Kamuy (Season 4) [1080p][HEVC x265 10bit][Multi-Subs]\\[Judas] Golden Kamuy - S04E01.mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
expectedTitles: []string{
|
||||
"Golden Kamuy S4",
|
||||
"Golden Kamuy Season 4",
|
||||
"Golden Kamuy 4th Season",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filePath, func(t *testing.T) {
|
||||
lf := anime.NewLocalFile(tt.filePath, tt.libraryPath)
|
||||
|
||||
if assert.NotNil(t, lf) {
|
||||
tv := lo.Map(lf.GetTitleVariations(), func(item *string, _ int) string { return *item })
|
||||
|
||||
if assert.ElementsMatch(t, tt.expectedTitles, tv) {
|
||||
spew.Dump(lf.GetTitleVariations())
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestLocalFile_GetParsedTitle(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
filePath string
|
||||
libraryPath string
|
||||
expectedParsedTitle string
|
||||
}{
|
||||
{
|
||||
filePath: "E:\\Anime\\Bungou Stray Dogs 5th Season\\Bungou Stray Dogs\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
expectedParsedTitle: "Bungou Stray Dogs",
|
||||
},
|
||||
{
|
||||
filePath: "E:\\Anime\\Shakugan No Shana\\Shakugan No Shana I\\Opening\\OP01.mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
expectedParsedTitle: "Shakugan No Shana I",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filePath, func(t *testing.T) {
|
||||
lf := anime.NewLocalFile(tt.filePath, tt.libraryPath)
|
||||
|
||||
if assert.NotNil(t, lf) {
|
||||
|
||||
if assert.Equal(t, tt.expectedParsedTitle, lf.GetParsedTitle()) {
|
||||
spew.Dump(lf.GetParsedTitle())
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestLocalFile_GetFolderTitle(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
filePath string
|
||||
libraryPath string
|
||||
expectedFolderTitle string
|
||||
}{
|
||||
{
|
||||
filePath: "E:\\Anime\\Bungou Stray Dogs 5th Season\\S05E11 - Episode Title.mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
expectedFolderTitle: "Bungou Stray Dogs",
|
||||
},
|
||||
{
|
||||
filePath: "E:\\Anime\\Shakugan No Shana\\Shakugan No Shana I\\Opening\\OP01.mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
expectedFolderTitle: "Shakugan No Shana I",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filePath, func(t *testing.T) {
|
||||
lf := anime.NewLocalFile(tt.filePath, tt.libraryPath)
|
||||
|
||||
if assert.NotNil(t, lf) {
|
||||
|
||||
if assert.Equal(t, tt.expectedFolderTitle, lf.GetFolderTitle()) {
|
||||
spew.Dump(lf.GetFolderTitle())
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
59
seanime-2.9.10/internal/library/anime/localfile_test.go
Normal file
59
seanime-2.9.10/internal/library/anime/localfile_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package anime_test
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"seanime/internal/library/anime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewLocalFile(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
libraryPath string
|
||||
expectedNbFolders int
|
||||
expectedFilename string
|
||||
os string
|
||||
}{
|
||||
{
|
||||
path: "E:\\Anime\\Bungou Stray Dogs 5th Season\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv",
|
||||
libraryPath: "E:\\Anime",
|
||||
expectedFilename: "[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv",
|
||||
expectedNbFolders: 1,
|
||||
os: "windows",
|
||||
},
|
||||
{
|
||||
path: "E:\\Anime\\Bungou Stray Dogs 5th Season\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv",
|
||||
libraryPath: "E:/ANIME",
|
||||
expectedFilename: "[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv",
|
||||
expectedNbFolders: 1,
|
||||
os: "windows",
|
||||
},
|
||||
{
|
||||
path: "/mnt/Anime/Bungou Stray Dogs/Bungou Stray Dogs 5th Season/[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv",
|
||||
libraryPath: "/mnt/Anime",
|
||||
expectedFilename: "[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv",
|
||||
expectedNbFolders: 2,
|
||||
os: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
if tt.os != "" {
|
||||
if tt.os != runtime.GOOS {
|
||||
t.Skipf("skipping test for %s", tt.path)
|
||||
}
|
||||
}
|
||||
|
||||
lf := anime.NewLocalFile(tt.path, tt.libraryPath)
|
||||
|
||||
if assert.NotNil(t, lf) {
|
||||
assert.Equal(t, tt.expectedNbFolders, len(lf.ParsedFolderData))
|
||||
assert.Equal(t, tt.expectedFilename, lf.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
207
seanime-2.9.10/internal/library/anime/localfile_wrapper.go
Normal file
207
seanime-2.9.10/internal/library/anime/localfile_wrapper.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type (
|
||||
// LocalFileWrapper takes a slice of LocalFiles and provides helper methods.
|
||||
LocalFileWrapper struct {
|
||||
LocalFiles []*LocalFile `json:"localFiles"`
|
||||
LocalEntries []*LocalFileWrapperEntry `json:"localEntries"`
|
||||
UnmatchedLocalFiles []*LocalFile `json:"unmatchedLocalFiles"`
|
||||
}
|
||||
|
||||
LocalFileWrapperEntry struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
LocalFiles []*LocalFile `json:"localFiles"`
|
||||
}
|
||||
)
|
||||
|
||||
// NewLocalFileWrapper creates and returns a reference to a new LocalFileWrapper
|
||||
func NewLocalFileWrapper(lfs []*LocalFile) *LocalFileWrapper {
|
||||
lfw := &LocalFileWrapper{
|
||||
LocalFiles: lfs,
|
||||
LocalEntries: make([]*LocalFileWrapperEntry, 0),
|
||||
UnmatchedLocalFiles: make([]*LocalFile, 0),
|
||||
}
|
||||
|
||||
// Group local files by media id
|
||||
groupedLfs := GroupLocalFilesByMediaID(lfs)
|
||||
for mId, gLfs := range groupedLfs {
|
||||
if mId == 0 {
|
||||
lfw.UnmatchedLocalFiles = gLfs
|
||||
continue
|
||||
}
|
||||
lfw.LocalEntries = append(lfw.LocalEntries, &LocalFileWrapperEntry{
|
||||
MediaId: mId,
|
||||
LocalFiles: gLfs,
|
||||
})
|
||||
}
|
||||
|
||||
return lfw
|
||||
}
|
||||
|
||||
func (lfw *LocalFileWrapper) GetLocalEntryById(mId int) (*LocalFileWrapperEntry, bool) {
|
||||
for _, me := range lfw.LocalEntries {
|
||||
if me.MediaId == mId {
|
||||
return me, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// GetMainLocalFiles returns the *main* local files.
|
||||
func (e *LocalFileWrapperEntry) GetMainLocalFiles() ([]*LocalFile, bool) {
|
||||
lfs := make([]*LocalFile, 0)
|
||||
for _, lf := range e.LocalFiles {
|
||||
if lf.IsMain() {
|
||||
lfs = append(lfs, lf)
|
||||
}
|
||||
}
|
||||
if len(lfs) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return lfs, true
|
||||
}
|
||||
|
||||
// GetUnwatchedLocalFiles returns the *main* local files that have not been watched.
|
||||
// It returns an empty slice if all local files have been watched.
|
||||
//
|
||||
// /!\ IF Episode 0 is present, progress will be decremented by 1. This is because we assume AniList includes the episode 0 in the total count.
|
||||
func (e *LocalFileWrapperEntry) GetUnwatchedLocalFiles(progress int) []*LocalFile {
|
||||
ret := make([]*LocalFile, 0)
|
||||
lfs, ok := e.GetMainLocalFiles()
|
||||
if !ok {
|
||||
return ret
|
||||
}
|
||||
|
||||
for _, lf := range lfs {
|
||||
if lf.GetEpisodeNumber() == 0 {
|
||||
progress = progress - 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, lf := range lfs {
|
||||
if lf.GetEpisodeNumber() > progress {
|
||||
ret = append(ret, lf)
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// GetFirstUnwatchedLocalFiles is like GetUnwatchedLocalFiles but returns local file with the lowest episode number.
|
||||
func (e *LocalFileWrapperEntry) GetFirstUnwatchedLocalFiles(progress int) (*LocalFile, bool) {
|
||||
lfs := e.GetUnwatchedLocalFiles(progress)
|
||||
if len(lfs) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
// Sort local files by episode number
|
||||
slices.SortStableFunc(lfs, func(a, b *LocalFile) int {
|
||||
return cmp.Compare(a.GetEpisodeNumber(), b.GetEpisodeNumber())
|
||||
})
|
||||
return lfs[0], true
|
||||
}
|
||||
|
||||
// HasMainLocalFiles returns true if there are any *main* local files.
|
||||
func (e *LocalFileWrapperEntry) HasMainLocalFiles() bool {
|
||||
for _, lf := range e.LocalFiles {
|
||||
if lf.IsMain() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FindLocalFileWithEpisodeNumber returns the *main* local file with the given episode number.
|
||||
func (e *LocalFileWrapperEntry) FindLocalFileWithEpisodeNumber(ep int) (*LocalFile, bool) {
|
||||
for _, lf := range e.LocalFiles {
|
||||
if !lf.IsMain() {
|
||||
continue
|
||||
}
|
||||
if lf.GetEpisodeNumber() == ep {
|
||||
return lf, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// FindLatestLocalFile returns the *main* local file with the highest episode number.
|
||||
func (e *LocalFileWrapperEntry) FindLatestLocalFile() (*LocalFile, bool) {
|
||||
lfs, ok := e.GetMainLocalFiles()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
// Get the local file with the highest episode number
|
||||
latest := lfs[0]
|
||||
for _, lf := range lfs {
|
||||
if lf.GetEpisodeNumber() > latest.GetEpisodeNumber() {
|
||||
latest = lf
|
||||
}
|
||||
}
|
||||
return latest, true
|
||||
}
|
||||
|
||||
// FindNextEpisode returns the *main* local file whose episode number is after the given local file.
|
||||
func (e *LocalFileWrapperEntry) FindNextEpisode(lf *LocalFile) (*LocalFile, bool) {
|
||||
lfs, ok := e.GetMainLocalFiles()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
// Get the local file whose episode number is after the given local file
|
||||
var next *LocalFile
|
||||
for _, l := range lfs {
|
||||
if l.GetEpisodeNumber() == lf.GetEpisodeNumber()+1 {
|
||||
next = l
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil, false
|
||||
}
|
||||
return next, true
|
||||
}
|
||||
|
||||
// GetProgressNumber returns the progress number of a **main** local file.
|
||||
func (e *LocalFileWrapperEntry) GetProgressNumber(lf *LocalFile) int {
|
||||
lfs, ok := e.GetMainLocalFiles()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
var hasEpZero bool
|
||||
for _, l := range lfs {
|
||||
if l.GetEpisodeNumber() == 0 {
|
||||
hasEpZero = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasEpZero {
|
||||
return lf.GetEpisodeNumber() + 1
|
||||
}
|
||||
|
||||
return lf.GetEpisodeNumber()
|
||||
}
|
||||
|
||||
func (lfw *LocalFileWrapper) GetUnmatchedLocalFiles() []*LocalFile {
|
||||
return lfw.UnmatchedLocalFiles
|
||||
}
|
||||
|
||||
func (lfw *LocalFileWrapper) GetLocalEntries() []*LocalFileWrapperEntry {
|
||||
return lfw.LocalEntries
|
||||
}
|
||||
|
||||
func (lfw *LocalFileWrapper) GetLocalFiles() []*LocalFile {
|
||||
return lfw.LocalFiles
|
||||
}
|
||||
|
||||
func (e *LocalFileWrapperEntry) GetLocalFiles() []*LocalFile {
|
||||
return e.LocalFiles
|
||||
}
|
||||
|
||||
func (e *LocalFileWrapperEntry) GetMediaId() int {
|
||||
return e.MediaId
|
||||
}
|
||||
194
seanime-2.9.10/internal/library/anime/localfile_wrapper_test.go
Normal file
194
seanime-2.9.10/internal/library/anime/localfile_wrapper_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package anime_test
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"seanime/internal/library/anime"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalFileWrapperEntry(t *testing.T) {
|
||||
|
||||
lfs := anime.MockHydratedLocalFiles(
|
||||
anime.MockGenerateHydratedLocalFileGroupOptions("/mnt/anime/", "/mnt/anime/One Piece/One Piece - %ep.mkv", 21, []anime.MockHydratedLocalFileWrapperOptionsMetadata{
|
||||
{MetadataEpisode: 1070, MetadataAniDbEpisode: "1070", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 1071, MetadataAniDbEpisode: "1071", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 1072, MetadataAniDbEpisode: "1072", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 1073, MetadataAniDbEpisode: "1073", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 1074, MetadataAniDbEpisode: "1074", MetadataType: anime.LocalFileTypeMain},
|
||||
}),
|
||||
anime.MockGenerateHydratedLocalFileGroupOptions("/mnt/anime/", "/mnt/anime/Blue Lock/Blue Lock - %ep.mkv", 22222, []anime.MockHydratedLocalFileWrapperOptionsMetadata{
|
||||
{MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 3, MetadataAniDbEpisode: "3", MetadataType: anime.LocalFileTypeMain},
|
||||
}),
|
||||
anime.MockGenerateHydratedLocalFileGroupOptions("/mnt/anime/", "/mnt/anime/Kimi ni Todoke/Kimi ni Todoke - %ep.mkv", 9656, []anime.MockHydratedLocalFileWrapperOptionsMetadata{
|
||||
{MetadataEpisode: 0, MetadataAniDbEpisode: "S1", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain},
|
||||
}),
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mediaId int
|
||||
expectedNbMainLocalFiles int
|
||||
expectedLatestEpisode int
|
||||
expectedEpisodeNumberAfterEpisode []int
|
||||
}{
|
||||
{
|
||||
name: "One Piece",
|
||||
mediaId: 21,
|
||||
expectedNbMainLocalFiles: 5,
|
||||
expectedLatestEpisode: 1074,
|
||||
expectedEpisodeNumberAfterEpisode: []int{1071, 1072},
|
||||
},
|
||||
{
|
||||
name: "Blue Lock",
|
||||
mediaId: 22222,
|
||||
expectedNbMainLocalFiles: 3,
|
||||
expectedLatestEpisode: 3,
|
||||
expectedEpisodeNumberAfterEpisode: []int{2, 3},
|
||||
},
|
||||
}
|
||||
|
||||
lfw := anime.NewLocalFileWrapper(lfs)
|
||||
|
||||
// Not empty
|
||||
if assert.Greater(t, len(lfw.GetLocalEntries()), 0) {
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
// Can get by id
|
||||
entry, ok := lfw.GetLocalEntryById(tt.mediaId)
|
||||
if assert.Truef(t, ok, "could not find entry for %s", tt.name) {
|
||||
|
||||
assert.Equalf(t, tt.mediaId, entry.GetMediaId(), "media id does not match for %s", tt.name)
|
||||
|
||||
// Can get main local files
|
||||
mainLfs, ok := entry.GetMainLocalFiles()
|
||||
if assert.Truef(t, ok, "could not find main local files for %s", tt.name) {
|
||||
|
||||
// Number of main local files matches
|
||||
assert.Equalf(t, tt.expectedNbMainLocalFiles, len(mainLfs), "number of main local files does not match for %s", tt.name)
|
||||
|
||||
// Can find latest episode
|
||||
latest, ok := entry.FindLatestLocalFile()
|
||||
if assert.Truef(t, ok, "could not find latest local file for %s", tt.name) {
|
||||
assert.Equalf(t, tt.expectedLatestEpisode, latest.GetEpisodeNumber(), "latest episode does not match for %s", tt.name)
|
||||
}
|
||||
|
||||
// Can find successive episodes
|
||||
firstEp, ok := entry.FindLocalFileWithEpisodeNumber(tt.expectedEpisodeNumberAfterEpisode[0])
|
||||
if assert.True(t, ok) {
|
||||
secondEp, ok := entry.FindNextEpisode(firstEp)
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, tt.expectedEpisodeNumberAfterEpisode[1], secondEp.GetEpisodeNumber(), "second episode does not match for %s", tt.name)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestLocalFileWrapperEntryProgressNumber(t *testing.T) {
|
||||
|
||||
lfs := anime.MockHydratedLocalFiles(
|
||||
anime.MockGenerateHydratedLocalFileGroupOptions("/mnt/anime/", "/mnt/anime/Kimi ni Todoke/Kimi ni Todoke - %ep.mkv", 9656, []anime.MockHydratedLocalFileWrapperOptionsMetadata{
|
||||
{MetadataEpisode: 0, MetadataAniDbEpisode: "S1", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain},
|
||||
}),
|
||||
anime.MockGenerateHydratedLocalFileGroupOptions("/mnt/anime/", "/mnt/anime/Kimi ni Todoke/Kimi ni Todoke - %ep.mkv", 9656_2, []anime.MockHydratedLocalFileWrapperOptionsMetadata{
|
||||
{MetadataEpisode: 1, MetadataAniDbEpisode: "S1", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 2, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 3, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain},
|
||||
}),
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mediaId int
|
||||
expectedNbMainLocalFiles int
|
||||
expectedLatestEpisode int
|
||||
expectedEpisodeNumberAfterEpisode []int
|
||||
expectedProgressNumbers []int
|
||||
}{
|
||||
{
|
||||
name: "Kimi ni Todoke",
|
||||
mediaId: 9656,
|
||||
expectedNbMainLocalFiles: 3,
|
||||
expectedLatestEpisode: 2,
|
||||
expectedEpisodeNumberAfterEpisode: []int{1, 2},
|
||||
expectedProgressNumbers: []int{1, 2, 3}, // S1 -> 1, 1 -> 2, 2 -> 3
|
||||
},
|
||||
{
|
||||
name: "Kimi ni Todoke 2",
|
||||
mediaId: 9656_2,
|
||||
expectedNbMainLocalFiles: 3,
|
||||
expectedLatestEpisode: 3,
|
||||
expectedEpisodeNumberAfterEpisode: []int{2, 3},
|
||||
expectedProgressNumbers: []int{1, 2, 3}, // S1 -> 1, 1 -> 2, 2 -> 3
|
||||
},
|
||||
}
|
||||
|
||||
lfw := anime.NewLocalFileWrapper(lfs)
|
||||
|
||||
// Not empty
|
||||
if assert.Greater(t, len(lfw.GetLocalEntries()), 0) {
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
// Can get by id
|
||||
entry, ok := lfw.GetLocalEntryById(tt.mediaId)
|
||||
if assert.Truef(t, ok, "could not find entry for %s", tt.name) {
|
||||
|
||||
assert.Equalf(t, tt.mediaId, entry.GetMediaId(), "media id does not match for %s", tt.name)
|
||||
|
||||
// Can get main local files
|
||||
mainLfs, ok := entry.GetMainLocalFiles()
|
||||
if assert.Truef(t, ok, "could not find main local files for %s", tt.name) {
|
||||
|
||||
// Number of main local files matches
|
||||
assert.Equalf(t, tt.expectedNbMainLocalFiles, len(mainLfs), "number of main local files does not match for %s", tt.name)
|
||||
|
||||
// Can find latest episode
|
||||
latest, ok := entry.FindLatestLocalFile()
|
||||
if assert.Truef(t, ok, "could not find latest local file for %s", tt.name) {
|
||||
assert.Equalf(t, tt.expectedLatestEpisode, latest.GetEpisodeNumber(), "latest episode does not match for %s", tt.name)
|
||||
}
|
||||
|
||||
// Can find successive episodes
|
||||
firstEp, ok := entry.FindLocalFileWithEpisodeNumber(tt.expectedEpisodeNumberAfterEpisode[0])
|
||||
if assert.True(t, ok) {
|
||||
secondEp, ok := entry.FindNextEpisode(firstEp)
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, tt.expectedEpisodeNumberAfterEpisode[1], secondEp.GetEpisodeNumber(), "second episode does not match for %s", tt.name)
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortStableFunc(mainLfs, func(i *anime.LocalFile, j *anime.LocalFile) int {
|
||||
return cmp.Compare(i.GetEpisodeNumber(), j.GetEpisodeNumber())
|
||||
})
|
||||
for idx, lf := range mainLfs {
|
||||
progressNum := entry.GetProgressNumber(lf)
|
||||
|
||||
assert.Equalf(t, tt.expectedProgressNumbers[idx], progressNum, "progress number does not match for %s", tt.name)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
{
|
||||
"154587": {
|
||||
"localFiles": [
|
||||
{
|
||||
"path": "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - 01 (1080p) [F02B9CEE].mkv",
|
||||
"name": "[SubsPlease] Sousou no Frieren - 01 (1080p) [F02B9CEE].mkv",
|
||||
"parsedInfo": {
|
||||
"original": "[SubsPlease] Sousou no Frieren - 01 (1080p) [F02B9CEE].mkv",
|
||||
"title": "Sousou no Frieren",
|
||||
"releaseGroup": "SubsPlease",
|
||||
"episode": "01"
|
||||
},
|
||||
"parsedFolderInfo": [
|
||||
{
|
||||
"original": "Sousou no Frieren",
|
||||
"title": "Sousou no Frieren"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"episode": 1,
|
||||
"aniDBEpisode": "1",
|
||||
"type": "main"
|
||||
},
|
||||
"locked": false,
|
||||
"ignored": false,
|
||||
"mediaId": 154587
|
||||
},
|
||||
{
|
||||
"path": "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - 02 (1080p) [E5A85899].mkv",
|
||||
"name": "[SubsPlease] Sousou no Frieren - 02 (1080p) [E5A85899].mkv",
|
||||
"parsedInfo": {
|
||||
"original": "[SubsPlease] Sousou no Frieren - 02 (1080p) [E5A85899].mkv",
|
||||
"title": "Sousou no Frieren",
|
||||
"releaseGroup": "SubsPlease",
|
||||
"episode": "02"
|
||||
},
|
||||
"parsedFolderInfo": [
|
||||
{
|
||||
"original": "Sousou no Frieren",
|
||||
"title": "Sousou no Frieren"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"episode": 2,
|
||||
"aniDBEpisode": "2",
|
||||
"type": "main"
|
||||
},
|
||||
"locked": false,
|
||||
"ignored": false,
|
||||
"mediaId": 154587
|
||||
},
|
||||
{
|
||||
"path": "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - 03 (1080p) [7EF3F175].mkv",
|
||||
"name": "[SubsPlease] Sousou no Frieren - 03 (1080p) [7EF3F175].mkv",
|
||||
"parsedInfo": {
|
||||
"original": "[SubsPlease] Sousou no Frieren - 03 (1080p) [7EF3F175].mkv",
|
||||
"title": "Sousou no Frieren",
|
||||
"releaseGroup": "SubsPlease",
|
||||
"episode": "03"
|
||||
},
|
||||
"parsedFolderInfo": [
|
||||
{
|
||||
"original": "Sousou no Frieren",
|
||||
"title": "Sousou no Frieren"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"episode": 3,
|
||||
"aniDBEpisode": "3",
|
||||
"type": "main"
|
||||
},
|
||||
"locked": false,
|
||||
"ignored": false,
|
||||
"mediaId": 154587
|
||||
},
|
||||
{
|
||||
"path": "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - 04 (1080p) [5ED46803].mkv",
|
||||
"name": "[SubsPlease] Sousou no Frieren - 04 (1080p) [5ED46803].mkv",
|
||||
"parsedInfo": {
|
||||
"original": "[SubsPlease] Sousou no Frieren - 04 (1080p) [5ED46803].mkv",
|
||||
"title": "Sousou no Frieren",
|
||||
"releaseGroup": "SubsPlease",
|
||||
"episode": "04"
|
||||
},
|
||||
"parsedFolderInfo": [
|
||||
{
|
||||
"original": "Sousou no Frieren",
|
||||
"title": "Sousou no Frieren"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"episode": 4,
|
||||
"aniDBEpisode": "4",
|
||||
"type": "main"
|
||||
},
|
||||
"locked": false,
|
||||
"ignored": false,
|
||||
"mediaId": 154587
|
||||
},
|
||||
{
|
||||
"path": "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - 05 (1080p) [8E3F8FA5].mkv",
|
||||
"name": "[SubsPlease] Sousou no Frieren - 05 (1080p) [8E3F8FA5].mkv",
|
||||
"parsedInfo": {
|
||||
"original": "[SubsPlease] Sousou no Frieren - 05 (1080p) [8E3F8FA5].mkv",
|
||||
"title": "Sousou no Frieren",
|
||||
"releaseGroup": "SubsPlease",
|
||||
"episode": "05"
|
||||
},
|
||||
"parsedFolderInfo": [
|
||||
{
|
||||
"original": "Sousou no Frieren",
|
||||
"title": "Sousou no Frieren"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"episode": 5,
|
||||
"aniDBEpisode": "5",
|
||||
"type": "main"
|
||||
},
|
||||
"locked": false,
|
||||
"ignored": false,
|
||||
"mediaId": 154587
|
||||
}
|
||||
],
|
||||
"animeCollection": {
|
||||
"MediaListCollection": {
|
||||
"lists": [
|
||||
{
|
||||
"status": "CURRENT",
|
||||
"entries": [
|
||||
{
|
||||
"id": 366875178,
|
||||
"score": 9,
|
||||
"progress": 4,
|
||||
"status": "CURRENT",
|
||||
"repeat": 0,
|
||||
"private": false,
|
||||
"startedAt": {
|
||||
"year": 2023,
|
||||
"month": 10
|
||||
},
|
||||
"completedAt": {},
|
||||
"media": {
|
||||
"id": 154587,
|
||||
"idMal": 52991,
|
||||
"siteUrl": "https://anilist.co/anime/154587",
|
||||
"status": "RELEASING",
|
||||
"season": "FALL",
|
||||
"type": "ANIME",
|
||||
"format": "TV",
|
||||
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/154587-ivXNJ23SM1xB.jpg",
|
||||
"episodes": 28,
|
||||
"synonyms": [
|
||||
"Frieren at the Funeral",
|
||||
"장송의 프리렌",
|
||||
"Frieren: Oltre la Fine del Viaggio",
|
||||
"คำอธิษฐานในวันที่จากลา Frieren",
|
||||
"Frieren e a Jornada para o Além",
|
||||
"Frieren – Nach dem Ende der Reise",
|
||||
"葬送的芙莉蓮",
|
||||
"Frieren: Más allá del final del viaje",
|
||||
"Frieren en el funeral",
|
||||
"Sōsō no Furīren",
|
||||
"Frieren. U kresu drogi",
|
||||
"Frieren - Pháp sư tiễn táng",
|
||||
"Фрирен, провожающая в последний путь"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Sousou no Frieren",
|
||||
"romaji": "Sousou no Frieren",
|
||||
"english": "Frieren: Beyond Journey’s End",
|
||||
"native": "葬送のフリーレン"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx154587-n1fmjRv4JQUd.jpg",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx154587-n1fmjRv4JQUd.jpg",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx154587-n1fmjRv4JQUd.jpg",
|
||||
"color": "#d6f1c9"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2023,
|
||||
"month": 9,
|
||||
"day": 29
|
||||
},
|
||||
"endDate": {},
|
||||
"nextAiringEpisode": {
|
||||
"airingAt": 1700229600,
|
||||
"timeUntilAiring": 223940,
|
||||
"episode": 11
|
||||
},
|
||||
"relations": {
|
||||
"edges": [
|
||||
{
|
||||
"relationType": "SOURCE",
|
||||
"node": {
|
||||
"id": 118586,
|
||||
"idMal": 126287,
|
||||
"siteUrl": "https://anilist.co/manga/118586",
|
||||
"status": "RELEASING",
|
||||
"type": "MANGA",
|
||||
"format": "MANGA",
|
||||
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/118586-1JLJiwaIlnBp.jpg",
|
||||
"synonyms": [
|
||||
"Frieren at the Funeral",
|
||||
"장송의 프리렌",
|
||||
"Frieren: Oltre la Fine del Viaggio",
|
||||
"คำอธิษฐานในวันที่จากลา Frieren",
|
||||
"Frieren e a Jornada para o Além",
|
||||
"Frieren – Nach dem Ende der Reise",
|
||||
"葬送的芙莉蓮",
|
||||
"Frieren After \"The End\"",
|
||||
"Frieren: Remnants of the Departed",
|
||||
"Frieren. U kresu drogi",
|
||||
"Frieren",
|
||||
"FRIEREN: Más allá del fin del viaje"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Sousou no Frieren",
|
||||
"romaji": "Sousou no Frieren",
|
||||
"english": "Frieren: Beyond Journey’s End",
|
||||
"native": "葬送のフリーレン"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx118586-F0Lp86XQV7du.jpg",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx118586-F0Lp86XQV7du.jpg",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx118586-F0Lp86XQV7du.jpg",
|
||||
"color": "#e4ae5d"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2020,
|
||||
"month": 4,
|
||||
"day": 28
|
||||
},
|
||||
"endDate": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"relationType": "CHARACTER",
|
||||
"node": {
|
||||
"id": 169811,
|
||||
"idMal": 56805,
|
||||
"siteUrl": "https://anilist.co/anime/169811",
|
||||
"status": "FINISHED",
|
||||
"type": "ANIME",
|
||||
"format": "MUSIC",
|
||||
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/169811-jgMVZlIdH19a.jpg",
|
||||
"episodes": 1,
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Yuusha",
|
||||
"romaji": "Yuusha",
|
||||
"native": "勇者"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx169811-H0RW7WHkRlbH.png",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx169811-H0RW7WHkRlbH.png",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx169811-H0RW7WHkRlbH.png"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2023,
|
||||
"month": 9,
|
||||
"day": 29
|
||||
},
|
||||
"endDate": {
|
||||
"year": 2023,
|
||||
"month": 9,
|
||||
"day": 29
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"relationType": "SIDE_STORY",
|
||||
"node": {
|
||||
"id": 170068,
|
||||
"idMal": 56885,
|
||||
"siteUrl": "https://anilist.co/anime/170068",
|
||||
"status": "RELEASING",
|
||||
"season": "FALL",
|
||||
"type": "ANIME",
|
||||
"format": "ONA",
|
||||
"synonyms": [
|
||||
"Sousou no Frieren Mini Anime",
|
||||
"Frieren: Beyond Journey’s End Mini Anime",
|
||||
"葬送のフリーレン ミニアニメ"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Sousou no Frieren: ●● no Mahou",
|
||||
"romaji": "Sousou no Frieren: ●● no Mahou",
|
||||
"native": "葬送のフリーレン ~●●の魔法~"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx170068-ijY3tCP8KoWP.jpg",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx170068-ijY3tCP8KoWP.jpg",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx170068-ijY3tCP8KoWP.jpg",
|
||||
"color": "#bbd678"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2023,
|
||||
"month": 10,
|
||||
"day": 11
|
||||
},
|
||||
"endDate": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"146065": {
|
||||
"localFiles": [],
|
||||
"animeCollection": {
|
||||
"MediaListCollection": {
|
||||
"lists": [
|
||||
{
|
||||
"status": "CURRENT",
|
||||
"entries": [
|
||||
{
|
||||
"id": 366466419,
|
||||
"score": 0,
|
||||
"progress": 0,
|
||||
"status": "CURRENT",
|
||||
"repeat": 0,
|
||||
"private": false,
|
||||
"startedAt": {
|
||||
"year": 2023,
|
||||
"month": 10,
|
||||
"day": 4
|
||||
},
|
||||
"completedAt": {
|
||||
"year": 2023,
|
||||
"month": 10,
|
||||
"day": 9
|
||||
},
|
||||
"media": {
|
||||
"id": 146065,
|
||||
"idMal": 51179,
|
||||
"siteUrl": "https://anilist.co/anime/146065",
|
||||
"status": "FINISHED",
|
||||
"season": "SUMMER",
|
||||
"type": "ANIME",
|
||||
"format": "TV",
|
||||
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/146065-33RDijfuxLLk.jpg",
|
||||
"episodes": 13,
|
||||
"synonyms": [
|
||||
"ชาตินี้พี่ต้องเทพ ภาค 2",
|
||||
"Mushoku Tensei: Isekai Ittara Honki Dasu 2nd Season",
|
||||
"Mushoku Tensei II: Jobless Reincarnation",
|
||||
"Mushoku Tensei II: Reencarnación desde cero",
|
||||
"无职转生~到了异世界就拿出真本事~第2季"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Mushoku Tensei II: Isekai Ittara Honki Dasu",
|
||||
"romaji": "Mushoku Tensei II: Isekai Ittara Honki Dasu",
|
||||
"english": "Mushoku Tensei: Jobless Reincarnation Season 2",
|
||||
"native": "無職転生 Ⅱ ~異世界行ったら本気だす~"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx146065-IjirxRK26O03.png",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx146065-IjirxRK26O03.png",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx146065-IjirxRK26O03.png",
|
||||
"color": "#35aee4"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2023,
|
||||
"month": 7,
|
||||
"day": 3
|
||||
},
|
||||
"endDate": {
|
||||
"year": 2023,
|
||||
"month": 9,
|
||||
"day": 25
|
||||
},
|
||||
"relations": {
|
||||
"edges": [
|
||||
{
|
||||
"relationType": "SOURCE",
|
||||
"node": {
|
||||
"id": 85470,
|
||||
"idMal": 70261,
|
||||
"siteUrl": "https://anilist.co/manga/85470",
|
||||
"status": "FINISHED",
|
||||
"type": "MANGA",
|
||||
"format": "NOVEL",
|
||||
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85470-akkFSKH9aacB.jpg",
|
||||
"synonyms": [
|
||||
"เกิดชาตินี้พี่ต้องเทพ"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu",
|
||||
"romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu",
|
||||
"english": "Mushoku Tensei: Jobless Reincarnation",
|
||||
"native": "無職転生 ~異世界行ったら本気だす~"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx85470-jt6BF9tDWB2X.jpg",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx85470-jt6BF9tDWB2X.jpg",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx85470-jt6BF9tDWB2X.jpg",
|
||||
"color": "#f1bb1a"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2014,
|
||||
"month": 1,
|
||||
"day": 23
|
||||
},
|
||||
"endDate": {
|
||||
"year": 2022,
|
||||
"month": 11,
|
||||
"day": 25
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"relationType": "ALTERNATIVE",
|
||||
"node": {
|
||||
"id": 85564,
|
||||
"idMal": 70259,
|
||||
"siteUrl": "https://anilist.co/manga/85564",
|
||||
"status": "RELEASING",
|
||||
"type": "MANGA",
|
||||
"format": "MANGA",
|
||||
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85564-Wy8IQU3Km61c.jpg",
|
||||
"synonyms": [
|
||||
"Mushoku Tensei: Uma segunda chance"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu",
|
||||
"romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu",
|
||||
"english": "Mushoku Tensei: Jobless Reincarnation",
|
||||
"native": "無職転生 ~異世界行ったら本気だす~"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx85564-egXRASF0x9B9.jpg",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx85564-egXRASF0x9B9.jpg",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx85564-egXRASF0x9B9.jpg",
|
||||
"color": "#e4ae0d"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2014,
|
||||
"month": 5,
|
||||
"day": 2
|
||||
},
|
||||
"endDate": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"relationType": "PREQUEL",
|
||||
"node": {
|
||||
"id": 127720,
|
||||
"idMal": 45576,
|
||||
"siteUrl": "https://anilist.co/anime/127720",
|
||||
"status": "FINISHED",
|
||||
"season": "FALL",
|
||||
"type": "ANIME",
|
||||
"format": "TV",
|
||||
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/127720-oBpHiMWQhFVN.jpg",
|
||||
"episodes": 12,
|
||||
"synonyms": [
|
||||
"Mushoku Tensei: Jobless Reincarnation Part 2",
|
||||
"ชาตินี้พี่ต้องเทพ พาร์ท 2"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu Part 2",
|
||||
"romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu Part 2",
|
||||
"english": "Mushoku Tensei: Jobless Reincarnation Cour 2",
|
||||
"native": "無職転生 ~異世界行ったら本気だす~ 第2クール"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx127720-ADJgIrUVMdU9.jpg",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx127720-ADJgIrUVMdU9.jpg",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx127720-ADJgIrUVMdU9.jpg",
|
||||
"color": "#d6bb1a"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2021,
|
||||
"month": 10,
|
||||
"day": 4
|
||||
},
|
||||
"endDate": {
|
||||
"year": 2021,
|
||||
"month": 12,
|
||||
"day": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"relationType": "ALTERNATIVE",
|
||||
"node": {
|
||||
"id": 142989,
|
||||
"idMal": 142765,
|
||||
"siteUrl": "https://anilist.co/manga/142989",
|
||||
"status": "RELEASING",
|
||||
"type": "MANGA",
|
||||
"format": "MANGA",
|
||||
"synonyms": [
|
||||
"Mushoku Tensei - Depressed Magician"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu - Shitsui no Majutsushi-hen",
|
||||
"romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu - Shitsui no Majutsushi-hen",
|
||||
"native": "無職転生 ~異世界行ったら本気だす~ 失意の魔術師編"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx142989-jYDNHLwdER70.png",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx142989-jYDNHLwdER70.png",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx142989-jYDNHLwdER70.png",
|
||||
"color": "#e4bb28"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2021,
|
||||
"month": 12,
|
||||
"day": 20
|
||||
},
|
||||
"endDate": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"relationType": "SEQUEL",
|
||||
"node": {
|
||||
"id": 166873,
|
||||
"idMal": 55888,
|
||||
"siteUrl": "https://anilist.co/anime/166873",
|
||||
"status": "NOT_YET_RELEASED",
|
||||
"season": "SPRING",
|
||||
"type": "ANIME",
|
||||
"format": "TV",
|
||||
"episodes": 12,
|
||||
"synonyms": [
|
||||
"Mushoku Tensei: Jobless Reincarnation Season 2 Part 2",
|
||||
"ชาตินี้พี่ต้องเทพ ภาค 2",
|
||||
"Mushoku Tensei: Isekai Ittara Honki Dasu 2nd Season Part 2",
|
||||
"Mushoku Tensei II: Jobless Reincarnation Part 2",
|
||||
"Mushoku Tensei II: Reencarnación desde cero",
|
||||
"无职转生~到了异世界就拿出真本事~第2季"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Mushoku Tensei II: Isekai Ittara Honki Dasu Part 2",
|
||||
"romaji": "Mushoku Tensei II: Isekai Ittara Honki Dasu Part 2",
|
||||
"native": "無職転生 Ⅱ ~異世界行ったら本気だす~ 第2クール"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx166873-cqMLPB00KcEI.jpg",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx166873-cqMLPB00KcEI.jpg",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx166873-cqMLPB00KcEI.jpg",
|
||||
"color": "#6b501a"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2024,
|
||||
"month": 4
|
||||
},
|
||||
"endDate": {
|
||||
"year": 2024,
|
||||
"month": 6
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
154
seanime-2.9.10/internal/library/anime/missing_episodes.go
Normal file
154
seanime-2.9.10/internal/library/anime/missing_episodes.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/hook"
|
||||
"seanime/internal/util/limiter"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/samber/lo"
|
||||
lop "github.com/samber/lo/parallel"
|
||||
"github.com/sourcegraph/conc/pool"
|
||||
)
|
||||
|
||||
type (
|
||||
MissingEpisodes struct {
|
||||
Episodes []*Episode `json:"episodes"`
|
||||
SilencedEpisodes []*Episode `json:"silencedEpisodes"`
|
||||
}
|
||||
|
||||
NewMissingEpisodesOptions struct {
|
||||
AnimeCollection *anilist.AnimeCollection
|
||||
LocalFiles []*LocalFile
|
||||
SilencedMediaIds []int
|
||||
MetadataProvider metadata.Provider
|
||||
}
|
||||
)
|
||||
|
||||
func NewMissingEpisodes(opts *NewMissingEpisodesOptions) *MissingEpisodes {
|
||||
missing := new(MissingEpisodes)
|
||||
|
||||
reqEvent := new(MissingEpisodesRequestedEvent)
|
||||
reqEvent.AnimeCollection = opts.AnimeCollection
|
||||
reqEvent.LocalFiles = opts.LocalFiles
|
||||
reqEvent.SilencedMediaIds = opts.SilencedMediaIds
|
||||
reqEvent.MissingEpisodes = missing
|
||||
err := hook.GlobalHookManager.OnMissingEpisodesRequested().Trigger(reqEvent)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
opts.AnimeCollection = reqEvent.AnimeCollection // Override the anime collection
|
||||
opts.LocalFiles = reqEvent.LocalFiles // Override the local files
|
||||
opts.SilencedMediaIds = reqEvent.SilencedMediaIds // Override the silenced media IDs
|
||||
missing = reqEvent.MissingEpisodes
|
||||
|
||||
// Default prevented by hook, return the missing episodes
|
||||
if reqEvent.DefaultPrevented {
|
||||
event := new(MissingEpisodesEvent)
|
||||
event.MissingEpisodes = missing
|
||||
err = hook.GlobalHookManager.OnMissingEpisodes().Trigger(event)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return event.MissingEpisodes
|
||||
}
|
||||
|
||||
groupedLfs := GroupLocalFilesByMediaID(opts.LocalFiles)
|
||||
|
||||
rateLimiter := limiter.NewLimiter(time.Second, 20)
|
||||
p := pool.NewWithResults[[]*EntryDownloadEpisode]()
|
||||
for mId, lfs := range groupedLfs {
|
||||
p.Go(func() []*EntryDownloadEpisode {
|
||||
entry, found := opts.AnimeCollection.GetListEntryFromAnimeId(mId)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip if the status is nil, dropped or completed
|
||||
if entry.Status == nil || *entry.Status == anilist.MediaListStatusDropped || *entry.Status == anilist.MediaListStatusCompleted {
|
||||
return nil
|
||||
}
|
||||
|
||||
latestLf, found := FindLatestLocalFileFromGroup(lfs)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
//If the latest local file is the same or higher than the current episode count, skip
|
||||
if entry.Media.GetCurrentEpisodeCount() == -1 || entry.Media.GetCurrentEpisodeCount() <= latestLf.GetEpisodeNumber() {
|
||||
return nil
|
||||
}
|
||||
rateLimiter.Wait()
|
||||
// Fetch anime metadata
|
||||
animeMetadata, err := opts.MetadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, entry.Media.ID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get download info
|
||||
downloadInfo, err := NewEntryDownloadInfo(&NewEntryDownloadInfoOptions{
|
||||
LocalFiles: lfs,
|
||||
AnimeMetadata: animeMetadata,
|
||||
Media: entry.Media,
|
||||
Progress: entry.Progress,
|
||||
Status: entry.Status,
|
||||
MetadataProvider: opts.MetadataProvider,
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
episodes := downloadInfo.EpisodesToDownload
|
||||
|
||||
sort.Slice(episodes, func(i, j int) bool {
|
||||
return episodes[i].Episode.GetEpisodeNumber() < episodes[j].Episode.GetEpisodeNumber()
|
||||
})
|
||||
|
||||
// If there are more than 1 episode to download, modify the name of the first episode
|
||||
if len(episodes) > 1 {
|
||||
episodes = episodes[:1] // keep the first episode
|
||||
if episodes[0].Episode != nil {
|
||||
episodes[0].Episode.DisplayTitle = episodes[0].Episode.DisplayTitle + fmt.Sprintf(" & %d more", len(downloadInfo.EpisodesToDownload)-1)
|
||||
}
|
||||
}
|
||||
return episodes
|
||||
})
|
||||
}
|
||||
epsToDownload := p.Wait()
|
||||
epsToDownload = lo.Filter(epsToDownload, func(item []*EntryDownloadEpisode, _ int) bool {
|
||||
return item != nil
|
||||
})
|
||||
|
||||
// Flatten
|
||||
flattenedEpsToDownload := lo.Flatten(epsToDownload)
|
||||
eps := lop.Map(flattenedEpsToDownload, func(item *EntryDownloadEpisode, _ int) *Episode {
|
||||
return item.Episode
|
||||
})
|
||||
// Sort
|
||||
sort.Slice(eps, func(i, j int) bool {
|
||||
return eps[i].GetEpisodeNumber() < eps[j].GetEpisodeNumber()
|
||||
})
|
||||
sort.Slice(eps, func(i, j int) bool {
|
||||
return eps[i].BaseAnime.ID < eps[j].BaseAnime.ID
|
||||
})
|
||||
|
||||
missing.Episodes = lo.Filter(eps, func(item *Episode, _ int) bool {
|
||||
return !lo.Contains(opts.SilencedMediaIds, item.BaseAnime.ID)
|
||||
})
|
||||
|
||||
missing.SilencedEpisodes = lo.Filter(eps, func(item *Episode, _ int) bool {
|
||||
return lo.Contains(opts.SilencedMediaIds, item.BaseAnime.ID)
|
||||
})
|
||||
|
||||
// Event
|
||||
event := new(MissingEpisodesEvent)
|
||||
event.MissingEpisodes = missing
|
||||
err = hook.GlobalHookManager.OnMissingEpisodes().Trigger(event)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return event.MissingEpisodes
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package anime_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/test_utils"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Test to retrieve accurate missing episodes
|
||||
// DEPRECATED
|
||||
func TestNewMissingEpisodes(t *testing.T) {
|
||||
t.Skip("Outdated test")
|
||||
test_utils.InitTestProvider(t, test_utils.Anilist())
|
||||
|
||||
metadataProvider := metadata.GetMockProvider(t)
|
||||
|
||||
anilistClient := anilist.TestGetMockAnilistClient()
|
||||
animeCollection, err := anilistClient.AnimeCollection(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mediaId int
|
||||
localFiles []*anime.LocalFile
|
||||
mediaAiredEpisodes int
|
||||
currentProgress int
|
||||
expectedMissingEpisodes int
|
||||
}{
|
||||
{
|
||||
// Sousou no Frieren - 10 currently aired episodes
|
||||
// User has 5 local files from ep 1 to 5, but only watched 4 episodes
|
||||
// So we should expect to see 5 missing episodes
|
||||
name: "Sousou no Frieren, missing 5 episodes",
|
||||
mediaId: 154587,
|
||||
localFiles: anime.MockHydratedLocalFiles(
|
||||
anime.MockGenerateHydratedLocalFileGroupOptions("E:/Anime", "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - %ep (1080p) [F02B9CEE].mkv", 154587, []anime.MockHydratedLocalFileWrapperOptionsMetadata{
|
||||
{MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 3, MetadataAniDbEpisode: "3", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 4, MetadataAniDbEpisode: "4", MetadataType: anime.LocalFileTypeMain},
|
||||
{MetadataEpisode: 5, MetadataAniDbEpisode: "5", MetadataType: anime.LocalFileTypeMain},
|
||||
}),
|
||||
),
|
||||
mediaAiredEpisodes: 10,
|
||||
currentProgress: 4,
|
||||
//expectedMissingEpisodes: 5,
|
||||
expectedMissingEpisodes: 1, // DEVNOTE: Now the value is 1 at most because everything else is merged
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
// Mock Anilist collection
|
||||
anilist.TestModifyAnimeCollectionEntry(animeCollection, tt.mediaId, anilist.TestModifyAnimeCollectionEntryInput{
|
||||
Progress: lo.ToPtr(tt.currentProgress), // Mock progress
|
||||
AiredEpisodes: lo.ToPtr(tt.mediaAiredEpisodes),
|
||||
NextAiringEpisode: &anilist.BaseAnime_NextAiringEpisode{
|
||||
Episode: tt.mediaAiredEpisodes + 1,
|
||||
},
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
if assert.NoError(t, err) {
|
||||
missingData := anime.NewMissingEpisodes(&anime.NewMissingEpisodesOptions{
|
||||
AnimeCollection: animeCollection,
|
||||
LocalFiles: tt.localFiles,
|
||||
MetadataProvider: metadataProvider,
|
||||
})
|
||||
|
||||
assert.Equal(t, tt.expectedMissingEpisodes, len(missingData.Episodes))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
24
seanime-2.9.10/internal/library/anime/normalized_media.go
Normal file
24
seanime-2.9.10/internal/library/anime/normalized_media.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/util/result"
|
||||
)
|
||||
|
||||
type NormalizedMedia struct {
|
||||
*anilist.BaseAnime
|
||||
}
|
||||
|
||||
type NormalizedMediaCache struct {
|
||||
*result.Cache[int, *NormalizedMedia]
|
||||
}
|
||||
|
||||
func NewNormalizedMedia(m *anilist.BaseAnime) *NormalizedMedia {
|
||||
return &NormalizedMedia{
|
||||
BaseAnime: m,
|
||||
}
|
||||
}
|
||||
|
||||
func NewNormalizedMediaCache() *NormalizedMediaCache {
|
||||
return &NormalizedMediaCache{result.NewCache[int, *NormalizedMedia]()}
|
||||
}
|
||||
50
seanime-2.9.10/internal/library/anime/playlist.go
Normal file
50
seanime-2.9.10/internal/library/anime/playlist.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"seanime/internal/util"
|
||||
)
|
||||
|
||||
type (
|
||||
// Playlist holds the data from models.PlaylistEntry
|
||||
Playlist struct {
|
||||
DbId uint `json:"dbId"` // DbId is the database ID of the models.PlaylistEntry
|
||||
Name string `json:"name"` // Name is the name of the playlist
|
||||
LocalFiles []*LocalFile `json:"localFiles"` // LocalFiles is a list of local files in the playlist, in order
|
||||
}
|
||||
)
|
||||
|
||||
// NewPlaylist creates a new Playlist instance
|
||||
func NewPlaylist(name string) *Playlist {
|
||||
return &Playlist{
|
||||
Name: name,
|
||||
LocalFiles: make([]*LocalFile, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *Playlist) SetLocalFiles(lfs []*LocalFile) {
|
||||
pd.LocalFiles = lfs
|
||||
}
|
||||
|
||||
// AddLocalFile adds a local file to the playlist
|
||||
func (pd *Playlist) AddLocalFile(localFile *LocalFile) {
|
||||
pd.LocalFiles = append(pd.LocalFiles, localFile)
|
||||
}
|
||||
|
||||
// RemoveLocalFile removes a local file from the playlist
|
||||
func (pd *Playlist) RemoveLocalFile(path string) {
|
||||
for i, lf := range pd.LocalFiles {
|
||||
if lf.GetNormalizedPath() == util.NormalizePath(path) {
|
||||
pd.LocalFiles = append(pd.LocalFiles[:i], pd.LocalFiles[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *Playlist) LocalFileExists(path string, lfs []*LocalFile) bool {
|
||||
for _, lf := range lfs {
|
||||
if lf.GetNormalizedPath() == util.NormalizePath(path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
111
seanime-2.9.10/internal/library/anime/schedule.go
Normal file
111
seanime-2.9.10/internal/library/anime/schedule.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/hook"
|
||||
"time"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type ScheduleItem struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
Title string `json:"title"`
|
||||
// Time is in 15:04 format
|
||||
Time string `json:"time"`
|
||||
// DateTime is in UTC
|
||||
DateTime time.Time `json:"dateTime"`
|
||||
Image string `json:"image"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
IsMovie bool `json:"isMovie"`
|
||||
IsSeasonFinale bool `json:"isSeasonFinale"`
|
||||
}
|
||||
|
||||
func GetScheduleItems(animeSchedule *anilist.AnimeAiringSchedule, animeCollection *anilist.AnimeCollection) []*ScheduleItem {
|
||||
animeEntryMap := make(map[int]*anilist.AnimeListEntry)
|
||||
for _, list := range animeCollection.MediaListCollection.GetLists() {
|
||||
for _, entry := range list.GetEntries() {
|
||||
animeEntryMap[entry.GetMedia().GetID()] = entry
|
||||
}
|
||||
}
|
||||
|
||||
type animeScheduleNode interface {
|
||||
GetAiringAt() int
|
||||
GetTimeUntilAiring() int
|
||||
GetEpisode() int
|
||||
}
|
||||
|
||||
type animeScheduleMedia interface {
|
||||
GetMedia() []*anilist.AnimeSchedule
|
||||
}
|
||||
|
||||
formatNodeItem := func(node animeScheduleNode, entry *anilist.AnimeListEntry) *ScheduleItem {
|
||||
t := time.Unix(int64(node.GetAiringAt()), 0)
|
||||
item := &ScheduleItem{
|
||||
MediaId: entry.GetMedia().GetID(),
|
||||
Title: *entry.GetMedia().GetTitle().GetUserPreferred(),
|
||||
Time: t.UTC().Format("15:04"),
|
||||
DateTime: t.UTC(),
|
||||
Image: entry.GetMedia().GetCoverImageSafe(),
|
||||
EpisodeNumber: node.GetEpisode(),
|
||||
IsMovie: entry.GetMedia().IsMovie(),
|
||||
IsSeasonFinale: false,
|
||||
}
|
||||
if entry.GetMedia().GetTotalEpisodeCount() > 0 && node.GetEpisode() == entry.GetMedia().GetTotalEpisodeCount() {
|
||||
item.IsSeasonFinale = true
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
formatPart := func(m animeScheduleMedia) ([]*ScheduleItem, bool) {
|
||||
if m == nil {
|
||||
return nil, false
|
||||
}
|
||||
ret := make([]*ScheduleItem, 0)
|
||||
for _, m := range m.GetMedia() {
|
||||
entry, ok := animeEntryMap[m.GetID()]
|
||||
if !ok || entry.Status == nil || *entry.Status == anilist.MediaListStatusDropped {
|
||||
continue
|
||||
}
|
||||
for _, n := range m.GetPrevious().GetNodes() {
|
||||
ret = append(ret, formatNodeItem(n, entry))
|
||||
}
|
||||
for _, n := range m.GetUpcoming().GetNodes() {
|
||||
ret = append(ret, formatNodeItem(n, entry))
|
||||
}
|
||||
}
|
||||
return ret, true
|
||||
}
|
||||
|
||||
ongoingItems, _ := formatPart(animeSchedule.GetOngoing())
|
||||
ongoingNextItems, _ := formatPart(animeSchedule.GetOngoingNext())
|
||||
precedingItems, _ := formatPart(animeSchedule.GetPreceding())
|
||||
upcomingItems, _ := formatPart(animeSchedule.GetUpcoming())
|
||||
upcomingNextItems, _ := formatPart(animeSchedule.GetUpcomingNext())
|
||||
|
||||
allItems := make([]*ScheduleItem, 0)
|
||||
allItems = append(allItems, ongoingItems...)
|
||||
allItems = append(allItems, ongoingNextItems...)
|
||||
allItems = append(allItems, precedingItems...)
|
||||
allItems = append(allItems, upcomingItems...)
|
||||
allItems = append(allItems, upcomingNextItems...)
|
||||
|
||||
ret := lo.UniqBy(allItems, func(item *ScheduleItem) string {
|
||||
if item == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d-%d-%d", item.MediaId, item.EpisodeNumber, item.DateTime.Unix())
|
||||
})
|
||||
|
||||
event := &AnimeScheduleItemsEvent{
|
||||
AnimeCollection: animeCollection,
|
||||
Items: ret,
|
||||
}
|
||||
err := hook.GlobalHookManager.OnAnimeScheduleItems().Trigger(event)
|
||||
if err != nil {
|
||||
return ret
|
||||
}
|
||||
|
||||
return event.Items
|
||||
}
|
||||
80
seanime-2.9.10/internal/library/anime/test_helpers.go
Normal file
80
seanime-2.9.10/internal/library/anime/test_helpers.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type MockHydratedLocalFileOptions struct {
|
||||
FilePath string
|
||||
LibraryPath string
|
||||
MediaId int
|
||||
MetadataEpisode int
|
||||
MetadataAniDbEpisode string
|
||||
MetadataType LocalFileType
|
||||
}
|
||||
|
||||
func MockHydratedLocalFile(opts MockHydratedLocalFileOptions) *LocalFile {
|
||||
lf := NewLocalFile(opts.FilePath, opts.LibraryPath)
|
||||
lf.MediaId = opts.MediaId
|
||||
lf.Metadata = &LocalFileMetadata{
|
||||
AniDBEpisode: opts.MetadataAniDbEpisode,
|
||||
Episode: opts.MetadataEpisode,
|
||||
Type: opts.MetadataType,
|
||||
}
|
||||
return lf
|
||||
}
|
||||
|
||||
// MockHydratedLocalFiles creates a slice of LocalFiles based on the provided options
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// MockHydratedLocalFiles(
|
||||
// MockHydratedLocalFileOptions{
|
||||
// FilePath: "/mnt/anime/One Piece/One Piece - 1070.mkv",
|
||||
// LibraryPath: "/mnt/anime/",
|
||||
// MetadataEpisode: 1070,
|
||||
// MetadataAniDbEpisode: "1070",
|
||||
// MetadataType: LocalFileTypeMain,
|
||||
// },
|
||||
// MockHydratedLocalFileOptions{
|
||||
// ...
|
||||
// },
|
||||
// )
|
||||
func MockHydratedLocalFiles(opts ...[]MockHydratedLocalFileOptions) []*LocalFile {
|
||||
lfs := make([]*LocalFile, 0, len(opts))
|
||||
for _, opt := range opts {
|
||||
for _, o := range opt {
|
||||
lfs = append(lfs, MockHydratedLocalFile(o))
|
||||
}
|
||||
}
|
||||
return lfs
|
||||
}
|
||||
|
||||
type MockHydratedLocalFileWrapperOptionsMetadata struct {
|
||||
MetadataEpisode int
|
||||
MetadataAniDbEpisode string
|
||||
MetadataType LocalFileType
|
||||
}
|
||||
|
||||
// MockGenerateHydratedLocalFileGroupOptions generates a slice of MockHydratedLocalFileOptions based on a template string and metadata
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// MockGenerateHydratedLocalFileGroupOptions("/mnt/anime/", "One Piece/One Piece - %ep.mkv", 21, []MockHydratedLocalFileWrapperOptionsMetadata{
|
||||
// {MetadataEpisode: 1070, MetadataAniDbEpisode: "1070", MetadataType: LocalFileTypeMain},
|
||||
// })
|
||||
func MockGenerateHydratedLocalFileGroupOptions(libraryPath string, template string, mId int, m []MockHydratedLocalFileWrapperOptionsMetadata) []MockHydratedLocalFileOptions {
|
||||
opts := make([]MockHydratedLocalFileOptions, 0, len(m))
|
||||
for _, metadata := range m {
|
||||
opts = append(opts, MockHydratedLocalFileOptions{
|
||||
FilePath: strings.ReplaceAll(template, "%ep", strconv.Itoa(metadata.MetadataEpisode)),
|
||||
LibraryPath: libraryPath,
|
||||
MediaId: mId,
|
||||
MetadataEpisode: metadata.MetadataEpisode,
|
||||
MetadataAniDbEpisode: metadata.MetadataAniDbEpisode,
|
||||
MetadataType: metadata.MetadataType,
|
||||
})
|
||||
}
|
||||
return opts
|
||||
}
|
||||
Reference in New Issue
Block a user