Files
memby/server/internal/api/admin/core.js
T
ponzischeme89andClaude Opus 5 4a4df7a73c App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the
player; MDBList ratings strip; episode and schedule detail pages; series
pace estimate; what's new panel; install-permission onboarding step;
synced per-profile preferences; Emby outage banner.

Gateway: rebuilt admin console (one fragment per page), preference
history and restore, merged Continue Watching, Emby health probe,
subtitle selection and Bazarr download, structured request logging with
per-request identity, and embedded build version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:33:56 +12:00

290 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* The console's shared runtime: one HTTP client, one error banner, one refresh loop and
the handful of formatters and markup helpers every page draws with. A page fragment
should contain the decisions that are its own and nothing else — if two pages need the
same piece of markup it belongs here, beside `ui`.
Pages plug into it rather than reaching around it:
Admin.onStatus(fn) fn(status) on every /admin/api/status poll
Admin.onRefresh(fn) an async task run alongside that poll
Admin.ready(fn) once, after the page has parsed
Admin.act(fn) run a mutation, then refresh, reporting failure in the banner */
const Admin = (() => {
const page = document.querySelector('[data-admin-page]').dataset.adminPage;
const statusHandlers = [];
const refreshTasks = [];
/* ---- transport ------------------------------------------------------- */
// The sign-in behind this page lasts thirty minutes and slides forward only for requests
// an operator actually caused, so the poll of a tab nobody is reading cannot keep it
// alive. Anything the console does while somebody is working it says so with this
// header; see operatorPresent on the server.
const ACTIVITY_WINDOW_MS = 5 * 60 * 1000;
let lastInteraction = Date.now();
for (const name of ['pointerdown', 'pointermove', 'keydown', 'wheel', 'scroll']) {
window.addEventListener(name, () => { lastInteraction = Date.now(); }, { passive: true });
}
// A 401 is an expired sign-in rather than a wrong token. Reloading re-renders this URL as
// the login form with `next` pointing back at it, so the operator signs in once and lands
// where they were, instead of reading a banner the page can never clear. The timestamp is
// what stops a 401 that survives the reload from looping.
const RELOGIN_KEY = 'memby-admin-relogin';
function reauthenticate() {
try {
if (Date.now() - Number(sessionStorage.getItem(RELOGIN_KEY) || 0) < 30000) return false;
sessionStorage.setItem(RELOGIN_KEY, String(Date.now()));
} catch (err) {
// Private-mode storage refusals must not cost the reload; take the loop risk.
}
window.location.reload();
return true;
}
async function api(path, options = {}) {
const active = Date.now() - lastInteraction < ACTIVITY_WINDOW_MS;
const response = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
...(active ? { 'X-Memby-Admin-Active': '1' } : {}),
...(options.headers || {}),
},
});
if (response.status === 401) {
throw new Error(reauthenticate()
? 'Your sign-in has expired. Signing in again…'
: 'Your sign-in has expired. Reload this page to sign in again.');
}
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();
}
/* ---- formatting ------------------------------------------------------ */
const escape = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[char]));
const number = (value) => (value ?? 0).toLocaleString();
const when = (value) => (value ? new Date(value).toLocaleString() : '—');
const time = (value) => (value ? new Date(value).toLocaleTimeString() : '—');
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';
}
function bytes(value) {
const units = ['B', 'KB', 'MB', 'GB'];
let amount = Number(value || 0);
let unit = 0;
while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit += 1; }
return (unit === 0 ? amount : amount.toFixed(1)) + ' ' + units[unit];
}
const initials = (name) => String(name || '?').trim().split(/\s+/).slice(0, 2)
.map((part) => part[0] || '').join('').toUpperCase();
// "Seen in the last quarter of an hour" is what the console means by active: a television
// checks in every few seconds while somebody is using it.
const ACTIVE_MS = 15 * 60 * 1000;
const IDLE_MS = 7 * 24 * 60 * 60 * 1000;
const recent = (value) => Boolean(value) && Date.now() - new Date(value).getTime() < ACTIVE_MS;
// Three states rather than two, because "not active this minute" covers both a set
// somebody switched off after breakfast and one that has not been seen since a firmware
// update in March — and only the second is worth an operator's attention. Green is on
// now, amber is a set in ordinary use that happens to be off, red is one that has
// stopped checking in. A device with no timestamp at all is red: never seen is the
// strongest version of not seen.
function presence(value) {
const seen = value ? new Date(value).getTime() : 0;
if (!seen) return { tone: 'bad', label: 'never seen' };
const age = Date.now() - seen;
if (age < ACTIVE_MS) return { tone: 'ok', label: 'active now' };
if (age < IDLE_MS) return { tone: 'warn', label: 'seen recently' };
return { tone: 'bad', label: 'not seen lately' };
}
const fmt = { escape, number, when, time, duration, bytes, initials, recent, presence };
/* ---- markup components ----------------------------------------------- */
/* Icons live here and nowhere else. Each is the `d` of one stroked path on a 24×24 grid,
the same shape the rail's marks take, so a page never carries SVG markup of its own and
two screens showing the same idea cannot draw it two ways. A fragment asks for one by
writing data-icon="…" on any element; a script asks with ui.icon(). An unknown name
draws nothing rather than a broken box — a mark is decoration, and a typo in one must
never be what an operator notices about a page. */
const icons = {
library: 'M3 5h18v14H3zM7 5v14M17 5v14M3 9.5h4M3 14.5h4M17 9.5h4M17 14.5h4',
people: 'M15 19v-1.2a3.3 3.3 0 0 0-3.3-3.3H6.8A3.3 3.3 0 0 0 3.5 17.8V19M9.2 11a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4ZM17 10.6a3 3 0 0 0-1.4-5.7M20.5 19v-1.2a3.3 3.3 0 0 0-2.4-3.2',
person: 'M18 20v-1.5a4 4 0 0 0-4-4h-4a4 4 0 0 0-4 4V20M12 10.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z',
tv: 'M4 5h16v10H4zM9 19h6M12 15v4M8 2.5 12 5l4-2.5',
sliders: 'M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6',
clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM12 7.2V12l3 1.8',
pulse: 'M3 12h3.5L9 19l5-14 2.5 7H21',
chart: 'M4 19V9m5 10V5m5 14v-7m5 7V3',
chip: 'M8 8h8v8H8zM4.5 4.5h15v15h-15zM9 2v2.5M15 2v2.5M9 19.5V22M15 19.5V22M2 9h2.5M2 15h2.5M19.5 9H22M19.5 15H22',
database: 'M12 8.2c4.4 0 8-1.2 8-2.6S16.4 3 12 3 4 4.2 4 5.6s3.6 2.6 8 2.6ZM4 5.6v12.8C4 19.8 7.6 21 12 21s8-1.2 8-2.6V5.6M4 12c0 1.4 3.6 2.6 8 2.6s8-1.2 8-2.6',
download: 'M12 3.5v10m0 0 4-4m-4 4-4-4M4.5 18h15',
sync: 'M3.5 12a8.5 8.5 0 0 1 14.6-6M20.5 12a8.5 8.5 0 0 1-14.6 6M18 2.5V6h-3.5M6 21.5V18h3.5',
search: 'M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Zm4.6-1.9L20 20',
star: 'm12 3.2 2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.4l5.9-.8L12 3.2Z',
sparkle: 'm10 3 1.5 4.3L16 8.8l-4.5 1.5L10 14.6 8.5 10.3 4 8.8l4.5-1.5L10 3ZM17.5 14l.9 2.4 2.6.9-2.6.9-.9 2.4-.9-2.4-2.6-.9 2.6-.9.9-2.4Z',
bell: 'M6.2 9.5a5.8 5.8 0 1 1 11.6 0c0 4.6 2.2 5.9 2.2 5.9H4s2.2-1.3 2.2-5.9M10 19.5a2 2 0 0 0 4 0',
shield: 'm12 3 7.5 3v5.4c0 5-3.2 8.2-7.5 9.6-4.3-1.4-7.5-4.6-7.5-9.6V6L12 3Zm-2.6 8.7 1.9 1.9 3.6-3.6',
wrench: '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',
play: 'M8 5.2v13.6L19 12 8 5.2ZM4 5v14',
list: 'M4 7h16M4 12h16M4 17h10',
inbox: 'M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5',
history: 'M3.5 12a8.5 8.5 0 1 0 2.8-6.3M3.5 4v4h4M12 7.5V12l3 1.8',
check: 'm5 12.5 4.5 4.5L19 7.5',
alert: 'M12 8.5v5m0 3.2h.01M10.3 4.4 2.7 17.5a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 4.4a2 2 0 0 0-3.4 0Z',
power: 'M12 3v9M7.5 6.2a7.5 7.5 0 1 0 9 0',
key: 'M14.5 3a6.5 6.5 0 1 0 3.4 12L19 14h2v-2h2V9.5l-2.5-2.5A6.5 6.5 0 0 0 14.5 3Zm-2.6 4.6a1.6 1.6 0 1 1-2.3 2.3 1.6 1.6 0 0 1 2.3-2.3Z',
};
const icon = (name) => (icons[name]
? '<svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><path d="' + icons[name] + '"/></svg>'
: '');
// An icon in a tinted plate — the tile and card-heading mark. The tone is what says which
// area a thing belongs to, so it is passed rather than derived.
const glyph = (name, tone) => (icons[name]
? '<span class="glyph"' + (tone ? ' data-tone="' + tone + '"' : '') + '>' + icon(name) + '</span>'
: '');
const ui = {
icon,
glyph,
// [label, value, options?] → the tile strip used at the top of most pages. The options
// are { small, icon, tone }: `small` for a value that is a sentence rather than a
// number, the other two for the mark above it.
tiles: (entries) => entries.map(([label, value, options]) => {
const opts = typeof options === 'object' && options !== null ? options : { small: options };
return '<div class="tile">' + (opts.icon ? glyph(opts.icon, opts.tone) : '') +
'<b' + (opts.small ? ' class="small"' : '') + '>' + escape(String(value)) +
'</b><span>' + escape(label) + '</span></div>';
}).join(''),
tag: (label, tone) => '<span class="tag"' + (tone ? ' data-tone="' + tone + '"' : '') +
'>' + escape(label) + '</span>',
chip: (label, tone) => '<span class="chip"' + (tone ? ' data-tone="' + tone + '"' : '') +
'>' + escape(label) + '</span>',
empty: (message) => '<p class="empty">' + escape(message) + '</p>',
emptyRow: (columns, message) => '<tr><td colspan="' + columns + '" class="muted">' +
escape(message) + '</td></tr>',
};
/* ---- page plumbing --------------------------------------------------- */
const $ = (id) => document.getElementById(id);
// `data-icon` (with an optional `data-icon-tone`) on any element is how a fragment asks
// for a mark without writing SVG. It is applied once, when the page has parsed, because
// what carries it is the static markup a fragment ships — anything a poll redraws asks
// with ui.glyph instead, or the mark would be wiped on the first refresh. The attribute
// is consumed, so running this again over the same tree cannot double the icon.
function decorate(root = document) {
for (const element of root.querySelectorAll('[data-icon]')) {
element.insertAdjacentHTML('afterbegin', glyph(element.dataset.icon, element.dataset.iconTone));
delete element.dataset.icon;
}
}
function error(message) {
const banner = $('error');
banner.textContent = message || '';
banner.hidden = !message;
}
// Never redraw markup the operator is working inside. Every poll would otherwise take a
// half-typed field, an open select or a scrolled list away mid-edit.
const settled = (element) => element && !element.contains(document.activeElement);
// The same rule for a single control: fill it in unless it is the one being used.
function fill(element, value) {
if (element && document.activeElement !== element) element.value = value;
return element;
}
function check(element, value) {
if (element && document.activeElement !== element) element.checked = Boolean(value);
return element;
}
const onStatus = (fn) => statusHandlers.push(fn);
const onRefresh = (fn) => refreshTasks.push(fn);
const ready = (fn) => (document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', fn) : fn());
function live(ok, label) {
const tag = $('live');
tag.textContent = label;
tag.dataset.tone = ok ? 'ok' : 'bad';
$('rail-live').dataset.tone = ok ? 'ok' : 'bad';
$('rail-live-label').textContent = ok ? 'online' : 'unreachable';
}
async function refresh() {
try {
const status = await api('/admin/api/status');
$('rail-version').textContent = 'gateway ' + (status.serverVersion || 'unknown');
statusHandlers.forEach((handler) => handler(status));
await Promise.all(refreshTasks.map((task) => task()));
live(true, 'updated ' + new Date().toLocaleTimeString());
error('');
} catch (err) {
live(false, 'not responding');
error(err.message);
}
}
async function act(fn) {
try {
await fn();
await refresh();
} catch (err) {
error(err.message);
}
}
/* ---- refresh loop ----------------------------------------------------- */
// Someone leaves this open on a second monitor, which makes its poll the gateway's most
// frequent caller by a wide margin. It stops entirely on a hidden tab and catches up the
// moment the tab is looked at again — a background tab nobody is reading has no status
// worth fetching.
const REFRESH_MS = 30000;
let timer = null;
function schedule() {
clearInterval(timer);
timer = document.hidden ? null : setInterval(refresh, REFRESH_MS);
}
document.addEventListener('visibilitychange', () => {
schedule();
if (!document.hidden) refresh();
});
ready(() => { decorate(); refresh(); schedule(); });
return {
page, api, fmt, ui, $, error, settled, fill, check,
onStatus, onRefresh, ready, refresh, act, decorate,
};
})();