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
+3
View File
@@ -0,0 +1,3 @@
.git
*.md
bin/
+21
View File
@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1
FROM golang:1.26-alpine AS build
WORKDIR /src
# Dependencies first so edits to the source don't re-download the module cache.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \
-ldflags="-s -w" -o /out/memby-server ./cmd/memby-server
# distroless/static carries CA certificates, which the gateway needs to reach an
# HTTPS Emby server, and runs as a non-root user by default.
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/memby-server /app/memby-server
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app/memby-server"]
+246
View File
@@ -0,0 +1,246 @@
# Memby gateway
A small Go service that sits between the Memby Android TV client and Emby. It owns
authentication, caching, search and the shaping of TV screens, so the client can stay a
thin renderer.
Video never passes through here. `/v1/items/{id}/playback` returns a direct-play URL
pointing at Emby itself; only metadata and artwork traverse the gateway.
## Run it
```bash
cp .env.example .env # from the repo root
$EDITOR .env # set MEMBY_EMBY_URL and POSTGRES_PASSWORD
docker compose up -d --build
curl localhost:8080/readyz
```
Then build the TV app against it:
```powershell
.\gradlew.bat assembleDebug -Pmemby.gatewayUrl=http://<host>:8080
```
Leaving `memby.gatewayUrl` blank keeps the app on its original direct-to-Emby path, so a
gateway outage is one rebuild away from being routed around.
## Local development
```bash
go build ./...
go test ./...
go run ./cmd/memby-server # needs Postgres + Redis reachable
```
On Windows, `go` may need `-buildvcs=false` when the working tree has no usable `.git`.
## API
All `/v1` routes need `Authorization: Bearer <token>` from `/v1/auth/login`. Image URLs
accept `?t=<token>` instead, because the client's image loader fetches plain URLs with no
headers attached.
| Method | Path | Purpose |
| --- | --- | --- |
| POST | `/v1/auth/login` | Emby credentials in, gateway token out |
| POST | `/v1/auth/logout` | Retire this device's token |
| GET | `/v1/auth/session` | Confirm a stored token is still valid |
| GET | `/v1/home?limit=` | **Every launcher row in one response** |
| GET | `/v1/recommendations?refresh=1` | Recommendation rows alone; `refresh` forces a rebuild |
| GET | `/v1/screensaver?limit=` | Backdrop pool, cached and shuffled per request |
| GET | `/v1/search?q=&limit=` | Library search |
| GET | `/v1/items/{id}` | Full metadata for one item |
| GET | `/v1/items/{id}/playback` | Resolves series → episode, returns a direct-play URL |
| GET | `/v1/items/{id}/trailer` | First local trailer, or 404 |
| POST | `/v1/items/{id}/favorite` | `{"value":true}` |
| POST | `/v1/items/{id}/played` | `{"value":true}` |
| POST | `/v1/playback/{started\|progress\|stopped}` | Progress reporting |
| POST | `/v1/analytics/rows` | Batched row engagement from a TV |
| GET | `/v1/images/{itemId}/{backdrop\|primary\|logo\|thumb}` | Artwork proxy |
| GET | `/healthz`, `/readyz` | Liveness, readiness |
### Server-driven rows
`/v1/home` returns a `rows` array — order, titles and kinds all decided here — plus the
four fixed rows repeated flat for the client's offline cache:
```json
{
"rows": [
{"id": "continue", "title": "Continue Watching", "kind": "continue", "items": [...]},
{"id": "next-up", "title": "Next Up", "kind": "nextup", "items": [...]},
{"id": "favorites", "title": "Favourites", "kind": "favorites", "items": [...]},
{"id": "latest-movies", "title": "Recently Added Movies", "kind": "latest", "items": [...]},
{"id": "similar:sev", "title": "Because you watched Severance", "kind": "similar", "items": [...]},
{"id": "recommended", "title": "Recommended from your watching history", "kind": "recommended", "items": [...]}
],
"continueWatching": [...], "nextUp": [...], "favorites": [...], "latestMovies": [...],
"partial": false
}
```
The TV renders whatever arrives, so a new row ships without an app release. `kind` picks
the card shape; an unrecognised kind falls back to poster cards rather than being dropped.
The user's own section toggles still hide the four fixed rows, but never rows the server
invented — nobody opted out of a row that did not exist when they last opened Settings.
### Recommendations
`internal/recommend` builds rows from viewing history. Two kinds:
- **"Because you watched X"** — Emby's own `/Items/{id}/Similar` for the most recent
distinct titles, filtered down to what the user has not seen. Emby's similarity ranking
is better than anything worth reimplementing here; this only removes the already-watched.
- **"Recommended from your watching history"** — genre and studio affinity. History is
weighted by recency (0.94 per position, so the 12th item counts about half the most
recent), favourites add a smaller fixed weight, and candidates are unplayed titles in the
top three genres scored by affinity + a mild community-rating nudge. Titles tagged with
many genres get a `sqrt(n)` penalty so genre-stuffing cannot buy a top slot.
Rows shorter than four items are dropped, and a user with no history gets no rows at all
rather than a strip of noise.
**The home screen never waits on the engine.** Rows live in their own `r:<userId>:rows`
cache key with a long TTL (2h). A cache miss serves home immediately without them and
triggers a background rebuild — deduplicated per user, so four TVs waking together do the
work once. Because the key sits outside the `u:` namespace, a favourite toggle does not
throw the recommendations away; only a finished playback does, since that is the one event
that genuinely changes viewing history.
The scoring is pure and unit-tested (`profile_test.go`), and the row assembly runs against
a fake Emby (`engine_test.go`), so neither needs a server to verify.
Item payloads are Emby's own JSON, forwarded verbatim. That is deliberate: the Android
client already models this shape, so there is no second schema to keep in sync.
`app/src/test/.../GatewayPayloadTest.kt` and `internal/api/api_test.go` pin the envelope
around it from both sides.
## Admin interface
`http://<host>:8080/admin/` — a single self-contained page for library imports, the
maintenance switch and row engagement. Set `MEMBY_ADMIN_TOKEN` to enable it; unset, every
`/admin` route 404s so it cannot be left exposed by accident. Paste the token into the
field at the top of the page; it is kept in the browser's local storage and sent as a
bearer header. Put the whole path behind your reverse proxy's own auth as well if the
gateway is reachable from outside the LAN.
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/admin/` | The page |
| GET | `/admin/api/status` | Library counts, sync history, maintenance state |
| POST | `/admin/api/sync` | `{"kind":"full"}` or `{"kind":"incremental"}` |
| POST | `/admin/api/maintenance` | `{"enabled":true,"message":"…"}` |
| GET | `/admin/api/analytics?days=7` | Row engagement |
## Library import
`internal/library` copies Emby's catalogue into Postgres so the gateway answers from its
own data instead of asking Emby per request.
- **Full** — pages through everything (500 items per request), then deletes any row it did
not touch, which is how removals propagate. Run once to seed; re-run after reorganising
the library.
- **Incremental** — asks Emby only for items changed since the last successful run
(`MinDateLastSaved`, with a minute of overlap so nothing falls between runs). This is
the hourly job: new episodes appear within the hour, and the weekly film drop rides
along with no extra configuration.
An incremental run with no previous success upgrades itself to a full one, so a fresh
deployment self-seeds on its first tick. Only one import runs at a time; the scheduler
skips its tick if one is still going, and interrupted runs are marked failed at boot
rather than sitting on "running" forever.
**Credentials.** Imports use `MEMBY_SYNC_USER_ID` + `MEMBY_SYNC_API_KEY` when set, and
otherwise borrow the most recently active TV session. The fallback means a new deployment
imports as soon as somebody signs in, but it stops working if that user is deleted — set a
service account for anything long-lived.
**What is *not* imported:** every query runs with `EnableUserData=false`. Watched flags,
favourites and resume positions are per-user and cannot be shared across a household, so
they still come from Emby live. The imported copy powers search and the recommendation
candidate pool.
## Maintenance mode
Takes Memby down independently of Emby: all `/v1` routes answer `503` with
`{"maintenance": true, "message": "…"}`, and the TV shows the operator's message instead of
a network error. `/healthz`, `/readyz` and `/admin` stay up — they are what you need while
the app is deliberately off.
The switch lives in Postgres, not memory, so a restart cannot quietly bring the app back
up mid-repair. Each instance caches it and re-reads every 30 seconds, so toggling it
directly in the database works too.
## Row analytics
The TV reports three signals per row — `impression` (drawn), `focus` (the remote landed
there, with dwell), `select` (something was opened) — batched and uploaded every 20
seconds to `POST /v1/analytics/rows`. Dwell below 400 ms is dropped client-side as D-pad
travel rather than attention, and the server clamps anything over 30 minutes.
Read it at `/admin/`, sorted by dwell. Dwell is the number worth watching: impressions
only say a row was on screen, while dwell says someone stopped there. It is the fastest
way to tell whether "Recommended from your watching history" is earning its slot.
Raw events are pruned after `MEMBY_ANALYTICS_RETENTION` (90 days) and aggregates are
computed at read time, so nothing survives the prune. This is tuning telemetry, not a
record of what anyone watched.
## Caching
Redis holds everything user-scoped under `u:<embyUserId>:*`, plus session lookups under
`sess:<tokenHash>` and recommendation rows under `r:<embyUserId>:rows`. Any mutation —
favourite, watched, playback stopped — drops the `u:` keys, so the next home request
re-reads Emby rather than serving a row it just contradicted. Partial home payloads are
served but never cached. The `r:` namespace is deliberately excluded from that wipe (see
Recommendations above).
Postgres holds only sessions. It is the durable half: losing Redis costs a cold cache,
losing Postgres signs everyone out.
## Configuration
| Variable | Default | Notes |
| --- | --- | --- |
| `MEMBY_EMBY_URL` | *required* | How the gateway reaches Emby |
| `MEMBY_EMBY_PUBLIC_URL` | = `MEMBY_EMBY_URL` | What TVs stream from |
| `MEMBY_DATABASE_URL` | *required* | Postgres DSN |
| `MEMBY_REDIS_URL` | `redis://localhost:6379/0` | |
| `MEMBY_LISTEN_ADDR` | `:8080` | |
| `MEMBY_CLIENT_NAME` | `Memby` | Shown in Emby's device list |
| `MEMBY_HOME_TTL` | `60s` | Also `MEMBY_ITEM_TTL`, `MEMBY_SEARCH_TTL`, `MEMBY_SCREENSAVER_TTL` |
| `MEMBY_RECOMMEND_TTL` | `2h` | How long computed recommendation rows stay warm |
| `MEMBY_RECOMMEND_TIMEOUT` | `60s` | Bounds a background rebuild |
| `MEMBY_ADMIN_TOKEN` | *empty* | Enables `/admin`. Empty = admin disabled |
| `MEMBY_SYNC_INTERVAL` | `1h` | Incremental import cadence; `0` disables |
| `MEMBY_SYNC_TIMEOUT` | `30m` | Bounds one import |
| `MEMBY_SYNC_ON_START` | `false` | Import at boot |
| `MEMBY_SYNC_USER_ID` / `MEMBY_SYNC_API_KEY` | *empty* | Emby service account for imports |
| `MEMBY_ANALYTICS_RETENTION` | `2160h` (90d) | Raw row events are pruned past this |
| `MEMBY_SESSION_CACHE_TTL` | `5m` | How long a token lookup stays in Redis |
| `MEMBY_SESSION_IDLE_EXPIRY` | `2160h` (90d) | Unused tokens are swept every 6h |
| `MEMBY_UPSTREAM_TIMEOUT` | `20s` | |
## Security notes
- The `sessions` table stores **live Emby access tokens** in plaintext. Gateway tokens are
stored only as SHA-256 hashes, so a database dump does not yield working gateway
credentials — but it does yield working *Emby* ones. Treat the Postgres volume as a
secret store, and encrypting `emby_token` at rest is the obvious next hardening step.
- Image URLs carry the gateway token in a query string, so it will appear in any access
log in front of this service. That token is revocable and grants nothing outside Memby,
which is why the artwork proxy exists at all.
- Nothing here terminates TLS. Put it behind your existing reverse proxy before exposing
it beyond the LAN.
## Not built yet
- **Live change feed.** Imports are polled hourly rather than driven by Emby's WebSocket,
so a brand-new episode can be up to an hour late. Good enough for a household; the
WebSocket would make it instant.
- **Cache warming.** Rows go cold after `MEMBY_HOME_TTL`; the first TV to ask pays for the
refresh. A background refresher per active session would hide that.
- **Rate limiting** on `/v1/auth/login`.
- **Per-user analytics breakdown.** Events carry a user id, but the admin page only shows
totals per row.
+237
View File
@@ -0,0 +1,237 @@
// Command memby-server is the Memby gateway: one HTTP service in front of Emby that
// owns auth, caching and the shaping of TV screens, so the Android client can stay thin.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/ponzischeme89/memby/server/internal/api"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/library"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
func main() {
// A distroless image has no shell or curl, so the container's healthcheck re-runs
// this binary with -healthcheck and it probes itself over the loopback interface.
healthcheck := flag.Bool("healthcheck", false, "probe the local /healthz endpoint and exit")
flag.Parse()
if *healthcheck {
if err := probeHealth(); err != nil {
os.Stderr.WriteString(err.Error() + "\n")
os.Exit(1)
}
return
}
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
if err := run(log); err != nil {
log.Error("fatal", "error", err)
os.Exit(1)
}
}
func run(log *slog.Logger) error {
cfg, err := config.Load()
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Postgres may still be starting when compose brings us up; retry briefly rather
// than crash-looping the container.
st, err := openStore(ctx, cfg.DatabaseURL, log)
if err != nil {
return err
}
defer st.Close()
if err := st.Migrate(ctx); err != nil {
return err
}
ca, err := cache.Open(cfg.RedisURL)
if err != nil {
return err
}
defer ca.Close()
if err := ca.Ping(ctx); err != nil {
return err
}
// A run interrupted by a restart is still marked "running" in the database; clear
// those before anything reads the sync history.
if err := st.MarkStaleRunsFailed(ctx); err != nil {
log.Warn("could not clear interrupted sync runs", "error", err)
}
embyClient := emby.New(cfg.EmbyURL, cfg.EmbyPublicURL, cfg.ClientName, cfg.UpstreamTimeout)
recommender := recommend.NewEngine(embyClient, log)
// Candidates come from the imported library when one exists, which keeps the
// recommendation rebuild off Emby entirely.
recommender.Library = st
syncer := library.NewSyncer(embyClient, st, emby.Credentials{
UserID: cfg.SyncUserID,
Token: cfg.SyncAPIKey,
DeviceID: "memby-gateway-sync",
}, log)
server := api.New(cfg, api.Deps{
Emby: embyClient,
Store: st,
Cache: ca,
Recommender: recommender,
Syncer: syncer,
Log: log,
})
if err := server.LoadMaintenance(ctx); err != nil {
return err
}
go server.WatchMaintenance(ctx, 30*time.Second)
go syncer.Schedule(ctx, cfg.SyncInterval)
if cfg.SyncOnStart {
go func() {
if _, err := syncer.Sync(ctx, "incremental", "startup"); err != nil {
log.Warn("startup sync failed", "error", err)
}
}()
}
go pruneAnalytics(ctx, st, cfg.AnalyticsRetention, log)
httpServer := &http.Server{
Addr: cfg.ListenAddr,
Handler: server.Routes(),
ReadHeaderTimeout: 10 * time.Second,
// No WriteTimeout: image proxying streams bodies of unpredictable size.
IdleTimeout: 60 * time.Second,
}
go sweepIdleSessions(ctx, st, cfg.SessionIdleExpiry, log)
errCh := make(chan error, 1)
go func() {
log.Info("listening", "addr", cfg.ListenAddr, "emby", cfg.EmbyURL)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
log.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return httpServer.Shutdown(shutdownCtx)
}
}
// probeHealth is the container healthcheck: hit our own /healthz over loopback.
func probeHealth() error {
addr := os.Getenv("MEMBY_LISTEN_ADDR")
if addr == "" {
addr = ":8080"
}
// ":8080" and "0.0.0.0:8080" both mean "connect to localhost" from in here.
if idx := strings.LastIndex(addr, ":"); idx >= 0 {
addr = "127.0.0.1" + addr[idx:]
}
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get("http://" + addr + "/healthz")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("healthz returned %d", resp.StatusCode)
}
return nil
}
func openStore(ctx context.Context, databaseURL string, log *slog.Logger) (*store.Store, error) {
var lastErr error
for attempt := range 10 {
st, err := store.Open(ctx, databaseURL)
if err == nil {
return st, nil
}
lastErr = err
log.Warn("waiting for postgres", "attempt", attempt+1, "error", err)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(2 * time.Second):
}
}
return nil, lastErr
}
// pruneAnalytics keeps raw row events inside their retention window. The admin page
// aggregates at read time, so nothing survives the prune — deliberately, since this is
// tuning telemetry rather than a permanent record of what anyone watched.
func pruneAnalytics(ctx context.Context, st *store.Store, retention time.Duration, log *slog.Logger) {
if retention <= 0 {
return
}
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
removed, err := st.PruneRowEvents(ctx, retention)
if err != nil {
log.Warn("analytics prune failed", "error", err)
continue
}
if removed > 0 {
log.Info("pruned row events", "count", removed)
}
}
}
}
// sweepIdleSessions retires gateway tokens that have not been used in a long time, so a
// TV that was factory-reset does not leave a live Emby token in the database forever.
func sweepIdleSessions(ctx context.Context, st *store.Store, idle time.Duration, log *slog.Logger) {
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
removed, err := st.DeleteIdleSessions(ctx, idle)
if err != nil {
log.Warn("session sweep failed", "error", err)
continue
}
if removed > 0 {
log.Info("retired idle sessions", "count", removed)
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
module github.com/ponzischeme89/memby/server
go 1.26
require (
github.com/jackc/pgx/v5 v5.7.2
github.com/redis/go-redis/v9 v9.7.0
)
require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
)
+38
View File
@@ -0,0 +1,38 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+171
View File
@@ -0,0 +1,171 @@
package api
import (
"context"
"crypto/subtle"
_ "embed"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/library"
"github.com/ponzischeme89/memby/server/internal/store"
)
//go:embed admin.html
var adminPage []byte
// adminRoutes is the operator interface: library imports, the maintenance switch, and
// row engagement. Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be
// left exposed by accident.
func (s *Server) adminRoutes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /admin/{$}", s.handleAdminPage)
mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus))
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
return mux
}
// adminAuth guards the admin API with a shared token, compared in constant time.
func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
return
}
presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 {
writeError(w, http.StatusUnauthorized, "invalid admin token")
return
}
h(w, r)
})
}
func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// The page holds no secrets; the token is entered by the operator and kept in the
// browser's local storage.
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(adminPage)
}
type adminStatus struct {
Maintenance store.Maintenance `json:"maintenance"`
Library store.LibraryStats `json:"library"`
SyncRunning bool `json:"syncRunning"`
Runs []store.SyncRun `json:"runs"`
SyncEvery string `json:"syncEvery"`
}
func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
stats, err := s.store.LibraryStats(ctx)
if err != nil {
s.log.Error("library stats failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read library stats")
return
}
runs, err := s.store.RecentSyncRuns(ctx, 10)
if err != nil {
s.log.Error("sync history failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read sync history")
return
}
writeJSON(w, http.StatusOK, adminStatus{
Maintenance: s.maintenance.get(),
Library: stats,
SyncRunning: s.syncer.Running(),
Runs: runs,
SyncEvery: s.cfg.SyncInterval.String(),
})
}
type syncRequest struct {
Kind string `json:"kind"`
}
// handleAdminSync starts an import in the background and returns immediately. A full
// import of a large library takes minutes; the page polls /admin/api/status for progress.
func (s *Server) handleAdminSync(w http.ResponseWriter, r *http.Request) {
var req syncRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if req.Kind != "full" && req.Kind != "incremental" {
writeError(w, http.StatusBadRequest, `kind must be "full" or "incremental"`)
return
}
if s.syncer.Running() {
writeError(w, http.StatusConflict, "a sync is already running")
return
}
go func() {
ctx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), s.cfg.SyncTimeout)
defer cancel()
if _, err := s.syncer.Sync(ctx, req.Kind, "manual"); err != nil {
s.log.Error("manual sync failed", "kind", req.Kind, "error", err)
}
}()
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started", "kind": req.Kind})
}
type maintenanceRequest struct {
Enabled bool `json:"enabled"`
Message string `json:"message"`
}
func (s *Server) handleAdminMaintenance(w http.ResponseWriter, r *http.Request) {
var req maintenanceRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
state := store.Maintenance{Enabled: req.Enabled, Message: strings.TrimSpace(req.Message)}
if err := s.store.SetMaintenance(r.Context(), state); err != nil {
s.log.Error("maintenance write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not update maintenance mode")
return
}
if err := s.LoadMaintenance(r.Context()); err != nil {
s.log.Warn("maintenance reload failed", "error", err)
}
s.log.Warn("maintenance mode changed", "enabled", state.Enabled, "message", state.Message)
writeJSON(w, http.StatusOK, s.maintenance.get())
}
func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
days := queryInt(r, "days", 7, 90)
since := time.Now().UTC().AddDate(0, 0, -days)
stats, err := s.store.RowStats(r.Context(), since)
if err != nil {
s.log.Error("row stats failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read analytics")
return
}
writeJSON(w, http.StatusOK, map[string]any{"days": days, "rows": stats})
}
// syncerHandle is the slice of the syncer the API needs, so api does not depend on the
// concrete type for testing.
type syncerHandle interface {
Running() bool
Sync(ctx context.Context, kind, trigger string) (library.Result, error)
}
+288
View File
@@ -0,0 +1,288 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Memby admin</title>
<style>
:root {
color-scheme: dark;
--bg: #0b0e11; --panel: #151a20; --line: #232a32;
--text: #e6eaee; --muted: #97a1ab; --accent: #52b54b; --danger: #e5534b;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 28px; background: var(--bg); color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
main { max-width: 980px; margin: 0 auto; display: grid; gap: 20px; }
h1 { font-size: 24px; margin: 0; }
h2 { font-size: 15px; margin: 0 0 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
header { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
section { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px; }
button {
background: var(--accent); color: #06240a; border: 0; border-radius: 7px;
padding: 9px 15px; font-weight: 600; font-size: 14px; cursor: pointer;
}
button.secondary { background: #2a323b; color: var(--text); }
button.danger { background: var(--danger); color: #fff; }
button:disabled { opacity: .45; cursor: not-allowed; }
input[type=text], input[type=password] {
background: #0e1216; border: 1px solid var(--line); border-radius: 7px;
color: var(--text); padding: 9px 11px; font-size: 14px; min-width: 260px;
}
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.stats { display: flex; gap: 26px; flex-wrap: wrap; margin-bottom: 14px; }
.stat b { display: block; font-size: 22px; font-weight: 600; }
.stat span { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
.scroll { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; font-size: 14px; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; }
th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
.pill { padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.ok { background: #16351a; color: #7bd88f; }
.bad { background: #3a1d1c; color: #ff8a80; }
.warn { background: #3a3320; color: #f0c674; }
.muted { color: var(--muted); }
.banner { padding: 11px 14px; border-radius: 8px; background: #3a1d1c; color: #ffb3ad; display: none; }
.banner.show { display: block; }
</style>
</head>
<body>
<main>
<header>
<h1>Memby admin</h1>
<span id="live" class="pill muted">connecting…</span>
<span style="flex:1"></span>
<input type="password" id="token" placeholder="Admin token" autocomplete="off">
<button id="save-token" class="secondary">Save</button>
</header>
<div id="error" class="banner"></div>
<section>
<h2>Library</h2>
<div class="stats" id="library-stats"><span class="muted">Loading…</span></div>
<div class="row">
<button id="sync-incremental">Sync new items</button>
<button id="sync-full" class="secondary">Full re-import</button>
<span class="muted" id="sync-hint"></span>
</div>
</section>
<section>
<h2>Maintenance</h2>
<p class="muted" style="margin-top:0">
Takes Memby offline for every TV, independently of Emby. Sign-in and all content
calls return 503 with the message below; this page keeps working.
</p>
<div class="row">
<input type="text" id="maintenance-message" placeholder="Message shown on the TV">
<button id="maintenance-on" class="danger">Go offline</button>
<button id="maintenance-off" class="secondary">Bring back online</button>
<span id="maintenance-state" class="pill"></span>
</div>
</section>
<section>
<h2>Row engagement</h2>
<div class="row" style="margin-bottom:12px">
<label class="muted" for="days">Window</label>
<select id="days" style="background:#0e1216;color:var(--text);border:1px solid var(--line);border-radius:7px;padding:8px">
<option value="1">24 hours</option>
<option value="7" selected>7 days</option>
<option value="30">30 days</option>
</select>
</div>
<div class="scroll">
<table>
<thead>
<tr>
<th>Row</th><th>Kind</th>
<th class="num">Dwell</th><th class="num">Impressions</th>
<th class="num">Focuses</th><th class="num">Opened</th>
<th class="num">Open rate</th><th class="num">Viewers</th>
</tr>
</thead>
<tbody id="analytics"><tr><td colspan="8" class="muted">No data yet.</td></tr></tbody>
</table>
</div>
</section>
<section>
<h2>Recent imports</h2>
<div class="scroll">
<table>
<thead>
<tr>
<th>Started</th><th>Kind</th><th>Trigger</th><th>Status</th>
<th class="num">Seen</th><th class="num">Written</th><th class="num">Removed</th><th>Notes</th>
</tr>
</thead>
<tbody id="runs"><tr><td colspan="8" class="muted">Nothing yet.</td></tr></tbody>
</table>
</div>
</section>
</main>
<script>
const tokenInput = document.getElementById('token');
tokenInput.value = localStorage.getItem('memby_admin_token') || '';
document.getElementById('save-token').addEventListener('click', () => {
localStorage.setItem('memby_admin_token', tokenInput.value.trim());
refresh();
});
function showError(message) {
const banner = document.getElementById('error');
banner.textContent = message || '';
banner.classList.toggle('show', Boolean(message));
}
async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: {
'Authorization': 'Bearer ' + (localStorage.getItem('memby_admin_token') || ''),
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
if (response.status === 401) throw new Error('Invalid admin token.');
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || ('Request failed (' + response.status + ')'));
}
return response.status === 204 ? null : response.json();
}
const number = (value) => (value ?? 0).toLocaleString();
function duration(ms) {
if (!ms) return '0s';
const seconds = Math.round(ms / 1000);
if (seconds < 60) return seconds + 's';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + 'm ' + (seconds % 60) + 's';
return Math.floor(minutes / 60) + 'h ' + (minutes % 60) + 'm';
}
const when = (value) => (value ? new Date(value).toLocaleString() : '—');
function renderStatus(status) {
const byType = status.library.byType || {};
const types = Object.keys(byType).sort();
document.getElementById('library-stats').innerHTML =
'<div class="stat"><b>' + number(status.library.total) + '</b><span>items</span></div>' +
types.map((type) =>
'<div class="stat"><b>' + number(byType[type]) + '</b><span>' + type + '</span></div>').join('') +
'<div class="stat"><b style="font-size:15px">' + when(status.library.lastSynced) +
'</b><span>last import</span></div>';
const running = status.syncRunning;
document.getElementById('sync-incremental').disabled = running;
document.getElementById('sync-full').disabled = running;
document.getElementById('sync-hint').textContent = running
? 'Import running…'
: 'Automatic incremental import every ' + status.syncEvery + '.';
const maintenance = status.maintenance || {};
const state = document.getElementById('maintenance-state');
state.textContent = maintenance.enabled ? 'OFFLINE' : 'online';
state.className = 'pill ' + (maintenance.enabled ? 'bad' : 'ok');
const messageField = document.getElementById('maintenance-message');
if (document.activeElement !== messageField) messageField.value = maintenance.message || '';
document.getElementById('runs').innerHTML = (status.runs || []).length
? status.runs.map((run) => {
const pill = run.status === 'success' ? 'ok' : run.status === 'running' ? 'warn' : 'bad';
return '<tr>' +
'<td>' + when(run.startedAt) + '</td>' +
'<td>' + run.kind + '</td>' +
'<td>' + run.trigger + '</td>' +
'<td><span class="pill ' + pill + '">' + run.status + '</span></td>' +
'<td class="num">' + number(run.itemsSeen) + '</td>' +
'<td class="num">' + number(run.itemsUpserted) + '</td>' +
'<td class="num">' + number(run.itemsRemoved) + '</td>' +
'<td class="muted">' + (run.error || '') + '</td>' +
'</tr>';
}).join('')
: '<tr><td colspan="8" class="muted">Nothing yet.</td></tr>';
}
function renderAnalytics(payload) {
const rows = payload.rows || [];
document.getElementById('analytics').innerHTML = rows.length
? rows.map((row) =>
'<tr>' +
'<td>' + row.rowId + '</td>' +
'<td class="muted">' + (row.rowKind || '—') + '</td>' +
'<td class="num">' + duration(row.dwellMs) + '</td>' +
'<td class="num">' + number(row.impressions) + '</td>' +
'<td class="num">' + number(row.focuses) + '</td>' +
'<td class="num">' + number(row.selects) + '</td>' +
'<td class="num">' + Math.round((row.selectRate || 0) * 100) + '%</td>' +
'<td class="num">' + number(row.viewers) + '</td>' +
'</tr>').join('')
: '<tr><td colspan="8" class="muted">No events in this window.</td></tr>';
}
async function refresh() {
const live = document.getElementById('live');
try {
const [status, analytics] = await Promise.all([
api('/admin/api/status'),
api('/admin/api/analytics?days=' + document.getElementById('days').value),
]);
renderStatus(status);
renderAnalytics(analytics);
live.textContent = 'updated ' + new Date().toLocaleTimeString();
live.className = 'pill ok';
showError('');
} catch (err) {
live.textContent = 'error';
live.className = 'pill bad';
showError(err.message);
}
}
async function act(fn) {
try {
await fn();
await refresh();
} catch (err) {
showError(err.message);
}
}
document.getElementById('sync-incremental').addEventListener('click', () =>
act(() => api('/admin/api/sync', { method: 'POST', body: JSON.stringify({ kind: 'incremental' }) })));
document.getElementById('sync-full').addEventListener('click', () => {
if (!confirm('Re-import the entire library? This can take several minutes.')) return;
act(() => api('/admin/api/sync', { method: 'POST', body: JSON.stringify({ kind: 'full' }) }));
});
document.getElementById('maintenance-on').addEventListener('click', () => {
if (!confirm('Take Memby offline for every TV?')) return;
act(() => api('/admin/api/maintenance', {
method: 'POST',
body: JSON.stringify({ enabled: true, message: document.getElementById('maintenance-message').value }),
}));
});
document.getElementById('maintenance-off').addEventListener('click', () =>
act(() => api('/admin/api/maintenance', {
method: 'POST',
body: JSON.stringify({ enabled: false, message: document.getElementById('maintenance-message').value }),
})));
document.getElementById('days').addEventListener('change', refresh);
refresh();
setInterval(refresh, 5000);
</script>
</body>
</html>
+196
View File
@@ -0,0 +1,196 @@
package api
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/store"
)
func testServer(cfg config.Config) *Server {
return New(cfg, Deps{Log: slog.New(slog.NewTextHandler(io.Discard, nil))})
}
func TestMaintenanceGatePassesTrafficWhenOnline(t *testing.T) {
server := testServer(config.Config{})
var reached bool
handler := server.maintenanceGate(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
reached = true
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/home", nil))
if !reached || rec.Code != http.StatusOK {
t.Fatalf("request should have passed through, got %d", rec.Code)
}
}
func TestMaintenanceGateBlocksWithTheOperatorsMessage(t *testing.T) {
server := testServer(config.Config{})
server.maintenance.set(store.Maintenance{Enabled: true, Message: "Back at 9pm"})
handler := server.maintenanceGate(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("handler must not run while offline")
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/home", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", rec.Code)
}
if rec.Header().Get("Retry-After") == "" {
t.Fatal("expected a Retry-After header")
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body: %v", err)
}
// The TV keys off `maintenance` to tell "we turned it off" from "the network died".
if body["maintenance"] != true {
t.Fatalf("expected maintenance:true, got %v", body)
}
if body["message"] != "Back at 9pm" {
t.Fatalf("operator message not surfaced: %v", body["message"])
}
}
func TestMaintenanceGateFallsBackToADefaultMessage(t *testing.T) {
server := testServer(config.Config{})
server.maintenance.set(store.Maintenance{Enabled: true})
rec := httptest.NewRecorder()
server.maintenanceGate(http.NotFoundHandler()).
ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/home", nil))
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if body["message"] != store.DefaultMaintenanceMessage {
t.Fatalf("expected the default message, got %v", body["message"])
}
}
func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) {
// Health checks and the admin page sit outside the gate on purpose: they are what
// you need most while the app is deliberately down.
server := testServer(config.Config{AdminToken: "secret"})
server.maintenance.set(store.Maintenance{Enabled: true})
rec := httptest.NewRecorder()
server.handleHealth(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("healthz should stay 200 during maintenance, got %d", rec.Code)
}
rec = httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("admin page should stay reachable, got %d", rec.Code)
}
}
func TestAdminIsDisabledWithoutAToken(t *testing.T) {
server := testServer(config.Config{})
for _, path := range []string{"/admin/", "/admin/api/status"} {
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("%s should 404 when no admin token is configured, got %d", path, rec.Code)
}
}
}
func TestAdminAuthRejectsAWrongToken(t *testing.T) {
server := testServer(config.Config{AdminToken: "secret"})
handler := server.adminAuth(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
cases := map[string]string{
"missing": "",
"wrong": "Bearer nope",
"prefix": "Bearer secretish",
}
for name, header := range cases {
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
if header != "" {
req.Header.Set("Authorization", header)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s token should be rejected, got %d", name, rec.Code)
}
}
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("the correct token should be accepted, got %d", rec.Code)
}
}
func TestToRowEventValidatesAndClamps(t *testing.T) {
now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)
t.Run("rejects unknown event kinds", func(t *testing.T) {
if _, ok := toRowEvent(rowEventPayload{RowID: "r", Event: "scrolled"}, "u", now); ok {
t.Fatal("unknown event kind should be dropped")
}
})
t.Run("rejects events with no row", func(t *testing.T) {
if _, ok := toRowEvent(rowEventPayload{Event: "focus"}, "u", now); ok {
t.Fatal("an event with no row id should be dropped")
}
})
t.Run("clamps implausible dwell", func(t *testing.T) {
event, ok := toRowEvent(rowEventPayload{RowID: "r", Event: "focus", DwellMs: 99 * 60 * 60 * 1000}, "u", now)
if !ok {
t.Fatal("event should be accepted")
}
if event.DwellMs != maxDwellMs {
t.Fatalf("dwell = %d, want clamped to %d", event.DwellMs, maxDwellMs)
}
event, _ = toRowEvent(rowEventPayload{RowID: "r", Event: "focus", DwellMs: -5}, "u", now)
if event.DwellMs != 0 {
t.Fatalf("negative dwell should floor at 0, got %d", event.DwellMs)
}
})
t.Run("ignores a device clock that is wildly wrong", func(t *testing.T) {
event, _ := toRowEvent(
rowEventPayload{RowID: "r", Event: "impression", OccurredAt: "1970-01-01T00:00:00Z"}, "u", now)
if !event.OccurredAt.Equal(now) {
t.Fatalf("expected the server clock to win, got %v", event.OccurredAt)
}
})
t.Run("accepts a plausible device timestamp", func(t *testing.T) {
earlier := now.Add(-30 * time.Second).Format(time.RFC3339)
event, _ := toRowEvent(rowEventPayload{RowID: "r", Event: "select", OccurredAt: earlier}, "u", now)
if event.OccurredAt.Equal(now) {
t.Fatal("a recent device timestamp should be kept")
}
})
t.Run("stamps the session's user", func(t *testing.T) {
event, _ := toRowEvent(rowEventPayload{RowID: "r", Event: "focus"}, "user-9", now)
if event.UserID != "user-9" {
t.Fatalf("user should come from the session, got %q", event.UserID)
}
})
}
+102
View File
@@ -0,0 +1,102 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// maxAnalyticsBatch caps one upload. The TV batches events and flushes periodically, so
// a larger payload than this means something has gone wrong client-side.
const maxAnalyticsBatch = 200
// maxDwellMs discards implausible dwell times — a TV left on a row overnight says
// nothing about what anyone was looking at.
const maxDwellMs = 30 * 60 * 1000
type rowEventPayload struct {
RowID string `json:"rowId"`
RowKind string `json:"rowKind"`
Event string `json:"event"`
ItemID string `json:"itemId"`
DwellMs int `json:"dwellMs"`
OccurredAt string `json:"occurredAt"`
}
type analyticsRequest struct {
Events []rowEventPayload `json:"events"`
}
// handleRowAnalytics accepts a batch of row engagement events from a TV.
//
// Fire-and-forget by design: the client does not retry, and a rejected event is never
// worth surfacing on screen. Bad events are dropped individually rather than failing the
// batch.
func (s *Server) handleRowAnalytics(w http.ResponseWriter, r *http.Request, sess store.Session) {
var req analyticsRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if len(req.Events) > maxAnalyticsBatch {
req.Events = req.Events[:maxAnalyticsBatch]
}
now := time.Now().UTC()
events := make([]store.RowEvent, 0, len(req.Events))
for _, payload := range req.Events {
event, ok := toRowEvent(payload, sess.EmbyUserID, now)
if !ok {
continue
}
events = append(events, event)
}
if err := s.store.InsertRowEvents(r.Context(), events); err != nil {
s.log.Warn("row analytics write failed", "error", err)
// Still a 204: telemetry must never make the TV think something is broken.
}
w.WriteHeader(http.StatusNoContent)
}
func toRowEvent(payload rowEventPayload, userID string, now time.Time) (store.RowEvent, bool) {
if payload.RowID == "" {
return store.RowEvent{}, false
}
switch payload.Event {
case store.RowEventImpression, store.RowEventFocus, store.RowEventSelect:
default:
return store.RowEvent{}, false
}
occurredAt := now
if payload.OccurredAt != "" {
if parsed, err := time.Parse(time.RFC3339, payload.OccurredAt); err == nil {
// Trust the device's clock only within a sane window; TVs are notorious for
// waking up in 1970.
if parsed.After(now.Add(-24*time.Hour)) && parsed.Before(now.Add(time.Hour)) {
occurredAt = parsed.UTC()
}
}
}
dwell := payload.DwellMs
if dwell < 0 {
dwell = 0
}
if dwell > maxDwellMs {
dwell = maxDwellMs
}
return store.RowEvent{
OccurredAt: occurredAt,
UserID: userID,
RowID: payload.RowID,
RowKind: payload.RowKind,
Event: payload.Event,
ItemID: payload.ItemID,
DwellMs: dwell,
}, true
}
+287
View File
@@ -0,0 +1,287 @@
// Package api exposes the gateway's HTTP surface.
//
// The API is shaped for one TV screen at a time rather than mirroring Emby: /v1/home
// returns everything the launcher renders in a single round trip, which is the whole
// point of putting a gateway in front of Emby.
package api
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
type Server struct {
cfg config.Config
emby *emby.Client
store *store.Store
cache *cache.Cache
recommender *recommend.Engine
syncer syncerHandle
log *slog.Logger
recommendationBuilds recommendationBuilds
maintenance maintenanceState
}
// Deps are the collaborators the API needs. A struct rather than positional arguments:
// this list has grown three times already.
type Deps struct {
Emby *emby.Client
Store *store.Store
Cache *cache.Cache
Recommender *recommend.Engine
Syncer syncerHandle
Log *slog.Logger
}
func New(cfg config.Config, deps Deps) *Server {
return &Server{
cfg: cfg,
emby: deps.Emby,
store: deps.Store,
cache: deps.Cache,
recommender: deps.Recommender,
syncer: deps.Syncer,
log: deps.Log,
}
}
func (s *Server) Routes() http.Handler {
// The client API lives on its own mux so maintenance mode can gate all of it at
// once, without the gate ever touching health checks or the admin page.
v1 := http.NewServeMux()
v1.HandleFunc("POST /v1/auth/login", s.handleLogin)
v1.Handle("POST /v1/auth/logout", s.authed(s.handleLogout))
v1.Handle("GET /v1/auth/session", s.authed(s.handleSession))
v1.Handle("GET /v1/home", s.authed(s.handleHome))
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
v1.Handle("GET /v1/recommendations", s.authed(s.handleRecommendations))
v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem))
v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite))
v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed))
v1.Handle("GET /v1/items/{id}/playback", s.authed(s.handlePlayback))
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport))
v1.Handle("POST /v1/analytics/rows", s.authed(s.handleRowAnalytics))
v1.Handle("GET /v1/images/{itemId}/{imageType}", s.authed(s.handleImage))
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealth)
mux.HandleFunc("GET /readyz", s.handleReady)
mux.Handle("/v1/", s.maintenanceGate(v1))
mux.Handle("/admin/", s.adminRoutes())
return s.withLogging(mux)
}
// --- middleware -------------------------------------------------------------
type authedFunc func(http.ResponseWriter, *http.Request, store.Session)
// authed resolves the bearer token to a session before running h.
//
// Images are also accepted with a `t=` query parameter: Coil builds plain URLs from the
// repository's helpers and cannot attach headers to them.
func (s *Server) authed(h authedFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "missing token")
return
}
sess, err := s.sessionFor(r.Context(), token)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusUnauthorized, "invalid token")
return
}
s.log.Error("session lookup failed", "error", err)
writeError(w, http.StatusInternalServerError, "session lookup failed")
return
}
h(w, r, sess)
})
}
func (s *Server) withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
// Path only: query strings can carry image tokens.
s.log.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"ms", time.Since(start).Milliseconds(),
)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
// --- sessions ---------------------------------------------------------------
func bearerToken(r *http.Request) string {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
}
if h := r.Header.Get("X-Memby-Token"); h != "" {
return strings.TrimSpace(h)
}
return strings.TrimSpace(r.URL.Query().Get("t"))
}
func hashToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
func newToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
type cachedSession struct {
EmbyUserID string `json:"u"`
EmbyToken string `json:"t"`
Username string `json:"n"`
ServerID string `json:"s"`
DeviceID string `json:"d"`
}
// sessionFor resolves a token, using Redis to keep the hot path off Postgres.
func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, error) {
hash := hashToken(token)
key := cache.SessionKey(hex.EncodeToString(hash))
if raw, err := s.cache.Get(ctx, key); err == nil {
var cs cachedSession
if json.Unmarshal(raw, &cs) == nil {
return store.Session{
TokenHash: hash,
EmbyUserID: cs.EmbyUserID,
EmbyToken: cs.EmbyToken,
Username: cs.Username,
ServerID: cs.ServerID,
DeviceID: cs.DeviceID,
}, nil
}
}
sess, err := s.store.SessionByTokenHash(ctx, hash)
if err != nil {
return store.Session{}, err
}
// Constant-time confirmation that the stored hash matches the presented token.
if subtle.ConstantTimeCompare(sess.TokenHash, hash) != 1 {
return store.Session{}, store.ErrNotFound
}
if raw, err := json.Marshal(cachedSession{
EmbyUserID: sess.EmbyUserID,
EmbyToken: sess.EmbyToken,
Username: sess.Username,
ServerID: sess.ServerID,
DeviceID: sess.DeviceID,
}); err == nil {
_ = s.cache.Set(ctx, key, raw, s.cfg.SessionTTL)
}
// Best-effort activity stamp; a failure here must not fail the request.
if err := s.store.Touch(ctx, hash); err != nil {
s.log.Warn("touch session failed", "error", err)
}
return sess, nil
}
func credentials(sess store.Session) emby.Credentials {
return emby.Credentials{UserID: sess.EmbyUserID, Token: sess.EmbyToken, DeviceID: sess.DeviceID}
}
// --- responses --------------------------------------------------------------
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
// Headers are already out; nothing useful left to do but stop.
return
}
}
func writeRaw(w http.ResponseWriter, status int, body []byte) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write(body)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
// writeUpstreamError mirrors Emby's status so the TV can tell "signed out" (401) from
// "server is unwell" (5xx) without parsing strings.
func (s *Server) writeUpstreamError(w http.ResponseWriter, err error, message string) {
var apiErr *emby.APIError
if errors.As(err, &apiErr) {
switch {
case apiErr.StatusCode == http.StatusUnauthorized, apiErr.StatusCode == http.StatusForbidden:
writeError(w, http.StatusUnauthorized, "emby rejected the session")
return
case apiErr.StatusCode == http.StatusNotFound:
writeError(w, http.StatusNotFound, "not found on the emby server")
return
}
}
s.log.Error(message, "error", err)
writeError(w, http.StatusBadGateway, message)
}
func queryInt(r *http.Request, key string, fallback, max int) int {
raw := r.URL.Query().Get(key)
if raw == "" {
return fallback
}
v, err := strconv.Atoi(raw)
if err != nil || v <= 0 {
return fallback
}
if v > max {
return max
}
return v
}
+143
View File
@@ -0,0 +1,143 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/ponzischeme89/memby/server/internal/emby"
)
func TestBearerTokenSources(t *testing.T) {
t.Run("authorization header", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/home", nil)
r.Header.Set("Authorization", "Bearer abc123")
if got := bearerToken(r); got != "abc123" {
t.Fatalf("got %q, want abc123", got)
}
})
t.Run("query parameter for image urls", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/images/1/backdrop?t=abc123", nil)
if got := bearerToken(r); got != "abc123" {
t.Fatalf("got %q, want abc123", got)
}
})
t.Run("absent", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/home", nil)
if got := bearerToken(r); got != "" {
t.Fatalf("got %q, want empty", got)
}
})
}
func TestHashTokenIsStable(t *testing.T) {
a, b := hashToken("token"), hashToken("token")
if string(a) != string(b) {
t.Fatal("hashing the same token produced different digests")
}
if string(a) == string(hashToken("other")) {
t.Fatal("different tokens hashed to the same digest")
}
}
func TestNewTokenIsUnique(t *testing.T) {
seen := map[string]bool{}
for range 100 {
token, err := newToken()
if err != nil {
t.Fatalf("newToken: %v", err)
}
if seen[token] {
t.Fatal("newToken repeated a value")
}
seen[token] = true
}
}
// Empty rows must serialise as [] so kotlinx.serialization can decode them into the
// client's non-null List fields.
func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) {
var resp homeResponse
ensureSlices(&resp)
body, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(body, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, row := range []string{"continueWatching", "nextUp", "favorites", "latestMovies"} {
if _, ok := decoded[row].([]any); !ok {
t.Fatalf("row %q encoded as %T, want array", row, decoded[row])
}
}
}
// The four fixed rows must keep their order, ids and kinds: the client maps kinds onto
// card shapes and uses ids as Compose keys.
func TestBaseRowsShape(t *testing.T) {
rows := baseRows(homeResponse{
ContinueWatching: []json.RawMessage{json.RawMessage(`{"Id":"1"}`)},
Favorites: []json.RawMessage{json.RawMessage(`{"Id":"2"}`)},
})
if len(rows) != 4 {
t.Fatalf("expected 4 base rows, got %d", len(rows))
}
wantIDs := []string{"continue", "next-up", "favorites", "latest-movies"}
wantKinds := []string{"continue", "nextup", "favorites", "latest"}
for i, row := range rows {
if row.ID != wantIDs[i] {
t.Fatalf("row %d id = %q, want %q", i, row.ID, wantIDs[i])
}
if row.Kind != wantKinds[i] {
t.Fatalf("row %d kind = %q, want %q", i, row.Kind, wantKinds[i])
}
if row.Title == "" {
t.Fatalf("row %d has no title", i)
}
}
// The favourites row carries the items the client used to assemble itself.
if len(rows[2].Items) != 1 {
t.Fatalf("favourites row lost its items: %+v", rows[2])
}
}
func TestRecommendationBuildsAreDeduplicatedPerUser(t *testing.T) {
var builds recommendationBuilds
if !builds.begin("user-1") {
t.Fatal("first build should be allowed to start")
}
if builds.begin("user-1") {
t.Fatal("a second concurrent build for the same user must be skipped")
}
if !builds.begin("user-2") {
t.Fatal("a different user must not be blocked")
}
builds.done("user-1")
if !builds.begin("user-1") {
t.Fatal("a build should be allowed again once the previous one finished")
}
}
func TestSummariseReadsResumePosition(t *testing.T) {
raw := json.RawMessage(`{"Id":"42","Name":"Arrival","Type":"Movie","UserData":{"PlaybackPositionTicks":36000000000}}`)
summary, err := emby.Summarise(raw)
if err != nil {
t.Fatalf("summarise: %v", err)
}
if summary.ID != "42" || summary.Name != "Arrival" {
t.Fatalf("unexpected summary: %+v", summary)
}
if got := summary.UserData.PlaybackPositionTicks / ticksPerMillisecond; got != 3_600_000 {
t.Fatalf("resume position = %d ms, want 3600000", got)
}
}
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/store"
)
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
DeviceID string `json:"deviceId"`
}
type loginResponse struct {
Token string `json:"token"`
UserID string `json:"userId"`
Username string `json:"username"`
ServerID string `json:"serverId"`
}
// handleLogin exchanges Emby credentials for a gateway token.
//
// The Emby access token stays here: the TV only ever holds the gateway token, so
// revoking a device is a DELETE in Postgres rather than an Emby-side cleanup.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" {
writeError(w, http.StatusBadRequest, "username is required")
return
}
if req.DeviceID == "" {
req.DeviceID = "memby-tv"
}
auth, err := s.emby.Authenticate(r.Context(), req.Username, req.Password, req.DeviceID)
if err != nil {
// Never echo Emby's body here: a failed sign-in is the one place a wrong
// password could be reflected back.
s.log.Warn("emby authentication failed", "username", req.Username)
writeError(w, http.StatusUnauthorized, "sign-in failed")
return
}
token, err := newToken()
if err != nil {
s.log.Error("token generation failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not issue a token")
return
}
sess := store.Session{
TokenHash: hashToken(token),
EmbyUserID: auth.User.ID,
EmbyToken: auth.AccessToken,
Username: auth.User.Name,
ServerID: auth.ServerID,
DeviceID: req.DeviceID,
}
if sess.Username == "" {
sess.Username = req.Username
}
if err := s.store.CreateSession(r.Context(), sess); err != nil {
s.log.Error("session persist failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not start a session")
return
}
writeJSON(w, http.StatusOK, loginResponse{
Token: token,
UserID: sess.EmbyUserID,
Username: sess.Username,
ServerID: sess.ServerID,
})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess store.Session) {
if err := s.store.DeleteSession(r.Context(), sess.TokenHash); err != nil {
s.log.Error("session delete failed", "error", err)
}
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(sess.TokenHash)))
_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
w.WriteHeader(http.StatusNoContent)
}
// handleSession lets the TV confirm a stored token is still good before rendering.
func (s *Server) handleSession(w http.ResponseWriter, _ *http.Request, sess store.Session) {
writeJSON(w, http.StatusOK, loginResponse{
UserID: sess.EmbyUserID,
Username: sess.Username,
ServerID: sess.ServerID,
})
}
+49
View File
@@ -0,0 +1,49 @@
package api
import (
"context"
"encoding/hex"
"net/http"
"time"
)
func hexHash(hash []byte) string { return hex.EncodeToString(hash) }
// handleHealth is liveness: the process is up. It touches no dependency, so an
// orchestrator does not restart the container just because Emby is down.
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleReady is readiness: everything this service needs is reachable.
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
checks := map[string]string{}
status := http.StatusOK
if err := s.store.Ping(ctx); err != nil {
checks["postgres"] = err.Error()
status = http.StatusServiceUnavailable
} else {
checks["postgres"] = "ok"
}
if err := s.cache.Ping(ctx); err != nil {
checks["redis"] = err.Error()
status = http.StatusServiceUnavailable
} else {
checks["redis"] = "ok"
}
// Emby being unreachable is reported but does not fail readiness: cached responses
// are still worth serving, and flapping the container would not bring Emby back.
if err := s.emby.Ping(ctx); err != nil {
checks["emby"] = err.Error()
} else {
checks["emby"] = "ok"
}
writeJSON(w, status, checks)
}
+285
View File
@@ -0,0 +1,285 @@
package api
import (
"encoding/json"
"math/rand/v2"
"net/http"
"net/url"
"strconv"
"sync"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
// Field sets mirror what each TV row actually renders. Asking Emby for less is the
// single biggest lever on home-screen latency, so keep these tight.
const (
fieldsContinue = "RunTimeTicks,SeriesName,PrimaryImageAspectRatio"
fieldsNextUp = "Overview,ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio"
fieldsRow = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio"
fieldsDetail = "Overview,Genres,MediaStreams,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio"
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
rowImageTypes = "Backdrop,Primary,Logo"
screensaverImageTypes = "Backdrop,Logo"
)
type homeResponse struct {
// Rows is the home screen as the server wants it drawn: order, titles and kinds all
// decided here, so a new row (a recommendation strip, a seasonal collection) ships
// without touching the TV app. The client renders whatever arrives.
Rows []recommend.Row `json:"rows"`
// The four fixed rows are also sent flat. They are what the client caches for an
// instant cold start, and what the direct-to-Emby path still produces.
ContinueWatching []json.RawMessage `json:"continueWatching"`
NextUp []json.RawMessage `json:"nextUp"`
Favorites []json.RawMessage `json:"favorites"`
LatestMovies []json.RawMessage `json:"latestMovies"`
// Partial is true when at least one row failed upstream. The TV shows what arrived
// and flags a refresh error rather than blanking the screen.
Partial bool `json:"partial"`
}
// handleHome answers the entire launcher in one round trip.
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
limit := queryInt(r, "limit", 24, 100)
key := cache.UserKey(sess.EmbyUserID, "home:"+itoa(limit))
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
cred := credentials(sess)
var (
mu sync.Mutex
failures int
out homeResponse
wg sync.WaitGroup
)
run := func(dest *[]json.RawMessage, fetch func() (*emby.ItemsResult, error)) {
wg.Add(1)
go func() {
defer wg.Done()
result, err := fetch()
mu.Lock()
defer mu.Unlock()
if err != nil {
failures++
s.log.Warn("home row failed", "error", err)
return
}
*dest = result.Items
}()
}
run(&out.ContinueWatching, func() (*emby.ItemsResult, error) {
return s.emby.Items(ctx, cred, rowParams(url.Values{
"Filters": {"IsResumable"},
"IncludeItemTypes": {"Movie,Episode"},
"Recursive": {"true"},
"SortBy": {"DatePlayed"},
"SortOrder": {"Descending"},
"Limit": {itoa(limit)},
}, fieldsContinue))
})
run(&out.NextUp, func() (*emby.ItemsResult, error) {
return s.emby.NextUp(ctx, cred, rowParams(url.Values{
"Limit": {itoa(limit)},
}, fieldsNextUp))
})
run(&out.Favorites, func() (*emby.ItemsResult, error) {
return s.emby.Items(ctx, cred, rowParams(url.Values{
"Filters": {"IsFavorite"},
"IncludeItemTypes": {"Movie,Series"},
"Recursive": {"true"},
"SortBy": {"SortName"},
"SortOrder": {"Ascending"},
"Limit": {itoa(limit)},
}, fieldsRow))
})
run(&out.LatestMovies, func() (*emby.ItemsResult, error) {
return s.emby.Items(ctx, cred, rowParams(url.Values{
"IncludeItemTypes": {"Movie"},
"Recursive": {"true"},
"SortBy": {"DateCreated"},
"SortOrder": {"Descending"},
"Limit": {itoa(limit)},
}, fieldsRow))
})
wg.Wait()
if failures == 4 {
writeError(w, http.StatusBadGateway, "could not reach the emby server")
return
}
out.Partial = failures > 0
ensureSlices(&out)
// Recommendations are read from their own long-lived cache. A miss means this
// response ships without them and a rebuild starts in the background — the home
// screen never waits on the engine.
recommendations := s.cachedRecommendations(ctx, sess.EmbyUserID)
if recommendations == nil {
s.refreshRecommendationsInBackground(sess)
}
out.Rows = append(baseRows(out), recommendations...)
body, err := json.Marshal(out)
if err != nil {
s.log.Error("home encode failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not build the home payload")
return
}
// A partial payload is served but never cached: the next request should retry.
if !out.Partial {
if err := s.cache.Set(ctx, key, body, s.cfg.HomeTTL); err != nil {
s.log.Warn("home cache write failed", "error", err)
}
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, body)
}
// handleScreensaver serves the backdrop pool. The pool is cached and shuffled per
// request, so the Dream still looks random without re-querying Emby every few seconds.
func (s *Server) handleScreensaver(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
limit := queryInt(r, "limit", 200, 400)
key := cache.UserKey(sess.EmbyUserID, "screensaver:"+itoa(limit))
var items []json.RawMessage
if raw, err := s.cache.Get(ctx, key); err == nil {
_ = json.Unmarshal(raw, &items)
}
if items == nil {
result, err := s.emby.Items(ctx, credentials(sess), url.Values{
"IncludeItemTypes": {"Movie,Series"},
"Recursive": {"true"},
"Filters": {"HasBackdrop"},
"SortBy": {"Random"},
"Limit": {itoa(limit)},
"Fields": {fieldsScreensaver},
"ImageTypeLimit": {"1"},
"EnableImageTypes": {screensaverImageTypes},
"EnableUserData": {"true"},
})
if err != nil {
s.writeUpstreamError(w, err, "could not load screensaver items")
return
}
items = result.Items
if raw, err := json.Marshal(items); err == nil {
_ = s.cache.Set(ctx, key, raw, s.cfg.ScreensaverTTL)
}
}
shuffled := make([]json.RawMessage, len(items))
copy(shuffled, items)
rand.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] })
writeJSON(w, http.StatusOK, map[string]any{"items": shuffled})
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
term := r.URL.Query().Get("q")
if len(term) < 2 {
writeJSON(w, http.StatusOK, map[string]any{"items": []json.RawMessage{}})
return
}
limit := queryInt(r, "limit", 40, 100)
key := cache.UserKey(sess.EmbyUserID, "search:"+itoa(limit)+":"+term)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
// The imported library answers search from Postgres, which is the difference
// between "instant" and "one round trip to Emby per keystroke". An empty result
// falls through to Emby, so search still works before the first import completes.
items, err := s.store.SearchLibrary(ctx, term, limit)
if err != nil {
s.log.Warn("library search failed; falling back to emby", "error", err)
items = nil
}
if len(items) == 0 {
result, err := s.emby.Items(ctx, credentials(sess), rowParams(url.Values{
"SearchTerm": {term},
"IncludeItemTypes": {"Movie,Series,Episode"},
"Recursive": {"true"},
"Limit": {itoa(limit)},
}, fieldsRow))
if err != nil {
s.writeUpstreamError(w, err, "search failed")
return
}
items = result.Items
}
body, err := json.Marshal(map[string]any{"items": nonNil(items)})
if err != nil {
writeError(w, http.StatusInternalServerError, "could not build search results")
return
}
if err := s.cache.Set(ctx, key, body, s.cfg.SearchTTL); err != nil {
s.log.Warn("search cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, body)
}
// baseRows describes the four fixed rows.
//
// Titles live here rather than in the app so wording can change server-side. They are
// emitted even when empty: the client draws its own "Nothing in progress" message, and a
// row that vanishes as you watch things is more jarring than an empty one.
func baseRows(h homeResponse) []recommend.Row {
return []recommend.Row{
{ID: "continue", Title: "Continue Watching", Kind: "continue", Items: h.ContinueWatching},
{ID: "next-up", Title: "Next Up", Kind: "nextup", Items: h.NextUp},
{ID: "favorites", Title: "Favourites", Kind: "favorites", Items: h.Favorites},
{ID: "latest-movies", Title: "Recently Added Movies", Kind: "latest", Items: h.LatestMovies},
}
}
// rowParams applies the query shape every list endpoint shares.
func rowParams(params url.Values, fields string) url.Values {
params.Set("Fields", fields)
params.Set("ImageTypeLimit", "1")
params.Set("EnableImages", "true")
params.Set("EnableImageTypes", rowImageTypes)
params.Set("EnableTotalRecordCount", "false")
params.Set("EnableUserData", "true")
return params
}
// ensureSlices keeps empty rows as [] rather than null, so kotlinx.serialization can
// decode them into non-null List fields.
func ensureSlices(h *homeResponse) {
h.ContinueWatching = nonNil(h.ContinueWatching)
h.NextUp = nonNil(h.NextUp)
h.Favorites = nonNil(h.Favorites)
h.LatestMovies = nonNil(h.LatestMovies)
}
func nonNil(items []json.RawMessage) []json.RawMessage {
if items == nil {
return []json.RawMessage{}
}
return items
}
func itoa(v int) string { return strconv.Itoa(v) }
+65
View File
@@ -0,0 +1,65 @@
package api
import (
"io"
"net/http"
"net/url"
"strings"
"github.com/ponzischeme89/memby/server/internal/store"
)
// allowedImageTypes guards the path segment we forward to Emby.
var allowedImageTypes = map[string]string{
"backdrop": "Backdrop",
"primary": "Primary",
"logo": "Logo",
"thumb": "Thumb",
}
// handleImage proxies artwork.
//
// Going through the gateway means the TV's image URLs carry a gateway token instead of a
// live Emby api_key, and it gives Emby's resized output a cacheable home. Images are
// tag-addressed, so a hit can be cached hard by any layer in front of this.
func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.Session) {
itemID := r.PathValue("itemId")
imageType, ok := allowedImageTypes[strings.ToLower(r.PathValue("imageType"))]
if !ok || itemID == "" {
writeError(w, http.StatusNotFound, "unknown image")
return
}
params := url.Values{}
for _, key := range []string{"tag", "maxWidth", "maxHeight", "quality"} {
if v := r.URL.Query().Get(key); v != "" {
params.Set(key, v)
}
}
resp, err := s.emby.ImageResponse(r.Context(), credentials(sess), itemID, imageType, params)
if err != nil {
s.writeUpstreamError(w, err, "could not load the image")
return
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct)
}
if cl := resp.Header.Get("Content-Length"); cl != "" {
w.Header().Set("Content-Length", cl)
}
// A tag identifies exact image bytes, so it can be cached indefinitely. Without one,
// stay conservative.
if params.Get("tag") != "" {
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "private, max-age=3600")
}
w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, resp.Body); err != nil {
s.log.Warn("image copy failed", "error", err)
}
}
+104
View File
@@ -0,0 +1,104 @@
package api
import (
"encoding/json"
"net/http"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/store"
)
type flagRequest struct {
Value bool `json:"value"`
}
// handleItem serves full metadata for one item. The TV asks for this only after D-pad
// focus settles, so it is worth caching for longer than a home row.
func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
key := cache.UserKey(sess.EmbyUserID, "item:"+itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
item, err := s.emby.Item(ctx, credentials(sess), itemID, fieldsDetail)
if err != nil {
s.writeUpstreamError(w, err, "could not load the item")
return
}
if err := s.cache.Set(ctx, key, item, s.cfg.ItemTTL); err != nil {
s.log.Warn("item cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, item)
}
// handleTrailer answers with the item's first local trailer, or 404 when it has none.
// The screensaver's Play action uses this before asking for a playback URL.
func (s *Server) handleTrailer(w http.ResponseWriter, r *http.Request, sess store.Session) {
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
result, err := s.emby.LocalTrailers(r.Context(), credentials(sess), itemID)
if err != nil {
s.writeUpstreamError(w, err, "could not load trailers")
return
}
if len(result.Items) == 0 {
writeError(w, http.StatusNotFound, "no trailer available")
return
}
writeRaw(w, http.StatusOK, result.Items[0])
}
func (s *Server) handleFavorite(w http.ResponseWriter, r *http.Request, sess store.Session) {
s.setFlag(w, r, sess, func(itemID string, value bool) (json.RawMessage, error) {
return s.emby.SetFavorite(r.Context(), credentials(sess), itemID, value)
})
}
func (s *Server) handlePlayed(w http.ResponseWriter, r *http.Request, sess store.Session) {
s.setFlag(w, r, sess, func(itemID string, value bool) (json.RawMessage, error) {
return s.emby.SetPlayed(r.Context(), credentials(sess), itemID, value)
})
}
// setFlag applies a user-data mutation and drops this user's cached views, so the next
// home request reflects it rather than serving the row it just contradicted.
func (s *Server) setFlag(
w http.ResponseWriter,
r *http.Request,
sess store.Session,
apply func(itemID string, value bool) (json.RawMessage, error),
) {
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
var req flagRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
userData, err := apply(itemID, req.Value)
if err != nil {
s.writeUpstreamError(w, err, "could not update the item")
return
}
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
s.log.Warn("cache invalidation failed", "error", err)
}
writeRaw(w, http.StatusOK, userData)
}
+87
View File
@@ -0,0 +1,87 @@
package api
import (
"context"
"net/http"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// maintenanceState caches the operator switch in memory so the hot path never queries
// Postgres, while Postgres stays the source of truth across restarts.
type maintenanceState struct {
mu sync.RWMutex
value store.Maintenance
}
func (m *maintenanceState) get() store.Maintenance {
m.mu.RLock()
defer m.mu.RUnlock()
return m.value
}
func (m *maintenanceState) set(value store.Maintenance) {
m.mu.Lock()
defer m.mu.Unlock()
m.value = value
}
// LoadMaintenance primes the cached switch. Called at boot, and after every toggle.
func (s *Server) LoadMaintenance(ctx context.Context) error {
state, err := s.store.Maintenance(ctx)
if err != nil {
return err
}
s.maintenance.set(state)
if state.Enabled {
s.log.Warn("starting in maintenance mode", "message", state.Message)
}
return nil
}
// WatchMaintenance re-reads the switch periodically, so a change made directly in the
// database (or by another instance) is picked up without a restart.
func (s *Server) WatchMaintenance(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := s.LoadMaintenance(ctx); err != nil {
s.log.Warn("maintenance refresh failed", "error", err)
}
}
}
}
// maintenanceGate turns the whole client API off, independently of Emby.
//
// 503 with a machine-readable `maintenance: true` so the TV can show the operator's
// message rather than a generic network error. Admin routes and health checks are
// deliberately outside this gate — you need them most while the app is down.
func (s *Server) maintenanceGate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
state := s.maintenance.get()
if !state.Enabled {
next.ServeHTTP(w, r)
return
}
message := state.Message
if message == "" {
message = store.DefaultMaintenanceMessage
}
// Retry-After keeps well-behaved clients from hammering a service that has
// already said it is unavailable.
w.Header().Set("Retry-After", "300")
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"error": message,
"maintenance": true,
"message": message,
})
})
}
+159
View File
@@ -0,0 +1,159 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
)
const ticksPerMillisecond = 10_000
type playbackResponse struct {
ItemID string `json:"itemId"`
Title string `json:"title"`
URL string `json:"url"`
ResumePositionMs int64 `json:"resumePositionMs"`
}
type playbackReport struct {
ItemID string `json:"itemId"`
PositionMs int64 `json:"positionMs"`
IsPaused bool `json:"isPaused"`
}
// handlePlayback resolves what to actually play.
//
// This is logic the TV used to carry: a series resolves to its next-up episode (falling
// back to the first), and the returned URL points straight at Emby so the video stream
// never traverses the gateway.
func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
cred := credentials(sess)
raw, err := s.emby.Item(ctx, cred, itemID, "RunTimeTicks,SeriesName")
if err != nil {
s.writeUpstreamError(w, err, "could not load the item")
return
}
item, err := emby.Summarise(raw)
if err != nil {
writeError(w, http.StatusBadGateway, "unreadable item from emby")
return
}
target := item
title := item.Name
if strings.EqualFold(item.Type, "Series") {
episode, err := s.firstPlayableEpisode(ctx, cred, item.ID)
if err != nil {
s.writeUpstreamError(w, err, "could not find an episode to play")
return
}
if episode == nil {
writeError(w, http.StatusNotFound, "no episodes found for this series")
return
}
target = *episode
if episode.Name != "" {
title = item.Name + " " + episode.Name
}
}
writeJSON(w, http.StatusOK, playbackResponse{
ItemID: target.ID,
Title: title,
URL: s.emby.StreamURL(cred, target.ID),
ResumePositionMs: max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
})
}
// firstPlayableEpisode prefers the server's next-up choice and falls back to episode one.
func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials, seriesID string) (*emby.Summary, error) {
nextUp, err := s.emby.NextUp(ctx, cred, url.Values{
"SeriesId": {seriesID},
"Limit": {"1"},
"Fields": {"RunTimeTicks"},
"EnableUserData": {"true"},
})
if err == nil && len(nextUp.Items) > 0 {
if summary, err := emby.Summarise(nextUp.Items[0]); err == nil {
return &summary, nil
}
}
episodes, err := s.emby.Episodes(ctx, cred, seriesID, url.Values{
"Limit": {"1"},
"Fields": {"RunTimeTicks"},
"EnableUserData": {"true"},
})
if err != nil {
return nil, err
}
if len(episodes.Items) == 0 {
return nil, nil
}
summary, err := emby.Summarise(episodes.Items[0])
if err != nil {
return nil, err
}
return &summary, nil
}
// handlePlaybackReport forwards progress to Emby. Stopping invalidates the user's cache
// so Continue Watching reflects the new position on the next home load.
func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, sess store.Session) {
phase := r.PathValue("phase")
switch phase {
case "started", "progress", "stopped":
default:
writeError(w, http.StatusNotFound, "unknown playback phase")
return
}
var report playbackReport
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&report); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if report.ItemID == "" {
writeError(w, http.StatusBadRequest, "itemId is required")
return
}
err := s.emby.ReportPlayback(r.Context(), credentials(sess), phase, report.ItemID,
max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused)
if err != nil {
// A dropped progress report is not worth failing playback over; log and accept.
s.log.Warn("playback report failed", "phase", phase, "error", err)
}
if phase == "stopped" {
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
s.log.Warn("cache invalidation failed", "error", err)
}
// Finishing something is the one event that genuinely changes viewing history,
// so it is also the only thing that retires the recommendation rows.
if err := s.cache.InvalidateRecommendations(r.Context(), sess.EmbyUserID); err != nil {
s.log.Warn("recommendation invalidation failed", "error", err)
}
}
w.WriteHeader(http.StatusNoContent)
}
func max64(v, floor int64) int64 {
if v < floor {
return floor
}
return v
}
+132
View File
@@ -0,0 +1,132 @@
package api
import (
"context"
"encoding/json"
"net/http"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
// recommendationBuilds tracks in-flight rebuilds per user.
//
// Without this, four TVs waking up together would each kick off the same handful of Emby
// queries. The first one through does the work; the rest skip it and pick the rows up on
// their next home load.
type recommendationBuilds struct {
mu sync.Mutex
running map[string]bool
}
func (b *recommendationBuilds) begin(userID string) bool {
b.mu.Lock()
defer b.mu.Unlock()
if b.running == nil {
b.running = map[string]bool{}
}
if b.running[userID] {
return false
}
b.running[userID] = true
return true
}
func (b *recommendationBuilds) done(userID string) {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.running, userID)
}
// cachedRecommendations returns the stored rows, or nil on a miss.
func (s *Server) cachedRecommendations(ctx context.Context, userID string) []recommend.Row {
raw, err := s.cache.Get(ctx, cache.RecommendationsKey(userID))
if err != nil {
return nil
}
var rows []recommend.Row
if err := json.Unmarshal(raw, &rows); err != nil {
return nil
}
return rows
}
// buildRecommendations computes and caches rows for one user.
func (s *Server) buildRecommendations(ctx context.Context, sess store.Session) ([]recommend.Row, error) {
rows, err := s.recommender.BuildRows(ctx, credentials(sess))
if err != nil {
return nil, err
}
// An empty result is cached too: a user with no history should not trigger a full
// rebuild on every single home load.
if raw, err := json.Marshal(rows); err == nil {
if err := s.cache.Set(ctx, cache.RecommendationsKey(sess.EmbyUserID), raw, s.cfg.RecommendTTL); err != nil {
s.log.Warn("recommendation cache write failed", "error", err)
}
}
return rows, nil
}
// refreshRecommendationsInBackground rebuilds without holding up the caller.
//
// The home screen must stay fast, so a cold cache means "no recommendation rows this
// time" rather than "wait several seconds for Emby". The rows appear on the next load.
func (s *Server) refreshRecommendationsInBackground(sess store.Session) {
if !s.recommendationBuilds.begin(sess.EmbyUserID) {
return
}
go func() {
defer s.recommendationBuilds.done(sess.EmbyUserID)
// Detached from the request: the TV's connection is long gone by the time this
// finishes, but the work is still worth completing.
ctx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), s.cfg.RecommendTimeout)
defer cancel()
started := time.Now()
rows, err := s.buildRecommendations(ctx, sess)
if err != nil {
s.log.Warn("recommendation build failed", "user", sess.EmbyUserID, "error", err)
return
}
s.log.Info("recommendations rebuilt",
"user", sess.EmbyUserID, "rows", len(rows), "ms", time.Since(started).Milliseconds())
}()
}
// handleRecommendations serves the rows on their own, building synchronously when the
// cache is cold. `?refresh=1` forces a rebuild — useful for testing the engine without
// waiting out the TTL.
func (s *Server) handleRecommendations(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
forceRefresh := r.URL.Query().Get("refresh") == "1"
if !forceRefresh {
if rows := s.cachedRecommendations(ctx, sess.EmbyUserID); rows != nil {
w.Header().Set("X-Memby-Cache", "hit")
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
return
}
}
buildCtx, cancel := context.WithTimeout(ctx, s.cfg.RecommendTimeout)
defer cancel()
rows, err := s.buildRecommendations(buildCtx, sess)
if err != nil {
s.writeUpstreamError(w, err, "could not build recommendations")
return
}
w.Header().Set("X-Memby-Cache", "miss")
writeJSON(w, http.StatusOK, map[string]any{"rows": nonNilRows(rows)})
}
func nonNilRows(rows []recommend.Row) []recommend.Row {
if rows == nil {
return []recommend.Row{}
}
return rows
}
+97
View File
@@ -0,0 +1,97 @@
// Package cache wraps Redis with the small surface the gateway needs.
//
// Every cached value is scoped to an Emby user id, because "what's on the home screen"
// is per-user. Mutations (favourite, watched, playback stopped) drop that user's keys
// so the next request re-reads Emby rather than serving a stale row.
package cache
import (
"context"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// ErrMiss means the key was absent — an ordinary outcome, not a failure.
var ErrMiss = errors.New("cache: miss")
type Cache struct {
rdb *redis.Client
}
func Open(redisURL string) (*Cache, error) {
opts, err := redis.ParseURL(redisURL)
if err != nil {
return nil, fmt.Errorf("cache: parse url: %w", err)
}
return &Cache{rdb: redis.NewClient(opts)}, nil
}
func (c *Cache) Close() error { return c.rdb.Close() }
func (c *Cache) Ping(ctx context.Context) error { return c.rdb.Ping(ctx).Err() }
func (c *Cache) Get(ctx context.Context, key string) ([]byte, error) {
b, err := c.rdb.Get(ctx, key).Bytes()
if errors.Is(err, redis.Nil) {
return nil, ErrMiss
}
if err != nil {
return nil, err
}
return b, nil
}
func (c *Cache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
return c.rdb.Set(ctx, key, value, ttl).Err()
}
func (c *Cache) Delete(ctx context.Context, keys ...string) error {
if len(keys) == 0 {
return nil
}
return c.rdb.Del(ctx, keys...).Err()
}
// InvalidateUser drops every cached view belonging to one Emby user.
//
// SCAN rather than KEYS so a large keyspace never blocks Redis; the key count here is
// small, but the habit costs nothing.
func (c *Cache) InvalidateUser(ctx context.Context, userID string) error {
pattern := fmt.Sprintf("u:%s:*", userID)
var cursor uint64
for {
keys, next, err := c.rdb.Scan(ctx, cursor, pattern, 200).Result()
if err != nil {
return err
}
if len(keys) > 0 {
if err := c.rdb.Del(ctx, keys...).Err(); err != nil {
return err
}
}
if next == 0 {
return nil
}
cursor = next
}
}
// UserKey builds the namespaced key used by everything user-scoped.
func UserKey(userID, view string) string { return fmt.Sprintf("u:%s:%s", userID, view) }
// RecommendationsKey sits in its own `r:` namespace on purpose.
//
// Recommendations cost several Emby queries to build, so they must survive the cache
// wipe that every favourite toggle triggers. Only a genuine change in viewing history
// — a finished playback — retires them, via [Cache.InvalidateRecommendations].
func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows", userID) }
func (c *Cache) InvalidateRecommendations(ctx context.Context, userID string) error {
return c.Delete(ctx, RecommendationsKey(userID))
}
// SessionKey caches a token→session lookup, keyed by token hash (never the token).
func SessionKey(tokenHashHex string) string { return "sess:" + tokenHashHex }
+136
View File
@@ -0,0 +1,136 @@
// Package config loads the gateway's settings from the environment.
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
ListenAddr string
// EmbyURL is how the gateway itself reaches Emby (may be a private/docker address).
EmbyURL string
// EmbyPublicURL is the address handed to TV clients for direct video playback.
// Defaults to EmbyURL; set it when the gateway talks to Emby over a network the
// TVs cannot reach.
EmbyPublicURL string
DatabaseURL string
RedisURL string
// ClientName is reported to Emby in the X-Emby-Authorization header, so sessions
// show up as this in Emby's dashboard.
ClientName string
HomeTTL time.Duration
ItemTTL time.Duration
SearchTTL time.Duration
ScreensaverTTL time.Duration
SessionTTL time.Duration
// SessionIdleExpiry retires gateway tokens that go unused for this long.
SessionIdleExpiry time.Duration
// RecommendTTL is how long computed recommendation rows stay warm. Long, because
// taste moves slowly and each rebuild costs several Emby queries.
RecommendTTL time.Duration
// RecommendTimeout bounds a background rebuild, which fans out further than a
// normal request and so needs more headroom than UpstreamTimeout.
RecommendTimeout time.Duration
UpstreamTimeout time.Duration
// AdminToken guards the operator interface. Empty disables /admin entirely, so an
// unconfigured deployment cannot leave it exposed.
AdminToken string
// SyncInterval is how often the library import runs. Zero disables the schedule.
SyncInterval time.Duration
// SyncTimeout bounds one import; a full pass over a large library is slow.
SyncTimeout time.Duration
// SyncOnStart triggers an incremental import at boot.
SyncOnStart bool
// SyncUserID / SyncAPIKey are an optional Emby service account for imports. Without
// them the newest TV session is borrowed instead.
SyncUserID string
SyncAPIKey string
// AnalyticsRetention is how long raw row events are kept before being pruned.
AnalyticsRetention time.Duration
}
func Load() (Config, error) {
c := Config{
ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"),
EmbyURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_URL"), "/"),
EmbyPublicURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_PUBLIC_URL"), "/"),
DatabaseURL: os.Getenv("MEMBY_DATABASE_URL"),
RedisURL: env("MEMBY_REDIS_URL", "redis://localhost:6379/0"),
ClientName: env("MEMBY_CLIENT_NAME", "Memby"),
HomeTTL: duration("MEMBY_HOME_TTL", 60*time.Second),
ItemTTL: duration("MEMBY_ITEM_TTL", 10*time.Minute),
SearchTTL: duration("MEMBY_SEARCH_TTL", 5*time.Minute),
ScreensaverTTL: duration("MEMBY_SCREENSAVER_TTL", 10*time.Minute),
SessionTTL: duration("MEMBY_SESSION_CACHE_TTL", 5*time.Minute),
SessionIdleExpiry: duration("MEMBY_SESSION_IDLE_EXPIRY", 90*24*time.Hour),
RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 2*time.Hour),
RecommendTimeout: duration("MEMBY_RECOMMEND_TIMEOUT", 60*time.Second),
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
SyncUserID: strings.TrimSpace(os.Getenv("MEMBY_SYNC_USER_ID")),
SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")),
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second),
}
if c.EmbyURL == "" {
return c, fmt.Errorf("MEMBY_EMBY_URL is required")
}
if c.DatabaseURL == "" {
return c, fmt.Errorf("MEMBY_DATABASE_URL is required")
}
if c.EmbyPublicURL == "" {
c.EmbyPublicURL = c.EmbyURL
}
return c, nil
}
func env(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func boolean(key string, fallback bool) bool {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback
}
value, err := strconv.ParseBool(raw)
if err != nil {
return fallback
}
return value
}
func duration(key string, fallback time.Duration) time.Duration {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback
}
if d, err := time.ParseDuration(raw); err == nil {
return d
}
// Bare numbers are read as seconds, which is friendlier in a compose file.
if secs, err := strconv.Atoi(raw); err == nil {
return time.Duration(secs) * time.Second
}
return fallback
}
+325
View File
@@ -0,0 +1,325 @@
// Package emby is a small client for the Emby REST API.
//
// Item payloads are deliberately carried as json.RawMessage and forwarded to the TV
// untouched: the Android client already models Emby's item shape, so passing it through
// verbatim means there is no second schema to keep in sync. Only the handful of fields
// the gateway itself reasons about (id, type, resume position) are ever unmarshalled.
package emby
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Client struct {
baseURL string
publicURL string
clientName string
http *http.Client
}
// Credentials identify one signed-in Emby user.
type Credentials struct {
UserID string
Token string
DeviceID string
}
type ItemsResult struct {
Items []json.RawMessage `json:"Items"`
TotalRecordCount int `json:"TotalRecordCount"`
}
type AuthResult struct {
User struct {
ID string `json:"Id"`
Name string `json:"Name"`
} `json:"User"`
AccessToken string `json:"AccessToken"`
ServerID string `json:"ServerId"`
}
// Summary is the minimal view of an item the gateway needs for its own logic.
type Summary struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
UserData struct {
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
} `json:"UserData"`
}
// APIError carries an upstream Emby status code so handlers can mirror it.
type APIError struct {
StatusCode int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("emby: status %d: %s", e.StatusCode, e.Body)
}
func New(baseURL, publicURL, clientName string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
publicURL: strings.TrimRight(publicURL, "/"),
clientName: clientName,
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
},
},
}
}
func (c *Client) Authenticate(ctx context.Context, username, password, deviceID string) (*AuthResult, error) {
body, err := json.Marshal(map[string]string{"Username": username, "Pw": password})
if err != nil {
return nil, err
}
req, err := c.newRequest(ctx, http.MethodPost, "/Users/AuthenticateByName", nil,
Credentials{DeviceID: deviceID}, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
var out AuthResult
if err := c.do(req, &out); err != nil {
return nil, err
}
if out.AccessToken == "" || out.User.ID == "" {
return nil, fmt.Errorf("emby: authentication returned no access token")
}
return &out, nil
}
func (c *Client) Items(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items", params)
}
func (c *Client) NextUp(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
params.Set("UserId", cred.UserID)
return c.items(ctx, cred, "/Shows/NextUp", params)
}
// Similar asks Emby which items resemble one the user already watched.
func (c *Client) Similar(ctx context.Context, cred Credentials, itemID string, params url.Values) (*ItemsResult, error) {
params.Set("UserId", cred.UserID)
return c.items(ctx, cred, "/Items/"+url.PathEscape(itemID)+"/Similar", params)
}
func (c *Client) Episodes(ctx context.Context, cred Credentials, seriesID string, params url.Values) (*ItemsResult, error) {
params.Set("UserId", cred.UserID)
return c.items(ctx, cred, "/Shows/"+url.PathEscape(seriesID)+"/Episodes", params)
}
// LocalTrailers returns the trailers Emby holds locally for an item.
func (c *Client) LocalTrailers(ctx context.Context, cred Credentials, itemID string) (*ItemsResult, error) {
path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID) + "/LocalTrailers"
req, err := c.newRequest(ctx, http.MethodGet, path, nil, cred, nil)
if err != nil {
return nil, err
}
// This endpoint answers with a bare array rather than an Items envelope.
var items []json.RawMessage
if err := c.do(req, &items); err != nil {
return nil, err
}
return &ItemsResult{Items: items, TotalRecordCount: len(items)}, nil
}
func (c *Client) Item(ctx context.Context, cred Credentials, itemID, fields string) (json.RawMessage, error) {
params := url.Values{}
if fields != "" {
params.Set("Fields", fields)
}
path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID)
req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil)
if err != nil {
return nil, err
}
var raw json.RawMessage
if err := c.do(req, &raw); err != nil {
return nil, err
}
return raw, nil
}
// SetFavorite/SetPlayed return Emby's resulting UserData verbatim.
func (c *Client) SetFavorite(ctx context.Context, cred Credentials, itemID string, favorite bool) (json.RawMessage, error) {
method := http.MethodDelete
if favorite {
method = http.MethodPost
}
path := "/Users/" + url.PathEscape(cred.UserID) + "/FavoriteItems/" + url.PathEscape(itemID)
return c.userDataCall(ctx, method, path, cred)
}
func (c *Client) SetPlayed(ctx context.Context, cred Credentials, itemID string, played bool) (json.RawMessage, error) {
method := http.MethodDelete
if played {
method = http.MethodPost
}
path := "/Users/" + url.PathEscape(cred.UserID) + "/PlayedItems/" + url.PathEscape(itemID)
return c.userDataCall(ctx, method, path, cred)
}
// ReportPlayback forwards a progress report. phase is "started", "progress" or "stopped".
func (c *Client) ReportPlayback(ctx context.Context, cred Credentials, phase string, itemID string, positionTicks int64, isPaused bool) error {
var path string
switch phase {
case "started":
path = "/Sessions/Playing"
case "progress":
path = "/Sessions/Playing/Progress"
case "stopped":
path = "/Sessions/Playing/Stopped"
default:
return fmt.Errorf("emby: unknown playback phase %q", phase)
}
body, err := json.Marshal(map[string]any{
"ItemId": itemID,
"PositionTicks": positionTicks,
"IsPaused": isPaused,
"IsMuted": false,
"CanSeek": true,
"PlayMethod": "DirectPlay",
})
if err != nil {
return err
}
req, err := c.newRequest(ctx, http.MethodPost, path, nil, cred, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
return c.do(req, nil)
}
// ImageResponse streams an image straight from Emby so the caller can copy it to the TV.
// The caller owns closing the body.
func (c *Client) ImageResponse(ctx context.Context, cred Credentials, itemID, imageType string, params url.Values) (*http.Response, error) {
path := "/Items/" + url.PathEscape(itemID) + "/Images/" + url.PathEscape(imageType)
req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil)
if err != nil {
return nil, err
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
resp.Body.Close()
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)}
}
return resp, nil
}
// StreamURL is the direct-play URL handed to the TV. It points at the *public* Emby
// address: video never flows through the gateway, only metadata does.
func (c *Client) StreamURL(cred Credentials, itemID string) string {
params := url.Values{}
params.Set("static", "true")
params.Set("api_key", cred.Token)
params.Set("DeviceId", cred.DeviceID)
return fmt.Sprintf("%s/Videos/%s/stream?%s", c.publicURL, url.PathEscape(itemID), params.Encode())
}
// Ping checks that Emby is reachable, for readiness probes.
func (c *Client) Ping(ctx context.Context) error {
req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil, Credentials{}, nil)
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *Client) items(ctx context.Context, cred Credentials, path string, params url.Values) (*ItemsResult, error) {
req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil)
if err != nil {
return nil, err
}
var out ItemsResult
if err := c.do(req, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) userDataCall(ctx context.Context, method, path string, cred Credentials) (json.RawMessage, error) {
req, err := c.newRequest(ctx, method, path, nil, cred, nil)
if err != nil {
return nil, err
}
var raw json.RawMessage
if err := c.do(req, &raw); err != nil {
return nil, err
}
return raw, nil
}
func (c *Client) newRequest(ctx context.Context, method, path string, params url.Values, cred Credentials, body io.Reader) (*http.Request, error) {
full := c.baseURL + path
if len(params) > 0 {
full += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, full, body)
if err != nil {
return nil, err
}
deviceID := cred.DeviceID
if deviceID == "" {
deviceID = "memby-gateway"
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Emby-Authorization", fmt.Sprintf(
`MediaBrowser Client="%s", Device="Memby Gateway", DeviceId="%s", Version="1.0"`,
c.clientName, deviceID,
))
if cred.Token != "" {
req.Header.Set("X-Emby-Token", cred.Token)
}
return req, nil
}
// do executes a request and decodes into out (which may be nil to discard the body).
func (c *Client) do(req *http.Request, out any) error {
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
// Emby error bodies can echo request details; cap what we keep and never log it
// alongside a token.
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return &APIError{StatusCode: resp.StatusCode, Body: string(body)}
}
if out == nil {
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
// Summarise unmarshals the fields the gateway reasons about from a raw item.
func Summarise(raw json.RawMessage) (Summary, error) {
var s Summary
err := json.Unmarshal(raw, &s)
return s, err
}
+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)
}
}
+270
View File
@@ -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
}
+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
}
+258
View File
@@ -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
}
+183
View File
@@ -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)
}
}
+113
View File
@@ -0,0 +1,113 @@
package store
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// RowEvent is one reported interaction with a home-screen row.
type RowEvent struct {
OccurredAt time.Time
UserID string
RowID string
RowKind string
Event string
ItemID string
DwellMs int
}
// Event kinds. Impressions say a row was drawn; focus says the remote actually landed
// on it and for how long; select says something was opened from it.
const (
RowEventImpression = "impression"
RowEventFocus = "focus"
RowEventSelect = "select"
)
// RowStat is the aggregate the admin page renders.
type RowStat struct {
RowID string `json:"rowId"`
RowKind string `json:"rowKind"`
Impressions int64 `json:"impressions"`
Focuses int64 `json:"focuses"`
Selects int64 `json:"selects"`
DwellMs int64 `json:"dwellMs"`
Viewers int64 `json:"viewers"`
SelectRate float64 `json:"selectRate"`
}
func (s *Store) InsertRowEvents(ctx context.Context, events []RowEvent) error {
if len(events) == 0 {
return nil
}
batch := &pgx.Batch{}
for _, event := range events {
batch.Queue(`
INSERT INTO row_events (occurred_at, emby_user_id, row_id, row_kind, event, item_id, dwell_ms)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
event.OccurredAt, event.UserID, event.RowID, event.RowKind,
event.Event, event.ItemID, event.DwellMs)
}
results := s.pool.SendBatch(ctx, batch)
defer results.Close()
for range events {
if _, err := results.Exec(); err != nil {
return fmt.Errorf("store: insert row events: %w", err)
}
}
return nil
}
// RowStats aggregates engagement since a point in time, busiest row first.
//
// Dwell is the interesting number: impressions only say a row was on screen, whereas
// dwell says someone actually stopped there.
func (s *Store) RowStats(ctx context.Context, since time.Time) ([]RowStat, error) {
rows, err := s.pool.Query(ctx, `
SELECT row_id,
(array_agg(row_kind ORDER BY occurred_at DESC))[1] AS row_kind,
count(*) FILTER (WHERE event = 'impression') AS impressions,
count(*) FILTER (WHERE event = 'focus') AS focuses,
count(*) FILTER (WHERE event = 'select') AS selects,
coalesce(sum(dwell_ms), 0) AS dwell_ms,
count(DISTINCT emby_user_id) AS viewers
FROM row_events
WHERE occurred_at >= $1
GROUP BY row_id
ORDER BY dwell_ms DESC, impressions DESC`, since)
if err != nil {
return nil, fmt.Errorf("store: row stats: %w", err)
}
defer rows.Close()
stats := []RowStat{}
for rows.Next() {
var stat RowStat
if err := rows.Scan(&stat.RowID, &stat.RowKind, &stat.Impressions, &stat.Focuses,
&stat.Selects, &stat.DwellMs, &stat.Viewers); err != nil {
return nil, err
}
if stat.Impressions > 0 {
stat.SelectRate = float64(stat.Selects) / float64(stat.Impressions)
}
stats = append(stats, stat)
}
return stats, rows.Err()
}
// PruneRowEvents drops raw events past their retention window. Aggregates are computed
// at read time, so nothing is preserved once the events go — which is the point: this is
// engagement telemetry for tuning rows, not a permanent record of what people watched.
func (s *Store) PruneRowEvents(ctx context.Context, olderThan time.Duration) (int64, error) {
tag, err := s.pool.Exec(ctx,
`DELETE FROM row_events WHERE occurred_at < now() - $1::interval`,
fmt.Sprintf("%d seconds", int64(olderThan.Seconds())))
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
+181
View File
@@ -0,0 +1,181 @@
package store
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// LibraryItem is one imported Emby item. Payload is Emby's JSON verbatim; the flat
// columns exist only so Postgres can filter and rank without opening the JSON.
type LibraryItem struct {
ID string
Type string
Name string
SeriesID string
SeriesName string
ProductionYear *int
CommunityRating *float64
Genres []string
Studios []string
DateCreated *time.Time
SearchText string
Payload json.RawMessage
}
// LibraryStats is what the admin page shows about the imported library.
type LibraryStats struct {
Total int64 `json:"total"`
ByType map[string]int64 `json:"byType"`
LastSynced *time.Time `json:"lastSynced"`
}
// UpsertLibraryItems writes a batch, refreshing synced_at on every row it touches.
//
// synced_at doubles as the mark-and-sweep marker: a full import stamps everything it
// sees, then deletes whatever kept an older stamp.
func (s *Store) UpsertLibraryItems(ctx context.Context, items []LibraryItem, syncedAt time.Time) (int64, error) {
if len(items) == 0 {
return 0, nil
}
batch := &pgx.Batch{}
for _, item := range items {
batch.Queue(`
INSERT INTO library_items (
id, type, name, series_id, series_name, production_year, community_rating,
genres, studios, date_created, search_text, payload, synced_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13)
ON CONFLICT (id) DO UPDATE SET
type = EXCLUDED.type,
name = EXCLUDED.name,
series_id = EXCLUDED.series_id,
series_name = EXCLUDED.series_name,
production_year = EXCLUDED.production_year,
community_rating = EXCLUDED.community_rating,
genres = EXCLUDED.genres,
studios = EXCLUDED.studios,
date_created = EXCLUDED.date_created,
search_text = EXCLUDED.search_text,
payload = EXCLUDED.payload,
synced_at = EXCLUDED.synced_at`,
item.ID, item.Type, item.Name, item.SeriesID, item.SeriesName,
item.ProductionYear, item.CommunityRating, item.Genres, item.Studios,
item.DateCreated, item.SearchText, string(item.Payload), syncedAt)
}
results := s.pool.SendBatch(ctx, batch)
defer results.Close()
var written int64
for range items {
tag, err := results.Exec()
if err != nil {
return written, fmt.Errorf("store: upsert library items: %w", err)
}
written += tag.RowsAffected()
}
return written, nil
}
// DeleteLibraryItemsBefore removes anything a full import did not touch — items deleted
// from Emby since the last run.
func (s *Store) DeleteLibraryItemsBefore(ctx context.Context, cutoff time.Time) (int64, error) {
tag, err := s.pool.Exec(ctx, `DELETE FROM library_items WHERE synced_at < $1`, cutoff)
if err != nil {
return 0, fmt.Errorf("store: prune library: %w", err)
}
return tag.RowsAffected(), nil
}
// SearchLibrary answers from the imported library rather than Emby.
//
// Full-text match first, with a trailing ILIKE so partial words ("sever") still hit
// before someone finishes typing on a remote.
func (s *Store) SearchLibrary(ctx context.Context, term string, limit int) ([]json.RawMessage, error) {
trimmed := strings.TrimSpace(term)
if trimmed == "" {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
SELECT payload
FROM library_items
WHERE search_tsv @@ plainto_tsquery('simple', $1)
OR search_text ILIKE '%' || $1 || '%'
ORDER BY
ts_rank(search_tsv, plainto_tsquery('simple', $1)) DESC,
(lower(name) = lower($1)) DESC,
community_rating DESC NULLS LAST,
name ASC
LIMIT $2`, trimmed, limit)
if err != nil {
return nil, fmt.Errorf("store: search library: %w", err)
}
return collectPayloads(rows)
}
// LibraryCandidates returns unwatched-agnostic candidates in the given genres, for the
// recommendation engine. User state is applied by the caller, which is the only place
// that knows it.
func (s *Store) LibraryCandidates(ctx context.Context, genres []string, limit int) ([]json.RawMessage, error) {
if len(genres) == 0 {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
SELECT payload
FROM library_items
WHERE type IN ('Movie', 'Series')
AND genres && $1
ORDER BY community_rating DESC NULLS LAST, date_created DESC NULLS LAST
LIMIT $2`, genres, limit)
if err != nil {
return nil, fmt.Errorf("store: library candidates: %w", err)
}
return collectPayloads(rows)
}
func (s *Store) LibraryStats(ctx context.Context) (LibraryStats, error) {
stats := LibraryStats{ByType: map[string]int64{}}
rows, err := s.pool.Query(ctx, `SELECT type, count(*) FROM library_items GROUP BY type`)
if err != nil {
return stats, fmt.Errorf("store: library stats: %w", err)
}
defer rows.Close()
for rows.Next() {
var itemType string
var count int64
if err := rows.Scan(&itemType, &count); err != nil {
return stats, err
}
stats.ByType[itemType] = count
stats.Total += count
}
if err := rows.Err(); err != nil {
return stats, err
}
var lastSynced *time.Time
if err := s.pool.QueryRow(ctx, `SELECT max(synced_at) FROM library_items`).Scan(&lastSynced); err != nil {
return stats, err
}
stats.LastSynced = lastSynced
return stats, nil
}
func collectPayloads(rows pgx.Rows) ([]json.RawMessage, error) {
defer rows.Close()
out := []json.RawMessage{}
for rows.Next() {
var payload []byte
if err := rows.Scan(&payload); err != nil {
return nil, err
}
out = append(out, json.RawMessage(payload))
}
return out, rows.Err()
}
+88
View File
@@ -0,0 +1,88 @@
-- Gateway sessions: one row per signed-in TV.
--
-- token_hash is SHA-256 of the bearer token handed to the device, so a database dump
-- does not hand over working gateway tokens. emby_token IS the live upstream token and
-- is stored as-is: treat this volume as a secret store.
CREATE TABLE IF NOT EXISTS sessions (
token_hash BYTEA PRIMARY KEY,
emby_user_id TEXT NOT NULL,
emby_token TEXT NOT NULL,
username TEXT NOT NULL,
server_id TEXT NOT NULL DEFAULT '',
device_id TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS sessions_emby_user_idx ON sessions (emby_user_id);
CREATE INDEX IF NOT EXISTS sessions_last_seen_idx ON sessions (last_seen_at);
-- The imported library.
--
-- payload is Emby's item JSON verbatim, so rows served from here are byte-identical to
-- rows served live. Deliberately holds NO per-user state: everything is imported with
-- EnableUserData=false, because one household shares this table and watched/favourite
-- flags are not shareable. Anything user-specific still comes from Emby live.
CREATE TABLE IF NOT EXISTS library_items (
id TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
series_id TEXT NOT NULL DEFAULT '',
series_name TEXT NOT NULL DEFAULT '',
production_year INT,
community_rating REAL,
genres TEXT[] NOT NULL DEFAULT '{}',
studios TEXT[] NOT NULL DEFAULT '{}',
date_created TIMESTAMPTZ,
search_text TEXT NOT NULL DEFAULT '',
payload JSONB NOT NULL,
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- 'simple' rather than 'english': film titles are proper nouns, and stemming
-- "Arrival" into "arriv" helps nobody.
search_tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', search_text)) STORED
);
CREATE INDEX IF NOT EXISTS library_items_search_idx ON library_items USING GIN (search_tsv);
CREATE INDEX IF NOT EXISTS library_items_genres_idx ON library_items USING GIN (genres);
CREATE INDEX IF NOT EXISTS library_items_type_created_idx ON library_items (type, date_created DESC);
CREATE INDEX IF NOT EXISTS library_items_synced_idx ON library_items (synced_at);
-- One row per import, so the admin page can show what happened and when.
CREATE TABLE IF NOT EXISTS sync_runs (
id BIGSERIAL PRIMARY KEY,
kind TEXT NOT NULL, -- full | incremental
trigger TEXT NOT NULL DEFAULT 'schedule', -- schedule | manual | startup
status TEXT NOT NULL, -- running | success | failed
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
items_seen INT NOT NULL DEFAULT 0,
items_upserted INT NOT NULL DEFAULT 0,
items_removed INT NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS sync_runs_started_idx ON sync_runs (started_at DESC);
-- Small key/value store for operator switches (currently just maintenance mode). Kept in
-- Postgres rather than memory so a restart cannot silently bring the app back up.
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Row-level engagement. One row per reported event; aggregation happens at read time,
-- which is fine at household scale and keeps the write path trivial.
CREATE TABLE IF NOT EXISTS row_events (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
emby_user_id TEXT NOT NULL,
row_id TEXT NOT NULL,
row_kind TEXT NOT NULL DEFAULT '',
event TEXT NOT NULL, -- impression | focus | select
item_id TEXT NOT NULL DEFAULT '',
dwell_ms INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS row_events_time_idx ON row_events (occurred_at DESC);
CREATE INDEX IF NOT EXISTS row_events_row_idx ON row_events (row_id, occurred_at DESC);
+80
View File
@@ -0,0 +1,80 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// MaintenanceKey is the app_settings row backing maintenance mode.
const MaintenanceKey = "maintenance"
// Maintenance is the operator switch that takes Memby down independently of Emby.
//
// Deliberately durable: a restart must not quietly bring the app back up while someone
// is still working on it.
type Maintenance struct {
Enabled bool `json:"enabled"`
Message string `json:"message"`
UpdatedAt time.Time `json:"updatedAt"`
}
// DefaultMaintenanceMessage is shown on the TV when the operator did not write one.
const DefaultMaintenanceMessage = "Memby is down for maintenance. Try again shortly."
func (s *Store) Maintenance(ctx context.Context) (Maintenance, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, MaintenanceKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return Maintenance{}, nil
}
if err != nil {
return Maintenance{}, fmt.Errorf("store: read maintenance: %w", err)
}
var state Maintenance
if err := json.Unmarshal(raw, &state); err != nil {
return Maintenance{}, fmt.Errorf("store: decode maintenance: %w", err)
}
return state, nil
}
func (s *Store) SetMaintenance(ctx context.Context, state Maintenance) error {
state.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(state)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
MaintenanceKey, string(raw))
if err != nil {
return fmt.Errorf("store: write maintenance: %w", err)
}
return nil
}
// NewestSession is the fallback credential for the library import: whichever TV signed
// in most recently. It means a fresh deployment can import without configuring a
// service account, at the cost of the import stopping if that user is ever removed.
func (s *Store) NewestSession(ctx context.Context) (Session, error) {
var sess Session
err := s.pool.QueryRow(ctx, `
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, last_seen_at
FROM sessions ORDER BY last_seen_at DESC LIMIT 1`).
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
&sess.ServerID, &sess.DeviceID, &sess.LastSeenAt)
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrNotFound
}
if err != nil {
return Session{}, fmt.Errorf("store: newest session: %w", err)
}
return sess, nil
}
+113
View File
@@ -0,0 +1,113 @@
// Package store persists gateway sessions in Postgres.
package store
import (
"context"
_ "embed"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed schema.sql
var schema string
// ErrNotFound is returned when a token does not match a live session.
var ErrNotFound = errors.New("store: session not found")
type Session struct {
TokenHash []byte
EmbyUserID string
EmbyToken string
Username string
ServerID string
DeviceID string
LastSeenAt time.Time
}
type Store struct {
pool *pgxpool.Pool
}
func Open(ctx context.Context, databaseURL string) (*Store, error) {
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, fmt.Errorf("store: connect: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("store: ping: %w", err)
}
return &Store{pool: pool}, nil
}
func (s *Store) Close() { s.pool.Close() }
func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
// Migrate applies the schema. It is idempotent, so it runs on every boot.
func (s *Store) Migrate(ctx context.Context) error {
if _, err := s.pool.Exec(ctx, schema); err != nil {
return fmt.Errorf("store: migrate: %w", err)
}
return nil
}
func (s *Store) CreateSession(ctx context.Context, sess Session) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO sessions (token_hash, emby_user_id, emby_token, username, server_id, device_id)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (token_hash) DO UPDATE SET
emby_token = EXCLUDED.emby_token,
username = EXCLUDED.username,
server_id = EXCLUDED.server_id,
device_id = EXCLUDED.device_id,
last_seen_at = now()`,
sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username, sess.ServerID, sess.DeviceID)
if err != nil {
return fmt.Errorf("store: create session: %w", err)
}
return nil
}
func (s *Store) SessionByTokenHash(ctx context.Context, hash []byte) (Session, error) {
var sess Session
err := s.pool.QueryRow(ctx, `
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, last_seen_at
FROM sessions WHERE token_hash = $1`, hash).
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
&sess.ServerID, &sess.DeviceID, &sess.LastSeenAt)
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrNotFound
}
if err != nil {
return Session{}, fmt.Errorf("store: load session: %w", err)
}
return sess, nil
}
// Touch records activity. Cheap enough to call on the auth path, and it is what the
// idle-expiry sweep reads.
func (s *Store) Touch(ctx context.Context, hash []byte) error {
_, err := s.pool.Exec(ctx, `UPDATE sessions SET last_seen_at = now() WHERE token_hash = $1`, hash)
return err
}
func (s *Store) DeleteSession(ctx context.Context, hash []byte) error {
_, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE token_hash = $1`, hash)
return err
}
// DeleteIdleSessions retires tokens unused for longer than idle, returning how many went.
func (s *Store) DeleteIdleSessions(ctx context.Context, idle time.Duration) (int64, error) {
tag, err := s.pool.Exec(ctx,
`DELETE FROM sessions WHERE last_seen_at < now() - $1::interval`,
fmt.Sprintf("%d seconds", int64(idle.Seconds())))
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
+101
View File
@@ -0,0 +1,101 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// SyncRun records one library import.
type SyncRun struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Trigger string `json:"trigger"`
Status string `json:"status"`
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt"`
ItemsSeen int `json:"itemsSeen"`
ItemsUpserted int `json:"itemsUpserted"`
ItemsRemoved int `json:"itemsRemoved"`
Error string `json:"error"`
}
const (
SyncStatusRunning = "running"
SyncStatusSuccess = "success"
SyncStatusFailed = "failed"
)
func (s *Store) StartSyncRun(ctx context.Context, kind, trigger string) (int64, error) {
var id int64
err := s.pool.QueryRow(ctx,
`INSERT INTO sync_runs (kind, trigger, status) VALUES ($1, $2, $3) RETURNING id`,
kind, trigger, SyncStatusRunning).Scan(&id)
if err != nil {
return 0, fmt.Errorf("store: start sync run: %w", err)
}
return id, nil
}
func (s *Store) FinishSyncRun(ctx context.Context, id int64, run SyncRun) error {
_, err := s.pool.Exec(ctx, `
UPDATE sync_runs
SET status = $2, finished_at = now(), items_seen = $3,
items_upserted = $4, items_removed = $5, error = $6
WHERE id = $1`,
id, run.Status, run.ItemsSeen, run.ItemsUpserted, run.ItemsRemoved, run.Error)
if err != nil {
return fmt.Errorf("store: finish sync run: %w", err)
}
return nil
}
func (s *Store) RecentSyncRuns(ctx context.Context, limit int) ([]SyncRun, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, kind, trigger, status, started_at, finished_at,
items_seen, items_upserted, items_removed, error
FROM sync_runs ORDER BY started_at DESC LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("store: recent sync runs: %w", err)
}
defer rows.Close()
runs := []SyncRun{}
for rows.Next() {
var run SyncRun
if err := rows.Scan(&run.ID, &run.Kind, &run.Trigger, &run.Status, &run.StartedAt,
&run.FinishedAt, &run.ItemsSeen, &run.ItemsUpserted, &run.ItemsRemoved, &run.Error); err != nil {
return nil, err
}
runs = append(runs, run)
}
return runs, rows.Err()
}
// LastSuccessfulSyncAt is the watermark an incremental import asks Emby about: "what has
// changed since?" Nil means nothing has ever completed, so a full import is required.
func (s *Store) LastSuccessfulSyncAt(ctx context.Context) (*time.Time, error) {
var at *time.Time
err := s.pool.QueryRow(ctx,
`SELECT max(started_at) FROM sync_runs WHERE status = $1`, SyncStatusSuccess).Scan(&at)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("store: last successful sync: %w", err)
}
return at, nil
}
// MarkStaleRunsFailed cleans up runs left "running" by a crash or a restart mid-import.
func (s *Store) MarkStaleRunsFailed(ctx context.Context) error {
_, err := s.pool.Exec(ctx, `
UPDATE sync_runs
SET status = $1, finished_at = now(),
error = 'interrupted — the gateway restarted while this import was running'
WHERE status = $2`, SyncStatusFailed, SyncStatusRunning)
return err
}