Release v0.2.34

This commit is contained in:
ponzischeme89
2026-08-09 08:25:50 +12:00
parent b1128bcce2
commit fdd9e6cab2
116 changed files with 11418 additions and 879 deletions
+16
View File
@@ -303,6 +303,22 @@ select { padding-right: 8px; }
.checks { display: grid; gap: 8px; }
.checks.columns { grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); }
/* A theme, shown as the colours it actually is.
This is the one component whose colours are data rather than vocabulary, so the three
custom properties below are set from a style attribute on the element — the fragment
still declares no *look* of its own, only which palette this row is. Anything else about
how a swatch is drawn belongs here. */
.swatch {
flex: 0 0 auto; width: 46px; height: 30px; border-radius: var(--radius-sm);
border: 1px solid var(--line); overflow: hidden; display: flex; align-items: flex-end;
background: var(--swatch-surface, var(--surface));
}
.swatch i {
display: block; width: 100%; height: 9px;
background: var(--swatch-accent, var(--accent));
border-top: 1px solid var(--swatch-hairline, var(--line));
}
.hint { color: var(--muted); font-size: 12.5px; margin: 0; }
/* ---------- tags, notices ---------- */
+1
View File
@@ -152,6 +152,7 @@ const Admin = (() => {
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',
captions: 'M4 5.5h16v13H4zM7 11h3m2 0h5M7 15h5m3 0h2',
};
const icon = (name) => (icons[name]
@@ -46,6 +46,27 @@
</div>
</section>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="sparkle" data-icon-tone="note">Colour schemes</h2>
<p class="card-note">Which palettes this person may choose between in Settings →
Appearance. Tick everything to leave them unrestricted. Their current choice is
an ordinary setting above; withdrawing it here puts them back on Midnight.</p>
<p class="card-note">Seasonal themes are not listed. They apply to every television in
the house for their dates and nobody can decline one — the only switch is
<em>Seasonal themes</em> on the features page.</p>
</div>
<span id="account-themes-state"></span>
</div>
<div class="checks columns" id="account-themes"></div>
<div class="card-foot">
<button class="primary" data-account-action="save-themes">Save colour schemes</button>
<button data-account-action="all-themes">Allow all</button>
<span class="hint" id="account-themes-message"></span>
</div>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="alert" data-icon-tone="bad">Remove Memby access</h2>
@@ -11,6 +11,13 @@ const base = '/admin/api/accounts/' + encodeURIComponent(userId);
// endpoint that would return a slice of the same query.
let catalogue = [];
// The selectable themes, likewise carried by that endpoint rather than written out here.
let themeCatalogue = [];
// The same dirty rule the settings form follows, kept separate so saving one does not
// discard an unsaved edit to the other.
let themesDirty = false;
// True while the operator has edited the settings form without saving. The page polls every
// thirty seconds and a redraw would take a half-finished change away mid-sentence, so a
// dirty form keeps the DOM it already has until it is saved, discarded or reloaded.
@@ -115,6 +122,48 @@ function renderSettings(account) {
settingControl(definition, values[definition.key])).join('') + '</div></div>').join('');
}
/* ---- colour schemes ------------------------------------------------------ */
// The palette is written the way Android reads it, #AARRGGBB, and CSS reads #RRGGBBAA. The
// conversion lives here rather than on the wire because the television is the end that has
// to parse thousands of these and the console is the end that parses eight.
function cssColour(value) {
const hex = String(value || '').replace('#', '');
if (hex.length !== 8) return '#' + hex;
return '#' + hex.slice(2) + hex.slice(0, 2);
}
function themeRow(theme, allowed) {
const palette = theme.palette || {};
// Only the palette itself is set inline; see the .swatch note in admin.css.
const style = '--swatch-surface:' + cssColour(palette.surface) + ';' +
'--swatch-accent:' + cssColour(palette.accent) + ';' +
'--swatch-hairline:' + cssColour(palette.hairline);
return '<label class="check"><input type="checkbox" data-theme-id="' + fmt.escape(theme.id) +
'"' + (allowed ? ' checked' : '') + '>' +
'<span class="swatch" style="' + fmt.escape(style) + '"><i></i></span>' +
'<span>' + fmt.escape(theme.name) + '<em>' + fmt.escape(theme.description) +
'</em></span></label>';
}
function renderThemes(account) {
// An empty list from the server means unrestricted, so it draws as every box ticked.
// Storing "all" and "never configured" identically is deliberate — they are the same
// decision — and this is the one place an operator would notice if it were not.
const allowed = account.themes || [];
const unrestricted = allowed.length === 0;
$('account-themes-state').innerHTML = unrestricted
? ui.tag('all schemes', 'idle')
: ui.tag(fmt.number(allowed.length) + ' of ' + fmt.number(themeCatalogue.length), 'note');
$('account-themes').innerHTML = themeCatalogue.map((theme) =>
themeRow(theme, unrestricted || allowed.includes(theme.id))).join('');
}
function collectThemes() {
return Array.from($('account-themes').querySelectorAll('input:checked'))
.map((input) => input.dataset.themeId);
}
/* ---- the rest of the page ---------------------------------------------- */
// Every build this set has been seen running, newest first and the current one flagged.
@@ -199,6 +248,7 @@ Admin.onRefresh(async () => {
$('account-settings-history').href = '/admin/accounts/' + encodeURIComponent(userId) + '/settings';
const payload = await Admin.api('/admin/api/accounts');
catalogue = payload.catalogue || catalogue;
themeCatalogue = payload.themes || themeCatalogue;
const account = (payload.accounts || []).find((entry) => entry.id === userId);
if (!account) {
$('account-identity').innerHTML =
@@ -209,13 +259,21 @@ Admin.onRefresh(async () => {
renderDevices(account);
renderRecommendations(account);
if (!dirty) renderSettings(account);
if (!themesDirty) renderThemes(account);
});
/* ---- actions ------------------------------------------------------------ */
function message(text) { $('account-settings-message').textContent = text || ''; }
function themeMessage(text) { $('account-themes-message').textContent = text || ''; }
document.addEventListener('input', (event) => {
if ($('account-themes').contains(event.target)) {
themesDirty = true;
themeMessage('unsaved changes');
return;
}
if (!$('account-settings').contains(event.target)) return;
dirty = true;
message('unsaved changes');
@@ -270,6 +328,28 @@ document.addEventListener('click', (event) => {
message('pushed');
});
}
if (action === 'save-themes') {
const themes = collectThemes();
if (!themes.length &&
!confirm('Allow this person no colour schemes? They will be left on Midnight with ' +
'nothing to choose between.')) return;
themeMessage('saving…');
Admin.act(async () => {
await Admin.api(base + '/themes', {
method: 'PUT', body: JSON.stringify({ themes }),
});
// Cleared before the refresh so the boxes are redrawn from what was stored, which is
// how "every box ticked" comes back as the unrestricted state rather than as a list.
themesDirty = false;
themeMessage('saved');
});
}
if (action === 'all-themes') {
$('account-themes').querySelectorAll('input[data-theme-id]')
.forEach((input) => { input.checked = true; });
themesDirty = true;
themeMessage('unsaved changes');
}
if (action === 'reload-preferences') {
dirty = false;
message('');
@@ -0,0 +1,47 @@
<div class="tiles" id="searches-tiles"></div>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="search" data-icon-tone="info">What the house looks for</h2>
<p class="card-note">Queries the search tab ran, grouped without regard to case and
labelled with the most recent spelling. Instant search asks from the second
character, so a title typed slowly leaves its prefixes here too.</p>
</div>
<label class="field narrow"><span>Window</span>
<select id="searches-days">
<option value="1">24 hours</option>
<option value="7" selected>7 days</option>
<option value="30">30 days</option>
</select></label>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>Query</th>
<th class="num">Searches</th>
<th class="num">Viewers</th>
<th>Last searched</th>
</tr></thead>
<tbody id="searches-terms"></tbody>
</table>
</div>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="history" data-icon-tone="note">As it happened</h2>
<p class="card-note">The log, newest first — the query exactly as it was typed, and who
typed it. This is the one to read when somebody says search is not finding something.</p>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>When</th>
<th>Viewer</th>
<th>Query</th>
</tr></thead>
<tbody id="searches-recent"></tbody>
</table>
</div>
</section>
@@ -0,0 +1,37 @@
const { fmt, ui, $ } = Admin;
Admin.onRefresh(async () => {
const payload = await Admin.api('/admin/api/searches?days=' + $('searches-days').value);
const terms = payload.terms || [];
const recent = payload.recent || [];
const totals = payload.totals || {};
$('searches-tiles').innerHTML = ui.tiles([
['searches', fmt.number(totals.searches), { icon: 'search', tone: 'info' }],
['distinct queries', fmt.number(totals.queries), { icon: 'list', tone: 'data' }],
['viewers searching', fmt.number(totals.viewers), { icon: 'people', tone: 'note' }],
// Stated rather than assumed: every figure on this page is bounded by how long the
// table keeps a row, and an operator reading a quiet week has no other way to tell a
// household that stopped searching from one whose history has aged out.
['history kept', payload.retentionDays + ' days', { small: true, icon: 'clock' }],
]);
$('searches-terms').innerHTML = terms.length ? terms.map((term) =>
'<tr><td>' + fmt.escape(term.query) + '</td>' +
'<td class="num">' + fmt.number(term.searches) + '</td>' +
'<td class="num">' + fmt.number(term.viewers) + '</td>' +
'<td class="muted">' + fmt.when(term.lastAt) + '</td></tr>').join('')
: ui.emptyRow(4, 'Nothing searched in this window.');
// An unattributed search keeps its row and shows the id: the query is the point, and a
// viewer whose sessions have all expired is still one searcher rather than nobody.
$('searches-recent').innerHTML = recent.length ? recent.map((event) =>
'<tr><td class="muted">' + fmt.when(event.occurredAt) + '</td>' +
'<td>' + (event.username
? fmt.escape(event.username)
: ui.tag(event.userId || 'unknown', 'warn')) + '</td>' +
'<td>' + fmt.escape(event.query) + '</td></tr>').join('')
: ui.emptyRow(3, 'No searches in this window.');
});
Admin.ready(() => $('searches-days').addEventListener('change', Admin.refresh));
@@ -0,0 +1,84 @@
<div class="tiles" id="subtitle-tiles"></div>
<div class="grid two">
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="wrench" data-icon-tone="data">Bazarr</h2>
<p class="card-note">Bazarr writes the subtitle file beside the media file, so Emby
finds it and the track behaves like one that was always there. Its address is
deployment configuration; this switch only decides whether viewers may use it.</p>
</div>
<span id="bazarr-state"></span>
</div>
<label class="check">
<input type="checkbox" id="bazarr-enabled">
<span>Offer Bazarr in the player<em>Off leaves every subtitle it has already
written in place.</em></span>
</label>
<p class="hint" id="bazarr-address"></p>
</section>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="captions" data-icon-tone="note">OpenSubtitles</h2>
<p class="card-note">OpenSubtitles hands back a file rather than writing one, so
Memby keeps what it fetches and serves it to the television itself. Titles are
matched on their IMDb or TMDb id, which is exact — there is no guessing at a name.</p>
</div>
<span id="opensubtitles-state"></span>
</div>
<label class="check">
<input type="checkbox" id="opensubtitles-enabled">
<span>Offer OpenSubtitles in the player<em>Needs an API key. It cannot be
switched on without one.</em></span>
</label>
<label class="field"><span>API key</span>
<em>From your consumer at opensubtitles.com. Leave blank to keep the saved key.</em>
<input type="password" id="opensubtitles-key" autocomplete="new-password"
placeholder="Paste an API key"></label>
<label class="check">
<input type="checkbox" id="opensubtitles-clear-key"><span>Remove the saved key</span>
</label>
<div class="fields">
<label class="field"><span>Account username</span>
<em>Optional, and the difference between a working feature and one that stops
after a few files: without an account, downloads come out of the small
anonymous allowance.</em>
<input type="text" id="opensubtitles-username" autocomplete="off"
placeholder="Not signed in"></label>
<label class="field"><span>Account password</span>
<em>Leave blank to keep the saved one.</em>
<input type="password" id="opensubtitles-password" autocomplete="new-password"></label>
</div>
<label class="check">
<input type="checkbox" id="opensubtitles-clear-login"><span>Sign out and forget the account</span>
</label>
</section>
</div>
<section class="card">
<div class="row">
<button class="primary" id="subtitle-save">Save subtitle settings</button>
<button id="subtitle-test">Test the providers</button>
<span class="hint" id="subtitle-hint"></span>
</div>
<div id="subtitle-test-results"></div>
</section>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="database" data-icon-tone="data">Subtitles Memby is holding</h2>
<p class="card-note">Only files fetched from a provider that cannot write beside the
media file are kept here; they are served to televisions as ordinary tracks on every
later playback. Emptying this is safe — each one can be fetched again, at the cost of
the download allowance that fetched it.</p>
</div>
<span id="stored-state"></span>
</div>
<div class="card-foot">
<button id="stored-clear">Delete every stored subtitle</button>
</div>
</section>
@@ -0,0 +1,98 @@
const { fmt, ui, $ } = Admin;
Admin.onStatus((status) => {
const subtitles = status.subtitles || {};
const stored = subtitles.stored || {};
$('subtitle-tiles').innerHTML = ui.tiles([
['offered on televisions', subtitles.available ? 'yes' : 'no',
{ small: true, icon: 'captions', tone: subtitles.available ? 'ok' : undefined }],
['providers on',
fmt.number((subtitles.bazarrEnabled && subtitles.bazarrConfigured ? 1 : 0) +
(subtitles.openSubtitlesEnabled ? 1 : 0)),
{ icon: 'list', tone: 'note' }],
['subtitles held', fmt.number(stored.count), { icon: 'database', tone: 'data' }],
['last fetched', fmt.when(stored.latest), { small: true, icon: 'clock' }],
]);
Admin.check($('bazarr-enabled'), subtitles.bazarrEnabled);
$('bazarr-enabled').disabled = !subtitles.bazarrConfigured;
$('bazarr-state').innerHTML = !subtitles.bazarrConfigured
? ui.tag('not configured', 'idle')
: (subtitles.bazarrEnabled ? ui.tag('on', 'ok') : ui.tag('off', 'idle'));
// The address is worth printing: it is the one thing on this page an operator cannot
// change here, so seeing which Bazarr is meant is how they find out it is the wrong one.
$('bazarr-address').textContent = subtitles.bazarrConfigured
? 'Configured at ' + subtitles.bazarrUrl
: 'Set MEMBY_BAZARR_URL and MEMBY_BAZARR_API_KEY to use Bazarr.';
Admin.check($('opensubtitles-enabled'), subtitles.openSubtitlesEnabled);
const keyField = $('opensubtitles-key');
keyField.placeholder = subtitles.openSubtitlesKeyConfigured
? 'Saved key (leave blank to keep)' : 'Paste an API key';
Admin.fill($('opensubtitles-username'), subtitles.openSubtitlesUsername || '');
$('opensubtitles-state').innerHTML = subtitles.openSubtitlesEnabled
? ui.tag(subtitles.openSubtitlesAccount ? 'on · signed in' : 'on · anonymous',
subtitles.openSubtitlesAccount ? 'ok' : 'warn')
: ui.tag(subtitles.openSubtitlesKeyConfigured ? 'off · key saved' : 'off · no key', 'idle');
// The feature flag overrides both switches, so a page that stayed silent about it would
// be showing two controls that visibly do nothing.
$('subtitle-hint').textContent = subtitles.featureEnabled
? 'A change applies to the next title opened; no app release is required.'
: 'Downloading subtitles is switched off on the Features page, so nothing here is offered.';
$('stored-state').innerHTML = stored.count
? ui.tag(fmt.number(stored.count) + ' files · ' + fmt.bytes(stored.bytes), 'data')
: ui.tag('nothing held', 'idle');
$('stored-clear').disabled = !stored.count;
});
const save = () => Admin.api('/admin/api/subtitle-settings', {
method: 'POST',
body: JSON.stringify({
bazarrEnabled: $('bazarr-enabled').checked,
openSubtitlesEnabled: $('opensubtitles-enabled').checked,
openSubtitlesApiKey: $('opensubtitles-key').value.trim(),
clearOpenSubtitlesApiKey: $('opensubtitles-clear-key').checked,
openSubtitlesUsername: $('opensubtitles-username').value.trim(),
openSubtitlesPassword: $('opensubtitles-password').value,
clearOpenSubtitlesLogin: $('opensubtitles-clear-login').checked,
}),
}).then(() => {
// The credential fields are emptied on the way out, so a saved page never has a secret
// sitting in a form somebody could walk past.
$('opensubtitles-key').value = '';
$('opensubtitles-password').value = '';
$('opensubtitles-clear-key').checked = false;
$('opensubtitles-clear-login').checked = false;
});
Admin.ready(() => {
$('subtitle-save').addEventListener('click', () => Admin.act(save));
$('subtitle-test').addEventListener('click', () => {
const results = $('subtitle-test-results');
results.innerHTML = ui.empty('Asking each provider…');
Admin.api('/admin/api/subtitle-test', { method: 'POST' }).then((answer) => {
const rows = answer.results || [];
results.innerHTML = rows.length
? '<div class="list">' + rows.map((row) =>
'<div class="list-row"><span class="list-main"><span>' +
'<span class="list-title">' + fmt.escape(row.provider) + '</span>' +
'<span class="list-meta">' + fmt.escape(row.message) + '</span></span></span>' +
'<span class="list-actions">' + ui.tag(row.ok ? 'reachable' : 'not reachable',
row.ok ? 'ok' : 'bad') + '</span></div>').join('') + '</div>'
: ui.empty('No provider is switched on, so there was nothing to ask.');
}).catch((error) => {
results.innerHTML = ui.empty(String(error.message || error));
});
});
$('stored-clear').addEventListener('click', () => {
if (!confirm('Delete every subtitle Memby is holding? Each can be fetched again.')) return;
Admin.act(() => Admin.api('/admin/api/subtitle-settings', {
method: 'POST', body: JSON.stringify({ action: 'clear-stored' }),
}));
});
});