Android TV client for Emby (Kotlin, Compose for TV) and the Memby gateway (Go, Postgres, Redis) that fronts it. Client: - Setup, profiles, home rows, Media3 playback, system screensaver (Dream) - Backend chosen at build time: gateway when memby.gatewayUrl is set, otherwise direct to Emby. Both paths stay working. - Server-composed home rows, rendered verbatim so new row types ship without an app release - Full-screen animated maintenance state, row engagement telemetry Gateway: - One request per TV screen; auth, caching, search and row shaping - Library import from Emby into Postgres (manual, then hourly incremental) - Recommendations from viewing history (recency-weighted genre affinity) - Admin page for imports, an offline switch, and per-row analytics - Video always direct-plays from Emby; only metadata passes through Identity is com.ponzischeme89.memby throughout, replacing com.mattcohen.embyclientsname. A changed applicationId installs as a new app: TVs need a fresh sign-in and the old package uninstalled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
231 lines
7.1 KiB
Go
231 lines
7.1 KiB
Go
package recommend
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/emby"
|
|
)
|
|
|
|
// fakeSource records the queries the engine makes and replays canned answers.
|
|
type fakeSource struct {
|
|
mu sync.Mutex
|
|
|
|
itemsByFilter map[string][]json.RawMessage
|
|
similar map[string][]json.RawMessage
|
|
itemsErr error
|
|
similarErr error
|
|
|
|
genreQueries []string
|
|
similarSeeds []string
|
|
}
|
|
|
|
func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Values) (*emby.ItemsResult, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.itemsErr != nil {
|
|
return nil, f.itemsErr
|
|
}
|
|
if genres := params.Get("Genres"); genres != "" {
|
|
f.genreQueries = append(f.genreQueries, genres)
|
|
}
|
|
key := params.Get("Filters")
|
|
return &emby.ItemsResult{Items: f.itemsByFilter[key]}, nil
|
|
}
|
|
|
|
func (f *fakeSource) Similar(_ context.Context, _ emby.Credentials, itemID string, _ url.Values) (*emby.ItemsResult, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.similarSeeds = append(f.similarSeeds, itemID)
|
|
if f.similarErr != nil {
|
|
return nil, f.similarErr
|
|
}
|
|
return &emby.ItemsResult{Items: f.similar[itemID]}, nil
|
|
}
|
|
|
|
func raw(id, name, itemType string, genres ...string) json.RawMessage {
|
|
quoted := make([]string, 0, len(genres))
|
|
for _, g := range genres {
|
|
quoted = append(quoted, `"`+g+`"`)
|
|
}
|
|
return json.RawMessage(`{"Id":"` + id + `","Name":"` + name + `","Type":"` + itemType +
|
|
`","Genres":[` + strings.Join(quoted, ",") + `],"CommunityRating":7.5}`)
|
|
}
|
|
|
|
func testEngine(source Source) *Engine {
|
|
engine := NewEngine(source, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
engine.MinRowItems = 2
|
|
return engine
|
|
}
|
|
|
|
func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) {
|
|
source := &fakeSource{
|
|
itemsByFilter: map[string][]json.RawMessage{
|
|
"IsResumable": {raw("ep1", "Good News", "Episode", "Drama")},
|
|
"IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")},
|
|
"IsFavorite": {raw("m2", "Arrival", "Movie", "Science Fiction")},
|
|
"IsUnplayed": {
|
|
raw("c1", "Blade Runner", "Movie", "Science Fiction"),
|
|
raw("c2", "Solaris", "Movie", "Science Fiction"),
|
|
raw("c3", "Barbie", "Movie", "Comedy"),
|
|
},
|
|
},
|
|
similar: map[string][]json.RawMessage{
|
|
"ep1": {raw("s1", "Devs", "Series", "Drama"), raw("s2", "Mr Robot", "Series", "Drama")},
|
|
"m1": {raw("s3", "Foundation", "Series", "Science Fiction"), raw("s4", "Arrival II", "Movie", "Science Fiction")},
|
|
},
|
|
}
|
|
|
|
rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
|
|
if err != nil {
|
|
t.Fatalf("BuildRows: %v", err)
|
|
}
|
|
if len(rows) != 3 {
|
|
t.Fatalf("expected 2 similar rows + 1 history row, got %d: %+v", len(rows), rowTitles(rows))
|
|
}
|
|
|
|
if rows[0].Kind != "similar" || !strings.HasPrefix(rows[0].Title, "Because you watched ") {
|
|
t.Fatalf("unexpected first row: %+v", rows[0])
|
|
}
|
|
last := rows[len(rows)-1]
|
|
if last.Kind != "recommended" || last.Title != "Recommended from your watching history" {
|
|
t.Fatalf("unexpected history row: %+v", last)
|
|
}
|
|
if last.ID != "recommended" {
|
|
t.Fatalf("history row id should be stable, got %q", last.ID)
|
|
}
|
|
}
|
|
|
|
func TestBuildRowsQueriesTheProfilesTopGenres(t *testing.T) {
|
|
source := &fakeSource{
|
|
itemsByFilter: map[string][]json.RawMessage{
|
|
"IsPlayed": {
|
|
raw("m1", "Dune", "Movie", "Science Fiction"),
|
|
raw("m2", "Alien", "Movie", "Science Fiction", "Horror"),
|
|
},
|
|
"IsUnplayed": {raw("c1", "Solaris", "Movie", "Science Fiction")},
|
|
},
|
|
}
|
|
|
|
if _, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"}); err != nil {
|
|
t.Fatalf("BuildRows: %v", err)
|
|
}
|
|
|
|
if len(source.genreQueries) != 1 {
|
|
t.Fatalf("expected a single OR'd genre query, got %v", source.genreQueries)
|
|
}
|
|
// Emby reads "|" as OR, so one query covers every top genre.
|
|
if !strings.HasPrefix(source.genreQueries[0], "Science Fiction") {
|
|
t.Fatalf("heaviest genre should lead the query, got %q", source.genreQueries[0])
|
|
}
|
|
}
|
|
|
|
func TestBuildRowsExcludesAlreadyWatchedFromSimilarRow(t *testing.T) {
|
|
source := &fakeSource{
|
|
itemsByFilter: map[string][]json.RawMessage{
|
|
"IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")},
|
|
},
|
|
similar: map[string][]json.RawMessage{
|
|
// Emby suggests something the user already finished; it must not appear.
|
|
"m1": {raw("m1", "Dune", "Movie", "Science Fiction"), raw("s1", "Foundation", "Series", "Science Fiction")},
|
|
},
|
|
}
|
|
engine := testEngine(source)
|
|
engine.MinRowItems = 1
|
|
|
|
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
|
|
if err != nil {
|
|
t.Fatalf("BuildRows: %v", err)
|
|
}
|
|
|
|
for _, row := range rows {
|
|
for _, item := range row.Items {
|
|
if strings.Contains(string(item), `"Id":"m1"`) {
|
|
t.Fatalf("row %q contained an already-watched item", row.ID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildRowsDropsRowsShorterThanTheMinimum(t *testing.T) {
|
|
source := &fakeSource{
|
|
itemsByFilter: map[string][]json.RawMessage{
|
|
"IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")},
|
|
"IsUnplayed": {raw("c1", "Solaris", "Movie", "Science Fiction")},
|
|
},
|
|
similar: map[string][]json.RawMessage{
|
|
"m1": {raw("s1", "Foundation", "Series", "Science Fiction")},
|
|
},
|
|
}
|
|
engine := testEngine(source)
|
|
engine.MinRowItems = 5
|
|
|
|
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
|
|
if err != nil {
|
|
t.Fatalf("BuildRows: %v", err)
|
|
}
|
|
if len(rows) != 0 {
|
|
t.Fatalf("expected short rows to be dropped, got %v", rowTitles(rows))
|
|
}
|
|
}
|
|
|
|
func TestBuildRowsReturnsNothingForAUserWithNoHistory(t *testing.T) {
|
|
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
|
|
|
|
rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "new"})
|
|
if err != nil {
|
|
t.Fatalf("BuildRows: %v", err)
|
|
}
|
|
if len(rows) != 0 {
|
|
t.Fatalf("a new user should get no rows, got %v", rowTitles(rows))
|
|
}
|
|
if len(source.similarSeeds) != 0 {
|
|
t.Fatal("no seeds means no similarity lookups should be attempted")
|
|
}
|
|
}
|
|
|
|
// A failing similarity lookup is one dead row, not a dead home screen.
|
|
func TestBuildRowsSurvivesASimilarLookupFailure(t *testing.T) {
|
|
source := &fakeSource{
|
|
itemsByFilter: map[string][]json.RawMessage{
|
|
"IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")},
|
|
"IsUnplayed": {
|
|
raw("c1", "Solaris", "Movie", "Science Fiction"),
|
|
raw("c2", "Blade Runner", "Movie", "Science Fiction"),
|
|
},
|
|
},
|
|
similarErr: errors.New("emby is unwell"),
|
|
}
|
|
|
|
rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
|
|
if err != nil {
|
|
t.Fatalf("BuildRows should not fail: %v", err)
|
|
}
|
|
if len(rows) != 1 || rows[0].Kind != "recommended" {
|
|
t.Fatalf("expected the history row to survive, got %v", rowTitles(rows))
|
|
}
|
|
}
|
|
|
|
func TestBuildRowsFailsWhenHistoryCannotBeRead(t *testing.T) {
|
|
source := &fakeSource{itemsErr: errors.New("emby down")}
|
|
|
|
if _, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"}); err == nil {
|
|
t.Fatal("expected an error when the history queries fail")
|
|
}
|
|
}
|
|
|
|
func rowTitles(rows []Row) []string {
|
|
out := make([]string, 0, len(rows))
|
|
for _, row := range rows {
|
|
out = append(out, row.Title)
|
|
}
|
|
return out
|
|
}
|