Files
memby/server/internal/api/admin.html
T

1308 lines
67 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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: #080b0e; --panel: #151a20; --panel-raised: #1a2027; --line: #252d35;
--text: #edf1f4; --muted: #98a3ad; --accent: #52b54b; --accent-soft: #18351b;
--danger: #e5534b; --rail-width: 242px;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { scroll-behavior: smooth; scroll-padding-top: 28px; }
body {
margin: 0; background:
radial-gradient(circle at 75% -20%, rgba(82,181,75,.09), transparent 34rem),
var(--bg); color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
.side-rail {
position: fixed; inset: 0 auto 0 0; z-index: 20; width: var(--rail-width);
display: flex; flex-direction: column; padding: 22px 12px 16px;
background: rgba(14,18,22,.97); border-right: 1px solid var(--line);
box-shadow: 12px 0 36px rgba(0,0,0,.18); overflow-y: auto;
}
.brand {
display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 0 10px;
color: var(--text); text-decoration: none;
}
.brand-mark {
display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 34px;
border-radius: 11px; background: var(--accent); color: #071608;
font-size: 19px; font-weight: 900; letter-spacing: -.08em;
box-shadow: 0 5px 18px rgba(82,181,75,.25);
}
.brand-copy { min-width: 0; }
.brand-copy b { display: block; font-size: 17px; line-height: 1.1; }
.brand-copy span { display: block; margin-top: 3px; color: var(--muted); font-size: 10px; letter-spacing: .04em; }
.rail-label {
margin: 24px 12px 8px; color: #69747e; font-size: 10px; font-weight: 700;
letter-spacing: .13em; text-transform: uppercase;
}
.rail-nav { display: grid; gap: 4px; }
.rail-link {
position: relative; display: flex; align-items: center; gap: 13px; min-height: 44px;
padding: 0 12px; border-radius: 9px; color: #aeb7bf; text-decoration: none;
font-size: 13px; font-weight: 600; transition: color .14s, background .14s, transform .14s;
}
.rail-link svg {
width: 19px; height: 19px; flex: 0 0 19px; fill: none; stroke: currentColor;
stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round;
}
.rail-link:hover { color: var(--text); background: #171d23; transform: translateX(2px); }
.rail-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.rail-link.active { color: #fff; background: var(--accent-soft); }
.rail-link.active::before {
content: ""; position: absolute; left: -12px; width: 3px; height: 24px;
border-radius: 0 4px 4px 0; background: var(--accent);
}
.rail-footer { margin-top: auto; padding: 15px 10px 0; border-top: 1px solid var(--line); }
.rail-footer .pill { display: inline-block; }
main {
width: calc(100% - var(--rail-width)); margin-left: var(--rail-width);
padding: 28px clamp(22px, 3vw, 48px) 60px; display: grid; gap: 20px;
}
h1 { font-size: clamp(24px, 3vw, 32px); line-height: 1.15; margin: 0; letter-spacing: -.025em; }
h2 { font-size: 15px; margin: 0 0 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
header { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; flex-wrap: wrap; padding: 6px 2px 10px; }
.eyebrow { margin: 0 0 5px; color: var(--accent); font-size: 11px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; }
.page-intro { margin: 7px 0 0; color: var(--muted); }
section {
scroll-margin-top: 24px; background: linear-gradient(145deg, var(--panel-raised), var(--panel));
border: 1px solid var(--line); border-radius: 13px; padding: 20px;
box-shadow: 0 12px 34px rgba(0,0,0,.12);
}
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], input[type=number], input[type=datetime-local] {
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; }
.event-toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
.event-toolbar input[type=search] {
background:#0e1216; border:1px solid var(--line); border-radius:7px;
color:var(--text); padding:8px 10px; min-width:300px;
}
select {
background:#0e1216; color:var(--text); border:1px solid var(--line);
border-radius:7px; padding:8px;
}
.event-log {
height: min(58vh, 720px); min-height: 360px; overflow: auto; background:#090c0f;
border:1px solid var(--line); border-radius:7px; font:12px/1.45 ui-monospace, Consolas, monospace;
}
.event-line { display:grid; grid-template-columns:190px 62px 240px minmax(340px,1fr); gap:10px; padding:5px 9px; border-bottom:1px solid #171c21; }
.event-line:hover { background:#12171c; }
.event-time,.event-attrs { color:var(--muted); }
.event-level { font-weight:700; }
.event-level.ERROR { color:#ff8a80; }
.event-level.WARN { color:#f0c674; }
.event-level.DEBUG { color:#88a4bd; }
.event-empty { padding:20px; color:var(--muted); }
.recommendation-controls { display:grid; grid-template-columns:repeat(4,minmax(150px,1fr)) auto; gap:10px; align-items:end; margin:14px 0; }
.recommendation-controls label { display:grid; gap:5px; color:var(--muted); font-size:12px; }
.recommendation-controls input { min-width:0; width:100%; }
.recommendation-list { display:grid; gap:10px; }
.recommendation-card { border:1px solid var(--line); border-radius:10px; background:#10151a; padding:14px; }
.recommendation-head { display:flex; gap:14px; justify-content:space-between; align-items:flex-start; }
.recommendation-title { font-size:16px; font-weight:700; }
.recommendation-score { color:#8ee39a; font:700 20px ui-monospace,Consolas,monospace; }
.reason-code,.component { display:inline-block; margin:5px 5px 0 0; padding:3px 7px; border-radius:5px; font-size:11px; }
.reason-code { color:#a9d7ff; background:#152a3b; }
.component { color:#d6dde3; background:#252d35; font-family:ui-monospace,Consolas,monospace; }
.component.negative { color:#ffaaa3; background:#3a1d1c; }
.recommendation-meta { color:var(--muted); font-size:12px; margin-top:4px; }
.reason-text { margin-top:9px; color:#dce3e8; }
.account-summary {
display:grid; grid-template-columns:repeat(3,minmax(150px,1fr)); gap:12px; margin-bottom:16px;
}
.account-stat { padding:15px; border:1px solid var(--line); border-radius:10px; background:#10151a; }
.account-stat b { display:block; font-size:24px; }
.account-stat span { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.06em; }
.account-list { display:grid; gap:12px; }
.account-card { border:1px solid var(--line); border-radius:11px; background:#10151a; overflow:hidden; }
.account-head { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; padding:17px; }
.account-name { display:flex; align-items:center; gap:12px; }
.account-avatar { display:grid; place-items:center; width:42px; height:42px; border-radius:50%; background:var(--accent-soft); color:#91df94; font-size:17px; font-weight:800; }
.account-name h3 { margin:0; font-size:17px; }
.account-name p { margin:2px 0 0; color:var(--muted); font-size:12px; }
.account-actions { display:flex; gap:8px; flex-wrap:wrap; justify-content:flex-end; }
.account-actions button,.device-actions button { padding:7px 10px; font-size:12px; }
.account-body { display:grid; grid-template-columns:minmax(0,1.2fr) minmax(280px,.8fr); border-top:1px solid var(--line); }
.account-pane { padding:16px 17px; min-width:0; }
.account-pane + .account-pane { border-left:1px solid var(--line); }
.account-pane h4 { margin:0 0 10px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.07em; }
.device-row { display:grid; grid-template-columns:minmax(150px,1fr) auto; gap:12px; align-items:center; padding:10px 0; border-top:1px solid #202830; }
.device-row:first-of-type { border-top:0; padding-top:0; }
.device-title { font-weight:650; }
.device-meta { color:var(--muted); font-size:12px; margin-top:2px; }
.device-actions { display:flex; gap:6px; }
.choice-list { display:flex; gap:6px; flex-wrap:wrap; }
.choice { padding:4px 8px; border-radius:6px; background:#202830; color:#dce3e8; font-size:12px; }
.choice.rating { background:#302b16; color:#f2d575; }
.account-note { padding:12px 14px; border:1px solid #2b4830; border-radius:8px; background:rgba(82,181,75,.07); color:#b9c6bc; font-size:13px; margin-bottom:16px; }
.feature-hero {
display:grid; grid-template-columns:minmax(0,1fr) auto; gap:22px; align-items:center;
padding:20px; margin-bottom:16px; border:1px solid #2b4830; border-radius:11px;
background:linear-gradient(120deg,rgba(82,181,75,.15),rgba(16,21,26,.7));
}
.feature-hero.safe { border-color:#705d2b; background:linear-gradient(120deg,rgba(240,198,116,.13),rgba(16,21,26,.7)); }
.feature-health { display:flex; gap:24px; flex-wrap:wrap; margin-top:13px; }
.feature-health b { display:block; font-size:20px; }
.feature-health span { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.06em; }
.feature-grid { display:grid; grid-template-columns:repeat(2,minmax(260px,1fr)); gap:12px; margin:16px 0; }
.feature-card { display:grid; gap:11px; padding:16px; border:1px solid var(--line); border-radius:10px; background:#10151a; }
.feature-card-head { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; }
.feature-card h3 { margin:0; font-size:16px; }
.feature-card p { margin:3px 0 0; color:var(--muted); font-size:13px; }
.feature-meta { display:flex; gap:7px; flex-wrap:wrap; align-items:center; }
.feature-meta code { color:#b7c1c9; font-size:11px; }
.feature-select { min-width:128px; font-weight:650; }
.recovery-note { color:#86ad8b; font-size:12px; }
.feature-actions { display:flex; gap:9px; align-items:center; flex-wrap:wrap; padding-top:15px; border-top:1px solid var(--line); }
details { margin-top:9px; }
summary { color:var(--muted); cursor:pointer; }
button:focus-visible, input:focus-visible, select:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px;
}
@media (max-width: 900px) {
:root { --rail-width: 72px; }
.side-rail { padding: 16px 8px; align-items: stretch; }
.brand { justify-content: center; padding: 0; }
.brand-copy, .rail-label, .rail-link span, .rail-footer span { display: none; }
.rail-nav { margin-top: 22px; gap: 6px; }
.rail-link { justify-content: center; padding: 0; min-height: 45px; }
.rail-link.active::before { left: -8px; }
.rail-footer { padding: 14px 0 0; text-align: center; }
main { padding: 20px 14px 44px; }
.event-line { grid-template-columns:150px 55px 1fr; }
.event-attrs { grid-column:1 / -1; }
.recommendation-controls { grid-template-columns:repeat(2,minmax(150px,1fr)); }
.feature-grid { grid-template-columns:1fr; }
.account-body { grid-template-columns:1fr; }
.account-pane + .account-pane { border-left:0; border-top:1px solid var(--line); }
}
@media (max-width: 560px) {
input[type=text], input[type=password], input[type=number], input[type=datetime-local],
.event-toolbar input[type=search] { min-width: 100% !important; width: 100%; }
section { padding: 16px; }
.stats { gap: 18px; }
.recommendation-controls { grid-template-columns:1fr; }
.feature-hero { grid-template-columns:1fr; }
.account-summary { grid-template-columns:1fr; }
.account-head,.device-row { grid-template-columns:1fr; display:grid; }
.account-actions { justify-content:flex-start; }
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.rail-link { transition: none; }
}
</style>
</head>
<body>
<aside class="side-rail" aria-label="Admin navigation">
<a class="brand" href="/admin/features" aria-label="Memby admin home">
<span class="brand-mark" aria-hidden="true">M</span>
<span class="brand-copy"><b>Memby</b><span>ADMIN CONSOLE</span></span>
</a>
<div class="rail-label">Manage</div>
<nav class="rail-nav">
<a class="rail-link" href="/admin/accounts" data-section="accounts" title="Memby accounts">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 19v-1.5A3.5 3.5 0 0 0 12.5 14h-5A3.5 3.5 0 0 0 4 17.5V19M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-2h3m-1.5-1.5v3"/></svg><span>Memby accounts</span>
</a>
<a class="rail-link" href="/admin/features" data-section="features" title="Features">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6"/></svg><span>Features</span>
</a>
<a class="rail-link" href="/admin/ratings" data-section="ratings" title="Movie ratings">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 2.1 5.4 5.9.4-4.6 3.8 1.5 5.7-4.9-3.2-4.9 3.2 1.5-5.7L4 8.8l5.9-.4L12 3Z"/></svg><span>Movie ratings</span>
</a>
<a class="rail-link" href="/admin/library" data-section="library" title="Library">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5h16v13H4zM8 5.5v13M4 10h4"/></svg><span>Library</span>
</a>
<a class="rail-link" href="/admin/recommendations" data-section="recommendations" title="Recommendations">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 1.5 5 5 .2-4 3 1.4 5-3.9-2.8-3.9 2.8 1.4-5-4-3 5-.2L12 3Z"/></svg><span>Recommendations</span>
</a>
<a class="rail-link" href="/admin/requests" data-section="requests" title="Media requests">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 4h14v16H5zM8 8h8M8 12h5M15 16h3m-1.5-1.5v3"/></svg><span>Media requests</span>
</a>
<a class="rail-link" href="/admin/playback" data-section="playback" title="Playback">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7zM4 5v14"/></svg><span>Playback</span>
</a>
<a class="rail-link" href="/admin/maintenance" data-section="maintenance" title="Maintenance">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m14.5 6.5 3-3 3 3-3 3M9 15l-5.5 5.5M13 4a5 5 0 0 0 6.5 6.5L10 20l-6-6 9.5-9.5Z"/></svg><span>Maintenance</span>
</a>
<a class="rail-link" href="/admin/updates" data-section="updates" title="App updates">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 16V4m0 0L8 8m4-4 4 4M5 13v6h14v-6"/></svg><span>App updates</span>
</a>
</nav>
<div class="rail-label">Observe</div>
<nav class="rail-nav">
<a class="rail-link" href="/admin/engagement" data-section="engagement" title="Row engagement">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19V9m5 10V5m5 14v-7m5 7V3"/></svg><span>Row engagement</span>
</a>
<a class="rail-link" href="/admin/imports" data-section="imports" title="Recent imports">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5"/></svg><span>Recent imports</span>
</a>
<a class="rail-link" href="/admin/logs" data-section="logs" title="Server logs">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v14H4zM7 9l2 2-2 2m5 1h5"/></svg><span>Server logs</span>
</a>
</nav>
<div class="rail-footer">
<span class="muted">Server status</span>
<div id="rail-live" class="pill muted" style="margin-top:7px">connecting…</div>
</div>
</aside>
<main>
<header id="top">
<div>
<p class="eyebrow">Memby gateway</p>
<h1 id="page-title">Admin console</h1>
<p class="page-intro" id="page-intro">Manage the library, TV experience and server health.</p>
</div>
<span id="live" class="pill muted">connecting…</span>
</header>
<div id="error" class="banner"></div>
<section id="accounts" data-admin-page="accounts">
<h2>Memby accounts</h2>
<div class="account-note">
This is the Memby account list, not the Emby user directory. A person appears here only after
signing in to the Memby app. Removing access signs their Memby devices out and does not delete
or change their Emby account.
</div>
<div id="account-summary" class="account-summary"><span class="muted">Loading accounts…</span></div>
<div id="account-list" class="account-list"><span class="muted">Loading accounts…</span></div>
</section>
<section id="library" data-admin-page="library">
<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 id="for-you" data-admin-page="recommendations">
<h2>For You</h2>
<div class="stats" id="for-you-stats"><span class="muted">Loading…</span></div>
<div class="row">
<button id="for-you-import">Import recent sessions</button>
<button id="for-you-full" class="secondary">Full Tracearr backfill</button>
<button id="for-you-rebuild" class="secondary">Rebuild all pools</button>
<span class="muted" id="for-you-hint"></span>
</div>
<hr style="border:0;border-top:1px solid var(--line);margin:20px 0">
<h2 style="margin-bottom:4px">Per-user pressure test</h2>
<p class="muted" style="margin-top:0">
Re-runs the shared weighted scorer over the user's prepared pool, after Emby
permission and parental-control filtering. Inspect every component and evidence reason.
</p>
<div class="recommendation-controls">
<label>User<select id="recommendation-user"><option value="">Choose a user…</option></select></label>
<label>Context<select id="recommendation-context">
<option value="default">Default</option>
<option value="bedtime">One episode before bed</option>
<option value="hidden">Hidden library</option>
<option value="new-releases">Recent new releases</option>
</select></label>
<label>Available minutes<input id="recommendation-minutes" type="number" min="0" max="360" value="0"></label>
<label>Evaluate at<input id="recommendation-at" type="datetime-local"></label>
<button id="recommendation-load">Run pressure test</button>
</div>
<div id="recommendation-summary" class="stats"></div>
<div id="recommendation-profile"></div>
<div id="recommendation-results" class="recommendation-list">
<span class="muted">Choose a user to inspect their recommendations.</span>
</div>
</section>
<section id="requests" data-admin-page="requests">
<h2>Media requests</h2>
<p class="muted" style="margin-top:0">
Choose who sees “Request it” when a library search has no matches. Movies are added
to Radarr and shows to Sonarr as unmonitored; no download search starts automatically.
</p>
<div id="request-services" class="row" style="margin-bottom:12px"></div>
<div id="request-users" style="display:grid;gap:8px;margin-bottom:12px">
<span class="muted">Loading users…</span>
</div>
<button id="request-save">Save access</button>
</section>
<section id="maintenance" data-admin-page="maintenance">
<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 id="features" data-admin-page="features">
<div id="feature-hero" class="feature-hero">
<div>
<p class="eyebrow" style="margin-bottom:4px">Server control plane</p>
<h2 style="color:var(--text);font-size:20px;text-transform:none;letter-spacing:-.01em;margin:0">Features without an app release</h2>
<p class="muted" style="margin:5px 0 0">Every optional feature has a safe default, an explicit override and a remote recovery path.</p>
<div id="feature-health" class="feature-health"><span>Loading feature state…</span></div>
</div>
<button id="feature-safe-mode" class="danger">Enable safe mode</button>
</div>
<div id="feature-list" class="feature-grid"><span class="muted">Loading flags…</span></div>
<div class="feature-actions">
<button id="feature-save">Publish changes</button>
<button id="feature-rollback" class="secondary">Roll back previous revision</button>
<button id="feature-reset" class="secondary">Clear all overrides</button>
<span id="feature-state" class="pill muted">loading…</span>
</div>
<h2 style="margin-top:24px">Client compatibility</h2>
<p class="muted">Capability reports are sent on every request. A feature is only presented to clients that declare its contract.</p>
<div class="scroll"><table>
<thead><tr><th>TV</th><th>User</th><th>App</th><th>Protocol</th><th>Control plane</th><th>Last seen</th></tr></thead>
<tbody id="feature-clients"><tr><td colspan="6" class="muted">No clients reported yet.</td></tr></tbody>
</table></div>
</section>
<section id="ratings" data-admin-page="ratings">
<h2>MDBList movie ratings</h2>
<p class="muted" style="margin-top:0">
Optionally enrich movie details with ratings fetched by the gateway. The API key
stays on this server, responses are cached for 24 hours, and failures never block a TV.
</p>
<div class="row" style="margin-bottom:14px">
<label style="display:flex;align-items:center;gap:9px">
<input type="checkbox" id="mdblist-enabled">
<span>Show external movie ratings</span>
</label>
<span id="mdblist-state" class="pill muted">off</span>
</div>
<div style="display:grid;gap:8px;margin-bottom:16px">
<label class="muted" for="mdblist-api-key">MDBList API key</label>
<div class="row">
<input type="password" id="mdblist-api-key" autocomplete="new-password" placeholder="Paste an API key">
<label style="display:flex;align-items:center;gap:7px">
<input type="checkbox" id="mdblist-clear-key"> Remove saved key
</label>
</div>
<span class="muted" style="font-size:12px">Leave the field blank to keep the currently saved key.</span>
</div>
<h2 style="margin-bottom:8px">Sources shown on TVs</h2>
<div id="mdblist-sources" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:9px;margin-bottom:16px">
<span class="muted">Loading sources…</span>
</div>
<button id="mdblist-save">Save ratings settings</button>
</section>
<section id="playback" data-admin-page="playback">
<h2>Playback experience</h2>
<p class="muted" style="margin-top:0">
Controls presentation policy returned with every playback launch. Changes apply to
the next title opened on every gateway-connected TV; no app release is required.
</p>
<div class="row">
<label style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="preroll-enabled"> Show the upcoming-show preroll
</label>
<label class="muted" style="display:flex;align-items:center;gap:8px">
Duration
<input type="number" id="preroll-duration" min="1" max="30" step="0.5" value="6.5" style="min-width:90px;width:90px">
seconds
</label>
<button id="playback-save">Save playback policy</button>
<span id="playback-state" class="pill"></span>
</div>
</section>
<section id="updates" data-admin-page="updates">
<h2>App updates</h2>
<p class="muted" style="margin-top:0">
TVs check on every launch. <strong>Optional</strong> shows a dismissable prompt;
<strong>required</strong> blocks the home screen until the viewer updates.
</p>
<div class="row" style="margin-bottom:10px">
<input type="text" id="update-version" placeholder="Latest version, e.g. 0.1.54" style="min-width:220px">
<input type="text" id="update-url" placeholder="APK URL, e.g. https://nas/memby/memby-0.1.54.apk" style="min-width:380px">
</div>
<div class="row" style="margin-bottom:10px">
<input type="text" id="update-notes" placeholder="What's new (shown on the TV)" style="min-width:480px">
</div>
<div class="row">
<label class="muted" style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="update-required"> Require this update
</label>
<button id="update-save">Save policy</button>
<button id="update-disable" class="secondary">Turn off prompts</button>
<span id="update-state" class="pill"></span>
</div>
</section>
<section id="engagement" data-admin-page="engagement">
<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 id="imports" data-admin-page="imports">
<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>
<section id="events" data-admin-page="logs">
<h2>Live server events</h2>
<div class="event-toolbar">
<select id="event-level" aria-label="Minimum event level">
<option value="DEBUG">Debug and above</option>
<option value="INFO" selected>Info and above</option>
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
</select>
<input type="search" id="event-search" placeholder="Filter message, path, version, status…">
<button id="event-pause" class="secondary">Pause</button>
<button id="event-clear" class="secondary">Clear view</button>
<button id="event-export" class="secondary">Export JSON</button>
<span id="event-stats" class="muted">Connecting…</span>
</div>
<div id="event-log" class="event-log" role="log" aria-live="polite">
<div class="event-empty">Waiting for server events…</div>
</div>
</section>
</main>
<script>
const railLinks = [...document.querySelectorAll('.rail-link[data-section]')];
const currentAdminPage = location.pathname.split('/').filter(Boolean).pop() || 'library';
const pageCopy = {
accounts: ['Memby accounts', 'See who uses Memby, where they are signed in and how they personalised recommendations.'],
library: ['Library', 'Import and inspect the catalogue Memby ranks.'],
recommendations: ['Recommendations', 'Pressure-test personalised rows and title scores per user.'],
requests: ['Media requests', 'Control who can request missing movies and shows.'],
features: ['Features', 'Roll out, stop and recover optional TV behaviour from the server.'],
ratings: ['Movie ratings', 'Configure optional server-side MDBList ratings for movie details.'],
playback: ['Playback', 'Control server-driven playback presentation on every TV.'],
maintenance: ['Maintenance', 'Control gateway availability for every television.'],
updates: ['App updates', 'Publish optional or mandatory client update policy.'],
engagement: ['Row engagement', 'Inspect impressions, focus, dwell and selections.'],
imports: ['Recent imports', 'Review catalogue synchronization history.'],
logs: ['Server logs', 'Follow structured gateway events in real time.'],
};
document.querySelectorAll('[data-admin-page]').forEach((section) => {
section.hidden = section.dataset.adminPage !== currentAdminPage;
});
railLinks.forEach((link) => {
const selected = link.dataset.section === currentAdminPage;
link.classList.toggle('active', selected);
if (selected) link.setAttribute('aria-current', 'page');
});
const copy = pageCopy[currentAdminPage] || pageCopy.library;
document.getElementById('page-title').textContent = copy[0];
document.getElementById('page-intro').textContent = copy[1];
document.title = copy[0] + ' · Memby admin';
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: {
'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() : '—');
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;',
}[char]));
function initials(name) {
return String(name || '?').trim().split(/\s+/).slice(0, 2).map((part) => part[0] || '').join('').toUpperCase();
}
function recent(value) {
if (!value) return false;
return Date.now() - new Date(value).getTime() < 15 * 60 * 1000;
}
function renderAccounts(payload) {
const accounts = payload.accounts || [];
const devices = accounts.flatMap((account) => account.devices || []);
const online = devices.filter((device) => recent(device.lastSeen)).length;
const personalised = accounts.filter((account) => account.recommendations?.completed).length;
document.getElementById('account-summary').innerHTML =
'<div class="account-stat"><b>' + number(accounts.length) + '</b><span>Memby accounts</span></div>' +
'<div class="account-stat"><b>' + number(devices.length) + '</b><span>signed-in devices · ' + online + ' active now</span></div>' +
'<div class="account-stat"><b>' + number(personalised) + '</b><span>recommendation prompts completed</span></div>';
document.getElementById('account-list').innerHTML = accounts.length ? accounts.map((account) => {
const prefs = account.recommendations || {};
const ratings = prefs.ratings || [];
const dimensions = [
['Genres', prefs.genres], ['Studios', prefs.studios], ['Actors', prefs.actors],
['Directors', prefs.directors], ['Types', prefs.contentTypes],
].filter((entry) => (entry[1] || []).length);
const choices = ratings.map((rating) =>
'<span class="choice rating" title="' + escapeHtml(rating.itemId) + '">' +
escapeHtml(rating.title) + ' · ' + number(rating.rating) + ' ★</span>').join('') +
dimensions.flatMap(([label, values]) => (values || []).map((value) =>
'<span class="choice">' + escapeHtml(label) + ': ' + escapeHtml(value) + '</span>')).join('');
const deviceRows = (account.devices || []).map((device) => {
const state = recent(device.lastSeen);
return '<div class="device-row"><div><div class="device-title">' +
'<span class="pill ' + (state ? 'ok' : 'muted') + '">' + (state ? 'active' : 'idle') + '</span> ' +
escapeHtml(device.name || 'Memby TV') + '</div><div class="device-meta">' +
escapeHtml(device.version ? 'Memby ' + device.version : 'Legacy Memby client') +
(device.protocol ? ' · protocol ' + escapeHtml(device.protocol) : '') +
' · last seen ' + escapeHtml(when(device.lastSeen)) +
' · signed in ' + escapeHtml(when(device.signedInAt)) + '</div></div>' +
'<div class="device-actions"><button class="secondary" data-account-action="rename-device" data-user-id="' +
escapeHtml(account.id) + '" data-device-id="' + escapeHtml(device.id) + '" data-device-name="' +
escapeHtml(device.name) + '"' + (!device.id ? ' disabled' : '') + '>Rename</button>' +
'<button class="secondary" data-account-action="remove-device" data-user-id="' + escapeHtml(account.id) +
'" data-device-id="' + escapeHtml(device.id) + '"' + (!device.id ? ' disabled' : '') + '>Sign out</button></div></div>';
}).join('');
const promptState = prefs.completed
? '<span class="pill ok">completed</span>'
: '<span class="pill warn">not completed</span>';
return '<article class="account-card"><div class="account-head"><div class="account-name">' +
'<div class="account-avatar">' + escapeHtml(initials(account.username)) + '</div><div><h3>' +
escapeHtml(account.username || 'Unnamed user') + '</h3><p>Memby account · last seen ' +
escapeHtml(when(account.lastSeen)) + ' · ' + number((account.devices || []).length) + ' device' +
((account.devices || []).length === 1 ? '' : 's') + '</p></div></div>' +
'<div class="account-actions">' + (prefs.updated
? '<button class="secondary" data-account-action="reset-recommendations" data-user-id="' +
escapeHtml(account.id) + '">Clear stored choices</button>' : '') +
'<button class="danger" data-account-action="remove-account" data-user-id="' + escapeHtml(account.id) +
'" data-username="' + escapeHtml(account.username) + '">Remove Memby access</button></div></div>' +
'<div class="account-body"><div class="account-pane"><h4>Signed-in devices</h4>' + deviceRows + '</div>' +
'<div class="account-pane"><h4>Recommendation prompt · ' + promptState + '</h4>' +
(choices || '<span class="muted">No recommendation selections have been saved.</span>') +
'</div></div></article>';
}).join('') : '<div class="account-note">No one has signed in to Memby yet. Emby-only users are intentionally not listed here.</div>';
}
let featureRevision = 0;
function renderFeatures(payload, clients) {
featureRevision = Number(payload.revision || 0);
const features = payload.features || [];
const enabled = features.filter((feature) => feature.enabled).length;
const overrides = features.filter((feature) => feature.source === 'override').length;
const capableClients = clients.filter((client) =>
(client.capabilities || []).includes('server_features_v1')).length;
const hero = document.getElementById('feature-hero');
hero.classList.toggle('safe', Boolean(payload.safeMode));
document.getElementById('feature-health').innerHTML =
'<div><b>' + enabled + ' / ' + features.length + '</b><span>features active</span></div>' +
'<div><b>' + overrides + '</b><span>explicit overrides</span></div>' +
'<div><b>' + capableClients + ' / ' + clients.length + '</b><span>TVs reporting control plane</span></div>' +
'<div><b>r' + featureRevision + '</b><span>published revision</span></div>';
const safeButton = document.getElementById('feature-safe-mode');
safeButton.textContent = payload.safeMode ? 'Leave safe mode' : 'Enable safe mode';
safeButton.className = payload.safeMode ? 'secondary' : 'danger';
const featureState = document.getElementById('feature-state');
featureState.textContent = payload.safeMode ? 'SAFE MODE · optional features off' : 'live · revision ' + featureRevision;
featureState.className = 'pill ' + (payload.safeMode ? 'warn' : 'ok');
document.getElementById('feature-rollback').disabled = !payload.canRollback;
const list = document.getElementById('feature-list');
if (!list.contains(document.activeElement)) {
list.innerHTML = features.map((feature) => {
const mode = feature.source === 'override' ? (feature.enabled ? 'on' : 'off') : 'default';
const effective = feature.enabled ? 'ACTIVE' : 'OFF';
return '<article class="feature-card">' +
'<div class="feature-card-head"><div><div class="feature-meta">' +
'<span class="pill ' + (feature.enabled ? 'ok' : 'muted') + '">' + effective + '</span>' +
'<span class="muted">' + escapeHtml(feature.area) + '</span></div>' +
'<h3>' + escapeHtml(feature.name) + '</h3><p>' + escapeHtml(feature.description) + '</p></div>' +
'<select class="feature-select" data-feature-key="' + escapeHtml(feature.key) + '" aria-label="' + escapeHtml(feature.name) + ' mode">' +
'<option value="default"' + (mode === 'default' ? ' selected' : '') + '>Safe default</option>' +
'<option value="on"' + (mode === 'on' ? ' selected' : '') + '>Forced on</option>' +
'<option value="off"' + (mode === 'off' ? ' selected' : '') + '>Forced off</option>' +
'</select></div>' +
'<div class="feature-meta"><code>' + escapeHtml(feature.key) + '</code>' +
'<span class="pill muted">protocol ' + number(feature.minimumProtocol) + '+</span>' +
'<span class="pill ' + (feature.compatible ? 'ok' : 'warn') + '">' +
(feature.compatible ? 'server compatible' : 'compatibility blocked') + '</span></div>' +
'<div class="recovery-note">↳ ' + escapeHtml(feature.recovery) + '</div></article>';
}).join('') || '<span class="muted">No server features are registered.</span>';
}
document.getElementById('feature-clients').innerHTML = clients.length
? clients.map((client) => {
const capable = (client.capabilities || []).includes('server_features_v1');
return '<tr><td>' + escapeHtml(client.deviceName || 'Memby TV') + '</td>' +
'<td>' + escapeHtml(client.username) + '</td><td>' + escapeHtml(client.version || 'legacy') + '</td>' +
'<td>' + escapeHtml(client.protocol || 'unknown') + '</td>' +
'<td><span class="pill ' + (capable ? 'ok' : 'warn') + '">' +
(capable ? 'reported' : 'legacy fallback') + '</span></td><td>' + escapeHtml(when(client.lastSeen)) + '</td></tr>';
}).join('')
: '<tr><td colspan="6" class="muted">No clients have signed in yet.</td></tr>';
}
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 forYou = status.forYou || {};
document.getElementById('for-you-stats').innerHTML =
'<div class="stat"><b>' + number(forYou.tracearrSessions) + '</b><span>Tracearr sessions</span></div>' +
'<div class="stat"><b>' + number(forYou.profiles) + '</b><span>user profiles</span></div>' +
'<div class="stat"><b>' + number(forYou.candidates) + '</b><span>ranked candidates</span></div>' +
'<div class="stat"><b style="font-size:15px">' + when(forYou.lastFullImport) +
'</b><span>last full import</span></div>';
const forYouRunning = Boolean(status.forYouRunning);
document.getElementById('for-you-import').disabled = forYouRunning;
document.getElementById('for-you-full').disabled = forYouRunning;
document.getElementById('for-you-rebuild').disabled = forYouRunning;
document.getElementById('for-you-hint').textContent =
forYouRunning ? 'For You maintenance running…' : 'Prepared pools normally refresh in the background.';
document.getElementById('request-services').innerHTML =
'<span class="pill ' + (status.radarrReady ? 'ok' : 'bad') + '">Radarr ' +
(status.radarrReady ? 'ready' : 'not configured') + '</span>' +
'<span class="pill ' + (status.sonarrReady ? 'ok' : 'bad') + '">Sonarr ' +
(status.sonarrReady ? 'ready' : 'not configured') + '</span>';
const allowedUsers = new Set((status.requestPolicy || {}).allowedUserIds || []);
const userBox = document.getElementById('request-users');
if (!userBox.contains(document.activeElement)) {
userBox.innerHTML = (status.requestUsers || []).length
? status.requestUsers.map((user) =>
'<label style="display:flex;align-items:center;gap:9px">' +
'<input type="checkbox" data-request-user="' + escapeHtml(user.id) + '"' +
(allowedUsers.has(user.id) ? ' checked' : '') + '>' +
'<span>' + escapeHtml(user.username) + '</span>' +
'<span class="muted">last seen ' + escapeHtml(when(user.lastSeen)) + '</span></label>'
).join('')
: '<span class="muted">No users have signed in yet.</span>';
}
const recommendationUser = document.getElementById('recommendation-user');
if (!recommendationUser.contains(document.activeElement)) {
const selectedUser = recommendationUser.value;
recommendationUser.innerHTML = '<option value="">Choose a user…</option>' +
(status.requestUsers || []).map((user) =>
'<option value="' + escapeHtml(user.id) + '">' +
escapeHtml(user.username) + '</option>').join('');
recommendationUser.value = selectedUser;
}
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 || '';
renderFeatures(status.features || {}, status.clients || []);
const mdblist = status.mdblist || {};
const mdblistEnabled = document.getElementById('mdblist-enabled');
if (document.activeElement !== mdblistEnabled) mdblistEnabled.checked = Boolean(mdblist.enabled);
const mdblistState = document.getElementById('mdblist-state');
mdblistState.textContent = mdblist.enabled
? 'on · ' + ((mdblist.sources || []).length) + ' sources'
: (mdblist.apiKeyConfigured ? 'off · key saved' : 'off · no key');
mdblistState.className = 'pill ' + (mdblist.enabled ? 'ok' : 'muted');
const keyField = document.getElementById('mdblist-api-key');
keyField.placeholder = mdblist.apiKeyConfigured ? 'Saved key (leave blank to keep)' : 'Paste an API key';
const sourceNames = {
imdb:'IMDb', tomatoes:'Rotten Tomatoes', audience:'Rotten Tomatoes Audience',
metacritic:'Metacritic', letterboxd:'Letterboxd', rogerebert:'Roger Ebert',
tmdb:'TMDb', trakt:'Trakt', mal:'MyAnimeList',
score:'MDBList Score', score_average:'MDBList Average',
};
const sourceBox = document.getElementById('mdblist-sources');
if (!sourceBox.contains(document.activeElement)) {
const selectedSources = new Set(mdblist.sources || []);
sourceBox.innerHTML = (mdblist.availableSources || []).map((source) =>
'<label style="display:flex;align-items:center;gap:8px">' +
'<input type="checkbox" data-mdblist-source="' + escapeHtml(source) + '"' +
(selectedSources.has(source) ? ' checked' : '') + '>' +
'<span>' + escapeHtml(sourceNames[source] || source) + '</span></label>'
).join('') || '<span class="muted">No rating sources available.</span>';
}
const playback = status.playbackPolicy || {};
const prerollEnabled = document.getElementById('preroll-enabled');
const prerollDuration = document.getElementById('preroll-duration');
if (document.activeElement !== prerollEnabled) {
prerollEnabled.checked = playback.prerollEnabled !== false;
}
if (document.activeElement !== prerollDuration) {
prerollDuration.value = ((playback.prerollDurationMs || 6500) / 1000).toString();
}
const playbackState = document.getElementById('playback-state');
playbackState.textContent = prerollEnabled.checked
? 'on · ' + prerollDuration.value + 's'
: 'off';
playbackState.className = 'pill ' + (prerollEnabled.checked ? 'ok' : 'muted');
const policy = status.updatePolicy || {};
const policyState = document.getElementById('update-state');
const required = Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion;
if (!policy.enabled) {
policyState.textContent = 'off';
policyState.className = 'pill muted';
} else {
policyState.textContent = required ? 'REQUIRED ' + policy.latestVersion : 'optional ' + policy.latestVersion;
policyState.className = 'pill ' + (required ? 'warn' : 'ok');
}
// Do not fight the operator for the field they are typing in.
const fields = {
'update-version': policy.latestVersion || '',
'update-url': policy.downloadUrl || '',
'update-notes': policy.notes || '',
};
for (const [id, value] of Object.entries(fields)) {
const el = document.getElementById(id);
if (document.activeElement !== el) el.value = value;
}
const requiredBox = document.getElementById('update-required');
if (document.activeElement !== requiredBox) requiredBox.checked = required;
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>';
}
function affinityEntries(profile) {
const dimensions = [
['Genre', profile.genres], ['Studio', profile.studios], ['Actor', profile.actors],
['Director', profile.directors], ['Franchise', profile.franchises],
['Runtime', profile.runtimeRanges], ['Age rating', profile.ageRatings],
['Community rating', profile.communityRatings], ['Release period', profile.releasePeriods],
['Content type', profile.contentTypes],
];
return dimensions.flatMap(([dimension, values]) =>
Object.entries(values || {}).map(([name, value]) => ({
dimension, name, weight: value.weight || 0, evidence: value.evidence || 0,
}))).sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight));
}
function renderRecommendationInspection(payload) {
const meta = payload.profileMeta || {};
document.getElementById('recommendation-summary').innerHTML =
'<div class="stat"><b>' + number(payload.poolCandidates) + '</b><span>prepared pool</span></div>' +
'<div class="stat"><b>' + number(payload.permissionEligible) + '</b><span>permission eligible</span></div>' +
'<div class="stat"><b>' + number((payload.items || []).length) + '</b><span>ranked result</span></div>' +
'<div class="stat"><b>' + number(meta.sourceEvents) + '</b><span>source events</span></div>' +
'<div class="stat"><b style="font-size:14px">' + escapeHtml(meta.algorithmVersion || '—') +
'</b><span>algorithm</span></div>' +
'<div class="stat"><b style="font-size:14px">' + when(meta.poolBuiltAt) +
'</b><span>pool built</span></div>';
const affinities = affinityEntries(payload.profile || {}).slice(0, 24);
const actions = payload.actions || [];
document.getElementById('recommendation-profile').innerHTML =
'<details><summary>Profile evidence · top affinities and explicit actions</summary>' +
'<div style="margin-top:8px">' +
(affinities.length ? affinities.map((entry) =>
'<span class="component ' + (entry.weight < 0 ? 'negative' : '') + '">' +
escapeHtml(entry.dimension + ': ' + entry.name) + ' ' +
(entry.weight >= 0 ? '+' : '') + entry.weight.toFixed(3) +
' · n=' + number(entry.evidence) + '</span>').join('') :
'<span class="muted">No repeated affinity evidence yet; cold-start priors apply.</span>') +
'</div><div style="margin-top:8px">' +
(actions.length ? actions.map((action) =>
'<span class="reason-code">' +
escapeHtml(action.action + ': ' + (action.title || action.itemId)) + '</span>').join('') :
'<span class="muted">No explicit recommendation actions.</span>') +
'</div></details>';
const results = payload.items || [];
document.getElementById('recommendation-results').innerHTML = results.length
? results.map((item, index) => {
const explanation = item.explanation || {};
const components = Object.entries(explanation.components || {})
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
const codes = explanation.reasonCodes || [];
const exposure = item.exposure || {};
return '<article class="recommendation-card">' +
'<div class="recommendation-head"><div>' +
'<div class="recommendation-title">#' + (index + 1) + ' · ' + escapeHtml(item.title) + '</div>' +
'<div class="recommendation-meta">' +
escapeHtml(item.type) + (item.year ? ' · ' + item.year : '') +
(item.runtimeMinutes ? ' · ' + item.runtimeMinutes + ' min' : '') +
(item.genres || []).map((genre) => ' · ' + escapeHtml(genre)).join('') +
'</div></div>' +
'<div class="recommendation-score">' + Number(explanation.total || 0).toFixed(3) + '</div>' +
'</div>' +
'<div class="reason-text">' +
escapeHtml(item.preparedReason || 'No legacy prepared explanation') +
(item.compatibilityLabel ? ' · ' + escapeHtml(item.compatibilityLabel) : '') +
'</div><div>' +
codes.map((code) => '<span class="reason-code">' + escapeHtml(code) + '</span>').join('') +
'</div><div>' +
components.map(([name, value]) =>
'<span class="component ' + (value < 0 ? 'negative' : '') + '">' +
escapeHtml(name) + '=' + (value >= 0 ? '+' : '') + Number(value).toFixed(3) +
'</span>').join('') +
'</div><details><summary>Pool, row and exposure details</summary>' +
'<div class="recommendation-meta">Base rank ' + number(item.baseRank) +
' · base ' + Number(item.baseScore || 0).toFixed(3) +
' · affinity ' + Number(item.affinityScore || 0).toFixed(3) +
' · compatibility ' + Number(item.compatibilityScore || 0).toFixed(3) +
' · impressions ' + number(exposure.impressions) +
' · focuses ' + number(exposure.focuses) +
' · selects ' + number(exposure.selects) + '</div>' +
'<div style="margin-top:6px">' + (item.eligibleRows || []).map((row) =>
'<span class="reason-code">' + escapeHtml(row) + '</span>').join('') + '</div>' +
(item.preparedEvidenceTitle ?
'<div class="recommendation-meta">Prepared evidence: ' +
escapeHtml(item.preparedEvidenceTitle) + '</div>' : '') +
'</details></article>';
}).join('')
: '<span class="muted">No candidates survived this context, explicit exclusions and permission filter.</span>';
}
async function loadRecommendationInspection() {
const userId = document.getElementById('recommendation-user').value;
if (!userId) {
showError('Choose a user to pressure-test.');
return;
}
const params = new URLSearchParams({
userId,
context: document.getElementById('recommendation-context').value,
minutes: document.getElementById('recommendation-minutes').value || '0',
limit: '100',
});
const localAt = document.getElementById('recommendation-at').value;
if (localAt) params.set('at', new Date(localAt).toISOString());
document.getElementById('recommendation-results').innerHTML =
'<span class="muted">Running permission check and scorer…</span>';
try {
renderRecommendationInspection(await api('/admin/api/recommendations?' + params.toString()));
showError('');
} catch (err) {
showError(err.message);
document.getElementById('recommendation-results').innerHTML =
'<span class="muted">Pressure test failed.</span>';
}
}
async function refresh() {
const live = document.getElementById('live');
const railLive = document.getElementById('rail-live');
try {
const [status, analytics, accounts] = await Promise.all([
api('/admin/api/status'),
currentAdminPage === 'engagement'
? api('/admin/api/analytics?days=' + document.getElementById('days').value)
: Promise.resolve(null),
currentAdminPage === 'accounts'
? api('/admin/api/accounts')
: Promise.resolve(null),
]);
renderStatus(status);
if (analytics) renderAnalytics(analytics);
if (accounts) renderAccounts(accounts);
const updatedAt = new Date().toLocaleTimeString();
live.textContent = 'updated ' + updatedAt;
live.className = 'pill ok';
railLive.textContent = 'online';
railLive.className = 'pill ok';
showError('');
} catch (err) {
live.textContent = 'error';
live.className = 'pill bad';
railLive.textContent = 'offline';
railLive.className = 'pill bad';
showError(err.message);
}
}
async function act(fn) {
try {
await fn();
await refresh();
} catch (err) {
showError(err.message);
}
}
document.getElementById('account-list').addEventListener('click', (event) => {
const button = event.target.closest('[data-account-action]');
if (!button) return;
const action = button.dataset.accountAction;
const userId = button.dataset.userId;
const base = '/admin/api/accounts/' + encodeURIComponent(userId);
if (action === 'rename-device') {
const deviceName = prompt('Name this Memby device', button.dataset.deviceName || 'Memby TV');
if (deviceName === null || !deviceName.trim()) return;
act(() => api(base + '/devices/' + encodeURIComponent(button.dataset.deviceId), {
method: 'PUT', body: JSON.stringify({deviceName: deviceName.trim()}),
}));
}
if (action === 'remove-device') {
if (!confirm('Sign this device out of Memby? Its Emby account will not be changed.')) return;
act(() => api(base + '/devices/' + encodeURIComponent(button.dataset.deviceId), {method: 'DELETE'}));
}
if (action === 'remove-account') {
if (!confirm('Remove Memby access for ' + (button.dataset.username || 'this user') +
'? Every Memby device will be signed out. Their Emby account will not be changed.')) return;
act(() => api(base + '/sessions', {method: 'DELETE'}));
}
if (action === 'reset-recommendations') {
if (!confirm('Clear this users stored recommendation choices? Viewing history remains intact.')) return;
act(() => api(base + '/recommendations', {method: 'DELETE'}));
}
});
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' }) }));
});
function forYouAction(action) {
return api('/admin/api/for-you', {
method: 'POST',
body: JSON.stringify({ action }),
});
}
document.getElementById('for-you-import').addEventListener('click', () =>
act(() => forYouAction('incremental-import')));
document.getElementById('for-you-full').addEventListener('click', () => {
if (!confirm('Backfill all Tracearr history and rebuild every active user pool?')) return;
act(() => forYouAction('full-import'));
});
document.getElementById('for-you-rebuild').addEventListener('click', () =>
act(() => forYouAction('rebuild-all')));
document.getElementById('recommendation-load').addEventListener('click', loadRecommendationInspection);
document.getElementById('request-save').addEventListener('click', () => {
const allowedUserIds = [...document.querySelectorAll('[data-request-user]:checked')]
.map((box) => box.dataset.requestUser);
act(() => api('/admin/api/request-policy', {
method: 'POST',
body: JSON.stringify({ allowedUserIds }),
}));
});
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 }),
})));
function featureAction(action, overrides = {}) {
return api('/admin/api/features', {
method: 'POST',
body: JSON.stringify({ action, expectedRevision: featureRevision, overrides }),
});
}
document.getElementById('feature-save').addEventListener('click', () => {
const overrides = {};
document.querySelectorAll('[data-feature-key]').forEach((select) => {
if (select.value === 'on') overrides[select.dataset.featureKey] = true;
if (select.value === 'off') overrides[select.dataset.featureKey] = false;
});
act(() => featureAction('save', overrides));
});
document.getElementById('feature-safe-mode').addEventListener('click', () => {
const leaving = document.getElementById('feature-safe-mode').textContent.startsWith('Leave');
if (!leaving && !confirm('Disable every optional feature immediately? Core sign-in, browsing and playback remain available.')) return;
act(() => featureAction(leaving ? 'leave-safe-mode' : 'safe-mode'));
});
document.getElementById('feature-rollback').addEventListener('click', () => {
if (!confirm('Restore the previous published feature revision?')) return;
act(() => featureAction('rollback'));
});
document.getElementById('feature-reset').addEventListener('click', () => {
if (!confirm('Clear every override and return all features to their safe software defaults?')) return;
act(() => featureAction('reset'));
});
document.getElementById('playback-save').addEventListener('click', () => {
const durationSeconds = Number(document.getElementById('preroll-duration').value);
if (!Number.isFinite(durationSeconds) || durationSeconds < 1 || durationSeconds > 30) {
showError('Preroll duration must be between 1 and 30 seconds.');
return;
}
act(() => api('/admin/api/playback-policy', {
method: 'POST',
body: JSON.stringify({
prerollEnabled: document.getElementById('preroll-enabled').checked,
prerollDurationMs: Math.round(durationSeconds * 1000),
}),
}));
});
document.getElementById('mdblist-save').addEventListener('click', () => {
const sources = [...document.querySelectorAll('[data-mdblist-source]:checked')]
.map((box) => box.dataset.mdblistSource);
act(() => api('/admin/api/mdblist-settings', {
method: 'POST',
body: JSON.stringify({
enabled: document.getElementById('mdblist-enabled').checked,
apiKey: document.getElementById('mdblist-api-key').value.trim(),
clearApiKey: document.getElementById('mdblist-clear-key').checked,
sources,
}),
}).then(() => {
document.getElementById('mdblist-api-key').value = '';
document.getElementById('mdblist-clear-key').checked = false;
}));
});
function updatePolicyBody(enabled) {
return JSON.stringify({
enabled,
latestVersion: document.getElementById('update-version').value.trim(),
downloadUrl: document.getElementById('update-url').value.trim(),
notes: document.getElementById('update-notes').value.trim(),
required: document.getElementById('update-required').checked,
});
}
document.getElementById('update-save').addEventListener('click', () => {
if (document.getElementById('update-required').checked &&
!confirm('Required updates block the home screen on every TV below this version. Continue?')) return;
act(() => api('/admin/api/update-policy', { method: 'POST', body: updatePolicyBody(true) }));
});
document.getElementById('update-disable').addEventListener('click', () =>
act(() => api('/admin/api/update-policy', { method: 'POST', body: updatePolicyBody(false) })));
document.getElementById('days').addEventListener('change', refresh);
const eventState = {
cursor: 0,
records: [],
dropped: 0,
paused: false,
fetching: false,
};
const eventRanks = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
function eventText(event) {
return [event.message, ...Object.entries(event.attributes || {}).flat()].join(' ').toLowerCase();
}
function renderEvents() {
const minimum = eventRanks[document.getElementById('event-level').value] || 20;
const search = document.getElementById('event-search').value.trim().toLowerCase();
const filtered = eventState.records.filter((event) =>
(eventRanks[event.level] || 0) >= minimum && (!search || eventText(event).includes(search)));
// Rendering every retained record at once can freeze a browser during an incident.
// Keep all records available for filtering/export, but virtualise the visible tail.
const visible = filtered.slice(-2500);
const log = document.getElementById('event-log');
const pinned = log.scrollHeight - log.scrollTop - log.clientHeight < 50;
log.innerHTML = visible.length ? visible.map((event) => {
const attrs = Object.entries(event.attributes || {})
.map(([key, value]) => escapeHtml(key) + '=' + escapeHtml(value)).join(' ');
return '<div class="event-line">' +
'<span class="event-time">' + escapeHtml(when(event.occurredAt)) + '</span>' +
'<span class="event-level ' + escapeHtml(event.level) + '">' + escapeHtml(event.level) + '</span>' +
'<span>' + escapeHtml(event.message) + '</span>' +
'<span class="event-attrs">' + attrs + '</span></div>';
}).join('') : '<div class="event-empty">No events match this filter.</div>';
if (pinned) log.scrollTop = log.scrollHeight;
document.getElementById('event-stats').textContent =
number(eventState.records.length) + ' retained · ' + number(filtered.length) + ' matching' +
(visible.length < filtered.length ? ' · showing latest ' + number(visible.length) : '') +
(eventState.dropped ? ' · ' + number(eventState.dropped) + ' overwritten before delivery' : '');
}
async function pollEvents() {
if (eventState.paused || eventState.fetching) return;
eventState.fetching = true;
try {
let pages = 0;
let page;
do {
page = await api('/admin/api/events?after=' + eventState.cursor + '&limit=1000');
eventState.cursor = page.next || eventState.cursor;
eventState.dropped += page.dropped || 0;
if ((page.events || []).length) {
eventState.records.push(...page.events);
if (eventState.records.length > 20000) {
eventState.records.splice(0, eventState.records.length - 20000);
}
}
pages += 1;
} while (page.hasMore && pages < 20 && !eventState.paused);
renderEvents();
showError('');
} catch (err) {
showError(err.message);
} finally {
eventState.fetching = false;
}
}
document.getElementById('event-pause').addEventListener('click', () => {
eventState.paused = !eventState.paused;
document.getElementById('event-pause').textContent = eventState.paused ? 'Resume' : 'Pause';
if (!eventState.paused) pollEvents();
});
document.getElementById('event-clear').addEventListener('click', () => {
eventState.records = [];
eventState.dropped = 0;
renderEvents();
});
document.getElementById('event-export').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(eventState.records, null, 2)], {type:'application/json'});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'memby-events-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json';
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
});
document.getElementById('event-level').addEventListener('change', renderEvents);
document.getElementById('event-search').addEventListener('input', renderEvents);
refresh();
setInterval(refresh, 5000);
if (currentAdminPage === 'logs') {
pollEvents();
setInterval(pollEvents, 5 * 60 * 1000);
}
</script>
</body>
</html>