Publish current app and server

This commit is contained in:
ponzischeme89
2026-08-02 22:10:19 +12:00
parent a265636139
commit 1ed180c739
203 changed files with 23933 additions and 2788 deletions
+584 -23
View File
@@ -7,19 +7,75 @@
<style>
:root {
color-scheme: dark;
--bg: #0b0e11; --panel: #151a20; --line: #232a32;
--text: #e6eaee; --muted: #97a1ab; --accent: #52b54b; --danger: #e5534b;
--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; padding: 28px; background: var(--bg); color: var(--text);
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;
}
main { width: 100%; margin: 0; display: grid; gap: 20px; }
h1 { font-size: 24px; margin: 0; }
.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: center; gap: 14px; flex-wrap: wrap; }
section { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px; }
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;
@@ -27,7 +83,7 @@
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=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;
}
@@ -68,23 +124,133 @@
.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; }
.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) {
body { padding:14px; }
: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; }
}
@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; }
}
@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/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/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>
<h1>Memby admin</h1>
<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>
<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">
@@ -94,7 +260,7 @@
</div>
</section>
<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">
@@ -103,9 +269,45 @@
<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>
<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
@@ -119,7 +321,52 @@
</div>
</section>
<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="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;
@@ -142,7 +389,7 @@
</div>
</section>
<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>
@@ -167,7 +414,7 @@
</div>
</section>
<section>
<section id="imports" data-admin-page="imports">
<h2>Recent imports</h2>
<div class="scroll">
<table>
@@ -182,7 +429,7 @@
</div>
</section>
<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">
@@ -204,6 +451,33 @@
</main>
<script>
const railLinks = [...document.querySelectorAll('.rail-link[data-section]')];
const currentAdminPage = location.pathname.split('/').filter(Boolean).pop() || 'library';
const pageCopy = {
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.'],
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 || '';
@@ -242,6 +516,65 @@ const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) =>
'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;',
}[char]));
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();
@@ -273,6 +606,34 @@ function renderStatus(status) {
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';
@@ -280,6 +641,23 @@ function renderStatus(status) {
const messageField = document.getElementById('maintenance-message');
if (document.activeElement !== messageField) messageField.value = maintenance.message || '';
renderFeatures(status.features || {}, status.clients || []);
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;
@@ -337,21 +715,145 @@ function renderAnalytics(payload) {
: '<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] = await Promise.all([
api('/admin/api/status'),
api('/admin/api/analytics?days=' + document.getElementById('days').value),
currentAdminPage === 'engagement'
? api('/admin/api/analytics?days=' + document.getElementById('days').value)
: Promise.resolve(null),
]);
renderStatus(status);
renderAnalytics(analytics);
live.textContent = 'updated ' + new Date().toLocaleTimeString();
if (analytics) renderAnalytics(analytics);
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);
}
}
@@ -390,6 +892,16 @@ document.getElementById('for-you-full').addEventListener('click', () => {
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;
@@ -405,6 +917,53 @@ document.getElementById('maintenance-off').addEventListener('click', () =>
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),
}),
}));
});
function updatePolicyBody(enabled) {
return JSON.stringify({
enabled,
@@ -515,8 +1074,10 @@ document.getElementById('event-search').addEventListener('input', renderEvents);
refresh();
setInterval(refresh, 5000);
pollEvents();
setInterval(pollEvents, 5 * 60 * 1000);
if (currentAdminPage === 'logs') {
pollEvents();
setInterval(pollEvents, 5 * 60 * 1000);
}
</script>
</body>
</html>