Memby v0.1.53: Android TV client plus gateway

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>
This commit is contained in:
ponzischeme89
2026-07-27 08:16:20 +12:00
co-authored by Claude Opus 5
commit 2ce405c540
99 changed files with 14433 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
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
}