App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
@@ -0,0 +1,152 @@
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
)
|
||||
|
||||
func relatedSubject() Item {
|
||||
decoded := Decode([]json.RawMessage{
|
||||
json.RawMessage(`{"Id":"subject","Name":"The Subject","Type":"Movie","Genres":["Thriller"]}`),
|
||||
})
|
||||
return decoded[0]
|
||||
}
|
||||
|
||||
func relatedEngine(source Source) *Engine {
|
||||
engine := NewEngine(source, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
engine.MinRowItems = 2
|
||||
return engine
|
||||
}
|
||||
|
||||
func relatedCatalogue(prefix string, count int) []json.RawMessage {
|
||||
out := make([]json.RawMessage, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
id := prefix + string(rune('a'+i))
|
||||
out = append(out, json.RawMessage(
|
||||
`{"Id":"`+id+`","Name":"`+id+`","Type":"Movie","Genres":["Thriller"]}`,
|
||||
))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func relatedIDs(items []Item) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, item.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The taste profile costs the same Emby fan-out the home rows pay for, and it is the
|
||||
// half of this answer a detail page can do without. Losing it must cost the reasons, not
|
||||
// the carousel — before this the whole request 502'd and the page said it had failed.
|
||||
func TestRelatedSurvivesAProfileThatCannotBeBuilt(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
itemsErr: errors.New("emby is unwell"),
|
||||
similar: map[string][]json.RawMessage{"subject": relatedCatalogue("similar-", 4)},
|
||||
}
|
||||
|
||||
reasons, related, err := relatedEngine(source).RelatedTo(
|
||||
context.Background(), emby.Credentials{UserID: "u1"}, relatedSubject(), 12,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected a degraded answer, got error: %v", err)
|
||||
}
|
||||
if len(related) != 4 {
|
||||
t.Fatalf("expected Emby's similar list, got %v", relatedIDs(related))
|
||||
}
|
||||
for _, reason := range reasons {
|
||||
if reason == "" {
|
||||
t.Fatal("a reason must never be blank")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A viewer who navigated on is not a fault, and continuing to fan out on their behalf
|
||||
// spends Emby requests nobody is waiting for.
|
||||
func TestRelatedReportsAnAbandonedRequest(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
source := &fakeSource{itemsErr: context.Canceled}
|
||||
_, _, err := relatedEngine(source).RelatedTo(
|
||||
ctx, emby.Credentials{UserID: "u1"}, relatedSubject(), 12,
|
||||
)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected the cancellation to be reported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Emby erroring and Emby honestly knowing nothing similar look identical on a TV, so both
|
||||
// fall through to the household's own catalogue in the same genres.
|
||||
func TestRelatedFallsBackToTheImportedCatalogue(t *testing.T) {
|
||||
for name, source := range map[string]*fakeSource{
|
||||
"similar lookup failed": {similarErr: errors.New("emby is unwell")},
|
||||
"nothing similar known": {similar: map[string][]json.RawMessage{}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
engine := relatedEngine(source)
|
||||
engine.Library = &fakeForYouLibrary{items: relatedCatalogue("library-", 3)}
|
||||
|
||||
_, related, err := engine.RelatedTo(
|
||||
context.Background(), emby.Credentials{UserID: "u1"}, relatedSubject(), 12,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("the fallback must not fail: %v", err)
|
||||
}
|
||||
if len(related) != 3 {
|
||||
t.Fatalf("expected the genre pool, got %v", relatedIDs(related))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue always contains the title the page is about, and Emby's own similarity
|
||||
// list occasionally does.
|
||||
func TestRelatedNeverIncludesItsOwnSubject(t *testing.T) {
|
||||
subject := json.RawMessage(
|
||||
`{"Id":"subject","Name":"The Subject","Type":"Movie","Genres":["Thriller"]}`,
|
||||
)
|
||||
source := &fakeSource{similar: map[string][]json.RawMessage{
|
||||
"subject": append([]json.RawMessage{subject}, relatedCatalogue("similar-", 3)...),
|
||||
}}
|
||||
engine := relatedEngine(source)
|
||||
engine.Library = &fakeForYouLibrary{items: []json.RawMessage{subject}}
|
||||
|
||||
_, related, err := engine.RelatedTo(
|
||||
context.Background(), emby.Credentials{UserID: "u1"}, relatedSubject(), 12,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, item := range related {
|
||||
if item.ID == "subject" {
|
||||
t.Fatalf("the page's own title was returned to it: %v", relatedIDs(related))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With no imported catalogue there is nothing to fall back to, and an empty carousel is
|
||||
// still an answer the page can open with.
|
||||
func TestRelatedWithoutALibraryStillAnswers(t *testing.T) {
|
||||
source := &fakeSource{similarErr: errors.New("emby is unwell")}
|
||||
|
||||
reasons, related, err := relatedEngine(source).RelatedTo(
|
||||
context.Background(), emby.Credentials{UserID: "u1"}, relatedSubject(), 12,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected an empty answer, got error: %v", err)
|
||||
}
|
||||
if len(related) != 0 {
|
||||
t.Fatalf("expected nothing, got %v", relatedIDs(related))
|
||||
}
|
||||
if len(reasons) == 0 {
|
||||
t.Fatal("catalogue facts should still explain the title")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user