improve tests

This commit is contained in:
5rahim
2026-04-01 21:42:56 +02:00
parent 0b8f548c87
commit b35a36f5ce
34 changed files with 5257 additions and 1497 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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 Journeys End",
"native": "葬送のフリーレン"
},
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx154587-n1fmjRv4JQUd.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx154587-n1fmjRv4JQUd.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx154587-n1fmjRv4JQUd.jpg",
"color": "#d6f1c9"
},
"startDate": {
"year": 2023,
"month": 9,
"day": 29
},
"endDate": {},
"nextAiringEpisode": {
"airingAt": 1700229600,
"timeUntilAiring": 223940,
"episode": 11
},
"relations": {
"edges": [
{
"relationType": "SOURCE",
"node": {
"id": 118586,
"idMal": 126287,
"siteUrl": "https://anilist.co/manga/118586",
"status": "RELEASING",
"type": "MANGA",
"format": "MANGA",
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/118586-1JLJiwaIlnBp.jpg",
"synonyms": [
"Frieren at the Funeral",
"장송의 프리렌",
"Frieren: Oltre la Fine del Viaggio",
"คำอธิษฐานในวันที่จากลา Frieren",
"Frieren e a Jornada para o Além",
"Frieren Nach dem Ende der Reise",
"葬送的芙莉蓮",
"Frieren After \"The End\"",
"Frieren: Remnants of the Departed",
"Frieren. U kresu drogi",
"Frieren",
"FRIEREN: Más allá del fin del viaje"
],
"isAdult": false,
"countryOfOrigin": "JP",
"title": {
"userPreferred": "Sousou no Frieren",
"romaji": "Sousou no Frieren",
"english": "Frieren: Beyond Journeys End",
"native": "葬送のフリーレン"
},
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx118586-F0Lp86XQV7du.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx118586-F0Lp86XQV7du.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx118586-F0Lp86XQV7du.jpg",
"color": "#e4ae5d"
},
"startDate": {
"year": 2020,
"month": 4,
"day": 28
},
"endDate": {}
}
},
{
"relationType": "CHARACTER",
"node": {
"id": 169811,
"idMal": 56805,
"siteUrl": "https://anilist.co/anime/169811",
"status": "FINISHED",
"type": "ANIME",
"format": "MUSIC",
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/169811-jgMVZlIdH19a.jpg",
"episodes": 1,
"isAdult": false,
"countryOfOrigin": "JP",
"title": {
"userPreferred": "Yuusha",
"romaji": "Yuusha",
"native": "勇者"
},
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx169811-H0RW7WHkRlbH.png",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx169811-H0RW7WHkRlbH.png",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx169811-H0RW7WHkRlbH.png"
},
"startDate": {
"year": 2023,
"month": 9,
"day": 29
},
"endDate": {
"year": 2023,
"month": 9,
"day": 29
}
}
},
{
"relationType": "SIDE_STORY",
"node": {
"id": 170068,
"idMal": 56885,
"siteUrl": "https://anilist.co/anime/170068",
"status": "RELEASING",
"season": "FALL",
"type": "ANIME",
"format": "ONA",
"synonyms": [
"Sousou no Frieren Mini Anime",
"Frieren: Beyond Journeys End Mini Anime",
"葬送のフリーレン ミニアニメ"
],
"isAdult": false,
"countryOfOrigin": "JP",
"title": {
"userPreferred": "Sousou no Frieren: ●● no Mahou",
"romaji": "Sousou no Frieren: ●● no Mahou",
"native": "葬送のフリーレン ~●●の魔法~"
},
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx170068-ijY3tCP8KoWP.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx170068-ijY3tCP8KoWP.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx170068-ijY3tCP8KoWP.jpg",
"color": "#bbd678"
},
"startDate": {
"year": 2023,
"month": 10,
"day": 11
},
"endDate": {}
}
}
]
}
}
}
]
}
]
}
}
},
"146065": {
"localFiles": [],
"animeCollection": {
"MediaListCollection": {
"lists": [
{
"status": "CURRENT",
"entries": [
{
"id": 366466419,
"score": 0,
"progress": 0,
"status": "CURRENT",
"repeat": 0,
"private": false,
"startedAt": {
"year": 2023,
"month": 10,
"day": 4
},
"completedAt": {
"year": 2023,
"month": 10,
"day": 9
},
"media": {
"id": 146065,
"idMal": 51179,
"siteUrl": "https://anilist.co/anime/146065",
"status": "FINISHED",
"season": "SUMMER",
"type": "ANIME",
"format": "TV",
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/146065-33RDijfuxLLk.jpg",
"episodes": 13,
"synonyms": [
"ชาตินี้พี่ต้องเทพ ภาค 2",
"Mushoku Tensei: Isekai Ittara Honki Dasu 2nd Season",
"Mushoku Tensei II: Jobless Reincarnation",
"Mushoku Tensei II: Reencarnación desde cero",
"无职转生到了异世界就拿出真本事第2季"
],
"isAdult": false,
"countryOfOrigin": "JP",
"title": {
"userPreferred": "Mushoku Tensei II: Isekai Ittara Honki Dasu",
"romaji": "Mushoku Tensei II: Isekai Ittara Honki Dasu",
"english": "Mushoku Tensei: Jobless Reincarnation Season 2",
"native": "無職転生 Ⅱ ~異世界行ったら本気だす~"
},
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx146065-IjirxRK26O03.png",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx146065-IjirxRK26O03.png",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx146065-IjirxRK26O03.png",
"color": "#35aee4"
},
"startDate": {
"year": 2023,
"month": 7,
"day": 3
},
"endDate": {
"year": 2023,
"month": 9,
"day": 25
},
"relations": {
"edges": [
{
"relationType": "SOURCE",
"node": {
"id": 85470,
"idMal": 70261,
"siteUrl": "https://anilist.co/manga/85470",
"status": "FINISHED",
"type": "MANGA",
"format": "NOVEL",
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85470-akkFSKH9aacB.jpg",
"synonyms": [
"เกิดชาตินี้พี่ต้องเทพ"
],
"isAdult": false,
"countryOfOrigin": "JP",
"title": {
"userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu",
"romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu",
"english": "Mushoku Tensei: Jobless Reincarnation",
"native": "無職転生 ~異世界行ったら本気だす~"
},
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx85470-jt6BF9tDWB2X.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx85470-jt6BF9tDWB2X.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx85470-jt6BF9tDWB2X.jpg",
"color": "#f1bb1a"
},
"startDate": {
"year": 2014,
"month": 1,
"day": 23
},
"endDate": {
"year": 2022,
"month": 11,
"day": 25
}
}
},
{
"relationType": "ALTERNATIVE",
"node": {
"id": 85564,
"idMal": 70259,
"siteUrl": "https://anilist.co/manga/85564",
"status": "RELEASING",
"type": "MANGA",
"format": "MANGA",
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85564-Wy8IQU3Km61c.jpg",
"synonyms": [
"Mushoku Tensei: Uma segunda chance"
],
"isAdult": false,
"countryOfOrigin": "JP",
"title": {
"userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu",
"romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu",
"english": "Mushoku Tensei: Jobless Reincarnation",
"native": "無職転生 ~異世界行ったら本気だす~"
},
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx85564-egXRASF0x9B9.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx85564-egXRASF0x9B9.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx85564-egXRASF0x9B9.jpg",
"color": "#e4ae0d"
},
"startDate": {
"year": 2014,
"month": 5,
"day": 2
},
"endDate": {}
}
},
{
"relationType": "PREQUEL",
"node": {
"id": 127720,
"idMal": 45576,
"siteUrl": "https://anilist.co/anime/127720",
"status": "FINISHED",
"season": "FALL",
"type": "ANIME",
"format": "TV",
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/127720-oBpHiMWQhFVN.jpg",
"episodes": 12,
"synonyms": [
"Mushoku Tensei: Jobless Reincarnation Part 2",
"ชาตินี้พี่ต้องเทพ พาร์ท 2"
],
"isAdult": false,
"countryOfOrigin": "JP",
"title": {
"userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu Part 2",
"romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu Part 2",
"english": "Mushoku Tensei: Jobless Reincarnation Cour 2",
"native": "無職転生 ~異世界行ったら本気だす~ 第2クール"
},
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx127720-ADJgIrUVMdU9.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx127720-ADJgIrUVMdU9.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx127720-ADJgIrUVMdU9.jpg",
"color": "#d6bb1a"
},
"startDate": {
"year": 2021,
"month": 10,
"day": 4
},
"endDate": {
"year": 2021,
"month": 12,
"day": 20
}
}
},
{
"relationType": "ALTERNATIVE",
"node": {
"id": 142989,
"idMal": 142765,
"siteUrl": "https://anilist.co/manga/142989",
"status": "RELEASING",
"type": "MANGA",
"format": "MANGA",
"synonyms": [
"Mushoku Tensei - Depressed Magician"
],
"isAdult": false,
"countryOfOrigin": "JP",
"title": {
"userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu - Shitsui no Majutsushi-hen",
"romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu - Shitsui no Majutsushi-hen",
"native": "無職転生 ~異世界行ったら本気だす~ 失意の魔術師編"
},
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx142989-jYDNHLwdER70.png",
"large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx142989-jYDNHLwdER70.png",
"medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx142989-jYDNHLwdER70.png",
"color": "#e4bb28"
},
"startDate": {
"year": 2021,
"month": 12,
"day": 20
},
"endDate": {}
}
},
{
"relationType": "SEQUEL",
"node": {
"id": 166873,
"idMal": 55888,
"siteUrl": "https://anilist.co/anime/166873",
"status": "NOT_YET_RELEASED",
"season": "SPRING",
"type": "ANIME",
"format": "TV",
"episodes": 12,
"synonyms": [
"Mushoku Tensei: Jobless Reincarnation Season 2 Part 2",
"ชาตินี้พี่ต้องเทพ ภาค 2",
"Mushoku Tensei: Isekai Ittara Honki Dasu 2nd Season Part 2",
"Mushoku Tensei II: Jobless Reincarnation Part 2",
"Mushoku Tensei II: Reencarnación desde cero",
"无职转生到了异世界就拿出真本事第2季"
],
"isAdult": false,
"countryOfOrigin": "JP",
"title": {
"userPreferred": "Mushoku Tensei II: Isekai Ittara Honki Dasu Part 2",
"romaji": "Mushoku Tensei II: Isekai Ittara Honki Dasu Part 2",
"native": "無職転生 Ⅱ ~異世界行ったら本気だす~ 第2クール"
},
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx166873-cqMLPB00KcEI.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx166873-cqMLPB00KcEI.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx166873-cqMLPB00KcEI.jpg",
"color": "#6b501a"
},
"startDate": {
"year": 2024,
"month": 4
},
"endDate": {
"year": 2024,
"month": 6
}
}
}
]
}
}
}
]
}
]
}
}
}
}

View File

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

View File

@@ -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(),

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

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

View File

@@ -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,

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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"

View File

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

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

View File

@@ -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() {

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

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

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

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

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

View File

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

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

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

View 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, &copyTorrent)
}
return out
}

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