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>
290 lines
13 KiB
JavaScript
290 lines
13 KiB
JavaScript
/* 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) => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||
}[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,
|
||
};
|
||
})();
|