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