mirror of
https://github.com/5rahim/seanime
synced 2026-04-18 22:24:55 +02:00
improve tests
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
package extension_playground
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"seanime/internal/api/anilist"
|
||||
metadataapi "seanime/internal/api/metadata"
|
||||
"seanime/internal/api/metadata_provider"
|
||||
"seanime/internal/extension"
|
||||
@@ -12,8 +10,8 @@ import (
|
||||
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
|
||||
hibiketorrent "seanime/internal/extension/hibike/torrent"
|
||||
"seanime/internal/platforms/platform"
|
||||
"seanime/internal/testmocks"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/result"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -268,8 +266,8 @@ func TestPlaygroundRepositoryCachesFetchedMedia(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, anime)
|
||||
require.NotNil(t, metadata)
|
||||
require.Equal(t, 1, fakePlatform.animeCalls[testAnimeID])
|
||||
require.Equal(t, 2, fakeMetadataProvider.calls[testAnimeID])
|
||||
require.Equal(t, 1, fakePlatform.AnimeCalls(testAnimeID))
|
||||
require.Equal(t, 2, fakeMetadataProvider.MetadataCalls(testAnimeID))
|
||||
|
||||
manga, err := repo.getManga(testMangaID)
|
||||
require.NoError(t, err)
|
||||
@@ -278,7 +276,7 @@ func TestPlaygroundRepositoryCachesFetchedMedia(t *testing.T) {
|
||||
manga, err = repo.getManga(testMangaID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, manga)
|
||||
require.Equal(t, 1, fakePlatform.mangaCalls[testMangaID])
|
||||
require.Equal(t, 1, fakePlatform.MangaCalls(testMangaID))
|
||||
}
|
||||
|
||||
func TestRunPlaygroundCodeValidation(t *testing.T) {
|
||||
@@ -612,10 +610,28 @@ func TestRunPlaygroundCodeOnlinestreamProvider(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func newTestPlaygroundRepository() (*PlaygroundRepository, *fakePlatform, *fakeMetadataProvider) {
|
||||
func newTestPlaygroundRepository() (*PlaygroundRepository, *testmocks.FakePlatform, *testmocks.FakeMetadataProvider) {
|
||||
logger := util.NewLogger()
|
||||
fakePlatform := newFakePlatform()
|
||||
fakeMetadataProvider := newFakeMetadataProvider()
|
||||
fakePlatform := testmocks.NewFakePlatformBuilder().
|
||||
WithAnime(testmocks.NewBaseAnime(testAnimeID, "Sample Anime")).
|
||||
WithManga(testmocks.NewBaseManga(testMangaID, "Blue Lock")).
|
||||
Build()
|
||||
fakeMetadataProvider := testmocks.NewFakeMetadataProviderBuilder().
|
||||
WithAnimeMetadata(testAnimeID, &metadataapi.AnimeMetadata{
|
||||
Titles: map[string]string{
|
||||
"en": "Sample Anime",
|
||||
},
|
||||
Episodes: map[string]*metadataapi.EpisodeMetadata{
|
||||
"1": {
|
||||
Episode: "1",
|
||||
EpisodeNumber: 1,
|
||||
AbsoluteEpisodeNumber: 13,
|
||||
AnidbEid: 77,
|
||||
},
|
||||
},
|
||||
Mappings: &metadataapi.AnimeMappings{AnidbId: 9001},
|
||||
}).
|
||||
Build()
|
||||
|
||||
return NewPlaygroundRepository(
|
||||
logger,
|
||||
@@ -628,224 +644,3 @@ func decodeJSON(t *testing.T, raw string, target interface{}) {
|
||||
t.Helper()
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), target))
|
||||
}
|
||||
|
||||
func newFakePlatform() *fakePlatform {
|
||||
return &fakePlatform{
|
||||
animeByID: map[int]*anilist.BaseAnime{
|
||||
testAnimeID: newTestAnime(testAnimeID, "Sample Anime"),
|
||||
},
|
||||
mangaByID: map[int]*anilist.BaseManga{
|
||||
testMangaID: newTestManga(testMangaID, "Blue Lock"),
|
||||
},
|
||||
animeCalls: make(map[int]int),
|
||||
mangaCalls: make(map[int]int),
|
||||
}
|
||||
}
|
||||
|
||||
func newFakeMetadataProvider() *fakeMetadataProvider {
|
||||
return &fakeMetadataProvider{
|
||||
metadataByID: map[int]*metadataapi.AnimeMetadata{
|
||||
testAnimeID: {
|
||||
Titles: map[string]string{
|
||||
"en": "Sample Anime",
|
||||
},
|
||||
Episodes: map[string]*metadataapi.EpisodeMetadata{
|
||||
"1": {
|
||||
Episode: "1",
|
||||
EpisodeNumber: 1,
|
||||
AbsoluteEpisodeNumber: 13,
|
||||
AnidbEid: 77,
|
||||
},
|
||||
},
|
||||
Mappings: &metadataapi.AnimeMappings{AnidbId: 9001},
|
||||
},
|
||||
},
|
||||
calls: make(map[int]int),
|
||||
cache: result.NewBoundedCache[string, *metadataapi.AnimeMetadata](10),
|
||||
}
|
||||
}
|
||||
|
||||
func newTestAnime(id int, title string) *anilist.BaseAnime {
|
||||
return &anilist.BaseAnime{
|
||||
ID: id,
|
||||
IDMal: new(501),
|
||||
Status: new(anilist.MediaStatusFinished),
|
||||
Format: new(anilist.MediaFormatTv),
|
||||
Episodes: new(12),
|
||||
IsAdult: new(false),
|
||||
Title: &anilist.BaseAnime_Title{
|
||||
English: new(title),
|
||||
Romaji: new(title),
|
||||
},
|
||||
Synonyms: []*string{new(title), new("Sample Anime Season 1")},
|
||||
StartDate: &anilist.BaseAnime_StartDate{
|
||||
Year: new(2024),
|
||||
Month: new(1),
|
||||
Day: new(2),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestManga(id int, title string) *anilist.BaseManga {
|
||||
return &anilist.BaseManga{
|
||||
ID: id,
|
||||
Status: new(anilist.MediaStatusFinished),
|
||||
Format: new(anilist.MediaFormatManga),
|
||||
IsAdult: new(false),
|
||||
Title: &anilist.BaseManga_Title{
|
||||
English: new(title),
|
||||
Romaji: new(title),
|
||||
},
|
||||
Synonyms: []*string{new(title), new(title + " Alternative")},
|
||||
StartDate: &anilist.BaseManga_StartDate{
|
||||
Year: new(2023),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type fakePlatform struct {
|
||||
animeByID map[int]*anilist.BaseAnime
|
||||
mangaByID map[int]*anilist.BaseManga
|
||||
animeCalls map[int]int
|
||||
mangaCalls map[int]int
|
||||
}
|
||||
|
||||
func (f *fakePlatform) SetUsername(string) {}
|
||||
|
||||
func (f *fakePlatform) UpdateEntry(context.Context, int, *anilist.MediaListStatus, *int, *int, *anilist.FuzzyDateInput, *anilist.FuzzyDateInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) UpdateEntryProgress(context.Context, int, int, *int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) UpdateEntryRepeat(context.Context, int, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) DeleteEntry(context.Context, int, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) GetAnime(_ context.Context, mediaID int) (*anilist.BaseAnime, error) {
|
||||
f.animeCalls[mediaID]++
|
||||
anime, ok := f.animeByID[mediaID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("anime %d not found", mediaID)
|
||||
}
|
||||
return anime, nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) GetAnimeByMalID(context.Context, int) (*anilist.BaseAnime, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) GetAnimeWithRelations(context.Context, int) (*anilist.CompleteAnime, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) GetAnimeDetails(context.Context, int) (*anilist.AnimeDetailsById_Media, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) GetManga(_ context.Context, mediaID int) (*anilist.BaseManga, error) {
|
||||
f.mangaCalls[mediaID]++
|
||||
manga, ok := f.mangaByID[mediaID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("manga %d not found", mediaID)
|
||||
}
|
||||
return manga, nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) GetAnimeCollection(context.Context, bool) (*anilist.AnimeCollection, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) GetRawAnimeCollection(context.Context, bool) (*anilist.AnimeCollection, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) GetMangaDetails(context.Context, int) (*anilist.MangaDetailsById_Media, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) GetAnimeCollectionWithRelations(context.Context) (*anilist.AnimeCollectionWithRelations, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) GetMangaCollection(context.Context, bool) (*anilist.MangaCollection, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) GetRawMangaCollection(context.Context, bool) (*anilist.MangaCollection, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) AddMediaToCollection(context.Context, []int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) GetStudioDetails(context.Context, int) (*anilist.StudioDetails, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) GetAnilistClient() anilist.AnilistClient {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) RefreshAnimeCollection(context.Context) (*anilist.AnimeCollection, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) RefreshMangaCollection(context.Context) (*anilist.MangaCollection, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) GetViewerStats(context.Context) (*anilist.ViewerStats, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) GetAnimeAiringSchedule(context.Context) (*anilist.AnimeAiringSchedule, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePlatform) ClearCache() {}
|
||||
|
||||
func (f *fakePlatform) Close() {}
|
||||
|
||||
type fakeMetadataProvider struct {
|
||||
metadataByID map[int]*metadataapi.AnimeMetadata
|
||||
calls map[int]int
|
||||
cache *result.BoundedCache[string, *metadataapi.AnimeMetadata]
|
||||
}
|
||||
|
||||
func (f *fakeMetadataProvider) GetAnimeMetadata(_ metadataapi.Platform, mediaID int) (*metadataapi.AnimeMetadata, error) {
|
||||
f.calls[mediaID]++
|
||||
if metadata, ok := f.metadataByID[mediaID]; ok {
|
||||
return metadata, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeMetadataProvider) GetAnimeMetadataWrapper(_ *anilist.BaseAnime, _ *metadataapi.AnimeMetadata) metadata_provider.AnimeMetadataWrapper {
|
||||
return fakeAnimeMetadataWrapper{}
|
||||
}
|
||||
|
||||
func (f *fakeMetadataProvider) GetCache() *result.BoundedCache[string, *metadataapi.AnimeMetadata] {
|
||||
return f.cache
|
||||
}
|
||||
|
||||
func (f *fakeMetadataProvider) SetUseFallbackProvider(bool) {}
|
||||
|
||||
func (f *fakeMetadataProvider) ClearCache() {
|
||||
f.cache.Clear()
|
||||
}
|
||||
|
||||
func (f *fakeMetadataProvider) Close() {}
|
||||
|
||||
type fakeAnimeMetadataWrapper struct{}
|
||||
|
||||
func (fakeAnimeMetadataWrapper) GetEpisodeMetadata(string) metadataapi.EpisodeMetadata {
|
||||
return metadataapi.EpisodeMetadata{}
|
||||
}
|
||||
|
||||
@@ -1,115 +1,246 @@
|
||||
//go:build outdated
|
||||
|
||||
package goja_bindings
|
||||
|
||||
import (
|
||||
"seanime/internal/util"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
gojautil "seanime/internal/util/goja"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAbortContext(t *testing.T) {
|
||||
vm := goja.New()
|
||||
BindAbortContext(vm, gojautil.NewScheduler())
|
||||
func TestAbortContextStateAndReason(t *testing.T) {
|
||||
t.Run("default abort state and reason", func(t *testing.T) {
|
||||
vm, _ := newAbortTestRuntime(t)
|
||||
|
||||
t.Run("AbortContext basic functionality", func(t *testing.T) {
|
||||
script := `
|
||||
const controller = new AbortContext();
|
||||
const signal = controller.signal;
|
||||
|
||||
let aborted = signal.aborted;
|
||||
controller.abort();
|
||||
|
||||
({
|
||||
initialAborted: aborted,
|
||||
finalAborted: signal.aborted
|
||||
})
|
||||
`
|
||||
val, err := vm.RunString(`
|
||||
(() => {
|
||||
const controller = new AbortContext();
|
||||
const signal = controller.signal;
|
||||
const initialAborted = signal.aborted;
|
||||
|
||||
val, err := vm.RunString(script)
|
||||
assert.NoError(t, err)
|
||||
controller.abort();
|
||||
const defaultReason = String(signal.reason);
|
||||
|
||||
controller.abort("ignored");
|
||||
|
||||
return {
|
||||
initialAborted,
|
||||
finalAborted: signal.aborted,
|
||||
defaultReason,
|
||||
finalReason: String(signal.reason),
|
||||
};
|
||||
})()
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
|
||||
obj := val.ToObject(vm)
|
||||
util.Spew(obj.Export())
|
||||
initialAborted := obj.Get("initialAborted").ToBoolean()
|
||||
finalAborted := obj.Get("finalAborted").ToBoolean()
|
||||
|
||||
assert.False(t, initialAborted, "Signal should not be aborted initially")
|
||||
assert.True(t, finalAborted, "Signal should be aborted after controller.abort()")
|
||||
require.False(t, obj.Get("initialAborted").ToBoolean())
|
||||
require.True(t, obj.Get("finalAborted").ToBoolean())
|
||||
require.Contains(t, obj.Get("defaultReason").String(), "context canceled")
|
||||
require.Equal(t, obj.Get("defaultReason").String(), obj.Get("finalReason").String())
|
||||
})
|
||||
|
||||
//t.Run("AbortSignal event listener", func(t *testing.T) {
|
||||
// script := `
|
||||
// const controller = new AbortContext();
|
||||
// const signal = controller.signal;
|
||||
//
|
||||
// let eventFired = false;
|
||||
// signal.addEventListener('abort', () => {
|
||||
// eventFired = true;
|
||||
// });
|
||||
//
|
||||
// controller.abort();
|
||||
//
|
||||
// eventFired
|
||||
// `
|
||||
//
|
||||
// val, err := vm.RunString(script)
|
||||
// require.NoError(t, err)
|
||||
// assert.True(t, val.ToBoolean(), "Abort event should fire")
|
||||
//})
|
||||
t.Run("custom abort reason", func(t *testing.T) {
|
||||
vm, _ := newAbortTestRuntime(t)
|
||||
|
||||
t.Run("AbortSignal with reason", func(t *testing.T) {
|
||||
script := `
|
||||
const controller = new AbortContext();
|
||||
const signal = controller.signal;
|
||||
|
||||
controller.abort('Custom reason');
|
||||
|
||||
signal.reason
|
||||
`
|
||||
val, err := vm.RunString(`
|
||||
(() => {
|
||||
const controller = new AbortContext();
|
||||
controller.abort("Custom reason");
|
||||
return {
|
||||
aborted: controller.signal.aborted,
|
||||
reason: controller.signal.reason,
|
||||
};
|
||||
})()
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
|
||||
val, err := vm.RunString(script)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Custom reason", val.String())
|
||||
obj := val.ToObject(vm)
|
||||
require.True(t, obj.Get("aborted").ToBoolean())
|
||||
require.Equal(t, "Custom reason", obj.Get("reason").String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestAbortContextAbortListeners(t *testing.T) {
|
||||
t.Run("listener fires once when registered before abort", func(t *testing.T) {
|
||||
vm, _ := newAbortTestRuntime(t)
|
||||
|
||||
var count atomic.Int32
|
||||
reasons := make(chan string, 1)
|
||||
vm.Set("recordAbort", func(reason string) {
|
||||
count.Add(1)
|
||||
reasons <- reason
|
||||
})
|
||||
|
||||
_, err := vm.RunString(`
|
||||
(() => {
|
||||
const controller = new AbortContext();
|
||||
controller.signal.addEventListener("abort", () => {
|
||||
recordAbort(String(controller.signal.reason));
|
||||
});
|
||||
controller.abort("first");
|
||||
controller.abort("second");
|
||||
})();
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case reason := <-reasons:
|
||||
require.Equal(t, "first", reason)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("abort listener was not called")
|
||||
}
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return count.Load() == 1
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
require.Equal(t, int32(1), count.Load())
|
||||
})
|
||||
|
||||
t.Run("listener added after abort fires asynchronously", func(t *testing.T) {
|
||||
vm, _ := newAbortTestRuntime(t)
|
||||
|
||||
reasons := make(chan string, 1)
|
||||
vm.Set("recordAbort", func(reason string) {
|
||||
reasons <- reason
|
||||
})
|
||||
|
||||
_, err := vm.RunString(`
|
||||
(() => {
|
||||
const controller = new AbortContext();
|
||||
controller.abort("late-reason");
|
||||
controller.signal.addEventListener("abort", () => {
|
||||
recordAbort(String(controller.signal.reason));
|
||||
});
|
||||
})();
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case reason := <-reasons:
|
||||
require.Equal(t, "late-reason", reason)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("late abort listener was not called")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAbortContextWithFetch(t *testing.T) {
|
||||
vm := goja.New()
|
||||
BindAbortContext(vm, gojautil.NewScheduler())
|
||||
fetch := BindFetch(vm)
|
||||
defer fetch.Close()
|
||||
t.Run("already aborted signal rejects before request starts", func(t *testing.T) {
|
||||
vm, _ := newAbortTestRuntime(t)
|
||||
|
||||
// Start the response channel handler
|
||||
go func() {
|
||||
for fn := range fetch.ResponseChannel() {
|
||||
fn()
|
||||
var requestCount atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
fetch := BindFetch(vm, []string{"*"})
|
||||
defer fetch.Close()
|
||||
|
||||
val, err := vm.RunString(fmt.Sprintf(`
|
||||
(() => {
|
||||
const controller = new AbortContext();
|
||||
controller.abort("request aborted");
|
||||
return fetch(%q, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
})()
|
||||
`, server.URL))
|
||||
require.NoError(t, err)
|
||||
|
||||
promise := requirePromise(t, val)
|
||||
waitForPromiseState(t, promise, goja.PromiseStateRejected)
|
||||
require.Equal(t, "request aborted", promise.Result().Export())
|
||||
require.Equal(t, int32(0), requestCount.Load())
|
||||
})
|
||||
|
||||
t.Run("in-flight request is canceled through signal context", func(t *testing.T) {
|
||||
vm, _ := newAbortTestRuntime(t)
|
||||
|
||||
started := make(chan struct{})
|
||||
canceled := make(chan struct{})
|
||||
var startedOnce sync.Once
|
||||
var canceledOnce sync.Once
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
startedOnce.Do(func() { close(started) })
|
||||
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
canceledOnce.Do(func() { close(canceled) })
|
||||
case <-time.After(2 * time.Second):
|
||||
w.WriteHeader(http.StatusGatewayTimeout)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
fetch := BindFetch(vm, []string{"*"})
|
||||
defer fetch.Close()
|
||||
|
||||
val, err := vm.RunString(fmt.Sprintf(`
|
||||
(() => {
|
||||
globalThis.controller = new AbortContext();
|
||||
return fetch(%q, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
})()
|
||||
`, server.URL))
|
||||
require.NoError(t, err)
|
||||
|
||||
promise := requirePromise(t, val)
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("request never reached test server")
|
||||
}
|
||||
}()
|
||||
|
||||
t.Run("Abort fetch immediately", func(t *testing.T) {
|
||||
script := `
|
||||
const controller = new AbortContext();
|
||||
controller.abort();
|
||||
|
||||
fetch('https://api.github.com/users/github', {
|
||||
signal: controller.signal
|
||||
})
|
||||
`
|
||||
_, err = vm.RunString(`controller.abort("stop-now")`)
|
||||
require.NoError(t, err)
|
||||
|
||||
val, err := vm.RunString(script)
|
||||
assert.NoError(t, err)
|
||||
select {
|
||||
case <-canceled:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("request context was not canceled")
|
||||
}
|
||||
|
||||
promise, ok := val.Export().(*goja.Promise)
|
||||
assert.True(t, ok, "fetch should return a promise")
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Promise should be rejected
|
||||
assert.Equal(t, goja.PromiseStateRejected, promise.State())
|
||||
waitForPromiseState(t, promise, goja.PromiseStateRejected)
|
||||
require.Contains(t, promise.Result().String(), "canceled")
|
||||
})
|
||||
}
|
||||
|
||||
func newAbortTestRuntime(t *testing.T) (*goja.Runtime, *gojautil.Scheduler) {
|
||||
t.Helper()
|
||||
|
||||
vm := goja.New()
|
||||
scheduler := gojautil.NewScheduler()
|
||||
BindAbortContext(vm, scheduler)
|
||||
t.Cleanup(scheduler.Stop)
|
||||
|
||||
return vm, scheduler
|
||||
}
|
||||
|
||||
func requirePromise(t *testing.T, value goja.Value) *goja.Promise {
|
||||
t.Helper()
|
||||
|
||||
promise, ok := value.Export().(*goja.Promise)
|
||||
require.True(t, ok, "value should export to a promise")
|
||||
return promise
|
||||
}
|
||||
|
||||
func waitForPromiseState(t *testing.T, promise *goja.Promise, expected goja.PromiseState) {
|
||||
t.Helper()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return promise.State() == expected
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package goja_bindings
|
||||
|
||||
import (
|
||||
"seanime/internal/util"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
gojabuffer "github.com/dop251/goja_nodejs/buffer"
|
||||
@@ -11,184 +13,275 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGojaCrypto(t *testing.T) {
|
||||
func TestGojaCryptoEncoders(t *testing.T) {
|
||||
vm := newCryptoTestVM(t)
|
||||
|
||||
val, err := vm.RunString(`
|
||||
(() => ({
|
||||
bufferBase64: Buffer.from("Hello, this is a string to encode!").toString("base64"),
|
||||
bufferDecoded: Buffer.from("SGVsbG8sIHRoaXMgaXMgYSBzdHJpbmcgdG8gZW5jb2RlIQ==", "base64").toString("utf-8"),
|
||||
base64RoundTrip: CryptoJS.enc.Utf8.stringify(
|
||||
CryptoJS.enc.Base64.parse(
|
||||
CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse("Hello, World!"))
|
||||
)
|
||||
),
|
||||
latin1RoundTrip: CryptoJS.enc.Latin1.stringify(CryptoJS.enc.Latin1.parse("Hello, World!")),
|
||||
hexRoundTrip: CryptoJS.enc.Hex.stringify(CryptoJS.enc.Hex.parse("48656c6c6f2c20576f726c6421")),
|
||||
utf8RoundTrip: CryptoJS.enc.Utf8.stringify(CryptoJS.enc.Utf8.parse("")),
|
||||
utf16RoundTrip: CryptoJS.enc.Utf16.stringify(CryptoJS.enc.Utf16.parse("Hello, World!")),
|
||||
utf16LERoundTrip: CryptoJS.enc.Utf16LE.stringify(CryptoJS.enc.Utf16LE.parse("Hello, World!")),
|
||||
}))()
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
|
||||
obj := val.ToObject(vm)
|
||||
require.Equal(t, "SGVsbG8sIHRoaXMgaXMgYSBzdHJpbmcgdG8gZW5jb2RlIQ==", obj.Get("bufferBase64").String())
|
||||
require.Equal(t, "Hello, this is a string to encode!", obj.Get("bufferDecoded").String())
|
||||
require.Equal(t, "Hello, World!", obj.Get("base64RoundTrip").String())
|
||||
require.Equal(t, "Hello, World!", obj.Get("latin1RoundTrip").String())
|
||||
require.Equal(t, "48656c6c6f2c20576f726c6421", obj.Get("hexRoundTrip").String())
|
||||
require.Equal(t, "", obj.Get("utf8RoundTrip").String())
|
||||
require.Equal(t, "Hello, World!", obj.Get("utf16RoundTrip").String())
|
||||
require.Equal(t, "Hello, World!", obj.Get("utf16LERoundTrip").String())
|
||||
}
|
||||
|
||||
func TestGojaCryptoAES(t *testing.T) {
|
||||
t.Run("random iv round trip", func(t *testing.T) {
|
||||
vm := newCryptoTestVM(t)
|
||||
|
||||
val, err := vm.RunString(`
|
||||
(() => {
|
||||
const message = "seanime";
|
||||
const key = CryptoJS.enc.Utf8.parse("secret key");
|
||||
const encrypted = CryptoJS.AES.encrypt(message, key);
|
||||
return {
|
||||
ciphertext: encrypted.toString(),
|
||||
decrypted: CryptoJS.AES.decrypt(encrypted, key).toString(CryptoJS.enc.Utf8),
|
||||
};
|
||||
})()
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
|
||||
obj := val.ToObject(vm)
|
||||
ciphertext := obj.Get("ciphertext").String()
|
||||
decoded, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, decoded, 32)
|
||||
require.Equal(t, "seanime", obj.Get("decrypted").String())
|
||||
})
|
||||
|
||||
t.Run("fixed iv ciphertext is deterministic", func(t *testing.T) {
|
||||
vm := newCryptoTestVM(t)
|
||||
message := "seanime"
|
||||
key := []byte("secret key")
|
||||
iv := []byte("3134003223491201")
|
||||
|
||||
val, err := vm.RunString(`
|
||||
(() => {
|
||||
const message = "seanime";
|
||||
const key = CryptoJS.enc.Utf8.parse("secret key");
|
||||
const iv = CryptoJS.enc.Utf8.parse("3134003223491201");
|
||||
const encrypted = CryptoJS.AES.encrypt(message, key, { iv });
|
||||
return {
|
||||
ciphertext: encrypted.toString(),
|
||||
ciphertextBase64: encrypted.toString(CryptoJS.enc.Base64),
|
||||
decryptedWithIV: CryptoJS.AES.decrypt(encrypted, key, { iv }).toString(CryptoJS.enc.Utf8),
|
||||
decryptedWithoutIV: CryptoJS.AES.decrypt(encrypted, key).toString(CryptoJS.enc.Utf8),
|
||||
};
|
||||
})()
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
|
||||
obj := val.ToObject(vm)
|
||||
expectedCiphertext := expectedAESCiphertext(message, key, iv)
|
||||
require.Equal(t, expectedCiphertext, obj.Get("ciphertext").String())
|
||||
require.Equal(t, expectedCiphertext, obj.Get("ciphertextBase64").String())
|
||||
require.Equal(t, message, obj.Get("decryptedWithIV").String())
|
||||
require.Empty(t, obj.Get("decryptedWithoutIV").String())
|
||||
})
|
||||
|
||||
t.Run("invalid iv length returns an error string", func(t *testing.T) {
|
||||
vm := newCryptoTestVM(t)
|
||||
|
||||
val, err := vm.RunString(`
|
||||
(() => {
|
||||
try {
|
||||
CryptoJS.AES.encrypt("seanime", CryptoJS.enc.Utf8.parse("secret key"), {
|
||||
iv: CryptoJS.enc.Utf8.parse("short"),
|
||||
});
|
||||
return "unexpected success";
|
||||
} catch (e) {
|
||||
return String(e);
|
||||
}
|
||||
})()
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, val.String(), "IV length must be equal to block size")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGojaCryptoOpenSSLDecrypt(t *testing.T) {
|
||||
vm := newCryptoTestVM(t)
|
||||
|
||||
val, err := vm.RunString(`
|
||||
(() => {
|
||||
const payload = "U2FsdGVkX19ZanX9W5jQGgNGOIOBGxhY6gxa1EHnRi3yHL8Ml4cMmQeryf9p04N12VuOjiBas21AcU0Ypc4dB4AWOdc9Cn1wdA2DuQhryUonKYHwV/XXJ53DBn1OIqAvrIAxrN8S2j9Rk5z/F/peu1Kk/d3m82jiKvhTWQcxDeDW8UzCMZbbFnm4qJC3k19+PD5Pal5sBcVTGRXNCpvSSpYb56FcP9Xs+3DyBWhNUqJuO+Wwm3G1J5HhklxCWZ7tcn7TE5Y8d5ORND7t51Padrw4LgEOootqHtfHuBVX6EqlvJslXt0kFgcXJUIO+hw0q5SJ+tiS7o/2OShJ7BCk4XzfQmhFJdBJYGjQ8WPMHYzLuMzDkf6zk2+m7YQtUTXx8SVoLXFOt8gNZeD942snGrWA5+CdYveOfJ8Yv7owoOueMzzYqr5rzG7GVapVI0HzrA24LR4AjRDICqTsJEy6Yg==";
|
||||
const key = "6315b93606d60f48c964b67b14701f3848ef25af01296cf7e6a98c9460e1d2ac";
|
||||
return CryptoJS.AES.decrypt(payload, key).toString(CryptoJS.enc.Utf8);
|
||||
})()
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `[{"file":"https://cloudburst82.xyz/_v7/b39c8e03ac287e819418f1ad0644d7c0f506c2def541ec36e8253cd39f36c15ab46274b0ce5189dc51b2b970efa7b3abd9c70f52b02839d47a75863596d321a0b9c8b0370f96fa253d059244713458951d6c965d17a36ce87d4e2844d4665b7b658acd2318d5f8730643d893d2e1577307c767157b45abf64588a76b0cd8c1d2/master.m3u8","type":"hls"}]`, val.String())
|
||||
}
|
||||
|
||||
func TestGojaCryptoErrorPaths(t *testing.T) {
|
||||
vm := newCryptoTestVM(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
script string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "encrypt requires two arguments",
|
||||
script: `
|
||||
(() => {
|
||||
try {
|
||||
CryptoJS.AES.encrypt("only-message");
|
||||
return "unexpected success";
|
||||
} catch (e) {
|
||||
return String(e);
|
||||
}
|
||||
})()
|
||||
`,
|
||||
want: "AES.encrypt requires at least 2 arguments",
|
||||
},
|
||||
{
|
||||
name: "decrypt requires two arguments",
|
||||
script: `
|
||||
(() => {
|
||||
try {
|
||||
CryptoJS.AES.decrypt("ciphertext-only");
|
||||
return "unexpected success";
|
||||
} catch (e) {
|
||||
return String(e);
|
||||
}
|
||||
})()
|
||||
`,
|
||||
want: "AES.decrypt requires at least 2 arguments",
|
||||
},
|
||||
{
|
||||
name: "word array rejects invalid encoder",
|
||||
script: `
|
||||
(() => {
|
||||
try {
|
||||
CryptoJS.AES.encrypt("seanime", CryptoJS.enc.Utf8.parse("secret key")).toString("bad");
|
||||
return "unexpected success";
|
||||
} catch (e) {
|
||||
return String(e);
|
||||
}
|
||||
})()
|
||||
`,
|
||||
want: "encoder parameter must be a CryptoJS.enc object",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
val, err := vm.RunString(tt.script)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, val.String(), tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGojaCryptoHelperCoverage(t *testing.T) {
|
||||
vm := goja.New()
|
||||
defer vm.ClearInterrupt()
|
||||
|
||||
t.Run("adjust key length preserves valid sizes and hashes invalid sizes", func(t *testing.T) {
|
||||
key16 := []byte("1234567890abcdef")
|
||||
key24 := []byte("1234567890abcdefghijklmn")
|
||||
key32 := []byte("1234567890abcdefghijklmnopqrstuv")
|
||||
shortKey := []byte("short")
|
||||
|
||||
require.Equal(t, key16, adjustKeyLength(key16))
|
||||
require.Equal(t, key24, adjustKeyLength(key24))
|
||||
require.Equal(t, key32, adjustKeyLength(key32))
|
||||
require.Len(t, adjustKeyLength(shortKey), 32)
|
||||
require.NotEqual(t, shortKey, adjustKeyLength(shortKey))
|
||||
})
|
||||
|
||||
t.Run("low-level parser fallbacks", func(t *testing.T) {
|
||||
require.Nil(t, base64Parse("%%%"))
|
||||
require.Nil(t, hexParse("xyz"))
|
||||
require.Empty(t, utf16Stringify([]byte{0x00}))
|
||||
require.Empty(t, utf16LEStringify([]byte{0x00}))
|
||||
})
|
||||
|
||||
t.Run("encoder wrappers handle undefined and wrong types", func(t *testing.T) {
|
||||
parseFns := []func(goja.FunctionCall) goja.Value{
|
||||
cryptoEncUtf8ParseFunc(vm),
|
||||
cryptoEncBase64ParseFunc(vm),
|
||||
cryptoEncHexParseFunc(vm),
|
||||
cryptoEncLatin1ParseFunc(vm),
|
||||
cryptoEncUtf16ParseFunc(vm),
|
||||
cryptoEncUtf16LEParseFunc(vm),
|
||||
}
|
||||
|
||||
for _, parseFn := range parseFns {
|
||||
ret := parseFn(goja.FunctionCall{Arguments: []goja.Value{goja.Undefined()}})
|
||||
require.Equal(t, "", ret.String())
|
||||
}
|
||||
|
||||
stringifyFns := []func(goja.FunctionCall) goja.Value{
|
||||
cryptoEncUtf8StringifyFunc(vm),
|
||||
cryptoEncBase64StringifyFunc(vm),
|
||||
cryptoEncHexStringifyFunc(vm),
|
||||
cryptoEncLatin1StringifyFunc(vm),
|
||||
cryptoEncUtf16StringifyFunc(vm),
|
||||
cryptoEncUtf16LEStringifyFunc(vm),
|
||||
}
|
||||
|
||||
for _, stringifyFn := range stringifyFns {
|
||||
ret := stringifyFn(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("not-bytes")}})
|
||||
require.Equal(t, "", ret.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newCryptoTestVM(t *testing.T) *goja.Runtime {
|
||||
t.Helper()
|
||||
|
||||
vm := goja.New()
|
||||
t.Cleanup(vm.ClearInterrupt)
|
||||
|
||||
registry := new(gojarequire.Registry)
|
||||
registry.Enable(vm)
|
||||
gojabuffer.Enable(vm)
|
||||
BindCrypto(vm)
|
||||
BindConsole(vm, util.NewLogger())
|
||||
require.NoError(t, BindCrypto(vm))
|
||||
|
||||
_, err := vm.RunString(`
|
||||
async function run() {
|
||||
|
||||
try {
|
||||
|
||||
console.log("\nTesting Buffer encoding/decoding")
|
||||
|
||||
const originalString = "Hello, this is a string to encode!"
|
||||
const base64String = Buffer.from(originalString).toString("base64")
|
||||
|
||||
console.log("Original String:", originalString)
|
||||
console.log("Base64 Encoded:", base64String)
|
||||
|
||||
const decodedString = Buffer.from(base64String, "base64").toString("utf-8")
|
||||
|
||||
console.log("Base64 Decoded:", decodedString)
|
||||
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
console.log("\nTesting AES")
|
||||
|
||||
let message = "seanime"
|
||||
let key = CryptoJS.enc.Utf8.parse("secret key")
|
||||
|
||||
|
||||
console.log("Message:", message)
|
||||
|
||||
let encrypted = CryptoJS.AES.encrypt(message, key)
|
||||
console.log("Encrypted without IV:", encrypted) // map[iv toString]
|
||||
console.log("Encrypted.toString():", encrypted.toString()) // AoHrnhJfbRht2idLHM82WdkIEpRbXufnA6+ozty9fbk=
|
||||
console.log("Encrypted.toString(CryptoJS.enc.Base64):", encrypted.toString(CryptoJS.enc.Base64)) // AoHrnhJfbRht2idLHM82WdkIEpRbXufnA6+ozty9fbk=
|
||||
|
||||
let decrypted = CryptoJS.AES.decrypt(encrypted, key)
|
||||
console.log("Decrypted:", decrypted.toString(CryptoJS.enc.Utf8))
|
||||
|
||||
let iv = CryptoJS.enc.Utf8.parse("3134003223491201")
|
||||
encrypted = CryptoJS.AES.encrypt(message, key, { iv: iv })
|
||||
console.log("Encrypted with IV:", encrypted) // map[iv toString]
|
||||
|
||||
decrypted = CryptoJS.AES.decrypt(encrypted, key)
|
||||
console.log("Decrypted without IV:", decrypted.toString(CryptoJS.enc.Utf8))
|
||||
|
||||
decrypted = CryptoJS.AES.decrypt(encrypted, key, { iv: iv })
|
||||
console.log("Decrypted with IV:", decrypted.toString(CryptoJS.enc.Utf8)) // seanime
|
||||
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
console.log("\nTesting encoders")
|
||||
|
||||
console.log("")
|
||||
let a = CryptoJS.enc.Utf8.parse("Hello, World!")
|
||||
console.log("Base64 Parsed:", a)
|
||||
let b = CryptoJS.enc.Base64.stringify(a)
|
||||
console.log("Base64 Stringified:", b)
|
||||
let c = CryptoJS.enc.Base64.parse(b)
|
||||
console.log("Base64 Parsed:", c)
|
||||
let d = CryptoJS.enc.Utf8.stringify(c)
|
||||
console.log("Base64 Stringified:", d)
|
||||
console.log("")
|
||||
|
||||
let words = CryptoJS.enc.Latin1.parse("Hello, World!")
|
||||
console.log("Latin1 Parsed:", words)
|
||||
let latin1 = CryptoJS.enc.Latin1.stringify(words)
|
||||
console.log("Latin1 Stringified", latin1)
|
||||
|
||||
words = CryptoJS.enc.Hex.parse("48656c6c6f2c20576f726c6421")
|
||||
console.log("Hex Parsed:", words)
|
||||
let hex = CryptoJS.enc.Hex.stringify(words)
|
||||
console.log("Hex Stringified", hex)
|
||||
|
||||
words = CryptoJS.enc.Utf8.parse("")
|
||||
console.log("Utf8 Parsed:", words)
|
||||
let utf8 = CryptoJS.enc.Utf8.stringify(words)
|
||||
console.log("Utf8 Stringified", utf8)
|
||||
|
||||
words = CryptoJS.enc.Utf16.parse("Hello, World!")
|
||||
console.log("Utf16 Parsed:", words)
|
||||
let utf16 = CryptoJS.enc.Utf16.stringify(words)
|
||||
console.log("Utf16 Stringified", utf16)
|
||||
|
||||
words = CryptoJS.enc.Utf16LE.parse("Hello, World!")
|
||||
console.log("Utf16LE Parsed:", words)
|
||||
utf16 = CryptoJS.enc.Utf16LE.stringify(words)
|
||||
console.log("Utf16LE Stringified", utf16)
|
||||
}
|
||||
catch (e) {
|
||||
console.error("Error:", e)
|
||||
}
|
||||
}
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
|
||||
runFunc, ok := goja.AssertFunction(vm.Get("run"))
|
||||
require.True(t, ok)
|
||||
|
||||
ret, err := runFunc(goja.Undefined())
|
||||
require.NoError(t, err)
|
||||
|
||||
promise := ret.Export().(*goja.Promise)
|
||||
|
||||
for promise.State() == goja.PromiseStatePending {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
if promise.State() == goja.PromiseStateRejected {
|
||||
err := promise.Result()
|
||||
t.Fatal(err)
|
||||
}
|
||||
return vm
|
||||
}
|
||||
|
||||
func TestGojaCryptoOpenSSL(t *testing.T) {
|
||||
vm := goja.New()
|
||||
defer vm.ClearInterrupt()
|
||||
func expectedAESCiphertext(message string, key []byte, iv []byte) string {
|
||||
hash := sha256.Sum256(key)
|
||||
padded := pkcs7(message, aes.BlockSize)
|
||||
ciphertext := make([]byte, len(padded))
|
||||
|
||||
registry := new(gojarequire.Registry)
|
||||
registry.Enable(vm)
|
||||
gojabuffer.Enable(vm)
|
||||
BindCrypto(vm)
|
||||
BindConsole(vm, util.NewLogger())
|
||||
|
||||
_, err := vm.RunString(`
|
||||
async function run() {
|
||||
|
||||
try {
|
||||
|
||||
console.log("\nTesting Buffer encoding/decoding")
|
||||
|
||||
const payload = "U2FsdGVkX19ZanX9W5jQGgNGOIOBGxhY6gxa1EHnRi3yHL8Ml4cMmQeryf9p04N12VuOjiBas21AcU0Ypc4dB4AWOdc9Cn1wdA2DuQhryUonKYHwV/XXJ53DBn1OIqAvrIAxrN8S2j9Rk5z/F/peu1Kk/d3m82jiKvhTWQcxDeDW8UzCMZbbFnm4qJC3k19+PD5Pal5sBcVTGRXNCpvSSpYb56FcP9Xs+3DyBWhNUqJuO+Wwm3G1J5HhklxCWZ7tcn7TE5Y8d5ORND7t51Padrw4LgEOootqHtfHuBVX6EqlvJslXt0kFgcXJUIO+hw0q5SJ+tiS7o/2OShJ7BCk4XzfQmhFJdBJYGjQ8WPMHYzLuMzDkf6zk2+m7YQtUTXx8SVoLXFOt8gNZeD942snGrWA5+CdYveOfJ8Yv7owoOueMzzYqr5rzG7GVapVI0HzrA24LR4AjRDICqTsJEy6Yg=="
|
||||
const key = "6315b93606d60f48c964b67b14701f3848ef25af01296cf7e6a98c9460e1d2ac"
|
||||
console.log("Original String:", payload)
|
||||
|
||||
const decrypted = CryptoJS.AES.decrypt(payload, key)
|
||||
|
||||
console.log("Decrypted:", decrypted.toString(CryptoJS.enc.Utf8))
|
||||
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
}
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
|
||||
runFunc, ok := goja.AssertFunction(vm.Get("run"))
|
||||
require.True(t, ok)
|
||||
|
||||
ret, err := runFunc(goja.Undefined())
|
||||
require.NoError(t, err)
|
||||
|
||||
promise := ret.Export().(*goja.Promise)
|
||||
|
||||
for promise.State() == goja.PromiseStatePending {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
block, err := aes.NewCipher(hash[:])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if promise.State() == goja.PromiseStateRejected {
|
||||
err := promise.Result()
|
||||
t.Fatal(err)
|
||||
}
|
||||
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, padded)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext)
|
||||
}
|
||||
|
||||
func pkcs7(message string, blockSize int) []byte {
|
||||
data := []byte(message)
|
||||
padding := blockSize - len(data)%blockSize
|
||||
for i := 0; i < padding; i++ {
|
||||
data = append(data, byte(padding))
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -2,113 +2,217 @@ package anime_test
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata_provider"
|
||||
"seanime/internal/database/db"
|
||||
"seanime/internal/extension"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/platforms/anilist_platform"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewLibraryCollection(t *testing.T) {
|
||||
logger := util.NewLogger()
|
||||
func TestNewLibraryCollectionContinueWatchingList(t *testing.T) {
|
||||
h := newAnimeTestHarness(t)
|
||||
|
||||
database, err := db.NewDatabase(t.TempDir(), "test", logger)
|
||||
assert.NoError(t, err)
|
||||
|
||||
metadataProvider := metadata_provider.NewTestProvider(t, database)
|
||||
//wsEventManager := events.NewMockWSEventManager(logger)
|
||||
|
||||
anilistClient := anilist.NewTestAnilistClient()
|
||||
anilistPlatform := anilist_platform.NewAnilistPlatform(util.NewRef(anilistClient), util.NewRef(extension.NewUnifiedBank()), logger, database)
|
||||
|
||||
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.NewTestLocalFiles(
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "E:/Anime",
|
||||
FilePathTemplate: "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - %ep (1080p) [F02B9CEE].mkv",
|
||||
MediaID: mediaId,
|
||||
Episodes: []anime.TestLocalFileEpisode{
|
||||
{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 2, AniDBEpisode: "2", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 3, AniDBEpisode: "3", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 4, AniDBEpisode: "4", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 5, AniDBEpisode: "5", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 6, AniDBEpisode: "6", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 7, AniDBEpisode: "7", Type: anime.LocalFileTypeMain},
|
||||
},
|
||||
localFiles := make([]*anime.LocalFile, 0)
|
||||
localFiles = append(localFiles, anime.NewTestLocalFiles(
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "/Anime",
|
||||
FilePathTemplate: "/Anime/Sousou no Frieren/[SubsPlease] Sousou no Frieren - %ep.mkv",
|
||||
MediaID: 154587,
|
||||
Episodes: []anime.TestLocalFileEpisode{
|
||||
{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 2, AniDBEpisode: "2", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 3, AniDBEpisode: "3", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 4, AniDBEpisode: "4", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 5, AniDBEpisode: "5", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 6, AniDBEpisode: "6", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 7, AniDBEpisode: "7", Type: anime.LocalFileTypeMain},
|
||||
},
|
||||
)...)
|
||||
anilist.PatchAnimeCollectionEntry(animeCollection, mediaId, anilist.AnimeCollectionEntryPatch{
|
||||
Status: new(anilist.MediaListStatusCurrent),
|
||||
Progress: new(4), // Mock progress
|
||||
})
|
||||
|
||||
// One Piece
|
||||
// Downloaded 1070-1075 but only watched up until 1060
|
||||
mediaId = 21
|
||||
lfs = append(lfs, anime.NewTestLocalFiles(
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "E:/Anime",
|
||||
FilePathTemplate: "E:\\Anime\\One Piece\\[SubsPlease] One Piece - %ep (1080p) [F02B9CEE].mkv",
|
||||
MediaID: mediaId,
|
||||
Episodes: []anime.TestLocalFileEpisode{
|
||||
{Episode: 1070, AniDBEpisode: "1070", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 1071, AniDBEpisode: "1071", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 1072, AniDBEpisode: "1072", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 1073, AniDBEpisode: "1073", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 1074, AniDBEpisode: "1074", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 1075, AniDBEpisode: "1075", Type: anime.LocalFileTypeMain},
|
||||
},
|
||||
},
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "/Anime",
|
||||
FilePathTemplate: "/Anime/Mushoku Tensei/[SubsPlease] Mushoku Tensei S2 - %ep.mkv",
|
||||
MediaID: 146065,
|
||||
Episodes: []anime.TestLocalFileEpisode{
|
||||
{Episode: 0, AniDBEpisode: "S1", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 2, AniDBEpisode: "2", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 3, AniDBEpisode: "3", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 4, AniDBEpisode: "4", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 5, AniDBEpisode: "5", Type: anime.LocalFileTypeMain},
|
||||
},
|
||||
)...)
|
||||
anilist.PatchAnimeCollectionEntry(animeCollection, mediaId, anilist.AnimeCollectionEntryPatch{
|
||||
Status: new(anilist.MediaListStatusCurrent),
|
||||
Progress: new(1060), // Mock progress
|
||||
})
|
||||
},
|
||||
)...)
|
||||
|
||||
// Add unmatched local files
|
||||
mediaId = 0
|
||||
lfs = append(lfs, anime.NewTestLocalFiles(
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "E:/Anime",
|
||||
FilePathTemplate: "E:\\Anime\\Unmatched\\[SubsPlease] Unmatched - %ep (1080p) [F02B9CEE].mkv",
|
||||
MediaID: mediaId,
|
||||
Episodes: []anime.TestLocalFileEpisode{
|
||||
{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 2, AniDBEpisode: "2", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 3, AniDBEpisode: "3", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 4, AniDBEpisode: "4", Type: anime.LocalFileTypeMain},
|
||||
},
|
||||
},
|
||||
)...)
|
||||
patchAnimeCollectionEntry(t, h.animeCollection, 154587, anilist.AnimeCollectionEntryPatch{
|
||||
Status: new(anilist.MediaListStatusCurrent),
|
||||
Progress: new(4),
|
||||
})
|
||||
patchCollectionEntryEpisodeCount(t, h.animeCollection, 154587, 7)
|
||||
h.setEpisodeMetadata(t, 154587, []int{1, 2, 3, 4, 5, 6, 7}, nil)
|
||||
|
||||
libraryCollection, err := anime.NewLibraryCollection(t.Context(), &anime.NewLibraryCollectionOptions{
|
||||
AnimeCollection: animeCollection,
|
||||
LocalFiles: lfs,
|
||||
PlatformRef: util.NewRef(anilistPlatform),
|
||||
MetadataProviderRef: util.NewRef(metadataProvider),
|
||||
})
|
||||
patchAnimeCollectionEntry(t, h.animeCollection, 146065, anilist.AnimeCollectionEntryPatch{
|
||||
Status: new(anilist.MediaListStatusCurrent),
|
||||
Progress: new(1),
|
||||
})
|
||||
patchCollectionEntryEpisodeCount(t, h.animeCollection, 146065, 6)
|
||||
h.setEpisodeMetadata(t, 146065, []int{1, 2, 3, 4, 5}, map[string]int{"S1": 1})
|
||||
|
||||
if assert.NoError(t, err) {
|
||||
libraryCollection := h.newLibraryCollection(t, localFiles)
|
||||
|
||||
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
|
||||
require.Len(t, libraryCollection.ContinueWatchingList, 2)
|
||||
require.Equal(t, 154587, libraryCollection.ContinueWatchingList[0].BaseAnime.ID)
|
||||
require.Equal(t, 5, libraryCollection.ContinueWatchingList[0].EpisodeNumber)
|
||||
require.Equal(t, 146065, libraryCollection.ContinueWatchingList[1].BaseAnime.ID)
|
||||
require.Equal(t, 1, libraryCollection.ContinueWatchingList[1].EpisodeNumber)
|
||||
require.Empty(t, libraryCollection.UnmatchedLocalFiles)
|
||||
require.Empty(t, libraryCollection.UnknownGroups)
|
||||
}
|
||||
|
||||
func TestNewLibraryCollectionMergesRepeatingAndHydratesStats(t *testing.T) {
|
||||
h := newAnimeTestHarness(t)
|
||||
|
||||
localFiles := anime.NewTestLocalFiles(
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "/Anime",
|
||||
FilePathTemplate: "/Anime/Sousou no Frieren/%ep.mkv",
|
||||
MediaID: 154587,
|
||||
Episodes: []anime.TestLocalFileEpisode{{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain}},
|
||||
},
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "/Anime",
|
||||
FilePathTemplate: "/Anime/One Piece/%ep.mkv",
|
||||
MediaID: 21,
|
||||
Episodes: []anime.TestLocalFileEpisode{{Episode: 1070, AniDBEpisode: "1070", Type: anime.LocalFileTypeMain}},
|
||||
},
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "/Anime",
|
||||
FilePathTemplate: "/Anime/Mushoku/%ep.mkv",
|
||||
MediaID: 146065,
|
||||
Episodes: []anime.TestLocalFileEpisode{{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain}},
|
||||
},
|
||||
)
|
||||
|
||||
patchAnimeCollectionEntry(t, h.animeCollection, 154587, anilist.AnimeCollectionEntryPatch{
|
||||
Status: new(anilist.MediaListStatusCurrent),
|
||||
Progress: new(0),
|
||||
})
|
||||
onePieceEntry := patchAnimeCollectionEntry(t, h.animeCollection, 21, anilist.AnimeCollectionEntryPatch{
|
||||
Status: new(anilist.MediaListStatusRepeating),
|
||||
Progress: new(1060),
|
||||
})
|
||||
mushokuEntry := patchAnimeCollectionEntry(t, h.animeCollection, 146065, anilist.AnimeCollectionEntryPatch{
|
||||
Status: new(anilist.MediaListStatusCompleted),
|
||||
Progress: new(12),
|
||||
})
|
||||
|
||||
movieFormat := anilist.MediaFormatMovie
|
||||
showFormat := anilist.MediaFormatTv
|
||||
ovaFormat := anilist.MediaFormatOva
|
||||
patchCollectionEntryFormat(t, h.animeCollection, 154587, showFormat)
|
||||
onePieceEntry.Media.Format = &movieFormat
|
||||
mushokuEntry.Media.Format = &ovaFormat
|
||||
|
||||
libraryCollection := h.newLibraryCollection(t, localFiles)
|
||||
|
||||
currentList := findCollectionListByStatus(t, libraryCollection, anilist.MediaListStatusCurrent)
|
||||
require.Len(t, currentList.Entries, 2)
|
||||
require.ElementsMatch(t, []int{154587, 21}, []int{currentList.Entries[0].MediaId, currentList.Entries[1].MediaId})
|
||||
require.Nil(t, findOptionalCollectionListByStatus(libraryCollection, anilist.MediaListStatusRepeating))
|
||||
|
||||
var repeatingEntry *anime.LibraryCollectionEntry
|
||||
for _, entry := range currentList.Entries {
|
||||
if entry.MediaId == 21 {
|
||||
repeatingEntry = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, repeatingEntry)
|
||||
require.NotNil(t, repeatingEntry.EntryListData.Status)
|
||||
require.Equal(t, anilist.MediaListStatusRepeating, *repeatingEntry.EntryListData.Status)
|
||||
|
||||
require.NotNil(t, libraryCollection.Stats)
|
||||
require.Equal(t, 3, libraryCollection.Stats.TotalEntries)
|
||||
require.Equal(t, len(localFiles), libraryCollection.Stats.TotalFiles)
|
||||
require.Equal(t, 1, libraryCollection.Stats.TotalShows)
|
||||
require.Equal(t, 1, libraryCollection.Stats.TotalMovies)
|
||||
require.Equal(t, 1, libraryCollection.Stats.TotalSpecials)
|
||||
}
|
||||
|
||||
func TestNewLibraryCollectionGroupsUnknownIgnoredAndUnmatchedFiles(t *testing.T) {
|
||||
h := newAnimeTestHarness(t)
|
||||
|
||||
localFiles := anime.NewTestLocalFiles(
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "/Anime",
|
||||
FilePathTemplate: "/Anime/Unknown Show/%ep.mkv",
|
||||
MediaID: 999999,
|
||||
Episodes: []anime.TestLocalFileEpisode{
|
||||
{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 2, AniDBEpisode: "2", Type: anime.LocalFileTypeMain},
|
||||
},
|
||||
},
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "/Anime",
|
||||
FilePathTemplate: "/Anime/Resolve/A/%ep.mkv",
|
||||
MediaID: 0,
|
||||
Episodes: []anime.TestLocalFileEpisode{
|
||||
{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 2, AniDBEpisode: "2", Type: anime.LocalFileTypeMain},
|
||||
},
|
||||
},
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "/Anime",
|
||||
FilePathTemplate: "/Anime/Resolve/B/%ep.mkv",
|
||||
MediaID: 0,
|
||||
Episodes: []anime.TestLocalFileEpisode{{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain}},
|
||||
},
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "/Anime",
|
||||
FilePathTemplate: "/Anime/Ignored/Z/%ep.mkv",
|
||||
MediaID: 0,
|
||||
Episodes: []anime.TestLocalFileEpisode{{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain}},
|
||||
},
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "/Anime",
|
||||
FilePathTemplate: "/Anime/Ignored/A/%ep.mkv",
|
||||
MediaID: 0,
|
||||
Episodes: []anime.TestLocalFileEpisode{{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain}},
|
||||
},
|
||||
)
|
||||
|
||||
localFiles[5].Ignored = true
|
||||
localFiles[6].Ignored = true
|
||||
|
||||
libraryCollection := h.newLibraryCollection(t, localFiles)
|
||||
|
||||
require.Empty(t, libraryCollection.ContinueWatchingList)
|
||||
require.Len(t, libraryCollection.UnknownGroups, 1)
|
||||
require.Equal(t, 999999, libraryCollection.UnknownGroups[0].MediaId)
|
||||
require.Len(t, libraryCollection.UnknownGroups[0].LocalFiles, 2)
|
||||
|
||||
require.Len(t, libraryCollection.UnmatchedLocalFiles, 3)
|
||||
require.Len(t, libraryCollection.UnmatchedGroups, 2)
|
||||
require.Equal(t, "/Anime/Resolve/A", libraryCollection.UnmatchedGroups[0].Dir)
|
||||
require.Len(t, libraryCollection.UnmatchedGroups[0].LocalFiles, 2)
|
||||
require.Equal(t, "/Anime/Resolve/B", libraryCollection.UnmatchedGroups[1].Dir)
|
||||
require.Len(t, libraryCollection.UnmatchedGroups[1].LocalFiles, 1)
|
||||
|
||||
require.Len(t, libraryCollection.IgnoredLocalFiles, 2)
|
||||
require.Equal(t, "/Anime/Ignored/A/1.mkv", libraryCollection.IgnoredLocalFiles[0].GetPath())
|
||||
require.Equal(t, "/Anime/Ignored/Z/1.mkv", libraryCollection.IgnoredLocalFiles[1].GetPath())
|
||||
}
|
||||
|
||||
func findCollectionListByStatus(t *testing.T, libraryCollection *anime.LibraryCollection, status anilist.MediaListStatus) *anime.LibraryCollectionList {
|
||||
t.Helper()
|
||||
list := findOptionalCollectionListByStatus(libraryCollection, status)
|
||||
require.NotNil(t, list)
|
||||
return list
|
||||
}
|
||||
|
||||
func findOptionalCollectionListByStatus(libraryCollection *anime.LibraryCollection, status anilist.MediaListStatus) *anime.LibraryCollectionList {
|
||||
for _, list := range libraryCollection.Lists {
|
||||
if list.Status == status {
|
||||
return list
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
27
internal/library/anime/entry_download_info_internal_test.go
Normal file
27
internal/library/anime/entry_download_info_internal_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEpisodeSliceHelpers(t *testing.T) {
|
||||
// this is a tiny direct test for the helper methods at the bottom of entry_download_info.go.
|
||||
// the higher-level tests already cover the real behavior, this one just keeps the utility methods exercised.
|
||||
slice := newEpisodeSlice(3)
|
||||
require.Len(t, slice.getSlice(), 3)
|
||||
require.Equal(t, 1, slice.get(0).episodeNumber)
|
||||
require.Equal(t, "2", slice.getEpisodeNumber(2).aniDBEpisode)
|
||||
require.Nil(t, slice.getEpisodeNumber(99))
|
||||
|
||||
clone := slice.copy()
|
||||
require.NotSame(t, slice, clone)
|
||||
require.Len(t, clone.getSlice(), 3)
|
||||
|
||||
slice.trimStart(1)
|
||||
require.Len(t, slice.getSlice(), 2)
|
||||
require.Equal(t, 2, slice.get(0).episodeNumber)
|
||||
|
||||
clone.print()
|
||||
}
|
||||
@@ -1,190 +1,229 @@
|
||||
package anime_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/api/metadata_provider"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/testutil"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewEntryDownloadInfo(t *testing.T) {
|
||||
env := testutil.NewTestEnv(t)
|
||||
func TestNewEntryDownloadInfoEpisodeZeroDiscrepancy(t *testing.T) {
|
||||
// anilist counts episode 0 here, but the metadata maps it as S1.
|
||||
// the expected list should still expose that extra slot as episode 0.
|
||||
h := newAnimeTestHarness(t)
|
||||
mediaID := 146065
|
||||
|
||||
logger := util.NewLogger()
|
||||
database := env.MustNewDatabase(logger)
|
||||
metadataProvider := metadata_provider.NewTestProviderWithEnv(env, database)
|
||||
|
||||
anilistClient := anilist.NewTestAnilistClient()
|
||||
animeCollection, err := anilistClient.AnimeCollection(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
patchEntryMediaStatus(t, h.animeCollection, mediaID, anilist.MediaStatusReleasing)
|
||||
patchAnimeCollectionEntry(t, h.animeCollection, mediaID, anilist.AnimeCollectionEntryPatch{
|
||||
AiredEpisodes: new(6),
|
||||
NextAiringEpisode: &anilist.BaseAnime_NextAiringEpisode{Episode: 7},
|
||||
})
|
||||
h.setEpisodeMetadata(t, mediaID, []int{1, 2, 3, 4, 5}, map[string]int{"S1": 1})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
localFiles []*anime.LocalFile
|
||||
mediaId int
|
||||
currentProgress int
|
||||
status anilist.MediaListStatus
|
||||
expectedEpisodeNumbersToDownload []struct {
|
||||
episodeNumber int
|
||||
aniDbEpisode string
|
||||
}
|
||||
name string
|
||||
progress int
|
||||
expectedEpisodes []downloadEpisodeExpectation
|
||||
}{
|
||||
{
|
||||
// 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"},
|
||||
},
|
||||
name: "progress zero keeps episode zero",
|
||||
progress: 0,
|
||||
expectedEpisodes: []downloadEpisodeExpectation{{0, "S1"}, {1, "1"}, {2, "2"}, {3, "3"}, {4, "4"}, {5, "5"}},
|
||||
},
|
||||
{
|
||||
// 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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Watashi ga Koibito ni Nareru Wake Naijan, Murimuri! Season 2",
|
||||
localFiles: nil,
|
||||
mediaId: 199112,
|
||||
currentProgress: 0,
|
||||
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"},
|
||||
},
|
||||
name: "progress one only removes episode zero",
|
||||
progress: 1,
|
||||
expectedEpisodes: []downloadEpisodeExpectation{{1, "1"}, {2, "2"}, {3, "3"}, {4, "4"}, {5, "5"}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// we only care about the logical episode list here, not local download state.
|
||||
info := h.newEntryDownloadInfo(t, mediaID, nil, tt.progress, anilist.MediaListStatusCurrent)
|
||||
|
||||
anilistEntry, _ := animeCollection.GetListEntryFromAnimeId(tt.mediaId)
|
||||
require.NotNil(t, anilistEntry)
|
||||
|
||||
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,
|
||||
MetadataProviderRef: util.NewRef(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)
|
||||
|
||||
require.ElementsMatch(t, tt.expectedEpisodes, collectDownloadEpisodes(info))
|
||||
require.False(t, info.HasInaccurateSchedule)
|
||||
// generated download entries use placeholder local files internally, then clear them back out.
|
||||
for _, episode := range info.EpisodesToDownload {
|
||||
require.Nil(t, episode.Episode.LocalFile)
|
||||
require.Equal(t, episode.AniDBEpisode, episode.Episode.AniDBEpisode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEntryDownloadInfoSpecialsDiscrepancyAndBatchFlags(t *testing.T) {
|
||||
// this covers the path where anilist's aired count includes specials.
|
||||
// we expect the main episodes plus two remapped specials, and finished media should allow batch mode.
|
||||
h := newAnimeTestHarness(t)
|
||||
mediaID := 154587
|
||||
|
||||
patchCollectionEntryEpisodeCount(t, h.animeCollection, mediaID, 6)
|
||||
patchEntryMediaStatus(t, h.animeCollection, mediaID, anilist.MediaStatusFinished)
|
||||
metadataOverride := h.setEpisodeMetadata(t, mediaID, []int{1, 2, 3, 4}, map[string]int{"S1": 1, "S2": 2})
|
||||
metadataOverride.Episodes["1"].AbsoluteEpisodeNumber = 13
|
||||
|
||||
info := h.newEntryDownloadInfo(t, mediaID, nil, 0, anilist.MediaListStatusCurrent)
|
||||
|
||||
require.ElementsMatch(t, []downloadEpisodeExpectation{{1, "1"}, {2, "2"}, {3, "3"}, {4, "4"}, {6, "S1"}, {5, "S2"}}, collectDownloadEpisodes(info))
|
||||
require.True(t, info.CanBatch)
|
||||
require.True(t, info.BatchAll)
|
||||
require.False(t, info.Rewatch)
|
||||
require.Equal(t, 12, info.AbsoluteOffset)
|
||||
}
|
||||
|
||||
func TestNewEntryDownloadInfoCompletedRewatchFiltersDownloadedEpisodes(t *testing.T) {
|
||||
// completed entries reset progress back to 0 for download planning.
|
||||
// the remaining list should just be "everything not already on disk" and mark this as a rewatch.
|
||||
h := newAnimeTestHarness(t)
|
||||
mediaID := 154587
|
||||
|
||||
patchCollectionEntryEpisodeCount(t, h.animeCollection, mediaID, 5)
|
||||
patchEntryMediaStatus(t, h.animeCollection, mediaID, anilist.MediaStatusFinished)
|
||||
h.setEpisodeMetadata(t, mediaID, []int{1, 2, 3, 4, 5}, nil)
|
||||
|
||||
localFiles := anime.NewTestLocalFiles(
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "/Anime",
|
||||
FilePathTemplate: "/Anime/Frieren/%ep.mkv",
|
||||
MediaID: mediaID,
|
||||
Episodes: []anime.TestLocalFileEpisode{
|
||||
{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 3, AniDBEpisode: "3", Type: anime.LocalFileTypeMain},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
info := h.newEntryDownloadInfo(t, mediaID, localFiles, 4, anilist.MediaListStatusCompleted)
|
||||
|
||||
require.ElementsMatch(t, []downloadEpisodeExpectation{{2, "2"}, {4, "4"}, {5, "5"}}, collectDownloadEpisodes(info))
|
||||
require.True(t, info.CanBatch)
|
||||
require.False(t, info.BatchAll)
|
||||
require.True(t, info.Rewatch)
|
||||
}
|
||||
|
||||
func TestNewEntryDownloadInfoScheduleFlags(t *testing.T) {
|
||||
t.Run("releasing without next airing is inaccurate", func(t *testing.T) {
|
||||
// releasing shows without next airing data keep the full aired list,
|
||||
// but they should be marked as having an inaccurate schedule.
|
||||
h := newAnimeTestHarness(t)
|
||||
mediaID := 154587
|
||||
|
||||
patchCollectionEntryEpisodeCount(t, h.animeCollection, mediaID, 5)
|
||||
patchEntryMediaStatus(t, h.animeCollection, mediaID, anilist.MediaStatusReleasing)
|
||||
h.clearNextAiringEpisode(t, mediaID)
|
||||
h.setEpisodeMetadata(t, mediaID, []int{1, 2, 3, 4, 5}, nil)
|
||||
|
||||
info := h.newEntryDownloadInfo(t, mediaID, nil, 0, anilist.MediaListStatusCurrent)
|
||||
|
||||
require.ElementsMatch(t, []downloadEpisodeExpectation{{1, "1"}, {2, "2"}, {3, "3"}, {4, "4"}, {5, "5"}}, collectDownloadEpisodes(info))
|
||||
require.True(t, info.HasInaccurateSchedule)
|
||||
})
|
||||
|
||||
t.Run("next airing trims future episodes", func(t *testing.T) {
|
||||
// once next airing is known, anything at or after that future episode should be filtered out.
|
||||
h := newAnimeTestHarness(t)
|
||||
mediaID := 154587
|
||||
|
||||
patchCollectionEntryEpisodeCount(t, h.animeCollection, mediaID, 12)
|
||||
patchEntryMediaStatus(t, h.animeCollection, mediaID, anilist.MediaStatusReleasing)
|
||||
patchAnimeCollectionEntry(t, h.animeCollection, mediaID, anilist.AnimeCollectionEntryPatch{
|
||||
NextAiringEpisode: &anilist.BaseAnime_NextAiringEpisode{Episode: 4},
|
||||
})
|
||||
h.setEpisodeMetadata(t, mediaID, []int{1, 2, 3, 4, 5, 6}, nil)
|
||||
|
||||
info := h.newEntryDownloadInfo(t, mediaID, nil, 0, anilist.MediaListStatusCurrent)
|
||||
|
||||
require.ElementsMatch(t, []downloadEpisodeExpectation{{1, "1"}, {2, "2"}, {3, "3"}}, collectDownloadEpisodes(info))
|
||||
require.False(t, info.HasInaccurateSchedule)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewEntryDownloadInfoFallsBackToMetadataCurrentEpisodeCount(t *testing.T) {
|
||||
// if media.Episodes is missing, the code falls back to aired episode dates in metadata.
|
||||
// only past-dated episodes should survive that fallback count.
|
||||
h := newAnimeTestHarness(t)
|
||||
mediaID := 154587
|
||||
|
||||
patchEntryMediaStatus(t, h.animeCollection, mediaID, anilist.MediaStatusFinished)
|
||||
h.clearEpisodeCount(t, mediaID)
|
||||
h.clearNextAiringEpisode(t, mediaID)
|
||||
h.setCustomMetadata(mediaID, h.newMetadataWithAirDates(t, mediaID, map[int]string{
|
||||
1: "2000-01-01",
|
||||
2: "2000-01-02",
|
||||
3: "2099-01-01",
|
||||
}))
|
||||
|
||||
info := h.newEntryDownloadInfo(t, mediaID, nil, 0, anilist.MediaListStatusCurrent)
|
||||
|
||||
require.ElementsMatch(t, []downloadEpisodeExpectation{{1, "1"}, {2, "2"}}, collectDownloadEpisodes(info))
|
||||
}
|
||||
|
||||
func TestNewEntryDownloadInfoEarlyReturnsAndErrors(t *testing.T) {
|
||||
t.Run("not yet released returns empty result", func(t *testing.T) {
|
||||
// unreleased media short-circuits before any download planning starts.
|
||||
h := newAnimeTestHarness(t)
|
||||
mediaID := 154587
|
||||
|
||||
patchEntryMediaStatus(t, h.animeCollection, mediaID, anilist.MediaStatusNotYetReleased)
|
||||
h.setEpisodeMetadata(t, mediaID, []int{1, 2, 3}, nil)
|
||||
|
||||
info := h.newEntryDownloadInfo(t, mediaID, nil, 0, anilist.MediaListStatusCurrent)
|
||||
|
||||
require.Empty(t, info.EpisodesToDownload)
|
||||
require.False(t, info.CanBatch)
|
||||
require.False(t, info.Rewatch)
|
||||
})
|
||||
|
||||
t.Run("missing metadata returns an error", func(t *testing.T) {
|
||||
// metadata is required for the planner, so nil should fail fast.
|
||||
h := newAnimeTestHarness(t)
|
||||
mediaID := 154587
|
||||
entry := h.findEntry(t, mediaID)
|
||||
|
||||
_, err := anime.NewEntryDownloadInfo(&anime.NewEntryDownloadInfoOptions{
|
||||
LocalFiles: nil,
|
||||
Progress: new(0),
|
||||
Status: new(anilist.MediaListStatusCurrent),
|
||||
Media: entry.Media,
|
||||
MetadataProviderRef: h.metadataProviderRef,
|
||||
AnimeMetadata: nil,
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestNewEntryDownloadInfo2(t *testing.T) {
|
||||
mediaId := 21
|
||||
env := testutil.NewTestEnv(t)
|
||||
|
||||
logger := util.NewLogger()
|
||||
database := env.MustNewDatabase(logger)
|
||||
metadataProvider := metadata_provider.NewTestProviderWithEnv(env, database)
|
||||
|
||||
anilistClient := anilist.NewTestAnilistClient()
|
||||
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: new(0),
|
||||
Status: new(anilist.MediaListStatusCurrent),
|
||||
Media: anilistEntry.Media,
|
||||
MetadataProviderRef: util.NewRef(metadataProvider),
|
||||
AnimeMetadata: animeMetadata,
|
||||
require.EqualError(t, err, "could not get anime metadata")
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, info)
|
||||
t.Run("missing current episode count returns empty result", func(t *testing.T) {
|
||||
// when both media and metadata resolve to zero aired episodes, we just get an empty plan.
|
||||
mediaID := 154587
|
||||
h := newAnimeTestHarness(t)
|
||||
|
||||
t.Log(len(info.EpisodesToDownload))
|
||||
assert.GreaterOrEqual(t, len(info.EpisodesToDownload), 1096)
|
||||
patchEntryMediaStatus(t, h.animeCollection, mediaID, anilist.MediaStatusFinished)
|
||||
h.clearEpisodeCount(t, mediaID)
|
||||
h.clearNextAiringEpisode(t, mediaID)
|
||||
h.setCustomMetadata(mediaID, h.setEpisodeMetadata(t, mediaID, []int{1, 2, 3}, nil))
|
||||
h.clearMetadataAirDates(mediaID)
|
||||
|
||||
info := h.newEntryDownloadInfo(t, mediaID, nil, 0, anilist.MediaListStatusCurrent)
|
||||
|
||||
require.Empty(t, info.EpisodesToDownload)
|
||||
})
|
||||
}
|
||||
|
||||
type downloadEpisodeExpectation struct {
|
||||
episodeNumber int
|
||||
aniDBEpisode string
|
||||
}
|
||||
|
||||
func collectDownloadEpisodes(info *anime.EntryDownloadInfo) []downloadEpisodeExpectation {
|
||||
ret := make([]downloadEpisodeExpectation, 0, len(info.EpisodesToDownload))
|
||||
for _, episode := range info.EpisodesToDownload {
|
||||
ret = append(ret, downloadEpisodeExpectation{
|
||||
episodeNumber: episode.EpisodeNumber,
|
||||
aniDBEpisode: episode.AniDBEpisode,
|
||||
})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -1,591 +0,0 @@
|
||||
{
|
||||
"154587": {
|
||||
"localFiles": [
|
||||
{
|
||||
"path": "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - 01 (1080p) [F02B9CEE].mkv",
|
||||
"name": "[SubsPlease] Sousou no Frieren - 01 (1080p) [F02B9CEE].mkv",
|
||||
"parsedInfo": {
|
||||
"original": "[SubsPlease] Sousou no Frieren - 01 (1080p) [F02B9CEE].mkv",
|
||||
"title": "Sousou no Frieren",
|
||||
"releaseGroup": "SubsPlease",
|
||||
"episode": "01"
|
||||
},
|
||||
"parsedFolderInfo": [
|
||||
{
|
||||
"original": "Sousou no Frieren",
|
||||
"title": "Sousou no Frieren"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"episode": 1,
|
||||
"aniDBEpisode": "1",
|
||||
"type": "main"
|
||||
},
|
||||
"locked": false,
|
||||
"ignored": false,
|
||||
"mediaId": 154587
|
||||
},
|
||||
{
|
||||
"path": "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - 02 (1080p) [E5A85899].mkv",
|
||||
"name": "[SubsPlease] Sousou no Frieren - 02 (1080p) [E5A85899].mkv",
|
||||
"parsedInfo": {
|
||||
"original": "[SubsPlease] Sousou no Frieren - 02 (1080p) [E5A85899].mkv",
|
||||
"title": "Sousou no Frieren",
|
||||
"releaseGroup": "SubsPlease",
|
||||
"episode": "02"
|
||||
},
|
||||
"parsedFolderInfo": [
|
||||
{
|
||||
"original": "Sousou no Frieren",
|
||||
"title": "Sousou no Frieren"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"episode": 2,
|
||||
"aniDBEpisode": "2",
|
||||
"type": "main"
|
||||
},
|
||||
"locked": false,
|
||||
"ignored": false,
|
||||
"mediaId": 154587
|
||||
},
|
||||
{
|
||||
"path": "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - 03 (1080p) [7EF3F175].mkv",
|
||||
"name": "[SubsPlease] Sousou no Frieren - 03 (1080p) [7EF3F175].mkv",
|
||||
"parsedInfo": {
|
||||
"original": "[SubsPlease] Sousou no Frieren - 03 (1080p) [7EF3F175].mkv",
|
||||
"title": "Sousou no Frieren",
|
||||
"releaseGroup": "SubsPlease",
|
||||
"episode": "03"
|
||||
},
|
||||
"parsedFolderInfo": [
|
||||
{
|
||||
"original": "Sousou no Frieren",
|
||||
"title": "Sousou no Frieren"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"episode": 3,
|
||||
"aniDBEpisode": "3",
|
||||
"type": "main"
|
||||
},
|
||||
"locked": false,
|
||||
"ignored": false,
|
||||
"mediaId": 154587
|
||||
},
|
||||
{
|
||||
"path": "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - 04 (1080p) [5ED46803].mkv",
|
||||
"name": "[SubsPlease] Sousou no Frieren - 04 (1080p) [5ED46803].mkv",
|
||||
"parsedInfo": {
|
||||
"original": "[SubsPlease] Sousou no Frieren - 04 (1080p) [5ED46803].mkv",
|
||||
"title": "Sousou no Frieren",
|
||||
"releaseGroup": "SubsPlease",
|
||||
"episode": "04"
|
||||
},
|
||||
"parsedFolderInfo": [
|
||||
{
|
||||
"original": "Sousou no Frieren",
|
||||
"title": "Sousou no Frieren"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"episode": 4,
|
||||
"aniDBEpisode": "4",
|
||||
"type": "main"
|
||||
},
|
||||
"locked": false,
|
||||
"ignored": false,
|
||||
"mediaId": 154587
|
||||
},
|
||||
{
|
||||
"path": "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - 05 (1080p) [8E3F8FA5].mkv",
|
||||
"name": "[SubsPlease] Sousou no Frieren - 05 (1080p) [8E3F8FA5].mkv",
|
||||
"parsedInfo": {
|
||||
"original": "[SubsPlease] Sousou no Frieren - 05 (1080p) [8E3F8FA5].mkv",
|
||||
"title": "Sousou no Frieren",
|
||||
"releaseGroup": "SubsPlease",
|
||||
"episode": "05"
|
||||
},
|
||||
"parsedFolderInfo": [
|
||||
{
|
||||
"original": "Sousou no Frieren",
|
||||
"title": "Sousou no Frieren"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"episode": 5,
|
||||
"aniDBEpisode": "5",
|
||||
"type": "main"
|
||||
},
|
||||
"locked": false,
|
||||
"ignored": false,
|
||||
"mediaId": 154587
|
||||
}
|
||||
],
|
||||
"animeCollection": {
|
||||
"MediaListCollection": {
|
||||
"lists": [
|
||||
{
|
||||
"status": "CURRENT",
|
||||
"entries": [
|
||||
{
|
||||
"id": 366875178,
|
||||
"score": 9,
|
||||
"progress": 4,
|
||||
"status": "CURRENT",
|
||||
"repeat": 0,
|
||||
"private": false,
|
||||
"startedAt": {
|
||||
"year": 2023,
|
||||
"month": 10
|
||||
},
|
||||
"completedAt": {},
|
||||
"media": {
|
||||
"id": 154587,
|
||||
"idMal": 52991,
|
||||
"siteUrl": "https://anilist.co/anime/154587",
|
||||
"status": "RELEASING",
|
||||
"season": "FALL",
|
||||
"type": "ANIME",
|
||||
"format": "TV",
|
||||
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/154587-ivXNJ23SM1xB.jpg",
|
||||
"episodes": 28,
|
||||
"synonyms": [
|
||||
"Frieren at the Funeral",
|
||||
"장송의 프리렌",
|
||||
"Frieren: Oltre la Fine del Viaggio",
|
||||
"คำอธิษฐานในวันที่จากลา Frieren",
|
||||
"Frieren e a Jornada para o Além",
|
||||
"Frieren – Nach dem Ende der Reise",
|
||||
"葬送的芙莉蓮",
|
||||
"Frieren: Más allá del final del viaje",
|
||||
"Frieren en el funeral",
|
||||
"Sōsō no Furīren",
|
||||
"Frieren. U kresu drogi",
|
||||
"Frieren - Pháp sư tiễn táng",
|
||||
"Фрирен, провожающая в последний путь"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Sousou no Frieren",
|
||||
"romaji": "Sousou no Frieren",
|
||||
"english": "Frieren: Beyond Journey’s End",
|
||||
"native": "葬送のフリーレン"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx154587-n1fmjRv4JQUd.jpg",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx154587-n1fmjRv4JQUd.jpg",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx154587-n1fmjRv4JQUd.jpg",
|
||||
"color": "#d6f1c9"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2023,
|
||||
"month": 9,
|
||||
"day": 29
|
||||
},
|
||||
"endDate": {},
|
||||
"nextAiringEpisode": {
|
||||
"airingAt": 1700229600,
|
||||
"timeUntilAiring": 223940,
|
||||
"episode": 11
|
||||
},
|
||||
"relations": {
|
||||
"edges": [
|
||||
{
|
||||
"relationType": "SOURCE",
|
||||
"node": {
|
||||
"id": 118586,
|
||||
"idMal": 126287,
|
||||
"siteUrl": "https://anilist.co/manga/118586",
|
||||
"status": "RELEASING",
|
||||
"type": "MANGA",
|
||||
"format": "MANGA",
|
||||
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/118586-1JLJiwaIlnBp.jpg",
|
||||
"synonyms": [
|
||||
"Frieren at the Funeral",
|
||||
"장송의 프리렌",
|
||||
"Frieren: Oltre la Fine del Viaggio",
|
||||
"คำอธิษฐานในวันที่จากลา Frieren",
|
||||
"Frieren e a Jornada para o Além",
|
||||
"Frieren – Nach dem Ende der Reise",
|
||||
"葬送的芙莉蓮",
|
||||
"Frieren After \"The End\"",
|
||||
"Frieren: Remnants of the Departed",
|
||||
"Frieren. U kresu drogi",
|
||||
"Frieren",
|
||||
"FRIEREN: Más allá del fin del viaje"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Sousou no Frieren",
|
||||
"romaji": "Sousou no Frieren",
|
||||
"english": "Frieren: Beyond Journey’s End",
|
||||
"native": "葬送のフリーレン"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx118586-F0Lp86XQV7du.jpg",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx118586-F0Lp86XQV7du.jpg",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx118586-F0Lp86XQV7du.jpg",
|
||||
"color": "#e4ae5d"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2020,
|
||||
"month": 4,
|
||||
"day": 28
|
||||
},
|
||||
"endDate": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"relationType": "CHARACTER",
|
||||
"node": {
|
||||
"id": 169811,
|
||||
"idMal": 56805,
|
||||
"siteUrl": "https://anilist.co/anime/169811",
|
||||
"status": "FINISHED",
|
||||
"type": "ANIME",
|
||||
"format": "MUSIC",
|
||||
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/169811-jgMVZlIdH19a.jpg",
|
||||
"episodes": 1,
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Yuusha",
|
||||
"romaji": "Yuusha",
|
||||
"native": "勇者"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx169811-H0RW7WHkRlbH.png",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx169811-H0RW7WHkRlbH.png",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx169811-H0RW7WHkRlbH.png"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2023,
|
||||
"month": 9,
|
||||
"day": 29
|
||||
},
|
||||
"endDate": {
|
||||
"year": 2023,
|
||||
"month": 9,
|
||||
"day": 29
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"relationType": "SIDE_STORY",
|
||||
"node": {
|
||||
"id": 170068,
|
||||
"idMal": 56885,
|
||||
"siteUrl": "https://anilist.co/anime/170068",
|
||||
"status": "RELEASING",
|
||||
"season": "FALL",
|
||||
"type": "ANIME",
|
||||
"format": "ONA",
|
||||
"synonyms": [
|
||||
"Sousou no Frieren Mini Anime",
|
||||
"Frieren: Beyond Journey’s End Mini Anime",
|
||||
"葬送のフリーレン ミニアニメ"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Sousou no Frieren: ●● no Mahou",
|
||||
"romaji": "Sousou no Frieren: ●● no Mahou",
|
||||
"native": "葬送のフリーレン ~●●の魔法~"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx170068-ijY3tCP8KoWP.jpg",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx170068-ijY3tCP8KoWP.jpg",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx170068-ijY3tCP8KoWP.jpg",
|
||||
"color": "#bbd678"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2023,
|
||||
"month": 10,
|
||||
"day": 11
|
||||
},
|
||||
"endDate": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"146065": {
|
||||
"localFiles": [],
|
||||
"animeCollection": {
|
||||
"MediaListCollection": {
|
||||
"lists": [
|
||||
{
|
||||
"status": "CURRENT",
|
||||
"entries": [
|
||||
{
|
||||
"id": 366466419,
|
||||
"score": 0,
|
||||
"progress": 0,
|
||||
"status": "CURRENT",
|
||||
"repeat": 0,
|
||||
"private": false,
|
||||
"startedAt": {
|
||||
"year": 2023,
|
||||
"month": 10,
|
||||
"day": 4
|
||||
},
|
||||
"completedAt": {
|
||||
"year": 2023,
|
||||
"month": 10,
|
||||
"day": 9
|
||||
},
|
||||
"media": {
|
||||
"id": 146065,
|
||||
"idMal": 51179,
|
||||
"siteUrl": "https://anilist.co/anime/146065",
|
||||
"status": "FINISHED",
|
||||
"season": "SUMMER",
|
||||
"type": "ANIME",
|
||||
"format": "TV",
|
||||
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/146065-33RDijfuxLLk.jpg",
|
||||
"episodes": 13,
|
||||
"synonyms": [
|
||||
"ชาตินี้พี่ต้องเทพ ภาค 2",
|
||||
"Mushoku Tensei: Isekai Ittara Honki Dasu 2nd Season",
|
||||
"Mushoku Tensei II: Jobless Reincarnation",
|
||||
"Mushoku Tensei II: Reencarnación desde cero",
|
||||
"无职转生~到了异世界就拿出真本事~第2季"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Mushoku Tensei II: Isekai Ittara Honki Dasu",
|
||||
"romaji": "Mushoku Tensei II: Isekai Ittara Honki Dasu",
|
||||
"english": "Mushoku Tensei: Jobless Reincarnation Season 2",
|
||||
"native": "無職転生 Ⅱ ~異世界行ったら本気だす~"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx146065-IjirxRK26O03.png",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx146065-IjirxRK26O03.png",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx146065-IjirxRK26O03.png",
|
||||
"color": "#35aee4"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2023,
|
||||
"month": 7,
|
||||
"day": 3
|
||||
},
|
||||
"endDate": {
|
||||
"year": 2023,
|
||||
"month": 9,
|
||||
"day": 25
|
||||
},
|
||||
"relations": {
|
||||
"edges": [
|
||||
{
|
||||
"relationType": "SOURCE",
|
||||
"node": {
|
||||
"id": 85470,
|
||||
"idMal": 70261,
|
||||
"siteUrl": "https://anilist.co/manga/85470",
|
||||
"status": "FINISHED",
|
||||
"type": "MANGA",
|
||||
"format": "NOVEL",
|
||||
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85470-akkFSKH9aacB.jpg",
|
||||
"synonyms": [
|
||||
"เกิดชาตินี้พี่ต้องเทพ"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu",
|
||||
"romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu",
|
||||
"english": "Mushoku Tensei: Jobless Reincarnation",
|
||||
"native": "無職転生 ~異世界行ったら本気だす~"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx85470-jt6BF9tDWB2X.jpg",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx85470-jt6BF9tDWB2X.jpg",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx85470-jt6BF9tDWB2X.jpg",
|
||||
"color": "#f1bb1a"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2014,
|
||||
"month": 1,
|
||||
"day": 23
|
||||
},
|
||||
"endDate": {
|
||||
"year": 2022,
|
||||
"month": 11,
|
||||
"day": 25
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"relationType": "ALTERNATIVE",
|
||||
"node": {
|
||||
"id": 85564,
|
||||
"idMal": 70259,
|
||||
"siteUrl": "https://anilist.co/manga/85564",
|
||||
"status": "RELEASING",
|
||||
"type": "MANGA",
|
||||
"format": "MANGA",
|
||||
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85564-Wy8IQU3Km61c.jpg",
|
||||
"synonyms": [
|
||||
"Mushoku Tensei: Uma segunda chance"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu",
|
||||
"romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu",
|
||||
"english": "Mushoku Tensei: Jobless Reincarnation",
|
||||
"native": "無職転生 ~異世界行ったら本気だす~"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx85564-egXRASF0x9B9.jpg",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx85564-egXRASF0x9B9.jpg",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx85564-egXRASF0x9B9.jpg",
|
||||
"color": "#e4ae0d"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2014,
|
||||
"month": 5,
|
||||
"day": 2
|
||||
},
|
||||
"endDate": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"relationType": "PREQUEL",
|
||||
"node": {
|
||||
"id": 127720,
|
||||
"idMal": 45576,
|
||||
"siteUrl": "https://anilist.co/anime/127720",
|
||||
"status": "FINISHED",
|
||||
"season": "FALL",
|
||||
"type": "ANIME",
|
||||
"format": "TV",
|
||||
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/127720-oBpHiMWQhFVN.jpg",
|
||||
"episodes": 12,
|
||||
"synonyms": [
|
||||
"Mushoku Tensei: Jobless Reincarnation Part 2",
|
||||
"ชาตินี้พี่ต้องเทพ พาร์ท 2"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu Part 2",
|
||||
"romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu Part 2",
|
||||
"english": "Mushoku Tensei: Jobless Reincarnation Cour 2",
|
||||
"native": "無職転生 ~異世界行ったら本気だす~ 第2クール"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx127720-ADJgIrUVMdU9.jpg",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx127720-ADJgIrUVMdU9.jpg",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx127720-ADJgIrUVMdU9.jpg",
|
||||
"color": "#d6bb1a"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2021,
|
||||
"month": 10,
|
||||
"day": 4
|
||||
},
|
||||
"endDate": {
|
||||
"year": 2021,
|
||||
"month": 12,
|
||||
"day": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"relationType": "ALTERNATIVE",
|
||||
"node": {
|
||||
"id": 142989,
|
||||
"idMal": 142765,
|
||||
"siteUrl": "https://anilist.co/manga/142989",
|
||||
"status": "RELEASING",
|
||||
"type": "MANGA",
|
||||
"format": "MANGA",
|
||||
"synonyms": [
|
||||
"Mushoku Tensei - Depressed Magician"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu - Shitsui no Majutsushi-hen",
|
||||
"romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu - Shitsui no Majutsushi-hen",
|
||||
"native": "無職転生 ~異世界行ったら本気だす~ 失意の魔術師編"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx142989-jYDNHLwdER70.png",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx142989-jYDNHLwdER70.png",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx142989-jYDNHLwdER70.png",
|
||||
"color": "#e4bb28"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2021,
|
||||
"month": 12,
|
||||
"day": 20
|
||||
},
|
||||
"endDate": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"relationType": "SEQUEL",
|
||||
"node": {
|
||||
"id": 166873,
|
||||
"idMal": 55888,
|
||||
"siteUrl": "https://anilist.co/anime/166873",
|
||||
"status": "NOT_YET_RELEASED",
|
||||
"season": "SPRING",
|
||||
"type": "ANIME",
|
||||
"format": "TV",
|
||||
"episodes": 12,
|
||||
"synonyms": [
|
||||
"Mushoku Tensei: Jobless Reincarnation Season 2 Part 2",
|
||||
"ชาตินี้พี่ต้องเทพ ภาค 2",
|
||||
"Mushoku Tensei: Isekai Ittara Honki Dasu 2nd Season Part 2",
|
||||
"Mushoku Tensei II: Jobless Reincarnation Part 2",
|
||||
"Mushoku Tensei II: Reencarnación desde cero",
|
||||
"无职转生~到了异世界就拿出真本事~第2季"
|
||||
],
|
||||
"isAdult": false,
|
||||
"countryOfOrigin": "JP",
|
||||
"title": {
|
||||
"userPreferred": "Mushoku Tensei II: Isekai Ittara Honki Dasu Part 2",
|
||||
"romaji": "Mushoku Tensei II: Isekai Ittara Honki Dasu Part 2",
|
||||
"native": "無職転生 Ⅱ ~異世界行ったら本気だす~ 第2クール"
|
||||
},
|
||||
"coverImage": {
|
||||
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx166873-cqMLPB00KcEI.jpg",
|
||||
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx166873-cqMLPB00KcEI.jpg",
|
||||
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx166873-cqMLPB00KcEI.jpg",
|
||||
"color": "#6b501a"
|
||||
},
|
||||
"startDate": {
|
||||
"year": 2024,
|
||||
"month": 4
|
||||
},
|
||||
"endDate": {
|
||||
"year": 2024,
|
||||
"month": 6
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +1,86 @@
|
||||
//go:build outdated
|
||||
|
||||
package anime_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata_provider"
|
||||
"seanime/internal/database/db"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/testutil"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Test to retrieve accurate missing episodes
|
||||
// DEPRECATED
|
||||
func TestNewMissingEpisodes(t *testing.T) {
|
||||
t.Skip("Outdated test")
|
||||
testutil.InitTestProvider(t, testutil.Anilist())
|
||||
logger := util.NewLogger()
|
||||
database, _ := db.NewDatabase(t.TempDir(), "test", logger)
|
||||
// missing episodes now collapse each show down to the next thing you need,
|
||||
// and anything silenced should be split into its own list.
|
||||
h := newAnimeTestHarness(t)
|
||||
|
||||
metadataProvider := metadata_provider.NewTestProvider(t, database)
|
||||
|
||||
anilistClient := anilist.NewTestAnilistClient()
|
||||
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.NewTestLocalFiles(
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "E:/Anime",
|
||||
FilePathTemplate: "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - %ep (1080p) [F02B9CEE].mkv",
|
||||
MediaID: 154587,
|
||||
Episodes: []anime.TestLocalFileEpisode{
|
||||
{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 2, AniDBEpisode: "2", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 3, AniDBEpisode: "3", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 4, AniDBEpisode: "4", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 5, AniDBEpisode: "5", Type: anime.LocalFileTypeMain},
|
||||
},
|
||||
},
|
||||
),
|
||||
mediaAiredEpisodes: 10,
|
||||
currentProgress: 4,
|
||||
//expectedMissingEpisodes: 5,
|
||||
expectedMissingEpisodes: 1, // DEVNOTE: Now the value is 1 at most because everything else is merged
|
||||
localFiles := anime.NewTestLocalFiles(
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "/Anime",
|
||||
FilePathTemplate: "/Anime/Frieren/%ep.mkv",
|
||||
MediaID: 154587,
|
||||
Episodes: []anime.TestLocalFileEpisode{
|
||||
{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 2, AniDBEpisode: "2", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 3, AniDBEpisode: "3", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 4, AniDBEpisode: "4", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 5, AniDBEpisode: "5", Type: anime.LocalFileTypeMain},
|
||||
},
|
||||
},
|
||||
}
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "/Anime",
|
||||
FilePathTemplate: "/Anime/Mushoku/%ep.mkv",
|
||||
MediaID: 146065,
|
||||
Episodes: []anime.TestLocalFileEpisode{
|
||||
{Episode: 0, AniDBEpisode: "S1", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 2, AniDBEpisode: "2", Type: anime.LocalFileTypeMain},
|
||||
},
|
||||
},
|
||||
anime.TestLocalFileGroup{
|
||||
LibraryPath: "/Anime",
|
||||
FilePathTemplate: "/Anime/OnePiece/%ep.mkv",
|
||||
MediaID: 21,
|
||||
Episodes: []anime.TestLocalFileEpisode{
|
||||
{Episode: 1069, AniDBEpisode: "1069", Type: anime.LocalFileTypeMain},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
for _, tt := range tests {
|
||||
// frieren should surface as a normal missing-episodes card.
|
||||
patchAnimeCollectionEntry(t, h.animeCollection, 154587, anilist.AnimeCollectionEntryPatch{
|
||||
Status: new(anilist.MediaListStatusCurrent),
|
||||
Progress: new(4),
|
||||
AiredEpisodes: new(10),
|
||||
NextAiringEpisode: &anilist.BaseAnime_NextAiringEpisode{Episode: 11},
|
||||
})
|
||||
h.setEpisodeMetadata(t, 154587, []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, nil)
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// mushoku follows the episode-zero discrepancy path, but this one is silenced.
|
||||
patchAnimeCollectionEntry(t, h.animeCollection, 146065, anilist.AnimeCollectionEntryPatch{
|
||||
Status: new(anilist.MediaListStatusCurrent),
|
||||
Progress: new(1),
|
||||
AiredEpisodes: new(6),
|
||||
NextAiringEpisode: &anilist.BaseAnime_NextAiringEpisode{Episode: 7},
|
||||
})
|
||||
h.setEpisodeMetadata(t, 146065, []int{1, 2, 3, 4, 5}, map[string]int{"S1": 1})
|
||||
|
||||
// Mock Anilist collection
|
||||
anilist.PatchAnimeCollectionEntry(animeCollection, tt.mediaId, anilist.AnimeCollectionEntryPatch{
|
||||
Progress: new(tt.currentProgress), // Mock progress
|
||||
AiredEpisodes: new(tt.mediaAiredEpisodes),
|
||||
NextAiringEpisode: &anilist.BaseAnime_NextAiringEpisode{
|
||||
Episode: tt.mediaAiredEpisodes + 1,
|
||||
},
|
||||
})
|
||||
// dropped entries should never show up here.
|
||||
patchAnimeCollectionEntry(t, h.animeCollection, 21, anilist.AnimeCollectionEntryPatch{
|
||||
Status: new(anilist.MediaListStatusDropped),
|
||||
Progress: new(1060),
|
||||
AiredEpisodes: new(1100),
|
||||
NextAiringEpisode: &anilist.BaseAnime_NextAiringEpisode{Episode: 1101},
|
||||
})
|
||||
|
||||
})
|
||||
missing := h.newMissingEpisodes(t, localFiles, []int{146065})
|
||||
|
||||
if assert.NoError(t, err) {
|
||||
missingData := anime.NewMissingEpisodes(&anime.NewMissingEpisodesOptions{
|
||||
AnimeCollection: animeCollection,
|
||||
LocalFiles: tt.localFiles,
|
||||
MetadataProviderRef: util.NewRef(metadataProvider),
|
||||
})
|
||||
|
||||
assert.Equal(t, tt.expectedMissingEpisodes, len(missingData.Episodes))
|
||||
}
|
||||
|
||||
}
|
||||
require.Len(t, missing.Episodes, 1)
|
||||
require.Equal(t, 154587, missing.Episodes[0].BaseAnime.ID)
|
||||
require.Equal(t, 6, missing.Episodes[0].EpisodeNumber)
|
||||
require.Equal(t, "Episode 6 & 4 more", missing.Episodes[0].DisplayTitle)
|
||||
|
||||
require.Len(t, missing.SilencedEpisodes, 1)
|
||||
require.Equal(t, 146065, missing.SilencedEpisodes[0].BaseAnime.ID)
|
||||
require.Equal(t, 3, missing.SilencedEpisodes[0].EpisodeNumber)
|
||||
require.Equal(t, "Episode 3 & 2 more", missing.SilencedEpisodes[0].DisplayTitle)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ type ScheduleItem struct {
|
||||
}
|
||||
|
||||
func GetScheduleItems(animeSchedule *anilist.AnimeAiringSchedule, animeCollection *anilist.AnimeCollection) []*ScheduleItem {
|
||||
if animeSchedule == nil || animeCollection == nil || animeCollection.MediaListCollection == nil {
|
||||
return []*ScheduleItem{}
|
||||
}
|
||||
|
||||
animeEntryMap := make(map[int]*anilist.AnimeListEntry)
|
||||
for _, list := range animeCollection.MediaListCollection.GetLists() {
|
||||
for _, entry := range list.GetEntries() {
|
||||
@@ -49,7 +53,7 @@ func GetScheduleItems(animeSchedule *anilist.AnimeAiringSchedule, animeCollectio
|
||||
t := time.Unix(int64(node.GetAiringAt()), 0)
|
||||
item := &ScheduleItem{
|
||||
MediaId: entry.GetMedia().GetID(),
|
||||
Title: *entry.GetMedia().GetTitle().GetUserPreferred(),
|
||||
Title: entry.GetMedia().GetTitleSafe(),
|
||||
Time: t.UTC().Format("15:04"),
|
||||
DateTime: t.UTC(),
|
||||
Image: entry.GetMedia().GetCoverImageSafe(),
|
||||
|
||||
126
internal/library/anime/schedule_test.go
Normal file
126
internal/library/anime/schedule_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package anime_test
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/customsource"
|
||||
"seanime/internal/library/anime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetScheduleItemsFormatsDeduplicates(t *testing.T) {
|
||||
// schedule items are merged from all schedule buckets,
|
||||
// deduped by media/episode/time
|
||||
h := newAnimeTestHarness(t)
|
||||
|
||||
patchAnimeCollectionEntry(t, h.animeCollection, 154587, anilist.AnimeCollectionEntryPatch{
|
||||
Status: new(anilist.MediaListStatusCurrent),
|
||||
AiredEpisodes: new(12),
|
||||
})
|
||||
patchCollectionEntryEpisodeCount(t, h.animeCollection, 154587, 12)
|
||||
|
||||
patchAnimeCollectionEntry(t, h.animeCollection, 146065, anilist.AnimeCollectionEntryPatch{
|
||||
Status: new(anilist.MediaListStatusCurrent),
|
||||
AiredEpisodes: new(1),
|
||||
})
|
||||
patchCollectionEntryEpisodeCount(t, h.animeCollection, 146065, 1)
|
||||
movieFormat := anilist.MediaFormatMovie
|
||||
patchCollectionEntryFormat(t, h.animeCollection, 146065, movieFormat)
|
||||
movieEntry := findCollectionEntryByMediaID(t, h.animeCollection, 146065)
|
||||
fallbackTitle := "movie fallback"
|
||||
movieEntry.Media.Title.UserPreferred = nil
|
||||
movieEntry.Media.Title.English = &fallbackTitle
|
||||
|
||||
// extension-backed ids should not leak into the schedule list.
|
||||
extensionEntry := findCollectionEntryByMediaID(t, h.animeCollection, 21)
|
||||
extensionID := customsource.GenerateMediaId(1, 99)
|
||||
extensionEntry.Media.ID = extensionID
|
||||
extensionEntry.Status = new(anilist.MediaListStatusCurrent)
|
||||
|
||||
animeSchedule := &anilist.AnimeAiringSchedule{
|
||||
Ongoing: &anilist.AnimeAiringSchedule_Ongoing{Media: []*anilist.AnimeSchedule{
|
||||
newAnimeSchedule(154587,
|
||||
[]*anilist.AnimeSchedule_Previous_Nodes{newPreviousScheduleNode(1_700_000_100, 11, -100)},
|
||||
[]*anilist.AnimeSchedule_Upcoming_Nodes{newUpcomingScheduleNode(1_700_000_200, 12, 200)},
|
||||
),
|
||||
newAnimeSchedule(extensionID, nil, []*anilist.AnimeSchedule_Upcoming_Nodes{newUpcomingScheduleNode(1_700_000_050, 1, 50)}),
|
||||
}},
|
||||
OngoingNext: &anilist.AnimeAiringSchedule_OngoingNext{Media: []*anilist.AnimeSchedule{
|
||||
newAnimeSchedule(154587, nil, []*anilist.AnimeSchedule_Upcoming_Nodes{newUpcomingScheduleNode(1_700_000_200, 12, 200)}),
|
||||
}},
|
||||
Upcoming: &anilist.AnimeAiringSchedule_Upcoming{Media: []*anilist.AnimeSchedule{
|
||||
newAnimeSchedule(146065, nil, []*anilist.AnimeSchedule_Upcoming_Nodes{newUpcomingScheduleNode(1_700_000_300, 1, 300)}),
|
||||
}},
|
||||
}
|
||||
|
||||
items := anime.GetScheduleItems(animeSchedule, h.animeCollection)
|
||||
|
||||
require.Len(t, items, 3)
|
||||
require.Len(t, findScheduleItems(items, 154587, 12), 1)
|
||||
require.Empty(t, findScheduleItems(items, extensionID, 1))
|
||||
|
||||
previousItem := findScheduleItem(t, items, 154587, 11)
|
||||
require.Equal(t, time.Unix(1_700_000_100, 0).UTC(), previousItem.DateTime)
|
||||
require.Equal(t, previousItem.DateTime.Format("15:04"), previousItem.Time)
|
||||
require.False(t, previousItem.IsSeasonFinale)
|
||||
require.False(t, previousItem.IsMovie)
|
||||
|
||||
finaleItem := findScheduleItem(t, items, 154587, 12)
|
||||
require.True(t, finaleItem.IsSeasonFinale)
|
||||
|
||||
movieItem := findScheduleItem(t, items, 146065, 1)
|
||||
require.Equal(t, fallbackTitle, movieItem.Title)
|
||||
require.True(t, movieItem.IsMovie)
|
||||
require.True(t, movieItem.IsSeasonFinale)
|
||||
}
|
||||
|
||||
func TestGetScheduleItemsHandlesNilInputs(t *testing.T) {
|
||||
// nil inputs should just give the caller an empty slice instead of exploding.
|
||||
require.Empty(t, anime.GetScheduleItems(nil, nil))
|
||||
}
|
||||
|
||||
func newAnimeSchedule(mediaID int, previous []*anilist.AnimeSchedule_Previous_Nodes, upcoming []*anilist.AnimeSchedule_Upcoming_Nodes) *anilist.AnimeSchedule {
|
||||
ret := &anilist.AnimeSchedule{ID: mediaID}
|
||||
if previous != nil {
|
||||
ret.Previous = &anilist.AnimeSchedule_Previous{Nodes: previous}
|
||||
}
|
||||
if upcoming != nil {
|
||||
ret.Upcoming = &anilist.AnimeSchedule_Upcoming{Nodes: upcoming}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func newPreviousScheduleNode(airingAt int, episode int, timeUntilAiring int) *anilist.AnimeSchedule_Previous_Nodes {
|
||||
return &anilist.AnimeSchedule_Previous_Nodes{
|
||||
AiringAt: airingAt,
|
||||
Episode: episode,
|
||||
TimeUntilAiring: timeUntilAiring,
|
||||
}
|
||||
}
|
||||
|
||||
func newUpcomingScheduleNode(airingAt int, episode int, timeUntilAiring int) *anilist.AnimeSchedule_Upcoming_Nodes {
|
||||
return &anilist.AnimeSchedule_Upcoming_Nodes{
|
||||
AiringAt: airingAt,
|
||||
Episode: episode,
|
||||
TimeUntilAiring: timeUntilAiring,
|
||||
}
|
||||
}
|
||||
|
||||
func findScheduleItem(t *testing.T, items []*anime.ScheduleItem, mediaID int, episodeNumber int) *anime.ScheduleItem {
|
||||
t.Helper()
|
||||
matching := findScheduleItems(items, mediaID, episodeNumber)
|
||||
require.Len(t, matching, 1)
|
||||
return matching[0]
|
||||
}
|
||||
|
||||
func findScheduleItems(items []*anime.ScheduleItem, mediaID int, episodeNumber int) []*anime.ScheduleItem {
|
||||
ret := make([]*anime.ScheduleItem, 0)
|
||||
for _, item := range items {
|
||||
if item.MediaId == mediaID && item.EpisodeNumber == episodeNumber {
|
||||
ret = append(ret, item)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
227
internal/library/anime/test_harness_test.go
Normal file
227
internal/library/anime/test_harness_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package anime_test
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/api/metadata_provider"
|
||||
"seanime/internal/extension"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/platforms/anilist_platform"
|
||||
"seanime/internal/platforms/platform"
|
||||
"seanime/internal/testutil"
|
||||
"seanime/internal/util"
|
||||
"sort"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type animeTestHarness struct {
|
||||
animeCollection *anilist.AnimeCollection
|
||||
metadataProvider *animeTestMetadataProvider
|
||||
platformRef *util.Ref[platform.Platform]
|
||||
metadataProviderRef *util.Ref[metadata_provider.Provider]
|
||||
}
|
||||
|
||||
type animeTestMetadataProvider struct {
|
||||
metadata_provider.Provider
|
||||
overrides map[int]*metadata.AnimeMetadata
|
||||
}
|
||||
|
||||
func newAnimeTestHarness(t *testing.T) *animeTestHarness {
|
||||
t.Helper()
|
||||
|
||||
// keep the real fixture stack, but make metadata overrides cheap and explicit per test.
|
||||
env := testutil.NewTestEnv(t)
|
||||
logger := util.NewLogger()
|
||||
database := env.MustNewDatabase(logger)
|
||||
metadataProvider := &animeTestMetadataProvider{
|
||||
Provider: metadata_provider.NewTestProviderWithEnv(env, database),
|
||||
overrides: make(map[int]*metadata.AnimeMetadata),
|
||||
}
|
||||
anilistClient := anilist.NewTestAnilistClient()
|
||||
anilistPlatform := anilist_platform.NewAnilistPlatform(util.NewRef(anilistClient), util.NewRef(extension.NewUnifiedBank()), logger, database)
|
||||
animeCollection, err := anilistPlatform.GetAnimeCollection(t.Context(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
metadataProviderInterface := metadata_provider.Provider(metadataProvider)
|
||||
platformInterface := platform.Platform(anilistPlatform)
|
||||
|
||||
return &animeTestHarness{
|
||||
animeCollection: animeCollection,
|
||||
metadataProvider: metadataProvider,
|
||||
platformRef: util.NewRef(platformInterface),
|
||||
metadataProviderRef: util.NewRef(metadataProviderInterface),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *animeTestMetadataProvider) GetAnimeMetadata(platform metadata.Platform, mediaID int) (*metadata.AnimeMetadata, error) {
|
||||
if animeMetadata, ok := p.overrides[mediaID]; ok {
|
||||
return animeMetadata, nil
|
||||
}
|
||||
return p.Provider.GetAnimeMetadata(platform, mediaID)
|
||||
}
|
||||
|
||||
func (h *animeTestHarness) findEntry(t *testing.T, mediaID int) *anilist.AnimeListEntry {
|
||||
t.Helper()
|
||||
return findCollectionEntryByMediaID(t, h.animeCollection, mediaID)
|
||||
}
|
||||
|
||||
func (h *animeTestHarness) setEpisodeMetadata(t *testing.T, mediaID int, mainEpisodes []int, specials map[string]int) *metadata.AnimeMetadata {
|
||||
t.Helper()
|
||||
|
||||
// most anime tests only need stable episode numbering, not a full metadata payload.
|
||||
media := h.findEntry(t, mediaID).Media
|
||||
animeMetadata := anime.NewAnimeMetadataFromEpisodeCount(media, mainEpisodes)
|
||||
for aniDBEpisode, episodeNumber := range specials {
|
||||
animeMetadata.Episodes[aniDBEpisode] = &metadata.EpisodeMetadata{
|
||||
Title: media.GetTitleSafe(),
|
||||
Image: media.GetBannerImageSafe(),
|
||||
EpisodeNumber: episodeNumber,
|
||||
Episode: aniDBEpisode,
|
||||
AbsoluteEpisodeNumber: episodeNumber,
|
||||
HasImage: true,
|
||||
}
|
||||
animeMetadata.SpecialCount++
|
||||
}
|
||||
h.metadataProvider.overrides[mediaID] = animeMetadata
|
||||
return animeMetadata
|
||||
}
|
||||
|
||||
func (h *animeTestHarness) setCustomMetadata(mediaID int, animeMetadata *metadata.AnimeMetadata) {
|
||||
h.metadataProvider.overrides[mediaID] = animeMetadata
|
||||
}
|
||||
|
||||
func (h *animeTestHarness) clearMetadataAirDates(mediaID int) {
|
||||
if animeMetadata, ok := h.metadataProvider.overrides[mediaID]; ok {
|
||||
for _, episode := range animeMetadata.Episodes {
|
||||
episode.AirDate = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *animeTestHarness) newMetadataWithAirDates(t *testing.T, mediaID int, airDates map[int]string) *metadata.AnimeMetadata {
|
||||
t.Helper()
|
||||
|
||||
// this is just for the fallback path where current episode count is inferred from aired dates.
|
||||
episodes := make([]int, 0, len(airDates))
|
||||
for episodeNumber := range airDates {
|
||||
episodes = append(episodes, episodeNumber)
|
||||
}
|
||||
sort.Ints(episodes)
|
||||
|
||||
animeMetadata := anime.NewAnimeMetadataFromEpisodeCount(h.findEntry(t, mediaID).Media, episodes)
|
||||
for episodeNumber, airDate := range airDates {
|
||||
animeMetadata.Episodes[strconv.Itoa(episodeNumber)].AirDate = airDate
|
||||
}
|
||||
|
||||
return animeMetadata
|
||||
}
|
||||
|
||||
func (h *animeTestHarness) clearNextAiringEpisode(t *testing.T, mediaID int) {
|
||||
t.Helper()
|
||||
h.findEntry(t, mediaID).Media.NextAiringEpisode = nil
|
||||
}
|
||||
|
||||
func (h *animeTestHarness) clearAllNextAiringEpisodes() {
|
||||
for _, list := range h.animeCollection.GetMediaListCollection().GetLists() {
|
||||
for _, entry := range list.GetEntries() {
|
||||
entry.Media.NextAiringEpisode = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *animeTestHarness) clearEpisodeCount(t *testing.T, mediaID int) {
|
||||
t.Helper()
|
||||
h.findEntry(t, mediaID).Media.Episodes = nil
|
||||
}
|
||||
|
||||
func (h *animeTestHarness) newLibraryCollection(t *testing.T, localFiles []*anime.LocalFile) *anime.LibraryCollection {
|
||||
t.Helper()
|
||||
|
||||
libraryCollection, err := anime.NewLibraryCollection(t.Context(), &anime.NewLibraryCollectionOptions{
|
||||
AnimeCollection: h.animeCollection,
|
||||
LocalFiles: localFiles,
|
||||
PlatformRef: h.platformRef,
|
||||
MetadataProviderRef: h.metadataProviderRef,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return libraryCollection
|
||||
}
|
||||
|
||||
func (h *animeTestHarness) newEntryDownloadInfo(t *testing.T, mediaID int, localFiles []*anime.LocalFile, progress int, status anilist.MediaListStatus) *anime.EntryDownloadInfo {
|
||||
t.Helper()
|
||||
|
||||
animeMetadata, err := h.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, mediaID)
|
||||
require.NoError(t, err)
|
||||
|
||||
info, err := anime.NewEntryDownloadInfo(&anime.NewEntryDownloadInfoOptions{
|
||||
LocalFiles: localFiles,
|
||||
Progress: new(progress),
|
||||
Status: new(status),
|
||||
Media: h.findEntry(t, mediaID).Media,
|
||||
MetadataProviderRef: h.metadataProviderRef,
|
||||
AnimeMetadata: animeMetadata,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func (h *animeTestHarness) newMissingEpisodes(t *testing.T, localFiles []*anime.LocalFile, silencedMediaIDs []int) *anime.MissingEpisodes {
|
||||
t.Helper()
|
||||
|
||||
missingEpisodes := anime.NewMissingEpisodes(&anime.NewMissingEpisodesOptions{
|
||||
AnimeCollection: h.animeCollection,
|
||||
LocalFiles: localFiles,
|
||||
SilencedMediaIds: silencedMediaIDs,
|
||||
MetadataProviderRef: h.metadataProviderRef,
|
||||
})
|
||||
require.NotNil(t, missingEpisodes)
|
||||
|
||||
return missingEpisodes
|
||||
}
|
||||
|
||||
func (h *animeTestHarness) newUpcomingEpisodes(t *testing.T) *anime.UpcomingEpisodes {
|
||||
t.Helper()
|
||||
|
||||
upcomingEpisodes := anime.NewUpcomingEpisodes(&anime.NewUpcomingEpisodesOptions{
|
||||
AnimeCollection: h.animeCollection,
|
||||
MetadataProviderRef: h.metadataProviderRef,
|
||||
})
|
||||
require.NotNil(t, upcomingEpisodes)
|
||||
|
||||
return upcomingEpisodes
|
||||
}
|
||||
|
||||
func patchAnimeCollectionEntry(t *testing.T, collection *anilist.AnimeCollection, mediaID int, patch anilist.AnimeCollectionEntryPatch) *anilist.AnimeListEntry {
|
||||
t.Helper()
|
||||
anilist.PatchAnimeCollectionEntry(collection, mediaID, patch)
|
||||
return findCollectionEntryByMediaID(t, collection, mediaID)
|
||||
}
|
||||
|
||||
func patchCollectionEntryFormat(t *testing.T, collection *anilist.AnimeCollection, mediaID int, format anilist.MediaFormat) {
|
||||
t.Helper()
|
||||
entry := findCollectionEntryByMediaID(t, collection, mediaID)
|
||||
entry.Media.Format = &format
|
||||
}
|
||||
|
||||
func patchCollectionEntryEpisodeCount(t *testing.T, collection *anilist.AnimeCollection, mediaID int, episodeCount int) {
|
||||
t.Helper()
|
||||
entry := findCollectionEntryByMediaID(t, collection, mediaID)
|
||||
entry.Media.Episodes = &episodeCount
|
||||
entry.Media.NextAiringEpisode = nil
|
||||
}
|
||||
|
||||
func patchEntryMediaStatus(t *testing.T, collection *anilist.AnimeCollection, mediaID int, status anilist.MediaStatus) {
|
||||
t.Helper()
|
||||
findCollectionEntryByMediaID(t, collection, mediaID).Media.Status = new(status)
|
||||
}
|
||||
|
||||
func findCollectionEntryByMediaID(t *testing.T, collection *anilist.AnimeCollection, mediaID int) *anilist.AnimeListEntry {
|
||||
t.Helper()
|
||||
entry, found := collection.GetListEntryFromAnimeId(mediaID)
|
||||
require.True(t, found)
|
||||
return entry
|
||||
}
|
||||
@@ -25,6 +25,9 @@ func NewTestLocalFiles(groups ...TestLocalFileGroup) []*LocalFile {
|
||||
for _, group := range groups {
|
||||
for _, episode := range group.Episodes {
|
||||
lf := NewLocalFile(strings.ReplaceAll(group.FilePathTemplate, "%ep", strconv.Itoa(episode.Episode)), group.LibraryPath)
|
||||
if lf.ParsedData != nil && lf.ParsedData.Episode == "" {
|
||||
lf.ParsedData.Episode = strconv.Itoa(episode.Episode)
|
||||
}
|
||||
lf.MediaId = group.MediaID
|
||||
lf.Metadata = &LocalFileMetadata{
|
||||
AniDBEpisode: episode.AniDBEpisode,
|
||||
|
||||
50
internal/library/anime/upcoming_episodes_test.go
Normal file
50
internal/library/anime/upcoming_episodes_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package anime_test
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewUpcomingEpisodesSortsAndHydratesMetadata(t *testing.T) {
|
||||
// upcoming episodes should be ordered by time until airing,
|
||||
// and each item should carry metadata for the exact next episode when we have it.
|
||||
h := newAnimeTestHarness(t)
|
||||
h.clearAllNextAiringEpisodes()
|
||||
|
||||
patchAnimeCollectionEntry(t, h.animeCollection, 154587, anilist.AnimeCollectionEntryPatch{
|
||||
Status: new(anilist.MediaListStatusCurrent),
|
||||
NextAiringEpisode: &anilist.BaseAnime_NextAiringEpisode{Episode: 8, AiringAt: 1_700_000_200, TimeUntilAiring: 200},
|
||||
})
|
||||
frierenMetadata := h.setEpisodeMetadata(t, 154587, []int{1, 2, 3, 4, 5, 6, 7, 8}, nil)
|
||||
frierenMetadata.Episodes["8"].Title = "frieren next"
|
||||
|
||||
patchAnimeCollectionEntry(t, h.animeCollection, 146065, anilist.AnimeCollectionEntryPatch{
|
||||
Status: new(anilist.MediaListStatusCurrent),
|
||||
NextAiringEpisode: &anilist.BaseAnime_NextAiringEpisode{Episode: 3, AiringAt: 1_700_000_050, TimeUntilAiring: 50},
|
||||
})
|
||||
mushokuMetadata := h.setEpisodeMetadata(t, 146065, []int{1, 2, 3}, nil)
|
||||
mushokuMetadata.Episodes["3"].Title = "mushoku next"
|
||||
|
||||
// dropped entries still have next-airing data in fixtures sometimes, but they should be filtered out.
|
||||
patchAnimeCollectionEntry(t, h.animeCollection, 21, anilist.AnimeCollectionEntryPatch{
|
||||
Status: new(anilist.MediaListStatusDropped),
|
||||
NextAiringEpisode: &anilist.BaseAnime_NextAiringEpisode{Episode: 1100, AiringAt: 1_700_000_010, TimeUntilAiring: 10},
|
||||
})
|
||||
|
||||
upcoming := h.newUpcomingEpisodes(t)
|
||||
|
||||
require.Len(t, upcoming.Episodes, 2)
|
||||
require.Equal(t, 146065, upcoming.Episodes[0].MediaId)
|
||||
require.Equal(t, 3, upcoming.Episodes[0].EpisodeNumber)
|
||||
require.Equal(t, 50, upcoming.Episodes[0].TimeUntilAiring)
|
||||
require.NotNil(t, upcoming.Episodes[0].EpisodeMetadata)
|
||||
require.Equal(t, "mushoku next", upcoming.Episodes[0].EpisodeMetadata.Title)
|
||||
|
||||
require.Equal(t, 154587, upcoming.Episodes[1].MediaId)
|
||||
require.Equal(t, 8, upcoming.Episodes[1].EpisodeNumber)
|
||||
require.Equal(t, 200, upcoming.Episodes[1].TimeUntilAiring)
|
||||
require.NotNil(t, upcoming.Episodes[1].EpisodeMetadata)
|
||||
require.Equal(t, "frieren next", upcoming.Episodes[1].EpisodeMetadata.Title)
|
||||
}
|
||||
@@ -209,6 +209,10 @@ func (as *AutoScanner) scan() {
|
||||
as.logger.Error().Err(err).Msg("autoscanner: Failed to get settings")
|
||||
return
|
||||
}
|
||||
if settings.Library == nil {
|
||||
as.logger.Error().Msg("autoscanner: Library settings are not set")
|
||||
return
|
||||
}
|
||||
|
||||
if settings.Library.LibraryPath == "" {
|
||||
as.logger.Error().Msg("autoscanner: Library path is not set")
|
||||
|
||||
228
internal/library/autoscanner/autoscanner_test.go
Normal file
228
internal/library/autoscanner/autoscanner_test.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package autoscanner
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/database/db"
|
||||
"seanime/internal/database/models"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/testutil"
|
||||
"seanime/internal/util"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewAutoScannerAppliesDefaultsAndSetters(t *testing.T) {
|
||||
// the constructor should apply defaults, and then the setters should override them.
|
||||
h := newAutoScannerTestHarness(t, false, 0)
|
||||
|
||||
require.Equal(t, 15*time.Second, h.autoScanner.waitTime)
|
||||
require.NotNil(t, h.autoScanner.fileActionCh)
|
||||
require.NotNil(t, h.autoScanner.scannedCh)
|
||||
require.False(t, h.autoScanner.enabled)
|
||||
|
||||
collection := &anilist.AnimeCollection{}
|
||||
h.autoScanner.SetAnimeCollection(collection)
|
||||
require.Same(t, collection, h.autoScanner.animeCollection)
|
||||
|
||||
settings := models.LibrarySettings{
|
||||
AutoScan: true,
|
||||
}
|
||||
h.autoScanner.SetSettings(settings)
|
||||
require.True(t, h.autoScanner.enabled)
|
||||
require.Equal(t, settings, h.autoScanner.settings)
|
||||
|
||||
custom := newAutoScannerTestHarness(t, true, 25*time.Millisecond)
|
||||
require.Equal(t, 25*time.Millisecond, custom.autoScanner.waitTime)
|
||||
require.True(t, custom.autoScanner.enabled)
|
||||
}
|
||||
|
||||
func TestAutoScannerNotifyQueuesSignalsAndMissedActions(t *testing.T) {
|
||||
// Notify should send a signal on the channel when enabled, but if we're currently waiting it should mark that we missed an action instead.
|
||||
var nilScanner *AutoScanner
|
||||
nilScanner.Notify()
|
||||
|
||||
t.Run("enabled queue gets a signal", func(t *testing.T) {
|
||||
h := newAutoScannerTestHarness(t, true, 10*time.Millisecond)
|
||||
|
||||
h.autoScanner.Notify()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return len(h.autoScanner.fileActionCh) == 1
|
||||
}, time.Second, 5*time.Millisecond)
|
||||
})
|
||||
|
||||
t.Run("disabled scanner stays quiet", func(t *testing.T) {
|
||||
h := newAutoScannerTestHarness(t, false, 10*time.Millisecond)
|
||||
|
||||
h.autoScanner.Notify()
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
require.Zero(t, len(h.autoScanner.fileActionCh))
|
||||
})
|
||||
|
||||
t.Run("waiting scanner marks the action as missed", func(t *testing.T) {
|
||||
h := newAutoScannerTestHarness(t, true, 10*time.Millisecond)
|
||||
h.autoScanner.waiting = true
|
||||
|
||||
h.autoScanner.Notify()
|
||||
|
||||
require.True(t, h.autoScanner.missedAction)
|
||||
require.Zero(t, len(h.autoScanner.fileActionCh))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAutoScannerWaitAndScanDebouncesMissedActions(t *testing.T) {
|
||||
// when another file event lands during the wait window, we should restart the timer and still scan once.
|
||||
h := newAutoScannerTestHarness(t, true, 25*time.Millisecond)
|
||||
h.seedSettings(t, "")
|
||||
|
||||
startedAt := time.Now()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
h.autoScanner.waitAndScan()
|
||||
}()
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
h.autoScanner.Notify()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return h.wsEventManager.count(events.AutoScanCompleted) == 1
|
||||
}, time.Second, 5*time.Millisecond)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("waitAndScan did not finish")
|
||||
}
|
||||
|
||||
require.Equal(t, 1, h.wsEventManager.count(events.AutoScanStarted))
|
||||
require.Equal(t, 1, h.wsEventManager.count(events.AutoScanCompleted))
|
||||
require.False(t, h.autoScanner.waiting)
|
||||
require.False(t, h.autoScanner.missedAction)
|
||||
require.GreaterOrEqual(t, time.Since(startedAt), 40*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestAutoScannerRunNowBypassesEnabledFlag(t *testing.T) {
|
||||
// even if the scanner is disabled, RunNow should trigger a scan
|
||||
h := newAutoScannerTestHarness(t, false, 10*time.Millisecond)
|
||||
h.seedSettings(t, "")
|
||||
|
||||
h.autoScanner.RunNow()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return h.wsEventManager.count(events.AutoScanCompleted) == 1
|
||||
}, time.Second, 5*time.Millisecond)
|
||||
|
||||
require.Equal(t, []string{events.AutoScanStarted, events.AutoScanCompleted}, h.wsEventManager.types())
|
||||
require.Zero(t, h.refreshCalls.Load())
|
||||
require.False(t, h.autoScanner.scanning.Load())
|
||||
}
|
||||
|
||||
func TestAutoScannerScanSkipsConcurrentRuns(t *testing.T) {
|
||||
// the compare-and-swap guard should keep a second scan from even starting.
|
||||
h := newAutoScannerTestHarness(t, true, 10*time.Millisecond)
|
||||
h.autoScanner.scanning.Store(true)
|
||||
t.Cleanup(func() {
|
||||
h.autoScanner.scanning.Store(false)
|
||||
})
|
||||
|
||||
h.autoScanner.scan()
|
||||
|
||||
require.Empty(t, h.wsEventManager.types())
|
||||
require.True(t, h.autoScanner.scanning.Load())
|
||||
}
|
||||
|
||||
type autoScannerTestHarness struct {
|
||||
database *db.Database
|
||||
wsEventManager *recordingWSEventManager
|
||||
autoScanner *AutoScanner
|
||||
refreshCalls atomic.Int32
|
||||
}
|
||||
|
||||
func newAutoScannerTestHarness(t *testing.T, enabled bool, waitTime time.Duration) *autoScannerTestHarness {
|
||||
t.Helper()
|
||||
|
||||
resetAutoscannerTestState(t)
|
||||
|
||||
env := testutil.NewTestEnv(t)
|
||||
logger := util.NewLogger()
|
||||
database := env.MustNewDatabase(logger)
|
||||
wsEventManager := &recordingWSEventManager{MockWSEventManager: events.NewMockWSEventManager(logger)}
|
||||
h := &autoScannerTestHarness{
|
||||
database: database,
|
||||
wsEventManager: wsEventManager,
|
||||
}
|
||||
h.autoScanner = New(&NewAutoScannerOptions{
|
||||
Database: database,
|
||||
Logger: logger,
|
||||
Enabled: enabled,
|
||||
WaitTime: waitTime,
|
||||
WSEventManager: wsEventManager,
|
||||
OnRefreshCollection: func() {
|
||||
h.refreshCalls.Add(1)
|
||||
},
|
||||
})
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *autoScannerTestHarness) seedSettings(t *testing.T, libraryPath string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := h.database.UpsertSettings(&models.Settings{
|
||||
BaseModel: models.BaseModel{ID: 1},
|
||||
Library: &models.LibrarySettings{
|
||||
LibraryPath: libraryPath,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
type recordingWSEventManager struct {
|
||||
*events.MockWSEventManager
|
||||
mu sync.Mutex
|
||||
typesSent []string
|
||||
}
|
||||
|
||||
func (m *recordingWSEventManager) SendEvent(t string, _ interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.typesSent = append(m.typesSent, t)
|
||||
}
|
||||
|
||||
func (m *recordingWSEventManager) count(eventType string) int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
count := 0
|
||||
for _, t := range m.typesSent {
|
||||
if t == eventType {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (m *recordingWSEventManager) types() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
ret := make([]string, len(m.typesSent))
|
||||
copy(ret, m.typesSent)
|
||||
return ret
|
||||
}
|
||||
|
||||
func resetAutoscannerTestState(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
previousSettings := db.CurrSettings
|
||||
db.CurrSettings = nil
|
||||
t.Cleanup(func() {
|
||||
db.CurrSettings = previousSettings
|
||||
})
|
||||
}
|
||||
@@ -240,7 +240,7 @@ func (fm *FillerManager) HydrateOnlinestreamFillerData(mId int, episodes []*onli
|
||||
if fm == nil {
|
||||
return
|
||||
}
|
||||
if episodes == nil || len(episodes) == 0 {
|
||||
if len(episodes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
662
internal/library/playbackmanager/playback_manager_test.go
Normal file
662
internal/library/playbackmanager/playback_manager_test.go
Normal file
@@ -0,0 +1,662 @@
|
||||
package playbackmanager
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata_provider"
|
||||
"seanime/internal/continuity"
|
||||
"seanime/internal/database/db"
|
||||
"seanime/internal/database/models"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/mediaplayers/mediaplayer"
|
||||
"seanime/internal/platforms/platform"
|
||||
"seanime/internal/testmocks"
|
||||
"seanime/internal/testutil"
|
||||
"seanime/internal/util"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/samber/mo"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPlaybackManagerUnitNewDefaultsAndSetters(t *testing.T) {
|
||||
// keep the constructor honest so the rest of the tests can rely on the default state.
|
||||
h := newPlaybackManagerTestHarness(t)
|
||||
|
||||
require.NotNil(t, h.playbackManager.settings)
|
||||
require.NotNil(t, h.playbackManager.historyMap)
|
||||
require.Empty(t, h.playbackManager.historyMap)
|
||||
require.True(t, h.playbackManager.nextEpisodeLocalFile.IsAbsent())
|
||||
require.True(t, h.playbackManager.animeCollection.IsAbsent())
|
||||
|
||||
collection := &anilist.AnimeCollection{}
|
||||
h.playbackManager.SetAnimeCollection(collection)
|
||||
require.True(t, h.playbackManager.animeCollection.IsPresent())
|
||||
require.Same(t, collection, h.playbackManager.animeCollection.MustGet())
|
||||
|
||||
settings := &Settings{AutoPlayNextEpisode: true}
|
||||
h.playbackManager.SetSettings(settings)
|
||||
require.Same(t, settings, h.playbackManager.settings)
|
||||
|
||||
h.playbackManager.SetPlaylistActive(true)
|
||||
require.True(t, h.playbackManager.isPlaylistActive.Load())
|
||||
h.playbackManager.SetPlaylistActive(false)
|
||||
require.False(t, h.playbackManager.isPlaylistActive.Load())
|
||||
}
|
||||
|
||||
func TestPlaybackManagerUnitCheckOrLoadAnimeCollectionCachesResult(t *testing.T) {
|
||||
// the first call should hit the platform, and later calls should reuse the cached collection.
|
||||
h := newPlaybackManagerTestHarness(t)
|
||||
expectedCollection := &anilist.AnimeCollection{}
|
||||
h.platform = testmocks.NewFakePlatformBuilder().WithAnimeCollection(expectedCollection).Build()
|
||||
h.playbackManager.platformRef = util.NewRef[platform.Platform](h.platform)
|
||||
|
||||
require.NoError(t, h.playbackManager.checkOrLoadAnimeCollection())
|
||||
require.Equal(t, 1, h.platform.AnimeCollectionCalls())
|
||||
require.Same(t, expectedCollection, h.playbackManager.animeCollection.MustGet())
|
||||
|
||||
require.NoError(t, h.playbackManager.checkOrLoadAnimeCollection())
|
||||
require.Equal(t, 1, h.platform.AnimeCollectionCalls())
|
||||
|
||||
h.playbackManager.animeCollection = mo.None[*anilist.AnimeCollection]()
|
||||
h.platform = testmocks.NewFakePlatformBuilder().WithAnimeCollectionError(errors.New("collection failed")).Build()
|
||||
h.playbackManager.platformRef = util.NewRef[platform.Platform](h.platform)
|
||||
err := h.playbackManager.checkOrLoadAnimeCollection()
|
||||
require.EqualError(t, err, "collection failed")
|
||||
require.Equal(t, 1, h.platform.AnimeCollectionCalls())
|
||||
}
|
||||
|
||||
func TestPlaybackManagerUnitGetNextEpisodeAndCurrentMediaID(t *testing.T) {
|
||||
// these are tiny state readers, so keep them focused on the state machine rules.
|
||||
h := newPlaybackManagerTestHarness(t)
|
||||
localFiles := anime.NewTestLocalFiles(anime.TestLocalFileGroup{
|
||||
LibraryPath: "/Anime",
|
||||
FilePathTemplate: "/Anime/Frieren/%ep.mkv",
|
||||
MediaID: 154587,
|
||||
Episodes: []anime.TestLocalFileEpisode{
|
||||
{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 2, AniDBEpisode: "2", Type: anime.LocalFileTypeMain},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := h.playbackManager.GetCurrentMediaID()
|
||||
require.EqualError(t, err, "no media is currently playing")
|
||||
require.Nil(t, h.playbackManager.GetNextEpisode())
|
||||
|
||||
h.playbackManager.currentLocalFile = mo.Some(localFiles[0])
|
||||
h.playbackManager.currentPlaybackType = StreamPlayback
|
||||
mediaID, err := h.playbackManager.GetCurrentMediaID()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 154587, mediaID)
|
||||
require.Nil(t, h.playbackManager.GetNextEpisode())
|
||||
|
||||
h.playbackManager.currentPlaybackType = LocalFilePlayback
|
||||
h.playbackManager.nextEpisodeLocalFile = mo.Some(localFiles[1])
|
||||
require.Same(t, localFiles[1], h.playbackManager.GetNextEpisode())
|
||||
}
|
||||
|
||||
func TestPlaybackManagerUnitPlaybackStatusSubscriptionLifecycle(t *testing.T) {
|
||||
// subscription cleanup matters because the manager broadcasts on these channels from goroutines.
|
||||
h := newPlaybackManagerTestHarness(t)
|
||||
subscriber := h.playbackManager.SubscribeToPlaybackStatus("unit")
|
||||
|
||||
storedSubscriber, ok := h.playbackManager.playbackStatusSubscribers.Get("unit")
|
||||
require.True(t, ok)
|
||||
require.Same(t, subscriber, storedSubscriber)
|
||||
require.False(t, subscriber.Canceled.Load())
|
||||
|
||||
h.playbackManager.UnsubscribeFromPlaybackStatus("unit")
|
||||
require.True(t, subscriber.Canceled.Load())
|
||||
_, ok = h.playbackManager.playbackStatusSubscribers.Get("unit")
|
||||
require.False(t, ok)
|
||||
|
||||
_, channelOpen := <-subscriber.EventCh
|
||||
require.False(t, channelOpen)
|
||||
|
||||
// a second unsubscribe should stay quiet instead of panicking.
|
||||
h.playbackManager.UnsubscribeFromPlaybackStatus("unit")
|
||||
}
|
||||
|
||||
func TestPlaybackManagerUnitRegisterMediaPlayerCallbackStopsAfterFalse(t *testing.T) {
|
||||
// callbacks are just another subscriber under the hood, so we can drive one directly.
|
||||
h := newPlaybackManagerTestHarness(t)
|
||||
received := make(chan PlaybackEvent, 1)
|
||||
|
||||
h.playbackManager.RegisterMediaPlayerCallback(func(event PlaybackEvent) bool {
|
||||
received <- event
|
||||
return false
|
||||
})
|
||||
|
||||
var subscriber *PlaybackStatusSubscriber
|
||||
require.Eventually(t, func() bool {
|
||||
h.playbackManager.playbackStatusSubscribers.Range(func(_ string, value *PlaybackStatusSubscriber) bool {
|
||||
subscriber = value
|
||||
return false
|
||||
})
|
||||
return subscriber != nil
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
subscriber.EventCh <- PlaybackErrorEvent{Reason: "boom"}
|
||||
|
||||
select {
|
||||
case event := <-received:
|
||||
require.Equal(t, "playback_error", event.Type())
|
||||
require.Equal(t, "boom", event.(PlaybackErrorEvent).Reason)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("callback did not receive playback event")
|
||||
}
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return len(h.playbackManager.playbackStatusSubscribers.Keys()) == 0
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestPlaybackManagerUnitAutoPlayNextEpisodeBranches(t *testing.T) {
|
||||
localFiles := anime.NewTestLocalFiles(anime.TestLocalFileGroup{
|
||||
LibraryPath: "/Anime",
|
||||
FilePathTemplate: "/Anime/Frieren/%ep.mkv",
|
||||
MediaID: 154587,
|
||||
Episodes: []anime.TestLocalFileEpisode{
|
||||
{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 2, AniDBEpisode: "2", Type: anime.LocalFileTypeMain},
|
||||
},
|
||||
})
|
||||
|
||||
t.Run("disabled autoplay is a no-op", func(t *testing.T) {
|
||||
// if the setting is off, the queue should stay untouched.
|
||||
h := newPlaybackManagerTestHarness(t)
|
||||
h.playbackManager.currentPlaybackType = LocalFilePlayback
|
||||
h.playbackManager.nextEpisodeLocalFile = mo.Some(localFiles[1])
|
||||
|
||||
require.NoError(t, h.playbackManager.AutoPlayNextEpisode())
|
||||
require.True(t, h.playbackManager.nextEpisodeLocalFile.IsPresent())
|
||||
})
|
||||
|
||||
t.Run("missing next episode stays quiet", func(t *testing.T) {
|
||||
// multiple clients can race this request, so no-next should just return nil.
|
||||
h := newPlaybackManagerTestHarness(t)
|
||||
h.playbackManager.settings.AutoPlayNextEpisode = true
|
||||
h.playbackManager.currentPlaybackType = LocalFilePlayback
|
||||
|
||||
require.NoError(t, h.playbackManager.AutoPlayNextEpisode())
|
||||
require.True(t, h.playbackManager.nextEpisodeLocalFile.IsAbsent())
|
||||
})
|
||||
|
||||
t.Run("play errors get wrapped", func(t *testing.T) {
|
||||
// once autoplay is enabled and a next file exists, play-next failures should bubble up with context.
|
||||
h := newPlaybackManagerTestHarness(t)
|
||||
h.playbackManager.settings.AutoPlayNextEpisode = true
|
||||
h.playbackManager.currentPlaybackType = LocalFilePlayback
|
||||
h.playbackManager.nextEpisodeLocalFile = mo.Some(localFiles[1])
|
||||
|
||||
err := h.playbackManager.AutoPlayNextEpisode()
|
||||
require.EqualError(t, err, "failed to auto play next episode: could not play next episode")
|
||||
require.True(t, h.playbackManager.nextEpisodeLocalFile.IsPresent())
|
||||
})
|
||||
}
|
||||
|
||||
func TestPlaybackManagerUnitStartPlayingAndStreamingValidation(t *testing.T) {
|
||||
t.Run("local playback fails if collection refresh fails", func(t *testing.T) {
|
||||
// this should stop before touching the media player when collection loading fails.
|
||||
h := newPlaybackManagerTestHarness(t)
|
||||
h.platform = testmocks.NewFakePlatformBuilder().WithAnimeCollectionError(errors.New("collection failed")).Build()
|
||||
h.playbackManager.platformRef = util.NewRef[platform.Platform](h.platform)
|
||||
|
||||
err := h.playbackManager.StartPlayingUsingMediaPlayer(&StartPlayingOptions{Payload: "/Anime/Frieren/1.mkv"})
|
||||
require.EqualError(t, err, "collection failed")
|
||||
})
|
||||
|
||||
t.Run("stream playback blocks offline mode", func(t *testing.T) {
|
||||
// offline mode is a hard stop even when the caller passed a media and episode.
|
||||
h := newPlaybackManagerTestHarness(t)
|
||||
h.playbackManager.isOfflineRef.Set(true)
|
||||
|
||||
err := h.playbackManager.StartStreamingUsingMediaPlayer("stream", &StartPlayingOptions{Payload: "https://example.com"}, testmocks.NewBaseAnime(154587, "Frieren"), "1")
|
||||
require.EqualError(t, err, "cannot stream when offline")
|
||||
require.True(t, h.playbackManager.currentStreamMedia.IsAbsent())
|
||||
})
|
||||
|
||||
t.Run("stream playback rejects missing data", func(t *testing.T) {
|
||||
// callers need to provide both the media and the anidb episode before we can track a stream.
|
||||
media := testmocks.NewBaseAnime(154587, "Frieren")
|
||||
h := newPlaybackManagerTestHarness(t)
|
||||
|
||||
err := h.playbackManager.StartStreamingUsingMediaPlayer("stream", &StartPlayingOptions{Payload: "https://example.com"}, nil, "1")
|
||||
require.EqualError(t, err, "cannot start streaming, not enough data provided")
|
||||
|
||||
err = h.playbackManager.StartStreamingUsingMediaPlayer("stream", &StartPlayingOptions{Payload: "https://example.com"}, media, "")
|
||||
require.EqualError(t, err, "cannot start streaming, not enough data provided")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPlaybackManagerUnitLocalPlaybackStatusAndProgressTracking(t *testing.T) {
|
||||
// this drives the local-file tracking handlers directly so state changes and progress syncing stay covered.
|
||||
h := newPlaybackManagerTestHarness(t)
|
||||
h.seedAutoUpdateProgress(t, true)
|
||||
|
||||
media := testmocks.NewBaseAnimeBuilder(154587, "Frieren").
|
||||
WithUserPreferredTitle("Frieren").
|
||||
WithEpisodes(12).
|
||||
Build()
|
||||
localFiles := anime.NewTestLocalFiles(anime.TestLocalFileGroup{
|
||||
LibraryPath: "/Anime",
|
||||
FilePathTemplate: "/Anime/Frieren/%ep.mkv",
|
||||
MediaID: media.ID,
|
||||
Episodes: []anime.TestLocalFileEpisode{
|
||||
{Episode: 1, AniDBEpisode: "1", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 2, AniDBEpisode: "2", Type: anime.LocalFileTypeMain},
|
||||
{Episode: 3, AniDBEpisode: "3", Type: anime.LocalFileTypeMain},
|
||||
},
|
||||
})
|
||||
wrapper := anime.NewLocalFileWrapper(localFiles)
|
||||
wrapperEntry, ok := wrapper.GetLocalEntryById(media.ID)
|
||||
require.True(t, ok)
|
||||
|
||||
h.playbackManager.currentMediaListEntry = mo.Some(&anilist.AnimeListEntry{
|
||||
Media: media,
|
||||
Progress: new(1),
|
||||
})
|
||||
h.playbackManager.currentLocalFile = mo.Some(localFiles[1])
|
||||
h.playbackManager.currentLocalFileWrapperEntry = mo.Some(wrapperEntry)
|
||||
subscriber := h.playbackManager.SubscribeToPlaybackStatus("unit-local")
|
||||
|
||||
status := &mediaplayer.PlaybackStatus{
|
||||
Filename: "2.mkv",
|
||||
Filepath: localFiles[1].Path,
|
||||
CompletionPercentage: 0.5,
|
||||
CurrentTimeInSeconds: 600,
|
||||
DurationInSeconds: 1200,
|
||||
PlaybackType: mediaplayer.PlaybackTypeFile,
|
||||
}
|
||||
|
||||
h.playbackManager.handlePlaybackStatus(status)
|
||||
|
||||
changedEvent := expectPlaybackEvent[PlaybackStatusChangedEvent](t, subscriber.EventCh)
|
||||
require.Equal(t, 2, changedEvent.State.EpisodeNumber)
|
||||
require.Equal(t, "2", changedEvent.State.AniDbEpisode)
|
||||
require.Equal(t, media.ID, changedEvent.State.MediaId)
|
||||
require.True(t, changedEvent.State.CanPlayNext)
|
||||
require.False(t, changedEvent.State.ProgressUpdated)
|
||||
require.Equal(t, events.PlaybackManagerProgressPlaybackState, h.wsEventManager.lastType())
|
||||
|
||||
completedStatus := &mediaplayer.PlaybackStatus{
|
||||
Filename: "2.mkv",
|
||||
Filepath: localFiles[1].Path,
|
||||
CompletionPercentage: 1,
|
||||
CurrentTimeInSeconds: 1200,
|
||||
DurationInSeconds: 1200,
|
||||
PlaybackType: mediaplayer.PlaybackTypeFile,
|
||||
}
|
||||
h.playbackManager.handleVideoCompleted(completedStatus)
|
||||
|
||||
completedChanged := expectPlaybackEvent[PlaybackStatusChangedEvent](t, subscriber.EventCh)
|
||||
require.Equal(t, 2, completedChanged.State.EpisodeNumber)
|
||||
completedEvent := expectPlaybackEvent[VideoCompletedEvent](t, subscriber.EventCh)
|
||||
require.Equal(t, "2.mkv", completedEvent.Filename)
|
||||
|
||||
progressCalls := h.platform.UpdateEntryProgressCalls()
|
||||
require.Len(t, progressCalls, 1)
|
||||
require.Equal(t, media.ID, progressCalls[0].MediaID)
|
||||
require.Equal(t, 2, progressCalls[0].Progress)
|
||||
require.NotNil(t, progressCalls[0].TotalEpisodes)
|
||||
require.Equal(t, 12, *progressCalls[0].TotalEpisodes)
|
||||
require.True(t, h.playbackManager.historyMap["2.mkv"].ProgressUpdated)
|
||||
require.Equal(t, events.PlaybackManagerProgressVideoCompleted, h.wsEventManager.lastType())
|
||||
require.Equal(t, 1, h.wsEventManager.count(events.PlaybackManagerProgressUpdated))
|
||||
|
||||
h.playbackManager.handleTrackingStopped("closed")
|
||||
|
||||
stoppedEvent := expectPlaybackEvent[VideoStoppedEvent](t, subscriber.EventCh)
|
||||
require.Equal(t, "closed", stoppedEvent.Reason)
|
||||
require.True(t, h.playbackManager.nextEpisodeLocalFile.IsPresent())
|
||||
require.Same(t, localFiles[2], h.playbackManager.nextEpisodeLocalFile.MustGet())
|
||||
require.Equal(t, events.PlaybackManagerProgressTrackingStopped, h.wsEventManager.lastType())
|
||||
}
|
||||
|
||||
func TestPlaybackManagerUnitStreamPlaybackStatusAndProgressTracking(t *testing.T) {
|
||||
// this covers the stream tracking handlers, including progress sync when a streamed episode completes.
|
||||
h := newPlaybackManagerTestHarness(t)
|
||||
h.seedAutoUpdateProgress(t, true)
|
||||
|
||||
media := testmocks.NewBaseAnimeBuilder(201, "Dungeon Meshi").
|
||||
WithUserPreferredTitle("Dungeon Meshi").
|
||||
WithEpisodes(24).
|
||||
Build()
|
||||
entry := &anilist.AnimeListEntry{Media: media, Progress: new(1)}
|
||||
collection := newAnimeCollection(media, entry, anilist.MediaListStatusCurrent)
|
||||
h.playbackManager.SetAnimeCollection(collection)
|
||||
h.playbackManager.currentStreamMedia = mo.Some(media)
|
||||
h.playbackManager.currentStreamEpisode = mo.Some(&anime.Episode{EpisodeNumber: 2, ProgressNumber: 2, AniDBEpisode: "2"})
|
||||
h.playbackManager.currentStreamAniDbEpisode = mo.Some("2")
|
||||
subscriber := h.playbackManager.SubscribeToPlaybackStatus("unit-stream")
|
||||
|
||||
startedStatus := &mediaplayer.PlaybackStatus{
|
||||
Filename: "Stream",
|
||||
Filepath: "https://example.com/stream/2",
|
||||
CompletionPercentage: 0.1,
|
||||
CurrentTimeInSeconds: 60,
|
||||
DurationInSeconds: 1500,
|
||||
PlaybackType: mediaplayer.PlaybackTypeStream,
|
||||
}
|
||||
h.playbackManager.handleStreamingTrackingStarted(startedStatus)
|
||||
|
||||
startedChanged := expectPlaybackEvent[PlaybackStatusChangedEvent](t, subscriber.EventCh)
|
||||
require.Equal(t, 2, startedChanged.State.EpisodeNumber)
|
||||
require.Equal(t, media.ID, startedChanged.State.MediaId)
|
||||
startedEvent := expectPlaybackEvent[StreamStartedEvent](t, subscriber.EventCh)
|
||||
require.Equal(t, "Stream", startedEvent.Filename)
|
||||
require.True(t, h.playbackManager.currentMediaListEntry.IsPresent())
|
||||
require.Equal(t, events.PlaybackManagerProgressTrackingStarted, h.wsEventManager.lastType())
|
||||
|
||||
status := &mediaplayer.PlaybackStatus{
|
||||
Filename: "Stream",
|
||||
Filepath: "https://example.com/stream/2",
|
||||
CompletionPercentage: 0.5,
|
||||
CurrentTimeInSeconds: 750,
|
||||
DurationInSeconds: 1500,
|
||||
Playing: true,
|
||||
PlaybackType: mediaplayer.PlaybackTypeStream,
|
||||
}
|
||||
h.playbackManager.handleStreamingPlaybackStatus(status)
|
||||
|
||||
streamChanged := expectPlaybackEvent[PlaybackStatusChangedEvent](t, subscriber.EventCh)
|
||||
require.Equal(t, 2, streamChanged.State.EpisodeNumber)
|
||||
require.Equal(t, events.PlaybackManagerProgressPlaybackState, h.wsEventManager.lastType())
|
||||
|
||||
completedStatus := &mediaplayer.PlaybackStatus{
|
||||
Filename: "Stream",
|
||||
Filepath: "https://example.com/stream/2",
|
||||
CompletionPercentage: 1,
|
||||
CurrentTimeInSeconds: 1500,
|
||||
DurationInSeconds: 1500,
|
||||
PlaybackType: mediaplayer.PlaybackTypeStream,
|
||||
}
|
||||
h.playbackManager.handleStreamingVideoCompleted(completedStatus)
|
||||
|
||||
completedChanged := expectPlaybackEvent[PlaybackStatusChangedEvent](t, subscriber.EventCh)
|
||||
require.Equal(t, 2, completedChanged.State.EpisodeNumber)
|
||||
completedEvent := expectPlaybackEvent[StreamCompletedEvent](t, subscriber.EventCh)
|
||||
require.Equal(t, "Stream", completedEvent.Filename)
|
||||
|
||||
progressCalls := h.platform.UpdateEntryProgressCalls()
|
||||
require.Len(t, progressCalls, 1)
|
||||
require.Equal(t, media.ID, progressCalls[0].MediaID)
|
||||
require.Equal(t, 2, progressCalls[0].Progress)
|
||||
require.NotNil(t, progressCalls[0].TotalEpisodes)
|
||||
require.Equal(t, 24, *progressCalls[0].TotalEpisodes)
|
||||
require.True(t, h.playbackManager.historyMap["Stream"].ProgressUpdated)
|
||||
require.Equal(t, 1, h.wsEventManager.count(events.PlaybackManagerProgressUpdated))
|
||||
|
||||
h.playbackManager.handleStreamingTrackingStopped("finished")
|
||||
|
||||
stoppedEvent := expectPlaybackEvent[StreamStoppedEvent](t, subscriber.EventCh)
|
||||
require.Equal(t, "finished", stoppedEvent.Reason)
|
||||
require.Equal(t, events.PlaybackManagerProgressTrackingStopped, h.wsEventManager.lastType())
|
||||
}
|
||||
|
||||
func TestPlaybackManagerUnitManualProgressTrackingSyncsProgress(t *testing.T) {
|
||||
// manual tracking should hold the current episode in memory and sync it when the user asks for it.
|
||||
h := newPlaybackManagerTestHarness(t)
|
||||
|
||||
media := testmocks.NewBaseAnimeBuilder(909, "Orb").
|
||||
WithUserPreferredTitle("Orb").
|
||||
WithEpisodes(25).
|
||||
Build()
|
||||
entry := &anilist.AnimeListEntry{Media: media, Progress: new(4)}
|
||||
h.platform = testmocks.NewFakePlatformBuilder().WithAnimeCollection(newAnimeCollection(media, entry, anilist.MediaListStatusCurrent)).Build()
|
||||
h.playbackManager.platformRef = util.NewRef[platform.Platform](h.platform)
|
||||
|
||||
err := h.playbackManager.StartManualProgressTracking(&StartManualProgressTrackingOptions{
|
||||
ClientId: "unit",
|
||||
MediaId: media.ID,
|
||||
EpisodeNumber: 5,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ManualTrackingPlayback, h.playbackManager.currentPlaybackType)
|
||||
require.True(t, h.playbackManager.currentManualTrackingState.IsPresent())
|
||||
require.Equal(t, 4, h.playbackManager.currentManualTrackingState.MustGet().CurrentProgress)
|
||||
require.Equal(t, 25, h.playbackManager.currentManualTrackingState.MustGet().TotalEpisodes)
|
||||
require.Eventually(t, func() bool {
|
||||
return h.wsEventManager.count(events.PlaybackManagerManualTrackingPlaybackState) > 0
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
err = h.playbackManager.SyncCurrentProgress()
|
||||
require.NoError(t, err)
|
||||
|
||||
progressCalls := h.platform.UpdateEntryProgressCalls()
|
||||
require.Len(t, progressCalls, 1)
|
||||
require.Equal(t, media.ID, progressCalls[0].MediaID)
|
||||
require.Equal(t, 5, progressCalls[0].Progress)
|
||||
require.NotNil(t, progressCalls[0].TotalEpisodes)
|
||||
require.Equal(t, 25, *progressCalls[0].TotalEpisodes)
|
||||
require.Equal(t, 2, h.refreshCalls)
|
||||
|
||||
h.playbackManager.CancelManualProgressTracking()
|
||||
require.Eventually(t, func() bool {
|
||||
return h.wsEventManager.count(events.PlaybackManagerManualTrackingStopped) == 1
|
||||
}, 4*time.Second, 25*time.Millisecond)
|
||||
require.True(t, h.playbackManager.currentManualTrackingState.IsAbsent())
|
||||
}
|
||||
|
||||
func TestPlaybackManagerLiveRepositoryEventsReachCallbacks(t *testing.T) {
|
||||
// this uses the real repository subscription wiring, but it stays in-memory and never launches a player.
|
||||
h := newPlaybackManagerTestHarness(t)
|
||||
repo := mediaplayer.NewRepository(&mediaplayer.NewRepositoryOptions{
|
||||
Logger: util.NewLogger(),
|
||||
Default: "",
|
||||
WSEventManager: events.NewMockWSEventManager(util.NewLogger()),
|
||||
})
|
||||
|
||||
h.playbackManager.SetMediaPlayerRepository(repo)
|
||||
t.Cleanup(func() {
|
||||
if h.playbackManager.cancel != nil {
|
||||
h.playbackManager.cancel()
|
||||
}
|
||||
})
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return h.playbackManager.MediaPlayerRepository == repo && h.playbackManager.mediaPlayerRepoSubscriber != nil
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
received := make(chan PlaybackErrorEvent, 1)
|
||||
h.playbackManager.RegisterMediaPlayerCallback(func(event PlaybackEvent) bool {
|
||||
playbackError, ok := event.(PlaybackErrorEvent)
|
||||
if ok {
|
||||
received <- playbackError
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
h.playbackManager.mediaPlayerRepoSubscriber.EventCh <- mediaplayer.TrackingRetryEvent{Reason: "player unreachable"}
|
||||
|
||||
select {
|
||||
case event := <-received:
|
||||
require.Equal(t, "player unreachable", event.Reason)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("callback did not receive repository event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackManagerLiveRepositoryStreamCompletionSyncsProgress(t *testing.T) {
|
||||
// this exercises the real repository subscription loop and proves stream completion can drive a progress sync.
|
||||
h := newPlaybackManagerTestHarness(t)
|
||||
h.seedAutoUpdateProgress(t, true)
|
||||
media := testmocks.NewBaseAnimeBuilder(700, "Lazarus").
|
||||
WithUserPreferredTitle("Lazarus").
|
||||
WithEpisodes(13).
|
||||
Build()
|
||||
h.playbackManager.SetAnimeCollection(newAnimeCollection(media, &anilist.AnimeListEntry{
|
||||
Media: media,
|
||||
Progress: new(0),
|
||||
}, anilist.MediaListStatusCurrent))
|
||||
h.playbackManager.currentStreamMedia = mo.Some(media)
|
||||
h.playbackManager.currentStreamEpisode = mo.Some(&anime.Episode{EpisodeNumber: 1, ProgressNumber: 1, AniDBEpisode: "1"})
|
||||
h.playbackManager.currentStreamAniDbEpisode = mo.Some("1")
|
||||
|
||||
repo := mediaplayer.NewRepository(&mediaplayer.NewRepositoryOptions{
|
||||
Logger: util.NewLogger(),
|
||||
Default: "",
|
||||
WSEventManager: events.NewMockWSEventManager(util.NewLogger()),
|
||||
})
|
||||
|
||||
h.playbackManager.SetMediaPlayerRepository(repo)
|
||||
t.Cleanup(func() {
|
||||
if h.playbackManager.cancel != nil {
|
||||
h.playbackManager.cancel()
|
||||
}
|
||||
})
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return h.playbackManager.MediaPlayerRepository == repo && h.playbackManager.mediaPlayerRepoSubscriber != nil
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
h.playbackManager.mediaPlayerRepoSubscriber.EventCh <- mediaplayer.StreamingTrackingStartedEvent{Status: &mediaplayer.PlaybackStatus{
|
||||
Filename: "Stream",
|
||||
Filepath: "https://example.com/stream/1",
|
||||
CompletionPercentage: 0.1,
|
||||
CurrentTimeInSeconds: 60,
|
||||
DurationInSeconds: 1500,
|
||||
PlaybackType: mediaplayer.PlaybackTypeStream,
|
||||
}}
|
||||
h.playbackManager.mediaPlayerRepoSubscriber.EventCh <- mediaplayer.StreamingVideoCompletedEvent{Status: &mediaplayer.PlaybackStatus{
|
||||
Filename: "Stream",
|
||||
Filepath: "https://example.com/stream/1",
|
||||
CompletionPercentage: 1,
|
||||
CurrentTimeInSeconds: 1500,
|
||||
DurationInSeconds: 1500,
|
||||
PlaybackType: mediaplayer.PlaybackTypeStream,
|
||||
}}
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
calls := h.platform.UpdateEntryProgressCalls()
|
||||
return len(calls) == 1 && calls[0].MediaID == media.ID && calls[0].Progress == 1
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
require.True(t, h.playbackManager.historyMap["Stream"].ProgressUpdated)
|
||||
}
|
||||
|
||||
type playbackManagerTestHarness struct {
|
||||
database *db.Database
|
||||
wsEventManager *recordingWSEventManager
|
||||
refreshCalls int
|
||||
platform *testmocks.FakePlatform
|
||||
playbackManager *PlaybackManager
|
||||
}
|
||||
|
||||
func newPlaybackManagerTestHarness(t *testing.T) *playbackManagerTestHarness {
|
||||
t.Helper()
|
||||
|
||||
env := testutil.NewTestEnv(t)
|
||||
logger := util.NewLogger()
|
||||
database := env.MustNewDatabase(logger)
|
||||
wsEventManager := &recordingWSEventManager{MockWSEventManager: events.NewMockWSEventManager(logger)}
|
||||
continuityManager := continuity.NewManager(&continuity.NewManagerOptions{
|
||||
FileCacher: env.NewCacher("continuity"),
|
||||
Logger: logger,
|
||||
Database: database,
|
||||
})
|
||||
continuityManager.SetSettings(&continuity.Settings{WatchContinuityEnabled: true})
|
||||
platformImpl := testmocks.NewFakePlatformBuilder().Build()
|
||||
platformInterface := platform.Platform(platformImpl)
|
||||
var provider metadata_provider.Provider
|
||||
|
||||
h := &playbackManagerTestHarness{
|
||||
database: database,
|
||||
wsEventManager: wsEventManager,
|
||||
platform: platformImpl,
|
||||
}
|
||||
h.playbackManager = New(&NewPlaybackManagerOptions{
|
||||
Logger: logger,
|
||||
WSEventManager: wsEventManager,
|
||||
PlatformRef: util.NewRef(platformInterface),
|
||||
MetadataProviderRef: util.NewRef(provider),
|
||||
Database: database,
|
||||
RefreshAnimeCollectionFunc: func() {
|
||||
h.refreshCalls++
|
||||
},
|
||||
ContinuityManager: continuityManager,
|
||||
IsOfflineRef: util.NewRef(false),
|
||||
})
|
||||
|
||||
h.seedAutoUpdateProgress(t, false)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *playbackManagerTestHarness) seedAutoUpdateProgress(t *testing.T, enabled bool) {
|
||||
t.Helper()
|
||||
|
||||
_, err := h.database.UpsertSettings(&models.Settings{
|
||||
BaseModel: models.BaseModel{ID: 1},
|
||||
Library: &models.LibrarySettings{
|
||||
AutoUpdateProgress: enabled,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
type recordingWSEventManager struct {
|
||||
*events.MockWSEventManager
|
||||
mu sync.Mutex
|
||||
events []events.MockWSEvent
|
||||
}
|
||||
|
||||
func (m *recordingWSEventManager) SendEvent(t string, payload interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.events = append(m.events, events.MockWSEvent{Type: t, Payload: payload})
|
||||
}
|
||||
|
||||
func (m *recordingWSEventManager) count(eventType string) int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
count := 0
|
||||
for _, event := range m.events {
|
||||
if event.Type == eventType {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (m *recordingWSEventManager) lastType() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if len(m.events) == 0 {
|
||||
return ""
|
||||
}
|
||||
return m.events[len(m.events)-1].Type
|
||||
}
|
||||
|
||||
func newAnimeCollection(media *anilist.BaseAnime, entry *anilist.AnimeListEntry, status anilist.MediaListStatus) *anilist.AnimeCollection {
|
||||
entry.Status = new(status)
|
||||
entry.Media = media
|
||||
return &anilist.AnimeCollection{
|
||||
MediaListCollection: &anilist.AnimeCollection_MediaListCollection{
|
||||
Lists: []*anilist.AnimeCollection_MediaListCollection_Lists{{
|
||||
Status: new(status),
|
||||
Entries: []*anilist.AnimeCollection_MediaListCollection_Lists_Entries{entry},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func expectPlaybackEvent[T PlaybackEvent](t *testing.T, ch <-chan PlaybackEvent) T {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case event := <-ch:
|
||||
typed, ok := event.(T)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected playback event type %T", event)
|
||||
}
|
||||
return typed
|
||||
case <-time.After(time.Second):
|
||||
var zero T
|
||||
t.Fatal("timed out waiting for playback event")
|
||||
return zero
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,52 @@
|
||||
//go:build outdated
|
||||
|
||||
package manga
|
||||
|
||||
import (
|
||||
_ "image/jpeg" // Register JPEG format
|
||||
_ "image/png" // Register PNG format
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetImageNaturalSize(t *testing.T) {
|
||||
// Test the function
|
||||
width, height, err := getImageNaturalSize("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
func newTestPNG(t *testing.T, width, height int) []byte {
|
||||
t.Helper()
|
||||
|
||||
spew.Dump(width, height)
|
||||
var buf bytes.Buffer
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
img.Set(0, 0, color.RGBA{R: 255, G: 128, B: 64, A: 255})
|
||||
require.NoError(t, png.Encode(&buf, img))
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestGetImageNaturalSize(t *testing.T) {
|
||||
imageData := newTestPNG(t, 23, 17)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(imageData)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
width, height, err := getImageNaturalSize(server.URL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 23, width)
|
||||
require.Equal(t, 17, height)
|
||||
}
|
||||
|
||||
func TestGetImageNaturalSizeB(t *testing.T) {
|
||||
width, height, err := getImageNaturalSizeB(newTestPNG(t, 31, 19))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 31, width)
|
||||
require.Equal(t, 19, height)
|
||||
}
|
||||
|
||||
func TestGetImageNaturalSizeBInvalidData(t *testing.T) {
|
||||
width, height, err := getImageNaturalSizeB([]byte("not an image"))
|
||||
require.Error(t, err)
|
||||
require.Zero(t, width)
|
||||
require.Zero(t, height)
|
||||
}
|
||||
|
||||
@@ -87,12 +87,14 @@ type WatchPartyManager struct {
|
||||
logger *zerolog.Logger
|
||||
manager *Manager
|
||||
|
||||
playbackController watchPartyPlaybackController
|
||||
|
||||
currentSession mo.Option[*WatchPartySession] // Current watch party session
|
||||
sessionCtx context.Context // Context for the current watch party session
|
||||
sessionCtxCancel context.CancelFunc // Cancel function for the current watch party session
|
||||
mu sync.RWMutex // Mutex for the watch party manager
|
||||
|
||||
// SeekToSlow management to prevent choppy playback
|
||||
// SeekToSlow management to prevent choppy player
|
||||
lastSeekTime time.Time // Time of last seek operation
|
||||
seekCooldown time.Duration // Minimum time between seeks
|
||||
|
||||
@@ -115,7 +117,7 @@ type WatchPartyManager struct {
|
||||
|
||||
// Buffering detection (peer only)
|
||||
bufferDetectionMu sync.Mutex // Mutex for buffering detection state
|
||||
lastPosition float64 // Last known playback position
|
||||
lastPosition float64 // Last known player position
|
||||
lastPositionTime time.Time // When we last updated the position
|
||||
stallCount int // Number of consecutive stalls detected
|
||||
|
||||
@@ -128,7 +130,14 @@ type WatchPartyManager struct {
|
||||
lastRxSequence uint64 // Latest received sequence number
|
||||
|
||||
// Peer
|
||||
peerPlaybackListener *WatchPartyPlaybackSubscriber // Listener for playback status changes (can be nil)
|
||||
peerPlaybackListener *WatchPartyPlaybackSubscriber // Listener for player status changes (can be nil)
|
||||
}
|
||||
|
||||
type watchPartyPlaybackController interface {
|
||||
PullStatus() (*WatchPartyPlaybackStatus, bool)
|
||||
Pause()
|
||||
Resume()
|
||||
SeekTo(float64)
|
||||
}
|
||||
|
||||
type WatchPartySession struct {
|
||||
@@ -283,6 +292,16 @@ func NewWatchPartyManager(manager *Manager) *WatchPartyManager {
|
||||
}
|
||||
}
|
||||
|
||||
func (wpm *WatchPartyManager) player() watchPartyPlaybackController {
|
||||
if wpm.playbackController != nil {
|
||||
return wpm.playbackController
|
||||
}
|
||||
if wpm.manager == nil {
|
||||
return nil
|
||||
}
|
||||
return wpm.manager.genericPlayer
|
||||
}
|
||||
|
||||
// Cleanup stops all goroutines and cleans up resources to prevent memory leaks
|
||||
func (wpm *WatchPartyManager) Cleanup() {
|
||||
wpm.mu.Lock()
|
||||
|
||||
@@ -300,8 +300,8 @@ func (wpm *WatchPartyManager) hostPlaybackHandleStatus(opts hostPlaybackHandleSt
|
||||
}
|
||||
}
|
||||
|
||||
// listenToPlaybackManager listens to playback events from the host.
|
||||
// It handles starting a new watch party session and sending playback status updates to peers.
|
||||
// listenToPlaybackManager listens to player events from the host.
|
||||
// It handles starting a new watch party session and sending player status updates to peers.
|
||||
func (wpm *WatchPartyManager) listenToPlaybackAsHost() {
|
||||
id := "nakama:watch-party:host"
|
||||
playbackSubscriber := wpm.manager.playbackManager.SubscribeToPlaybackStatus(id)
|
||||
@@ -627,7 +627,7 @@ func (wpm *WatchPartyManager) handleWatchPartyBufferUpdateEvent(payload *WatchPa
|
||||
wpm.sendSessionStateToClient()
|
||||
}
|
||||
|
||||
// checkAndManageBuffering manages playback based on peer buffering states
|
||||
// checkAndManageBuffering manages player based on peer buffering states
|
||||
// NOTE: This function should NOT be called while holding wpm.mu as it may need to acquire bufferMu
|
||||
func (wpm *WatchPartyManager) checkAndManageBuffering() {
|
||||
session, ok := wpm.currentSession.Get()
|
||||
@@ -635,8 +635,13 @@ func (wpm *WatchPartyManager) checkAndManageBuffering() {
|
||||
return
|
||||
}
|
||||
|
||||
player := wpm.player()
|
||||
if player == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get current playback status
|
||||
playbackStatus, hasPlayback := wpm.manager.genericPlayer.PullStatus()
|
||||
playbackStatus, hasPlayback := player.PullStatus()
|
||||
if !hasPlayback {
|
||||
return
|
||||
}
|
||||
@@ -673,7 +678,7 @@ func (wpm *WatchPartyManager) checkAndManageBuffering() {
|
||||
Int("totalPeers", totalPeers).
|
||||
Msg("nakama: Pausing playback due to peer buffering")
|
||||
|
||||
wpm.manager.genericPlayer.Pause()
|
||||
player.Pause()
|
||||
wpm.isWaitingForBuffers = true
|
||||
wpm.bufferWaitStart = time.Now()
|
||||
}
|
||||
@@ -694,13 +699,13 @@ func (wpm *WatchPartyManager) checkAndManageBuffering() {
|
||||
Bool("maxWaitExceeded", waitTime > maxWaitTime).
|
||||
Msg("nakama: Resuming playback after buffer wait")
|
||||
|
||||
wpm.manager.genericPlayer.Resume()
|
||||
player.Resume()
|
||||
wpm.isWaitingForBuffers = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForPeersReady waits for peers to be ready before resuming playback
|
||||
// waitForPeersReady waits for peers to be ready before resuming player
|
||||
func (wpm *WatchPartyManager) waitForPeersReady(onReady func()) {
|
||||
session, ok := wpm.currentSession.Get()
|
||||
if !ok {
|
||||
@@ -916,7 +921,7 @@ func (wpm *WatchPartyManager) handleWatchPartyRelayModeOriginStreamStartedEvent(
|
||||
|
||||
}
|
||||
|
||||
// handleWatchPartyRelayModeOriginPlaybackStatusEvent is called when the relay origin sends us (the host) a playback status update
|
||||
// handleWatchPartyRelayModeOriginPlaybackStatusEvent is called when the relay origin sends us (the host) a player status update
|
||||
func (wpm *WatchPartyManager) handleWatchPartyRelayModeOriginPlaybackStatusEvent(payload *WatchPartyRelayModeOriginPlaybackStatusPayload) {
|
||||
wpm.mu.Lock()
|
||||
defer wpm.mu.Unlock()
|
||||
@@ -938,7 +943,7 @@ func (wpm *WatchPartyManager) handleWatchPartyRelayModeOriginPlaybackStatusEvent
|
||||
})
|
||||
}
|
||||
|
||||
// handleWatchPartyRelayModeOriginPlaybackStoppedEvent is called when the relay origin sends us (the host) a playback stopped event
|
||||
// handleWatchPartyRelayModeOriginPlaybackStoppedEvent is called when the relay origin sends us (the host) a player stopped event
|
||||
func (wpm *WatchPartyManager) handleWatchPartyRelayModeOriginPlaybackStoppedEvent() {
|
||||
wpm.mu.Lock()
|
||||
defer wpm.mu.Unlock()
|
||||
|
||||
@@ -131,7 +131,7 @@ func (wpm *WatchPartyManager) stopStatusReporting() {
|
||||
}
|
||||
}
|
||||
|
||||
// sendStatusToHost sends current playback status and buffer state to the host
|
||||
// sendStatusToHost sends current player status and buffer state to the host
|
||||
func (wpm *WatchPartyManager) sendStatusToHost(peerId string) {
|
||||
playbackStatus, hasPlayback := wpm.manager.genericPlayer.PullStatus()
|
||||
if !hasPlayback {
|
||||
@@ -152,7 +152,7 @@ func (wpm *WatchPartyManager) sendStatusToHost(peerId string) {
|
||||
})
|
||||
}
|
||||
|
||||
// calculateBufferState calculates buffering state and buffer health from playback status
|
||||
// calculateBufferState calculates buffering state and buffer health from player status
|
||||
func (wpm *WatchPartyManager) calculateBufferState(status *WatchPartyPlaybackStatus) (bool, float64) {
|
||||
if status == nil {
|
||||
return true, 0.0 // No status means we're probably buffering
|
||||
@@ -249,7 +249,7 @@ func (wpm *WatchPartyManager) calculateBufferState(status *WatchPartyPlaybackSta
|
||||
}
|
||||
}
|
||||
|
||||
// resetBufferingState resets the buffering detection state (useful when playback changes)
|
||||
// resetBufferingState resets the buffering detection state (useful when player changes)
|
||||
func (wpm *WatchPartyManager) resetBufferingState() {
|
||||
wpm.bufferDetectionMu.Lock()
|
||||
defer wpm.bufferDetectionMu.Unlock()
|
||||
@@ -548,7 +548,7 @@ func (wpm *WatchPartyManager) handleWatchPartyCreatedEvent(payload *WatchPartyCr
|
||||
// handleWatchPartyStoppedEvent is called when the host stops a watch party.
|
||||
//
|
||||
// We check if the user was a participant in an active watch party session.
|
||||
// If yes, we will cancel playback.
|
||||
// If yes, we will cancel player.
|
||||
func (wpm *WatchPartyManager) handleWatchPartyStoppedEvent() {
|
||||
if wpm.manager.IsHost() {
|
||||
return
|
||||
@@ -591,7 +591,7 @@ func (wpm *WatchPartyManager) handleWatchPartyStoppedEvent() {
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// relayModeListenToPlayerAsOrigin starts listening to players when in relay mode.
|
||||
// If the user is the relay origin, we listen to playback started events to send it to the relay host.
|
||||
// If the user is the relay origin, we listen to player started events to send it to the relay host.
|
||||
func (wpm *WatchPartyManager) relayModeListenToPlayerAsOrigin() {
|
||||
go func() {
|
||||
id := "nakama:relay-origin"
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
|
||||
const debugSyncing = false
|
||||
|
||||
// handleWatchPartyPlaybackStatusEvent is called when the host sends a playback status.
|
||||
// handleWatchPartyPlaybackStatusEvent is called when the host sends a player status.
|
||||
//
|
||||
// We check if the peer is a participant in the session.
|
||||
// If yes, we will update the playback status and sync the playback position.
|
||||
// If yes, we will update the player status and sync the player position.
|
||||
func (wpm *WatchPartyManager) handleWatchPartyPlaybackStatusEvent(payload *WatchPartyPlaybackStatusPayload) {
|
||||
if wpm.manager.IsHost() {
|
||||
return
|
||||
@@ -54,8 +54,13 @@ func (wpm *WatchPartyManager) handleWatchPartyPlaybackStatusEvent(payload *Watch
|
||||
return
|
||||
}
|
||||
|
||||
player := wpm.player()
|
||||
if player == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// If the playback manager doesn't have a status, do nothing
|
||||
playbackStatus, ok := wpm.manager.genericPlayer.PullStatus()
|
||||
playbackStatus, ok := player.PullStatus()
|
||||
if !ok {
|
||||
if debugSyncing {
|
||||
wpm.logger.Error().Msg("nakama: Cannot sync, no status")
|
||||
@@ -134,7 +139,7 @@ func (wpm *WatchPartyManager) handleWatchPartyPlaybackStatusEvent(payload *Watch
|
||||
wpm.pendingSeekPosition = seekPosition
|
||||
wpm.seekMu.Unlock()
|
||||
|
||||
wpm.manager.genericPlayer.SeekTo(seekPosition)
|
||||
player.SeekTo(seekPosition)
|
||||
} else if positionDrift > 0 && positionDrift <= ResumeAheadTolerance {
|
||||
wpm.logger.Debug().
|
||||
Float64("positionDrift", positionDrift).
|
||||
@@ -144,49 +149,25 @@ func (wpm *WatchPartyManager) handleWatchPartyPlaybackStatusEvent(payload *Watch
|
||||
}
|
||||
|
||||
wpm.logger.Debug().Msg("nakama: Host resumed, resuming peer playback")
|
||||
wpm.manager.genericPlayer.Resume()
|
||||
player.Resume()
|
||||
} else {
|
||||
wpm.logger.Debug().Msg("nakama: Host paused, handling peer pause")
|
||||
wpm.handleHostPause(payloadStatus, playbackStatus, timeSinceMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle position sync for different state combinations
|
||||
if payloadStatus.Paused == playbackStatus.Paused {
|
||||
// Both in same state, use normal sync
|
||||
wpm.syncPlaybackPosition(payloadStatus, playbackStatus, timeSinceMessage, session)
|
||||
} else if !payloadStatus.Paused && playbackStatus.Paused {
|
||||
// Host playing, peer paused, sync position and resume
|
||||
hostExpectedPosition := payloadStatus.CurrentTime + timeSinceMessage
|
||||
|
||||
wpm.logger.Debug().
|
||||
Float64("hostPosition", hostExpectedPosition).
|
||||
Float64("peerPosition", playbackStatus.CurrentTime).
|
||||
Msg("nakama: Host is playing but peer is paused, syncing and resuming")
|
||||
|
||||
// Resume and sync to host position
|
||||
wpm.manager.genericPlayer.Resume()
|
||||
|
||||
// Track pending seek
|
||||
now := time.Now()
|
||||
wpm.seekMu.Lock()
|
||||
wpm.pendingSeekTime = now
|
||||
wpm.pendingSeekPosition = hostExpectedPosition
|
||||
wpm.seekMu.Unlock()
|
||||
|
||||
wpm.manager.genericPlayer.SeekTo(hostExpectedPosition)
|
||||
} else if payloadStatus.Paused && !playbackStatus.Paused {
|
||||
// Host paused, peer playing, pause immediately
|
||||
wpm.logger.Debug().Msg("nakama: Host is paused but peer is playing, pausing immediately")
|
||||
|
||||
// Cancel catch-up and pause
|
||||
wpm.cancelCatchUp()
|
||||
wpm.handleHostPause(payloadStatus, playbackStatus, timeSinceMessage)
|
||||
}
|
||||
// Both in same state, use normal sync.
|
||||
wpm.syncPlaybackPosition(payloadStatus, playbackStatus, timeSinceMessage, session)
|
||||
}
|
||||
|
||||
// handleHostPause handles when the host pauses playback
|
||||
// handleHostPause handles when the host pauses player
|
||||
func (wpm *WatchPartyManager) handleHostPause(hostStatus *WatchPartyPlaybackStatus, peerStatus *WatchPartyPlaybackStatus, timeSinceMessage float64) {
|
||||
player := wpm.player()
|
||||
if player == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel any ongoing catch-up operation
|
||||
wpm.cancelCatchUp()
|
||||
|
||||
@@ -220,15 +201,20 @@ func (wpm *WatchPartyManager) handleHostPause(hostStatus *WatchPartyPlaybackStat
|
||||
wpm.pendingSeekPosition = hostActualPausePosition
|
||||
wpm.seekMu.Unlock()
|
||||
|
||||
wpm.manager.genericPlayer.SeekTo(hostActualPausePosition)
|
||||
player.SeekTo(hostActualPausePosition)
|
||||
}
|
||||
wpm.manager.genericPlayer.Pause()
|
||||
player.Pause()
|
||||
wpm.logger.Debug().Msgf("nakama: Host paused, peer paused immediately (diff: %.2f)", timeDifference)
|
||||
}
|
||||
}
|
||||
|
||||
// startCatchUp starts a catch-up operation to sync with the host's pause position
|
||||
func (wpm *WatchPartyManager) startCatchUp(hostPausePosition float64, timeSinceMessage float64) {
|
||||
player := wpm.player()
|
||||
if player == nil {
|
||||
return
|
||||
}
|
||||
|
||||
wpm.catchUpMu.Lock()
|
||||
defer wpm.catchUpMu.Unlock()
|
||||
|
||||
@@ -266,13 +252,13 @@ func (wpm *WatchPartyManager) startCatchUp(hostPausePosition float64, timeSinceM
|
||||
wpm.pendingSeekPosition = hostPausePosition
|
||||
wpm.seekMu.Unlock()
|
||||
|
||||
wpm.manager.genericPlayer.SeekTo(hostPausePosition)
|
||||
wpm.manager.genericPlayer.Pause()
|
||||
player.SeekTo(hostPausePosition)
|
||||
player.Pause()
|
||||
return
|
||||
}
|
||||
|
||||
// Get current playback status
|
||||
currentStatus, ok := wpm.manager.genericPlayer.PullStatus()
|
||||
currentStatus, ok := player.PullStatus()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -289,8 +275,8 @@ func (wpm *WatchPartyManager) startCatchUp(hostPausePosition float64, timeSinceM
|
||||
wpm.pendingSeekPosition = hostPausePosition
|
||||
wpm.seekMu.Unlock()
|
||||
|
||||
wpm.manager.genericPlayer.SeekTo(hostPausePosition)
|
||||
wpm.manager.genericPlayer.Pause()
|
||||
player.SeekTo(hostPausePosition)
|
||||
player.Pause()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -317,9 +303,13 @@ func (wpm *WatchPartyManager) cancelCatchUp() {
|
||||
}
|
||||
}
|
||||
|
||||
// syncPlaybackPosition synchronizes playback position when both host and peer are in the same play/pause state
|
||||
// syncPlaybackPosition synchronizes player position when both host and peer are in the same play/pause state
|
||||
func (wpm *WatchPartyManager) syncPlaybackPosition(hostStatus *WatchPartyPlaybackStatus, peerStatus *WatchPartyPlaybackStatus, timeSinceMessage float64, session *WatchPartySession) {
|
||||
now := time.Now()
|
||||
player := wpm.player()
|
||||
if player == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore very old messages to prevent stale syncing
|
||||
if timeSinceMessage > MaxMessageAge {
|
||||
@@ -422,7 +412,7 @@ func (wpm *WatchPartyManager) syncPlaybackPosition(hostStatus *WatchPartyPlaybac
|
||||
wpm.pendingSeekPosition = seekPosition
|
||||
wpm.seekMu.Unlock()
|
||||
|
||||
wpm.manager.genericPlayer.SeekTo(seekPosition)
|
||||
player.SeekTo(seekPosition)
|
||||
wpm.lastSeekTime = now
|
||||
}
|
||||
}
|
||||
|
||||
336
internal/nakama/watch_party_syncing_test.go
Normal file
336
internal/nakama/watch_party_syncing_test.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package nakama
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"seanime/internal/database/models"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/util"
|
||||
|
||||
"github.com/samber/mo"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWatchPartyPlaybackStatusIgnoresStaleSequence(t *testing.T) {
|
||||
// old messages should not trigger any sync work once we've seen a newer sequence.
|
||||
h := newWatchPartySyncHarness(false)
|
||||
h.setPeerSession(1.2)
|
||||
h.player.setStatus(&WatchPartyPlaybackStatus{Paused: false, CurrentTime: 12, Duration: 100})
|
||||
h.wpm.lastRxSequence = 5
|
||||
|
||||
h.wpm.handleWatchPartyPlaybackStatusEvent(&WatchPartyPlaybackStatusPayload{
|
||||
PlaybackStatus: &WatchPartyPlaybackStatus{Paused: false, CurrentTime: 18, Duration: 100},
|
||||
Timestamp: time.Now().UnixNano(),
|
||||
SequenceNumber: 4,
|
||||
})
|
||||
|
||||
require.Equal(t, 5, int(h.wpm.lastRxSequence))
|
||||
require.Zero(t, h.player.pauseCount())
|
||||
require.Zero(t, h.player.resumeCount())
|
||||
require.Empty(t, h.player.seekHistory())
|
||||
}
|
||||
|
||||
func TestWatchPartyPlaybackStatusResumesAndSeeksOnce(t *testing.T) {
|
||||
// host resume should only do one resume path and one corrective seek.
|
||||
h := newWatchPartySyncHarness(false)
|
||||
h.setPeerSession(1.2)
|
||||
h.player.setStatus(&WatchPartyPlaybackStatus{Paused: true, CurrentTime: 10, Duration: 100})
|
||||
|
||||
h.wpm.handleWatchPartyPlaybackStatusEvent(&WatchPartyPlaybackStatusPayload{
|
||||
PlaybackStatus: &WatchPartyPlaybackStatus{Paused: false, CurrentTime: 14, Duration: 100},
|
||||
Timestamp: time.Now().Add(-300 * time.Millisecond).UnixNano(),
|
||||
SequenceNumber: 1,
|
||||
})
|
||||
|
||||
seeks := h.player.seekHistory()
|
||||
require.Equal(t, 1, h.player.resumeCount())
|
||||
require.Zero(t, h.player.pauseCount())
|
||||
require.Len(t, seeks, 1)
|
||||
require.InDelta(t, 14.6, seeks[0], 0.25)
|
||||
require.Equal(t, uint64(1), h.wpm.lastRxSequence)
|
||||
}
|
||||
|
||||
func TestWatchPartySyncPlaybackPositionSkipsWhilePendingSeekIsFresh(t *testing.T) {
|
||||
// a recent local seek should suppress another correction until the first one settles.
|
||||
h := newWatchPartySyncHarness(false)
|
||||
session := h.setPeerSession(1.0)
|
||||
h.player.setStatus(&WatchPartyPlaybackStatus{Paused: false, CurrentTime: 16, Duration: 100})
|
||||
h.wpm.pendingSeekTime = time.Now().Add(-100 * time.Millisecond)
|
||||
h.wpm.pendingSeekPosition = 18
|
||||
|
||||
h.wpm.syncPlaybackPosition(
|
||||
&WatchPartyPlaybackStatus{Paused: false, CurrentTime: 20, Duration: 100},
|
||||
&WatchPartyPlaybackStatus{Paused: false, CurrentTime: 16, Duration: 100},
|
||||
0.1,
|
||||
session,
|
||||
)
|
||||
|
||||
require.Empty(t, h.player.seekHistory())
|
||||
require.False(t, h.wpm.pendingSeekTime.IsZero())
|
||||
}
|
||||
|
||||
func TestWatchPartyPlaybackStatusPauseStartsCatchUp(t *testing.T) {
|
||||
// when the host pauses far ahead of the peer, we should catch up before pausing.
|
||||
h := newWatchPartySyncHarness(false)
|
||||
h.setPeerSession(1.0)
|
||||
h.player.setPullSequence(
|
||||
&WatchPartyPlaybackStatus{Paused: false, CurrentTime: 7.5, Duration: 100},
|
||||
&WatchPartyPlaybackStatus{Paused: false, CurrentTime: 9.7, Duration: 100},
|
||||
)
|
||||
|
||||
h.wpm.handleWatchPartyPlaybackStatusEvent(&WatchPartyPlaybackStatusPayload{
|
||||
PlaybackStatus: &WatchPartyPlaybackStatus{Paused: true, CurrentTime: 10, Duration: 100},
|
||||
Timestamp: time.Now().UnixNano(),
|
||||
SequenceNumber: 1,
|
||||
})
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return h.player.pauseCount() == 1 && len(h.player.seekHistory()) == 1
|
||||
}, time.Second, 25*time.Millisecond)
|
||||
require.Zero(t, h.player.resumeCount())
|
||||
require.InDelta(t, 10.0, h.player.seekHistory()[0], 0.01)
|
||||
}
|
||||
|
||||
func TestWatchPartyCheckAndManageBufferingPausesAndResumes(t *testing.T) {
|
||||
// host playback should pause for buffering peers and resume once everyone is ready.
|
||||
h := newWatchPartySyncHarness(true)
|
||||
peer := &WatchPartySessionParticipant{ID: "peer-1", Username: "peer", IsReady: false, IsBuffering: true}
|
||||
h.setHostSession(peer)
|
||||
h.player.setStatus(&WatchPartyPlaybackStatus{Paused: false, CurrentTime: 20, Duration: 100})
|
||||
|
||||
h.wpm.checkAndManageBuffering()
|
||||
|
||||
require.Equal(t, 1, h.player.pauseCount())
|
||||
require.True(t, h.wpm.isWaitingForBuffers)
|
||||
|
||||
peer.IsBuffering = false
|
||||
peer.IsReady = true
|
||||
h.player.setStatus(&WatchPartyPlaybackStatus{Paused: true, CurrentTime: 20, Duration: 100})
|
||||
h.wpm.bufferWaitStart = time.Now().Add(-150 * time.Millisecond)
|
||||
|
||||
h.wpm.checkAndManageBuffering()
|
||||
|
||||
require.Equal(t, 1, h.player.resumeCount())
|
||||
require.False(t, h.wpm.isWaitingForBuffers)
|
||||
}
|
||||
|
||||
func TestWatchPartyCalculateBufferStateDetectsStallsAndSeeks(t *testing.T) {
|
||||
// stall detection should take two bad samples, then reset once playback jumps like a seek.
|
||||
h := newWatchPartySyncHarness(false)
|
||||
|
||||
isBuffering, health := h.wpm.calculateBufferState(&WatchPartyPlaybackStatus{Paused: false, CurrentTime: 10, Duration: 100})
|
||||
require.False(t, isBuffering)
|
||||
require.InDelta(t, 1.0, health, 0.01)
|
||||
|
||||
h.wpm.lastPosition = 10
|
||||
h.wpm.lastPositionTime = time.Now().Add(-2 * time.Second)
|
||||
h.wpm.stallCount = 0
|
||||
isBuffering, health = h.wpm.calculateBufferState(&WatchPartyPlaybackStatus{Paused: false, CurrentTime: 10.1, Duration: 100})
|
||||
require.False(t, isBuffering)
|
||||
require.InDelta(t, 0.85, health, 0.01)
|
||||
require.Equal(t, 1, h.wpm.stallCount)
|
||||
|
||||
h.wpm.lastPosition = 10.1
|
||||
h.wpm.lastPositionTime = time.Now().Add(-2 * time.Second)
|
||||
h.wpm.stallCount = 1
|
||||
isBuffering, health = h.wpm.calculateBufferState(&WatchPartyPlaybackStatus{Paused: false, CurrentTime: 10.2, Duration: 100})
|
||||
require.True(t, isBuffering)
|
||||
require.InDelta(t, 0.7, health, 0.01)
|
||||
require.Equal(t, 2, h.wpm.stallCount)
|
||||
|
||||
h.wpm.lastPosition = 10.2
|
||||
h.wpm.lastPositionTime = time.Now().Add(-2 * time.Second)
|
||||
h.wpm.stallCount = 2
|
||||
isBuffering, health = h.wpm.calculateBufferState(&WatchPartyPlaybackStatus{Paused: false, CurrentTime: 14.6, Duration: 100})
|
||||
require.False(t, isBuffering)
|
||||
require.InDelta(t, 1.0, health, 0.01)
|
||||
require.Zero(t, h.wpm.stallCount)
|
||||
}
|
||||
|
||||
type watchPartySyncHarness struct {
|
||||
wpm *WatchPartyManager
|
||||
player *fakeWatchPartyPlayer
|
||||
}
|
||||
|
||||
func newWatchPartySyncHarness(isHost bool) *watchPartySyncHarness {
|
||||
logger := util.NewLogger()
|
||||
manager := &Manager{
|
||||
logger: logger,
|
||||
settings: &models.NakamaSettings{IsHost: isHost},
|
||||
wsEventManager: events.NewMockWSEventManager(logger),
|
||||
hostConnection: &HostConnection{PeerId: "peer-1", Authenticated: true},
|
||||
isOfflineRef: util.NewRef(false),
|
||||
}
|
||||
|
||||
wpm := NewWatchPartyManager(manager)
|
||||
player := &fakeWatchPartyPlayer{}
|
||||
wpm.playbackController = player
|
||||
|
||||
return &watchPartySyncHarness{
|
||||
wpm: wpm,
|
||||
player: player,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *watchPartySyncHarness) setPeerSession(syncThreshold float64) *WatchPartySession {
|
||||
session := &WatchPartySession{
|
||||
ID: "session-1",
|
||||
Participants: map[string]*WatchPartySessionParticipant{
|
||||
"peer-1": {
|
||||
ID: "peer-1",
|
||||
Username: "host",
|
||||
IsHost: true,
|
||||
},
|
||||
},
|
||||
Settings: &WatchPartySessionSettings{
|
||||
SyncThreshold: syncThreshold,
|
||||
MaxBufferWaitTime: 2,
|
||||
},
|
||||
CurrentMediaInfo: &WatchPartySessionMediaInfo{MediaId: 1, EpisodeNumber: 1, StreamType: WatchPartyStreamTypeFile},
|
||||
}
|
||||
|
||||
h.wpm.currentSession = mo.Some(session)
|
||||
return session
|
||||
}
|
||||
|
||||
func (h *watchPartySyncHarness) setHostSession(peer *WatchPartySessionParticipant) *WatchPartySession {
|
||||
session := &WatchPartySession{
|
||||
ID: "session-1",
|
||||
Participants: map[string]*WatchPartySessionParticipant{
|
||||
"host": {
|
||||
ID: "host",
|
||||
Username: "host",
|
||||
IsHost: true,
|
||||
IsReady: true,
|
||||
},
|
||||
peer.ID: peer,
|
||||
},
|
||||
Settings: &WatchPartySessionSettings{
|
||||
SyncThreshold: 1.0,
|
||||
MaxBufferWaitTime: 2,
|
||||
},
|
||||
CurrentMediaInfo: &WatchPartySessionMediaInfo{MediaId: 1, EpisodeNumber: 1, StreamType: WatchPartyStreamTypeFile},
|
||||
}
|
||||
|
||||
h.wpm.currentSession = mo.Some(session)
|
||||
return session
|
||||
}
|
||||
|
||||
type fakeWatchPartyPlayer struct {
|
||||
mu sync.Mutex
|
||||
status *WatchPartyPlaybackStatus
|
||||
sequence []*WatchPartyPlaybackStatus
|
||||
pullIndex int
|
||||
pauses int
|
||||
resumes int
|
||||
seeks []float64
|
||||
}
|
||||
|
||||
func (p *fakeWatchPartyPlayer) setStatus(status *WatchPartyPlaybackStatus) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.status = clonePlaybackStatus(status)
|
||||
p.sequence = nil
|
||||
p.pullIndex = 0
|
||||
}
|
||||
|
||||
func (p *fakeWatchPartyPlayer) setPullSequence(statuses ...*WatchPartyPlaybackStatus) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.sequence = make([]*WatchPartyPlaybackStatus, 0, len(statuses))
|
||||
for _, status := range statuses {
|
||||
p.sequence = append(p.sequence, clonePlaybackStatus(status))
|
||||
}
|
||||
p.pullIndex = 0
|
||||
if len(p.sequence) > 0 {
|
||||
p.status = clonePlaybackStatus(p.sequence[0])
|
||||
}
|
||||
}
|
||||
|
||||
func (p *fakeWatchPartyPlayer) PullStatus() (*WatchPartyPlaybackStatus, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if len(p.sequence) > 0 {
|
||||
idx := p.pullIndex
|
||||
if idx >= len(p.sequence) {
|
||||
idx = len(p.sequence) - 1
|
||||
}
|
||||
status := clonePlaybackStatus(p.sequence[idx])
|
||||
p.status = clonePlaybackStatus(status)
|
||||
if p.pullIndex < len(p.sequence)-1 {
|
||||
p.pullIndex++
|
||||
}
|
||||
return status, true
|
||||
}
|
||||
|
||||
if p.status == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return clonePlaybackStatus(p.status), true
|
||||
}
|
||||
|
||||
func (p *fakeWatchPartyPlayer) Pause() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.pauses++
|
||||
if p.status != nil {
|
||||
p.status.Paused = true
|
||||
}
|
||||
}
|
||||
|
||||
func (p *fakeWatchPartyPlayer) Resume() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.resumes++
|
||||
if p.status != nil {
|
||||
p.status.Paused = false
|
||||
}
|
||||
}
|
||||
|
||||
func (p *fakeWatchPartyPlayer) SeekTo(seconds float64) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.seeks = append(p.seeks, seconds)
|
||||
if p.status != nil {
|
||||
p.status.CurrentTime = seconds
|
||||
}
|
||||
}
|
||||
|
||||
func (p *fakeWatchPartyPlayer) pauseCount() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.pauses
|
||||
}
|
||||
|
||||
func (p *fakeWatchPartyPlayer) resumeCount() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.resumes
|
||||
}
|
||||
|
||||
func (p *fakeWatchPartyPlayer) seekHistory() []float64 {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
ret := make([]float64, len(p.seeks))
|
||||
copy(ret, p.seeks)
|
||||
return ret
|
||||
}
|
||||
|
||||
func clonePlaybackStatus(status *WatchPartyPlaybackStatus) *WatchPartyPlaybackStatus {
|
||||
if status == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *status
|
||||
return &cloned
|
||||
}
|
||||
@@ -305,14 +305,17 @@ func (m *Manager) startPlaylist(playlist *anime.Playlist, options *startPlaylist
|
||||
m.nativePlayer.VideoCore().Unsubscribe("playlist-manager")
|
||||
return
|
||||
case event := <-playbackManagerSubscriber.EventCh:
|
||||
if m.playerType.Load() != SystemPlayer {
|
||||
continue
|
||||
}
|
||||
switch e := event.(type) {
|
||||
case playbackmanager.VideoCompletedEvent, playbackmanager.StreamCompletedEvent:
|
||||
if m.playerType.Load() != SystemPlayer {
|
||||
continue
|
||||
}
|
||||
m.state.Store(StateCompleted)
|
||||
|
||||
case playbackmanager.PlaybackErrorEvent, playbackmanager.VideoStoppedEvent, playbackmanager.StreamStoppedEvent:
|
||||
if m.playerType.Load() != SystemPlayer {
|
||||
continue
|
||||
}
|
||||
if m.state.Load() == StateStarted {
|
||||
m.StopPlaylist("Playlist stopped")
|
||||
|
||||
@@ -365,9 +368,6 @@ func (m *Manager) startPlaylist(playlist *anime.Playlist, options *startPlaylist
|
||||
}
|
||||
}
|
||||
case event := <-videoCoreSubscriber.Events():
|
||||
if m.playerType.Load() != NativePlayer {
|
||||
continue
|
||||
}
|
||||
if !event.IsNativePlayer() {
|
||||
continue
|
||||
}
|
||||
@@ -377,10 +377,16 @@ func (m *Manager) startPlaylist(playlist *anime.Playlist, options *startPlaylist
|
||||
m.playerType.Store(NativePlayer)
|
||||
|
||||
case *videocore.VideoCompletedEvent:
|
||||
if m.playerType.Load() != NativePlayer {
|
||||
continue
|
||||
}
|
||||
m.markCurrentAsCompleted()
|
||||
m.state.Store(StateCompleted)
|
||||
|
||||
case *videocore.VideoEndedEvent:
|
||||
if m.playerType.Load() != NativePlayer {
|
||||
continue
|
||||
}
|
||||
if m.state.Load() == StateCompleted {
|
||||
m.markCurrentAsCompleted()
|
||||
m.playNextEpisode()
|
||||
@@ -388,6 +394,9 @@ func (m *Manager) startPlaylist(playlist *anime.Playlist, options *startPlaylist
|
||||
m.state.Store(StateIdle)
|
||||
|
||||
case *videocore.VideoTerminatedEvent:
|
||||
if m.playerType.Load() != NativePlayer {
|
||||
continue
|
||||
}
|
||||
if m.state.Load() == StateStarted || m.state.Load() == StateCompleted {
|
||||
m.StopPlaylist("Playlist stopped")
|
||||
}
|
||||
@@ -480,6 +489,7 @@ func (m *Manager) markCurrentAsCompleted() {
|
||||
|
||||
data, ok := m.currentPlaylistData.Get()
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -672,10 +682,10 @@ func (m *Manager) StopPlaylist(reason string, isError ...bool) {
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
data, ok := m.currentPlaylistData.Get()
|
||||
// Delete playlist if all episodes are completed
|
||||
go func() {
|
||||
data, ok := m.currentPlaylistData.Get()
|
||||
if !ok {
|
||||
go func(data *playlistData, ok bool) {
|
||||
if !ok || data == nil {
|
||||
return
|
||||
}
|
||||
d := *data
|
||||
@@ -692,7 +702,7 @@ func (m *Manager) StopPlaylist(reason string, isError ...bool) {
|
||||
_ = db_bridge.DeletePlaylist(m.db, d.playlist.DbId)
|
||||
m.wsEventManager.SendEventTo(m.clientId, events.InvalidateQueries, []string{events.GetPlaylistsEndpoint})
|
||||
}
|
||||
}()
|
||||
}(data, ok)
|
||||
m.isStartingPlaylist.Store(false)
|
||||
m.resetPlaylist()
|
||||
if len(isError) > 0 && isError[0] {
|
||||
@@ -706,7 +716,6 @@ func (m *Manager) StopPlaylist(reason string, isError ...bool) {
|
||||
// isEpisodeCompleted is true if the current episode is completed (used for manual tracking)
|
||||
func (m *Manager) PlayEpisode(which string, isCurrentCompleted bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.logger.Debug().Str("which", which).Bool("isCurrentCompleted", isCurrentCompleted).Msg("playlist: Episode requested")
|
||||
|
||||
@@ -721,6 +730,7 @@ func (m *Manager) PlayEpisode(which string, isCurrentCompleted bool) {
|
||||
|
||||
currentEpisode, ok := m.currentEpisode.Get()
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
if which == "next" {
|
||||
m.logger.Debug().Msg("playlist: No episodes in playlist, playing next episode")
|
||||
m.playNextEpisode()
|
||||
@@ -739,12 +749,14 @@ func (m *Manager) PlayEpisode(which string, isCurrentCompleted bool) {
|
||||
|
||||
if episode == nil {
|
||||
m.logger.Error().Msgf("playlist: Episode not found for '%s'", which)
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
m.logger.Debug().Str("which", which).Int("mediaId", episode.Episode.BaseAnime.ID).Str("aniDBEpisode", episode.Episode.AniDBEpisode).Str("episode", episode.Episode.DisplayTitle).Msg("playlist: Episode found")
|
||||
|
||||
m.playEpisode(episode)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) ReopenEpisode() {
|
||||
|
||||
605
internal/playlist/manager_test.go
Normal file
605
internal/playlist/manager_test.go
Normal file
@@ -0,0 +1,605 @@
|
||||
package playlist
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"seanime/internal/api/metadata_provider"
|
||||
"seanime/internal/continuity"
|
||||
"seanime/internal/database/db"
|
||||
"seanime/internal/database/db_bridge"
|
||||
"seanime/internal/database/models"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/library/playbackmanager"
|
||||
"seanime/internal/mediaplayers/mediaplayer"
|
||||
"seanime/internal/nativeplayer"
|
||||
"seanime/internal/platforms/platform"
|
||||
"seanime/internal/testmocks"
|
||||
"seanime/internal/testutil"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/videocore"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/samber/mo"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPlaylistManagerSendCurrentPlaylistToClient(t *testing.T) {
|
||||
t.Run("no playlist sends nil playlist", func(t *testing.T) {
|
||||
// this keeps the UI bootstrap path honest when nothing is playing yet.
|
||||
h := newPlaylistTestHarness(t)
|
||||
h.manager.clientId = "web"
|
||||
|
||||
h.manager.sendCurrentPlaylistToClient()
|
||||
|
||||
event := h.wsEventManager.lastPlaylistServerEvent(t, ServerEventCurrentPlaylist)
|
||||
payload := decodeCurrentPlaylistPayload(t, event)
|
||||
require.Nil(t, payload.Playlist)
|
||||
require.Nil(t, payload.PlaylistEpisode)
|
||||
})
|
||||
|
||||
t.Run("active playlist sends playlist and episode", func(t *testing.T) {
|
||||
// once a playlist is active, the client should receive both the queue and the selected episode.
|
||||
h := newPlaylistTestHarness(t)
|
||||
h.manager.clientId = "web"
|
||||
episodeOne := newStreamPlaylistEpisode(101, 1, "1")
|
||||
playlist := newPlaylistFixture("queue", episodeOne)
|
||||
h.manager.currentPlaylistData = mo.Some(&playlistData{
|
||||
playlist: playlist,
|
||||
options: newClientPlaylistOptions("web"),
|
||||
})
|
||||
h.manager.currentEpisode = mo.Some(episodeOne)
|
||||
|
||||
h.manager.sendCurrentPlaylistToClient()
|
||||
|
||||
event := h.wsEventManager.lastPlaylistServerEvent(t, ServerEventCurrentPlaylist)
|
||||
payload := decodeCurrentPlaylistPayload(t, event)
|
||||
require.NotNil(t, payload.Playlist)
|
||||
require.Equal(t, playlist.Name, payload.Playlist.Name)
|
||||
require.Len(t, payload.Playlist.Episodes, 1)
|
||||
require.NotNil(t, payload.PlaylistEpisode)
|
||||
require.Equal(t, episodeOne.Episode.BaseAnime.ID, payload.PlaylistEpisode.Episode.BaseAnime.ID)
|
||||
require.Equal(t, episodeOne.Episode.AniDBEpisode, payload.PlaylistEpisode.Episode.AniDBEpisode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPlaylistManagerPlayEpisodeNextWithoutCurrentEpisode(t *testing.T) {
|
||||
// calling next with no current episode should pick the first incomplete entry instead of hanging on the mutex.
|
||||
h := newPlaylistTestHarness(t)
|
||||
h.manager.clientId = "web"
|
||||
episodeOne := newStreamPlaylistEpisode(201, 1, "1")
|
||||
episodeTwo := newStreamPlaylistEpisode(201, 2, "2")
|
||||
playlist := newPlaylistFixture("queue", episodeOne, episodeTwo)
|
||||
h.manager.currentPlaylistData = mo.Some(&playlistData{
|
||||
playlist: playlist,
|
||||
options: newClientPlaylistOptions("web"),
|
||||
})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
h.manager.PlayEpisode("next", false)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("PlayEpisode(next) deadlocked without a current episode")
|
||||
}
|
||||
|
||||
require.True(t, h.manager.currentEpisode.IsPresent())
|
||||
require.Same(t, episodeOne, h.manager.currentEpisode.MustGet())
|
||||
|
||||
event := h.wsEventManager.lastPlaylistServerEvent(t, ServerEventPlayEpisode)
|
||||
payload, ok := event.Payload.(playEpisodePayload)
|
||||
require.True(t, ok)
|
||||
require.Same(t, episodeOne, payload.PlaylistEpisode)
|
||||
}
|
||||
|
||||
func TestPlaylistManagerPlayEpisodePrevious(t *testing.T) {
|
||||
// previous should switch back to the earlier entry and notify the client with that episode.
|
||||
h := newPlaylistTestHarness(t)
|
||||
h.manager.clientId = "web"
|
||||
episodeOne := newStreamPlaylistEpisode(301, 1, "1")
|
||||
episodeTwo := newStreamPlaylistEpisode(301, 2, "2")
|
||||
playlist := newPlaylistFixture("queue", episodeOne, episodeTwo)
|
||||
h.manager.currentPlaylistData = mo.Some(&playlistData{
|
||||
playlist: playlist,
|
||||
options: newClientPlaylistOptions("web"),
|
||||
})
|
||||
h.manager.currentEpisode = mo.Some(episodeTwo)
|
||||
|
||||
h.manager.PlayEpisode("previous", false)
|
||||
|
||||
require.True(t, h.manager.currentEpisode.IsPresent())
|
||||
require.Same(t, episodeOne, h.manager.currentEpisode.MustGet())
|
||||
|
||||
event := h.wsEventManager.lastPlaylistServerEvent(t, ServerEventPlayEpisode)
|
||||
payload, ok := event.Payload.(playEpisodePayload)
|
||||
require.True(t, ok)
|
||||
require.Same(t, episodeOne, payload.PlaylistEpisode)
|
||||
}
|
||||
|
||||
func TestPlaylistManagerMarkCurrentAsCompletedPersistsAndUpdatesProgress(t *testing.T) {
|
||||
// completing an episode should update both the stored playlist and the AniList progress bridge.
|
||||
h := newPlaylistTestHarness(t)
|
||||
h.manager.clientId = "web"
|
||||
episodeOne := newStreamPlaylistEpisode(401, 4, "4")
|
||||
playlist := newPlaylistFixture("queue", episodeOne)
|
||||
h.persistPlaylist(t, playlist)
|
||||
h.manager.currentPlaylistData = mo.Some(&playlistData{
|
||||
playlist: playlist,
|
||||
options: newClientPlaylistOptions("web"),
|
||||
})
|
||||
h.manager.currentEpisode = mo.Some(episodeOne)
|
||||
|
||||
h.manager.markCurrentAsCompleted()
|
||||
|
||||
require.True(t, episodeOne.IsCompleted)
|
||||
require.Eventually(t, func() bool {
|
||||
calls := h.platform.UpdateEntryProgressCalls()
|
||||
if len(calls) != 1 {
|
||||
return false
|
||||
}
|
||||
storedPlaylist, err := db_bridge.GetPlaylist(h.database, playlist.DbId)
|
||||
if err != nil || len(storedPlaylist.Episodes) != 1 {
|
||||
return false
|
||||
}
|
||||
return storedPlaylist.Episodes[0].IsCompleted
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
calls := h.platform.UpdateEntryProgressCalls()
|
||||
require.Len(t, calls, 1)
|
||||
require.Equal(t, 401, calls[0].MediaID)
|
||||
require.Equal(t, 4, calls[0].Progress)
|
||||
require.NotNil(t, calls[0].TotalEpisodes)
|
||||
require.Equal(t, 12, *calls[0].TotalEpisodes)
|
||||
}
|
||||
|
||||
func TestPlaylistManagerStopPlaylistDeletesCompletedPlaylistAndResetsState(t *testing.T) {
|
||||
// stopping an already-finished playlist should clear in-memory state and remove the stored queue.
|
||||
h := newPlaylistTestHarness(t)
|
||||
h.manager.clientId = "web"
|
||||
episodeOne := newStreamPlaylistEpisode(501, 1, "1")
|
||||
episodeOne.IsCompleted = true
|
||||
playlist := newPlaylistFixture("queue", episodeOne)
|
||||
h.persistPlaylist(t, playlist)
|
||||
var canceled bool
|
||||
h.manager.currentPlaylistData = mo.Some(&playlistData{
|
||||
playlist: playlist,
|
||||
options: newClientPlaylistOptions("web"),
|
||||
})
|
||||
h.manager.currentEpisode = mo.Some(episodeOne)
|
||||
h.manager.cancel = func() {
|
||||
canceled = true
|
||||
}
|
||||
|
||||
h.manager.StopPlaylist("done")
|
||||
|
||||
require.True(t, canceled)
|
||||
require.True(t, h.manager.currentPlaylistData.IsAbsent())
|
||||
require.True(t, h.manager.currentEpisode.IsAbsent())
|
||||
require.Nil(t, h.manager.cancel)
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := db_bridge.GetPlaylist(h.database, playlist.DbId)
|
||||
return err != nil
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
require.Contains(t, h.wsEventManager.eventTypesForClient("web"), events.InvalidateQueries)
|
||||
require.Contains(t, h.wsEventManager.eventTypesForClient("web"), events.InfoToast)
|
||||
invalidatePayload := h.wsEventManager.lastDirectedEvent(t, events.InvalidateQueries).payload
|
||||
queries, ok := invalidatePayload.([]string)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, []string{events.GetPlaylistsEndpoint}, queries)
|
||||
}
|
||||
|
||||
func TestPlaylistManagerListenToEventsStartsPlaylistAndServesCurrentPlaylist(t *testing.T) {
|
||||
// the common client flow is start playlist first, then ask for the current queue state.
|
||||
h := newPlaylistTestHarness(t)
|
||||
episodeOne := newStreamPlaylistEpisode(601, 1, "1")
|
||||
episodeTwo := newStreamPlaylistEpisode(601, 2, "2")
|
||||
playlist := newPlaylistFixture("queue", episodeOne, episodeTwo)
|
||||
h.persistPlaylist(t, playlist)
|
||||
|
||||
h.sendPlaylistClientEvent(t, "web", ClientEvent{Type: ClientEventStart, Payload: startPlaylistPayload{
|
||||
DbId: playlist.DbId,
|
||||
ClientId: "web",
|
||||
LocalFilePlaybackMethod: ClientPlaybackMethodNativePlayer,
|
||||
StreamPlaybackMethod: ClientPlaybackMethodNativePlayer,
|
||||
}})
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return h.manager.currentEpisode.IsPresent() && h.manager.currentEpisode.MustGet().Episode.AniDBEpisode == "1"
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
playEvent := h.wsEventManager.waitForPlaylistServerEvent(t, ServerEventPlayEpisode)
|
||||
playPayload, ok := playEvent.Payload.(playEpisodePayload)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, episodeOne.Episode.BaseAnime.ID, playPayload.PlaylistEpisode.Episode.BaseAnime.ID)
|
||||
require.Equal(t, episodeOne.Episode.AniDBEpisode, playPayload.PlaylistEpisode.Episode.AniDBEpisode)
|
||||
|
||||
h.sendPlaylistClientEvent(t, "web", ClientEvent{Type: ClientEventCurrentPlaylist})
|
||||
|
||||
currentEvent := h.wsEventManager.waitForPlaylistServerEvent(t, ServerEventCurrentPlaylist)
|
||||
currentPayload := decodeCurrentPlaylistPayload(t, currentEvent)
|
||||
require.NotNil(t, currentPayload.Playlist)
|
||||
require.Equal(t, playlist.Name, currentPayload.Playlist.Name)
|
||||
require.NotNil(t, currentPayload.PlaylistEpisode)
|
||||
require.Equal(t, episodeOne.Episode.AniDBEpisode, currentPayload.PlaylistEpisode.Episode.AniDBEpisode)
|
||||
|
||||
h.manager.StopPlaylist("done")
|
||||
}
|
||||
|
||||
func TestPlaylistManagerNativeLifecycleAdvancesToNextEpisode(t *testing.T) {
|
||||
// the normal native-player loop is: load metadata, complete the episode, then move to the next one on ended.
|
||||
h := newPlaylistTestHarness(t)
|
||||
episodeOne := newStreamPlaylistEpisode(701, 1, "1")
|
||||
episodeTwo := newStreamPlaylistEpisode(701, 2, "2")
|
||||
playlist := newPlaylistFixture("queue", episodeOne, episodeTwo)
|
||||
h.manager.clientId = "web"
|
||||
h.persistPlaylist(t, playlist)
|
||||
h.manager.startPlaylist(playlist, newClientPlaylistOptions("web"))
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return h.manager.currentEpisode.IsPresent() && h.manager.currentEpisode.MustGet().Episode.AniDBEpisode == "1"
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
h.sendNativeLoadedSequence(t, "web", episodeOne)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return h.manager.playerType.Load() == NativePlayer && h.manager.state.Load() == StateStarted
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
h.sendVideoCoreClientEvent("web", videocore.PlayerEventVideoCompleted, map[string]any{
|
||||
"currentTime": 1200.0,
|
||||
"duration": 1200.0,
|
||||
"paused": true,
|
||||
})
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return h.manager.state.Load() == StateCompleted && episodeOne.IsCompleted
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
h.sendVideoCoreClientEvent("web", videocore.PlayerEventVideoEnded, map[string]any{"autoNext": false})
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return h.manager.currentEpisode.IsPresent() && h.manager.currentEpisode.MustGet().Episode.AniDBEpisode == "2"
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
playEvent := h.wsEventManager.lastPlaylistServerEvent(t, ServerEventPlayEpisode)
|
||||
playPayload, ok := playEvent.Payload.(playEpisodePayload)
|
||||
require.True(t, ok)
|
||||
require.Same(t, episodeTwo, playPayload.PlaylistEpisode)
|
||||
require.True(t, episodeOne.IsCompleted)
|
||||
|
||||
h.manager.StopPlaylist("done")
|
||||
}
|
||||
|
||||
func TestPlaylistManagerNativeTerminationStopsPlaylist(t *testing.T) {
|
||||
// when the native player closes during playback, the playlist should stop and clear its state.
|
||||
h := newPlaylistTestHarness(t)
|
||||
episodeOne := newStreamPlaylistEpisode(801, 1, "1")
|
||||
playlist := newPlaylistFixture("queue", episodeOne)
|
||||
h.manager.clientId = "web"
|
||||
h.persistPlaylist(t, playlist)
|
||||
h.manager.startPlaylist(playlist, newClientPlaylistOptions("web"))
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return h.manager.currentEpisode.IsPresent() && h.manager.currentEpisode.MustGet().Episode.AniDBEpisode == "1"
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
h.sendNativeLoadedSequence(t, "web", episodeOne)
|
||||
require.Eventually(t, func() bool {
|
||||
return h.manager.playerType.Load() == NativePlayer && h.manager.state.Load() == StateStarted
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
h.sendVideoCoreClientEvent("web", videocore.PlayerEventVideoTerminated, map[string]any{})
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return h.manager.currentPlaylistData.IsAbsent() && h.manager.currentEpisode.IsAbsent()
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
require.Contains(t, h.wsEventManager.eventTypesForClient("web"), events.InfoToast)
|
||||
}
|
||||
|
||||
func TestPlaylistManagerListenToEventsReopensCurrentEpisode(t *testing.T) {
|
||||
// reopen should send the currently selected episode back to the client without changing selection.
|
||||
h := newPlaylistTestHarness(t)
|
||||
episodeOne := newStreamPlaylistEpisode(901, 1, "1")
|
||||
playlist := newPlaylistFixture("queue", episodeOne)
|
||||
h.manager.currentPlaylistData = mo.Some(&playlistData{playlist: playlist, options: newClientPlaylistOptions("web")})
|
||||
h.manager.currentEpisode = mo.Some(episodeOne)
|
||||
h.manager.clientId = "web"
|
||||
|
||||
h.sendPlaylistClientEvent(t, "web", ClientEvent{Type: ClientEventReopenEpisode})
|
||||
|
||||
playEvent := h.wsEventManager.waitForPlaylistServerEvent(t, ServerEventPlayEpisode)
|
||||
playPayload, ok := playEvent.Payload.(playEpisodePayload)
|
||||
require.True(t, ok)
|
||||
require.Same(t, episodeOne, playPayload.PlaylistEpisode)
|
||||
}
|
||||
|
||||
type playlistTestHarness struct {
|
||||
database *db.Database
|
||||
wsEventManager *recordingPlaylistWSEventManager
|
||||
platform *testmocks.FakePlatform
|
||||
playbackManager *playbackmanager.PlaybackManager
|
||||
videoCore *videocore.VideoCore
|
||||
nativePlayer *nativeplayer.NativePlayer
|
||||
manager *Manager
|
||||
}
|
||||
|
||||
func newPlaylistTestHarness(t *testing.T) *playlistTestHarness {
|
||||
t.Helper()
|
||||
|
||||
env := testutil.NewTestEnv(t)
|
||||
logger := util.NewLogger()
|
||||
database := env.MustNewDatabase(logger)
|
||||
wsEventManager := &recordingPlaylistWSEventManager{MockWSEventManager: events.NewMockWSEventManager(logger)}
|
||||
platformImpl := testmocks.NewFakePlatformBuilder().Build()
|
||||
platformInterface := platform.Platform(platformImpl)
|
||||
var provider metadata_provider.Provider
|
||||
continuityManager := continuity.NewManager(&continuity.NewManagerOptions{
|
||||
FileCacher: env.NewCacher("playlist-continuity"),
|
||||
Logger: logger,
|
||||
Database: database,
|
||||
})
|
||||
continuityManager.SetSettings(&continuity.Settings{WatchContinuityEnabled: false})
|
||||
videoCore := videocore.New(videocore.NewVideoCoreOptions{
|
||||
WsEventManager: wsEventManager,
|
||||
Logger: logger,
|
||||
MetadataProviderRef: util.NewRef(provider),
|
||||
ContinuityManager: continuityManager,
|
||||
PlatformRef: util.NewRef(platformInterface),
|
||||
IsOfflineRef: util.NewRef(false),
|
||||
})
|
||||
videoCore.SetSettings(&models.Settings{
|
||||
Library: &models.LibrarySettings{AutoUpdateProgress: false},
|
||||
MediaPlayer: &models.MediaPlayerSettings{},
|
||||
})
|
||||
nativePlayer := nativeplayer.New(nativeplayer.NewNativePlayerOptions{
|
||||
WsEventManager: wsEventManager,
|
||||
Logger: logger,
|
||||
VideoCore: videoCore,
|
||||
})
|
||||
t.Cleanup(videoCore.Shutdown)
|
||||
playbackManager := playbackmanager.New(&playbackmanager.NewPlaybackManagerOptions{
|
||||
Logger: logger,
|
||||
Database: database,
|
||||
WSEventManager: wsEventManager,
|
||||
PlatformRef: util.NewRef(platformInterface),
|
||||
MetadataProviderRef: util.NewRef(provider),
|
||||
IsOfflineRef: util.NewRef(false),
|
||||
})
|
||||
playbackManager.SetMediaPlayerRepository(mediaplayer.NewRepository(&mediaplayer.NewRepositoryOptions{
|
||||
Logger: logger,
|
||||
Default: "",
|
||||
WSEventManager: wsEventManager,
|
||||
}))
|
||||
|
||||
manager := NewManager(&NewManagerOptions{
|
||||
PlaybackManager: playbackManager,
|
||||
NativePlayer: nativePlayer,
|
||||
Logger: logger,
|
||||
PlatformRef: util.NewRef(platformInterface),
|
||||
WSEventManager: wsEventManager,
|
||||
Database: database,
|
||||
})
|
||||
manager.currentPlaylistData = mo.None[*playlistData]()
|
||||
manager.currentEpisode = mo.None[*anime.PlaylistEpisode]()
|
||||
manager.state.Store(StateIdle)
|
||||
manager.playerType.Store("")
|
||||
|
||||
return &playlistTestHarness{
|
||||
database: database,
|
||||
wsEventManager: wsEventManager,
|
||||
platform: platformImpl,
|
||||
playbackManager: playbackManager,
|
||||
videoCore: videoCore,
|
||||
nativePlayer: nativePlayer,
|
||||
manager: manager,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *playlistTestHarness) sendPlaylistClientEvent(t *testing.T, clientID string, payload ClientEvent) {
|
||||
t.Helper()
|
||||
h.waitForClientSubscriber(t, "playlist-manager")
|
||||
|
||||
h.wsEventManager.MockSendClientEvent(&events.WebsocketClientEvent{
|
||||
ClientID: clientID,
|
||||
Type: events.PlaylistEvent,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *playlistTestHarness) sendVideoCoreClientEvent(clientID string, eventType videocore.ClientEventType, payload interface{}) {
|
||||
h.wsEventManager.MockSendClientEvent(&events.WebsocketClientEvent{
|
||||
ClientID: clientID,
|
||||
Type: events.VideoCoreEventType,
|
||||
Payload: map[string]interface{}{
|
||||
"type": eventType,
|
||||
"payload": payload,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *playlistTestHarness) sendNativeLoadedSequence(t *testing.T, clientID string, episode *anime.PlaylistEpisode) {
|
||||
t.Helper()
|
||||
|
||||
h.sendVideoCoreClientEvent(clientID, videocore.PlayerEventVideoLoaded, map[string]interface{}{
|
||||
"state": videocore.PlaybackState{
|
||||
ClientId: clientID,
|
||||
PlayerType: videocore.NativePlayer,
|
||||
PlaybackInfo: &videocore.VideoPlaybackInfo{
|
||||
Id: "playback-1",
|
||||
PlaybackType: videocore.PlaybackTypeTorrent,
|
||||
Media: episode.Episode.BaseAnime,
|
||||
Episode: episode.Episode,
|
||||
},
|
||||
},
|
||||
})
|
||||
h.sendVideoCoreClientEvent(clientID, videocore.PlayerEventVideoLoadedMetadata, map[string]interface{}{
|
||||
"currentTime": 0.0,
|
||||
"duration": 1200.0,
|
||||
"paused": false,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *playlistTestHarness) waitForClientSubscriber(t *testing.T, id string) {
|
||||
t.Helper()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return h.wsEventManager.ClientEventSubscribers.Has(id)
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func (h *playlistTestHarness) persistPlaylist(t *testing.T, playlist *anime.Playlist) {
|
||||
t.Helper()
|
||||
|
||||
data, err := json.Marshal(playlist.Episodes)
|
||||
require.NoError(t, err)
|
||||
entry := &models.Playlist{
|
||||
Name: playlist.Name,
|
||||
Value: data,
|
||||
}
|
||||
require.NoError(t, h.database.Gorm().Create(entry).Error)
|
||||
playlist.DbId = entry.ID
|
||||
}
|
||||
|
||||
type recordingDirectedEvent struct {
|
||||
clientID string
|
||||
eventType string
|
||||
payload interface{}
|
||||
}
|
||||
|
||||
type recordingPlaylistWSEventManager struct {
|
||||
*events.MockWSEventManager
|
||||
mu sync.Mutex
|
||||
directed []recordingDirectedEvent
|
||||
broadcast []recordingDirectedEvent
|
||||
}
|
||||
|
||||
func (m *recordingPlaylistWSEventManager) SendEvent(t string, payload interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.broadcast = append(m.broadcast, recordingDirectedEvent{eventType: t, payload: payload})
|
||||
}
|
||||
|
||||
func (m *recordingPlaylistWSEventManager) SendEventTo(clientID string, t string, payload interface{}, _ ...bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.directed = append(m.directed, recordingDirectedEvent{clientID: clientID, eventType: t, payload: payload})
|
||||
}
|
||||
|
||||
func (m *recordingPlaylistWSEventManager) lastPlaylistServerEvent(t *testing.T, eventType PlaylistServerEventType) ServerEvent {
|
||||
t.Helper()
|
||||
|
||||
event, ok := m.tryLastPlaylistServerEvent(eventType)
|
||||
if ok {
|
||||
return event
|
||||
}
|
||||
t.Fatalf("playlist server event %s not found", eventType)
|
||||
return ServerEvent{}
|
||||
}
|
||||
|
||||
func (m *recordingPlaylistWSEventManager) waitForPlaylistServerEvent(t *testing.T, eventType PlaylistServerEventType) ServerEvent {
|
||||
t.Helper()
|
||||
|
||||
var event ServerEvent
|
||||
require.Eventually(t, func() bool {
|
||||
var ok bool
|
||||
event, ok = m.tryLastPlaylistServerEvent(eventType)
|
||||
return ok
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
return event
|
||||
}
|
||||
|
||||
func (m *recordingPlaylistWSEventManager) tryLastPlaylistServerEvent(eventType PlaylistServerEventType) (ServerEvent, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for i := len(m.directed) - 1; i >= 0; i-- {
|
||||
if m.directed[i].eventType != string(events.PlaylistEvent) {
|
||||
continue
|
||||
}
|
||||
event, ok := m.directed[i].payload.(ServerEvent)
|
||||
if ok && event.Type == eventType {
|
||||
return event, true
|
||||
}
|
||||
}
|
||||
return ServerEvent{}, false
|
||||
}
|
||||
|
||||
func (m *recordingPlaylistWSEventManager) lastDirectedEvent(t *testing.T, eventType string) recordingDirectedEvent {
|
||||
t.Helper()
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for i := len(m.directed) - 1; i >= 0; i-- {
|
||||
if m.directed[i].eventType == eventType {
|
||||
return m.directed[i]
|
||||
}
|
||||
}
|
||||
t.Fatalf("directed event %s not found", eventType)
|
||||
return recordingDirectedEvent{}
|
||||
}
|
||||
|
||||
func (m *recordingPlaylistWSEventManager) eventTypesForClient(clientID string) []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
ret := make([]string, 0)
|
||||
for _, event := range m.directed {
|
||||
if event.clientID == clientID {
|
||||
ret = append(ret, event.eventType)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
type currentPlaylistPayload struct {
|
||||
PlaylistEpisode *anime.PlaylistEpisode `json:"playlistEpisode"`
|
||||
Playlist *anime.Playlist `json:"playlist"`
|
||||
}
|
||||
|
||||
func decodeCurrentPlaylistPayload(t *testing.T, event ServerEvent) currentPlaylistPayload {
|
||||
t.Helper()
|
||||
|
||||
data, err := json.Marshal(event.Payload)
|
||||
require.NoError(t, err)
|
||||
var payload currentPlaylistPayload
|
||||
require.NoError(t, json.Unmarshal(data, &payload))
|
||||
return payload
|
||||
}
|
||||
|
||||
func newClientPlaylistOptions(clientID string) *startPlaylistPayload {
|
||||
return &startPlaylistPayload{
|
||||
ClientId: clientID,
|
||||
LocalFilePlaybackMethod: ClientPlaybackMethodNativePlayer,
|
||||
StreamPlaybackMethod: ClientPlaybackMethodNativePlayer,
|
||||
}
|
||||
}
|
||||
|
||||
func newPlaylistFixture(name string, episodes ...*anime.PlaylistEpisode) *anime.Playlist {
|
||||
playlist := anime.NewPlaylist(name)
|
||||
playlist.SetEpisodes(episodes)
|
||||
return playlist
|
||||
}
|
||||
|
||||
func newStreamPlaylistEpisode(mediaID int, episodeNumber int, aniDBEpisode string) *anime.PlaylistEpisode {
|
||||
title := "playlist anime"
|
||||
media := testmocks.NewBaseAnimeBuilder(mediaID, title).
|
||||
WithUserPreferredTitle(title).
|
||||
WithEpisodes(12).
|
||||
Build()
|
||||
media.IDMal = nil
|
||||
|
||||
return &anime.PlaylistEpisode{
|
||||
Episode: &anime.Episode{
|
||||
BaseAnime: media,
|
||||
EpisodeNumber: episodeNumber,
|
||||
ProgressNumber: episodeNumber,
|
||||
AniDBEpisode: aniDBEpisode,
|
||||
DisplayTitle: "Episode",
|
||||
},
|
||||
WatchType: anime.WatchTypeTorrent,
|
||||
}
|
||||
}
|
||||
109
internal/testmocks/fake_metadata_provider.go
Normal file
109
internal/testmocks/fake_metadata_provider.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package testmocks
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/api/metadata_provider"
|
||||
"seanime/internal/util/result"
|
||||
)
|
||||
|
||||
type FakeMetadataProviderBuilder struct {
|
||||
provider *FakeMetadataProvider
|
||||
}
|
||||
|
||||
type FakeMetadataProvider struct {
|
||||
metadataByID map[int]*metadata.AnimeMetadata
|
||||
wrappersByID map[int]metadata_provider.AnimeMetadataWrapper
|
||||
calls map[int]int
|
||||
cache *result.BoundedCache[string, *metadata.AnimeMetadata]
|
||||
}
|
||||
|
||||
type FakeAnimeMetadataWrapper struct {
|
||||
episodes map[string]metadata.EpisodeMetadata
|
||||
}
|
||||
|
||||
func NewFakeMetadataProviderBuilder() *FakeMetadataProviderBuilder {
|
||||
return &FakeMetadataProviderBuilder{
|
||||
provider: &FakeMetadataProvider{
|
||||
metadataByID: make(map[int]*metadata.AnimeMetadata),
|
||||
wrappersByID: make(map[int]metadata_provider.AnimeMetadataWrapper),
|
||||
calls: make(map[int]int),
|
||||
cache: result.NewBoundedCache[string, *metadata.AnimeMetadata](10),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *FakeMetadataProviderBuilder) WithAnimeMetadata(mediaID int, animeMetadata *metadata.AnimeMetadata) *FakeMetadataProviderBuilder {
|
||||
if animeMetadata != nil {
|
||||
b.provider.metadataByID[mediaID] = animeMetadata
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *FakeMetadataProviderBuilder) WithWrapper(mediaID int, wrapper metadata_provider.AnimeMetadataWrapper) *FakeMetadataProviderBuilder {
|
||||
if wrapper != nil {
|
||||
b.provider.wrappersByID[mediaID] = wrapper
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *FakeMetadataProviderBuilder) WithWrapperEpisodes(mediaID int, episodes map[string]metadata.EpisodeMetadata) *FakeMetadataProviderBuilder {
|
||||
if episodes == nil {
|
||||
return b
|
||||
}
|
||||
|
||||
copyEpisodes := make(map[string]metadata.EpisodeMetadata, len(episodes))
|
||||
for key, episode := range episodes {
|
||||
copyEpisodes[key] = episode
|
||||
}
|
||||
|
||||
b.provider.wrappersByID[mediaID] = FakeAnimeMetadataWrapper{episodes: copyEpisodes}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *FakeMetadataProviderBuilder) Build() *FakeMetadataProvider {
|
||||
return b.provider
|
||||
}
|
||||
|
||||
func (f *FakeMetadataProvider) MetadataCalls(mediaID int) int {
|
||||
return f.calls[mediaID]
|
||||
}
|
||||
|
||||
func (f *FakeMetadataProvider) GetAnimeMetadata(_ metadata.Platform, mediaID int) (*metadata.AnimeMetadata, error) {
|
||||
f.calls[mediaID]++
|
||||
if animeMetadata, ok := f.metadataByID[mediaID]; ok {
|
||||
return animeMetadata, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *FakeMetadataProvider) GetAnimeMetadataWrapper(anime *anilist.BaseAnime, _ *metadata.AnimeMetadata) metadata_provider.AnimeMetadataWrapper {
|
||||
if anime != nil {
|
||||
if wrapper, ok := f.wrappersByID[anime.ID]; ok {
|
||||
return wrapper
|
||||
}
|
||||
}
|
||||
return FakeAnimeMetadataWrapper{episodes: map[string]metadata.EpisodeMetadata{}}
|
||||
}
|
||||
|
||||
func (f *FakeMetadataProvider) GetCache() *result.BoundedCache[string, *metadata.AnimeMetadata] {
|
||||
return f.cache
|
||||
}
|
||||
|
||||
func (f *FakeMetadataProvider) SetUseFallbackProvider(bool) {}
|
||||
|
||||
func (f *FakeMetadataProvider) ClearCache() {
|
||||
f.cache.Clear()
|
||||
}
|
||||
|
||||
func (f *FakeMetadataProvider) Close() {}
|
||||
|
||||
func (f FakeAnimeMetadataWrapper) GetEpisodeMetadata(episode string) metadata.EpisodeMetadata {
|
||||
if f.episodes == nil {
|
||||
return metadata.EpisodeMetadata{}
|
||||
}
|
||||
if episodeMetadata, ok := f.episodes[episode]; ok {
|
||||
return episodeMetadata
|
||||
}
|
||||
return metadata.EpisodeMetadata{}
|
||||
}
|
||||
276
internal/testmocks/fake_platform.go
Normal file
276
internal/testmocks/fake_platform.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package testmocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"seanime/internal/api/anilist"
|
||||
)
|
||||
|
||||
type FakePlatformBuilder struct {
|
||||
platform *FakePlatform
|
||||
}
|
||||
|
||||
type FakePlatform struct {
|
||||
animeByID map[int]*anilist.BaseAnime
|
||||
mangaByID map[int]*anilist.BaseManga
|
||||
animeCollection *anilist.AnimeCollection
|
||||
rawAnimeCollection *anilist.AnimeCollection
|
||||
animeCollectionWithRel *anilist.AnimeCollectionWithRelations
|
||||
mangaCollection *anilist.MangaCollection
|
||||
rawMangaCollection *anilist.MangaCollection
|
||||
animeAiringSchedule *anilist.AnimeAiringSchedule
|
||||
viewerStats *anilist.ViewerStats
|
||||
animeCollectionErr error
|
||||
rawAnimeCollectionErr error
|
||||
animeCollectionWithRelErr error
|
||||
mangaCollectionErr error
|
||||
rawMangaCollectionErr error
|
||||
animeAiringScheduleErr error
|
||||
viewerStatsErr error
|
||||
updateEntryProgressErr error
|
||||
animeCalls map[int]int
|
||||
mangaCalls map[int]int
|
||||
animeCollectionCalls int
|
||||
rawAnimeCollectionCalls int
|
||||
animeCollectionWithRelCalls int
|
||||
mangaCollectionCalls int
|
||||
rawMangaCollectionCalls int
|
||||
animeAiringScheduleCalls int
|
||||
viewerStatsCalls int
|
||||
updateEntryProgressCalls []FakeUpdateEntryProgressCall
|
||||
}
|
||||
|
||||
type FakeUpdateEntryProgressCall struct {
|
||||
MediaID int
|
||||
Progress int
|
||||
TotalEpisodes *int
|
||||
}
|
||||
|
||||
func NewFakePlatformBuilder() *FakePlatformBuilder {
|
||||
return &FakePlatformBuilder{
|
||||
platform: &FakePlatform{
|
||||
animeByID: make(map[int]*anilist.BaseAnime),
|
||||
mangaByID: make(map[int]*anilist.BaseManga),
|
||||
animeCalls: make(map[int]int),
|
||||
mangaCalls: make(map[int]int),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *FakePlatformBuilder) WithAnime(anime *anilist.BaseAnime) *FakePlatformBuilder {
|
||||
if anime != nil {
|
||||
b.platform.animeByID[anime.ID] = anime
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *FakePlatformBuilder) WithManga(manga *anilist.BaseManga) *FakePlatformBuilder {
|
||||
if manga != nil {
|
||||
b.platform.mangaByID[manga.ID] = manga
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *FakePlatformBuilder) WithAnimeCollection(collection *anilist.AnimeCollection) *FakePlatformBuilder {
|
||||
b.platform.animeCollection = collection
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *FakePlatformBuilder) WithAnimeCollectionError(err error) *FakePlatformBuilder {
|
||||
b.platform.animeCollectionErr = err
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *FakePlatformBuilder) WithRawAnimeCollection(collection *anilist.AnimeCollection) *FakePlatformBuilder {
|
||||
b.platform.rawAnimeCollection = collection
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *FakePlatformBuilder) WithAnimeCollectionWithRelations(collection *anilist.AnimeCollectionWithRelations) *FakePlatformBuilder {
|
||||
b.platform.animeCollectionWithRel = collection
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *FakePlatformBuilder) WithMangaCollection(collection *anilist.MangaCollection) *FakePlatformBuilder {
|
||||
b.platform.mangaCollection = collection
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *FakePlatformBuilder) WithAnimeAiringSchedule(schedule *anilist.AnimeAiringSchedule) *FakePlatformBuilder {
|
||||
b.platform.animeAiringSchedule = schedule
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *FakePlatformBuilder) WithViewerStats(stats *anilist.ViewerStats) *FakePlatformBuilder {
|
||||
b.platform.viewerStats = stats
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *FakePlatformBuilder) WithUpdateEntryProgressError(err error) *FakePlatformBuilder {
|
||||
b.platform.updateEntryProgressErr = err
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *FakePlatformBuilder) Build() *FakePlatform {
|
||||
return b.platform
|
||||
}
|
||||
|
||||
func (f *FakePlatform) AnimeCalls(mediaID int) int {
|
||||
return f.animeCalls[mediaID]
|
||||
}
|
||||
|
||||
func (f *FakePlatform) MangaCalls(mediaID int) int {
|
||||
return f.mangaCalls[mediaID]
|
||||
}
|
||||
|
||||
func (f *FakePlatform) AnimeCollectionCalls() int {
|
||||
return f.animeCollectionCalls
|
||||
}
|
||||
|
||||
func (f *FakePlatform) UpdateEntryProgressCalls() []FakeUpdateEntryProgressCall {
|
||||
ret := make([]FakeUpdateEntryProgressCall, len(f.updateEntryProgressCalls))
|
||||
copy(ret, f.updateEntryProgressCalls)
|
||||
return ret
|
||||
}
|
||||
|
||||
func (f *FakePlatform) SetUsername(string) {}
|
||||
|
||||
func (f *FakePlatform) UpdateEntry(context.Context, int, *anilist.MediaListStatus, *int, *int, *anilist.FuzzyDateInput, *anilist.FuzzyDateInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) UpdateEntryProgress(_ context.Context, mediaID int, progress int, totalEpisodes *int) error {
|
||||
call := FakeUpdateEntryProgressCall{}
|
||||
call.MediaID = mediaID
|
||||
call.Progress = progress
|
||||
if totalEpisodes != nil {
|
||||
call.TotalEpisodes = new(*totalEpisodes)
|
||||
}
|
||||
f.updateEntryProgressCalls = append(f.updateEntryProgressCalls, call)
|
||||
return f.updateEntryProgressErr
|
||||
}
|
||||
|
||||
func (f *FakePlatform) UpdateEntryRepeat(context.Context, int, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) DeleteEntry(context.Context, int, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) GetAnime(_ context.Context, mediaID int) (*anilist.BaseAnime, error) {
|
||||
f.animeCalls[mediaID]++
|
||||
anime, ok := f.animeByID[mediaID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("anime %d not found", mediaID)
|
||||
}
|
||||
return anime, nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) GetAnimeByMalID(context.Context, int) (*anilist.BaseAnime, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) GetAnimeWithRelations(context.Context, int) (*anilist.CompleteAnime, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) GetAnimeDetails(context.Context, int) (*anilist.AnimeDetailsById_Media, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) GetManga(_ context.Context, mediaID int) (*anilist.BaseManga, error) {
|
||||
f.mangaCalls[mediaID]++
|
||||
manga, ok := f.mangaByID[mediaID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("manga %d not found", mediaID)
|
||||
}
|
||||
return manga, nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) GetAnimeCollection(context.Context, bool) (*anilist.AnimeCollection, error) {
|
||||
f.animeCollectionCalls++
|
||||
if f.animeCollectionErr != nil {
|
||||
return nil, f.animeCollectionErr
|
||||
}
|
||||
if f.animeCollection == nil {
|
||||
f.animeCollection = &anilist.AnimeCollection{}
|
||||
}
|
||||
return f.animeCollection, nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) GetRawAnimeCollection(context.Context, bool) (*anilist.AnimeCollection, error) {
|
||||
f.rawAnimeCollectionCalls++
|
||||
if f.rawAnimeCollectionErr != nil {
|
||||
return nil, f.rawAnimeCollectionErr
|
||||
}
|
||||
return f.rawAnimeCollection, nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) GetMangaDetails(context.Context, int) (*anilist.MangaDetailsById_Media, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) GetAnimeCollectionWithRelations(context.Context) (*anilist.AnimeCollectionWithRelations, error) {
|
||||
f.animeCollectionWithRelCalls++
|
||||
if f.animeCollectionWithRelErr != nil {
|
||||
return nil, f.animeCollectionWithRelErr
|
||||
}
|
||||
return f.animeCollectionWithRel, nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) GetMangaCollection(context.Context, bool) (*anilist.MangaCollection, error) {
|
||||
f.mangaCollectionCalls++
|
||||
if f.mangaCollectionErr != nil {
|
||||
return nil, f.mangaCollectionErr
|
||||
}
|
||||
return f.mangaCollection, nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) GetRawMangaCollection(context.Context, bool) (*anilist.MangaCollection, error) {
|
||||
f.rawMangaCollectionCalls++
|
||||
if f.rawMangaCollectionErr != nil {
|
||||
return nil, f.rawMangaCollectionErr
|
||||
}
|
||||
return f.rawMangaCollection, nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) AddMediaToCollection(context.Context, []int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) GetStudioDetails(context.Context, int) (*anilist.StudioDetails, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) GetAnilistClient() anilist.AnilistClient {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) RefreshAnimeCollection(context.Context) (*anilist.AnimeCollection, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) RefreshMangaCollection(context.Context) (*anilist.MangaCollection, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) GetViewerStats(context.Context) (*anilist.ViewerStats, error) {
|
||||
f.viewerStatsCalls++
|
||||
if f.viewerStatsErr != nil {
|
||||
return nil, f.viewerStatsErr
|
||||
}
|
||||
return f.viewerStats, nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) GetAnimeAiringSchedule(context.Context) (*anilist.AnimeAiringSchedule, error) {
|
||||
f.animeAiringScheduleCalls++
|
||||
if f.animeAiringScheduleErr != nil {
|
||||
return nil, f.animeAiringScheduleErr
|
||||
}
|
||||
return f.animeAiringSchedule, nil
|
||||
}
|
||||
|
||||
func (f *FakePlatform) ClearCache() {}
|
||||
|
||||
func (f *FakePlatform) Close() {}
|
||||
301
internal/testmocks/media_builders.go
Normal file
301
internal/testmocks/media_builders.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package testmocks
|
||||
|
||||
import "seanime/internal/api/anilist"
|
||||
|
||||
type BaseAnimeBuilder struct {
|
||||
anime *anilist.BaseAnime
|
||||
}
|
||||
|
||||
func NewBaseAnimeBuilder(id int, title string) *BaseAnimeBuilder {
|
||||
return &BaseAnimeBuilder{anime: &anilist.BaseAnime{
|
||||
ID: id,
|
||||
IDMal: new(501),
|
||||
Status: new(anilist.MediaStatusFinished),
|
||||
Type: new(anilist.MediaTypeAnime),
|
||||
Format: new(anilist.MediaFormatTv),
|
||||
Episodes: new(12),
|
||||
IsAdult: new(false),
|
||||
Title: &anilist.BaseAnime_Title{
|
||||
English: new(title),
|
||||
Romaji: new(title),
|
||||
},
|
||||
Synonyms: []*string{new(title), new("Sample Anime Season 1")},
|
||||
StartDate: &anilist.BaseAnime_StartDate{
|
||||
Year: new(2024),
|
||||
Month: new(1),
|
||||
Day: new(2),
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func NewBaseAnime(id int, title string) *anilist.BaseAnime {
|
||||
return NewBaseAnimeBuilder(id, title).Build()
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) WithIDMal(idMal int) *BaseAnimeBuilder {
|
||||
b.anime.IDMal = new(idMal)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) WithSiteURL(siteURL string) *BaseAnimeBuilder {
|
||||
b.anime.SiteURL = new(siteURL)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) WithTitles(english string, romaji string, native string, userPreferred string) *BaseAnimeBuilder {
|
||||
ensureAnimeTitle(b.anime)
|
||||
b.anime.Title.English = new(english)
|
||||
b.anime.Title.Romaji = new(romaji)
|
||||
b.anime.Title.Native = new(native)
|
||||
b.anime.Title.UserPreferred = new(userPreferred)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) WithEnglishTitle(title string) *BaseAnimeBuilder {
|
||||
ensureAnimeTitle(b.anime)
|
||||
b.anime.Title.English = new(title)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) WithRomajiTitle(title string) *BaseAnimeBuilder {
|
||||
ensureAnimeTitle(b.anime)
|
||||
b.anime.Title.Romaji = new(title)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) WithNativeTitle(title string) *BaseAnimeBuilder {
|
||||
ensureAnimeTitle(b.anime)
|
||||
b.anime.Title.Native = new(title)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) WithUserPreferredTitle(title string) *BaseAnimeBuilder {
|
||||
ensureAnimeTitle(b.anime)
|
||||
b.anime.Title.UserPreferred = new(title)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) WithStatus(status anilist.MediaStatus) *BaseAnimeBuilder {
|
||||
b.anime.Status = new(status)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) WithFormat(format anilist.MediaFormat) *BaseAnimeBuilder {
|
||||
b.anime.Format = new(format)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) WithEpisodes(episodes int) *BaseAnimeBuilder {
|
||||
b.anime.Episodes = new(episodes)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) WithIsAdult(isAdult bool) *BaseAnimeBuilder {
|
||||
b.anime.IsAdult = new(isAdult)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) WithSynonyms(synonyms ...string) *BaseAnimeBuilder {
|
||||
b.anime.Synonyms = stringPointers(synonyms...)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) WithStartDate(year int, month int, day int) *BaseAnimeBuilder {
|
||||
b.anime.StartDate = &anilist.BaseAnime_StartDate{
|
||||
Year: new(year),
|
||||
Month: new(month),
|
||||
Day: new(day),
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) WithEndDate(year int, month int, day int) *BaseAnimeBuilder {
|
||||
b.anime.EndDate = &anilist.BaseAnime_EndDate{
|
||||
Year: new(year),
|
||||
Month: new(month),
|
||||
Day: new(day),
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) WithCoverImage(url string) *BaseAnimeBuilder {
|
||||
b.anime.CoverImage = &anilist.BaseAnime_CoverImage{
|
||||
ExtraLarge: new(url),
|
||||
Large: new(url),
|
||||
Medium: new(url),
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) WithBannerImage(url string) *BaseAnimeBuilder {
|
||||
b.anime.BannerImage = new(url)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) WithNextAiringEpisode(episode int, airingAt int, timeUntilAiring int) *BaseAnimeBuilder {
|
||||
b.anime.NextAiringEpisode = &anilist.BaseAnime_NextAiringEpisode{
|
||||
Episode: episode,
|
||||
AiringAt: airingAt,
|
||||
TimeUntilAiring: timeUntilAiring,
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseAnimeBuilder) Build() *anilist.BaseAnime {
|
||||
return b.anime
|
||||
}
|
||||
|
||||
type BaseMangaBuilder struct {
|
||||
manga *anilist.BaseManga
|
||||
}
|
||||
|
||||
func NewBaseMangaBuilder(id int, title string) *BaseMangaBuilder {
|
||||
return &BaseMangaBuilder{manga: &anilist.BaseManga{
|
||||
ID: id,
|
||||
Status: new(anilist.MediaStatusFinished),
|
||||
Type: new(anilist.MediaTypeManga),
|
||||
Format: new(anilist.MediaFormatManga),
|
||||
IsAdult: new(false),
|
||||
Title: &anilist.BaseManga_Title{
|
||||
English: new(title),
|
||||
Romaji: new(title),
|
||||
},
|
||||
Synonyms: []*string{new(title), new(title + " Alternative")},
|
||||
StartDate: &anilist.BaseManga_StartDate{
|
||||
Year: new(2023),
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func NewBaseManga(id int, title string) *anilist.BaseManga {
|
||||
return NewBaseMangaBuilder(id, title).Build()
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) WithIDMal(idMal int) *BaseMangaBuilder {
|
||||
b.manga.IDMal = new(idMal)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) WithSiteURL(siteURL string) *BaseMangaBuilder {
|
||||
b.manga.SiteURL = new(siteURL)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) WithTitles(english string, romaji string, native string, userPreferred string) *BaseMangaBuilder {
|
||||
ensureMangaTitle(b.manga)
|
||||
b.manga.Title.English = new(english)
|
||||
b.manga.Title.Romaji = new(romaji)
|
||||
b.manga.Title.Native = new(native)
|
||||
b.manga.Title.UserPreferred = new(userPreferred)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) WithEnglishTitle(title string) *BaseMangaBuilder {
|
||||
ensureMangaTitle(b.manga)
|
||||
b.manga.Title.English = new(title)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) WithRomajiTitle(title string) *BaseMangaBuilder {
|
||||
ensureMangaTitle(b.manga)
|
||||
b.manga.Title.Romaji = new(title)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) WithNativeTitle(title string) *BaseMangaBuilder {
|
||||
ensureMangaTitle(b.manga)
|
||||
b.manga.Title.Native = new(title)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) WithUserPreferredTitle(title string) *BaseMangaBuilder {
|
||||
ensureMangaTitle(b.manga)
|
||||
b.manga.Title.UserPreferred = new(title)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) WithStatus(status anilist.MediaStatus) *BaseMangaBuilder {
|
||||
b.manga.Status = new(status)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) WithFormat(format anilist.MediaFormat) *BaseMangaBuilder {
|
||||
b.manga.Format = new(format)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) WithChapters(chapters int) *BaseMangaBuilder {
|
||||
b.manga.Chapters = new(chapters)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) WithVolumes(volumes int) *BaseMangaBuilder {
|
||||
b.manga.Volumes = new(volumes)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) WithIsAdult(isAdult bool) *BaseMangaBuilder {
|
||||
b.manga.IsAdult = new(isAdult)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) WithSynonyms(synonyms ...string) *BaseMangaBuilder {
|
||||
b.manga.Synonyms = stringPointers(synonyms...)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) WithStartDate(year int, month int, day int) *BaseMangaBuilder {
|
||||
b.manga.StartDate = &anilist.BaseManga_StartDate{
|
||||
Year: new(year),
|
||||
Month: new(month),
|
||||
Day: new(day),
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) WithEndDate(year int, month int, day int) *BaseMangaBuilder {
|
||||
b.manga.EndDate = &anilist.BaseManga_EndDate{
|
||||
Year: new(year),
|
||||
Month: new(month),
|
||||
Day: new(day),
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) WithCoverImage(url string) *BaseMangaBuilder {
|
||||
b.manga.CoverImage = &anilist.BaseManga_CoverImage{
|
||||
ExtraLarge: new(url),
|
||||
Large: new(url),
|
||||
Medium: new(url),
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) WithBannerImage(url string) *BaseMangaBuilder {
|
||||
b.manga.BannerImage = new(url)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BaseMangaBuilder) Build() *anilist.BaseManga {
|
||||
return b.manga
|
||||
}
|
||||
|
||||
func ensureAnimeTitle(anime *anilist.BaseAnime) {
|
||||
if anime.Title == nil {
|
||||
anime.Title = &anilist.BaseAnime_Title{}
|
||||
}
|
||||
}
|
||||
|
||||
func ensureMangaTitle(manga *anilist.BaseManga) {
|
||||
if manga.Title == nil {
|
||||
manga.Title = &anilist.BaseManga_Title{}
|
||||
}
|
||||
}
|
||||
|
||||
func stringPointers(values ...string) []*string {
|
||||
ret := make([]*string, 0, len(values))
|
||||
for _, value := range values {
|
||||
ret = append(ret, new(value))
|
||||
}
|
||||
return ret
|
||||
}
|
||||
114
internal/testmocks/media_builders_test.go
Normal file
114
internal/testmocks/media_builders_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package testmocks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"seanime/internal/api/anilist"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// makes sure the anime builder can override the fields tests usually care about.
|
||||
func TestBaseAnimeBuilder(t *testing.T) {
|
||||
anime := NewBaseAnimeBuilder(44, "seed title").
|
||||
WithIDMal(404).
|
||||
WithSiteURL("https://example.com/anime/44").
|
||||
WithTitles("English Title", "Romaji Title", "Native Title", "Preferred Title").
|
||||
WithStatus(anilist.MediaStatusReleasing).
|
||||
WithFormat(anilist.MediaFormatMovie).
|
||||
WithEpisodes(24).
|
||||
WithIsAdult(true).
|
||||
WithSynonyms("Alt 1", "Alt 2").
|
||||
WithStartDate(2022, 7, 10).
|
||||
WithEndDate(2022, 12, 25).
|
||||
WithCoverImage("https://example.com/anime/44.jpg").
|
||||
WithBannerImage("https://example.com/anime/44-banner.jpg").
|
||||
WithNextAiringEpisode(5, 123456, 7890).
|
||||
Build()
|
||||
|
||||
require.Equal(t, 44, anime.ID)
|
||||
require.NotNil(t, anime.IDMal)
|
||||
require.Equal(t, 404, *anime.IDMal)
|
||||
require.NotNil(t, anime.Type)
|
||||
require.Equal(t, anilist.MediaTypeAnime, *anime.Type)
|
||||
require.NotNil(t, anime.SiteURL)
|
||||
require.Equal(t, "https://example.com/anime/44", *anime.SiteURL)
|
||||
require.Equal(t, "English Title", *anime.Title.English)
|
||||
require.Equal(t, "Romaji Title", *anime.Title.Romaji)
|
||||
require.Equal(t, "Native Title", *anime.Title.Native)
|
||||
require.Equal(t, "Preferred Title", *anime.Title.UserPreferred)
|
||||
require.Equal(t, anilist.MediaStatusReleasing, *anime.Status)
|
||||
require.Equal(t, anilist.MediaFormatMovie, *anime.Format)
|
||||
require.Equal(t, 24, *anime.Episodes)
|
||||
require.Equal(t, true, *anime.IsAdult)
|
||||
require.Len(t, anime.Synonyms, 2)
|
||||
require.Equal(t, "Alt 1", *anime.Synonyms[0])
|
||||
require.Equal(t, 2022, *anime.StartDate.Year)
|
||||
require.Equal(t, 7, *anime.StartDate.Month)
|
||||
require.Equal(t, 10, *anime.StartDate.Day)
|
||||
require.Equal(t, 2022, *anime.EndDate.Year)
|
||||
require.Equal(t, 12, *anime.EndDate.Month)
|
||||
require.Equal(t, 25, *anime.EndDate.Day)
|
||||
require.Equal(t, "https://example.com/anime/44.jpg", *anime.CoverImage.Large)
|
||||
require.Equal(t, "https://example.com/anime/44-banner.jpg", *anime.BannerImage)
|
||||
require.Equal(t, 5, anime.NextAiringEpisode.Episode)
|
||||
require.Equal(t, 123456, anime.NextAiringEpisode.AiringAt)
|
||||
require.Equal(t, 7890, anime.NextAiringEpisode.TimeUntilAiring)
|
||||
}
|
||||
|
||||
// makes sure the manga builder covers the common overrides too.
|
||||
func TestBaseMangaBuilder(t *testing.T) {
|
||||
manga := NewBaseMangaBuilder(88, "seed manga").
|
||||
WithIDMal(808).
|
||||
WithSiteURL("https://example.com/manga/88").
|
||||
WithTitles("English Manga", "Romaji Manga", "Native Manga", "Preferred Manga").
|
||||
WithStatus(anilist.MediaStatusHiatus).
|
||||
WithFormat(anilist.MediaFormatOneShot).
|
||||
WithChapters(10).
|
||||
WithVolumes(2).
|
||||
WithIsAdult(true).
|
||||
WithSynonyms("Manga Alt").
|
||||
WithStartDate(2021, 5, 3).
|
||||
WithEndDate(2021, 9, 1).
|
||||
WithCoverImage("https://example.com/manga/88.jpg").
|
||||
WithBannerImage("https://example.com/manga/88-banner.jpg").
|
||||
Build()
|
||||
|
||||
require.Equal(t, 88, manga.ID)
|
||||
require.NotNil(t, manga.IDMal)
|
||||
require.Equal(t, 808, *manga.IDMal)
|
||||
require.NotNil(t, manga.Type)
|
||||
require.Equal(t, anilist.MediaTypeManga, *manga.Type)
|
||||
require.NotNil(t, manga.SiteURL)
|
||||
require.Equal(t, "https://example.com/manga/88", *manga.SiteURL)
|
||||
require.Equal(t, "English Manga", *manga.Title.English)
|
||||
require.Equal(t, "Romaji Manga", *manga.Title.Romaji)
|
||||
require.Equal(t, "Native Manga", *manga.Title.Native)
|
||||
require.Equal(t, "Preferred Manga", *manga.Title.UserPreferred)
|
||||
require.Equal(t, anilist.MediaStatusHiatus, *manga.Status)
|
||||
require.Equal(t, anilist.MediaFormatOneShot, *manga.Format)
|
||||
require.Equal(t, 10, *manga.Chapters)
|
||||
require.Equal(t, 2, *manga.Volumes)
|
||||
require.Equal(t, true, *manga.IsAdult)
|
||||
require.Len(t, manga.Synonyms, 1)
|
||||
require.Equal(t, "Manga Alt", *manga.Synonyms[0])
|
||||
require.Equal(t, 2021, *manga.StartDate.Year)
|
||||
require.Equal(t, 5, *manga.StartDate.Month)
|
||||
require.Equal(t, 3, *manga.StartDate.Day)
|
||||
require.Equal(t, 2021, *manga.EndDate.Year)
|
||||
require.Equal(t, 9, *manga.EndDate.Month)
|
||||
require.Equal(t, 1, *manga.EndDate.Day)
|
||||
require.Equal(t, "https://example.com/manga/88.jpg", *manga.CoverImage.Large)
|
||||
require.Equal(t, "https://example.com/manga/88-banner.jpg", *manga.BannerImage)
|
||||
}
|
||||
|
||||
// keeps the old helper around for tests that only need the seeded defaults.
|
||||
func TestSeededMediaHelpers(t *testing.T) {
|
||||
anime := NewBaseAnime(11, "default anime")
|
||||
manga := NewBaseManga(22, "default manga")
|
||||
|
||||
require.Equal(t, 11, anime.ID)
|
||||
require.Equal(t, 22, manga.ID)
|
||||
require.Equal(t, "default anime", *anime.Title.English)
|
||||
require.Equal(t, "default manga", *manga.Title.English)
|
||||
}
|
||||
@@ -21,7 +21,7 @@ type (
|
||||
)
|
||||
|
||||
func (r *Repository) DeselectAndDownload(p *DeselectAndDownloadParams) error {
|
||||
if p.Torrent == nil || r.torrentRepository == nil {
|
||||
if p == nil || p.Torrent == nil || r.torrentRepository == nil {
|
||||
r.logger.Error().Msg("torrent client: torrent is nil (deselect)")
|
||||
return errors.New("torrent is nil")
|
||||
}
|
||||
@@ -84,6 +84,11 @@ type (
|
||||
// If the torrent has not been added yet, set SmartSelect.ShouldAddTorrent to true.
|
||||
// The torrent will NOT be removed if the selection fails.
|
||||
func (r *Repository) SmartSelect(p *SmartSelectParams) error {
|
||||
if p == nil || p.Torrent == nil {
|
||||
r.logger.Error().Msg("torrent client: torrent is nil (smart select)")
|
||||
return errors.New("torrent is nil")
|
||||
}
|
||||
|
||||
if p.Media == nil || p.PlatformRef.IsAbsent() || r.torrentRepository == nil {
|
||||
r.logger.Error().Msg("torrent client: media or platform is nil (smart select)")
|
||||
return errors.New("media or anilist client wrapper is nil")
|
||||
|
||||
246
internal/torrent_clients/torrent_client/smart_select_test.go
Normal file
246
internal/torrent_clients/torrent_client/smart_select_test.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package torrent_client_test
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata_provider"
|
||||
"seanime/internal/extension"
|
||||
hibiketorrent "seanime/internal/extension/hibike/torrent"
|
||||
"seanime/internal/platforms/platform"
|
||||
"seanime/internal/testmocks"
|
||||
"seanime/internal/torrent_clients/torrent_client"
|
||||
"seanime/internal/torrents/torrent"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func testLogger() *zerolog.Logger {
|
||||
logger := zerolog.Nop()
|
||||
return &logger
|
||||
}
|
||||
|
||||
func newTorrentRepository(bank *extension.UnifiedBank) *torrent.Repository {
|
||||
return torrent.NewRepository(&torrent.NewRepositoryOptions{
|
||||
Logger: testLogger(),
|
||||
MetadataProviderRef: util.NewRef[metadata_provider.Provider](nil),
|
||||
ExtensionBankRef: util.NewRef(bank),
|
||||
})
|
||||
}
|
||||
|
||||
func newClientRepository(torrentRepo *torrent.Repository) *torrent_client.Repository {
|
||||
return torrent_client.NewRepository(&torrent_client.NewRepositoryOptions{
|
||||
Logger: testLogger(),
|
||||
Provider: torrent_client.NoneClient,
|
||||
TorrentRepository: torrentRepo,
|
||||
})
|
||||
}
|
||||
|
||||
func newEmptyClientRepository() *torrent_client.Repository {
|
||||
return newClientRepository(newTorrentRepository(extension.NewUnifiedBank()))
|
||||
}
|
||||
|
||||
func newClientRepositoryWithProvider(providerID string) *torrent_client.Repository {
|
||||
bank := extension.NewUnifiedBank()
|
||||
bank.Set(providerID, extension.NewAnimeTorrentProviderExtension(&extension.Extension{
|
||||
ID: providerID,
|
||||
Name: "Test Provider",
|
||||
Version: "1.0.0",
|
||||
ManifestURI: "builtin",
|
||||
Language: extension.LanguageGo,
|
||||
Type: extension.TypeAnimeTorrentProvider,
|
||||
}, stubAnimeProvider{}))
|
||||
|
||||
return newClientRepository(newTorrentRepository(bank))
|
||||
}
|
||||
|
||||
func presentPlatformRef() *util.Ref[platform.Platform] {
|
||||
return util.NewRef[platform.Platform](testmocks.NewFakePlatformBuilder().Build())
|
||||
}
|
||||
|
||||
func testTorrent(providerID string) *hibiketorrent.AnimeTorrent {
|
||||
return &hibiketorrent.AnimeTorrent{
|
||||
Provider: providerID,
|
||||
InfoHash: "hash",
|
||||
}
|
||||
}
|
||||
|
||||
func mediaWithEpisodes(count int) *anilist.CompleteAnime {
|
||||
return &anilist.CompleteAnime{Episodes: &count}
|
||||
}
|
||||
|
||||
type stubAnimeProvider struct{}
|
||||
|
||||
func (stubAnimeProvider) Search(hibiketorrent.AnimeSearchOptions) ([]*hibiketorrent.AnimeTorrent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubAnimeProvider) SmartSearch(hibiketorrent.AnimeSmartSearchOptions) ([]*hibiketorrent.AnimeTorrent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubAnimeProvider) GetTorrentInfoHash(*hibiketorrent.AnimeTorrent) (string, error) {
|
||||
return "hash", nil
|
||||
}
|
||||
|
||||
func (stubAnimeProvider) GetTorrentMagnetLink(*hibiketorrent.AnimeTorrent) (string, error) {
|
||||
return "magnet:?xt=urn:btih:hash", nil
|
||||
}
|
||||
|
||||
func (stubAnimeProvider) GetLatest() ([]*hibiketorrent.AnimeTorrent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubAnimeProvider) GetSettings() hibiketorrent.AnimeProviderSettings {
|
||||
return hibiketorrent.AnimeProviderSettings{}
|
||||
}
|
||||
|
||||
func TestDeselectAndDownloadValidation(t *testing.T) {
|
||||
t.Run("nil params", func(t *testing.T) {
|
||||
repo := newEmptyClientRepository()
|
||||
|
||||
err := repo.DeselectAndDownload(nil)
|
||||
|
||||
require.EqualError(t, err, "torrent is nil")
|
||||
})
|
||||
|
||||
t.Run("nil torrent", func(t *testing.T) {
|
||||
repo := newEmptyClientRepository()
|
||||
|
||||
err := repo.DeselectAndDownload(&torrent_client.DeselectAndDownloadParams{
|
||||
FileIndices: []int{0},
|
||||
})
|
||||
|
||||
require.EqualError(t, err, "torrent is nil")
|
||||
})
|
||||
|
||||
t.Run("nil repository", func(t *testing.T) {
|
||||
repo := newClientRepository(nil)
|
||||
|
||||
err := repo.DeselectAndDownload(&torrent_client.DeselectAndDownloadParams{
|
||||
Torrent: testTorrent("provider"),
|
||||
FileIndices: []int{0},
|
||||
})
|
||||
|
||||
require.EqualError(t, err, "torrent is nil")
|
||||
})
|
||||
|
||||
t.Run("empty file indices", func(t *testing.T) {
|
||||
repo := newEmptyClientRepository()
|
||||
|
||||
err := repo.DeselectAndDownload(&torrent_client.DeselectAndDownloadParams{
|
||||
Torrent: testTorrent("provider"),
|
||||
})
|
||||
|
||||
require.EqualError(t, err, "no file indices provided")
|
||||
})
|
||||
|
||||
t.Run("provider not found", func(t *testing.T) {
|
||||
repo := newEmptyClientRepository()
|
||||
|
||||
err := repo.DeselectAndDownload(&torrent_client.DeselectAndDownloadParams{
|
||||
Torrent: testTorrent("missing-provider"),
|
||||
FileIndices: []int{0},
|
||||
})
|
||||
|
||||
require.EqualError(t, err, "provider extension not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSmartSelectValidation(t *testing.T) {
|
||||
t.Run("nil params", func(t *testing.T) {
|
||||
repo := newEmptyClientRepository()
|
||||
|
||||
err := repo.SmartSelect(nil)
|
||||
|
||||
require.EqualError(t, err, "torrent is nil")
|
||||
})
|
||||
|
||||
t.Run("nil torrent", func(t *testing.T) {
|
||||
repo := newEmptyClientRepository()
|
||||
|
||||
err := repo.SmartSelect(&torrent_client.SmartSelectParams{
|
||||
Media: mediaWithEpisodes(12),
|
||||
PlatformRef: presentPlatformRef(),
|
||||
EpisodeNumbers: []int{1},
|
||||
})
|
||||
|
||||
require.EqualError(t, err, "torrent is nil")
|
||||
})
|
||||
|
||||
t.Run("nil repository", func(t *testing.T) {
|
||||
repo := newClientRepository(nil)
|
||||
|
||||
err := repo.SmartSelect(&torrent_client.SmartSelectParams{
|
||||
Torrent: testTorrent("provider"),
|
||||
Media: mediaWithEpisodes(12),
|
||||
PlatformRef: presentPlatformRef(),
|
||||
EpisodeNumbers: []int{1},
|
||||
})
|
||||
|
||||
require.EqualError(t, err, "media or anilist client wrapper is nil")
|
||||
})
|
||||
|
||||
t.Run("nil media", func(t *testing.T) {
|
||||
repo := newEmptyClientRepository()
|
||||
|
||||
err := repo.SmartSelect(&torrent_client.SmartSelectParams{
|
||||
Torrent: testTorrent("provider"),
|
||||
PlatformRef: presentPlatformRef(),
|
||||
EpisodeNumbers: []int{1},
|
||||
})
|
||||
|
||||
require.EqualError(t, err, "media or anilist client wrapper is nil")
|
||||
})
|
||||
|
||||
t.Run("absent platform ref", func(t *testing.T) {
|
||||
repo := newEmptyClientRepository()
|
||||
|
||||
err := repo.SmartSelect(&torrent_client.SmartSelectParams{
|
||||
Torrent: testTorrent("provider"),
|
||||
Media: mediaWithEpisodes(12),
|
||||
EpisodeNumbers: []int{1},
|
||||
})
|
||||
|
||||
require.EqualError(t, err, "media or anilist client wrapper is nil")
|
||||
})
|
||||
|
||||
t.Run("provider not found", func(t *testing.T) {
|
||||
repo := newEmptyClientRepository()
|
||||
|
||||
err := repo.SmartSelect(&torrent_client.SmartSelectParams{
|
||||
Torrent: testTorrent("missing-provider"),
|
||||
Media: mediaWithEpisodes(12),
|
||||
PlatformRef: presentPlatformRef(),
|
||||
EpisodeNumbers: []int{1},
|
||||
})
|
||||
|
||||
require.EqualError(t, err, "provider extension not found")
|
||||
})
|
||||
|
||||
t.Run("movie or single episode", func(t *testing.T) {
|
||||
repo := newClientRepositoryWithProvider("provider")
|
||||
|
||||
err := repo.SmartSelect(&torrent_client.SmartSelectParams{
|
||||
Torrent: testTorrent("provider"),
|
||||
Media: mediaWithEpisodes(1),
|
||||
PlatformRef: presentPlatformRef(),
|
||||
EpisodeNumbers: []int{1},
|
||||
})
|
||||
|
||||
require.EqualError(t, err, "smart select is not supported for movies or single-episode series")
|
||||
})
|
||||
|
||||
t.Run("empty episode numbers", func(t *testing.T) {
|
||||
repo := newClientRepositoryWithProvider("provider")
|
||||
|
||||
err := repo.SmartSelect(&torrent_client.SmartSelectParams{
|
||||
Torrent: testTorrent("provider"),
|
||||
Media: mediaWithEpisodes(12),
|
||||
PlatformRef: presentPlatformRef(),
|
||||
})
|
||||
|
||||
require.EqualError(t, err, "no episode numbers provided")
|
||||
})
|
||||
}
|
||||
118
internal/torrents/analyzer/analyzer_test.go
Normal file
118
internal/torrents/analyzer/analyzer_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package torrent_analyzer
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/platforms/platform"
|
||||
"seanime/internal/util"
|
||||
)
|
||||
|
||||
func TestNewAnalyzerInitializesFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
paths := []string{
|
||||
filepath.Join(root, "Season 1", "[Seanime] Example Show - 01.mkv"),
|
||||
filepath.Join(root, "Season 1", "[Seanime] Example Show - 02.mkv"),
|
||||
}
|
||||
media := &anilist.CompleteAnime{ID: 42}
|
||||
|
||||
analyzer := NewAnalyzer(&NewAnalyzerOptions{
|
||||
Filepaths: paths,
|
||||
Media: media,
|
||||
ForceMatch: true,
|
||||
PlatformRef: util.NewRef[platform.Platform](nil),
|
||||
})
|
||||
|
||||
require.Len(t, analyzer.files, len(paths))
|
||||
require.Same(t, media, analyzer.media)
|
||||
require.True(t, analyzer.forceMatch)
|
||||
for index, file := range analyzer.files {
|
||||
require.Equal(t, index, file.GetIndex())
|
||||
require.Equal(t, filepath.ToSlash(paths[index]), file.GetPath())
|
||||
require.NotNil(t, file.GetLocalFile())
|
||||
require.Equal(t, filepath.ToSlash(paths[index]), file.GetLocalFile().Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeTorrentFilesReturnsErrorWhenPlatformRefAbsent(t *testing.T) {
|
||||
analyzer := NewAnalyzer(&NewAnalyzerOptions{
|
||||
Filepaths: []string{filepath.Join(t.TempDir(), "[Seanime] Example Show - 01.mkv")},
|
||||
Media: &anilist.CompleteAnime{ID: 42},
|
||||
})
|
||||
|
||||
analysis, err := analyzer.AnalyzeTorrentFiles()
|
||||
|
||||
require.Nil(t, analysis)
|
||||
require.EqualError(t, err, "anilist client wrapper is nil")
|
||||
}
|
||||
|
||||
// Verifies that the helper methods for selecting files from the analysis work as expected
|
||||
func TestAnalysisSelectionHelpers(t *testing.T) {
|
||||
analysis, files := newAnalysisFixture(t)
|
||||
|
||||
correspondingFiles := analysis.GetCorrespondingFiles()
|
||||
require.Len(t, correspondingFiles, 3)
|
||||
require.Same(t, files[0], correspondingFiles[0])
|
||||
require.Same(t, files[1], correspondingFiles[1])
|
||||
require.Same(t, files[3], correspondingFiles[3])
|
||||
|
||||
correspondingMainFiles := analysis.GetCorrespondingMainFiles()
|
||||
require.Len(t, correspondingMainFiles, 2)
|
||||
require.Same(t, files[0], correspondingMainFiles[0])
|
||||
require.Same(t, files[3], correspondingMainFiles[3])
|
||||
|
||||
mainFile, ok := analysis.GetMainFileByEpisode(3)
|
||||
require.True(t, ok)
|
||||
require.Same(t, files[3], mainFile)
|
||||
|
||||
missingMainFile, ok := analysis.GetMainFileByEpisode(99)
|
||||
require.False(t, ok)
|
||||
require.Nil(t, missingMainFile)
|
||||
|
||||
aniDBFile, ok := analysis.GetFileByAniDBEpisode("3")
|
||||
require.True(t, ok)
|
||||
require.Same(t, files[3], aniDBFile)
|
||||
|
||||
missingAniDBFile, ok := analysis.GetFileByAniDBEpisode("missing")
|
||||
require.False(t, ok)
|
||||
require.Nil(t, missingAniDBFile)
|
||||
|
||||
unselectedFiles := analysis.GetUnselectedFiles()
|
||||
require.Len(t, unselectedFiles, 1)
|
||||
require.Same(t, files[2], unselectedFiles[2])
|
||||
|
||||
require.ElementsMatch(t, []int{0, 3}, analysis.GetIndices(correspondingMainFiles))
|
||||
require.Equal(t, []int{1, 2}, analysis.GetUnselectedIndices(correspondingMainFiles))
|
||||
require.Equal(t, files, analysis.GetFiles())
|
||||
}
|
||||
|
||||
func newAnalysisFixture(t *testing.T) (*Analysis, []*File) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
files := []*File{
|
||||
newAnalyzedFile(filepath.Join(root, "[Seanime] Example Show - 01.mkv"), 0, 42, 1, anime.LocalFileTypeMain, "1"),
|
||||
newAnalyzedFile(filepath.Join(root, "[Seanime] Example Show - OVA.mkv"), 1, 42, 0, anime.LocalFileTypeSpecial, "S1"),
|
||||
newAnalyzedFile(filepath.Join(root, "[Seanime] Other Show - 01.mkv"), 2, 7, 1, anime.LocalFileTypeMain, "1"),
|
||||
newAnalyzedFile(filepath.Join(root, "[Seanime] Example Show - 03.mkv"), 3, 42, 3, anime.LocalFileTypeMain, "3"),
|
||||
}
|
||||
|
||||
return &Analysis{
|
||||
files: files,
|
||||
media: &anilist.CompleteAnime{ID: 42},
|
||||
}, files
|
||||
}
|
||||
|
||||
func newAnalyzedFile(path string, index int, mediaID int, episode int, fileType anime.LocalFileType, aniDBEpisode string) *File {
|
||||
file := newFile(index, path)
|
||||
file.localFile.MediaId = mediaID
|
||||
file.localFile.Metadata = &anime.LocalFileMetadata{
|
||||
Episode: episode,
|
||||
AniDBEpisode: aniDBEpisode,
|
||||
Type: fileType,
|
||||
}
|
||||
return file
|
||||
}
|
||||
320
internal/torrents/torrent/repository_test.go
Normal file
320
internal/torrents/torrent/repository_test.go
Normal file
@@ -0,0 +1,320 @@
|
||||
package torrent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/api/metadata_provider"
|
||||
"seanime/internal/extension"
|
||||
hibiketorrent "seanime/internal/extension/hibike/torrent"
|
||||
"seanime/internal/testmocks"
|
||||
"seanime/internal/util"
|
||||
)
|
||||
|
||||
func TestRepositoryProviderSelection(t *testing.T) {
|
||||
mainProvider := newStubAnimeProvider(hibiketorrent.AnimeProviderSettings{Type: hibiketorrent.AnimeProviderTypeMain})
|
||||
fallbackProvider := newStubAnimeProvider(hibiketorrent.AnimeProviderSettings{Type: hibiketorrent.AnimeProviderTypeMain})
|
||||
specialProvider := newStubAnimeProvider(hibiketorrent.AnimeProviderSettings{Type: hibiketorrent.AnimeProviderTypeSpecial})
|
||||
|
||||
repo := newTorrentRepositoryForTests(map[string]*stubAnimeProvider{
|
||||
"main": mainProvider,
|
||||
"fallback": fallbackProvider,
|
||||
"special": specialProvider,
|
||||
}, testmocks.NewFakeMetadataProviderBuilder().Build())
|
||||
repo.SetSettings(&RepositorySettings{DefaultAnimeProvider: "fallback", AutoSelectProvider: "special"})
|
||||
|
||||
ext, ok := repo.GetDefaultAnimeProviderExtension()
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "fallback", ext.GetID())
|
||||
|
||||
ext, ok = repo.GetAnimeProviderExtensionOrFirst("missing")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "fallback", ext.GetID())
|
||||
|
||||
ext, ok = repo.GetAutoSelectProviderExtension()
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "special", ext.GetID())
|
||||
|
||||
filtered := repo.GetAnimeProviderExtensionsBy(func(ext extension.AnimeTorrentProviderExtension) bool {
|
||||
return ext.GetProvider().GetSettings().Type == hibiketorrent.AnimeProviderTypeSpecial
|
||||
})
|
||||
require.Len(t, filtered, 1)
|
||||
require.Equal(t, "special", filtered[0].GetID())
|
||||
}
|
||||
|
||||
func TestRepositoryProviderSelectionWithoutProviders(t *testing.T) {
|
||||
repo := newTorrentRepositoryForTests(nil, testmocks.NewFakeMetadataProviderBuilder().Build())
|
||||
|
||||
ext, ok := repo.GetDefaultAnimeProviderExtension()
|
||||
require.False(t, ok)
|
||||
require.Nil(t, ext)
|
||||
|
||||
ext, ok = repo.GetAnimeProviderExtensionOrFirst("missing")
|
||||
require.False(t, ok)
|
||||
require.Nil(t, ext)
|
||||
}
|
||||
|
||||
func TestSearchAnimeSimpleFallbackDedupAndSorting(t *testing.T) {
|
||||
metadataCache.Clear()
|
||||
provider := newStubAnimeProvider(hibiketorrent.AnimeProviderSettings{Type: hibiketorrent.AnimeProviderTypeMain})
|
||||
provider.searchResults = []*hibiketorrent.AnimeTorrent{
|
||||
{Name: "[SubsPlease] Example Show - 02 (1080p).mkv", InfoHash: "hash-2", Seeders: 10},
|
||||
{Name: "[Best] Example Show - 01-12 (1080p).mkv", InfoHash: "hash-best", Seeders: 1, IsBestRelease: true},
|
||||
{Name: "[Duplicate] Example Show - 02 (1080p).mkv", InfoHash: "hash-2", Seeders: 99},
|
||||
{Name: "[SubsPlease] Example Show - 01 (720p).mkv", Seeders: 20},
|
||||
}
|
||||
|
||||
fakeMetadata := testmocks.NewFakeMetadataProviderBuilder().Build()
|
||||
repo := newTorrentRepositoryForTests(map[string]*stubAnimeProvider{"main": provider}, fakeMetadata)
|
||||
repo.SetSettings(&RepositorySettings{DefaultAnimeProvider: "main"})
|
||||
|
||||
media := testmocks.NewBaseAnimeBuilder(100, "Example Show").WithEpisodes(12).Build()
|
||||
|
||||
result, err := repo.SearchAnime(context.Background(), AnimeSearchOptions{
|
||||
Provider: "missing-provider",
|
||||
Type: AnimeSearchTypeSimple,
|
||||
Media: media,
|
||||
Query: "Example Show",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, provider.searchCallsCount())
|
||||
require.Equal(t, 0, provider.smartCallsCount())
|
||||
require.Equal(t, 1, fakeMetadata.MetadataCalls(media.ID))
|
||||
require.Len(t, result.Torrents, 3)
|
||||
require.Empty(t, result.Previews)
|
||||
require.Equal(t, "hash-best", result.Torrents[0].InfoHash)
|
||||
require.Equal(t, "[SubsPlease] Example Show - 01 (720p).mkv", result.Torrents[1].InfoHash)
|
||||
require.Equal(t, "hash-2", result.Torrents[2].InfoHash)
|
||||
require.Equal(t, 10, result.Torrents[2].Seeders)
|
||||
require.Len(t, result.TorrentMetadata, 3)
|
||||
require.Contains(t, result.TorrentMetadata, "hash-best")
|
||||
require.Contains(t, result.TorrentMetadata, "hash-2")
|
||||
require.Contains(t, result.TorrentMetadata, "[SubsPlease] Example Show - 01 (720p).mkv")
|
||||
require.Nil(t, result.AnimeMetadata)
|
||||
|
||||
lastSearch := provider.lastSearchOptions()
|
||||
require.Equal(t, "Example Show", lastSearch.Query)
|
||||
require.Equal(t, media.ID, lastSearch.Media.ID)
|
||||
require.Equal(t, media.GetTotalEpisodeCount(), lastSearch.Media.EpisodeCount)
|
||||
}
|
||||
|
||||
func TestSearchAnimeSmartUsesMetadataAndSpecialProviders(t *testing.T) {
|
||||
metadataCache.Clear()
|
||||
mainProvider := newStubAnimeProvider(hibiketorrent.AnimeProviderSettings{
|
||||
Type: hibiketorrent.AnimeProviderTypeMain,
|
||||
CanSmartSearch: true,
|
||||
})
|
||||
mainProvider.smartResults = []*hibiketorrent.AnimeTorrent{{
|
||||
Name: "[Main] Example Show - 05 (1080p).mkv",
|
||||
InfoHash: "main-hash",
|
||||
Seeders: 20,
|
||||
EpisodeNumber: 5,
|
||||
}}
|
||||
|
||||
specialSimpleProvider := newStubAnimeProvider(hibiketorrent.AnimeProviderSettings{
|
||||
Type: hibiketorrent.AnimeProviderTypeSpecial,
|
||||
CanSmartSearch: false,
|
||||
})
|
||||
specialSimpleProvider.searchResults = []*hibiketorrent.AnimeTorrent{{
|
||||
Name: "[SpecialSimple] Example Show Batch (1080p).mkv",
|
||||
InfoHash: "special-simple-hash",
|
||||
Seeders: 5,
|
||||
IsBatch: true,
|
||||
}}
|
||||
|
||||
specialSmartProvider := newStubAnimeProvider(hibiketorrent.AnimeProviderSettings{
|
||||
Type: hibiketorrent.AnimeProviderTypeSpecial,
|
||||
CanSmartSearch: true,
|
||||
})
|
||||
specialSmartProvider.smartResults = []*hibiketorrent.AnimeTorrent{{
|
||||
Name: "[SpecialSmart] Example Show Batch (1080p).mkv",
|
||||
InfoHash: "special-smart-hash",
|
||||
Seeders: 8,
|
||||
IsBatch: true,
|
||||
}}
|
||||
|
||||
fakeMetadata := testmocks.NewFakeMetadataProviderBuilder().WithAnimeMetadata(200, &metadata.AnimeMetadata{
|
||||
Episodes: map[string]*metadata.EpisodeMetadata{
|
||||
"1": {Episode: "1", AbsoluteEpisodeNumber: 13},
|
||||
"5": {Episode: "5", AnidbEid: 505},
|
||||
},
|
||||
Mappings: &metadata.AnimeMappings{AnidbId: 1001},
|
||||
}).Build()
|
||||
|
||||
repo := newTorrentRepositoryForTests(map[string]*stubAnimeProvider{
|
||||
"main": mainProvider,
|
||||
"special-simple": specialSimpleProvider,
|
||||
"special-smart": specialSmartProvider,
|
||||
}, fakeMetadata)
|
||||
|
||||
media := testmocks.NewBaseAnimeBuilder(200, "Example Show").WithEpisodes(24).Build()
|
||||
|
||||
result, err := repo.SearchAnime(context.Background(), AnimeSearchOptions{
|
||||
Provider: "main",
|
||||
Type: AnimeSearchTypeSmart,
|
||||
Media: media,
|
||||
Query: "Example Show",
|
||||
Batch: true,
|
||||
EpisodeNumber: 5,
|
||||
IncludeSpecialProviders: true,
|
||||
SkipPreviews: true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, mainProvider.smartCallsCount())
|
||||
require.Equal(t, 0, mainProvider.searchCallsCount())
|
||||
require.Equal(t, 1, specialSimpleProvider.searchCallsCount())
|
||||
require.Equal(t, 0, specialSimpleProvider.smartCallsCount())
|
||||
require.Equal(t, 1, specialSmartProvider.smartCallsCount())
|
||||
require.Equal(t, 1, fakeMetadata.MetadataCalls(media.ID))
|
||||
require.ElementsMatch(t, []string{"special-simple", "special-smart"}, result.IncludedSpecialProviders)
|
||||
require.Len(t, result.Torrents, 3)
|
||||
require.Equal(t, "main-hash", result.Torrents[0].InfoHash)
|
||||
require.Equal(t, "special-smart-hash", result.Torrents[1].InfoHash)
|
||||
require.Equal(t, "special-simple-hash", result.Torrents[2].InfoHash)
|
||||
require.NotNil(t, result.AnimeMetadata)
|
||||
require.Empty(t, result.Previews)
|
||||
|
||||
lastSmart := mainProvider.lastSmartOptions()
|
||||
require.Equal(t, 1001, lastSmart.AnidbAID)
|
||||
require.Equal(t, 505, lastSmart.AnidbEID)
|
||||
require.Equal(t, 12, lastSmart.Media.AbsoluteSeasonOffset)
|
||||
require.Equal(t, 5, lastSmart.EpisodeNumber)
|
||||
require.Equal(t, "Example Show", lastSmart.Query)
|
||||
}
|
||||
|
||||
func TestSearchAnimeErrorsWhenNoProviderExists(t *testing.T) {
|
||||
metadataCache.Clear()
|
||||
repo := newTorrentRepositoryForTests(nil, testmocks.NewFakeMetadataProviderBuilder().Build())
|
||||
media := testmocks.NewBaseAnime(300, "Missing Provider")
|
||||
|
||||
result, err := repo.SearchAnime(context.Background(), AnimeSearchOptions{
|
||||
Provider: "missing",
|
||||
Type: AnimeSearchTypeSimple,
|
||||
Media: media,
|
||||
Query: "Missing Provider",
|
||||
})
|
||||
|
||||
require.Nil(t, result)
|
||||
require.EqualError(t, err, "torrent provider not found")
|
||||
}
|
||||
|
||||
type stubAnimeProvider struct {
|
||||
settings hibiketorrent.AnimeProviderSettings
|
||||
searchResults []*hibiketorrent.AnimeTorrent
|
||||
smartResults []*hibiketorrent.AnimeTorrent
|
||||
searchErr error
|
||||
smartErr error
|
||||
|
||||
mu sync.Mutex
|
||||
searchCalls int
|
||||
smartCalls int
|
||||
lastSearchOpt hibiketorrent.AnimeSearchOptions
|
||||
lastSmartOpt hibiketorrent.AnimeSmartSearchOptions
|
||||
}
|
||||
|
||||
func newStubAnimeProvider(settings hibiketorrent.AnimeProviderSettings) *stubAnimeProvider {
|
||||
return &stubAnimeProvider{settings: settings}
|
||||
}
|
||||
|
||||
func (s *stubAnimeProvider) Search(opts hibiketorrent.AnimeSearchOptions) ([]*hibiketorrent.AnimeTorrent, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.searchCalls++
|
||||
s.lastSearchOpt = opts
|
||||
return cloneTorrents(s.searchResults), s.searchErr
|
||||
}
|
||||
|
||||
func (s *stubAnimeProvider) SmartSearch(opts hibiketorrent.AnimeSmartSearchOptions) ([]*hibiketorrent.AnimeTorrent, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.smartCalls++
|
||||
s.lastSmartOpt = opts
|
||||
return cloneTorrents(s.smartResults), s.smartErr
|
||||
}
|
||||
|
||||
func (s *stubAnimeProvider) GetTorrentInfoHash(torrent *hibiketorrent.AnimeTorrent) (string, error) {
|
||||
if torrent == nil {
|
||||
return "", nil
|
||||
}
|
||||
return torrent.InfoHash, nil
|
||||
}
|
||||
|
||||
func (s *stubAnimeProvider) GetTorrentMagnetLink(*hibiketorrent.AnimeTorrent) (string, error) {
|
||||
return "magnet:?xt=urn:btih:test", nil
|
||||
}
|
||||
|
||||
func (s *stubAnimeProvider) GetLatest() ([]*hibiketorrent.AnimeTorrent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubAnimeProvider) GetSettings() hibiketorrent.AnimeProviderSettings {
|
||||
return s.settings
|
||||
}
|
||||
|
||||
func (s *stubAnimeProvider) searchCallsCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.searchCalls
|
||||
}
|
||||
|
||||
func (s *stubAnimeProvider) smartCallsCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.smartCalls
|
||||
}
|
||||
|
||||
func (s *stubAnimeProvider) lastSearchOptions() hibiketorrent.AnimeSearchOptions {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.lastSearchOpt
|
||||
}
|
||||
|
||||
func (s *stubAnimeProvider) lastSmartOptions() hibiketorrent.AnimeSmartSearchOptions {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.lastSmartOpt
|
||||
}
|
||||
|
||||
func newTorrentRepositoryForTests(providers map[string]*stubAnimeProvider, metadataProvider metadata_provider.Provider) *Repository {
|
||||
logger := zerolog.Nop()
|
||||
bank := extension.NewUnifiedBank()
|
||||
for id, provider := range providers {
|
||||
bank.Set(id, extension.NewAnimeTorrentProviderExtension(&extension.Extension{
|
||||
ID: id,
|
||||
Name: id,
|
||||
Version: "1.0.0",
|
||||
ManifestURI: "builtin",
|
||||
Language: extension.LanguageGo,
|
||||
Type: extension.TypeAnimeTorrentProvider,
|
||||
}, provider))
|
||||
}
|
||||
|
||||
return NewRepository(&NewRepositoryOptions{
|
||||
Logger: &logger,
|
||||
MetadataProviderRef: util.NewRef[metadata_provider.Provider](metadataProvider),
|
||||
ExtensionBankRef: util.NewRef(bank),
|
||||
})
|
||||
}
|
||||
|
||||
func cloneTorrents(in []*hibiketorrent.AnimeTorrent) []*hibiketorrent.AnimeTorrent {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]*hibiketorrent.AnimeTorrent, 0, len(in))
|
||||
for _, torrent := range in {
|
||||
if torrent == nil {
|
||||
out = append(out, nil)
|
||||
continue
|
||||
}
|
||||
copyTorrent := *torrent
|
||||
out = append(out, ©Torrent)
|
||||
}
|
||||
return out
|
||||
}
|
||||
381
internal/torrentstream/torrentstream_test.go
Normal file
381
internal/torrentstream/torrentstream_test.go
Normal file
@@ -0,0 +1,381 @@
|
||||
package torrentstream
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata_provider"
|
||||
"seanime/internal/database/models"
|
||||
"seanime/internal/events"
|
||||
hibiketorrent "seanime/internal/extension/hibike/torrent"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/platforms/platform"
|
||||
"seanime/internal/testmocks"
|
||||
"seanime/internal/testutil"
|
||||
"seanime/internal/util"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/samber/mo"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type recordedWSEvent struct {
|
||||
clientID string
|
||||
event string
|
||||
payload interface{}
|
||||
}
|
||||
|
||||
type recordingWSEventManager struct {
|
||||
*events.MockWSEventManager
|
||||
mu sync.Mutex
|
||||
events []recordedWSEvent
|
||||
}
|
||||
|
||||
func newRecordingWSEventManager(t *testing.T) *recordingWSEventManager {
|
||||
t.Helper()
|
||||
return &recordingWSEventManager{
|
||||
MockWSEventManager: events.NewMockWSEventManager(util.NewLogger()),
|
||||
events: make([]recordedWSEvent, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *recordingWSEventManager) SendEvent(eventType string, payload interface{}) {
|
||||
m.mu.Lock()
|
||||
m.events = append(m.events, recordedWSEvent{event: eventType, payload: payload})
|
||||
m.mu.Unlock()
|
||||
m.MockWSEventManager.SendEvent(eventType, payload)
|
||||
}
|
||||
|
||||
func (m *recordingWSEventManager) SendEventTo(clientID string, eventType string, payload interface{}, noLog ...bool) {
|
||||
m.mu.Lock()
|
||||
m.events = append(m.events, recordedWSEvent{clientID: clientID, event: eventType, payload: payload})
|
||||
m.mu.Unlock()
|
||||
m.MockWSEventManager.SendEventTo(clientID, eventType, payload, noLog...)
|
||||
}
|
||||
|
||||
func (m *recordingWSEventManager) snapshot() []recordedWSEvent {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
ret := make([]recordedWSEvent, len(m.events))
|
||||
copy(ret, m.events)
|
||||
return ret
|
||||
}
|
||||
|
||||
func newTorrentstreamTestRepository(t *testing.T) (*Repository, *testutil.TestEnv, *recordingWSEventManager) {
|
||||
t.Helper()
|
||||
metadataProvider := testmocks.NewFakeMetadataProviderBuilder().Build()
|
||||
return newTorrentstreamTestRepositoryWithMetadataProvider(t, metadataProvider)
|
||||
}
|
||||
|
||||
func newTorrentstreamTestRepositoryWithMetadataProvider(t *testing.T, metadataProvider metadata_provider.Provider) (*Repository, *testutil.TestEnv, *recordingWSEventManager) {
|
||||
t.Helper()
|
||||
env := testutil.NewTestEnv(t)
|
||||
ws := newRecordingWSEventManager(t)
|
||||
|
||||
repo := NewRepository(&NewRepositoryOptions{
|
||||
Logger: env.Logger(),
|
||||
BaseAnimeCache: anilist.NewBaseAnimeCache(),
|
||||
CompleteAnimeCache: anilist.NewCompleteAnimeCache(),
|
||||
PlatformRef: util.NewRef[platform.Platform](nil),
|
||||
MetadataProviderRef: util.NewRef[metadata_provider.Provider](metadataProvider),
|
||||
WSEventManager: ws,
|
||||
Database: env.NewDatabase(""),
|
||||
})
|
||||
|
||||
t.Cleanup(func() {
|
||||
repo.Shutdown()
|
||||
})
|
||||
|
||||
return repo, env, ws
|
||||
}
|
||||
|
||||
func TestHydrateStreamCollectionMergesAniListAndLibraryState(t *testing.T) {
|
||||
mediaInLibrary := testmocks.NewBaseAnimeBuilder(1, "Library Show").WithEpisodes(12).Build()
|
||||
mediaAlreadyQueued := testmocks.NewBaseAnimeBuilder(2, "Queued Show").WithEpisodes(12).Build()
|
||||
unreleasedMedia := testmocks.NewBaseAnimeBuilder(3, "Unreleased Show").WithEpisodes(12).WithStatus(anilist.MediaStatusNotYetReleased).Build()
|
||||
|
||||
fakeMetadata := testmocks.NewFakeMetadataProviderBuilder().
|
||||
WithAnimeMetadata(mediaInLibrary.ID, anime.NewAnimeMetadataFromEpisodeCount(mediaInLibrary, []int{1, 2, 3})).
|
||||
Build()
|
||||
|
||||
repo, _, _ := newTorrentstreamTestRepositoryWithMetadataProvider(t, fakeMetadata)
|
||||
libraryCollection := &anime.LibraryCollection{
|
||||
ContinueWatchingList: []*anime.Episode{{
|
||||
BaseAnime: mediaAlreadyQueued,
|
||||
ProgressNumber: 1,
|
||||
EpisodeNumber: 1,
|
||||
DisplayTitle: "Episode 1",
|
||||
EpisodeMetadata: &anime.EpisodeMetadata{},
|
||||
}},
|
||||
Lists: []*anime.LibraryCollectionList{{
|
||||
Status: anilist.MediaListStatusCurrent,
|
||||
Entries: []*anime.LibraryCollectionEntry{{
|
||||
Media: mediaInLibrary,
|
||||
MediaId: mediaInLibrary.ID,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
repo.HydrateStreamCollection(&HydrateStreamCollectionOptions{
|
||||
AnimeCollection: &anilist.AnimeCollection{
|
||||
MediaListCollection: &anilist.AnimeCollection_MediaListCollection{
|
||||
Lists: []*anilist.AnimeCollection_MediaListCollection_Lists{
|
||||
newAnimeCollectionList(anilist.MediaListStatusCurrent,
|
||||
newAnimeCollectionEntry(mediaInLibrary, 1, anilist.MediaListStatusCurrent),
|
||||
newAnimeCollectionEntry(mediaAlreadyQueued, 0, anilist.MediaListStatusCurrent),
|
||||
newAnimeCollectionEntry(unreleasedMedia, 0, anilist.MediaListStatusCurrent),
|
||||
),
|
||||
newAnimeCollectionList(anilist.MediaListStatusRepeating,
|
||||
newAnimeCollectionEntry(mediaInLibrary, 1, anilist.MediaListStatusRepeating),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
LibraryCollection: libraryCollection,
|
||||
MetadataProviderRef: util.NewRef[metadata_provider.Provider](fakeMetadata),
|
||||
})
|
||||
|
||||
require.NotNil(t, libraryCollection.Stream)
|
||||
require.Len(t, libraryCollection.Stream.ContinueWatchingList, 1)
|
||||
require.Len(t, libraryCollection.Stream.Anime, 1)
|
||||
require.Equal(t, mediaAlreadyQueued.ID, libraryCollection.Stream.Anime[0].ID)
|
||||
require.Contains(t, libraryCollection.Stream.ListData, mediaAlreadyQueued.ID)
|
||||
require.Equal(t, 0, libraryCollection.Stream.ListData[mediaAlreadyQueued.ID].Progress)
|
||||
require.NotContains(t, libraryCollection.Stream.ListData, unreleasedMedia.ID)
|
||||
|
||||
nextEpisode := libraryCollection.Stream.ContinueWatchingList[0]
|
||||
require.Equal(t, mediaInLibrary.ID, nextEpisode.BaseAnime.ID)
|
||||
require.Equal(t, 2, nextEpisode.EpisodeNumber)
|
||||
require.Equal(t, 2, nextEpisode.GetProgressNumber())
|
||||
require.Equal(t, "Episode 2", nextEpisode.DisplayTitle)
|
||||
|
||||
require.Equal(t, 1, fakeMetadata.MetadataCalls(mediaInLibrary.ID))
|
||||
require.Equal(t, 0, fakeMetadata.MetadataCalls(mediaAlreadyQueued.ID))
|
||||
require.Equal(t, 0, fakeMetadata.MetadataCalls(unreleasedMedia.ID))
|
||||
}
|
||||
|
||||
func TestHydrateStreamCollectionFallsBackWhenEpisodeMetadataMissing(t *testing.T) {
|
||||
media := testmocks.NewBaseAnimeBuilder(44, "Fallback Show").WithEpisodes(12).WithBannerImage("https://example.com/banner.jpg").Build()
|
||||
fakeMetadata := testmocks.NewFakeMetadataProviderBuilder().
|
||||
WithAnimeMetadata(media.ID, anime.NewAnimeMetadataFromEpisodeCount(media, []int{1})).
|
||||
Build()
|
||||
|
||||
repo, _, _ := newTorrentstreamTestRepositoryWithMetadataProvider(t, fakeMetadata)
|
||||
libraryCollection := &anime.LibraryCollection{}
|
||||
|
||||
repo.HydrateStreamCollection(&HydrateStreamCollectionOptions{
|
||||
AnimeCollection: &anilist.AnimeCollection{
|
||||
MediaListCollection: &anilist.AnimeCollection_MediaListCollection{
|
||||
Lists: []*anilist.AnimeCollection_MediaListCollection_Lists{
|
||||
newAnimeCollectionList(anilist.MediaListStatusCurrent,
|
||||
newAnimeCollectionEntry(media, 1, anilist.MediaListStatusCurrent),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
LibraryCollection: libraryCollection,
|
||||
MetadataProviderRef: util.NewRef[metadata_provider.Provider](fakeMetadata),
|
||||
})
|
||||
|
||||
require.NotNil(t, libraryCollection.Stream)
|
||||
require.Len(t, libraryCollection.Stream.ContinueWatchingList, 1)
|
||||
require.Len(t, libraryCollection.Stream.Anime, 1)
|
||||
|
||||
episode := libraryCollection.Stream.ContinueWatchingList[0]
|
||||
require.Equal(t, media.ID, episode.BaseAnime.ID)
|
||||
require.Equal(t, 2, episode.EpisodeNumber)
|
||||
require.Equal(t, 2, episode.GetProgressNumber())
|
||||
require.Equal(t, "Episode 2", episode.DisplayTitle)
|
||||
require.Equal(t, media.GetPreferredTitle(), episode.EpisodeTitle)
|
||||
require.NotNil(t, episode.EpisodeMetadata)
|
||||
require.Equal(t, media.GetBannerImageSafe(), episode.EpisodeMetadata.Image)
|
||||
require.True(t, episode.IsInvalid)
|
||||
|
||||
require.Contains(t, libraryCollection.Stream.ListData, media.ID)
|
||||
require.Equal(t, 1, libraryCollection.Stream.ListData[media.ID].Progress)
|
||||
require.Equal(t, 1, fakeMetadata.MetadataCalls(media.ID))
|
||||
}
|
||||
|
||||
func TestRepositoryDefaultsAndSettingsGuards(t *testing.T) {
|
||||
repo, _, _ := newTorrentstreamTestRepository(t)
|
||||
|
||||
require.False(t, repo.IsEnabled())
|
||||
require.EqualError(t, repo.FailIfNoSettings(), "torrentstream: no settings provided, the module is dormant")
|
||||
require.Equal(t, repo.getDefaultDownloadPath(), repo.GetDownloadDir())
|
||||
|
||||
previous, ok := repo.GetPreviousStreamOptions()
|
||||
require.False(t, ok)
|
||||
require.Nil(t, previous)
|
||||
|
||||
expected := &StartStreamOptions{MediaId: 10, EpisodeNumber: 3, AniDBEpisode: "3"}
|
||||
repo.previousStreamOptions = mo.Some(expected)
|
||||
|
||||
previous, ok = repo.GetPreviousStreamOptions()
|
||||
require.True(t, ok)
|
||||
require.Same(t, expected, previous)
|
||||
|
||||
repo.settings = mo.Some(Settings{
|
||||
TorrentstreamSettings: models.TorrentstreamSettings{
|
||||
Enabled: true,
|
||||
DownloadDir: "/custom/downloads",
|
||||
},
|
||||
Host: "127.0.0.1",
|
||||
Port: 43210,
|
||||
})
|
||||
|
||||
require.True(t, repo.IsEnabled())
|
||||
require.NoError(t, repo.FailIfNoSettings())
|
||||
require.Equal(t, "/custom/downloads", repo.GetDownloadDir())
|
||||
}
|
||||
|
||||
func TestInitModulesRejectsNilSettingsAndDisablesModule(t *testing.T) {
|
||||
repo, _, _ := newTorrentstreamTestRepository(t)
|
||||
|
||||
err := repo.InitModules(nil, "127.0.0.1", 8080)
|
||||
require.EqualError(t, err, "torrentstream: Cannot initialize module, no settings provided")
|
||||
require.True(t, repo.settings.IsAbsent())
|
||||
|
||||
err = repo.InitModules(&models.TorrentstreamSettings{Enabled: false}, "127.0.0.1", 8080)
|
||||
require.NoError(t, err)
|
||||
require.True(t, repo.settings.IsAbsent())
|
||||
require.False(t, repo.IsEnabled())
|
||||
}
|
||||
|
||||
func TestSendStateEvent(t *testing.T) {
|
||||
repo, _, ws := newTorrentstreamTestRepository(t)
|
||||
|
||||
repo.sendStateEvent(eventLoading, TLSStateSearchingTorrents)
|
||||
repo.sendStateEvent(eventTorrentLoaded)
|
||||
|
||||
eventsSnapshot := ws.snapshot()
|
||||
require.Len(t, eventsSnapshot, 2)
|
||||
require.Equal(t, events.TorrentStreamState, eventsSnapshot[0].event)
|
||||
require.Equal(t, events.TorrentStreamState, eventsSnapshot[1].event)
|
||||
|
||||
firstPayload := decodePayloadMap(t, eventsSnapshot[0].payload)
|
||||
require.Equal(t, eventLoading, firstPayload["state"])
|
||||
require.Equal(t, string(TLSStateSearchingTorrents), firstPayload["data"])
|
||||
|
||||
secondPayload := decodePayloadMap(t, eventsSnapshot[1].payload)
|
||||
require.Equal(t, eventTorrentLoaded, secondPayload["state"])
|
||||
require.Nil(t, secondPayload["data"])
|
||||
}
|
||||
|
||||
func TestGetBatchHistoryReturnsEmptyWhenMissing(t *testing.T) {
|
||||
repo, _, _ := newTorrentstreamTestRepository(t)
|
||||
|
||||
history := repo.GetBatchHistory(404)
|
||||
|
||||
require.NotNil(t, history)
|
||||
require.Nil(t, history.Torrent)
|
||||
require.Nil(t, history.Metadata)
|
||||
require.Nil(t, history.BatchEpisodeFiles)
|
||||
}
|
||||
|
||||
func TestAddBatchHistoryPersistsAndInvalidatesQueries(t *testing.T) {
|
||||
repo, _, ws := newTorrentstreamTestRepository(t)
|
||||
torrent := &hibiketorrent.AnimeTorrent{
|
||||
Provider: "provider",
|
||||
Name: "[Seanime] Example Show - 01-12 (1080p).mkv",
|
||||
InfoHash: "hash-1",
|
||||
IsBatch: true,
|
||||
}
|
||||
files := &hibiketorrent.BatchEpisodeFiles{
|
||||
Current: 0,
|
||||
CurrentEpisodeNumber: 1,
|
||||
CurrentAniDBEpisode: "1",
|
||||
Files: []*hibiketorrent.AnimeTorrentFile{{
|
||||
Index: 0,
|
||||
Path: "/downloads/[Seanime] Example Show - 01.mkv",
|
||||
Name: "[Seanime] Example Show - 01.mkv",
|
||||
}},
|
||||
}
|
||||
|
||||
repo.AddBatchHistory(100, torrent, files)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
got := repo.GetBatchHistory(100)
|
||||
return got.Torrent != nil && got.Torrent.InfoHash == "hash-1"
|
||||
}, 2*time.Second, 20*time.Millisecond)
|
||||
|
||||
history := repo.GetBatchHistory(100)
|
||||
require.NotNil(t, history.Torrent)
|
||||
require.Equal(t, torrent.InfoHash, history.Torrent.InfoHash)
|
||||
require.NotNil(t, history.Metadata)
|
||||
require.Equal(t, "Seanime", history.Metadata.ReleaseGroup)
|
||||
require.NotNil(t, history.BatchEpisodeFiles)
|
||||
require.Equal(t, 1, history.BatchEpisodeFiles.CurrentEpisodeNumber)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
for _, event := range ws.snapshot() {
|
||||
if event.event != events.InvalidateQueries {
|
||||
continue
|
||||
}
|
||||
payload, ok := event.payload.([]string)
|
||||
if ok && len(payload) == 1 && payload[0] == events.GetTorrentstreamBatchHistoryEndpoint {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 2*time.Second, 20*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestAddBatchHistoryUpdatesExistingRecord(t *testing.T) {
|
||||
repo, _, _ := newTorrentstreamTestRepository(t)
|
||||
|
||||
repo.AddBatchHistory(101, &hibiketorrent.AnimeTorrent{
|
||||
Name: "[Seanime] Example Show - 01-12 (1080p).mkv",
|
||||
InfoHash: "old-hash",
|
||||
}, nil)
|
||||
require.Eventually(t, func() bool {
|
||||
got := repo.GetBatchHistory(101)
|
||||
return got.Torrent != nil && got.Torrent.InfoHash == "old-hash"
|
||||
}, 2*time.Second, 20*time.Millisecond)
|
||||
|
||||
updatedFiles := &hibiketorrent.BatchEpisodeFiles{CurrentEpisodeNumber: 5, CurrentAniDBEpisode: "5"}
|
||||
repo.AddBatchHistory(101, &hibiketorrent.AnimeTorrent{
|
||||
Name: "[Seanime] Example Show - 05 (1080p).mkv",
|
||||
InfoHash: "new-hash",
|
||||
}, updatedFiles)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
got := repo.GetBatchHistory(101)
|
||||
return got.Torrent != nil && got.Torrent.InfoHash == "new-hash"
|
||||
}, 2*time.Second, 20*time.Millisecond)
|
||||
|
||||
history := repo.GetBatchHistory(101)
|
||||
require.Equal(t, "new-hash", history.Torrent.InfoHash)
|
||||
require.NotNil(t, history.BatchEpisodeFiles)
|
||||
require.Equal(t, 5, history.BatchEpisodeFiles.CurrentEpisodeNumber)
|
||||
require.Equal(t, "5", history.BatchEpisodeFiles.CurrentAniDBEpisode)
|
||||
}
|
||||
|
||||
func decodePayloadMap(t *testing.T, payload interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
bytes, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
ret := make(map[string]interface{})
|
||||
require.NoError(t, json.Unmarshal(bytes, &ret))
|
||||
return ret
|
||||
}
|
||||
|
||||
func newAnimeCollectionList(status anilist.MediaListStatus, entries ...*anilist.AnimeCollection_MediaListCollection_Lists_Entries) *anilist.AnimeCollection_MediaListCollection_Lists {
|
||||
return &anilist.AnimeCollection_MediaListCollection_Lists{
|
||||
Status: &status,
|
||||
Name: new(string(status)),
|
||||
IsCustomList: new(false),
|
||||
Entries: entries,
|
||||
}
|
||||
}
|
||||
|
||||
func newAnimeCollectionEntry(media *anilist.BaseAnime, progress int, status anilist.MediaListStatus) *anilist.AnimeCollection_MediaListCollection_Lists_Entries {
|
||||
return &anilist.AnimeCollection_MediaListCollection_Lists_Entries{
|
||||
Media: media,
|
||||
Progress: &progress,
|
||||
Score: new(8.5),
|
||||
Repeat: new(0),
|
||||
Status: &status,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user