Files
memby/server/internal/api/admin.html
T
ponzischeme89andClaude Opus 5 2ce405c540 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>
2026-07-27 08:16:20 +12:00

289 lines
11 KiB
HTML

<!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>