Server changes/Sonarr

This commit is contained in:
ponzischeme89
2026-07-27 21:06:51 +12:00
parent 62f6345a40
commit 8d6cf2f5a1
42 changed files with 2388 additions and 284 deletions
+2
View File
@@ -8,6 +8,7 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN mkdir -p /out/releases
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \
-ldflags="-s -w" -o /out/memby-server ./cmd/memby-server
@@ -16,6 +17,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/memby-server /app/memby-server
COPY --from=build --chown=nonroot:nonroot /out/releases /data/releases
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app/memby-server"]
+93 -9
View File
@@ -35,6 +35,17 @@ go run ./cmd/memby-server # needs Postgres + Redis reachable
On Windows, `go` may need `-buildvcs=false` when the working tree has no usable `.git`.
## Logs
The server writes one compact, structured text line per event, designed for
`docker compose logs -f server`. At the default `MEMBY_LOG_LEVEL=INFO`, successful
health checks, live maintenance polls and artwork requests are hidden; warnings and
failures from those routes are still shown.
Set `MEMBY_LOG_LEVEL=DEBUG` temporarily and recreate the server container when those
high-frequency requests are useful during diagnosis. Library imports log their start,
progress after each 500-item page, and final counts and duration.
## API
All `/v1` routes need `Authorization: Bearer <token>` from `/v1/auth/login`. Image URLs
@@ -43,9 +54,11 @@ headers attached.
| Method | Path | Purpose |
| --- | --- | --- |
| POST | `/v1/auth/login` | Emby credentials in, gateway token out |
| POST | `/v1/auth/login` | Emby credentials + device identity in, gateway token out |
| GET | `/v1/auth/policy` | Public client allowance used by the sign-in screen |
| POST | `/v1/auth/logout` | Retire this device's token |
| GET | `/v1/auth/session` | Confirm a stored token is still valid |
| GET | `/v1/status` | Lightweight live maintenance state; remains available during maintenance |
| GET | `/v1/home?limit=` | **Every launcher row in one response** |
| GET | `/v1/recommendations?refresh=1` | Recommendation rows alone; `refresh` forces a rebuild |
| GET | `/v1/update` | Whether this client (per `X-Memby-Version`) must update |
@@ -71,10 +84,14 @@ four fixed rows repeated flat for the client's offline cache:
"rows": [
{"id": "continue", "title": "Continue Watching", "kind": "continue", "items": [...]},
{"id": "next-up", "title": "Next Up", "kind": "nextup", "items": [...]},
{"id": "sonarr-airing-today", "title": "Shows airing today", "kind": "schedule", "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": [...]}
{"id": "recommended", "title": "Recommended from your watching history", "kind": "recommended", "items": [...]},
{"id": "curated:apple-tv", "title": "Apple TV+ Shows", "kind": "shows", "items": [...]},
{"id": "curated:drama-shows", "title": "Drama TV Shows", "kind": "shows", "items": [...]},
{"id": "curated:comedy-shows", "title": "Comedy TV Shows", "kind": "shows", "items": [...]}
],
"continueWatching": [...], "nextUp": [...], "favorites": [...], "latestMovies": [...],
"partial": false
@@ -98,11 +115,16 @@ invented — nobody opted out of a row that did not exist when they last opened
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.
- **Curated TV shelves** — Apple TV+, Drama and Comedy membership is filtered from the
imported Postgres catalogue. Within each shelf, unseen shows are ranked using the same
personal genre/studio profile; the shelves themselves are ordered by affinity too.
A new profile falls back to community rating until it has viewing history.
Rows shorter than four items are dropped, and a user with no history gets no rows at all
rather than a strip of noise.
rather than a strip of noise, except curated shelves: these remain useful with their
quality-ranked fallback.
**The home screen never waits on the engine.** Rows live in their own `r:<userId>:rows`
**The home screen never waits on the engine.** Rows live in their own `r:<userId>:rows:v2`
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
@@ -112,11 +134,28 @@ 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.
Curated shelves require at least one completed library import because their genre/studio
filter runs against Postgres. Emby's exact studio naming is preserved during import;
Apple matching accepts `Apple TV+`, `Apple TV Plus` and `Apple Studios`.
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.
### Sonarr schedule
When `MEMBY_SONARR_URL` and `MEMBY_SONARR_API_KEY` are set, the gateway reads Sonarr's
v3 calendar and inserts **Shows airing today** directly after Next Up. Cards show the
local air time, season/episode number, episode title and one of: upcoming, downloading,
awaiting download, unmonitored, or the exact time Sonarr added the episode file.
This row is informational. An episode listed before it is downloaded is not an Emby
item, so the TV deliberately does not offer Play, Favourite or Watched actions on it.
Sonarr poster and fanart requests are proxied through the gateway; its API key is never
sent to the TV. The shared Redis entry is refreshed every five minutes by default, not
once per user.
## Admin interface
`http://<host>:8080/admin/` — a single self-contained page for library imports, the
@@ -167,8 +206,10 @@ candidate pool.
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.
a network error. The exact `/v1/status` control route stays up too: open clients poll it
every ten seconds, stop active playback, and move immediately to the maintenance screen.
`/healthz`, `/readyz` and `/admin` also 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
@@ -177,8 +218,40 @@ directly in the database works too.
## App update policy
The gateway decides whether a TV may keep running its current build. Clients send
`X-Memby-Version` on every request and ask `GET /v1/update` on each launch; the verdict is
`none`, `optional` or `mandatory`.
`X-Memby-Version` on every request and ask `GET /v1/update` on each launch and hourly
while left open; the verdict is `none`, `optional` or `mandatory`.
### Automated tagged releases
`.gitea/workflows/release.yml` turns a pushed semantic-version tag into an update:
1. Gitea Actions derives the Android version from a tag such as `v0.1.54`.
2. It runs the tests and builds the APK with the established release signing key.
3. It uploads the signed APK to `POST /admin/api/release`.
4. The gateway stores the APK in its `memby-releases` volume and makes it the latest
optional update. Older TVs prompt on their next check.
The repository needs an Actions runner with the `ubuntu-latest` label and these Actions
secrets:
| Secret | Purpose |
| --- | --- |
| `ANDROID_KEYSTORE_BASE64` | Existing release keystore, base64 encoded as one line |
| `ANDROID_KEYSTORE_PASSWORD` | Keystore password |
| `ANDROID_KEY_ALIAS` | Signing alias |
| `ANDROID_KEY_PASSWORD` | Key password |
| `MEMBY_RELEASE_PUBLISH_TOKEN` | Same dedicated value configured on the gateway |
Enable repository Actions and give the job permission to read contents. Then releasing
from any clone is:
```bash
git tag -a v0.1.54 -m "Personalised favourites"
git push origin main v0.1.54
```
Tags are the release boundary; an ordinary branch push never publishes an APK. Android
will only accept updates signed by the same keystore as the installed app.
Set it on the admin page: **latest version**, **APK URL** (normally the same file the
landing page serves), release notes, and a **Require this update** toggle.
@@ -229,7 +302,9 @@ served but never cached. The `r:` namespace is deliberately excluded from that w
Recommendations above).
Postgres holds only sessions. It is the durable half: losing Redis costs a cold cache,
losing Postgres signs everyone out.
losing Postgres signs everyone out. A session is unique per Emby user and stable device
ID; signing the same TV in again rotates its token, while a new TV is rejected once
`MEMBY_MAX_CLIENTS_PER_USER` is reached.
## Configuration
@@ -240,18 +315,27 @@ losing Postgres signs everyone out.
| `MEMBY_DATABASE_URL` | *required* | Postgres DSN |
| `MEMBY_REDIS_URL` | `redis://localhost:6379/0` | |
| `MEMBY_LISTEN_ADDR` | `:8080` | |
| `MEMBY_LOG_LEVEL` | `INFO` | Use `DEBUG` for successful probe, status-poll and artwork requests |
| `MEMBY_TIMEZONE` | `Pacific/Auckland` | Local day and time labels for schedule rows |
| `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_PUBLIC_URL` | *empty* | Public gateway origin used for APK download links |
| `MEMBY_RELEASE_DIR` | `/data/releases` | Persistent signed APK directory |
| `MEMBY_RELEASE_PUBLISH_TOKEN` | *empty* | Enables the CI-only release upload endpoint |
| `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_SONARR_URL` | *empty* | Sonarr address reachable by the gateway; empty disables integration |
| `MEMBY_SONARR_API_KEY` | *empty* | Sonarr Settings → General → Security API key |
| `MEMBY_SONARR_TTL` | `5m` | Shared Redis lifetime for today's calendar |
| `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_MAX_CLIENTS_PER_USER` | `1` | Strict maximum number of distinct signed-in TVs per Emby user |
| `MEMBY_UPSTREAM_TIMEOUT` | `20s` | |
## Security notes
+39 -2
View File
@@ -4,6 +4,7 @@ package main
import (
"context"
"encoding/hex"
"errors"
"flag"
"fmt"
@@ -20,7 +21,9 @@ import (
"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/logging"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -37,7 +40,8 @@ func main() {
return
}
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
logLevel := logging.ParseLevel(os.Getenv("MEMBY_LOG_LEVEL"))
log := logging.New(os.Stdout, logLevel)
if err := run(log); err != nil {
log.Error("fatal", "error", err)
@@ -82,6 +86,34 @@ func run(log *slog.Logger) error {
}
embyClient := emby.New(cfg.EmbyURL, cfg.EmbyPublicURL, cfg.ClientName, cfg.UpstreamTimeout)
retiredSessions, err := st.TrimSessionsToLimit(ctx, cfg.MaxClientsPerUser)
if err != nil {
return err
}
for _, sess := range retiredSessions {
_ = ca.Delete(ctx, cache.SessionKey(hex.EncodeToString(sess.TokenHash)))
if err := embyClient.Logout(ctx, emby.Credentials{
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
}); err != nil {
log.Warn("could not retire excess emby session",
"username", sess.Username,
"device", sess.DeviceName,
"error", err,
)
}
}
if len(retiredSessions) > 0 {
log.Info("enforced per-user device allowance",
"retired_sessions", len(retiredSessions),
"max_clients_per_user", cfg.MaxClientsPerUser,
)
}
var sonarrClient *sonarr.Client
if cfg.SonarrURL != "" {
sonarrClient = sonarr.New(cfg.SonarrURL, cfg.SonarrAPIKey, cfg.UpstreamTimeout)
log.Info("sonarr integration enabled", "url", cfg.SonarrURL)
}
recommender := recommend.NewEngine(embyClient, log)
// Candidates come from the imported library when one exists, which keeps the
@@ -99,6 +131,7 @@ func run(log *slog.Logger) error {
Store: st,
Cache: ca,
Recommender: recommender,
Sonarr: sonarrClient,
Syncer: syncer,
Log: log,
})
@@ -135,7 +168,11 @@ func run(log *slog.Logger) error {
errCh := make(chan error, 1)
go func() {
log.Info("listening", "addr", cfg.ListenAddr, "emby", cfg.EmbyURL)
log.Info("server ready",
"listen", cfg.ListenAddr,
"emby", cfg.EmbyURL,
"sync_every", cfg.SyncInterval,
)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
+1
View File
@@ -29,6 +29,7 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
return mux
}
+22
View File
@@ -97,6 +97,28 @@ func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) {
}
}
func TestServiceStatusReportsMaintenanceOutsideTheGate(t *testing.T) {
server := testServer(config.Config{})
server.maintenance.set(store.Maintenance{Enabled: true, Message: "Back after dinner"})
rec := httptest.NewRecorder()
server.handleServiceStatus(
rec,
httptest.NewRequest(http.MethodGet, "/v1/status", nil),
store.Session{},
)
if rec.Code != http.StatusOK {
t.Fatalf("status endpoint returned %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["maintenance"] != true || body["message"] != "Back after dinner" {
t.Fatalf("unexpected status response: %v", body)
}
}
func TestAdminIsDisabledWithoutAToken(t *testing.T) {
server := testServer(config.Config{})
+41 -3
View File
@@ -18,12 +18,14 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"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/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -33,8 +35,10 @@ type Server struct {
store *store.Store
cache *cache.Cache
recommender *recommend.Engine
sonarr *sonarr.Client
syncer syncerHandle
log *slog.Logger
sonarrMu sync.Mutex
recommendationBuilds recommendationBuilds
maintenance maintenanceState
@@ -48,17 +52,22 @@ type Deps struct {
Store *store.Store
Cache *cache.Cache
Recommender *recommend.Engine
Sonarr *sonarr.Client
Syncer syncerHandle
Log *slog.Logger
}
func New(cfg config.Config, deps Deps) *Server {
if cfg.MaxClientsPerUser < 1 {
cfg.MaxClientsPerUser = 1
}
return &Server{
cfg: cfg,
emby: deps.Emby,
store: deps.Store,
cache: deps.Cache,
recommender: deps.Recommender,
sonarr: deps.Sonarr,
syncer: deps.Syncer,
log: deps.Log,
}
@@ -70,6 +79,7 @@ func (s *Server) Routes() http.Handler {
v1 := http.NewServeMux()
v1.HandleFunc("POST /v1/auth/login", s.handleLogin)
v1.HandleFunc("GET /v1/auth/policy", s.handleAuthPolicy)
v1.Handle("POST /v1/auth/logout", s.authed(s.handleLogout))
v1.Handle("GET /v1/auth/session", s.authed(s.handleSession))
@@ -93,8 +103,12 @@ func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealth)
mux.HandleFunc("GET /readyz", s.handleReady)
// Exact route outside the maintenance gate: signed-in clients poll this lightweight
// status even while every normal /v1 operation is deliberately unavailable.
mux.Handle("GET /v1/status", s.authed(s.handleServiceStatus))
mux.Handle("/v1/", s.maintenanceGate(v1))
mux.Handle("/admin/", s.adminRoutes())
mux.HandleFunc("GET /updates/{filename}", s.handleReleaseDownload)
return s.withLogging(mux)
}
@@ -134,15 +148,33 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
// Path only: query strings can carry image tokens.
s.log.Info("request",
level := requestLogLevel(r.URL.Path, rec.status)
s.log.Log(r.Context(), level, "HTTP request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"ms", time.Since(start).Milliseconds(),
"duration", time.Since(start).Round(time.Millisecond),
)
})
}
// Successful high-frequency probes and artwork fetches stay available at DEBUG without
// overwhelming the normal Docker log. Failures are always promoted so they remain
// visible regardless of path.
func requestLogLevel(path string, status int) slog.Level {
switch {
case status >= http.StatusInternalServerError:
return slog.LevelError
case status >= http.StatusBadRequest:
return slog.LevelWarn
case path == "/healthz", path == "/readyz", path == "/v1/status",
strings.HasPrefix(path, "/v1/images/"):
return slog.LevelDebug
default:
return slog.LevelInfo
}
}
type statusRecorder struct {
http.ResponseWriter
status int
@@ -184,6 +216,7 @@ type cachedSession struct {
Username string `json:"n"`
ServerID string `json:"s"`
DeviceID string `json:"d"`
DeviceName string `json:"dn,omitempty"`
}
// sessionFor resolves a token, using Redis to keep the hot path off Postgres.
@@ -201,6 +234,7 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
Username: cs.Username,
ServerID: cs.ServerID,
DeviceID: cs.DeviceID,
DeviceName: cs.DeviceName,
}, nil
}
}
@@ -220,6 +254,7 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
Username: sess.Username,
ServerID: sess.ServerID,
DeviceID: sess.DeviceID,
DeviceName: sess.DeviceName,
}); err == nil {
_ = s.cache.Set(ctx, key, raw, s.cfg.SessionTTL)
}
@@ -231,7 +266,10 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
}
func credentials(sess store.Session) emby.Credentials {
return emby.Credentials{UserID: sess.EmbyUserID, Token: sess.EmbyToken, DeviceID: sess.DeviceID}
return emby.Credentials{
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
}
}
// --- responses --------------------------------------------------------------
+59
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
)
@@ -57,6 +58,64 @@ func TestNewTokenIsUnique(t *testing.T) {
}
}
func TestAuthPolicyPublishesConfiguredDeviceAllowance(t *testing.T) {
s := &Server{cfg: config.Config{MaxClientsPerUser: 4}}
req := httptest.NewRequest(http.MethodGet, "/v1/auth/policy", nil)
rec := httptest.NewRecorder()
s.handleAuthPolicy(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var policy authPolicyResponse
if err := json.Unmarshal(rec.Body.Bytes(), &policy); err != nil {
t.Fatalf("decode policy: %v", err)
}
if policy.MaxClientsPerUser != 4 {
t.Fatalf("max clients = %d, want 4", policy.MaxClientsPerUser)
}
}
func TestSonarrScheduleRequiresCapableClient(t *testing.T) {
tests := map[string]bool{
"": false,
"0.1.53": false,
"0.1.54": true,
"0.2.0": true,
}
for version, want := range tests {
req := httptest.NewRequest(http.MethodGet, "/v1/home", nil)
req.Header.Set("X-Memby-Version", version)
if got := supportsSonarrSchedule(req); got != want {
t.Errorf("supportsSonarrSchedule(%q) = %v, want %v", version, got, want)
}
}
}
func TestPlaybackHintAvoidsAnUpstreamItemLookup(t *testing.T) {
req := httptest.NewRequest(
http.MethodGet,
"/v1/items/42/playback?type=Movie&title=Arrival&resumePositionMs=12000",
nil,
)
item, ok := playbackHint(req, "42")
if !ok {
t.Fatal("valid hint was rejected")
}
if item.ID != "42" || item.Type != "Movie" || item.Name != "Arrival" {
t.Fatalf("unexpected hinted item: %+v", item)
}
if item.UserData.PlaybackPositionTicks != 12_000*ticksPerMillisecond {
t.Fatalf("resume ticks = %d", item.UserData.PlaybackPositionTicks)
}
bad := httptest.NewRequest(http.MethodGet, "/v1/items/42/playback?type=Playlist", nil)
if _, ok := playbackHint(bad, "42"); ok {
t.Fatal("unsupported type should fall back to Emby")
}
}
// Empty rows must serialise as [] so kotlinx.serialization can decode them into the
// client's non-null List fields.
func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) {
+78 -16
View File
@@ -2,24 +2,39 @@ package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
)
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
DeviceID string `json:"deviceId"`
Username string `json:"username"`
Password string `json:"password"`
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
}
type loginResponse struct {
Token string `json:"token"`
UserID string `json:"userId"`
Username string `json:"username"`
ServerID string `json:"serverId"`
Token string `json:"token"`
UserID string `json:"userId"`
Username string `json:"username"`
ServerID string `json:"serverId"`
ActiveClients int `json:"activeClients,omitempty"`
MaxClientsPerUser int `json:"maxClientsPerUser"`
}
type authPolicyResponse struct {
MaxClientsPerUser int `json:"maxClientsPerUser"`
}
func (s *Server) handleAuthPolicy(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, authPolicyResponse{
MaxClientsPerUser: s.cfg.MaxClientsPerUser,
})
}
// handleLogin exchanges Emby credentials for a gateway token.
@@ -40,8 +55,21 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if req.DeviceID == "" {
req.DeviceID = "memby-tv"
}
req.DeviceName = strings.TrimSpace(req.DeviceName)
if req.DeviceName == "" {
// Compatibility for APKs released before device naming. New clients require an
// editable name in their UI, but an older TV must still be able to sign in while
// the household rollout is in progress.
req.DeviceName = "Memby TV"
}
if len([]rune(req.DeviceName)) > 80 {
writeError(w, http.StatusBadRequest, "device name is too long")
return
}
auth, err := s.emby.Authenticate(r.Context(), req.Username, req.Password, req.DeviceID)
auth, err := s.emby.Authenticate(
r.Context(), req.Username, req.Password, req.DeviceID, req.DeviceName,
)
if err != nil {
// Never echo Emby's body here: a failed sign-in is the one place a wrong
// password could be reflected back.
@@ -64,21 +92,54 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
Username: auth.User.Name,
ServerID: auth.ServerID,
DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
}
if sess.Username == "" {
sess.Username = req.Username
}
if err := s.store.CreateSession(r.Context(), sess); err != nil {
replacedHash, activeClients, err := s.store.CreateSession(
r.Context(), sess, s.cfg.MaxClientsPerUser,
)
if errors.Is(err, store.ErrDeviceLimit) {
if revokeErr := s.emby.Logout(r.Context(), emby.Credentials{
UserID: auth.User.ID, Token: auth.AccessToken,
DeviceID: req.DeviceID, DeviceName: req.DeviceName,
}); revokeErr != nil {
s.log.Warn("could not retire refused emby session", "error", revokeErr)
}
s.log.Warn("device allowance reached",
"username", sess.Username,
"active_clients", activeClients,
"max_clients", s.cfg.MaxClientsPerUser,
)
writeJSON(w, http.StatusConflict, map[string]any{
"error": "device_limit_reached",
"message": "This account has reached its Memby device allowance.",
"activeClients": activeClients,
"maxClientsPerUser": s.cfg.MaxClientsPerUser,
})
return
}
if err != nil {
_ = s.emby.Logout(r.Context(), emby.Credentials{
UserID: auth.User.ID, Token: auth.AccessToken,
DeviceID: req.DeviceID, DeviceName: req.DeviceName,
})
s.log.Error("session persist failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not start a session")
return
}
if len(replacedHash) > 0 {
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(replacedHash)))
}
writeJSON(w, http.StatusOK, loginResponse{
Token: token,
UserID: sess.EmbyUserID,
Username: sess.Username,
ServerID: sess.ServerID,
Token: token,
UserID: sess.EmbyUserID,
Username: sess.Username,
ServerID: sess.ServerID,
ActiveClients: activeClients,
MaxClientsPerUser: s.cfg.MaxClientsPerUser,
})
}
@@ -94,8 +155,9 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess store
// 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,
UserID: sess.EmbyUserID,
Username: sess.Username,
ServerID: sess.ServerID,
MaxClientsPerUser: s.cfg.MaxClientsPerUser,
})
}
+33 -5
View File
@@ -8,6 +8,7 @@ import (
"strconv"
"sync"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/recommend"
@@ -63,10 +64,11 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
cred := credentials(sess)
var (
mu sync.Mutex
failures int
out homeResponse
wg sync.WaitGroup
mu sync.Mutex
failures int
out homeResponse
sonarrRow *recommend.Row
wg sync.WaitGroup
)
run := func(dest *[]json.RawMessage, fetch func() (*emby.ItemsResult, error)) {
@@ -119,6 +121,20 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
"Limit": {itoa(limit)},
}, fieldsRow))
})
if s.sonarr != nil && supportsSonarrSchedule(r) {
wg.Add(1)
go func() {
defer wg.Done()
row, err := s.sonarrAiringTodayRow(ctx)
if err != nil {
s.log.Warn("sonarr calendar row failed", "error", err)
return
}
mu.Lock()
sonarrRow = row
mu.Unlock()
}()
}
wg.Wait()
@@ -136,7 +152,12 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
if recommendations == nil {
s.refreshRecommendationsInBackground(sess)
}
out.Rows = append(baseRows(out), recommendations...)
rows := baseRows(out)
if sonarrRow != nil {
// The schedule is most useful beside Next Up, before personal collections.
rows = append(rows[:2], append([]recommend.Row{*sonarrRow}, rows[2:]...)...)
}
out.Rows = append(rows, recommendations...)
body, err := json.Marshal(out)
if err != nil {
@@ -154,6 +175,13 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
writeRaw(w, http.StatusOK, body)
}
// Older clients render unknown rows but do not understand MembyPlayable=false, so they
// could try to send a synthetic Sonarr id to Emby. The feature ships with 0.1.54.
func supportsSonarrSchedule(r *http.Request) bool {
version := clientVersion(r)
return version != "" && appupdate.CompareVersions(version, "0.1.54") >= 0
}
// 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) {
+52
View File
@@ -1,11 +1,14 @@
package api
import (
"errors"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -29,6 +32,10 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
writeError(w, http.StatusNotFound, "unknown image")
return
}
if strings.HasPrefix(itemID, "sonarr:") {
s.handleSonarrImage(w, r, itemID, imageType)
return
}
params := url.Values{}
for _, key := range []string{"tag", "maxWidth", "maxHeight", "quality"} {
@@ -63,3 +70,48 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
s.log.Warn("image copy failed", "error", err)
}
}
func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) {
if s.sonarr == nil {
writeError(w, http.StatusNotFound, "unknown image")
return
}
parts := strings.Split(itemID, ":")
if len(parts) != 3 {
writeError(w, http.StatusNotFound, "unknown image")
return
}
seriesID, err := strconv.Atoi(parts[1])
if err != nil || seriesID <= 0 {
writeError(w, http.StatusNotFound, "unknown image")
return
}
coverType := map[string]string{"Primary": "poster", "Backdrop": "fanart"}[imageType]
if coverType == "" {
writeError(w, http.StatusNotFound, "unknown image")
return
}
resp, err := s.sonarr.MediaCover(r.Context(), seriesID, coverType)
if err != nil {
var apiErr *sonarr.APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
writeError(w, http.StatusNotFound, "image not found")
return
}
s.log.Warn("sonarr image failed", "series_id", seriesID, "type", coverType, "error", err)
writeError(w, http.StatusBadGateway, "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)
}
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("sonarr image copy failed", "error", err)
}
}
+15
View File
@@ -85,3 +85,18 @@ func (s *Server) maintenanceGate(next http.Handler) http.Handler {
})
})
}
// handleServiceStatus is the live control channel clients poll while the app is open.
// It sits outside maintenanceGate so maintenance can interrupt playback rather than only
// being discovered the next time a content request happens to run.
func (s *Server) handleServiceStatus(w http.ResponseWriter, _ *http.Request, _ store.Session) {
state := s.maintenance.get()
message := state.Message
if state.Enabled && message == "" {
message = store.DefaultMaintenanceMessage
}
writeJSON(w, http.StatusOK, map[string]any{
"maintenance": state.Enabled,
"message": message,
})
}
+38 -9
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/ponzischeme89/memby/server/internal/emby"
@@ -40,15 +41,18 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
}
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
item, hinted := playbackHint(r, itemID)
if !hinted {
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
@@ -78,6 +82,31 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
})
}
// Current clients already know the selected item's type, title and cached resume point.
// Accepting those as hints removes one serial Emby request from every launch. Older
// clients omit them and retain the authoritative lookup above.
func playbackHint(r *http.Request, itemID string) (emby.Summary, bool) {
itemType := strings.TrimSpace(r.URL.Query().Get("type"))
switch {
case strings.EqualFold(itemType, "Movie"):
itemType = "Movie"
case strings.EqualFold(itemType, "Episode"):
itemType = "Episode"
case strings.EqualFold(itemType, "Series"):
itemType = "Series"
default:
return emby.Summary{}, false
}
resumeMs, _ := strconv.ParseInt(r.URL.Query().Get("resumePositionMs"), 10, 64)
item := emby.Summary{
ID: itemID,
Name: strings.TrimSpace(r.URL.Query().Get("title")),
Type: itemType,
}
item.UserData.PlaybackPositionTicks = max64(resumeMs, 0) * ticksPerMillisecond
return item, true
}
// 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{
+4
View File
@@ -79,6 +79,10 @@ func Decide(policy Policy, clientVersion string) Decision {
return none
}
// CompareVersions returns -1, 0, or 1 and is shared by policy decisions and the release
// publisher's downgrade guard.
func CompareVersions(a, b string) int { return compare(parseVersion(a), parseVersion(b)) }
// compare returns -1, 0 or 1. Missing components count as zero, so 0.1 == 0.1.0.
func compare(a, b []int) int {
for i := 0; i < len(a) || i < len(b); i++ {
+1 -1
View File
@@ -87,7 +87,7 @@ func UserKey(userID, view string) string { return fmt.Sprintf("u:%s:%s", userID,
// 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 RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows:v2", userID) }
func (c *Cache) InvalidateRecommendations(ctx context.Context, userID string) error {
return c.Delete(ctx, RecommendationsKey(userID))
+54 -1
View File
@@ -7,6 +7,7 @@ import (
"strconv"
"strings"
"time"
_ "time/tzdata"
)
type Config struct {
@@ -33,6 +34,8 @@ type Config struct {
SessionTTL time.Duration
// SessionIdleExpiry retires gateway tokens that go unused for this long.
SessionIdleExpiry time.Duration
// MaxClientsPerUser is enforced transactionally when a new TV signs in.
MaxClientsPerUser int
// RecommendTTL is how long computed recommendation rows stay warm. Long, because
// taste moves slowly and each rebuild costs several Emby queries.
@@ -47,6 +50,14 @@ type Config struct {
// unconfigured deployment cannot leave it exposed.
AdminToken string
// PublicURL is the externally reachable Memby gateway address used in update links.
PublicURL string
// ReleaseDir persists signed APKs published by CI.
ReleaseDir string
// ReleasePublishToken authorizes the CI-only release upload endpoint. It is separate
// from AdminToken so a compromised build runner cannot change maintenance settings.
ReleasePublishToken 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.
@@ -60,6 +71,13 @@ type Config struct {
// AnalyticsRetention is how long raw row events are kept before being pruned.
AnalyticsRetention time.Duration
// Sonarr is optional. When configured, its local calendar supplies the informational
// "Shows airing today" home row. The API key never leaves this server.
SonarrURL string
SonarrAPIKey string
SonarrTTL time.Duration
SonarrLocation *time.Location
}
func Load() (Config, error) {
@@ -76,10 +94,16 @@ func Load() (Config, error) {
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),
MaxClientsPerUser: integer("MEMBY_MAX_CLIENTS_PER_USER", 1),
RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 2*time.Hour),
RecommendTimeout: duration("MEMBY_RECOMMEND_TIMEOUT", 60*time.Second),
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
ReleaseDir: env("MEMBY_RELEASE_DIR", "/data/releases"),
ReleasePublishToken: strings.TrimSpace(
os.Getenv("MEMBY_RELEASE_PUBLISH_TOKEN"),
),
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
@@ -87,6 +111,9 @@ func Load() (Config, error) {
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),
SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"),
SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")),
SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute),
}
if c.EmbyURL == "" {
@@ -95,9 +122,23 @@ func Load() (Config, error) {
if c.DatabaseURL == "" {
return c, fmt.Errorf("MEMBY_DATABASE_URL is required")
}
if c.MaxClientsPerUser < 1 {
return c, fmt.Errorf("MEMBY_MAX_CLIENTS_PER_USER must be at least 1")
}
if c.EmbyPublicURL == "" {
c.EmbyPublicURL = c.EmbyURL
}
if c.ReleasePublishToken != "" && c.PublicURL == "" {
return c, fmt.Errorf("MEMBY_PUBLIC_URL is required when release publishing is enabled")
}
if (c.SonarrURL == "") != (c.SonarrAPIKey == "") {
return c, fmt.Errorf("MEMBY_SONARR_URL and MEMBY_SONARR_API_KEY must be set together")
}
location, err := time.LoadLocation(env("MEMBY_TIMEZONE", "Pacific/Auckland"))
if err != nil {
return c, fmt.Errorf("MEMBY_TIMEZONE: %w", err)
}
c.SonarrLocation = location
return c, nil
}
@@ -134,3 +175,15 @@ func duration(key string, fallback time.Duration) time.Duration {
}
return fallback
}
func integer(key string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return value
}
+22 -7
View File
@@ -27,9 +27,10 @@ type Client struct {
// Credentials identify one signed-in Emby user.
type Credentials struct {
UserID string
Token string
DeviceID string
UserID string
Token string
DeviceID string
DeviceName string
}
type ItemsResult struct {
@@ -82,13 +83,13 @@ func New(baseURL, publicURL, clientName string, timeout time.Duration) *Client {
}
}
func (c *Client) Authenticate(ctx context.Context, username, password, deviceID string) (*AuthResult, error) {
func (c *Client) Authenticate(ctx context.Context, username, password, deviceID, deviceName 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))
Credentials{DeviceID: deviceID, DeviceName: deviceName}, bytes.NewReader(body))
if err != nil {
return nil, err
}
@@ -104,6 +105,16 @@ func (c *Client) Authenticate(ctx context.Context, username, password, deviceID
return &out, nil
}
// Logout retires a token created during authentication. The gateway uses this when a
// device is refused by policy so Emby is not left holding an orphaned session.
func (c *Client) Logout(ctx context.Context, cred Credentials) error {
req, err := c.newRequest(ctx, http.MethodPost, "/Sessions/Logout", nil, cred, nil)
if err != nil {
return err
}
return c.do(req, 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)
}
@@ -285,10 +296,14 @@ func (c *Client) newRequest(ctx context.Context, method, path string, params url
if deviceID == "" {
deviceID = "memby-gateway"
}
deviceName := strings.TrimSpace(cred.DeviceName)
if deviceName == "" {
deviceName = "Memby TV"
}
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,
`MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="1.0"`,
c.clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID,
))
if cred.Token != "" {
req.Header.Set("X-Emby-Token", cred.Token)
+8 -1
View File
@@ -122,6 +122,7 @@ func (s *Syncer) Sync(ctx context.Context, kind, trigger string) (Result, error)
return Result{}, err
}
s.log.Info("library sync started", "kind", kind, "trigger", trigger)
result, syncErr := s.run(ctx, cred, kind, since, startedAt)
result.Kind = kind
result.Duration = time.Since(startedAt)
@@ -147,7 +148,8 @@ func (s *Syncer) Sync(ctx context.Context, kind, trigger string) (Result, error)
}
s.log.Info("library sync finished",
"kind", kind, "trigger", trigger, "seen", result.Seen,
"upserted", result.Upserted, "removed", result.Removed, "ms", result.DurationMs)
"upserted", result.Upserted, "removed", result.Removed,
"duration", result.Duration.Round(time.Millisecond))
return result, nil
}
@@ -204,6 +206,11 @@ func (s *Syncer) run(
result.Seen += len(page.Items)
result.Upserted += int(written)
s.log.Info("library sync progress",
"kind", kind,
"seen", result.Seen,
"upserted", result.Upserted,
)
if len(page.Items) < pageSize {
break
+110 -12
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"log/slog"
"net/url"
"sort"
"strconv"
"strings"
"sync"
@@ -33,6 +34,27 @@ type LibrarySource interface {
LibraryCandidates(ctx context.Context, genres []string, limit int) ([]json.RawMessage, error)
}
// CuratedLibrarySource is the optional richer catalogue query used for server-authored
// collections. Keeping it separate preserves compatibility with simpler test sources.
type CuratedLibrarySource interface {
CuratedCandidates(
ctx context.Context,
itemTypes, genres, studios []string,
limit int,
) ([]json.RawMessage, error)
}
// CuratedRow defines one reusable server-side shelf. Filtering determines membership;
// the user's profile determines both item order and shelf order.
type CuratedRow struct {
ID string
Title string
Kind string
ItemTypes []string
Genres []string
Studios []string
}
type Engine struct {
source Source
log *slog.Logger
@@ -47,6 +69,7 @@ type Engine struct {
// screen rather than a wall of near-duplicates.
MaxSimilarRows int
RowSize int
CuratedRows []CuratedRow
}
func NewEngine(source Source, log *slog.Logger) *Engine {
@@ -56,6 +79,29 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
MinRowItems: 4,
MaxSimilarRows: 2,
RowSize: 20,
CuratedRows: []CuratedRow{
{
ID: "curated:apple-tv",
Title: "Apple TV+ Shows",
Kind: "shows",
ItemTypes: []string{"Series"},
Studios: []string{"Apple TV+", "Apple TV Plus", "Apple Studios"},
},
{
ID: "curated:drama-shows",
Title: "Drama TV Shows",
Kind: "shows",
ItemTypes: []string{"Series"},
Genres: []string{"Drama"},
},
{
ID: "curated:comedy-shows",
Title: "Comedy TV Shows",
Kind: "shows",
ItemTypes: []string{"Series"},
Genres: []string{"Comedy"},
},
},
}
}
@@ -76,25 +122,77 @@ func (e *Engine) BuildRows(ctx context.Context, cred emby.Credentials) ([]Row, e
}
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 := make([]Row, 0, e.MaxSimilarRows+1+len(e.CuratedRows))
if !profile.IsEmpty() {
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)
}
}
if row, ok := e.historyRow(ctx, cred, profile); ok {
rows = append(rows, row)
}
rows = append(rows, e.buildCuratedRows(ctx, profile)...)
return rows, nil
}
func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
library, ok := e.Library.(CuratedLibrarySource)
if !ok {
return nil
}
type rankedDefinition struct {
definition CuratedRow
affinity float64
order int
}
definitions := make([]rankedDefinition, 0, len(e.CuratedRows))
for order, definition := range e.CuratedRows {
definitions = append(definitions, rankedDefinition{
definition: definition,
affinity: profile.CollectionAffinity(definition.Genres, definition.Studios),
order: order,
})
}
sort.SliceStable(definitions, func(i, j int) bool {
if definitions[i].affinity != definitions[j].affinity {
return definitions[i].affinity > definitions[j].affinity
}
return definitions[i].order < definitions[j].order
})
rows := make([]Row, 0, len(definitions))
for _, ranked := range definitions {
definition := ranked.definition
raws, err := library.CuratedCandidates(
ctx,
definition.ItemTypes,
definition.Genres,
definition.Studios,
e.RowSize*6,
)
if err != nil {
e.log.Warn("curated row failed", "row", definition.ID, "error", err)
continue
}
items := RankCollection(profile, Decode(raws), e.RowSize)
if len(items) < e.MinRowItems {
continue
}
rows = append(rows, Row{
ID: definition.ID,
Title: definition.Title,
Kind: definition.Kind,
Items: Raws(items),
})
}
return rows
}
// 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 (
+86
View File
@@ -27,6 +27,31 @@ type fakeSource struct {
similarSeeds []string
}
type fakeCuratedLibrary struct {
byGenre map[string][]json.RawMessage
}
func (f *fakeCuratedLibrary) LibraryCandidates(
_ context.Context,
_ []string,
_ int,
) ([]json.RawMessage, error) {
return nil, nil
}
func (f *fakeCuratedLibrary) CuratedCandidates(
_ context.Context,
_ []string,
genres, studios []string,
_ int,
) ([]json.RawMessage, error) {
key := strings.Join(genres, "|")
if len(studios) > 0 {
key = "studio:" + studios[0]
}
return f.byGenre[key], nil
}
func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Values) (*emby.ItemsResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -191,6 +216,67 @@ func TestBuildRowsReturnsNothingForAUserWithNoHistory(t *testing.T) {
}
}
func TestCuratedShowRowsAndItemsAreOrderedByViewingAffinity(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
"IsPlayed": {
raw("history", "Funny History", "Episode", "Comedy"),
},
},
similar: map[string][]json.RawMessage{},
}
engine := testEngine(source)
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
"Comedy": {
raw("comedy-low", "Lower Rated Match", "Series", "Comedy"),
raw("comedy-high", "Higher Rated Match", "Series", "Comedy"),
},
"Drama": {
raw("drama-1", "Drama One", "Series", "Drama"),
raw("drama-2", "Drama Two", "Series", "Drama"),
},
}}
engine.CuratedRows = []CuratedRow{
{ID: "drama", Title: "Drama TV Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Drama"}},
{ID: "comedy", Title: "Comedy TV Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Comedy"}},
}
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("expected two curated rows, got %v", rowTitles(rows))
}
if rows[0].ID != "comedy" || rows[1].ID != "drama" {
t.Fatalf("user's comedy affinity should order shelves, got %v", rowTitles(rows))
}
if rows[0].Kind != "shows" {
t.Fatalf("curated TV shelf kind = %q", rows[0].Kind)
}
}
func TestCuratedRowsFallBackToRatingForANewUser(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
engine := testEngine(source)
engine.MinRowItems = 1
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
"Drama": {raw("drama", "Strong Drama", "Series", "Drama")},
}}
engine.CuratedRows = []CuratedRow{{
ID: "drama", Title: "Drama TV Shows", Kind: "shows",
ItemTypes: []string{"Series"}, Genres: []string{"Drama"},
}}
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "new"})
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].ID != "drama" {
t.Fatalf("new profiles should receive quality-ranked curated rows: %+v", rows)
}
}
// A failing similarity lookup is one dead row, not a dead home screen.
func TestBuildRowsSurvivesASimilarLookupFailure(t *testing.T) {
source := &fakeSource{
+32 -1
View File
@@ -193,8 +193,39 @@ func (p Profile) Score(candidate Item) float64 {
return genreScore + studioScore + ratingScore
}
// CollectionAffinity decides which curated shelf appears first for this user.
func (p Profile) CollectionAffinity(genres, studios []string) float64 {
var score float64
for _, genre := range genres {
score += weightFold(p.GenreWeights, genre)
}
for _, studio := range studios {
score += weightFold(p.StudioWeights, studio)
}
return score
}
func weightFold(weights map[string]float64, wanted string) float64 {
for key, value := range weights {
if strings.EqualFold(strings.TrimSpace(key), strings.TrimSpace(wanted)) {
return value
}
}
return 0
}
// Rank scores, filters and truncates candidates, dropping duplicates by id.
func Rank(profile Profile, candidates []Item, limit int) []Item {
return rank(profile, candidates, limit, false)
}
// RankCollection keeps unseen candidates with no affinity/rating score at the end,
// ensuring a curated shelf remains useful for a new profile or unrated library.
func RankCollection(profile Profile, candidates []Item, limit int) []Item {
return rank(profile, candidates, limit, true)
}
func rank(profile Profile, candidates []Item, limit int, includeZero bool) []Item {
type scored struct {
item Item
score float64
@@ -207,7 +238,7 @@ func Rank(profile Profile, candidates []Item, limit int) []Item {
continue
}
seen[candidate.ID] = true
if score := profile.Score(candidate); score > 0 {
if score := profile.Score(candidate); score > 0 || includeZero && score == 0 {
ranked = append(ranked, scored{candidate, score})
}
}
+10
View File
@@ -153,6 +153,16 @@ func TestRankRespectsLimit(t *testing.T) {
}
}
func TestCollectionAffinityIsCaseInsensitive(t *testing.T) {
profile := Profile{
GenreWeights: map[string]float64{"Comedy": 2},
StudioWeights: map[string]float64{"Apple TV+": 0.5},
}
if got := profile.CollectionAffinity([]string{"comedy"}, []string{"apple tv+"}); got != 2.5 {
t.Fatalf("CollectionAffinity = %v, want 2.5", 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"}`)})
+45
View File
@@ -138,6 +138,51 @@ func (s *Store) LibraryCandidates(ctx context.Context, genres []string, limit in
return collectPayloads(rows)
}
// CuratedCandidates filters the imported catalogue for a server-authored shelf. Arrays
// are matched case-insensitively because Emby studio capitalisation is not consistent.
func (s *Store) CuratedCandidates(
ctx context.Context,
itemTypes, genres, studios []string,
limit int,
) ([]json.RawMessage, error) {
if len(itemTypes) == 0 || (len(genres) == 0 && len(studios) == 0) {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
SELECT payload
FROM library_items
WHERE type = ANY($1)
AND (
cardinality($2::text[]) = 0 OR
EXISTS (
SELECT 1 FROM unnest(genres) AS genre
WHERE lower(genre) = ANY($2)
)
)
AND (
cardinality($3::text[]) = 0 OR
EXISTS (
SELECT 1 FROM unnest(studios) AS studio
WHERE lower(studio) = ANY($3)
)
)
ORDER BY community_rating DESC NULLS LAST, date_created DESC NULLS LAST
LIMIT $4`,
itemTypes, lowerStrings(genres), lowerStrings(studios), limit)
if err != nil {
return nil, fmt.Errorf("store: curated candidates: %w", err)
}
return collectPayloads(rows)
}
func lowerStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
out = append(out, strings.ToLower(strings.TrimSpace(value)))
}
return out
}
func (s *Store) LibraryStats(ctx context.Context) (LibraryStats, error) {
stats := LibraryStats{ByType: map[string]int64{}}
+16
View File
@@ -10,12 +10,28 @@ CREATE TABLE IF NOT EXISTS sessions (
username TEXT NOT NULL,
server_id TEXT NOT NULL DEFAULT '',
device_id TEXT NOT NULL DEFAULT '',
device_name TEXT NOT NULL DEFAULT 'Memby TV',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS device_name TEXT NOT NULL DEFAULT 'Memby TV';
-- Older builds could create more than one token for the same physical TV. Keep the most
-- recently used row before adding the identity constraint.
DELETE FROM sessions older
USING sessions newer
WHERE older.emby_user_id = newer.emby_user_id
AND older.device_id = newer.device_id
AND (
older.last_seen_at < newer.last_seen_at
OR (older.last_seen_at = newer.last_seen_at AND older.token_hash < newer.token_hash)
);
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);
CREATE UNIQUE INDEX IF NOT EXISTS sessions_user_device_idx
ON sessions (emby_user_id, device_id);
-- The imported library.
--
+104 -11
View File
@@ -17,6 +17,7 @@ var schema string
// ErrNotFound is returned when a token does not match a live session.
var ErrNotFound = errors.New("store: session not found")
var ErrDeviceLimit = errors.New("store: device limit reached")
type Session struct {
TokenHash []byte
@@ -25,6 +26,7 @@ type Session struct {
Username string
ServerID string
DeviceID string
DeviceName string
LastSeenAt time.Time
}
@@ -56,30 +58,73 @@ func (s *Store) Migrate(ctx context.Context) error {
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
// CreateSession enforces a user's device allowance under a per-user transaction lock.
// Re-authenticating the same stable device replaces its token and never consumes a slot.
// The replaced hash is returned so its Redis entry can be invalidated immediately.
func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int) ([]byte, int, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, 0, fmt.Errorf("store: begin session: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, sess.EmbyUserID); err != nil {
return nil, 0, fmt.Errorf("store: lock user sessions: %w", err)
}
var previousHash []byte
err = tx.QueryRow(ctx, `
SELECT token_hash FROM sessions
WHERE emby_user_id = $1 AND device_id = $2`,
sess.EmbyUserID, sess.DeviceID).Scan(&previousHash)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, 0, fmt.Errorf("store: find device session: %w", err)
}
existingDevice := err == nil
var activeClients int
if err := tx.QueryRow(ctx,
`SELECT count(*) FROM sessions WHERE emby_user_id = $1`,
sess.EmbyUserID).Scan(&activeClients); err != nil {
return nil, 0, fmt.Errorf("store: count user sessions: %w", err)
}
if !existingDevice && activeClients >= maxClients {
return nil, activeClients, ErrDeviceLimit
}
_, err = tx.Exec(ctx, `
INSERT INTO sessions (
token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (emby_user_id, device_id) DO UPDATE SET
token_hash = EXCLUDED.token_hash,
emby_token = EXCLUDED.emby_token,
username = EXCLUDED.username,
server_id = EXCLUDED.server_id,
device_id = EXCLUDED.device_id,
device_name = EXCLUDED.device_name,
last_seen_at = now()`,
sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username, sess.ServerID, sess.DeviceID)
sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username,
sess.ServerID, sess.DeviceID, sess.DeviceName)
if err != nil {
return fmt.Errorf("store: create session: %w", err)
return nil, 0, fmt.Errorf("store: create session: %w", err)
}
return nil
if !existingDevice {
activeClients++
}
if err := tx.Commit(ctx); err != nil {
return nil, 0, fmt.Errorf("store: commit session: %w", err)
}
return previousHash, activeClients, 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
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name, 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)
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.LastSeenAt)
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrNotFound
}
@@ -111,3 +156,51 @@ func (s *Store) DeleteIdleSessions(ctx context.Context, idle time.Duration) (int
}
return tag.RowsAffected(), nil
}
// TrimSessionsToLimit brings data created under an older, more generous policy back
// within the current allowance. The most recently active devices survive.
func (s *Store) TrimSessionsToLimit(ctx context.Context, maxClients int) ([]Session, error) {
rows, err := s.pool.Query(ctx, `
WITH ranked AS (
SELECT token_hash,
row_number() OVER (
PARTITION BY emby_user_id
ORDER BY last_seen_at DESC, created_at DESC, token_hash DESC
) AS device_rank
FROM sessions
),
retired AS (
DELETE FROM sessions current
USING ranked
WHERE current.token_hash = ranked.token_hash
AND ranked.device_rank > $1
RETURNING current.token_hash, current.emby_user_id, current.emby_token,
current.username, current.server_id, current.device_id,
current.device_name, current.last_seen_at
)
SELECT token_hash, emby_user_id, emby_token, username, server_id,
device_id, device_name, last_seen_at
FROM retired`,
maxClients,
)
if err != nil {
return nil, fmt.Errorf("store: trim sessions: %w", err)
}
defer rows.Close()
var retired []Session
for rows.Next() {
var sess Session
if err := rows.Scan(
&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.LastSeenAt,
); err != nil {
return nil, fmt.Errorf("store: scan trimmed session: %w", err)
}
retired = append(retired, sess)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: trim sessions rows: %w", err)
}
return retired, nil
}