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
|
||||
}
|
||||
Reference in New Issue
Block a user