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:
ponzischeme89
2026-08-06 22:33:56 +12:00
co-authored by Claude Opus 5
parent 2675e6d82b
commit 4a4df7a73c
257 changed files with 24868 additions and 3108 deletions
+90 -14
View File
@@ -982,25 +982,60 @@ func (e *Engine) similarRow(ctx context.Context, cred emby.Credentials, profile
// in the library is like it.
//
// Both halves come from the same profile the home rows are built from, so the page can
// never explain a taste the engine does not hold. A failed Similar lookup still returns
// the reasons — the strip under the description is worth more than the carousel.
// never explain a taste the engine does not hold. Every part of it degrades rather than
// fails: the only error this returns is the viewer having gone away, because a detail
// page that cannot explain itself still has to open, and a carousel is worth having
// without a reason strip above it.
func (e *Engine) RelatedTo(
ctx context.Context,
cred emby.Credentials,
item Item,
limit int,
) (reasons []string, related []Item, err error) {
history, favorites, err := e.gatherSignals(ctx, cred)
if err != nil {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
profile := BuildProfile(history, favorites)
reasons = Why(profile, item, ReasonLimit)
if limit <= 0 {
limit = e.RowSize
}
result, similarErr := e.source.Similar(ctx, cred, item.ID, url.Values{
// The profile is the expensive half and the optional one: it costs the same Emby
// fan-out the home rows pay for, and everything below still works without it. Why
// falls back to catalogue facts and FilterUnseen keeps everything, so a household
// whose history request timed out gets a carousel instead of a failed page.
var profile Profile
history, favorites, signalErr := e.gatherSignals(ctx, cred)
switch {
case signalErr != nil && ctx.Err() != nil:
return nil, nil, ctx.Err()
case signalErr != nil:
e.log.Warn(
"related profile unavailable; answering without one",
"item", item.ID, "error", signalErr,
)
default:
profile = BuildProfile(history, favorites)
}
reasons = Why(profile, item, ReasonLimit)
return reasons, e.relatedCandidates(ctx, cred, profile, item, limit), nil
}
// relatedCandidates fills the carousel from the best source that answers, in falling
// order of quality: Emby's own similarity ranking, then the imported catalogue's titles
// in the same genres. The second exists because the first has two failure modes that
// look identical on a TV — Emby erroring, and Emby honestly knowing nothing similar
// about a title nobody has tagged — and an empty strip on a detail page reads as broken
// either way.
func (e *Engine) relatedCandidates(
ctx context.Context,
cred emby.Credentials,
profile Profile,
item Item,
limit int,
) []Item {
var candidates []Item
result, err := e.source.Similar(ctx, cred, item.ID, url.Values{
"UserId": {cred.UserID},
"Limit": {strconv.Itoa(limit * 2)},
"Fields": {candidateFields},
@@ -1009,19 +1044,60 @@ func (e *Engine) RelatedTo(
"EnableImageTypes": {rowImageTypes},
"EnableUserData": {"true"},
})
if similarErr != nil {
e.log.Warn("related lookup failed", "item", item.ID, "error", similarErr)
return reasons, nil, nil
switch {
case err != nil && ctx.Err() != nil:
return nil
case err != nil:
e.log.Warn("related lookup failed", "item", item.ID, "error", err)
case result != nil:
candidates = excludeItem(Decode(result.Items), item.ID)
}
candidates := Decode(result.Items)
related = FilterUnseen(profile, candidates, limit)
related := FilterUnseen(profile, candidates, limit)
// A carousel of two looks broken. Someone deep into a franchise has seen most of
// what resembles it, so fall back to Emby's unfiltered order rather than a stub.
if len(related) < e.MinRowItems && len(candidates) > len(related) {
related = trim(candidates, limit)
}
return reasons, related, nil
if len(related) >= e.MinRowItems {
return related
}
if neighbours := e.genreNeighbours(ctx, profile, item, limit); len(neighbours) > len(related) {
return neighbours
}
return related
}
// genreNeighbours is the answer of last resort: the household's own catalogue, in this
// title's genres, best rated first. It reads from Postgres rather than Emby, so it is
// also the only half of this that still works while Emby is the thing that is down.
func (e *Engine) genreNeighbours(ctx context.Context, profile Profile, item Item, limit int) []Item {
if e.Library == nil || len(item.Genres) == 0 || ctx.Err() != nil {
return nil
}
raws, err := e.Library.LibraryCandidates(ctx, item.Genres, limit*6)
if err != nil {
e.log.Warn("related genre fallback failed", "item", item.ID, "error", err)
return nil
}
candidates := excludeItem(Decode(raws), item.ID)
if unseen := FilterUnseen(profile, candidates, limit); len(unseen) > 0 {
return unseen
}
return trim(candidates, limit)
}
// excludeItem drops the title the page is about. Emby occasionally returns it in its own
// similarity list, and the imported catalogue always does.
func excludeItem(items []Item, id string) []Item {
out := make([]Item, 0, len(items))
for _, candidate := range items {
if candidate.ID == id {
continue
}
out = append(out, candidate)
}
return out
}
func trim(items []Item, limit int) []Item {
+8 -3
View File
@@ -45,9 +45,11 @@ func DefaultWeightedConfig() WeightedConfig {
}
type Person struct {
Name string `json:"Name"`
Type string `json:"Type"`
Role string `json:"Role"`
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
Role string `json:"Role"`
PrimaryImageTag string `json:"PrimaryImageTag"`
}
// Affinity retains its evidence count so a single accidental play cannot silently
@@ -88,10 +90,12 @@ type ViewingEvidence struct {
type OnboardingPreferences struct {
Completed bool `json:"completed"`
Prompted bool `json:"prompted,omitempty"`
Ratings map[string]int `json:"ratings,omitempty"`
Genres []string `json:"genres,omitempty"`
Studios []string `json:"studios,omitempty"`
Actors []string `json:"actors,omitempty"`
Actresses []string `json:"actresses,omitempty"`
Directors []string `json:"directors,omitempty"`
ContentTypes []string `json:"contentTypes,omitempty"`
}
@@ -112,6 +116,7 @@ func (p *WeightedProfile) ApplyOnboarding(preferences OnboardingPreferences, min
add(p.Genres, preferences.Genres)
add(p.Studios, preferences.Studios)
add(p.Actors, preferences.Actors)
add(p.Actors, preferences.Actresses)
add(p.Directors, preferences.Directors)
add(p.ContentTypes, preferences.ContentTypes)
}
+9
View File
@@ -98,6 +98,15 @@ func TestOnboardingRatingCreatesImmediateMetadataAffinity(t *testing.T) {
}
}
func TestOnboardingActressesFeedActorAffinity(t *testing.T) {
profile := WeightedProfile{}
profile.ApplyOnboarding(OnboardingPreferences{Actresses: []string{"Amy Adams"}}, 2)
got := profile.Actors["amy adams"]
if got.Evidence != 2 || got.Weight <= 0 {
t.Fatalf("actress affinity = %#v", got)
}
}
func TestWeightedRankNewReleaseEligibilityPrecedesPersonalization(t *testing.T) {
now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
old := rankedFixture("old", "Perfect Old Match", "Movie", []string{"Drama"}, 90)
+152
View File
@@ -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")
}
}