1162 lines
57 KiB
HTML
1162 lines
57 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Memby admin</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--bg: #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; }
|
|
.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; }
|
|
}
|
|
@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/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="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 = {
|
|
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) => ({
|
|
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
|
}[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();
|
|
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] = await Promise.all([
|
|
api('/admin/api/status'),
|
|
currentAdminPage === 'engagement'
|
|
? api('/admin/api/analytics?days=' + document.getElementById('days').value)
|
|
: Promise.resolve(null),
|
|
]);
|
|
renderStatus(status);
|
|
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);
|
|
}
|
|
}
|
|
|
|
async function act(fn) {
|
|
try {
|
|
await fn();
|
|
await refresh();
|
|
} catch (err) {
|
|
showError(err.message);
|
|
}
|
|
}
|
|
|
|
document.getElementById('sync-incremental').addEventListener('click', () =>
|
|
act(() => api('/admin/api/sync', { method: 'POST', body: JSON.stringify({ kind: 'incremental' }) })));
|
|
|
|
document.getElementById('sync-full').addEventListener('click', () => {
|
|
if (!confirm('Re-import the entire library? This can take several minutes.')) return;
|
|
act(() => api('/admin/api/sync', { method: 'POST', body: JSON.stringify({ kind: 'full' }) }));
|
|
});
|
|
|
|
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>
|