node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View 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**.

View 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"`
}
)

View 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
}

View 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
}
}
}

View 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
}

View 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)
}
}

View File

@@ -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)
}

View 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
}

View 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
}

View 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
}
}

View 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))
}
}
})
}
}

View 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
}

View 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
}

View 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
}

View 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"`
}

View 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
}

View 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)
}

View 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())
}
}
})
}
}

View 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)
}
})
}
}

View 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
}

View 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)
}
}
}
}
}
}

View File

@@ -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 Journeys 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 Journeys 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 Journeys 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
}
}
}
]
}
}
}
]
}
]
}
}
}
}

View 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
}

View File

@@ -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))
}
}
}

View 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]()}
}

View 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
}

View 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
}

View 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
}