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
|
||||
}
|
||||
Vendored
+97
@@ -0,0 +1,97 @@
|
||||
// Package cache wraps Redis with the small surface the gateway needs.
|
||||
//
|
||||
// Every cached value is scoped to an Emby user id, because "what's on the home screen"
|
||||
// is per-user. Mutations (favourite, watched, playback stopped) drop that user's keys
|
||||
// so the next request re-reads Emby rather than serving a stale row.
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// ErrMiss means the key was absent — an ordinary outcome, not a failure.
|
||||
var ErrMiss = errors.New("cache: miss")
|
||||
|
||||
type Cache struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func Open(redisURL string) (*Cache, error) {
|
||||
opts, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache: parse url: %w", err)
|
||||
}
|
||||
return &Cache{rdb: redis.NewClient(opts)}, nil
|
||||
}
|
||||
|
||||
func (c *Cache) Close() error { return c.rdb.Close() }
|
||||
|
||||
func (c *Cache) Ping(ctx context.Context) error { return c.rdb.Ping(ctx).Err() }
|
||||
|
||||
func (c *Cache) Get(ctx context.Context, key string) ([]byte, error) {
|
||||
b, err := c.rdb.Get(ctx, key).Bytes()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, ErrMiss
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (c *Cache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
|
||||
return c.rdb.Set(ctx, key, value, ttl).Err()
|
||||
}
|
||||
|
||||
func (c *Cache) Delete(ctx context.Context, keys ...string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
return c.rdb.Del(ctx, keys...).Err()
|
||||
}
|
||||
|
||||
// InvalidateUser drops every cached view belonging to one Emby user.
|
||||
//
|
||||
// SCAN rather than KEYS so a large keyspace never blocks Redis; the key count here is
|
||||
// small, but the habit costs nothing.
|
||||
func (c *Cache) InvalidateUser(ctx context.Context, userID string) error {
|
||||
pattern := fmt.Sprintf("u:%s:*", userID)
|
||||
var cursor uint64
|
||||
for {
|
||||
keys, next, err := c.rdb.Scan(ctx, cursor, pattern, 200).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
if err := c.rdb.Del(ctx, keys...).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if next == 0 {
|
||||
return nil
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
}
|
||||
|
||||
// UserKey builds the namespaced key used by everything user-scoped.
|
||||
func UserKey(userID, view string) string { return fmt.Sprintf("u:%s:%s", userID, view) }
|
||||
|
||||
// RecommendationsKey sits in its own `r:` namespace on purpose.
|
||||
//
|
||||
// Recommendations cost several Emby queries to build, so they must survive the cache
|
||||
// wipe that every favourite toggle triggers. Only a genuine change in viewing history
|
||||
// — a finished playback — retires them, via [Cache.InvalidateRecommendations].
|
||||
func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows", userID) }
|
||||
|
||||
func (c *Cache) InvalidateRecommendations(ctx context.Context, userID string) error {
|
||||
return c.Delete(ctx, RecommendationsKey(userID))
|
||||
}
|
||||
|
||||
// SessionKey caches a token→session lookup, keyed by token hash (never the token).
|
||||
func SessionKey(tokenHashHex string) string { return "sess:" + tokenHashHex }
|
||||
@@ -0,0 +1,136 @@
|
||||
// Package config loads the gateway's settings from the environment.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
|
||||
// EmbyURL is how the gateway itself reaches Emby (may be a private/docker address).
|
||||
EmbyURL string
|
||||
// EmbyPublicURL is the address handed to TV clients for direct video playback.
|
||||
// Defaults to EmbyURL; set it when the gateway talks to Emby over a network the
|
||||
// TVs cannot reach.
|
||||
EmbyPublicURL string
|
||||
|
||||
DatabaseURL string
|
||||
RedisURL string
|
||||
|
||||
// ClientName is reported to Emby in the X-Emby-Authorization header, so sessions
|
||||
// show up as this in Emby's dashboard.
|
||||
ClientName string
|
||||
|
||||
HomeTTL time.Duration
|
||||
ItemTTL time.Duration
|
||||
SearchTTL time.Duration
|
||||
ScreensaverTTL time.Duration
|
||||
SessionTTL time.Duration
|
||||
// SessionIdleExpiry retires gateway tokens that go unused for this long.
|
||||
SessionIdleExpiry time.Duration
|
||||
|
||||
// RecommendTTL is how long computed recommendation rows stay warm. Long, because
|
||||
// taste moves slowly and each rebuild costs several Emby queries.
|
||||
RecommendTTL time.Duration
|
||||
// RecommendTimeout bounds a background rebuild, which fans out further than a
|
||||
// normal request and so needs more headroom than UpstreamTimeout.
|
||||
RecommendTimeout time.Duration
|
||||
|
||||
UpstreamTimeout time.Duration
|
||||
|
||||
// AdminToken guards the operator interface. Empty disables /admin entirely, so an
|
||||
// unconfigured deployment cannot leave it exposed.
|
||||
AdminToken string
|
||||
|
||||
// SyncInterval is how often the library import runs. Zero disables the schedule.
|
||||
SyncInterval time.Duration
|
||||
// SyncTimeout bounds one import; a full pass over a large library is slow.
|
||||
SyncTimeout time.Duration
|
||||
// SyncOnStart triggers an incremental import at boot.
|
||||
SyncOnStart bool
|
||||
// SyncUserID / SyncAPIKey are an optional Emby service account for imports. Without
|
||||
// them the newest TV session is borrowed instead.
|
||||
SyncUserID string
|
||||
SyncAPIKey string
|
||||
|
||||
// AnalyticsRetention is how long raw row events are kept before being pruned.
|
||||
AnalyticsRetention time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
c := Config{
|
||||
ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"),
|
||||
EmbyURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_URL"), "/"),
|
||||
EmbyPublicURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_PUBLIC_URL"), "/"),
|
||||
DatabaseURL: os.Getenv("MEMBY_DATABASE_URL"),
|
||||
RedisURL: env("MEMBY_REDIS_URL", "redis://localhost:6379/0"),
|
||||
ClientName: env("MEMBY_CLIENT_NAME", "Memby"),
|
||||
HomeTTL: duration("MEMBY_HOME_TTL", 60*time.Second),
|
||||
ItemTTL: duration("MEMBY_ITEM_TTL", 10*time.Minute),
|
||||
SearchTTL: duration("MEMBY_SEARCH_TTL", 5*time.Minute),
|
||||
ScreensaverTTL: duration("MEMBY_SCREENSAVER_TTL", 10*time.Minute),
|
||||
SessionTTL: duration("MEMBY_SESSION_CACHE_TTL", 5*time.Minute),
|
||||
SessionIdleExpiry: duration("MEMBY_SESSION_IDLE_EXPIRY", 90*24*time.Hour),
|
||||
RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 2*time.Hour),
|
||||
RecommendTimeout: duration("MEMBY_RECOMMEND_TIMEOUT", 60*time.Second),
|
||||
|
||||
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
|
||||
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
|
||||
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
|
||||
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
|
||||
SyncUserID: strings.TrimSpace(os.Getenv("MEMBY_SYNC_USER_ID")),
|
||||
SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")),
|
||||
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
|
||||
UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second),
|
||||
}
|
||||
|
||||
if c.EmbyURL == "" {
|
||||
return c, fmt.Errorf("MEMBY_EMBY_URL is required")
|
||||
}
|
||||
if c.DatabaseURL == "" {
|
||||
return c, fmt.Errorf("MEMBY_DATABASE_URL is required")
|
||||
}
|
||||
if c.EmbyPublicURL == "" {
|
||||
c.EmbyPublicURL = c.EmbyURL
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boolean(key string, fallback bool) bool {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
value, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func duration(key string, fallback time.Duration) time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
if d, err := time.ParseDuration(raw); err == nil {
|
||||
return d
|
||||
}
|
||||
// Bare numbers are read as seconds, which is friendlier in a compose file.
|
||||
if secs, err := strconv.Atoi(raw); err == nil {
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// Package emby is a small client for the Emby REST API.
|
||||
//
|
||||
// Item payloads are deliberately carried as json.RawMessage and forwarded to the TV
|
||||
// untouched: the Android client already models Emby's item shape, so passing it through
|
||||
// verbatim means there is no second schema to keep in sync. Only the handful of fields
|
||||
// the gateway itself reasons about (id, type, resume position) are ever unmarshalled.
|
||||
package emby
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
publicURL string
|
||||
clientName string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// Credentials identify one signed-in Emby user.
|
||||
type Credentials struct {
|
||||
UserID string
|
||||
Token string
|
||||
DeviceID string
|
||||
}
|
||||
|
||||
type ItemsResult struct {
|
||||
Items []json.RawMessage `json:"Items"`
|
||||
TotalRecordCount int `json:"TotalRecordCount"`
|
||||
}
|
||||
|
||||
type AuthResult struct {
|
||||
User struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
} `json:"User"`
|
||||
AccessToken string `json:"AccessToken"`
|
||||
ServerID string `json:"ServerId"`
|
||||
}
|
||||
|
||||
// Summary is the minimal view of an item the gateway needs for its own logic.
|
||||
type Summary struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
UserData struct {
|
||||
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
|
||||
} `json:"UserData"`
|
||||
}
|
||||
|
||||
// APIError carries an upstream Emby status code so handlers can mirror it.
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("emby: status %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
func New(baseURL, publicURL, clientName string, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
publicURL: strings.TrimRight(publicURL, "/"),
|
||||
clientName: clientName,
|
||||
http: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 20,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Authenticate(ctx context.Context, username, password, deviceID string) (*AuthResult, error) {
|
||||
body, err := json.Marshal(map[string]string{"Username": username, "Pw": password})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := c.newRequest(ctx, http.MethodPost, "/Users/AuthenticateByName", nil,
|
||||
Credentials{DeviceID: deviceID}, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var out AuthResult
|
||||
if err := c.do(req, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.AccessToken == "" || out.User.ID == "" {
|
||||
return nil, fmt.Errorf("emby: authentication returned no access token")
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) Items(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
|
||||
return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items", params)
|
||||
}
|
||||
|
||||
func (c *Client) NextUp(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
|
||||
params.Set("UserId", cred.UserID)
|
||||
return c.items(ctx, cred, "/Shows/NextUp", params)
|
||||
}
|
||||
|
||||
// Similar asks Emby which items resemble one the user already watched.
|
||||
func (c *Client) Similar(ctx context.Context, cred Credentials, itemID string, params url.Values) (*ItemsResult, error) {
|
||||
params.Set("UserId", cred.UserID)
|
||||
return c.items(ctx, cred, "/Items/"+url.PathEscape(itemID)+"/Similar", params)
|
||||
}
|
||||
|
||||
func (c *Client) Episodes(ctx context.Context, cred Credentials, seriesID string, params url.Values) (*ItemsResult, error) {
|
||||
params.Set("UserId", cred.UserID)
|
||||
return c.items(ctx, cred, "/Shows/"+url.PathEscape(seriesID)+"/Episodes", params)
|
||||
}
|
||||
|
||||
// LocalTrailers returns the trailers Emby holds locally for an item.
|
||||
func (c *Client) LocalTrailers(ctx context.Context, cred Credentials, itemID string) (*ItemsResult, error) {
|
||||
path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID) + "/LocalTrailers"
|
||||
req, err := c.newRequest(ctx, http.MethodGet, path, nil, cred, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// This endpoint answers with a bare array rather than an Items envelope.
|
||||
var items []json.RawMessage
|
||||
if err := c.do(req, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ItemsResult{Items: items, TotalRecordCount: len(items)}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Item(ctx context.Context, cred Credentials, itemID, fields string) (json.RawMessage, error) {
|
||||
params := url.Values{}
|
||||
if fields != "" {
|
||||
params.Set("Fields", fields)
|
||||
}
|
||||
path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID)
|
||||
req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw json.RawMessage
|
||||
if err := c.do(req, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// SetFavorite/SetPlayed return Emby's resulting UserData verbatim.
|
||||
func (c *Client) SetFavorite(ctx context.Context, cred Credentials, itemID string, favorite bool) (json.RawMessage, error) {
|
||||
method := http.MethodDelete
|
||||
if favorite {
|
||||
method = http.MethodPost
|
||||
}
|
||||
path := "/Users/" + url.PathEscape(cred.UserID) + "/FavoriteItems/" + url.PathEscape(itemID)
|
||||
return c.userDataCall(ctx, method, path, cred)
|
||||
}
|
||||
|
||||
func (c *Client) SetPlayed(ctx context.Context, cred Credentials, itemID string, played bool) (json.RawMessage, error) {
|
||||
method := http.MethodDelete
|
||||
if played {
|
||||
method = http.MethodPost
|
||||
}
|
||||
path := "/Users/" + url.PathEscape(cred.UserID) + "/PlayedItems/" + url.PathEscape(itemID)
|
||||
return c.userDataCall(ctx, method, path, cred)
|
||||
}
|
||||
|
||||
// ReportPlayback forwards a progress report. phase is "started", "progress" or "stopped".
|
||||
func (c *Client) ReportPlayback(ctx context.Context, cred Credentials, phase string, itemID string, positionTicks int64, isPaused bool) error {
|
||||
var path string
|
||||
switch phase {
|
||||
case "started":
|
||||
path = "/Sessions/Playing"
|
||||
case "progress":
|
||||
path = "/Sessions/Playing/Progress"
|
||||
case "stopped":
|
||||
path = "/Sessions/Playing/Stopped"
|
||||
default:
|
||||
return fmt.Errorf("emby: unknown playback phase %q", phase)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"ItemId": itemID,
|
||||
"PositionTicks": positionTicks,
|
||||
"IsPaused": isPaused,
|
||||
"IsMuted": false,
|
||||
"CanSeek": true,
|
||||
"PlayMethod": "DirectPlay",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := c.newRequest(ctx, http.MethodPost, path, nil, cred, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return c.do(req, nil)
|
||||
}
|
||||
|
||||
// ImageResponse streams an image straight from Emby so the caller can copy it to the TV.
|
||||
// The caller owns closing the body.
|
||||
func (c *Client) ImageResponse(ctx context.Context, cred Credentials, itemID, imageType string, params url.Values) (*http.Response, error) {
|
||||
path := "/Items/" + url.PathEscape(itemID) + "/Images/" + url.PathEscape(imageType)
|
||||
req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
resp.Body.Close()
|
||||
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// StreamURL is the direct-play URL handed to the TV. It points at the *public* Emby
|
||||
// address: video never flows through the gateway, only metadata does.
|
||||
func (c *Client) StreamURL(cred Credentials, itemID string) string {
|
||||
params := url.Values{}
|
||||
params.Set("static", "true")
|
||||
params.Set("api_key", cred.Token)
|
||||
params.Set("DeviceId", cred.DeviceID)
|
||||
return fmt.Sprintf("%s/Videos/%s/stream?%s", c.publicURL, url.PathEscape(itemID), params.Encode())
|
||||
}
|
||||
|
||||
// Ping checks that Emby is reachable, for readiness probes.
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil, Credentials{}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do(req, nil)
|
||||
}
|
||||
|
||||
func (c *Client) items(ctx context.Context, cred Credentials, path string, params url.Values) (*ItemsResult, error) {
|
||||
req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out ItemsResult
|
||||
if err := c.do(req, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) userDataCall(ctx context.Context, method, path string, cred Credentials) (json.RawMessage, error) {
|
||||
req, err := c.newRequest(ctx, method, path, nil, cred, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw json.RawMessage
|
||||
if err := c.do(req, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func (c *Client) newRequest(ctx context.Context, method, path string, params url.Values, cred Credentials, body io.Reader) (*http.Request, error) {
|
||||
full := c.baseURL + path
|
||||
if len(params) > 0 {
|
||||
full += "?" + params.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, full, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deviceID := cred.DeviceID
|
||||
if deviceID == "" {
|
||||
deviceID = "memby-gateway"
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Emby-Authorization", fmt.Sprintf(
|
||||
`MediaBrowser Client="%s", Device="Memby Gateway", DeviceId="%s", Version="1.0"`,
|
||||
c.clientName, deviceID,
|
||||
))
|
||||
if cred.Token != "" {
|
||||
req.Header.Set("X-Emby-Token", cred.Token)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// do executes a request and decodes into out (which may be nil to discard the body).
|
||||
func (c *Client) do(req *http.Request, out any) error {
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
// Emby error bodies can echo request details; cap what we keep and never log it
|
||||
// alongside a token.
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return &APIError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
if out == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// Summarise unmarshals the fields the gateway reasons about from a raw item.
|
||||
func Summarise(raw json.RawMessage) (Summary, error) {
|
||||
var s Summary
|
||||
err := json.Unmarshal(raw, &s)
|
||||
return s, err
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// Package library imports Emby's catalogue into Postgres so the gateway can answer from
|
||||
// its own copy instead of asking Emby on every request.
|
||||
//
|
||||
// Two shapes of import:
|
||||
//
|
||||
// - **full** — page through everything, then delete whatever the pass did not touch.
|
||||
// Run once to seed, and again whenever the library has been reorganised.
|
||||
// - **incremental** — ask Emby only for items changed since the last successful run.
|
||||
// Cheap enough to run hourly, which is what new episodes need; films appearing
|
||||
// weekly are picked up by the same pass.
|
||||
//
|
||||
// Only shared metadata is imported (EnableUserData=false). Watched state, favourites and
|
||||
// resume positions are per-user and stay live.
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// pageSize balances round trips against Emby's response size. 500 items of metadata
|
||||
// is roughly a megabyte of JSON.
|
||||
pageSize = 500
|
||||
|
||||
// syncFields is everything the gateway serves or filters on. Images are requested as
|
||||
// tags only — the artwork itself is proxied on demand.
|
||||
syncFields = "Genres,Studios,Overview,Taglines,ProductionYear,CommunityRating,OfficialRating," +
|
||||
"RunTimeTicks,SeriesName,PrimaryImageAspectRatio,DateCreated,MediaStreams"
|
||||
|
||||
syncImageTypes = "Backdrop,Primary,Logo,Thumb"
|
||||
syncItemTypes = "Movie,Series,Episode"
|
||||
)
|
||||
|
||||
// ErrNoCredentials means nothing has ever signed in and no service account is set, so
|
||||
// there is no way to talk to Emby on the library's behalf.
|
||||
var ErrNoCredentials = errors.New("library: no emby credentials available for sync")
|
||||
|
||||
type Syncer struct {
|
||||
emby *emby.Client
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
|
||||
// serviceCred is the optional configured account. When empty, the newest TV session
|
||||
// is borrowed instead.
|
||||
serviceCred emby.Credentials
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
}
|
||||
|
||||
func NewSyncer(embyClient *emby.Client, st *store.Store, serviceCred emby.Credentials, log *slog.Logger) *Syncer {
|
||||
return &Syncer{emby: embyClient, store: st, serviceCred: serviceCred, log: log}
|
||||
}
|
||||
|
||||
// Result summarises one import.
|
||||
type Result struct {
|
||||
Kind string `json:"kind"`
|
||||
Seen int `json:"seen"`
|
||||
Upserted int `json:"upserted"`
|
||||
Removed int `json:"removed"`
|
||||
Duration time.Duration `json:"-"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
}
|
||||
|
||||
// Running reports whether an import is in flight, so the admin page can disable its
|
||||
// buttons and the scheduler can skip a tick.
|
||||
func (s *Syncer) Running() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.running
|
||||
}
|
||||
|
||||
// Sync runs an import. kind is "full" or "incremental"; an incremental run with no prior
|
||||
// successful sync silently upgrades itself to a full one, because there is no watermark
|
||||
// to work from.
|
||||
func (s *Syncer) Sync(ctx context.Context, kind, trigger string) (Result, error) {
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
return Result{}, errors.New("library: a sync is already running")
|
||||
}
|
||||
s.running = true
|
||||
s.mu.Unlock()
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
cred, err := s.credentials(ctx)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
var since *time.Time
|
||||
if kind == "incremental" {
|
||||
since, err = s.store.LastSuccessfulSyncAt(ctx)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if since == nil {
|
||||
s.log.Info("no previous sync; upgrading to a full import")
|
||||
kind = "full"
|
||||
}
|
||||
}
|
||||
|
||||
startedAt := time.Now().UTC()
|
||||
runID, err := s.store.StartSyncRun(ctx, kind, trigger)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
result, syncErr := s.run(ctx, cred, kind, since, startedAt)
|
||||
result.Kind = kind
|
||||
result.Duration = time.Since(startedAt)
|
||||
result.DurationMs = result.Duration.Milliseconds()
|
||||
|
||||
record := store.SyncRun{
|
||||
Status: store.SyncStatusSuccess,
|
||||
ItemsSeen: result.Seen,
|
||||
ItemsUpserted: result.Upserted,
|
||||
ItemsRemoved: result.Removed,
|
||||
}
|
||||
if syncErr != nil {
|
||||
record.Status = store.SyncStatusFailed
|
||||
record.Error = syncErr.Error()
|
||||
}
|
||||
// Always record the outcome, even when the caller's context died mid-import.
|
||||
if err := s.store.FinishSyncRun(context.WithoutCancel(ctx), runID, record); err != nil {
|
||||
s.log.Error("could not record sync run", "error", err)
|
||||
}
|
||||
|
||||
if syncErr != nil {
|
||||
return result, syncErr
|
||||
}
|
||||
s.log.Info("library sync finished",
|
||||
"kind", kind, "trigger", trigger, "seen", result.Seen,
|
||||
"upserted", result.Upserted, "removed", result.Removed, "ms", result.DurationMs)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Syncer) run(
|
||||
ctx context.Context,
|
||||
cred emby.Credentials,
|
||||
kind string,
|
||||
since *time.Time,
|
||||
syncedAt time.Time,
|
||||
) (Result, error) {
|
||||
var result Result
|
||||
|
||||
for startIndex := 0; ; startIndex += pageSize {
|
||||
params := url.Values{
|
||||
"IncludeItemTypes": {syncItemTypes},
|
||||
"Recursive": {"true"},
|
||||
"SortBy": {"DateCreated"},
|
||||
"SortOrder": {"Ascending"},
|
||||
"StartIndex": {strconv.Itoa(startIndex)},
|
||||
"Limit": {strconv.Itoa(pageSize)},
|
||||
"Fields": {syncFields},
|
||||
"ImageTypeLimit": {"1"},
|
||||
"EnableImages": {"true"},
|
||||
"EnableImageTypes": {syncImageTypes},
|
||||
"EnableTotalRecordCount": {"false"},
|
||||
// The imported copy is shared by every user, so it must not carry one
|
||||
// user's watched/favourite state.
|
||||
"EnableUserData": {"false"},
|
||||
}
|
||||
if since != nil {
|
||||
// Emby returns items created or edited after this instant. Overlap by a
|
||||
// minute so an item saved during the previous run is not missed.
|
||||
params.Set("MinDateLastSaved", since.Add(-time.Minute).UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
page, err := s.emby.Items(ctx, cred, params)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("library: fetch page at %d: %w", startIndex, err)
|
||||
}
|
||||
if len(page.Items) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
items := make([]store.LibraryItem, 0, len(page.Items))
|
||||
for _, raw := range page.Items {
|
||||
if item, ok := toLibraryItem(raw); ok {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
written, err := s.store.UpsertLibraryItems(ctx, items, syncedAt)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.Seen += len(page.Items)
|
||||
result.Upserted += int(written)
|
||||
|
||||
if len(page.Items) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Only a full pass has seen everything, so only a full pass may delete.
|
||||
if kind == "full" {
|
||||
removed, err := s.store.DeleteLibraryItemsBefore(ctx, syncedAt)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Removed = int(removed)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// credentials prefers the configured service account and otherwise borrows the most
|
||||
// recent TV session.
|
||||
func (s *Syncer) credentials(ctx context.Context) (emby.Credentials, error) {
|
||||
if s.serviceCred.Token != "" && s.serviceCred.UserID != "" {
|
||||
return s.serviceCred, nil
|
||||
}
|
||||
sess, err := s.store.NewestSession(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return emby.Credentials{}, ErrNoCredentials
|
||||
}
|
||||
return emby.Credentials{}, err
|
||||
}
|
||||
return emby.Credentials{
|
||||
UserID: sess.EmbyUserID,
|
||||
Token: sess.EmbyToken,
|
||||
DeviceID: "memby-gateway-sync",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Schedule runs an incremental import on an interval until ctx is cancelled.
|
||||
//
|
||||
// New episodes tend to land through the day and films weekly; an hourly incremental pass
|
||||
// covers both without ever asking Emby for the whole catalogue again.
|
||||
func (s *Syncer) Schedule(ctx context.Context, interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
s.log.Info("library auto-sync disabled")
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
s.log.Info("library auto-sync scheduled", "interval", interval.String())
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if s.Running() {
|
||||
s.log.Info("skipping scheduled sync; one is already running")
|
||||
continue
|
||||
}
|
||||
if _, err := s.Sync(ctx, "incremental", "schedule"); err != nil {
|
||||
if errors.Is(err, ErrNoCredentials) {
|
||||
// Nobody has signed in yet. Not worth an error-level log every hour.
|
||||
s.log.Info("skipping scheduled sync; no credentials yet")
|
||||
continue
|
||||
}
|
||||
s.log.Error("scheduled sync failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncItem mirrors the Emby fields promoted to columns.
|
||||
type syncItem struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
SeriesID string `json:"SeriesId"`
|
||||
SeriesName string `json:"SeriesName"`
|
||||
ProductionYear *int `json:"ProductionYear"`
|
||||
CommunityRating *float64 `json:"CommunityRating"`
|
||||
Genres []string `json:"Genres"`
|
||||
Studios []struct {
|
||||
Name string `json:"Name"`
|
||||
} `json:"Studios"`
|
||||
DateCreated *time.Time `json:"DateCreated"`
|
||||
}
|
||||
|
||||
// toLibraryItem flattens the columns Postgres filters on while keeping the payload whole.
|
||||
func toLibraryItem(raw json.RawMessage) (store.LibraryItem, bool) {
|
||||
var parsed syncItem
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil || parsed.ID == "" {
|
||||
return store.LibraryItem{}, false
|
||||
}
|
||||
|
||||
studios := make([]string, 0, len(parsed.Studios))
|
||||
for _, studio := range parsed.Studios {
|
||||
if name := strings.TrimSpace(studio.Name); name != "" {
|
||||
studios = append(studios, name)
|
||||
}
|
||||
}
|
||||
genres := make([]string, 0, len(parsed.Genres))
|
||||
for _, genre := range parsed.Genres {
|
||||
if g := strings.TrimSpace(genre); g != "" {
|
||||
genres = append(genres, g)
|
||||
}
|
||||
}
|
||||
|
||||
return store.LibraryItem{
|
||||
ID: parsed.ID,
|
||||
Type: parsed.Type,
|
||||
Name: parsed.Name,
|
||||
SeriesID: parsed.SeriesID,
|
||||
SeriesName: parsed.SeriesName,
|
||||
ProductionYear: parsed.ProductionYear,
|
||||
CommunityRating: parsed.CommunityRating,
|
||||
Genres: genres,
|
||||
Studios: studios,
|
||||
DateCreated: parsed.DateCreated,
|
||||
SearchText: searchText(parsed),
|
||||
Payload: raw,
|
||||
}, true
|
||||
}
|
||||
|
||||
// searchText is what full-text search matches against. Series name is included so
|
||||
// searching a show finds its episodes.
|
||||
func searchText(parsed syncItem) string {
|
||||
parts := []string{parsed.Name}
|
||||
if parsed.SeriesName != "" && !strings.EqualFold(parsed.SeriesName, parsed.Name) {
|
||||
parts = append(parts, parsed.SeriesName)
|
||||
}
|
||||
if parsed.ProductionYear != nil {
|
||||
parts = append(parts, strconv.Itoa(*parsed.ProductionYear))
|
||||
}
|
||||
parts = append(parts, parsed.Genres...)
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToLibraryItemFlattensColumnsAndKeepsPayload(t *testing.T) {
|
||||
raw := json.RawMessage(`{
|
||||
"Id":"42","Name":"Arrival","Type":"Movie","ProductionYear":2016,
|
||||
"CommunityRating":7.9,"Genres":["Science Fiction"," Drama "],
|
||||
"Studios":[{"Name":"Paramount"},{"Name":" "}],
|
||||
"DateCreated":"2024-03-01T10:00:00Z",
|
||||
"ImageTags":{"Primary":"abc"}
|
||||
}`)
|
||||
|
||||
item, ok := toLibraryItem(raw)
|
||||
if !ok {
|
||||
t.Fatal("expected the item to parse")
|
||||
}
|
||||
if item.ID != "42" || item.Name != "Arrival" || item.Type != "Movie" {
|
||||
t.Fatalf("unexpected columns: %+v", item)
|
||||
}
|
||||
if *item.ProductionYear != 2016 || *item.CommunityRating != 7.9 {
|
||||
t.Fatalf("unexpected numbers: %+v", item)
|
||||
}
|
||||
// Whitespace-only studio names are dropped, real ones trimmed.
|
||||
if len(item.Studios) != 1 || item.Studios[0] != "Paramount" {
|
||||
t.Fatalf("unexpected studios: %v", item.Studios)
|
||||
}
|
||||
if len(item.Genres) != 2 || item.Genres[1] != "Drama" {
|
||||
t.Fatalf("genres should be trimmed: %v", item.Genres)
|
||||
}
|
||||
if item.DateCreated == nil || item.DateCreated.Year() != 2024 {
|
||||
t.Fatalf("unexpected date: %v", item.DateCreated)
|
||||
}
|
||||
// The payload must survive byte-identical: it is what the TV receives, and it holds
|
||||
// fields (image tags, overview) that no column models.
|
||||
if string(item.Payload) != string(raw) {
|
||||
t.Fatal("payload was altered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToLibraryItemRejectsUnusableRows(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"malformed": `{"Id":`,
|
||||
"no id": `{"Name":"Nameless"}`,
|
||||
} {
|
||||
if _, ok := toLibraryItem(json.RawMessage(raw)); ok {
|
||||
t.Fatalf("%s should have been rejected", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTextIncludesSeriesNameSoEpisodesAreFindable(t *testing.T) {
|
||||
year := 2022
|
||||
text := searchText(syncItem{
|
||||
Name: "Good News About Hell",
|
||||
Type: "Episode",
|
||||
SeriesName: "Severance",
|
||||
ProductionYear: &year,
|
||||
Genres: []string{"Drama", "Thriller"},
|
||||
})
|
||||
|
||||
for _, want := range []string{"Good News About Hell", "Severance", "2022", "Drama"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("search text %q is missing %q", text, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTextDoesNotRepeatTheTitleForAMovie(t *testing.T) {
|
||||
text := searchText(syncItem{Name: "Dune", Type: "Movie", SeriesName: "Dune"})
|
||||
|
||||
if strings.Count(strings.ToLower(text), "dune") != 1 {
|
||||
t.Fatalf("title should appear once, got %q", text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
)
|
||||
|
||||
// Row is one horizontal strip on the TV home screen.
|
||||
type Row struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
Items []json.RawMessage `json:"items"`
|
||||
}
|
||||
|
||||
// Source is the slice of the Emby client this package needs, narrowed so tests can
|
||||
// supply a fake without a server.
|
||||
type Source interface {
|
||||
Items(ctx context.Context, cred emby.Credentials, params url.Values) (*emby.ItemsResult, error)
|
||||
Similar(ctx context.Context, cred emby.Credentials, itemID string, params url.Values) (*emby.ItemsResult, error)
|
||||
}
|
||||
|
||||
// LibrarySource is the imported catalogue. When present, the candidate pool comes from
|
||||
// Postgres instead of Emby, which takes the rebuild off Emby entirely.
|
||||
type LibrarySource interface {
|
||||
LibraryCandidates(ctx context.Context, genres []string, limit int) ([]json.RawMessage, error)
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
source Source
|
||||
log *slog.Logger
|
||||
|
||||
// Library is optional; nil (or an empty library) falls back to querying Emby.
|
||||
Library LibrarySource
|
||||
|
||||
// MinRowItems is the shortest row worth showing. A two-item "Recommended" strip
|
||||
// looks broken next to full rows, so short rows are dropped entirely.
|
||||
MinRowItems int
|
||||
// MaxSimilarRows caps "Because you watched …" rows so the home screen stays a home
|
||||
// screen rather than a wall of near-duplicates.
|
||||
MaxSimilarRows int
|
||||
RowSize int
|
||||
}
|
||||
|
||||
func NewEngine(source Source, log *slog.Logger) *Engine {
|
||||
return &Engine{
|
||||
source: source,
|
||||
log: log,
|
||||
MinRowItems: 4,
|
||||
MaxSimilarRows: 2,
|
||||
RowSize: 20,
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
historyFields = "Genres,Studios,CommunityRating,SeriesName,ProductionYear,RunTimeTicks"
|
||||
candidateFields = "Genres,Studios,CommunityRating,ProductionYear,RunTimeTicks,PrimaryImageAspectRatio"
|
||||
rowImageTypes = "Backdrop,Primary,Logo"
|
||||
)
|
||||
|
||||
// BuildRows produces the recommendation rows for one user.
|
||||
//
|
||||
// Cost is a handful of Emby queries, which is why callers cache the result rather than
|
||||
// computing it on every home load.
|
||||
func (e *Engine) BuildRows(ctx context.Context, cred emby.Credentials) ([]Row, error) {
|
||||
history, favorites, err := e.gatherSignals(ctx, cred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profile := BuildProfile(history, favorites)
|
||||
if profile.IsEmpty() {
|
||||
// A brand-new user has nothing to recommend from. No rows is the honest answer.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rows := make([]Row, 0, e.MaxSimilarRows+1)
|
||||
for _, seed := range e.seedsFor(profile) {
|
||||
row, ok := e.similarRow(ctx, cred, profile, seed)
|
||||
if ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
|
||||
if row, ok := e.historyRow(ctx, cred, profile); ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// gatherSignals reads what the user has watched and favourited, in parallel.
|
||||
func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (history, favorites []Item, err error) {
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
firstErr error
|
||||
)
|
||||
|
||||
fetch := func(dest *[]Item, params url.Values) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result, fetchErr := e.source.Items(ctx, cred, params)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if fetchErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = fetchErr
|
||||
}
|
||||
return
|
||||
}
|
||||
*dest = Decode(result.Items)
|
||||
}()
|
||||
}
|
||||
|
||||
// In-progress titles are the strongest signal available, so they lead the history
|
||||
// list and pick up the heaviest recency weights.
|
||||
var resumable, played []Item
|
||||
fetch(&resumable, url.Values{
|
||||
"Filters": {"IsResumable"},
|
||||
"IncludeItemTypes": {"Movie,Episode"},
|
||||
"Recursive": {"true"},
|
||||
"SortBy": {"DatePlayed"},
|
||||
"SortOrder": {"Descending"},
|
||||
"Limit": {"20"},
|
||||
"Fields": {historyFields},
|
||||
"EnableUserData": {"true"},
|
||||
"EnableImages": {"false"},
|
||||
})
|
||||
fetch(&played, url.Values{
|
||||
"Filters": {"IsPlayed"},
|
||||
"IncludeItemTypes": {"Movie,Episode"},
|
||||
"Recursive": {"true"},
|
||||
"SortBy": {"DatePlayed"},
|
||||
"SortOrder": {"Descending"},
|
||||
"Limit": {"60"},
|
||||
"Fields": {historyFields},
|
||||
"EnableUserData": {"true"},
|
||||
"EnableImages": {"false"},
|
||||
})
|
||||
fetch(&favorites, url.Values{
|
||||
"Filters": {"IsFavorite"},
|
||||
"IncludeItemTypes": {"Movie,Series"},
|
||||
"Recursive": {"true"},
|
||||
"SortBy": {"SortName"},
|
||||
"Limit": {"40"},
|
||||
"Fields": {historyFields},
|
||||
"EnableUserData": {"true"},
|
||||
"EnableImages": {"false"},
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
if firstErr != nil {
|
||||
return nil, nil, firstErr
|
||||
}
|
||||
return append(resumable, played...), favorites, nil
|
||||
}
|
||||
|
||||
// libraryCandidates reads the pool from the imported library. Returns ok=false when
|
||||
// there is no library, it is empty, or it errors — every one of which means "ask Emby".
|
||||
func (e *Engine) libraryCandidates(ctx context.Context, genres []string) ([]Item, bool) {
|
||||
if e.Library == nil {
|
||||
return nil, false
|
||||
}
|
||||
raws, err := e.Library.LibraryCandidates(ctx, genres, e.RowSize*6)
|
||||
if err != nil {
|
||||
e.log.Warn("library candidates failed; falling back to emby", "error", err)
|
||||
return nil, false
|
||||
}
|
||||
if len(raws) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return Decode(raws), true
|
||||
}
|
||||
|
||||
func (e *Engine) seedsFor(profile Profile) []Seed {
|
||||
if len(profile.Seeds) <= e.MaxSimilarRows {
|
||||
return profile.Seeds
|
||||
}
|
||||
return profile.Seeds[:e.MaxSimilarRows]
|
||||
}
|
||||
|
||||
// similarRow asks Emby what resembles a title the user just watched. Emby's own
|
||||
// similarity scoring beats anything computed here, so this only filters out the seen.
|
||||
func (e *Engine) similarRow(ctx context.Context, cred emby.Credentials, profile Profile, seed Seed) (Row, bool) {
|
||||
result, err := e.source.Similar(ctx, cred, seed.ID, url.Values{
|
||||
"UserId": {cred.UserID},
|
||||
"Limit": {strconv.Itoa(e.RowSize * 2)},
|
||||
"Fields": {candidateFields},
|
||||
"ImageTypeLimit": {"1"},
|
||||
"EnableImageTypes": {rowImageTypes},
|
||||
"EnableUserData": {"true"},
|
||||
})
|
||||
if err != nil {
|
||||
// One dead row should never sink the home screen.
|
||||
e.log.Warn("similar lookup failed", "seed", seed.ID, "error", err)
|
||||
return Row{}, false
|
||||
}
|
||||
|
||||
items := FilterUnseen(profile, Decode(result.Items), e.RowSize)
|
||||
if len(items) < e.MinRowItems {
|
||||
return Row{}, false
|
||||
}
|
||||
return Row{
|
||||
ID: "similar:" + seed.ID,
|
||||
Title: "Because you watched " + seed.Name,
|
||||
Kind: "similar",
|
||||
Items: Raws(items),
|
||||
}, true
|
||||
}
|
||||
|
||||
// historyRow is the genre-affinity row: unwatched titles from the genres the user has
|
||||
// been spending time in, ranked by how closely they match the whole profile.
|
||||
func (e *Engine) historyRow(ctx context.Context, cred emby.Credentials, profile Profile) (Row, bool) {
|
||||
genres := profile.TopGenres(3)
|
||||
if len(genres) == 0 {
|
||||
return Row{}, false
|
||||
}
|
||||
|
||||
if candidates, ok := e.libraryCandidates(ctx, genres); ok {
|
||||
items := Rank(profile, candidates, e.RowSize)
|
||||
if len(items) < e.MinRowItems {
|
||||
return Row{}, false
|
||||
}
|
||||
return Row{
|
||||
ID: "recommended",
|
||||
Title: "Recommended from your watching history",
|
||||
Kind: "recommended",
|
||||
Items: Raws(items),
|
||||
}, true
|
||||
}
|
||||
|
||||
// Emby treats "|" as OR in a Genres filter, so one query covers every top genre.
|
||||
result, err := e.source.Items(ctx, cred, url.Values{
|
||||
"IncludeItemTypes": {"Movie,Series"},
|
||||
"Recursive": {"true"},
|
||||
"Filters": {"IsUnplayed"},
|
||||
"Genres": {strings.Join(genres, "|")},
|
||||
"SortBy": {"CommunityRating"},
|
||||
"SortOrder": {"Descending"},
|
||||
"Limit": {"120"},
|
||||
"Fields": {candidateFields},
|
||||
"ImageTypeLimit": {"1"},
|
||||
"EnableImages": {"true"},
|
||||
"EnableImageTypes": {rowImageTypes},
|
||||
"EnableUserData": {"true"},
|
||||
})
|
||||
if err != nil {
|
||||
e.log.Warn("recommendation candidates failed", "error", err)
|
||||
return Row{}, false
|
||||
}
|
||||
|
||||
items := Rank(profile, Decode(result.Items), e.RowSize)
|
||||
if len(items) < e.MinRowItems {
|
||||
return Row{}, false
|
||||
}
|
||||
return Row{
|
||||
ID: "recommended",
|
||||
Title: "Recommended from your watching history",
|
||||
Kind: "recommended",
|
||||
Items: Raws(items),
|
||||
}, true
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
)
|
||||
|
||||
// fakeSource records the queries the engine makes and replays canned answers.
|
||||
type fakeSource struct {
|
||||
mu sync.Mutex
|
||||
|
||||
itemsByFilter map[string][]json.RawMessage
|
||||
similar map[string][]json.RawMessage
|
||||
itemsErr error
|
||||
similarErr error
|
||||
|
||||
genreQueries []string
|
||||
similarSeeds []string
|
||||
}
|
||||
|
||||
func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Values) (*emby.ItemsResult, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.itemsErr != nil {
|
||||
return nil, f.itemsErr
|
||||
}
|
||||
if genres := params.Get("Genres"); genres != "" {
|
||||
f.genreQueries = append(f.genreQueries, genres)
|
||||
}
|
||||
key := params.Get("Filters")
|
||||
return &emby.ItemsResult{Items: f.itemsByFilter[key]}, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) Similar(_ context.Context, _ emby.Credentials, itemID string, _ url.Values) (*emby.ItemsResult, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.similarSeeds = append(f.similarSeeds, itemID)
|
||||
if f.similarErr != nil {
|
||||
return nil, f.similarErr
|
||||
}
|
||||
return &emby.ItemsResult{Items: f.similar[itemID]}, nil
|
||||
}
|
||||
|
||||
func raw(id, name, itemType string, genres ...string) json.RawMessage {
|
||||
quoted := make([]string, 0, len(genres))
|
||||
for _, g := range genres {
|
||||
quoted = append(quoted, `"`+g+`"`)
|
||||
}
|
||||
return json.RawMessage(`{"Id":"` + id + `","Name":"` + name + `","Type":"` + itemType +
|
||||
`","Genres":[` + strings.Join(quoted, ",") + `],"CommunityRating":7.5}`)
|
||||
}
|
||||
|
||||
func testEngine(source Source) *Engine {
|
||||
engine := NewEngine(source, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
engine.MinRowItems = 2
|
||||
return engine
|
||||
}
|
||||
|
||||
func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
itemsByFilter: map[string][]json.RawMessage{
|
||||
"IsResumable": {raw("ep1", "Good News", "Episode", "Drama")},
|
||||
"IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")},
|
||||
"IsFavorite": {raw("m2", "Arrival", "Movie", "Science Fiction")},
|
||||
"IsUnplayed": {
|
||||
raw("c1", "Blade Runner", "Movie", "Science Fiction"),
|
||||
raw("c2", "Solaris", "Movie", "Science Fiction"),
|
||||
raw("c3", "Barbie", "Movie", "Comedy"),
|
||||
},
|
||||
},
|
||||
similar: map[string][]json.RawMessage{
|
||||
"ep1": {raw("s1", "Devs", "Series", "Drama"), raw("s2", "Mr Robot", "Series", "Drama")},
|
||||
"m1": {raw("s3", "Foundation", "Series", "Science Fiction"), raw("s4", "Arrival II", "Movie", "Science Fiction")},
|
||||
},
|
||||
}
|
||||
|
||||
rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRows: %v", err)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("expected 2 similar rows + 1 history row, got %d: %+v", len(rows), rowTitles(rows))
|
||||
}
|
||||
|
||||
if rows[0].Kind != "similar" || !strings.HasPrefix(rows[0].Title, "Because you watched ") {
|
||||
t.Fatalf("unexpected first row: %+v", rows[0])
|
||||
}
|
||||
last := rows[len(rows)-1]
|
||||
if last.Kind != "recommended" || last.Title != "Recommended from your watching history" {
|
||||
t.Fatalf("unexpected history row: %+v", last)
|
||||
}
|
||||
if last.ID != "recommended" {
|
||||
t.Fatalf("history row id should be stable, got %q", last.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRowsQueriesTheProfilesTopGenres(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
itemsByFilter: map[string][]json.RawMessage{
|
||||
"IsPlayed": {
|
||||
raw("m1", "Dune", "Movie", "Science Fiction"),
|
||||
raw("m2", "Alien", "Movie", "Science Fiction", "Horror"),
|
||||
},
|
||||
"IsUnplayed": {raw("c1", "Solaris", "Movie", "Science Fiction")},
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"}); err != nil {
|
||||
t.Fatalf("BuildRows: %v", err)
|
||||
}
|
||||
|
||||
if len(source.genreQueries) != 1 {
|
||||
t.Fatalf("expected a single OR'd genre query, got %v", source.genreQueries)
|
||||
}
|
||||
// Emby reads "|" as OR, so one query covers every top genre.
|
||||
if !strings.HasPrefix(source.genreQueries[0], "Science Fiction") {
|
||||
t.Fatalf("heaviest genre should lead the query, got %q", source.genreQueries[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRowsExcludesAlreadyWatchedFromSimilarRow(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
itemsByFilter: map[string][]json.RawMessage{
|
||||
"IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")},
|
||||
},
|
||||
similar: map[string][]json.RawMessage{
|
||||
// Emby suggests something the user already finished; it must not appear.
|
||||
"m1": {raw("m1", "Dune", "Movie", "Science Fiction"), raw("s1", "Foundation", "Series", "Science Fiction")},
|
||||
},
|
||||
}
|
||||
engine := testEngine(source)
|
||||
engine.MinRowItems = 1
|
||||
|
||||
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRows: %v", err)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
for _, item := range row.Items {
|
||||
if strings.Contains(string(item), `"Id":"m1"`) {
|
||||
t.Fatalf("row %q contained an already-watched item", row.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRowsDropsRowsShorterThanTheMinimum(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
itemsByFilter: map[string][]json.RawMessage{
|
||||
"IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")},
|
||||
"IsUnplayed": {raw("c1", "Solaris", "Movie", "Science Fiction")},
|
||||
},
|
||||
similar: map[string][]json.RawMessage{
|
||||
"m1": {raw("s1", "Foundation", "Series", "Science Fiction")},
|
||||
},
|
||||
}
|
||||
engine := testEngine(source)
|
||||
engine.MinRowItems = 5
|
||||
|
||||
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRows: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("expected short rows to be dropped, got %v", rowTitles(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRowsReturnsNothingForAUserWithNoHistory(t *testing.T) {
|
||||
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
|
||||
|
||||
rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "new"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRows: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("a new user should get no rows, got %v", rowTitles(rows))
|
||||
}
|
||||
if len(source.similarSeeds) != 0 {
|
||||
t.Fatal("no seeds means no similarity lookups should be attempted")
|
||||
}
|
||||
}
|
||||
|
||||
// A failing similarity lookup is one dead row, not a dead home screen.
|
||||
func TestBuildRowsSurvivesASimilarLookupFailure(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
itemsByFilter: map[string][]json.RawMessage{
|
||||
"IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")},
|
||||
"IsUnplayed": {
|
||||
raw("c1", "Solaris", "Movie", "Science Fiction"),
|
||||
raw("c2", "Blade Runner", "Movie", "Science Fiction"),
|
||||
},
|
||||
},
|
||||
similarErr: errors.New("emby is unwell"),
|
||||
}
|
||||
|
||||
rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRows should not fail: %v", err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Kind != "recommended" {
|
||||
t.Fatalf("expected the history row to survive, got %v", rowTitles(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRowsFailsWhenHistoryCannotBeRead(t *testing.T) {
|
||||
source := &fakeSource{itemsErr: errors.New("emby down")}
|
||||
|
||||
if _, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"}); err == nil {
|
||||
t.Fatal("expected an error when the history queries fail")
|
||||
}
|
||||
}
|
||||
|
||||
func rowTitles(rows []Row) []string {
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, row.Title)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Package recommend turns a user's Emby watch history into home-screen rows.
|
||||
//
|
||||
// The scoring here is deliberately simple and explainable — genre and studio affinity
|
||||
// weighted by recency, penalised for what the user has already seen. It runs against one
|
||||
// household's library, where a heavier model would have neither the data to learn from
|
||||
// nor a way to show its work when a row looks wrong.
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// recencyDecay is applied per position down the history list. At 0.94, the 12th item
|
||||
// carries about half the weight of the most recent one, so tastes can shift without the
|
||||
// rows lagging weeks behind.
|
||||
const recencyDecay = 0.94
|
||||
|
||||
// favoriteWeight is what an explicit favourite contributes. Deliberately below a fresh
|
||||
// play: favouriting is a durable signal, but what someone watched last night is a better
|
||||
// predictor of what they want tonight.
|
||||
const favoriteWeight = 0.6
|
||||
|
||||
// Item is the slice of an Emby item this package reasons about. The raw payload rides
|
||||
// along so rows can be emitted without re-fetching or re-encoding.
|
||||
type Item struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
SeriesID string `json:"SeriesId"`
|
||||
SeriesName string `json:"SeriesName"`
|
||||
Genres []string `json:"Genres"`
|
||||
CommunityRating float64 `json:"CommunityRating"`
|
||||
Studios []struct {
|
||||
Name string `json:"Name"`
|
||||
} `json:"Studios"`
|
||||
UserData struct {
|
||||
Played bool `json:"Played"`
|
||||
PlayCount int `json:"PlayCount"`
|
||||
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
|
||||
IsFavorite bool `json:"IsFavorite"`
|
||||
} `json:"UserData"`
|
||||
|
||||
Raw json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
// Seed is a title recent enough to anchor a "Because you watched …" row.
|
||||
type Seed struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
// Profile is what the engine learned about one user.
|
||||
type Profile struct {
|
||||
GenreWeights map[string]float64
|
||||
StudioWeights map[string]float64
|
||||
// Seen holds item ids *and* series ids already watched or in progress, so a
|
||||
// recommendation never suggests something the user is already partway through.
|
||||
Seen map[string]bool
|
||||
Seeds []Seed
|
||||
}
|
||||
|
||||
func (p Profile) IsEmpty() bool { return len(p.GenreWeights) == 0 && len(p.Seeds) == 0 }
|
||||
|
||||
// Decode parses raw Emby items, keeping the original payload attached.
|
||||
func Decode(raws []json.RawMessage) []Item {
|
||||
items := make([]Item, 0, len(raws))
|
||||
for _, raw := range raws {
|
||||
var item Item
|
||||
if err := json.Unmarshal(raw, &item); err != nil || item.ID == "" {
|
||||
continue
|
||||
}
|
||||
item.Raw = raw
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// BuildProfile weights history by recency and folds in favourites.
|
||||
//
|
||||
// history must be ordered most-recent-first; favourites are unordered and all carry the
|
||||
// same weight.
|
||||
func BuildProfile(history, favorites []Item) Profile {
|
||||
profile := Profile{
|
||||
GenreWeights: map[string]float64{},
|
||||
StudioWeights: map[string]float64{},
|
||||
Seen: map[string]bool{},
|
||||
}
|
||||
|
||||
seedSeen := map[string]bool{}
|
||||
for i, item := range history {
|
||||
weight := math.Pow(recencyDecay, float64(i))
|
||||
profile.absorb(item, weight)
|
||||
|
||||
// An episode seeds its series, not itself: "Because you watched Severance"
|
||||
// reads better than "Because you watched Good News".
|
||||
seedID, seedName := item.ID, item.Name
|
||||
if item.SeriesID != "" {
|
||||
seedID, seedName = item.SeriesID, item.SeriesName
|
||||
}
|
||||
if seedID != "" && seedName != "" && !seedSeen[seedID] {
|
||||
seedSeen[seedID] = true
|
||||
profile.Seeds = append(profile.Seeds, Seed{ID: seedID, Name: seedName})
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range favorites {
|
||||
profile.absorb(item, favoriteWeight)
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func (p *Profile) absorb(item Item, weight float64) {
|
||||
if item.ID != "" {
|
||||
p.Seen[item.ID] = true
|
||||
}
|
||||
if item.SeriesID != "" {
|
||||
p.Seen[item.SeriesID] = true
|
||||
}
|
||||
for _, genre := range item.Genres {
|
||||
if g := strings.TrimSpace(genre); g != "" {
|
||||
p.GenreWeights[g] += weight
|
||||
}
|
||||
}
|
||||
for _, studio := range item.Studios {
|
||||
if s := strings.TrimSpace(studio.Name); s != "" {
|
||||
// Studio is a weaker signal than genre: people follow what a thing *is*
|
||||
// more reliably than who made it.
|
||||
p.StudioWeights[s] += weight * 0.4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TopGenres returns the n heaviest genres, highest first. Ties break alphabetically so
|
||||
// the Emby query — and therefore the cached row — is stable between calls.
|
||||
func (p Profile) TopGenres(n int) []string {
|
||||
type kv struct {
|
||||
genre string
|
||||
weight float64
|
||||
}
|
||||
pairs := make([]kv, 0, len(p.GenreWeights))
|
||||
for genre, weight := range p.GenreWeights {
|
||||
pairs = append(pairs, kv{genre, weight})
|
||||
}
|
||||
sort.Slice(pairs, func(i, j int) bool {
|
||||
if pairs[i].weight != pairs[j].weight {
|
||||
return pairs[i].weight > pairs[j].weight
|
||||
}
|
||||
return pairs[i].genre < pairs[j].genre
|
||||
})
|
||||
if n > len(pairs) {
|
||||
n = len(pairs)
|
||||
}
|
||||
out := make([]string, 0, n)
|
||||
for _, pair := range pairs[:n] {
|
||||
out = append(out, pair.genre)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Score rates a candidate against the profile. A negative score means "exclude".
|
||||
func (p Profile) Score(candidate Item) float64 {
|
||||
if p.Seen[candidate.ID] {
|
||||
return -1
|
||||
}
|
||||
if candidate.SeriesID != "" && p.Seen[candidate.SeriesID] {
|
||||
return -1
|
||||
}
|
||||
if candidate.UserData.Played || candidate.UserData.PlaybackPositionTicks > 0 {
|
||||
return -1
|
||||
}
|
||||
|
||||
var genreScore float64
|
||||
for _, genre := range candidate.Genres {
|
||||
genreScore += p.GenreWeights[strings.TrimSpace(genre)]
|
||||
}
|
||||
// Divide by sqrt(genre count) so a title tagged with eight genres cannot outrank a
|
||||
// focused match simply by touching more of the profile.
|
||||
if n := len(candidate.Genres); n > 1 {
|
||||
genreScore /= math.Sqrt(float64(n))
|
||||
}
|
||||
|
||||
var studioScore float64
|
||||
for _, studio := range candidate.Studios {
|
||||
studioScore += p.StudioWeights[strings.TrimSpace(studio.Name)]
|
||||
}
|
||||
|
||||
// A mild quality nudge, capped so a beloved genre still beats a well-rated stranger.
|
||||
ratingScore := candidate.CommunityRating / 10 * 0.5
|
||||
|
||||
return genreScore + studioScore + ratingScore
|
||||
}
|
||||
|
||||
// Rank scores, filters and truncates candidates, dropping duplicates by id.
|
||||
func Rank(profile Profile, candidates []Item, limit int) []Item {
|
||||
type scored struct {
|
||||
item Item
|
||||
score float64
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
ranked := make([]scored, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if seen[candidate.ID] {
|
||||
continue
|
||||
}
|
||||
seen[candidate.ID] = true
|
||||
if score := profile.Score(candidate); score > 0 {
|
||||
ranked = append(ranked, scored{candidate, score})
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
if ranked[i].score != ranked[j].score {
|
||||
return ranked[i].score > ranked[j].score
|
||||
}
|
||||
return ranked[i].item.Name < ranked[j].item.Name
|
||||
})
|
||||
|
||||
if limit > 0 && len(ranked) > limit {
|
||||
ranked = ranked[:limit]
|
||||
}
|
||||
out := make([]Item, 0, len(ranked))
|
||||
for _, entry := range ranked {
|
||||
out = append(out, entry.item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FilterUnseen keeps only what the user has not watched, preserving Emby's ordering.
|
||||
// Used for "Because you watched …", where Emby's own similarity ranking is better than
|
||||
// anything this package would compute.
|
||||
func FilterUnseen(profile Profile, candidates []Item, limit int) []Item {
|
||||
out := make([]Item, 0, len(candidates))
|
||||
seen := map[string]bool{}
|
||||
for _, candidate := range candidates {
|
||||
if seen[candidate.ID] || profile.Score(candidate) < 0 {
|
||||
continue
|
||||
}
|
||||
seen[candidate.ID] = true
|
||||
out = append(out, candidate)
|
||||
if limit > 0 && len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Raws unwraps items back to the payloads the TV will receive.
|
||||
func Raws(items []Item) []json.RawMessage {
|
||||
out := make([]json.RawMessage, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, item.Raw)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func item(id, name, itemType string, genres []string, rating float64) Item {
|
||||
return Item{ID: id, Name: name, Type: itemType, Genres: genres, CommunityRating: rating}
|
||||
}
|
||||
|
||||
func episode(id, name, seriesID, seriesName string, genres []string) Item {
|
||||
it := item(id, name, "Episode", genres, 0)
|
||||
it.SeriesID = seriesID
|
||||
it.SeriesName = seriesName
|
||||
return it
|
||||
}
|
||||
|
||||
func TestBuildProfileWeightsRecentHistoryHigher(t *testing.T) {
|
||||
history := []Item{
|
||||
item("1", "Newest", "Movie", []string{"Science Fiction"}, 8),
|
||||
item("2", "Older", "Movie", []string{"Comedy"}, 8),
|
||||
}
|
||||
profile := BuildProfile(history, nil)
|
||||
|
||||
if profile.GenreWeights["Science Fiction"] <= profile.GenreWeights["Comedy"] {
|
||||
t.Fatalf("recent genre should outweigh older: %+v", profile.GenreWeights)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProfileSeedsSeriesRatherThanEpisode(t *testing.T) {
|
||||
history := []Item{
|
||||
episode("ep1", "Good News", "sev", "Severance", []string{"Drama"}),
|
||||
}
|
||||
profile := BuildProfile(history, nil)
|
||||
|
||||
if len(profile.Seeds) != 1 {
|
||||
t.Fatalf("expected one seed, got %+v", profile.Seeds)
|
||||
}
|
||||
if profile.Seeds[0].ID != "sev" || profile.Seeds[0].Name != "Severance" {
|
||||
t.Fatalf("expected the series as seed, got %+v", profile.Seeds[0])
|
||||
}
|
||||
// The series must count as seen, or we would recommend a show already in progress.
|
||||
if !profile.Seen["sev"] {
|
||||
t.Fatal("series id should be marked seen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProfileDeduplicatesSeeds(t *testing.T) {
|
||||
history := []Item{
|
||||
episode("ep2", "Half Loop", "sev", "Severance", nil),
|
||||
episode("ep1", "Good News", "sev", "Severance", nil),
|
||||
item("m1", "Dune", "Movie", nil, 0),
|
||||
}
|
||||
profile := BuildProfile(history, nil)
|
||||
|
||||
if len(profile.Seeds) != 2 {
|
||||
t.Fatalf("expected 2 distinct seeds, got %d: %+v", len(profile.Seeds), profile.Seeds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavoritesContributeLessThanAFreshPlay(t *testing.T) {
|
||||
fromHistory := BuildProfile([]Item{item("1", "A", "Movie", []string{"Horror"}, 0)}, nil)
|
||||
fromFavorite := BuildProfile(nil, []Item{item("2", "B", "Movie", []string{"Horror"}, 0)})
|
||||
|
||||
if fromFavorite.GenreWeights["Horror"] >= fromHistory.GenreWeights["Horror"] {
|
||||
t.Fatal("a favourite should weigh less than the most recent play")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopGenresIsDeterministicOnTies(t *testing.T) {
|
||||
profile := Profile{GenreWeights: map[string]float64{"Western": 1, "Action": 1, "Drama": 2}}
|
||||
for range 20 {
|
||||
got := profile.TopGenres(3)
|
||||
want := []string{"Drama", "Action", "Western"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unstable ordering: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreExcludesWhatTheUserAlreadySaw(t *testing.T) {
|
||||
profile := BuildProfile([]Item{item("seen", "Seen", "Movie", []string{"Drama"}, 0)}, nil)
|
||||
|
||||
if score := profile.Score(item("seen", "Seen", "Movie", []string{"Drama"}, 8)); score >= 0 {
|
||||
t.Fatalf("watched item should be excluded, scored %v", score)
|
||||
}
|
||||
|
||||
inProgress := item("new", "New", "Movie", []string{"Drama"}, 8)
|
||||
inProgress.UserData.PlaybackPositionTicks = 500
|
||||
if score := profile.Score(inProgress); score >= 0 {
|
||||
t.Fatalf("in-progress item should be excluded, scored %v", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreExcludesEpisodesOfASeriesInProgress(t *testing.T) {
|
||||
profile := BuildProfile([]Item{episode("ep1", "Pilot", "sev", "Severance", []string{"Drama"})}, nil)
|
||||
|
||||
candidate := episode("ep9", "Finale", "sev", "Severance", []string{"Drama"})
|
||||
if score := profile.Score(candidate); score >= 0 {
|
||||
t.Fatalf("another episode of a watched series should be excluded, scored %v", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreDoesNotRewardGenreStuffing(t *testing.T) {
|
||||
profile := Profile{
|
||||
GenreWeights: map[string]float64{"Drama": 1, "Action": 1, "Comedy": 1, "Horror": 1},
|
||||
StudioWeights: map[string]float64{},
|
||||
Seen: map[string]bool{},
|
||||
}
|
||||
|
||||
focused := item("a", "Focused", "Movie", []string{"Drama"}, 0)
|
||||
stuffed := item("b", "Stuffed", "Movie", []string{"Drama", "Action", "Comedy", "Horror"}, 0)
|
||||
|
||||
// The stuffed title still scores higher — it genuinely matches more of the profile —
|
||||
// but the sqrt penalty must keep it from scoring 4x the focused one.
|
||||
if profile.Score(stuffed) >= 4*profile.Score(focused) {
|
||||
t.Fatalf("genre stuffing was not penalised: focused=%v stuffed=%v",
|
||||
profile.Score(focused), profile.Score(stuffed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankOrdersByAffinityAndDropsDuplicates(t *testing.T) {
|
||||
profile := BuildProfile([]Item{item("h", "History", "Movie", []string{"Science Fiction"}, 0)}, nil)
|
||||
|
||||
candidates := []Item{
|
||||
item("c1", "Comedy Pick", "Movie", []string{"Comedy"}, 9),
|
||||
item("c2", "Sci-Fi Pick", "Movie", []string{"Science Fiction"}, 5),
|
||||
item("c2", "Sci-Fi Pick (dupe)", "Movie", []string{"Science Fiction"}, 5),
|
||||
item("h", "History", "Movie", []string{"Science Fiction"}, 10),
|
||||
}
|
||||
|
||||
ranked := Rank(profile, candidates, 10)
|
||||
|
||||
if len(ranked) != 2 {
|
||||
t.Fatalf("expected 2 results (dupe collapsed, watched dropped), got %d: %+v", len(ranked), ranked)
|
||||
}
|
||||
if ranked[0].ID != "c2" {
|
||||
t.Fatalf("genre affinity should beat a higher rating, got %q first", ranked[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankRespectsLimit(t *testing.T) {
|
||||
profile := Profile{GenreWeights: map[string]float64{"Drama": 1}, Seen: map[string]bool{}}
|
||||
candidates := make([]Item, 0, 30)
|
||||
for i := range 30 {
|
||||
candidates = append(candidates, item(string(rune('a'+i)), "Title", "Movie", []string{"Drama"}, 5))
|
||||
}
|
||||
if got := len(Rank(profile, candidates, 8)); got != 8 {
|
||||
t.Fatalf("limit not applied: got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeKeepsRawPayload(t *testing.T) {
|
||||
raw := json.RawMessage(`{"Id":"1","Name":"Dune","Type":"Movie","Genres":["Science Fiction"],"ImageTags":{"Primary":"abc"}}`)
|
||||
items := Decode([]json.RawMessage{raw, json.RawMessage(`{"broken":`), json.RawMessage(`{"Name":"no id"}`)})
|
||||
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected malformed and id-less items to be skipped, got %d", len(items))
|
||||
}
|
||||
// The raw payload must survive untouched: it carries image tags the TV needs and
|
||||
// that this package never models.
|
||||
if string(items[0].Raw) != string(raw) {
|
||||
t.Fatalf("raw payload was altered: %s", items[0].Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterUnseenPreservesEmbyOrdering(t *testing.T) {
|
||||
profile := BuildProfile([]Item{item("seen", "Seen", "Movie", nil, 0)}, nil)
|
||||
candidates := []Item{
|
||||
item("seen", "Seen", "Movie", nil, 0),
|
||||
item("b", "Second", "Movie", nil, 0),
|
||||
item("a", "First", "Movie", nil, 0),
|
||||
}
|
||||
|
||||
got := FilterUnseen(profile, candidates, 10)
|
||||
|
||||
if len(got) != 2 || got[0].ID != "b" || got[1].ID != "a" {
|
||||
t.Fatalf("ordering not preserved: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// RowEvent is one reported interaction with a home-screen row.
|
||||
type RowEvent struct {
|
||||
OccurredAt time.Time
|
||||
UserID string
|
||||
RowID string
|
||||
RowKind string
|
||||
Event string
|
||||
ItemID string
|
||||
DwellMs int
|
||||
}
|
||||
|
||||
// Event kinds. Impressions say a row was drawn; focus says the remote actually landed
|
||||
// on it and for how long; select says something was opened from it.
|
||||
const (
|
||||
RowEventImpression = "impression"
|
||||
RowEventFocus = "focus"
|
||||
RowEventSelect = "select"
|
||||
)
|
||||
|
||||
// RowStat is the aggregate the admin page renders.
|
||||
type RowStat struct {
|
||||
RowID string `json:"rowId"`
|
||||
RowKind string `json:"rowKind"`
|
||||
Impressions int64 `json:"impressions"`
|
||||
Focuses int64 `json:"focuses"`
|
||||
Selects int64 `json:"selects"`
|
||||
DwellMs int64 `json:"dwellMs"`
|
||||
Viewers int64 `json:"viewers"`
|
||||
SelectRate float64 `json:"selectRate"`
|
||||
}
|
||||
|
||||
func (s *Store) InsertRowEvents(ctx context.Context, events []RowEvent) error {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
batch := &pgx.Batch{}
|
||||
for _, event := range events {
|
||||
batch.Queue(`
|
||||
INSERT INTO row_events (occurred_at, emby_user_id, row_id, row_kind, event, item_id, dwell_ms)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
event.OccurredAt, event.UserID, event.RowID, event.RowKind,
|
||||
event.Event, event.ItemID, event.DwellMs)
|
||||
}
|
||||
|
||||
results := s.pool.SendBatch(ctx, batch)
|
||||
defer results.Close()
|
||||
for range events {
|
||||
if _, err := results.Exec(); err != nil {
|
||||
return fmt.Errorf("store: insert row events: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RowStats aggregates engagement since a point in time, busiest row first.
|
||||
//
|
||||
// Dwell is the interesting number: impressions only say a row was on screen, whereas
|
||||
// dwell says someone actually stopped there.
|
||||
func (s *Store) RowStats(ctx context.Context, since time.Time) ([]RowStat, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT row_id,
|
||||
(array_agg(row_kind ORDER BY occurred_at DESC))[1] AS row_kind,
|
||||
count(*) FILTER (WHERE event = 'impression') AS impressions,
|
||||
count(*) FILTER (WHERE event = 'focus') AS focuses,
|
||||
count(*) FILTER (WHERE event = 'select') AS selects,
|
||||
coalesce(sum(dwell_ms), 0) AS dwell_ms,
|
||||
count(DISTINCT emby_user_id) AS viewers
|
||||
FROM row_events
|
||||
WHERE occurred_at >= $1
|
||||
GROUP BY row_id
|
||||
ORDER BY dwell_ms DESC, impressions DESC`, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: row stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
stats := []RowStat{}
|
||||
for rows.Next() {
|
||||
var stat RowStat
|
||||
if err := rows.Scan(&stat.RowID, &stat.RowKind, &stat.Impressions, &stat.Focuses,
|
||||
&stat.Selects, &stat.DwellMs, &stat.Viewers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if stat.Impressions > 0 {
|
||||
stat.SelectRate = float64(stat.Selects) / float64(stat.Impressions)
|
||||
}
|
||||
stats = append(stats, stat)
|
||||
}
|
||||
return stats, rows.Err()
|
||||
}
|
||||
|
||||
// PruneRowEvents drops raw events past their retention window. Aggregates are computed
|
||||
// at read time, so nothing is preserved once the events go — which is the point: this is
|
||||
// engagement telemetry for tuning rows, not a permanent record of what people watched.
|
||||
func (s *Store) PruneRowEvents(ctx context.Context, olderThan time.Duration) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM row_events WHERE occurred_at < now() - $1::interval`,
|
||||
fmt.Sprintf("%d seconds", int64(olderThan.Seconds())))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// LibraryItem is one imported Emby item. Payload is Emby's JSON verbatim; the flat
|
||||
// columns exist only so Postgres can filter and rank without opening the JSON.
|
||||
type LibraryItem struct {
|
||||
ID string
|
||||
Type string
|
||||
Name string
|
||||
SeriesID string
|
||||
SeriesName string
|
||||
ProductionYear *int
|
||||
CommunityRating *float64
|
||||
Genres []string
|
||||
Studios []string
|
||||
DateCreated *time.Time
|
||||
SearchText string
|
||||
Payload json.RawMessage
|
||||
}
|
||||
|
||||
// LibraryStats is what the admin page shows about the imported library.
|
||||
type LibraryStats struct {
|
||||
Total int64 `json:"total"`
|
||||
ByType map[string]int64 `json:"byType"`
|
||||
LastSynced *time.Time `json:"lastSynced"`
|
||||
}
|
||||
|
||||
// UpsertLibraryItems writes a batch, refreshing synced_at on every row it touches.
|
||||
//
|
||||
// synced_at doubles as the mark-and-sweep marker: a full import stamps everything it
|
||||
// sees, then deletes whatever kept an older stamp.
|
||||
func (s *Store) UpsertLibraryItems(ctx context.Context, items []LibraryItem, syncedAt time.Time) (int64, error) {
|
||||
if len(items) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
batch := &pgx.Batch{}
|
||||
for _, item := range items {
|
||||
batch.Queue(`
|
||||
INSERT INTO library_items (
|
||||
id, type, name, series_id, series_name, production_year, community_rating,
|
||||
genres, studios, date_created, search_text, payload, synced_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
type = EXCLUDED.type,
|
||||
name = EXCLUDED.name,
|
||||
series_id = EXCLUDED.series_id,
|
||||
series_name = EXCLUDED.series_name,
|
||||
production_year = EXCLUDED.production_year,
|
||||
community_rating = EXCLUDED.community_rating,
|
||||
genres = EXCLUDED.genres,
|
||||
studios = EXCLUDED.studios,
|
||||
date_created = EXCLUDED.date_created,
|
||||
search_text = EXCLUDED.search_text,
|
||||
payload = EXCLUDED.payload,
|
||||
synced_at = EXCLUDED.synced_at`,
|
||||
item.ID, item.Type, item.Name, item.SeriesID, item.SeriesName,
|
||||
item.ProductionYear, item.CommunityRating, item.Genres, item.Studios,
|
||||
item.DateCreated, item.SearchText, string(item.Payload), syncedAt)
|
||||
}
|
||||
|
||||
results := s.pool.SendBatch(ctx, batch)
|
||||
defer results.Close()
|
||||
|
||||
var written int64
|
||||
for range items {
|
||||
tag, err := results.Exec()
|
||||
if err != nil {
|
||||
return written, fmt.Errorf("store: upsert library items: %w", err)
|
||||
}
|
||||
written += tag.RowsAffected()
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// DeleteLibraryItemsBefore removes anything a full import did not touch — items deleted
|
||||
// from Emby since the last run.
|
||||
func (s *Store) DeleteLibraryItemsBefore(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM library_items WHERE synced_at < $1`, cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: prune library: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// SearchLibrary answers from the imported library rather than Emby.
|
||||
//
|
||||
// Full-text match first, with a trailing ILIKE so partial words ("sever") still hit
|
||||
// before someone finishes typing on a remote.
|
||||
func (s *Store) SearchLibrary(ctx context.Context, term string, limit int) ([]json.RawMessage, error) {
|
||||
trimmed := strings.TrimSpace(term)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT payload
|
||||
FROM library_items
|
||||
WHERE search_tsv @@ plainto_tsquery('simple', $1)
|
||||
OR search_text ILIKE '%' || $1 || '%'
|
||||
ORDER BY
|
||||
ts_rank(search_tsv, plainto_tsquery('simple', $1)) DESC,
|
||||
(lower(name) = lower($1)) DESC,
|
||||
community_rating DESC NULLS LAST,
|
||||
name ASC
|
||||
LIMIT $2`, trimmed, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: search library: %w", err)
|
||||
}
|
||||
return collectPayloads(rows)
|
||||
}
|
||||
|
||||
// LibraryCandidates returns unwatched-agnostic candidates in the given genres, for the
|
||||
// recommendation engine. User state is applied by the caller, which is the only place
|
||||
// that knows it.
|
||||
func (s *Store) LibraryCandidates(ctx context.Context, genres []string, limit int) ([]json.RawMessage, error) {
|
||||
if len(genres) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT payload
|
||||
FROM library_items
|
||||
WHERE type IN ('Movie', 'Series')
|
||||
AND genres && $1
|
||||
ORDER BY community_rating DESC NULLS LAST, date_created DESC NULLS LAST
|
||||
LIMIT $2`, genres, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: library candidates: %w", err)
|
||||
}
|
||||
return collectPayloads(rows)
|
||||
}
|
||||
|
||||
func (s *Store) LibraryStats(ctx context.Context) (LibraryStats, error) {
|
||||
stats := LibraryStats{ByType: map[string]int64{}}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `SELECT type, count(*) FROM library_items GROUP BY type`)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("store: library stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var itemType string
|
||||
var count int64
|
||||
if err := rows.Scan(&itemType, &count); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.ByType[itemType] = count
|
||||
stats.Total += count
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
var lastSynced *time.Time
|
||||
if err := s.pool.QueryRow(ctx, `SELECT max(synced_at) FROM library_items`).Scan(&lastSynced); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.LastSynced = lastSynced
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func collectPayloads(rows pgx.Rows) ([]json.RawMessage, error) {
|
||||
defer rows.Close()
|
||||
out := []json.RawMessage{}
|
||||
for rows.Next() {
|
||||
var payload []byte
|
||||
if err := rows.Scan(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, json.RawMessage(payload))
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Gateway sessions: one row per signed-in TV.
|
||||
--
|
||||
-- token_hash is SHA-256 of the bearer token handed to the device, so a database dump
|
||||
-- does not hand over working gateway tokens. emby_token IS the live upstream token and
|
||||
-- is stored as-is: treat this volume as a secret store.
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token_hash BYTEA PRIMARY KEY,
|
||||
emby_user_id TEXT NOT NULL,
|
||||
emby_token TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
server_id TEXT NOT NULL DEFAULT '',
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sessions_emby_user_idx ON sessions (emby_user_id);
|
||||
CREATE INDEX IF NOT EXISTS sessions_last_seen_idx ON sessions (last_seen_at);
|
||||
|
||||
-- The imported library.
|
||||
--
|
||||
-- payload is Emby's item JSON verbatim, so rows served from here are byte-identical to
|
||||
-- rows served live. Deliberately holds NO per-user state: everything is imported with
|
||||
-- EnableUserData=false, because one household shares this table and watched/favourite
|
||||
-- flags are not shareable. Anything user-specific still comes from Emby live.
|
||||
CREATE TABLE IF NOT EXISTS library_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
series_id TEXT NOT NULL DEFAULT '',
|
||||
series_name TEXT NOT NULL DEFAULT '',
|
||||
production_year INT,
|
||||
community_rating REAL,
|
||||
genres TEXT[] NOT NULL DEFAULT '{}',
|
||||
studios TEXT[] NOT NULL DEFAULT '{}',
|
||||
date_created TIMESTAMPTZ,
|
||||
search_text TEXT NOT NULL DEFAULT '',
|
||||
payload JSONB NOT NULL,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
-- 'simple' rather than 'english': film titles are proper nouns, and stemming
|
||||
-- "Arrival" into "arriv" helps nobody.
|
||||
search_tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', search_text)) STORED
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS library_items_search_idx ON library_items USING GIN (search_tsv);
|
||||
CREATE INDEX IF NOT EXISTS library_items_genres_idx ON library_items USING GIN (genres);
|
||||
CREATE INDEX IF NOT EXISTS library_items_type_created_idx ON library_items (type, date_created DESC);
|
||||
CREATE INDEX IF NOT EXISTS library_items_synced_idx ON library_items (synced_at);
|
||||
|
||||
-- One row per import, so the admin page can show what happened and when.
|
||||
CREATE TABLE IF NOT EXISTS sync_runs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
kind TEXT NOT NULL, -- full | incremental
|
||||
trigger TEXT NOT NULL DEFAULT 'schedule', -- schedule | manual | startup
|
||||
status TEXT NOT NULL, -- running | success | failed
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ,
|
||||
items_seen INT NOT NULL DEFAULT 0,
|
||||
items_upserted INT NOT NULL DEFAULT 0,
|
||||
items_removed INT NOT NULL DEFAULT 0,
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sync_runs_started_idx ON sync_runs (started_at DESC);
|
||||
|
||||
-- Small key/value store for operator switches (currently just maintenance mode). Kept in
|
||||
-- Postgres rather than memory so a restart cannot silently bring the app back up.
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Row-level engagement. One row per reported event; aggregation happens at read time,
|
||||
-- which is fine at household scale and keeps the write path trivial.
|
||||
CREATE TABLE IF NOT EXISTS row_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
emby_user_id TEXT NOT NULL,
|
||||
row_id TEXT NOT NULL,
|
||||
row_kind TEXT NOT NULL DEFAULT '',
|
||||
event TEXT NOT NULL, -- impression | focus | select
|
||||
item_id TEXT NOT NULL DEFAULT '',
|
||||
dwell_ms INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS row_events_time_idx ON row_events (occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS row_events_row_idx ON row_events (row_id, occurred_at DESC);
|
||||
@@ -0,0 +1,80 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// MaintenanceKey is the app_settings row backing maintenance mode.
|
||||
const MaintenanceKey = "maintenance"
|
||||
|
||||
// Maintenance is the operator switch that takes Memby down independently of Emby.
|
||||
//
|
||||
// Deliberately durable: a restart must not quietly bring the app back up while someone
|
||||
// is still working on it.
|
||||
type Maintenance struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Message string `json:"message"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// DefaultMaintenanceMessage is shown on the TV when the operator did not write one.
|
||||
const DefaultMaintenanceMessage = "Memby is down for maintenance. Try again shortly."
|
||||
|
||||
func (s *Store) Maintenance(ctx context.Context) (Maintenance, error) {
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, MaintenanceKey).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Maintenance{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Maintenance{}, fmt.Errorf("store: read maintenance: %w", err)
|
||||
}
|
||||
|
||||
var state Maintenance
|
||||
if err := json.Unmarshal(raw, &state); err != nil {
|
||||
return Maintenance{}, fmt.Errorf("store: decode maintenance: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetMaintenance(ctx context.Context, state Maintenance) error {
|
||||
state.UpdatedAt = time.Now().UTC()
|
||||
raw, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES ($1, $2::jsonb, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
||||
MaintenanceKey, string(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: write maintenance: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewestSession is the fallback credential for the library import: whichever TV signed
|
||||
// in most recently. It means a fresh deployment can import without configuring a
|
||||
// service account, at the cost of the import stopping if that user is ever removed.
|
||||
func (s *Store) NewestSession(ctx context.Context) (Session, error) {
|
||||
var sess Session
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, last_seen_at
|
||||
FROM sessions ORDER BY last_seen_at DESC LIMIT 1`).
|
||||
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
|
||||
&sess.ServerID, &sess.DeviceID, &sess.LastSeenAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Session{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("store: newest session: %w", err)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Package store persists gateway sessions in Postgres.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed schema.sql
|
||||
var schema string
|
||||
|
||||
// ErrNotFound is returned when a token does not match a live session.
|
||||
var ErrNotFound = errors.New("store: session not found")
|
||||
|
||||
type Session struct {
|
||||
TokenHash []byte
|
||||
EmbyUserID string
|
||||
EmbyToken string
|
||||
Username string
|
||||
ServerID string
|
||||
DeviceID string
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func Open(ctx context.Context, databaseURL string) (*Store, error) {
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: connect: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("store: ping: %w", err)
|
||||
}
|
||||
return &Store{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() { s.pool.Close() }
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
|
||||
|
||||
// Migrate applies the schema. It is idempotent, so it runs on every boot.
|
||||
func (s *Store) Migrate(ctx context.Context) error {
|
||||
if _, err := s.pool.Exec(ctx, schema); err != nil {
|
||||
return fmt.Errorf("store: migrate: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, sess Session) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO sessions (token_hash, emby_user_id, emby_token, username, server_id, device_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (token_hash) DO UPDATE SET
|
||||
emby_token = EXCLUDED.emby_token,
|
||||
username = EXCLUDED.username,
|
||||
server_id = EXCLUDED.server_id,
|
||||
device_id = EXCLUDED.device_id,
|
||||
last_seen_at = now()`,
|
||||
sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username, sess.ServerID, sess.DeviceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: create session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SessionByTokenHash(ctx context.Context, hash []byte) (Session, error) {
|
||||
var sess Session
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, last_seen_at
|
||||
FROM sessions WHERE token_hash = $1`, hash).
|
||||
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
|
||||
&sess.ServerID, &sess.DeviceID, &sess.LastSeenAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Session{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("store: load session: %w", err)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// Touch records activity. Cheap enough to call on the auth path, and it is what the
|
||||
// idle-expiry sweep reads.
|
||||
func (s *Store) Touch(ctx context.Context, hash []byte) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE sessions SET last_seen_at = now() WHERE token_hash = $1`, hash)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSession(ctx context.Context, hash []byte) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE token_hash = $1`, hash)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteIdleSessions retires tokens unused for longer than idle, returning how many went.
|
||||
func (s *Store) DeleteIdleSessions(ctx context.Context, idle time.Duration) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM sessions WHERE last_seen_at < now() - $1::interval`,
|
||||
fmt.Sprintf("%d seconds", int64(idle.Seconds())))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// SyncRun records one library import.
|
||||
type SyncRun struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Trigger string `json:"trigger"`
|
||||
Status string `json:"status"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt"`
|
||||
ItemsSeen int `json:"itemsSeen"`
|
||||
ItemsUpserted int `json:"itemsUpserted"`
|
||||
ItemsRemoved int `json:"itemsRemoved"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
const (
|
||||
SyncStatusRunning = "running"
|
||||
SyncStatusSuccess = "success"
|
||||
SyncStatusFailed = "failed"
|
||||
)
|
||||
|
||||
func (s *Store) StartSyncRun(ctx context.Context, kind, trigger string) (int64, error) {
|
||||
var id int64
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO sync_runs (kind, trigger, status) VALUES ($1, $2, $3) RETURNING id`,
|
||||
kind, trigger, SyncStatusRunning).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: start sync run: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Store) FinishSyncRun(ctx context.Context, id int64, run SyncRun) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE sync_runs
|
||||
SET status = $2, finished_at = now(), items_seen = $3,
|
||||
items_upserted = $4, items_removed = $5, error = $6
|
||||
WHERE id = $1`,
|
||||
id, run.Status, run.ItemsSeen, run.ItemsUpserted, run.ItemsRemoved, run.Error)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: finish sync run: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) RecentSyncRuns(ctx context.Context, limit int) ([]SyncRun, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, kind, trigger, status, started_at, finished_at,
|
||||
items_seen, items_upserted, items_removed, error
|
||||
FROM sync_runs ORDER BY started_at DESC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: recent sync runs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
runs := []SyncRun{}
|
||||
for rows.Next() {
|
||||
var run SyncRun
|
||||
if err := rows.Scan(&run.ID, &run.Kind, &run.Trigger, &run.Status, &run.StartedAt,
|
||||
&run.FinishedAt, &run.ItemsSeen, &run.ItemsUpserted, &run.ItemsRemoved, &run.Error); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runs = append(runs, run)
|
||||
}
|
||||
return runs, rows.Err()
|
||||
}
|
||||
|
||||
// LastSuccessfulSyncAt is the watermark an incremental import asks Emby about: "what has
|
||||
// changed since?" Nil means nothing has ever completed, so a full import is required.
|
||||
func (s *Store) LastSuccessfulSyncAt(ctx context.Context) (*time.Time, error) {
|
||||
var at *time.Time
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT max(started_at) FROM sync_runs WHERE status = $1`, SyncStatusSuccess).Scan(&at)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: last successful sync: %w", err)
|
||||
}
|
||||
return at, nil
|
||||
}
|
||||
|
||||
// MarkStaleRunsFailed cleans up runs left "running" by a crash or a restart mid-import.
|
||||
func (s *Store) MarkStaleRunsFailed(ctx context.Context) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE sync_runs
|
||||
SET status = $1, finished_at = now(),
|
||||
error = 'interrupted — the gateway restarted while this import was running'
|
||||
WHERE status = $2`, SyncStatusFailed, SyncStatusRunning)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user