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
+342
View File
@@ -0,0 +1,342 @@
// Package library imports Emby's catalogue into Postgres so the gateway can answer from
// its own copy instead of asking Emby on every request.
//
// Two shapes of import:
//
// - **full** — page through everything, then delete whatever the pass did not touch.
// Run once to seed, and again whenever the library has been reorganised.
// - **incremental** — ask Emby only for items changed since the last successful run.
// Cheap enough to run hourly, which is what new episodes need; films appearing
// weekly are picked up by the same pass.
//
// Only shared metadata is imported (EnableUserData=false). Watched state, favourites and
// resume positions are per-user and stay live.
package library
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
)
const (
// pageSize balances round trips against Emby's response size. 500 items of metadata
// is roughly a megabyte of JSON.
pageSize = 500
// syncFields is everything the gateway serves or filters on. Images are requested as
// tags only — the artwork itself is proxied on demand.
syncFields = "Genres,Studios,Overview,Taglines,ProductionYear,CommunityRating,OfficialRating," +
"RunTimeTicks,SeriesName,PrimaryImageAspectRatio,DateCreated,MediaStreams"
syncImageTypes = "Backdrop,Primary,Logo,Thumb"
syncItemTypes = "Movie,Series,Episode"
)
// ErrNoCredentials means nothing has ever signed in and no service account is set, so
// there is no way to talk to Emby on the library's behalf.
var ErrNoCredentials = errors.New("library: no emby credentials available for sync")
type Syncer struct {
emby *emby.Client
store *store.Store
log *slog.Logger
// serviceCred is the optional configured account. When empty, the newest TV session
// is borrowed instead.
serviceCred emby.Credentials
mu sync.Mutex
running bool
}
func NewSyncer(embyClient *emby.Client, st *store.Store, serviceCred emby.Credentials, log *slog.Logger) *Syncer {
return &Syncer{emby: embyClient, store: st, serviceCred: serviceCred, log: log}
}
// Result summarises one import.
type Result struct {
Kind string `json:"kind"`
Seen int `json:"seen"`
Upserted int `json:"upserted"`
Removed int `json:"removed"`
Duration time.Duration `json:"-"`
DurationMs int64 `json:"durationMs"`
}
// Running reports whether an import is in flight, so the admin page can disable its
// buttons and the scheduler can skip a tick.
func (s *Syncer) Running() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
}
// Sync runs an import. kind is "full" or "incremental"; an incremental run with no prior
// successful sync silently upgrades itself to a full one, because there is no watermark
// to work from.
func (s *Syncer) Sync(ctx context.Context, kind, trigger string) (Result, error) {
s.mu.Lock()
if s.running {
s.mu.Unlock()
return Result{}, errors.New("library: a sync is already running")
}
s.running = true
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.running = false
s.mu.Unlock()
}()
cred, err := s.credentials(ctx)
if err != nil {
return Result{}, err
}
var since *time.Time
if kind == "incremental" {
since, err = s.store.LastSuccessfulSyncAt(ctx)
if err != nil {
return Result{}, err
}
if since == nil {
s.log.Info("no previous sync; upgrading to a full import")
kind = "full"
}
}
startedAt := time.Now().UTC()
runID, err := s.store.StartSyncRun(ctx, kind, trigger)
if err != nil {
return Result{}, err
}
result, syncErr := s.run(ctx, cred, kind, since, startedAt)
result.Kind = kind
result.Duration = time.Since(startedAt)
result.DurationMs = result.Duration.Milliseconds()
record := store.SyncRun{
Status: store.SyncStatusSuccess,
ItemsSeen: result.Seen,
ItemsUpserted: result.Upserted,
ItemsRemoved: result.Removed,
}
if syncErr != nil {
record.Status = store.SyncStatusFailed
record.Error = syncErr.Error()
}
// Always record the outcome, even when the caller's context died mid-import.
if err := s.store.FinishSyncRun(context.WithoutCancel(ctx), runID, record); err != nil {
s.log.Error("could not record sync run", "error", err)
}
if syncErr != nil {
return result, syncErr
}
s.log.Info("library sync finished",
"kind", kind, "trigger", trigger, "seen", result.Seen,
"upserted", result.Upserted, "removed", result.Removed, "ms", result.DurationMs)
return result, nil
}
func (s *Syncer) run(
ctx context.Context,
cred emby.Credentials,
kind string,
since *time.Time,
syncedAt time.Time,
) (Result, error) {
var result Result
for startIndex := 0; ; startIndex += pageSize {
params := url.Values{
"IncludeItemTypes": {syncItemTypes},
"Recursive": {"true"},
"SortBy": {"DateCreated"},
"SortOrder": {"Ascending"},
"StartIndex": {strconv.Itoa(startIndex)},
"Limit": {strconv.Itoa(pageSize)},
"Fields": {syncFields},
"ImageTypeLimit": {"1"},
"EnableImages": {"true"},
"EnableImageTypes": {syncImageTypes},
"EnableTotalRecordCount": {"false"},
// The imported copy is shared by every user, so it must not carry one
// user's watched/favourite state.
"EnableUserData": {"false"},
}
if since != nil {
// Emby returns items created or edited after this instant. Overlap by a
// minute so an item saved during the previous run is not missed.
params.Set("MinDateLastSaved", since.Add(-time.Minute).UTC().Format(time.RFC3339))
}
page, err := s.emby.Items(ctx, cred, params)
if err != nil {
return result, fmt.Errorf("library: fetch page at %d: %w", startIndex, err)
}
if len(page.Items) == 0 {
break
}
items := make([]store.LibraryItem, 0, len(page.Items))
for _, raw := range page.Items {
if item, ok := toLibraryItem(raw); ok {
items = append(items, item)
}
}
written, err := s.store.UpsertLibraryItems(ctx, items, syncedAt)
if err != nil {
return result, err
}
result.Seen += len(page.Items)
result.Upserted += int(written)
if len(page.Items) < pageSize {
break
}
}
// Only a full pass has seen everything, so only a full pass may delete.
if kind == "full" {
removed, err := s.store.DeleteLibraryItemsBefore(ctx, syncedAt)
if err != nil {
return result, err
}
result.Removed = int(removed)
}
return result, nil
}
// credentials prefers the configured service account and otherwise borrows the most
// recent TV session.
func (s *Syncer) credentials(ctx context.Context) (emby.Credentials, error) {
if s.serviceCred.Token != "" && s.serviceCred.UserID != "" {
return s.serviceCred, nil
}
sess, err := s.store.NewestSession(ctx)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return emby.Credentials{}, ErrNoCredentials
}
return emby.Credentials{}, err
}
return emby.Credentials{
UserID: sess.EmbyUserID,
Token: sess.EmbyToken,
DeviceID: "memby-gateway-sync",
}, nil
}
// Schedule runs an incremental import on an interval until ctx is cancelled.
//
// New episodes tend to land through the day and films weekly; an hourly incremental pass
// covers both without ever asking Emby for the whole catalogue again.
func (s *Syncer) Schedule(ctx context.Context, interval time.Duration) {
if interval <= 0 {
s.log.Info("library auto-sync disabled")
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
s.log.Info("library auto-sync scheduled", "interval", interval.String())
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if s.Running() {
s.log.Info("skipping scheduled sync; one is already running")
continue
}
if _, err := s.Sync(ctx, "incremental", "schedule"); err != nil {
if errors.Is(err, ErrNoCredentials) {
// Nobody has signed in yet. Not worth an error-level log every hour.
s.log.Info("skipping scheduled sync; no credentials yet")
continue
}
s.log.Error("scheduled sync failed", "error", err)
}
}
}
}
// syncItem mirrors the Emby fields promoted to columns.
type syncItem struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
SeriesID string `json:"SeriesId"`
SeriesName string `json:"SeriesName"`
ProductionYear *int `json:"ProductionYear"`
CommunityRating *float64 `json:"CommunityRating"`
Genres []string `json:"Genres"`
Studios []struct {
Name string `json:"Name"`
} `json:"Studios"`
DateCreated *time.Time `json:"DateCreated"`
}
// toLibraryItem flattens the columns Postgres filters on while keeping the payload whole.
func toLibraryItem(raw json.RawMessage) (store.LibraryItem, bool) {
var parsed syncItem
if err := json.Unmarshal(raw, &parsed); err != nil || parsed.ID == "" {
return store.LibraryItem{}, false
}
studios := make([]string, 0, len(parsed.Studios))
for _, studio := range parsed.Studios {
if name := strings.TrimSpace(studio.Name); name != "" {
studios = append(studios, name)
}
}
genres := make([]string, 0, len(parsed.Genres))
for _, genre := range parsed.Genres {
if g := strings.TrimSpace(genre); g != "" {
genres = append(genres, g)
}
}
return store.LibraryItem{
ID: parsed.ID,
Type: parsed.Type,
Name: parsed.Name,
SeriesID: parsed.SeriesID,
SeriesName: parsed.SeriesName,
ProductionYear: parsed.ProductionYear,
CommunityRating: parsed.CommunityRating,
Genres: genres,
Studios: studios,
DateCreated: parsed.DateCreated,
SearchText: searchText(parsed),
Payload: raw,
}, true
}
// searchText is what full-text search matches against. Series name is included so
// searching a show finds its episodes.
func searchText(parsed syncItem) string {
parts := []string{parsed.Name}
if parsed.SeriesName != "" && !strings.EqualFold(parsed.SeriesName, parsed.Name) {
parts = append(parts, parsed.SeriesName)
}
if parsed.ProductionYear != nil {
parts = append(parts, strconv.Itoa(*parsed.ProductionYear))
}
parts = append(parts, parsed.Genres...)
return strings.Join(parts, " ")
}
+79
View File
@@ -0,0 +1,79 @@
package library
import (
"encoding/json"
"strings"
"testing"
)
func TestToLibraryItemFlattensColumnsAndKeepsPayload(t *testing.T) {
raw := json.RawMessage(`{
"Id":"42","Name":"Arrival","Type":"Movie","ProductionYear":2016,
"CommunityRating":7.9,"Genres":["Science Fiction"," Drama "],
"Studios":[{"Name":"Paramount"},{"Name":" "}],
"DateCreated":"2024-03-01T10:00:00Z",
"ImageTags":{"Primary":"abc"}
}`)
item, ok := toLibraryItem(raw)
if !ok {
t.Fatal("expected the item to parse")
}
if item.ID != "42" || item.Name != "Arrival" || item.Type != "Movie" {
t.Fatalf("unexpected columns: %+v", item)
}
if *item.ProductionYear != 2016 || *item.CommunityRating != 7.9 {
t.Fatalf("unexpected numbers: %+v", item)
}
// Whitespace-only studio names are dropped, real ones trimmed.
if len(item.Studios) != 1 || item.Studios[0] != "Paramount" {
t.Fatalf("unexpected studios: %v", item.Studios)
}
if len(item.Genres) != 2 || item.Genres[1] != "Drama" {
t.Fatalf("genres should be trimmed: %v", item.Genres)
}
if item.DateCreated == nil || item.DateCreated.Year() != 2024 {
t.Fatalf("unexpected date: %v", item.DateCreated)
}
// The payload must survive byte-identical: it is what the TV receives, and it holds
// fields (image tags, overview) that no column models.
if string(item.Payload) != string(raw) {
t.Fatal("payload was altered")
}
}
func TestToLibraryItemRejectsUnusableRows(t *testing.T) {
for name, raw := range map[string]string{
"malformed": `{"Id":`,
"no id": `{"Name":"Nameless"}`,
} {
if _, ok := toLibraryItem(json.RawMessage(raw)); ok {
t.Fatalf("%s should have been rejected", name)
}
}
}
func TestSearchTextIncludesSeriesNameSoEpisodesAreFindable(t *testing.T) {
year := 2022
text := searchText(syncItem{
Name: "Good News About Hell",
Type: "Episode",
SeriesName: "Severance",
ProductionYear: &year,
Genres: []string{"Drama", "Thriller"},
})
for _, want := range []string{"Good News About Hell", "Severance", "2022", "Drama"} {
if !strings.Contains(text, want) {
t.Fatalf("search text %q is missing %q", text, want)
}
}
}
func TestSearchTextDoesNotRepeatTheTitleForAMovie(t *testing.T) {
text := searchText(syncItem{Name: "Dune", Type: "Movie", SeriesName: "Dune"})
if strings.Count(strings.ToLower(text), "dune") != 1 {
t.Fatalf("title should appear once, got %q", text)
}
}