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:
@@ -0,0 +1,270 @@
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
)
|
||||
|
||||
// Row is one horizontal strip on the TV home screen.
|
||||
type Row struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
Items []json.RawMessage `json:"items"`
|
||||
}
|
||||
|
||||
// Source is the slice of the Emby client this package needs, narrowed so tests can
|
||||
// supply a fake without a server.
|
||||
type Source interface {
|
||||
Items(ctx context.Context, cred emby.Credentials, params url.Values) (*emby.ItemsResult, error)
|
||||
Similar(ctx context.Context, cred emby.Credentials, itemID string, params url.Values) (*emby.ItemsResult, error)
|
||||
}
|
||||
|
||||
// LibrarySource is the imported catalogue. When present, the candidate pool comes from
|
||||
// Postgres instead of Emby, which takes the rebuild off Emby entirely.
|
||||
type LibrarySource interface {
|
||||
LibraryCandidates(ctx context.Context, genres []string, limit int) ([]json.RawMessage, error)
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
source Source
|
||||
log *slog.Logger
|
||||
|
||||
// Library is optional; nil (or an empty library) falls back to querying Emby.
|
||||
Library LibrarySource
|
||||
|
||||
// MinRowItems is the shortest row worth showing. A two-item "Recommended" strip
|
||||
// looks broken next to full rows, so short rows are dropped entirely.
|
||||
MinRowItems int
|
||||
// MaxSimilarRows caps "Because you watched …" rows so the home screen stays a home
|
||||
// screen rather than a wall of near-duplicates.
|
||||
MaxSimilarRows int
|
||||
RowSize int
|
||||
}
|
||||
|
||||
func NewEngine(source Source, log *slog.Logger) *Engine {
|
||||
return &Engine{
|
||||
source: source,
|
||||
log: log,
|
||||
MinRowItems: 4,
|
||||
MaxSimilarRows: 2,
|
||||
RowSize: 20,
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
historyFields = "Genres,Studios,CommunityRating,SeriesName,ProductionYear,RunTimeTicks"
|
||||
candidateFields = "Genres,Studios,CommunityRating,ProductionYear,RunTimeTicks,PrimaryImageAspectRatio"
|
||||
rowImageTypes = "Backdrop,Primary,Logo"
|
||||
)
|
||||
|
||||
// BuildRows produces the recommendation rows for one user.
|
||||
//
|
||||
// Cost is a handful of Emby queries, which is why callers cache the result rather than
|
||||
// computing it on every home load.
|
||||
func (e *Engine) BuildRows(ctx context.Context, cred emby.Credentials) ([]Row, error) {
|
||||
history, favorites, err := e.gatherSignals(ctx, cred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profile := BuildProfile(history, favorites)
|
||||
if profile.IsEmpty() {
|
||||
// A brand-new user has nothing to recommend from. No rows is the honest answer.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rows := make([]Row, 0, e.MaxSimilarRows+1)
|
||||
for _, seed := range e.seedsFor(profile) {
|
||||
row, ok := e.similarRow(ctx, cred, profile, seed)
|
||||
if ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
|
||||
if row, ok := e.historyRow(ctx, cred, profile); ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// gatherSignals reads what the user has watched and favourited, in parallel.
|
||||
func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (history, favorites []Item, err error) {
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
firstErr error
|
||||
)
|
||||
|
||||
fetch := func(dest *[]Item, params url.Values) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result, fetchErr := e.source.Items(ctx, cred, params)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if fetchErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = fetchErr
|
||||
}
|
||||
return
|
||||
}
|
||||
*dest = Decode(result.Items)
|
||||
}()
|
||||
}
|
||||
|
||||
// In-progress titles are the strongest signal available, so they lead the history
|
||||
// list and pick up the heaviest recency weights.
|
||||
var resumable, played []Item
|
||||
fetch(&resumable, url.Values{
|
||||
"Filters": {"IsResumable"},
|
||||
"IncludeItemTypes": {"Movie,Episode"},
|
||||
"Recursive": {"true"},
|
||||
"SortBy": {"DatePlayed"},
|
||||
"SortOrder": {"Descending"},
|
||||
"Limit": {"20"},
|
||||
"Fields": {historyFields},
|
||||
"EnableUserData": {"true"},
|
||||
"EnableImages": {"false"},
|
||||
})
|
||||
fetch(&played, url.Values{
|
||||
"Filters": {"IsPlayed"},
|
||||
"IncludeItemTypes": {"Movie,Episode"},
|
||||
"Recursive": {"true"},
|
||||
"SortBy": {"DatePlayed"},
|
||||
"SortOrder": {"Descending"},
|
||||
"Limit": {"60"},
|
||||
"Fields": {historyFields},
|
||||
"EnableUserData": {"true"},
|
||||
"EnableImages": {"false"},
|
||||
})
|
||||
fetch(&favorites, url.Values{
|
||||
"Filters": {"IsFavorite"},
|
||||
"IncludeItemTypes": {"Movie,Series"},
|
||||
"Recursive": {"true"},
|
||||
"SortBy": {"SortName"},
|
||||
"Limit": {"40"},
|
||||
"Fields": {historyFields},
|
||||
"EnableUserData": {"true"},
|
||||
"EnableImages": {"false"},
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
if firstErr != nil {
|
||||
return nil, nil, firstErr
|
||||
}
|
||||
return append(resumable, played...), favorites, nil
|
||||
}
|
||||
|
||||
// libraryCandidates reads the pool from the imported library. Returns ok=false when
|
||||
// there is no library, it is empty, or it errors — every one of which means "ask Emby".
|
||||
func (e *Engine) libraryCandidates(ctx context.Context, genres []string) ([]Item, bool) {
|
||||
if e.Library == nil {
|
||||
return nil, false
|
||||
}
|
||||
raws, err := e.Library.LibraryCandidates(ctx, genres, e.RowSize*6)
|
||||
if err != nil {
|
||||
e.log.Warn("library candidates failed; falling back to emby", "error", err)
|
||||
return nil, false
|
||||
}
|
||||
if len(raws) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return Decode(raws), true
|
||||
}
|
||||
|
||||
func (e *Engine) seedsFor(profile Profile) []Seed {
|
||||
if len(profile.Seeds) <= e.MaxSimilarRows {
|
||||
return profile.Seeds
|
||||
}
|
||||
return profile.Seeds[:e.MaxSimilarRows]
|
||||
}
|
||||
|
||||
// similarRow asks Emby what resembles a title the user just watched. Emby's own
|
||||
// similarity scoring beats anything computed here, so this only filters out the seen.
|
||||
func (e *Engine) similarRow(ctx context.Context, cred emby.Credentials, profile Profile, seed Seed) (Row, bool) {
|
||||
result, err := e.source.Similar(ctx, cred, seed.ID, url.Values{
|
||||
"UserId": {cred.UserID},
|
||||
"Limit": {strconv.Itoa(e.RowSize * 2)},
|
||||
"Fields": {candidateFields},
|
||||
"ImageTypeLimit": {"1"},
|
||||
"EnableImageTypes": {rowImageTypes},
|
||||
"EnableUserData": {"true"},
|
||||
})
|
||||
if err != nil {
|
||||
// One dead row should never sink the home screen.
|
||||
e.log.Warn("similar lookup failed", "seed", seed.ID, "error", err)
|
||||
return Row{}, false
|
||||
}
|
||||
|
||||
items := FilterUnseen(profile, Decode(result.Items), e.RowSize)
|
||||
if len(items) < e.MinRowItems {
|
||||
return Row{}, false
|
||||
}
|
||||
return Row{
|
||||
ID: "similar:" + seed.ID,
|
||||
Title: "Because you watched " + seed.Name,
|
||||
Kind: "similar",
|
||||
Items: Raws(items),
|
||||
}, true
|
||||
}
|
||||
|
||||
// historyRow is the genre-affinity row: unwatched titles from the genres the user has
|
||||
// been spending time in, ranked by how closely they match the whole profile.
|
||||
func (e *Engine) historyRow(ctx context.Context, cred emby.Credentials, profile Profile) (Row, bool) {
|
||||
genres := profile.TopGenres(3)
|
||||
if len(genres) == 0 {
|
||||
return Row{}, false
|
||||
}
|
||||
|
||||
if candidates, ok := e.libraryCandidates(ctx, genres); ok {
|
||||
items := Rank(profile, candidates, e.RowSize)
|
||||
if len(items) < e.MinRowItems {
|
||||
return Row{}, false
|
||||
}
|
||||
return Row{
|
||||
ID: "recommended",
|
||||
Title: "Recommended from your watching history",
|
||||
Kind: "recommended",
|
||||
Items: Raws(items),
|
||||
}, true
|
||||
}
|
||||
|
||||
// Emby treats "|" as OR in a Genres filter, so one query covers every top genre.
|
||||
result, err := e.source.Items(ctx, cred, url.Values{
|
||||
"IncludeItemTypes": {"Movie,Series"},
|
||||
"Recursive": {"true"},
|
||||
"Filters": {"IsUnplayed"},
|
||||
"Genres": {strings.Join(genres, "|")},
|
||||
"SortBy": {"CommunityRating"},
|
||||
"SortOrder": {"Descending"},
|
||||
"Limit": {"120"},
|
||||
"Fields": {candidateFields},
|
||||
"ImageTypeLimit": {"1"},
|
||||
"EnableImages": {"true"},
|
||||
"EnableImageTypes": {rowImageTypes},
|
||||
"EnableUserData": {"true"},
|
||||
})
|
||||
if err != nil {
|
||||
e.log.Warn("recommendation candidates failed", "error", err)
|
||||
return Row{}, false
|
||||
}
|
||||
|
||||
items := Rank(profile, Decode(result.Items), e.RowSize)
|
||||
if len(items) < e.MinRowItems {
|
||||
return Row{}, false
|
||||
}
|
||||
return Row{
|
||||
ID: "recommended",
|
||||
Title: "Recommended from your watching history",
|
||||
Kind: "recommended",
|
||||
Items: Raws(items),
|
||||
}, true
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Package recommend turns a user's Emby watch history into home-screen rows.
|
||||
//
|
||||
// The scoring here is deliberately simple and explainable — genre and studio affinity
|
||||
// weighted by recency, penalised for what the user has already seen. It runs against one
|
||||
// household's library, where a heavier model would have neither the data to learn from
|
||||
// nor a way to show its work when a row looks wrong.
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// recencyDecay is applied per position down the history list. At 0.94, the 12th item
|
||||
// carries about half the weight of the most recent one, so tastes can shift without the
|
||||
// rows lagging weeks behind.
|
||||
const recencyDecay = 0.94
|
||||
|
||||
// favoriteWeight is what an explicit favourite contributes. Deliberately below a fresh
|
||||
// play: favouriting is a durable signal, but what someone watched last night is a better
|
||||
// predictor of what they want tonight.
|
||||
const favoriteWeight = 0.6
|
||||
|
||||
// Item is the slice of an Emby item this package reasons about. The raw payload rides
|
||||
// along so rows can be emitted without re-fetching or re-encoding.
|
||||
type Item struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
SeriesID string `json:"SeriesId"`
|
||||
SeriesName string `json:"SeriesName"`
|
||||
Genres []string `json:"Genres"`
|
||||
CommunityRating float64 `json:"CommunityRating"`
|
||||
Studios []struct {
|
||||
Name string `json:"Name"`
|
||||
} `json:"Studios"`
|
||||
UserData struct {
|
||||
Played bool `json:"Played"`
|
||||
PlayCount int `json:"PlayCount"`
|
||||
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
|
||||
IsFavorite bool `json:"IsFavorite"`
|
||||
} `json:"UserData"`
|
||||
|
||||
Raw json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
// Seed is a title recent enough to anchor a "Because you watched …" row.
|
||||
type Seed struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
// Profile is what the engine learned about one user.
|
||||
type Profile struct {
|
||||
GenreWeights map[string]float64
|
||||
StudioWeights map[string]float64
|
||||
// Seen holds item ids *and* series ids already watched or in progress, so a
|
||||
// recommendation never suggests something the user is already partway through.
|
||||
Seen map[string]bool
|
||||
Seeds []Seed
|
||||
}
|
||||
|
||||
func (p Profile) IsEmpty() bool { return len(p.GenreWeights) == 0 && len(p.Seeds) == 0 }
|
||||
|
||||
// Decode parses raw Emby items, keeping the original payload attached.
|
||||
func Decode(raws []json.RawMessage) []Item {
|
||||
items := make([]Item, 0, len(raws))
|
||||
for _, raw := range raws {
|
||||
var item Item
|
||||
if err := json.Unmarshal(raw, &item); err != nil || item.ID == "" {
|
||||
continue
|
||||
}
|
||||
item.Raw = raw
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// BuildProfile weights history by recency and folds in favourites.
|
||||
//
|
||||
// history must be ordered most-recent-first; favourites are unordered and all carry the
|
||||
// same weight.
|
||||
func BuildProfile(history, favorites []Item) Profile {
|
||||
profile := Profile{
|
||||
GenreWeights: map[string]float64{},
|
||||
StudioWeights: map[string]float64{},
|
||||
Seen: map[string]bool{},
|
||||
}
|
||||
|
||||
seedSeen := map[string]bool{}
|
||||
for i, item := range history {
|
||||
weight := math.Pow(recencyDecay, float64(i))
|
||||
profile.absorb(item, weight)
|
||||
|
||||
// An episode seeds its series, not itself: "Because you watched Severance"
|
||||
// reads better than "Because you watched Good News".
|
||||
seedID, seedName := item.ID, item.Name
|
||||
if item.SeriesID != "" {
|
||||
seedID, seedName = item.SeriesID, item.SeriesName
|
||||
}
|
||||
if seedID != "" && seedName != "" && !seedSeen[seedID] {
|
||||
seedSeen[seedID] = true
|
||||
profile.Seeds = append(profile.Seeds, Seed{ID: seedID, Name: seedName})
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range favorites {
|
||||
profile.absorb(item, favoriteWeight)
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func (p *Profile) absorb(item Item, weight float64) {
|
||||
if item.ID != "" {
|
||||
p.Seen[item.ID] = true
|
||||
}
|
||||
if item.SeriesID != "" {
|
||||
p.Seen[item.SeriesID] = true
|
||||
}
|
||||
for _, genre := range item.Genres {
|
||||
if g := strings.TrimSpace(genre); g != "" {
|
||||
p.GenreWeights[g] += weight
|
||||
}
|
||||
}
|
||||
for _, studio := range item.Studios {
|
||||
if s := strings.TrimSpace(studio.Name); s != "" {
|
||||
// Studio is a weaker signal than genre: people follow what a thing *is*
|
||||
// more reliably than who made it.
|
||||
p.StudioWeights[s] += weight * 0.4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TopGenres returns the n heaviest genres, highest first. Ties break alphabetically so
|
||||
// the Emby query — and therefore the cached row — is stable between calls.
|
||||
func (p Profile) TopGenres(n int) []string {
|
||||
type kv struct {
|
||||
genre string
|
||||
weight float64
|
||||
}
|
||||
pairs := make([]kv, 0, len(p.GenreWeights))
|
||||
for genre, weight := range p.GenreWeights {
|
||||
pairs = append(pairs, kv{genre, weight})
|
||||
}
|
||||
sort.Slice(pairs, func(i, j int) bool {
|
||||
if pairs[i].weight != pairs[j].weight {
|
||||
return pairs[i].weight > pairs[j].weight
|
||||
}
|
||||
return pairs[i].genre < pairs[j].genre
|
||||
})
|
||||
if n > len(pairs) {
|
||||
n = len(pairs)
|
||||
}
|
||||
out := make([]string, 0, n)
|
||||
for _, pair := range pairs[:n] {
|
||||
out = append(out, pair.genre)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Score rates a candidate against the profile. A negative score means "exclude".
|
||||
func (p Profile) Score(candidate Item) float64 {
|
||||
if p.Seen[candidate.ID] {
|
||||
return -1
|
||||
}
|
||||
if candidate.SeriesID != "" && p.Seen[candidate.SeriesID] {
|
||||
return -1
|
||||
}
|
||||
if candidate.UserData.Played || candidate.UserData.PlaybackPositionTicks > 0 {
|
||||
return -1
|
||||
}
|
||||
|
||||
var genreScore float64
|
||||
for _, genre := range candidate.Genres {
|
||||
genreScore += p.GenreWeights[strings.TrimSpace(genre)]
|
||||
}
|
||||
// Divide by sqrt(genre count) so a title tagged with eight genres cannot outrank a
|
||||
// focused match simply by touching more of the profile.
|
||||
if n := len(candidate.Genres); n > 1 {
|
||||
genreScore /= math.Sqrt(float64(n))
|
||||
}
|
||||
|
||||
var studioScore float64
|
||||
for _, studio := range candidate.Studios {
|
||||
studioScore += p.StudioWeights[strings.TrimSpace(studio.Name)]
|
||||
}
|
||||
|
||||
// A mild quality nudge, capped so a beloved genre still beats a well-rated stranger.
|
||||
ratingScore := candidate.CommunityRating / 10 * 0.5
|
||||
|
||||
return genreScore + studioScore + ratingScore
|
||||
}
|
||||
|
||||
// Rank scores, filters and truncates candidates, dropping duplicates by id.
|
||||
func Rank(profile Profile, candidates []Item, limit int) []Item {
|
||||
type scored struct {
|
||||
item Item
|
||||
score float64
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
ranked := make([]scored, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if seen[candidate.ID] {
|
||||
continue
|
||||
}
|
||||
seen[candidate.ID] = true
|
||||
if score := profile.Score(candidate); score > 0 {
|
||||
ranked = append(ranked, scored{candidate, score})
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
if ranked[i].score != ranked[j].score {
|
||||
return ranked[i].score > ranked[j].score
|
||||
}
|
||||
return ranked[i].item.Name < ranked[j].item.Name
|
||||
})
|
||||
|
||||
if limit > 0 && len(ranked) > limit {
|
||||
ranked = ranked[:limit]
|
||||
}
|
||||
out := make([]Item, 0, len(ranked))
|
||||
for _, entry := range ranked {
|
||||
out = append(out, entry.item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FilterUnseen keeps only what the user has not watched, preserving Emby's ordering.
|
||||
// Used for "Because you watched …", where Emby's own similarity ranking is better than
|
||||
// anything this package would compute.
|
||||
func FilterUnseen(profile Profile, candidates []Item, limit int) []Item {
|
||||
out := make([]Item, 0, len(candidates))
|
||||
seen := map[string]bool{}
|
||||
for _, candidate := range candidates {
|
||||
if seen[candidate.ID] || profile.Score(candidate) < 0 {
|
||||
continue
|
||||
}
|
||||
seen[candidate.ID] = true
|
||||
out = append(out, candidate)
|
||||
if limit > 0 && len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Raws unwraps items back to the payloads the TV will receive.
|
||||
func Raws(items []Item) []json.RawMessage {
|
||||
out := make([]json.RawMessage, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, item.Raw)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func item(id, name, itemType string, genres []string, rating float64) Item {
|
||||
return Item{ID: id, Name: name, Type: itemType, Genres: genres, CommunityRating: rating}
|
||||
}
|
||||
|
||||
func episode(id, name, seriesID, seriesName string, genres []string) Item {
|
||||
it := item(id, name, "Episode", genres, 0)
|
||||
it.SeriesID = seriesID
|
||||
it.SeriesName = seriesName
|
||||
return it
|
||||
}
|
||||
|
||||
func TestBuildProfileWeightsRecentHistoryHigher(t *testing.T) {
|
||||
history := []Item{
|
||||
item("1", "Newest", "Movie", []string{"Science Fiction"}, 8),
|
||||
item("2", "Older", "Movie", []string{"Comedy"}, 8),
|
||||
}
|
||||
profile := BuildProfile(history, nil)
|
||||
|
||||
if profile.GenreWeights["Science Fiction"] <= profile.GenreWeights["Comedy"] {
|
||||
t.Fatalf("recent genre should outweigh older: %+v", profile.GenreWeights)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProfileSeedsSeriesRatherThanEpisode(t *testing.T) {
|
||||
history := []Item{
|
||||
episode("ep1", "Good News", "sev", "Severance", []string{"Drama"}),
|
||||
}
|
||||
profile := BuildProfile(history, nil)
|
||||
|
||||
if len(profile.Seeds) != 1 {
|
||||
t.Fatalf("expected one seed, got %+v", profile.Seeds)
|
||||
}
|
||||
if profile.Seeds[0].ID != "sev" || profile.Seeds[0].Name != "Severance" {
|
||||
t.Fatalf("expected the series as seed, got %+v", profile.Seeds[0])
|
||||
}
|
||||
// The series must count as seen, or we would recommend a show already in progress.
|
||||
if !profile.Seen["sev"] {
|
||||
t.Fatal("series id should be marked seen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProfileDeduplicatesSeeds(t *testing.T) {
|
||||
history := []Item{
|
||||
episode("ep2", "Half Loop", "sev", "Severance", nil),
|
||||
episode("ep1", "Good News", "sev", "Severance", nil),
|
||||
item("m1", "Dune", "Movie", nil, 0),
|
||||
}
|
||||
profile := BuildProfile(history, nil)
|
||||
|
||||
if len(profile.Seeds) != 2 {
|
||||
t.Fatalf("expected 2 distinct seeds, got %d: %+v", len(profile.Seeds), profile.Seeds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavoritesContributeLessThanAFreshPlay(t *testing.T) {
|
||||
fromHistory := BuildProfile([]Item{item("1", "A", "Movie", []string{"Horror"}, 0)}, nil)
|
||||
fromFavorite := BuildProfile(nil, []Item{item("2", "B", "Movie", []string{"Horror"}, 0)})
|
||||
|
||||
if fromFavorite.GenreWeights["Horror"] >= fromHistory.GenreWeights["Horror"] {
|
||||
t.Fatal("a favourite should weigh less than the most recent play")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopGenresIsDeterministicOnTies(t *testing.T) {
|
||||
profile := Profile{GenreWeights: map[string]float64{"Western": 1, "Action": 1, "Drama": 2}}
|
||||
for range 20 {
|
||||
got := profile.TopGenres(3)
|
||||
want := []string{"Drama", "Action", "Western"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unstable ordering: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreExcludesWhatTheUserAlreadySaw(t *testing.T) {
|
||||
profile := BuildProfile([]Item{item("seen", "Seen", "Movie", []string{"Drama"}, 0)}, nil)
|
||||
|
||||
if score := profile.Score(item("seen", "Seen", "Movie", []string{"Drama"}, 8)); score >= 0 {
|
||||
t.Fatalf("watched item should be excluded, scored %v", score)
|
||||
}
|
||||
|
||||
inProgress := item("new", "New", "Movie", []string{"Drama"}, 8)
|
||||
inProgress.UserData.PlaybackPositionTicks = 500
|
||||
if score := profile.Score(inProgress); score >= 0 {
|
||||
t.Fatalf("in-progress item should be excluded, scored %v", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreExcludesEpisodesOfASeriesInProgress(t *testing.T) {
|
||||
profile := BuildProfile([]Item{episode("ep1", "Pilot", "sev", "Severance", []string{"Drama"})}, nil)
|
||||
|
||||
candidate := episode("ep9", "Finale", "sev", "Severance", []string{"Drama"})
|
||||
if score := profile.Score(candidate); score >= 0 {
|
||||
t.Fatalf("another episode of a watched series should be excluded, scored %v", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreDoesNotRewardGenreStuffing(t *testing.T) {
|
||||
profile := Profile{
|
||||
GenreWeights: map[string]float64{"Drama": 1, "Action": 1, "Comedy": 1, "Horror": 1},
|
||||
StudioWeights: map[string]float64{},
|
||||
Seen: map[string]bool{},
|
||||
}
|
||||
|
||||
focused := item("a", "Focused", "Movie", []string{"Drama"}, 0)
|
||||
stuffed := item("b", "Stuffed", "Movie", []string{"Drama", "Action", "Comedy", "Horror"}, 0)
|
||||
|
||||
// The stuffed title still scores higher — it genuinely matches more of the profile —
|
||||
// but the sqrt penalty must keep it from scoring 4x the focused one.
|
||||
if profile.Score(stuffed) >= 4*profile.Score(focused) {
|
||||
t.Fatalf("genre stuffing was not penalised: focused=%v stuffed=%v",
|
||||
profile.Score(focused), profile.Score(stuffed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankOrdersByAffinityAndDropsDuplicates(t *testing.T) {
|
||||
profile := BuildProfile([]Item{item("h", "History", "Movie", []string{"Science Fiction"}, 0)}, nil)
|
||||
|
||||
candidates := []Item{
|
||||
item("c1", "Comedy Pick", "Movie", []string{"Comedy"}, 9),
|
||||
item("c2", "Sci-Fi Pick", "Movie", []string{"Science Fiction"}, 5),
|
||||
item("c2", "Sci-Fi Pick (dupe)", "Movie", []string{"Science Fiction"}, 5),
|
||||
item("h", "History", "Movie", []string{"Science Fiction"}, 10),
|
||||
}
|
||||
|
||||
ranked := Rank(profile, candidates, 10)
|
||||
|
||||
if len(ranked) != 2 {
|
||||
t.Fatalf("expected 2 results (dupe collapsed, watched dropped), got %d: %+v", len(ranked), ranked)
|
||||
}
|
||||
if ranked[0].ID != "c2" {
|
||||
t.Fatalf("genre affinity should beat a higher rating, got %q first", ranked[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankRespectsLimit(t *testing.T) {
|
||||
profile := Profile{GenreWeights: map[string]float64{"Drama": 1}, Seen: map[string]bool{}}
|
||||
candidates := make([]Item, 0, 30)
|
||||
for i := range 30 {
|
||||
candidates = append(candidates, item(string(rune('a'+i)), "Title", "Movie", []string{"Drama"}, 5))
|
||||
}
|
||||
if got := len(Rank(profile, candidates, 8)); got != 8 {
|
||||
t.Fatalf("limit not applied: got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeKeepsRawPayload(t *testing.T) {
|
||||
raw := json.RawMessage(`{"Id":"1","Name":"Dune","Type":"Movie","Genres":["Science Fiction"],"ImageTags":{"Primary":"abc"}}`)
|
||||
items := Decode([]json.RawMessage{raw, json.RawMessage(`{"broken":`), json.RawMessage(`{"Name":"no id"}`)})
|
||||
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected malformed and id-less items to be skipped, got %d", len(items))
|
||||
}
|
||||
// The raw payload must survive untouched: it carries image tags the TV needs and
|
||||
// that this package never models.
|
||||
if string(items[0].Raw) != string(raw) {
|
||||
t.Fatalf("raw payload was altered: %s", items[0].Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterUnseenPreservesEmbyOrdering(t *testing.T) {
|
||||
profile := BuildProfile([]Item{item("seen", "Seen", "Movie", nil, 0)}, nil)
|
||||
candidates := []Item{
|
||||
item("seen", "Seen", "Movie", nil, 0),
|
||||
item("b", "Second", "Movie", nil, 0),
|
||||
item("a", "First", "Movie", nil, 0),
|
||||
}
|
||||
|
||||
got := FilterUnseen(profile, candidates, 10)
|
||||
|
||||
if len(got) != 2 || got[0].ID != "b" || got[1].ID != "a" {
|
||||
t.Fatalf("ordering not preserved: %+v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user