Memby v0.1.53: Android TV client plus gateway
Android TV client for Emby (Kotlin, Compose for TV) and the Memby gateway (Go, Postgres, Redis) that fronts it. Client: - Setup, profiles, home rows, Media3 playback, system screensaver (Dream) - Backend chosen at build time: gateway when memby.gatewayUrl is set, otherwise direct to Emby. Both paths stay working. - Server-composed home rows, rendered verbatim so new row types ship without an app release - Full-screen animated maintenance state, row engagement telemetry Gateway: - One request per TV screen; auth, caching, search and row shaping - Library import from Emby into Postgres (manual, then hourly incremental) - Recommendations from viewing history (recency-weighted genre affinity) - Admin page for imports, an offline switch, and per-row analytics - Video always direct-plays from Emby; only metadata passes through Identity is com.ponzischeme89.memby throughout, replacing com.mattcohen.embyclientsname. A changed applicationId installs as a new app: TVs need a fresh sign-in and the old package uninstalled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user