Release v0.2.34
This commit is contained in:
@@ -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 ---------- */
|
||||
|
||||
@@ -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' }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user