Release v0.2.34
This commit is contained in:
@@ -53,8 +53,10 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
s.adminAuth(s.handleAdminRestorePreferences))
|
||||
mux.Handle("DELETE /admin/api/accounts/{userID}/recommendations", s.adminAuth(s.handleAdminResetRecommendations))
|
||||
mux.Handle("PUT /admin/api/accounts/{userID}/recommendations/prompt", s.adminAuth(s.handleAdminPromptRecommendations))
|
||||
mux.Handle("PUT /admin/api/accounts/{userID}/themes", s.adminAuth(s.handleAdminUserThemes))
|
||||
mux.Handle("GET /admin/api/recommendations", s.adminAuth(s.handleAdminRecommendations))
|
||||
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
|
||||
mux.Handle("GET /admin/api/searches", s.adminAuth(s.handleAdminSearches))
|
||||
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
|
||||
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
|
||||
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
|
||||
@@ -65,6 +67,8 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
|
||||
mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
|
||||
mux.Handle("POST /admin/api/mdblist-settings", s.adminAuth(s.handleAdminMDBListSettings))
|
||||
mux.Handle("POST /admin/api/subtitle-settings", s.adminAuth(s.handleAdminSubtitleSettings))
|
||||
mux.Handle("POST /admin/api/subtitle-test", s.adminAuth(s.handleAdminSubtitleTest))
|
||||
mux.Handle("POST /admin/api/features", s.adminAuth(s.handleAdminFeaturePolicy))
|
||||
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
|
||||
|
||||
@@ -229,23 +233,24 @@ func (s *Server) serveAdminPage(w http.ResponseWriter, r *http.Request, page, ne
|
||||
type adminStatus struct {
|
||||
// ServerVersion is what the page's footer reports. An operator reading the live log
|
||||
// needs to know which build wrote it, and the page is the one place that is asked.
|
||||
ServerVersion string `json:"serverVersion"`
|
||||
Maintenance store.Maintenance `json:"maintenance"`
|
||||
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
|
||||
Library store.LibraryStats `json:"library"`
|
||||
SyncRunning bool `json:"syncRunning"`
|
||||
Runs []store.SyncRun `json:"runs"`
|
||||
SyncEvery string `json:"syncEvery"`
|
||||
ForYou store.ForYouStats `json:"forYou"`
|
||||
ForYouRunning bool `json:"forYouRunning"`
|
||||
RequestPolicy store.RequestPolicy `json:"requestPolicy"`
|
||||
PlaybackPolicy store.PlaybackPolicy `json:"playbackPolicy"`
|
||||
MDBList mdblistAdminSettings `json:"mdblist"`
|
||||
Features featureResponse `json:"features"`
|
||||
RequestUsers []store.KnownUser `json:"requestUsers"`
|
||||
Clients []store.KnownClient `json:"clients"`
|
||||
SonarrReady bool `json:"sonarrReady"`
|
||||
RadarrReady bool `json:"radarrReady"`
|
||||
ServerVersion string `json:"serverVersion"`
|
||||
Maintenance store.Maintenance `json:"maintenance"`
|
||||
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
|
||||
Library store.LibraryStats `json:"library"`
|
||||
SyncRunning bool `json:"syncRunning"`
|
||||
Runs []store.SyncRun `json:"runs"`
|
||||
SyncEvery string `json:"syncEvery"`
|
||||
ForYou store.ForYouStats `json:"forYou"`
|
||||
ForYouRunning bool `json:"forYouRunning"`
|
||||
RequestPolicy store.RequestPolicy `json:"requestPolicy"`
|
||||
PlaybackPolicy store.PlaybackPolicy `json:"playbackPolicy"`
|
||||
MDBList mdblistAdminSettings `json:"mdblist"`
|
||||
Subtitles subtitleAdminSettings `json:"subtitles"`
|
||||
Features featureResponse `json:"features"`
|
||||
RequestUsers []store.KnownUser `json:"requestUsers"`
|
||||
Clients []store.KnownClient `json:"clients"`
|
||||
SonarrReady bool `json:"sonarrReady"`
|
||||
RadarrReady bool `json:"radarrReady"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -304,6 +309,7 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return policy
|
||||
}(),
|
||||
Subtitles: s.subtitleAdminSettings(ctx),
|
||||
Features: featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion),
|
||||
RequestUsers: requestUsers,
|
||||
Clients: clients,
|
||||
|
||||
@@ -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' }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,11 @@ type adminMembyAccount struct {
|
||||
// the defaults, and saying so is the difference between "chose this" and "has not
|
||||
// chosen anything".
|
||||
Settings adminAccountSettings `json:"settings"`
|
||||
// Themes is the ids this person may choose between, and an empty array means every
|
||||
// selectable theme rather than none — the same permissive reading the store and
|
||||
// themeAllowed take. The console renders that as every box ticked, which is what an
|
||||
// operator who has never touched the page should see.
|
||||
Themes []string `json:"themes"`
|
||||
}
|
||||
|
||||
type adminAccountSettings struct {
|
||||
@@ -87,6 +92,14 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
settings = map[string]store.UserPreferences{}
|
||||
}
|
||||
|
||||
themes, err := s.store.AllUserThemes(r.Context())
|
||||
if err != nil {
|
||||
// Same trade the settings read above makes: a colour allowlist that would not load
|
||||
// must not cost the operator the device list and the sign-out buttons.
|
||||
s.loggerFor(r.Context()).Warn("theme allowlist read failed", "error", err)
|
||||
themes = map[string][]string{}
|
||||
}
|
||||
|
||||
result := make([]adminMembyAccount, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
pref := preferences[account.ID]
|
||||
@@ -115,6 +128,7 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
result = append(result, adminMembyAccount{
|
||||
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
|
||||
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
|
||||
Themes: nonNilStrings(themes[account.ID]),
|
||||
Recommendations: adminOnboardingPreferences{
|
||||
Completed: pref.Completed, Prompted: pref.Prompted,
|
||||
Updated: len(account.RecommendationPreferences) > 2,
|
||||
@@ -131,6 +145,11 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"accounts": result, "catalogue": preferenceCatalogue,
|
||||
"schemaVersion": preferenceSchemaVersion,
|
||||
// The selectable themes, for the same reason the preference catalogue rides along:
|
||||
// a page that hard-coded the swatches would drift from what the gateway will accept
|
||||
// the first time a theme is added, and would do it without saying so. Seasonal ones
|
||||
// are absent because they are not grantable — see themes.go.
|
||||
"themes": selectableThemes(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,11 @@ var adminNav = []adminNavGroup{
|
||||
Intro: "Presentation policy sent with every playback launch.",
|
||||
Icon: "M8 5v14l11-7zM4 5v14",
|
||||
},
|
||||
{
|
||||
ID: "subtitles", Label: "Subtitles", Title: "Subtitles",
|
||||
Intro: "Which providers a viewer may fetch a missing subtitle from.",
|
||||
Icon: "M4 5.5h16v13H4zM7 15h5m3 0h2M7 11h3m2 0h5",
|
||||
},
|
||||
{
|
||||
ID: "updates", Label: "App updates", Title: "App updates",
|
||||
Intro: "Publish an optional or a required client update.",
|
||||
@@ -147,6 +152,11 @@ var adminNav = []adminNavGroup{
|
||||
Intro: "Impressions, focus, dwell and selections per launcher row.",
|
||||
Icon: "M4 19V9m5 10V5m5 14v-7m5 7V3",
|
||||
},
|
||||
{
|
||||
ID: "searches", Label: "Searches", Title: "Searches",
|
||||
Intro: "What the household has been looking for, and what it searched just now.",
|
||||
Icon: "M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Zm4.6-1.9L20 20",
|
||||
},
|
||||
{
|
||||
ID: "logs", Label: "Server logs", Title: "Server logs",
|
||||
Intro: "Structured gateway events as they happen.",
|
||||
|
||||
@@ -167,5 +167,26 @@ func adminPreviewData() map[string]any {
|
||||
"focuses": 300, "selections": 74, "averageDwellMs": 1800},
|
||||
}},
|
||||
"/admin/api/requests": map[string]any{"requests": []any{}},
|
||||
// A prefix among the terms and an unattributed row in the log, because both are
|
||||
// ordinary here and a preview showing neither would not be a preview of this page.
|
||||
"/admin/api/searches": map[string]any{
|
||||
"days": 7, "retentionDays": searchWindowDays,
|
||||
"termLimit": searchTermLimit, "eventLimit": searchEventLimit,
|
||||
"totals": map[string]any{"searches": 214, "queries": 96, "viewers": 2},
|
||||
"terms": []any{
|
||||
map[string]any{"query": "severance", "searches": 18, "viewers": 2, "lastAt": stamp(40 * time.Minute)},
|
||||
map[string]any{"query": "dune", "searches": 11, "viewers": 1, "lastAt": stamp(3 * time.Hour)},
|
||||
map[string]any{"query": "sev", "searches": 9, "viewers": 2, "lastAt": stamp(40 * time.Minute)},
|
||||
map[string]any{"query": "the bear", "searches": 4, "viewers": 1, "lastAt": stamp(2 * 24 * time.Hour)},
|
||||
},
|
||||
"recent": []any{
|
||||
map[string]any{"occurredAt": stamp(40 * time.Minute), "userId": "u-1",
|
||||
"username": "matt", "query": "severance"},
|
||||
map[string]any{"occurredAt": stamp(3 * time.Hour), "userId": "u-2",
|
||||
"username": "sam", "query": "dune"},
|
||||
map[string]any{"occurredAt": stamp(26 * time.Hour), "userId": "u-9",
|
||||
"username": "", "query": "the bear"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The console's window on search history.
|
||||
//
|
||||
// Two shapes of the same table, because they answer different questions. The summary says
|
||||
// what the household looks for, which is what a library is bought and organised against;
|
||||
// the log says what happened just now, which is what an operator needs the moment somebody
|
||||
// reports that search is not finding something — it shows the query exactly as it was
|
||||
// typed, by whom, and when.
|
||||
const (
|
||||
// searchTermLimit caps the summary. Long enough to show a tail, short enough that the
|
||||
// table is read rather than scrolled.
|
||||
searchTermLimit = 25
|
||||
// searchEventLimit caps the log. It is a window on recent activity, not an export.
|
||||
searchEventLimit = 100
|
||||
// searchWindowDays is the widest window the page offers, and it is the retention
|
||||
// period rather than a round number: RecordSearch prunes to it, so a page offering
|
||||
// more would draw a flat line for the difference.
|
||||
searchWindowDays = int(store.SearchRetention / (24 * time.Hour))
|
||||
)
|
||||
|
||||
type adminSearchesResponse struct {
|
||||
Days int `json:"days"`
|
||||
Retention int `json:"retentionDays"`
|
||||
Totals store.SearchTotals `json:"totals"`
|
||||
Terms []store.SearchTerm `json:"terms"`
|
||||
Recent []store.SearchEvent `json:"recent"`
|
||||
TermLimit int `json:"termLimit"`
|
||||
EventLimit int `json:"eventLimit"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSearches(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
days := queryInt(r, "days", 7, searchWindowDays)
|
||||
since := time.Now().UTC().AddDate(0, 0, -days)
|
||||
|
||||
totals, err := s.store.SearchTotals(ctx, since)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Error("search totals failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read search history")
|
||||
return
|
||||
}
|
||||
terms, err := s.store.SearchTerms(ctx, since, searchTermLimit)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Error("search terms failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read search history")
|
||||
return
|
||||
}
|
||||
recent, err := s.store.SearchEvents(ctx, since, searchEventLimit)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Error("search events failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read search history")
|
||||
return
|
||||
}
|
||||
|
||||
// A name a search cannot be attributed to costs the column and nothing else: the
|
||||
// queries are the page, and an operator reading it after a household member's last
|
||||
// session expired must still see what was searched for.
|
||||
if users, err := s.store.KnownUsers(ctx); err == nil {
|
||||
recent = nameSearchEvents(recent, users)
|
||||
} else {
|
||||
s.loggerFor(ctx).Warn("search history names unresolved", "error", err)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, adminSearchesResponse{
|
||||
Days: days,
|
||||
Retention: searchWindowDays,
|
||||
Totals: totals,
|
||||
Terms: terms,
|
||||
Recent: recent,
|
||||
TermLimit: searchTermLimit,
|
||||
EventLimit: searchEventLimit,
|
||||
})
|
||||
}
|
||||
|
||||
// nameSearchEvents fills in who made each search.
|
||||
//
|
||||
// Resolved here rather than joined in SQL because the log is capped and the household is
|
||||
// small: one read of sessions serves a whole page, where a join would repeat the lookup
|
||||
// per row. An id with no session left is returned unnamed rather than dropped — the
|
||||
// console prints the id, which still distinguishes one searcher from another.
|
||||
func nameSearchEvents(events []store.SearchEvent, users []store.KnownUser) []store.SearchEvent {
|
||||
names := make(map[string]string, len(users))
|
||||
for _, user := range users {
|
||||
if user.Username != "" {
|
||||
names[user.ID] = user.Username
|
||||
}
|
||||
}
|
||||
for i := range events {
|
||||
events[i].Username = names[events[i].UserID]
|
||||
}
|
||||
return events
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The console's half of subtitle downloads.
|
||||
//
|
||||
// The operator has two providers to choose between and they need different things said
|
||||
// about them. Bazarr is a service the household already runs, so the console can only turn
|
||||
// it on or off — its address is deployment configuration and stays an environment
|
||||
// variable. OpenSubtitles is an account, so its credentials live here and can be entered,
|
||||
// replaced or removed without a redeployment.
|
||||
//
|
||||
// Nothing on this page ever returns a credential. The console is told whether a key is
|
||||
// saved and whether an account is attached, which is what an operator needs to answer
|
||||
// "why is this not working", and never the values themselves — the stance the MDBList page
|
||||
// already takes.
|
||||
|
||||
// subtitleAdminSettings is the page's whole view of the policy.
|
||||
type subtitleAdminSettings struct {
|
||||
// BazarrConfigured is whether this deployment has a Bazarr at all. It is separate from
|
||||
// BazarrEnabled so the page can say "no address configured" rather than drawing a
|
||||
// switch that would do nothing.
|
||||
BazarrConfigured bool `json:"bazarrConfigured"`
|
||||
BazarrEnabled bool `json:"bazarrEnabled"`
|
||||
BazarrURL string `json:"bazarrUrl,omitempty"`
|
||||
|
||||
OpenSubtitlesEnabled bool `json:"openSubtitlesEnabled"`
|
||||
OpenSubtitlesKeyConfigured bool `json:"openSubtitlesKeyConfigured"`
|
||||
// OpenSubtitlesAccount is whether a username and password are saved. It matters more
|
||||
// than it looks: without one, downloads go against the anonymous allowance, which is a
|
||||
// handful of files a day and fails in front of a television rather than in a log.
|
||||
OpenSubtitlesAccount bool `json:"openSubtitlesAccount"`
|
||||
OpenSubtitlesUsername string `json:"openSubtitlesUsername,omitempty"`
|
||||
|
||||
// FeatureEnabled is the `subtitle_download` flag. It is reported here because it
|
||||
// overrides both providers, and an operator who has turned it off on the features page
|
||||
// should not have to guess why these switches do nothing.
|
||||
FeatureEnabled bool `json:"featureEnabled"`
|
||||
// Available is the answer a television gets: the feature is on and at least one
|
||||
// provider can be asked.
|
||||
Available bool `json:"available"`
|
||||
|
||||
Stored store.DownloadedSubtitleStats `json:"stored"`
|
||||
}
|
||||
|
||||
func (s *Server) subtitleAdminSettings(ctx context.Context) subtitleAdminSettings {
|
||||
policy := s.subtitlePolicy(ctx)
|
||||
sources := s.subtitleSources(ctx)
|
||||
settings := subtitleAdminSettings{
|
||||
BazarrConfigured: s.bazarr != nil,
|
||||
BazarrEnabled: policy.BazarrEnabled,
|
||||
BazarrURL: s.cfg.BazarrURL,
|
||||
OpenSubtitlesEnabled: policy.OpenSubtitlesEnabled,
|
||||
OpenSubtitlesKeyConfigured: policy.OpenSubtitlesAPIKey != "",
|
||||
OpenSubtitlesAccount: policy.OpenSubtitlesUsername != "" && policy.OpenSubtitlesPassword != "",
|
||||
OpenSubtitlesUsername: policy.OpenSubtitlesUsername,
|
||||
FeatureEnabled: s.featureEnabled(ctx, featureSubtitleDownload),
|
||||
Available: sources.any(),
|
||||
}
|
||||
if stats, err := s.store.DownloadedSubtitleStats(ctx); err == nil {
|
||||
settings.Stored = stats
|
||||
} else {
|
||||
s.loggerFor(ctx).Warn("downloaded subtitle stats failed", "error", err)
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
type subtitleSettingsRequest struct {
|
||||
Action string `json:"action"`
|
||||
|
||||
BazarrEnabled bool `json:"bazarrEnabled"`
|
||||
OpenSubtitlesEnabled bool `json:"openSubtitlesEnabled"`
|
||||
|
||||
// A blank key keeps whatever is saved, so an operator changing one switch does not
|
||||
// have to paste a credential back in to do it. Clearing is its own flag, because
|
||||
// "leave it alone" and "remove it" cannot both be the empty string.
|
||||
OpenSubtitlesAPIKey string `json:"openSubtitlesApiKey"`
|
||||
ClearOpenSubtitlesAPIKey bool `json:"clearOpenSubtitlesApiKey"`
|
||||
OpenSubtitlesUsername string `json:"openSubtitlesUsername"`
|
||||
OpenSubtitlesPassword string `json:"openSubtitlesPassword"`
|
||||
ClearOpenSubtitlesLogin bool `json:"clearOpenSubtitlesLogin"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSubtitleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req subtitleSettingsRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
|
||||
// Emptying the store is the page's one destructive control, and it is safe in the way
|
||||
// a cache purge is: every file can be fetched again, at the cost of the provider
|
||||
// allowance that fetched it. It is a separate action rather than a checkbox on the
|
||||
// save, so it cannot happen as a side effect of changing a switch.
|
||||
if strings.TrimSpace(req.Action) == "clear-stored" {
|
||||
removed, err := s.store.ClearDownloadedSubtitles(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Error("clearing stored subtitles failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not clear stored subtitles")
|
||||
return
|
||||
}
|
||||
s.loggerFor(ctx).Info("stored subtitles cleared", "removed", removed)
|
||||
writeJSON(w, http.StatusOK, s.subtitleAdminSettings(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
current, err := s.store.SubtitlePolicy(ctx)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not read subtitle settings")
|
||||
return
|
||||
}
|
||||
next := store.SubtitlePolicy{
|
||||
BazarrEnabled: req.BazarrEnabled,
|
||||
OpenSubtitlesEnabled: req.OpenSubtitlesEnabled,
|
||||
OpenSubtitlesAPIKey: current.OpenSubtitlesAPIKey,
|
||||
OpenSubtitlesUsername: current.OpenSubtitlesUsername,
|
||||
OpenSubtitlesPassword: current.OpenSubtitlesPassword,
|
||||
}
|
||||
if req.ClearOpenSubtitlesAPIKey {
|
||||
next.OpenSubtitlesAPIKey = ""
|
||||
} else if replacement := strings.TrimSpace(req.OpenSubtitlesAPIKey); replacement != "" {
|
||||
next.OpenSubtitlesAPIKey = replacement
|
||||
}
|
||||
if req.ClearOpenSubtitlesLogin {
|
||||
next.OpenSubtitlesUsername, next.OpenSubtitlesPassword = "", ""
|
||||
} else if username := strings.TrimSpace(req.OpenSubtitlesUsername); username != "" {
|
||||
next.OpenSubtitlesUsername = username
|
||||
// The password only moves when one was typed. Changing a username without
|
||||
// retyping the password is an ordinary edit, and the field is blank on every load.
|
||||
if password := req.OpenSubtitlesPassword; password != "" {
|
||||
next.OpenSubtitlesPassword = password
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.store.SetSubtitlePolicy(ctx, next); err != nil {
|
||||
s.loggerFor(ctx).Error("subtitle policy write failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save subtitle settings")
|
||||
return
|
||||
}
|
||||
s.loggerFor(ctx).Info("subtitle providers changed",
|
||||
"bazarr", next.BazarrEnabled,
|
||||
"opensubtitles", next.OpenSubtitlesEnabled,
|
||||
"opensubtitles_account", next.OpenSubtitlesUsername != "",
|
||||
)
|
||||
writeJSON(w, http.StatusOK, s.subtitleAdminSettings(ctx))
|
||||
}
|
||||
|
||||
// handleAdminSubtitleTest asks each enabled provider whether it is actually reachable.
|
||||
//
|
||||
// It exists because every other symptom of a wrong key looks identical from a television:
|
||||
// the search comes back empty. One button that says "the key is rejected" is the whole
|
||||
// difference between a five-minute fix and an evening of guessing.
|
||||
func (s *Server) handleAdminSubtitleTest(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
type probe struct {
|
||||
Provider string `json:"provider"`
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
results := []probe{}
|
||||
|
||||
if s.bazarr != nil {
|
||||
result := probe{Provider: "Bazarr", OK: true, Message: "Reachable."}
|
||||
if err := s.bazarr.Ping(ctx); err != nil {
|
||||
result.OK, result.Message = false, "Did not answer: "+err.Error()
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
if client := s.openSubtitlesClient(ctx); client != nil {
|
||||
result := probe{Provider: "OpenSubtitles", OK: true}
|
||||
if err := client.Ping(ctx); err != nil {
|
||||
result.OK, result.Message = false, "Did not answer: "+err.Error()
|
||||
} else if client.HasAccount() {
|
||||
result.Message = "Reachable, signed in."
|
||||
} else {
|
||||
// Worth saying rather than reporting a plain success: an anonymous key works
|
||||
// perfectly for searching and runs out after a few downloads, which is the
|
||||
// failure this page exists to make findable.
|
||||
result.Message = "Reachable, but with no account — downloads use the small anonymous allowance."
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": results})
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/foryou"
|
||||
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
|
||||
"github.com/ponzischeme89/memby/server/internal/mdblist"
|
||||
"github.com/ponzischeme89/memby/server/internal/opensubtitles"
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
@@ -50,9 +51,19 @@ type Server struct {
|
||||
log *slog.Logger
|
||||
events *serverlogging.Buffer
|
||||
sonarrMu sync.Mutex
|
||||
radarrMu sync.Mutex
|
||||
bazarrMu sync.Mutex
|
||||
mdblistMu sync.Mutex
|
||||
// sonarrSeriesMu guards the catalogue cache separately from the calendar's, so an add
|
||||
// to My Shows never waits behind a launcher rebuilding the schedule row.
|
||||
sonarrSeriesMu sync.Mutex
|
||||
radarrMu sync.Mutex
|
||||
bazarrMu sync.Mutex
|
||||
// openSubtitles is built from the operator's saved credentials rather than from
|
||||
// configuration, so it is cached against a fingerprint of them and rebuilt when they
|
||||
// change. It is cached at all because the client holds a login token, and logging in
|
||||
// per download would spend a different allowance than the one being conserved.
|
||||
openSubtitlesMu sync.Mutex
|
||||
openSubtitles *opensubtitles.Client
|
||||
openSubtitlesKey string
|
||||
mdblistMu sync.Mutex
|
||||
// mdblistSettingsCache spares every row and keystroke a settings read.
|
||||
mdblistSettingsCache mdblistSettingsCache
|
||||
// ratingsWarm fills and renews the durable rating cache behind the viewer, so a row
|
||||
@@ -125,6 +136,9 @@ func (s *Server) Routes() http.Handler {
|
||||
v1.Handle("GET /v1/home", s.authed(s.handleHome))
|
||||
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
|
||||
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
|
||||
// A genre is browsed, not searched: the chip is a filter and this is the route that
|
||||
// treats it as one. Paged, because a household's Drama shelf is not a screenful.
|
||||
v1.Handle("GET /v1/genres/{genre}/items", s.authed(s.handleGenreItems))
|
||||
v1.Handle("GET /v1/search/history", s.authed(s.handleRecentSearches))
|
||||
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
|
||||
v1.Handle("GET /v1/requests/lookup", s.authed(s.handleRequestLookup))
|
||||
@@ -145,6 +159,10 @@ func (s *Server) Routes() http.Handler {
|
||||
v1.Handle("GET /v1/features", s.authed(s.handleFeatures))
|
||||
// A viewer's settings follow the person, not the television. Both verbs land on one
|
||||
// handler because a write answers with the stored document, not the submitted one.
|
||||
// The palette, fetched only when the revision on the status poll moves. A GET with no
|
||||
// write beside it: what a viewer may change is themeId, and that is an ordinary
|
||||
// setting on the route above — this route only answers with what came of it.
|
||||
v1.Handle("GET /v1/theme", s.authed(s.handleTheme))
|
||||
v1.Handle("GET /v1/preferences", s.authed(s.handlePreferences))
|
||||
v1.Handle("PUT /v1/preferences", s.authed(s.handlePreferences))
|
||||
|
||||
@@ -155,10 +173,19 @@ func (s *Server) Routes() http.Handler {
|
||||
v1.Handle("GET /v1/items/{id}/related", s.authed(s.handleRelated))
|
||||
v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite))
|
||||
v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed))
|
||||
v1.Handle("POST /v1/items/{id}/hide-from-resume", s.authed(s.handleHideFromResume))
|
||||
v1.Handle("GET /v1/items/{id}/playback", s.authed(s.handlePlayback))
|
||||
v1.Handle("GET /v1/items/{id}/next", s.authed(s.handleNextEpisode))
|
||||
v1.Handle("GET /v1/items/{id}/subtitles/search", s.authed(s.handleSubtitleSearch))
|
||||
v1.Handle("POST /v1/items/{id}/subtitles/download", s.authed(s.handleSubtitleDownload))
|
||||
// Repairing the timing of a subtitle the title already has, which is a different
|
||||
// question from fetching another copy of it — see subtitle_fix.go.
|
||||
v1.Handle("POST /v1/items/{id}/subtitles/fix", s.authed(s.handleSubtitleFix))
|
||||
// The one route that serves a subtitle rather than pointing at Emby's. It exists for
|
||||
// the provider that hands back bytes instead of writing beside the media file; the
|
||||
// token arrives in the query string, the way artwork's does, because a media player
|
||||
// fetching a sidecar sends none of Memby's headers.
|
||||
v1.Handle("GET /v1/subtitles/{file}", s.authed(s.handleStoredSubtitle))
|
||||
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
|
||||
v1.Handle("GET /v1/items/{id}/intro", s.authed(s.handleIntro))
|
||||
v1.Handle("GET /v1/items/{id}/trickplay", s.authed(s.handleTrickplay))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -277,6 +278,74 @@ func TestSearchHistoryResponseEncodesEmptyQueriesAsArray(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Both routes that write search_history apply one rule, so a query /v1/search records is
|
||||
// exactly one /v1/search/history would have accepted. The length is counted in runes:
|
||||
// bytes would reject a Japanese title at a third of an English one's length.
|
||||
func TestSearchQueryRecordable(t *testing.T) {
|
||||
long := strings.Repeat("a", maxSearchQueryRunes)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
term string
|
||||
want bool
|
||||
}{
|
||||
{"ordinary", "titanic", true},
|
||||
{"at the floor", "up", true},
|
||||
{"one letter", "u", false},
|
||||
{"blank", " ", false},
|
||||
{"padded is measured trimmed", " up ", true},
|
||||
{"at the ceiling", long, true},
|
||||
{"past the ceiling", long + "a", false},
|
||||
{"multibyte counted as runes", strings.Repeat("あ", maxSearchQueryRunes), true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := searchQueryRecordable(tc.term); got != tc.want {
|
||||
t.Fatalf("searchQueryRecordable(%q) = %v, want %v", tc.term, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A search whose viewer no longer has a session keeps its row: the query is what the page
|
||||
// is for, and an id still tells one searcher from another.
|
||||
func TestNameSearchEventsKeepsUnattributedRows(t *testing.T) {
|
||||
events := []store.SearchEvent{
|
||||
{Query: "severance", UserID: "u-1"},
|
||||
{Query: "dune", UserID: "gone"},
|
||||
}
|
||||
users := []store.KnownUser{
|
||||
{ID: "u-1", Username: "matt"},
|
||||
{ID: "u-2", Username: "sam"},
|
||||
}
|
||||
|
||||
got := nameSearchEvents(events, users)
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("event count = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Username != "matt" {
|
||||
t.Fatalf("username = %q, want matt", got[0].Username)
|
||||
}
|
||||
if got[1].Username != "" || got[1].Query != "dune" {
|
||||
t.Fatalf("unattributed event = %+v, want an empty name and its query", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// The page must not offer a window the table cannot fill: RecordSearch prunes to the
|
||||
// retention period, so a wider one would draw a flat line for the difference.
|
||||
func TestSearchWindowMatchesRetention(t *testing.T) {
|
||||
if searchWindowDays != 30 {
|
||||
t.Fatalf("search window = %d days, want 30 to match store.SearchRetention", searchWindowDays)
|
||||
}
|
||||
}
|
||||
|
||||
// Recording must never be what stops a search being answered. A gateway with no database
|
||||
// reaches this on every keystroke, so the guard comes before anything that could panic on
|
||||
// a half-built server.
|
||||
func TestRecordSearchQueryWithoutStoreIsSilent(t *testing.T) {
|
||||
s := &Server{}
|
||||
s.recordSearchQuery(context.Background(), store.Session{EmbyUserID: "u1"}, "titanic")
|
||||
}
|
||||
|
||||
// The fixed rows must keep their order, ids and kinds: the client maps kinds onto
|
||||
// card shapes and uses ids as Compose keys.
|
||||
func TestBaseRowsShape(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package api
|
||||
|
||||
import "strings"
|
||||
|
||||
// Where a title's closing credits begin.
|
||||
//
|
||||
// Two sources, in order of trust, because Emby gives one and the media gives the other:
|
||||
//
|
||||
// - `CreditsStart`, a marker Emby's own detector writes. It is in Emby's `MarkerType`
|
||||
// enumeration and is what this feature was originally built on — but **Emby 4.10 does not
|
||||
// write it**. A survey of a 20,000-item library found `Chapter`, `IntroStart` and
|
||||
// `IntroEnd` and nothing else, so the enum value existing is not the detector populating
|
||||
// it. It is still read first, so the day a version does write it this needs no change.
|
||||
// - A chapter *named* like credits. Plenty of media carries "Credits" or "End Credits" as
|
||||
// ordinary chapter metadata, and in that same library 216 items had one, clustered at
|
||||
// 90–98% of runtime and consistent within a show. That is where the feature's coverage
|
||||
// actually comes from today.
|
||||
//
|
||||
// There is deliberately no end marker in either source. Credits run to the end of the file by
|
||||
// definition, so nothing writes one and this must not invent one.
|
||||
|
||||
const markerCreditsStart = "CreditsStart"
|
||||
|
||||
// creditsMinimumPositionFraction is how far into a file a credit roll has to begin.
|
||||
//
|
||||
// **This is the load-bearing guard, and it exists because of one observed case.** Chapter
|
||||
// names are not a vocabulary anybody agreed on, and real media carries "Opening Credits" —
|
||||
// Belfast at 1% of runtime, Game of Thrones at 0%. A name match without a position test
|
||||
// therefore starts the credits pane in the *first minute* of a film and runs its opening at
|
||||
// double speed, which is the worst thing this feature could possibly do.
|
||||
//
|
||||
// Three quarters is deliberately far below the evidence rather than near it: every genuine
|
||||
// credit roll in that survey began at 90% or later, so this leaves fifteen points of headroom
|
||||
// for a long roll while rejecting the whole first half of a file outright.
|
||||
const creditsMinimumPositionFraction = 0.75
|
||||
|
||||
// creditsFromChapters finds where the closing credits begin.
|
||||
//
|
||||
// The rule exists twice — the television's copy is `creditsStartFrom` in `data/Credits.kt` —
|
||||
// and the two are pinned by deliberately parallel tests (`credits_test.go`, `CreditsTest`).
|
||||
// With no gateway there is nobody to ask, and the picture must not start shrinking at a
|
||||
// different moment depending on whether the container is up.
|
||||
//
|
||||
// Most of this is about refusing to answer, and nothing is a perfectly good answer: the player
|
||||
// never shrinks anything and the credits play out full size, which is what every other client
|
||||
// does anyway.
|
||||
//
|
||||
// [runtimeMs] may be zero when Emby does not report one. An explicit marker is still honoured
|
||||
// then — it is Emby asserting a position rather than this inferring one — but a *named*
|
||||
// chapter is refused outright, because the name alone cannot distinguish an opening credit
|
||||
// sequence from a closing one and the position test is the only thing that can.
|
||||
func creditsFromChapters(chapters []embyChapter, runtimeMs int64) (int64, bool) {
|
||||
floor := int64(-1)
|
||||
if runtimeMs > 0 {
|
||||
floor = int64(float64(runtimeMs) * creditsMinimumPositionFraction)
|
||||
}
|
||||
|
||||
// An explicit marker first. The last one wins, where the intro rule takes the first:
|
||||
// two starts mean the markers are untrustworthy, so each rule picks whichever risks
|
||||
// least, and the two features are damaged in opposite directions. An intro skip firing
|
||||
// late throws somebody past the story, so the earlier marker is safer there; the credits
|
||||
// pane firing early runs the last scene past them at double speed, so the later marker is
|
||||
// safer here.
|
||||
marked := int64(-1)
|
||||
for _, chapter := range chapters {
|
||||
if chapter.MarkerType != markerCreditsStart || chapter.StartPositionTicks <= 0 {
|
||||
continue
|
||||
}
|
||||
marked = chapter.StartPositionTicks / ticksPerMillisecond
|
||||
}
|
||||
// A marker below the floor is a mis-detection whoever wrote it, so it falls through to the
|
||||
// names rather than being honoured — but with no runtime to measure against, an explicit
|
||||
// assertion gets the benefit of the doubt.
|
||||
if marked > 0 && (floor < 0 || marked >= floor) {
|
||||
return marked, true
|
||||
}
|
||||
|
||||
if floor < 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Then the names. The *earliest* qualifying chapter wins here, which is the opposite of
|
||||
// the marker rule above and is not an inconsistency: several credits-named chapters are
|
||||
// ordinary rather than suspicious — "The Pitt" carries both "Credits" and "End Credits" —
|
||||
// and they describe one roll, which begins at the first of them.
|
||||
named := int64(-1)
|
||||
for _, chapter := range chapters {
|
||||
if !isCreditsChapterName(chapter.Name) || chapter.StartPositionTicks <= 0 {
|
||||
continue
|
||||
}
|
||||
at := chapter.StartPositionTicks / ticksPerMillisecond
|
||||
if at < floor {
|
||||
continue
|
||||
}
|
||||
if named < 0 || at < named {
|
||||
named = at
|
||||
}
|
||||
}
|
||||
if named > 0 {
|
||||
return named, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// isCreditsChapterName recognises a chapter that names a credit roll.
|
||||
//
|
||||
// The exclusions are belt-and-braces beside [creditsMinimumPositionFraction], which is what
|
||||
// actually stops an opening sequence being read as a closing one — a position test catches
|
||||
// wordings nobody thought of, where a list of them only catches the ones on the list. They are
|
||||
// here so the trap is stated where the next reader will look for it.
|
||||
func isCreditsChapterName(name string) bool {
|
||||
lowered := strings.ToLower(strings.TrimSpace(name))
|
||||
if lowered == "" {
|
||||
return false
|
||||
}
|
||||
for _, opening := range []string{"opening", "main title", "title sequence", "intro"} {
|
||||
if strings.Contains(lowered, opening) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return strings.Contains(lowered, "credit") ||
|
||||
strings.Contains(lowered, "end titles") ||
|
||||
strings.Contains(lowered, "closing")
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
// The credits rule, pinned against the same cases as the television's copy (`CreditsTest`
|
||||
// in app/src/test). The two exist separately because with no gateway there is nobody to
|
||||
// ask, and the picture must not start shrinking at a different moment depending on whether
|
||||
// the container is up — so when one of these changes, the other has to change with it.
|
||||
//
|
||||
// The cases come from a survey of a real 20,000-item library, and the shape of that survey is
|
||||
// why the rule looks the way it does. Emby 4.10 wrote **no `CreditsStart` at all** — only
|
||||
// `Chapter`, `IntroStart` and `IntroEnd` — while 216 items carried a chapter *named* like
|
||||
// credits at 90–98% of runtime. Two of them carried "Opening Credits" at 0–1%, which is the
|
||||
// case the position floor exists for and the one worth never regressing.
|
||||
|
||||
func named(seconds int64, name string) embyChapter {
|
||||
return embyChapter{
|
||||
StartPositionTicks: seconds * 1_000 * ticksPerMillisecond,
|
||||
MarkerType: "Chapter",
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
|
||||
// A two-thousand-second episode, so a percentage of runtime reads as a round number.
|
||||
const testRuntimeMs = 2_000_000
|
||||
|
||||
func TestCreditsFromChapters(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
chapters []embyChapter
|
||||
runtimeMs int64
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
// A real episode as the server holds it: chapters, the intro pair, more
|
||||
// chapters, and the credits marker near the end.
|
||||
name: "a real episode",
|
||||
chapters: []embyChapter{
|
||||
chapter(0, "Chapter"),
|
||||
chapter(463, "IntroStart"),
|
||||
chapter(583, "IntroEnd"),
|
||||
chapter(1200, "Chapter"),
|
||||
chapter(1900, "CreditsStart"),
|
||||
},
|
||||
want: 1_900_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
// A film, which commonly has the credits marker and no intro at all. This is
|
||||
// the case that would have been lost had the two features shared one flag.
|
||||
name: "credits with no intro",
|
||||
chapters: []embyChapter{
|
||||
chapter(0, "Chapter"),
|
||||
chapter(1400, "Chapter"),
|
||||
chapter(1850, "CreditsStart"),
|
||||
},
|
||||
want: 1_850_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "no markers at all",
|
||||
chapters: []embyChapter{chapter(0, "Chapter"), chapter(300, "Chapter")},
|
||||
},
|
||||
{
|
||||
name: "no chapters at all",
|
||||
chapters: nil,
|
||||
},
|
||||
{
|
||||
// An intro pair is a different feature and must never be read as credits.
|
||||
name: "intro markers are not credits",
|
||||
chapters: []embyChapter{
|
||||
chapter(463, "IntroStart"),
|
||||
chapter(583, "IntroEnd"),
|
||||
},
|
||||
},
|
||||
{
|
||||
// A marker at zero says the whole file is credits, which is not something Emby
|
||||
// means and not something worth shrinking a picture for.
|
||||
name: "a marker at the very beginning",
|
||||
chapters: []embyChapter{chapter(0, "CreditsStart"), chapter(300, "Chapter")},
|
||||
},
|
||||
{
|
||||
// The last marker wins, where the intro rule takes the first. Two starts mean
|
||||
// the markers are untrustworthy, and the two features are damaged in opposite
|
||||
// directions: an intro skip that fires late throws somebody past the story, so
|
||||
// the earlier marker is safer there; the credits pane firing early runs the last
|
||||
// scene past somebody at double speed, so the later marker is safer here.
|
||||
name: "two starts, the later one wins",
|
||||
chapters: []embyChapter{
|
||||
chapter(1700, "CreditsStart"),
|
||||
chapter(1900, "CreditsStart"),
|
||||
},
|
||||
want: 1_900_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
// Order in the array is not trusted to be sorted, so a later marker earlier in
|
||||
// the list still loses to the one further into the film.
|
||||
name: "the later marker wins whatever order they arrive in",
|
||||
chapters: []embyChapter{
|
||||
chapter(1900, "CreditsStart"),
|
||||
chapter(1700, "CreditsStart"),
|
||||
},
|
||||
want: 1_700_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
// Squid Game, as the library actually holds it: no marker of any kind, one
|
||||
// ordinary chapter named "Credits" at 90% of runtime. This is where every bit of
|
||||
// this feature's coverage comes from today.
|
||||
name: "a chapter named Credits, which is all Emby 4.10 gives",
|
||||
chapters: []embyChapter{
|
||||
chapter(0, "Chapter"),
|
||||
chapter(463, "IntroStart"),
|
||||
chapter(583, "IntroEnd"),
|
||||
named(1800, "Credits"),
|
||||
},
|
||||
want: 1_800_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "a chapter named End Credits",
|
||||
chapters: []embyChapter{named(1920, "End Credits")},
|
||||
want: 1_920_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
// THE case. Belfast carries "Opening Credits" at 1% of runtime and Game of Thrones
|
||||
// at 0%. Matching a name without testing the position starts the pane in the first
|
||||
// minute of a film and runs its opening at double speed — the worst thing this
|
||||
// feature could do, and the reason creditsMinimumPositionFraction exists.
|
||||
name: "Opening Credits at the start of a film is never the credit roll",
|
||||
chapters: []embyChapter{
|
||||
named(20, "Opening Credits"),
|
||||
chapter(600, "Chapter"),
|
||||
},
|
||||
},
|
||||
{
|
||||
// Belfast in full: both chapters present. The opening one must be rejected and the
|
||||
// closing one found, which the position floor does on its own.
|
||||
name: "an opening and a closing credit chapter in one film",
|
||||
chapters: []embyChapter{
|
||||
named(20, "Opening Credits"),
|
||||
chapter(600, "Chapter"),
|
||||
named(1900, "End Credits"),
|
||||
},
|
||||
want: 1_900_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
// "The Pitt" carries both, seconds apart, describing one roll. The *earliest*
|
||||
// qualifying chapter wins here — the opposite of the marker rule above — because
|
||||
// the roll begins at the first of them and taking the last would skip part of it.
|
||||
name: "two credits chapters describing one roll take the earlier",
|
||||
chapters: []embyChapter{
|
||||
named(1920, "End Credits"),
|
||||
named(1900, "Credits"),
|
||||
},
|
||||
want: 1_900_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
// Anything in the first three quarters is refused however it is worded. A position
|
||||
// test catches wordings nobody thought of; a list of words only catches the listed.
|
||||
name: "a credits-named chapter too early to be the roll",
|
||||
chapters: []embyChapter{named(900, "Credits")},
|
||||
},
|
||||
{
|
||||
// An explicit marker outranks a name, and is honoured even with no runtime to
|
||||
// measure against: it is Emby asserting a position rather than this inferring one.
|
||||
name: "a marker is honoured when the runtime is unknown",
|
||||
chapters: []embyChapter{chapter(1900, "CreditsStart")},
|
||||
runtimeMs: -1,
|
||||
want: 1_900_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
// A name is not. Without a runtime there is no way to tell an opening credit
|
||||
// sequence from a closing one, and guessing is what this whole guard refuses.
|
||||
name: "a name is refused when the runtime is unknown",
|
||||
chapters: []embyChapter{named(1900, "End Credits")},
|
||||
runtimeMs: -1,
|
||||
},
|
||||
{
|
||||
// A marker below the floor is a mis-detection whoever wrote it, so it gives way to
|
||||
// a name that does qualify rather than being honoured on authority.
|
||||
name: "a marker below the floor falls through to a name that qualifies",
|
||||
chapters: []embyChapter{
|
||||
chapter(200, "CreditsStart"),
|
||||
named(1900, "End Credits"),
|
||||
},
|
||||
want: 1_900_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
// The intro's own chapters are named "Intro Start"/"Intro End" in this library, and
|
||||
// an episode whose titles run late must never have them read as a credit roll.
|
||||
name: "chapters named for the intro are never credits",
|
||||
chapters: []embyChapter{
|
||||
named(1800, "Intro Start"),
|
||||
named(1900, "Intro End"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// 0 means "the ordinary case, use the default"; -1 means "deliberately
|
||||
// unknown", which is a case of its own rather than an absent field.
|
||||
runtime := tc.runtimeMs
|
||||
switch runtime {
|
||||
case 0:
|
||||
runtime = testRuntimeMs
|
||||
case -1:
|
||||
runtime = 0
|
||||
}
|
||||
got, ok := creditsFromChapters(tc.chapters, runtime)
|
||||
if ok != tc.ok {
|
||||
t.Fatalf("available = %v, want %v (start %d)", ok, tc.ok, got)
|
||||
}
|
||||
if ok && got != tc.want {
|
||||
t.Fatalf("start = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The two halves of one reading are independent. A film with credits and no intro must not
|
||||
// report an intro starting at zero, which is what a shared flag would have produced.
|
||||
func TestMarkersResponseKeepsTheHalvesApart(t *testing.T) {
|
||||
response := markersResponse(chapterMarkers{creditsStart: 6_840_000, creditsFound: true})
|
||||
if response.Available || response.StartMs != 0 || response.EndMs != 0 {
|
||||
t.Fatalf("intro = %+v, want an absent intro on a title that has none", response)
|
||||
}
|
||||
if !response.CreditsAvailable || response.CreditsStartMs != 6_840_000 {
|
||||
t.Fatalf("credits = %+v, want the marker that was found", response)
|
||||
}
|
||||
|
||||
response = markersResponse(chapterMarkers{
|
||||
intro: introSegment{StartMs: 463_000, EndMs: 583_000}, introFound: true,
|
||||
})
|
||||
if response.CreditsAvailable || response.CreditsStartMs != 0 {
|
||||
t.Fatalf("credits = %+v, want none on a title with only an intro", response)
|
||||
}
|
||||
}
|
||||
|
||||
// An operator turning one feature off must not take the other with it, and must not poison
|
||||
// the cache: masking happens on the way out, so the entry behind it still holds the truth.
|
||||
func TestMaskMarkersWithholdsOnlyTheDisabledHalf(t *testing.T) {
|
||||
full := introResponse{
|
||||
Available: true, StartMs: 463_000, EndMs: 583_000,
|
||||
CreditsAvailable: true, CreditsStartMs: 2_704_000,
|
||||
}
|
||||
|
||||
withoutIntro := maskMarkers(full, false, true)
|
||||
if withoutIntro.Available || withoutIntro.StartMs != 0 || withoutIntro.EndMs != 0 {
|
||||
t.Fatalf("intro = %+v, want it withheld", withoutIntro)
|
||||
}
|
||||
if !withoutIntro.CreditsAvailable || withoutIntro.CreditsStartMs != 2_704_000 {
|
||||
t.Fatal("turning the skip button off must not cost the credits pane as well")
|
||||
}
|
||||
|
||||
withoutCredits := maskMarkers(full, true, false)
|
||||
if withoutCredits.CreditsAvailable || withoutCredits.CreditsStartMs != 0 {
|
||||
t.Fatalf("credits = %+v, want them withheld", withoutCredits)
|
||||
}
|
||||
if !withoutCredits.Available || withoutCredits.StartMs != 463_000 {
|
||||
t.Fatal("turning the credits pane off must not cost the skip button as well")
|
||||
}
|
||||
}
|
||||
|
||||
// The toggle a television is told to obey has to be one this build knows.
|
||||
func TestSpeedUpCreditsIsCatalogued(t *testing.T) {
|
||||
definition, ok := preferenceDefinitionFor("speedUpCredits")
|
||||
if !ok {
|
||||
t.Fatal("speedUpCredits is missing from the preference catalogue")
|
||||
}
|
||||
if definition.Kind != preferenceToggle {
|
||||
t.Fatalf("kind = %v, want a toggle", definition.Kind)
|
||||
}
|
||||
if definition.Default != true {
|
||||
t.Fatalf("default = %v, want true — the feature is that it happens unasked, and it "+
|
||||
"is visible and reversible in a way an automatic seek is not", definition.Default)
|
||||
}
|
||||
|
||||
// An illegal value must come back as the default rather than reaching a player.
|
||||
normalised := normalizePreferences(map[string]any{"speedUpCredits": "sometimes"})
|
||||
if normalised["speedUpCredits"] != true {
|
||||
t.Fatalf("normalised = %v, want true", normalised["speedUpCredits"])
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ const (
|
||||
featureSubtitleDownload = "subtitle_download"
|
||||
featureTrickplay = "trickplay"
|
||||
featureSkipIntro = "skip_intro"
|
||||
featureEndCredits = "end_credits"
|
||||
featureSeasonalThemes = "seasonal_themes"
|
||||
featureSeasonalDecorations = "seasonal_decorations"
|
||||
)
|
||||
|
||||
type featureDefinition struct {
|
||||
@@ -80,6 +83,40 @@ var featureCatalogue = []featureDefinition{
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "skip_intro_v1",
|
||||
Recovery: "Takes effect the next time playback starts; the button simply stops appearing.",
|
||||
},
|
||||
{
|
||||
Key: featureEndCredits, Name: "Speed through the credits", Area: "Playback",
|
||||
Description: "Shrink the picture and run the closing credits at double speed with " +
|
||||
"the next episode beside them, from the credits marker Emby writes. It is read " +
|
||||
"from the same chapter list as the title sequence, so turning this off saves no " +
|
||||
"request unless that is off too.",
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "end_credits_v1",
|
||||
Recovery: "Takes effect the next time playback starts; the credits simply play out full size.",
|
||||
},
|
||||
{
|
||||
// The only switch there is for seasonal themes, and it is deliberately the
|
||||
// operator's rather than the viewer's: a per-person opt-out is a thing somebody
|
||||
// turns off in October and never reconsiders, which is the same as the feature not
|
||||
// existing. Off here means every television falls back to its viewer's own choice
|
||||
// on the next status poll.
|
||||
Key: featureSeasonalThemes, Name: "Seasonal themes", Area: "Presentation",
|
||||
Description: "Put every television into the Halloween, Christmas or Easter palette " +
|
||||
"for its dates. Viewers cannot decline one; turning this off is the only way to " +
|
||||
"stop them.",
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "themes_v1",
|
||||
Recovery: "Takes effect on the next status poll, within ten seconds on an open TV.",
|
||||
},
|
||||
{
|
||||
// A second switch rather than a consequence of the one above, because the palette
|
||||
// and the animation have quite different costs. Snow drifting over the launcher is
|
||||
// the only thing in the app that animates continuously while somebody is browsing,
|
||||
// and these are weak boxes; an operator who finds it costs frames should be able to
|
||||
// keep December looking like December without it.
|
||||
Key: featureSeasonalDecorations, Name: "Seasonal decorations", Area: "Presentation",
|
||||
Description: "Drift snow, bats or blossom over the launcher while a seasonal theme " +
|
||||
"is on. Turning it off keeps the seasonal colours and stops the animation.",
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "seasonal_decorations_v1",
|
||||
Recovery: "Takes effect on the next status poll; the launcher simply stops drawing them.",
|
||||
},
|
||||
{
|
||||
Key: featureInstallPermission, Name: "Ask TVs for install permission", Area: "Setup",
|
||||
Description: "Ask a signed-in TV that cannot install its own updates to grant the " +
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// Browsing a genre is a *filter*, not a search.
|
||||
//
|
||||
// The search page's genre chips used to run their label through /v1/search, which is a
|
||||
// text query: "Drama" then matched a film called Drama, anything with the word in its
|
||||
// overview, and — because relevance is a score rather than a rule — a scattering of titles
|
||||
// that are not in the genre at all, while missing most of the ones that are. So this asks
|
||||
// Emby the question actually being asked, with the genre as a filter, and answers a page
|
||||
// at a time.
|
||||
//
|
||||
// It goes to Emby with the viewer's own credentials rather than to the imported catalogue,
|
||||
// for the reason handleSearch does: the household copy may hold titles a library
|
||||
// permission or a parental control hides from this person, so it cannot be the authority
|
||||
// on what they may see.
|
||||
const (
|
||||
// A screenful on a television grid is 4–5 columns of about 3 rows. This is several of
|
||||
// those, so the scroll reaches the next page long before the viewer reaches the end of
|
||||
// this one, and small enough that opening a genre is one quick request rather than a
|
||||
// wait on a library's worth of Comedy.
|
||||
genrePageSize = 48
|
||||
genrePageMax = 100
|
||||
)
|
||||
|
||||
// genrePage is the wire shape. The total is what lets the television stop asking: a page
|
||||
// short of the limit also ends the scroll, but a genre whose last page happens to divide
|
||||
// evenly would otherwise cost one more empty request to discover that.
|
||||
type genrePage struct {
|
||||
Genre string `json:"genre"`
|
||||
Items []json.RawMessage `json:"items"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGenreItems(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
ctx := r.Context()
|
||||
genre := strings.TrimSpace(r.PathValue("genre"))
|
||||
if genre == "" {
|
||||
writeError(w, http.StatusBadRequest, "a genre is required")
|
||||
return
|
||||
}
|
||||
limit := queryInt(r, "limit", genrePageSize, genrePageMax)
|
||||
offset := queryOffset(r, "offset")
|
||||
|
||||
key := cache.UserKey(sess.EmbyUserID, "genre:"+genre+":"+itoa(offset)+":"+itoa(limit))
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
w.Header().Set("X-Memby-Cache", "hit")
|
||||
writeRaw(w, http.StatusOK, raw)
|
||||
return
|
||||
}
|
||||
|
||||
params := rowParams(url.Values{
|
||||
"Genres": {genre},
|
||||
"IncludeItemTypes": {"Movie,Series"},
|
||||
"Recursive": {"true"},
|
||||
"StartIndex": {itoa(offset)},
|
||||
"Limit": {itoa(limit)},
|
||||
// Newest first, because a genre is browsed to find something to watch and the
|
||||
// alphabet is not an answer to that. The second key is what makes paging safe:
|
||||
// with only a date, two titles sharing one could swap places between requests and
|
||||
// the scroll would repeat one card and never show the other.
|
||||
"SortBy": {"PremiereDate,SortName"},
|
||||
"SortOrder": {"Descending"},
|
||||
}, fieldsRow)
|
||||
// rowParams turns this off for the home rows, which never page. Here it is the number
|
||||
// the scroll stops on.
|
||||
params.Set("EnableTotalRecordCount", "true")
|
||||
|
||||
// Episodes are deliberately not among the types. An episode inherits its series'
|
||||
// genres, so including them would fill a page with twenty entries of one comedy and
|
||||
// bury the nineteen other shows behind it.
|
||||
result, err := s.emby.Items(ctx, credentials(sess), params)
|
||||
if err != nil {
|
||||
s.writeUpstreamError(ctx, w, err, "could not browse genre")
|
||||
return
|
||||
}
|
||||
items := nonNil(result.Items)
|
||||
s.decorateItemRatings(ctx, items)
|
||||
|
||||
total := genreTotal(result.TotalRecordCount, offset, len(items), limit)
|
||||
|
||||
// The first page is somebody opening a genre, which is a navigation event worth the
|
||||
// log; the pages after it are one viewer scrolling and would bury it.
|
||||
if offset == 0 {
|
||||
s.loggerFor(ctx).Info("genre browsed", "genre", genre, "results", len(items), "total", total)
|
||||
} else {
|
||||
s.loggerFor(ctx).Debug("genre page", "genre", genre, "offset", offset, "results", len(items))
|
||||
}
|
||||
|
||||
body, err := json.Marshal(genrePage{
|
||||
Genre: genre,
|
||||
Items: items,
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
Total: total,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not build genre results")
|
||||
return
|
||||
}
|
||||
if err := s.cache.Set(ctx, key, body, s.cfg.SearchTTL); err != nil {
|
||||
s.loggerFor(ctx).Warn("genre cache write failed", "error", err)
|
||||
}
|
||||
w.Header().Set("X-Memby-Cache", "miss")
|
||||
writeRaw(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// genreTotal is what the television's scroll stops on, and it has to be right in the case
|
||||
// where nobody counted.
|
||||
//
|
||||
// Emby answers TotalRecordCount when it is asked to, and that is the honest number. When
|
||||
// it does not (an older build, or a library it will not count), the page itself is the only
|
||||
// evidence: a *full* page means there may well be more, so the total is nudged one past
|
||||
// what has been delivered and the scroll asks again; a short page is the end of the genre,
|
||||
// so the total is exactly what has been delivered and the scroll stops. Getting that
|
||||
// backwards either strands the viewer half way through a genre or leaves the grid asking
|
||||
// for a page that will never come.
|
||||
func genreTotal(reported, offset, count, limit int) int {
|
||||
if reported > 0 {
|
||||
return reported
|
||||
}
|
||||
total := offset + count
|
||||
if count >= limit && limit > 0 {
|
||||
total++
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// queryOffset is queryInt's other half: an offset of zero is a legal value rather than a
|
||||
// missing one, which is exactly the case queryInt reads as "use the fallback".
|
||||
func queryOffset(r *http.Request, key string) int {
|
||||
raw := r.URL.Query().Get(key)
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenreTotalPrefersEmbysOwnCount(t *testing.T) {
|
||||
if got := genreTotal(412, 48, 48, 48); got != 412 {
|
||||
t.Fatalf("genreTotal = %d, want the reported 412", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenreTotalKeepsScrollingWhenNobodyCounted(t *testing.T) {
|
||||
// A full page with no count: there may be more, so the total has to sit past what has
|
||||
// been delivered or the television stops half way through the genre.
|
||||
if got := genreTotal(0, 48, 48, 48); got <= 96 {
|
||||
t.Fatalf("genreTotal = %d, want more than the 96 delivered", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenreTotalStopsOnAShortPage(t *testing.T) {
|
||||
// A short page is the end of the genre. Claiming anything beyond it leaves the grid
|
||||
// asking for a page that will never come.
|
||||
if got := genreTotal(0, 48, 11, 48); got != 59 {
|
||||
t.Fatalf("genreTotal = %d, want exactly the 59 delivered", got)
|
||||
}
|
||||
if got := genreTotal(0, 0, 0, 48); got != 0 {
|
||||
t.Fatalf("genreTotal = %d, want 0 for a genre with nothing in it", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryOffsetTreatsZeroAsAValueRatherThanAMissingOne(t *testing.T) {
|
||||
cases := map[string]int{
|
||||
"": 0,
|
||||
"offset=0": 0,
|
||||
"offset=48": 48,
|
||||
"offset=-3": 0,
|
||||
"offset=nonsense": 0,
|
||||
}
|
||||
for query, want := range cases {
|
||||
r := httptest.NewRequest("GET", "/v1/genres/Comedy/items?"+query, nil)
|
||||
if got := queryOffset(r, "offset"); got != want {
|
||||
t.Errorf("queryOffset(%q) = %d, want %d", query, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -522,6 +522,12 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
|
||||
limit := queryInt(r, "limit", 40, 100)
|
||||
key := cache.UserKey(sess.EmbyUserID, "search:"+itoa(limit)+":"+term+":d"+sess.DeviceID)
|
||||
|
||||
// Every search the tab performs is recorded here, before the cache is consulted, so a
|
||||
// query answered from Redis counts the same as one that reached Emby. The client also
|
||||
// posts to /v1/search/history and an older APK is the only thing that records at all —
|
||||
// recordSearchQuery's dedupe window is what stops the two writing the same query twice.
|
||||
s.recordSearchQuery(ctx, sess, term)
|
||||
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
w.Header().Set("X-Memby-Cache", "hit")
|
||||
writeRaw(w, http.StatusOK, raw)
|
||||
@@ -560,6 +566,49 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
|
||||
writeRaw(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
const (
|
||||
// minSearchQueryRunes matches the client's own floor: one letter matches half a
|
||||
// library, so the search tab does not ask below two and neither route records below it.
|
||||
minSearchQueryRunes = 2
|
||||
// maxSearchQueryRunes bounds what is written to search_history. The query arrives in a
|
||||
// URL on one of the two routes, so the table's row size must not be the client's to
|
||||
// choose. Runes rather than bytes, or a title in Japanese is rejected at a third of the
|
||||
// length of one in English.
|
||||
maxSearchQueryRunes = 200
|
||||
)
|
||||
|
||||
// searchQueryRecordable is the one rule both routes apply, so a query the search handler
|
||||
// records is exactly one the history endpoint would have accepted.
|
||||
func searchQueryRecordable(term string) bool {
|
||||
n := len([]rune(strings.TrimSpace(term)))
|
||||
return n >= minSearchQueryRunes && n <= maxSearchQueryRunes
|
||||
}
|
||||
|
||||
// recordSearchQuery writes a query the search tab performed, and never makes the viewer
|
||||
// wait for it.
|
||||
//
|
||||
// Detached from the request context deliberately: instant search cancels the in-flight
|
||||
// request on every keystroke (the client's collectLatest), so a write hung off r.Context()
|
||||
// would be abandoned for precisely the searches somebody typed fastest — and the record is
|
||||
// worth having whether or not they waited for the results.
|
||||
func (s *Server) recordSearchQuery(ctx context.Context, sess store.Session, term string) {
|
||||
if s.store == nil || !searchQueryRecordable(term) {
|
||||
return
|
||||
}
|
||||
term = strings.TrimSpace(term)
|
||||
log := s.loggerFor(ctx)
|
||||
detached := context.WithoutCancel(ctx)
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(detached, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.store.RecordSearch(ctx, sess.EmbyUserID, term); err != nil {
|
||||
// Telemetry, not the answer: a search whose record failed still returns
|
||||
// results, and this is DEBUG for the same reason the search line itself is.
|
||||
log.Debug("search not recorded", "query", term, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
type searchHistoryRequest struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
@@ -602,7 +651,7 @@ func (s *Server) handleSearchHistory(w http.ResponseWriter, r *http.Request, ses
|
||||
return
|
||||
}
|
||||
query := strings.TrimSpace(req.Query)
|
||||
if len([]rune(query)) < 2 || len([]rune(query)) > 200 {
|
||||
if !searchQueryRecordable(query) {
|
||||
writeError(w, http.StatusBadRequest, "search query length is invalid")
|
||||
return
|
||||
}
|
||||
|
||||
+110
-30
@@ -53,10 +53,29 @@ type introSegment struct {
|
||||
// a zero pair: an intro legitimately starting at 0 ms must be distinguishable from a title
|
||||
// that has none, and the client defaults to false so a gateway that predates this — or has
|
||||
// the feature turned off — can never conjure a button.
|
||||
//
|
||||
// The closing credits ride the same response for one reason: they are in the same chapter
|
||||
// list, so answering both costs the one Emby request this handler was always going to make.
|
||||
// A second route for `CreditsStart` would have doubled the cost of a feature whose entire
|
||||
// claim is that it is free.
|
||||
type introResponse struct {
|
||||
Available bool `json:"available"`
|
||||
StartMs int64 `json:"startMs,omitempty"`
|
||||
EndMs int64 `json:"endMs,omitempty"`
|
||||
// CreditsAvailable and CreditsStartMs describe the closing credits. Separate from
|
||||
// Available on purpose: an episode routinely has one and not the other, and folding
|
||||
// them into a single flag would cost the credits pane every title Emby has detected no
|
||||
// intro for — which is most films.
|
||||
CreditsAvailable bool `json:"creditsAvailable"`
|
||||
CreditsStartMs int64 `json:"creditsStartMs,omitempty"`
|
||||
}
|
||||
|
||||
// chapterMarkers is everything one reading of an item's chapter list came to.
|
||||
type chapterMarkers struct {
|
||||
intro introSegment
|
||||
introFound bool
|
||||
creditsStart int64
|
||||
creditsFound bool
|
||||
}
|
||||
|
||||
// embyChapter is one entry of Emby's Chapters field. Only two of its keys matter here.
|
||||
@@ -128,77 +147,138 @@ func (s *Server) handleIntro(w http.ResponseWriter, r *http.Request, sess store.
|
||||
writeError(w, http.StatusBadRequest, "item id is required")
|
||||
return
|
||||
}
|
||||
if !s.skipIntroEnabled(ctx) {
|
||||
// Two features read this one list, and the request is only worth making if the operator
|
||||
// has left at least one of them on.
|
||||
intro, credits := s.skipIntroEnabled(ctx), s.endCreditsEnabled(ctx)
|
||||
if !intro && !credits {
|
||||
writeJSON(w, http.StatusOK, introResponse{})
|
||||
return
|
||||
}
|
||||
|
||||
segment, ok, err := s.introFor(ctx, sess, itemID)
|
||||
markers, err := s.markersFor(ctx, sess, itemID)
|
||||
if err != nil {
|
||||
// Trouble is answered with "no intro" rather than an error. The button is an
|
||||
// optional convenience on a film that is already playing, and a failure the viewer
|
||||
// Trouble is answered with "nothing found" rather than an error. Both features are
|
||||
// optional conveniences on a film that is already playing, and a failure the viewer
|
||||
// cannot act on is not worth a red line in the log for every episode watched.
|
||||
s.loggerFor(ctx).Debug("intro markers unavailable", "item_id", itemID, "error", err)
|
||||
s.loggerFor(ctx).Debug("chapter markers unavailable", "item_id", itemID, "error", err)
|
||||
writeJSON(w, http.StatusOK, introResponse{})
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
if !markers.introFound && !markers.creditsFound {
|
||||
writeJSON(w, http.StatusOK, introResponse{})
|
||||
return
|
||||
}
|
||||
// The same answer for everyone in the house, and it only changes when the media does.
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
writeJSON(w, http.StatusOK, introResponse{
|
||||
Available: true,
|
||||
StartMs: segment.StartMs,
|
||||
EndMs: segment.EndMs,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, maskMarkers(markersResponse(markers), intro, credits))
|
||||
}
|
||||
|
||||
// markersResponse is the wire shape of a reading, and the one place the two halves are put
|
||||
// together — so a title with credits and no intro cannot accidentally report an intro
|
||||
// starting at zero.
|
||||
func markersResponse(markers chapterMarkers) introResponse {
|
||||
response := introResponse{}
|
||||
if markers.introFound {
|
||||
response.Available = true
|
||||
response.StartMs = markers.intro.StartMs
|
||||
response.EndMs = markers.intro.EndMs
|
||||
}
|
||||
if markers.creditsFound {
|
||||
response.CreditsAvailable = true
|
||||
response.CreditsStartMs = markers.creditsStart
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// maskMarkers withholds the half of a reading whose feature the operator has turned off.
|
||||
//
|
||||
// It happens on the way out rather than on the way in, which is what lets the cache hold
|
||||
// the unmasked truth: a feature switched back on takes effect on the next playback, instead
|
||||
// of serving a day of deliberate silence from an entry written while it was off.
|
||||
func maskMarkers(response introResponse, intro, credits bool) introResponse {
|
||||
if !intro {
|
||||
response.Available, response.StartMs, response.EndMs = false, 0, 0
|
||||
}
|
||||
if !credits {
|
||||
response.CreditsAvailable, response.CreditsStartMs = false, 0
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func (s *Server) skipIntroEnabled(ctx context.Context) bool {
|
||||
return s.emby != nil && s.featureEnabled(ctx, featureSkipIntro)
|
||||
}
|
||||
|
||||
func introCacheKey(itemID string) string { return "intro:v1:" + itemID }
|
||||
func (s *Server) endCreditsEnabled(ctx context.Context) bool {
|
||||
return s.emby != nil && s.featureEnabled(ctx, featureEndCredits)
|
||||
}
|
||||
|
||||
// introFor reads an item's chapter markers, remembering what they came to.
|
||||
// introCacheKey is v2 because the cached shape grew the credits marker. An entry written by
|
||||
// the previous build holds no `creditsAvailable`, and decoding it would report "no credits"
|
||||
// for a day on every title the house had already played — so the key moves rather than the
|
||||
// old entries being trusted.
|
||||
func introCacheKey(itemID string) string { return "intro:v2:" + itemID }
|
||||
|
||||
// markersFor reads an item's chapter markers, remembering what they came to.
|
||||
//
|
||||
// "No intro" is cached as well as an intro. It is the common case — a film, a special, an
|
||||
// episode Emby has not analysed yet — and without it every playback in the house would be
|
||||
// a fresh request to Emby for the same no.
|
||||
func (s *Server) introFor(
|
||||
// "Nothing found" is cached as well as a finding. It is the common case — a film, a special,
|
||||
// an episode Emby has not analysed yet — and without it every playback in the house would
|
||||
// be a fresh request to Emby for the same no.
|
||||
//
|
||||
// One reading answers for both features. The intro and the credits are the same field of
|
||||
// the same response, so splitting them into two lookups would have made the second one cost
|
||||
// a round trip it has no need to spend.
|
||||
func (s *Server) markersFor(
|
||||
ctx context.Context, sess store.Session, itemID string,
|
||||
) (introSegment, bool, error) {
|
||||
) (chapterMarkers, error) {
|
||||
key := introCacheKey(itemID)
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
var cached introResponse
|
||||
if json.Unmarshal(raw, &cached) == nil {
|
||||
return introSegment{StartMs: cached.StartMs, EndMs: cached.EndMs}, cached.Available, nil
|
||||
return chapterMarkers{
|
||||
intro: introSegment{StartMs: cached.StartMs, EndMs: cached.EndMs},
|
||||
introFound: cached.Available,
|
||||
creditsStart: cached.CreditsStartMs,
|
||||
creditsFound: cached.CreditsAvailable,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := s.emby.Item(ctx, credentials(sess), itemID, "Chapters")
|
||||
if err != nil {
|
||||
return introSegment{}, false, err
|
||||
return chapterMarkers{}, err
|
||||
}
|
||||
// RunTimeTicks rides along because the credits rule needs it: a chapter merely *named*
|
||||
// "Credits" cannot be told from "Opening Credits" without knowing how far into the file it
|
||||
// sits. It is a default field on this response, so asking for it costs nothing.
|
||||
var parsed struct {
|
||||
Chapters []embyChapter `json:"Chapters"`
|
||||
Chapters []embyChapter `json:"Chapters"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return introSegment{}, false, err
|
||||
return chapterMarkers{}, err
|
||||
}
|
||||
|
||||
segment, ok := introFromChapters(parsed.Chapters)
|
||||
segment, introFound := introFromChapters(parsed.Chapters)
|
||||
creditsStart, creditsFound := creditsFromChapters(
|
||||
parsed.Chapters, parsed.RunTimeTicks/ticksPerMillisecond,
|
||||
)
|
||||
markers := chapterMarkers{
|
||||
intro: segment,
|
||||
introFound: introFound,
|
||||
creditsStart: creditsStart,
|
||||
creditsFound: creditsFound,
|
||||
}
|
||||
|
||||
// The shorter "not analysed yet" life applies unless *something* was found. A title
|
||||
// with credits but no intro has been analysed, and re-asking hourly for the intro Emby
|
||||
// has already decided it has none of would be a request per playback for a settled no.
|
||||
ttl := introMissingTTL
|
||||
if ok {
|
||||
if introFound || creditsFound {
|
||||
ttl = introTTL
|
||||
}
|
||||
if encoded, err := json.Marshal(introResponse{
|
||||
Available: ok,
|
||||
StartMs: segment.StartMs,
|
||||
EndMs: segment.EndMs,
|
||||
}); err == nil {
|
||||
if encoded, err := json.Marshal(markersResponse(markers)); err == nil {
|
||||
_ = s.cache.Set(ctx, key, encoded, ttl)
|
||||
}
|
||||
return segment, ok, nil
|
||||
return markers, nil
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ func (s *Server) handleSeasonFinale(w http.ResponseWriter, r *http.Request, sess
|
||||
return
|
||||
}
|
||||
|
||||
series, err := s.sonarr.Series(ctx)
|
||||
series, err := s.sonarrSeriesCatalogue(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("season finale Sonarr series unavailable", "item", itemID, "error", err)
|
||||
s.writeSeasonFinaleResponse(ctx, key, empty, w)
|
||||
@@ -277,6 +277,26 @@ func (s *Server) handlePlayed(w http.ResponseWriter, r *http.Request, sess store
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHideFromResume(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
itemID := r.PathValue("id")
|
||||
if itemID == "" {
|
||||
writeError(w, http.StatusBadRequest, "item id is required")
|
||||
return
|
||||
}
|
||||
userData, err := s.emby.HideFromResume(r.Context(), credentials(sess), itemID)
|
||||
if err != nil {
|
||||
s.writeUpstreamError(r.Context(), w, err, "could not remove the item from Continue Watching")
|
||||
return
|
||||
}
|
||||
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
|
||||
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
|
||||
}
|
||||
if s.forYou != nil {
|
||||
s.forYou.MarkDirty(r.Context(), sess)
|
||||
}
|
||||
writeRaw(w, http.StatusOK, userData)
|
||||
}
|
||||
|
||||
// setFlag applies a user-data mutation and drops this user's cached views, so the next
|
||||
// home request reflects it rather than serving the row it just contradicted.
|
||||
func (s *Server) setFlag(
|
||||
|
||||
@@ -133,11 +133,11 @@ func componentFor(path string) string {
|
||||
return "auth"
|
||||
case path == "/v1/home", path == "/v1/features":
|
||||
return "home"
|
||||
case path == "/v1/preferences":
|
||||
case path == "/v1/preferences", path == "/v1/theme":
|
||||
return "settings"
|
||||
case path == "/v1/screensaver", path == "/v1/preroll":
|
||||
return "screensaver"
|
||||
case strings.HasPrefix(path, "/v1/search"):
|
||||
case strings.HasPrefix(path, "/v1/search"), strings.HasPrefix(path, "/v1/genres/"):
|
||||
return "search"
|
||||
case strings.HasPrefix(path, "/v1/requests"):
|
||||
return "requests"
|
||||
|
||||
@@ -42,6 +42,7 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) {
|
||||
"/v1/auth/login": "auth",
|
||||
"/v1/auth/devices/tv-1": "devices",
|
||||
"/v1/search": "search",
|
||||
"/v1/genres/Comedy/items": "search",
|
||||
"/v1/items/42": "details",
|
||||
"/v1/items/42/related": "details",
|
||||
"/v1/items/42/playback": "playback",
|
||||
|
||||
@@ -132,5 +132,13 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
|
||||
// fetches /v1/preferences when they differ. That is what turns this poll into the
|
||||
// delivery channel for an operator pushing someone's settings.
|
||||
"preferencesRevision": s.preferenceRevisionFor(r, sess),
|
||||
// The theme, as an id and a revision rather than the palette itself — the
|
||||
// preferencesRevision precedent, for the same reason. The set refetches /v1/theme
|
||||
// only when one of these moves, which is what makes a season arriving at midnight
|
||||
// cost one request per television instead of a palette on every ten-second poll.
|
||||
// It rides the poll rather than the sign-in because that is the whole point: a
|
||||
// season has to reach a set that is already switched on, without anybody doing
|
||||
// anything.
|
||||
"theme": themeStatus(s.themeFor(r.Context(), sess)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func (s *Server) listMyShows(w http.ResponseWriter, r *http.Request, sess store.
|
||||
}
|
||||
sonarrSeries := []sonarr.Series{}
|
||||
if s.sonarr != nil {
|
||||
if value, seriesErr := s.sonarr.Series(r.Context()); seriesErr == nil {
|
||||
if value, seriesErr := s.sonarrSeriesCatalogue(r.Context()); seriesErr == nil {
|
||||
sonarrSeries = value
|
||||
} else {
|
||||
s.loggerFor(r.Context()).Warn("Sonarr status unavailable for My Shows", "error", seriesErr)
|
||||
@@ -193,7 +193,7 @@ func (s *Server) syncReturnNotifications(
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
all, err := s.sonarr.Series(r.Context())
|
||||
all, err := s.sonarrSeriesCatalogue(r.Context())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ type playbackResponse struct {
|
||||
// already holds this response, and one boolean on a request that is made once per
|
||||
// playback is cheaper than a field on the poll every open TV makes every ten seconds.
|
||||
SubtitleDownloadAvailable bool `json:"subtitleDownloadAvailable"`
|
||||
// Whether at least one subtitle on this title can be checked against another readable
|
||||
// text track. Like the download flag, this rides on the playback response because only
|
||||
// the subtitle drop-up needs it, and defaults to false for older gateways on the client.
|
||||
SubtitleFixAvailable bool `json:"subtitleFixAvailable"`
|
||||
// Whether it is worth asking this gateway for seek previews. Only the answer rides
|
||||
// here; the manifest itself does not, because reading it costs a round trip to Emby
|
||||
// and this response is the one thing standing between a Play press and a decoder
|
||||
@@ -49,6 +53,11 @@ type playbackResponse struct {
|
||||
// trip to Emby, and nothing about a skip button is needed before the first frame. The
|
||||
// television asks for the segment itself once playback has settled.
|
||||
SkipIntroAvailable bool `json:"skipIntroAvailable"`
|
||||
// Whether it is worth asking where the closing credits begin. Same reasoning again, and
|
||||
// deliberately a second boolean rather than a reuse of SkipIntroAvailable: the two are
|
||||
// separate features with separate switches, and a house that has turned the skip button
|
||||
// off has not asked to lose the credits pane with it.
|
||||
EndCreditsAvailable bool `json:"endCreditsAvailable"`
|
||||
}
|
||||
|
||||
type playableSubtitle struct {
|
||||
@@ -205,8 +214,10 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
||||
PlaySessionID: playSessionID,
|
||||
PlayMethod: playMethod,
|
||||
SubtitleDownloadAvailable: s.subtitleDownloadAvailable(ctx),
|
||||
SubtitleFixAvailable: s.subtitleFixAvailable(subtitles),
|
||||
TrickplayAvailable: s.trickplayEnabled(ctx),
|
||||
SkipIntroAvailable: s.skipIntroEnabled(ctx),
|
||||
EndCreditsAvailable: s.endCreditsEnabled(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -432,6 +443,11 @@ func (s *Server) playbackSubtitles(
|
||||
Codec: stream.Codec,
|
||||
})
|
||||
}
|
||||
// Anything the gateway fetched itself joins the list here, so a subtitle downloaded
|
||||
// from a provider that cannot write beside the media file is an ordinary track on
|
||||
// every later playback — not something that exists only in the response to the
|
||||
// download that produced it.
|
||||
out = mergeSubtitleTracks(out, s.storedSubtitlesFor(ctx, itemID))
|
||||
delivery, playMethod := selectPlaybackDelivery(source, forceTranscode || subtitleIndex != nil)
|
||||
if delivery != "" {
|
||||
delivery = s.emby.DeliveryURL(cred, delivery)
|
||||
@@ -742,7 +758,7 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
|
||||
if json.Unmarshal(rawSeries, &seriesItem) != nil || strings.TrimSpace(seriesItem.Name) == "" {
|
||||
return ""
|
||||
}
|
||||
sonarrSeries, err := s.sonarr.Series(ctx)
|
||||
sonarrSeries, err := s.sonarrSeriesCatalogue(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("auto-follow Sonarr lookup failed", "error", err)
|
||||
return ""
|
||||
|
||||
@@ -112,6 +112,21 @@ var preferenceCatalogue = []preferenceDefinition{
|
||||
Description: "Use each title's logo artwork in place of plain text.",
|
||||
Kind: preferenceToggle, Default: true,
|
||||
},
|
||||
{
|
||||
// The one thing about themes a viewer decides. The options are the selectable
|
||||
// catalogue in themes.go rather than a list written out here, so a theme added
|
||||
// there cannot become a value this rejects.
|
||||
//
|
||||
// Note what this key does *not* control: whether a season is in force. That is
|
||||
// resolved server-side on top of this choice (resolveTheme), so a viewer's stored
|
||||
// selection survives underneath Halloween rather than being overwritten by it.
|
||||
// Note also that the per-user allowlist is not expressed here — this vocabulary is
|
||||
// the same for everybody, and an operator's restriction is applied at resolution.
|
||||
Key: "themeId", Name: "Colour scheme", Area: "Presentation",
|
||||
Description: "Which palette this viewer's televisions paint themselves.",
|
||||
Kind: preferenceChoice, Default: defaultThemeID,
|
||||
Options: themeOptions(),
|
||||
},
|
||||
{
|
||||
Key: "welcomeQuoteStyle", Name: "Welcome tone", Area: "Presentation",
|
||||
Description: "Tone of the short line shown after signing in.",
|
||||
@@ -143,6 +158,17 @@ var preferenceCatalogue = []preferenceDefinition{
|
||||
option(skipIntroOff, "Do nothing"),
|
||||
},
|
||||
},
|
||||
{
|
||||
// Default on: the whole feature is that it happens without being asked for, and it
|
||||
// is visible, reversible and over in a minute — a viewer who dislikes it turns it
|
||||
// off having seen exactly what it does. That is a different trade from
|
||||
// skipIntroMode's, which defaults to the button rather than the automatic seek
|
||||
// because a jump nobody can see coming is not recoverable by watching it.
|
||||
Key: "speedUpCredits", Name: "Speed through the credits", Area: "Playback",
|
||||
Description: "When an episode reaches its closing credits, shrink them to one side " +
|
||||
"at double speed and show what is on next beside them.",
|
||||
Kind: preferenceToggle, Default: true,
|
||||
},
|
||||
{
|
||||
Key: "subtitlesEnabled", Name: "Subtitles", Area: "Playback",
|
||||
Description: "Turn a subtitle track on automatically when the title has one.",
|
||||
|
||||
@@ -18,8 +18,67 @@ import (
|
||||
// deployment until the previous daily cache expires.
|
||||
const sonarrCalendarCachePrefix = "sonarr:calendar:v4:"
|
||||
const sonarrPrerollCachePrefix = "sonarr:preroll:v2:"
|
||||
const sonarrSeriesCacheKey = "sonarr:series:v1"
|
||||
const sonarrScheduleDays = 5
|
||||
|
||||
// sonarrSeriesCatalogue is Sonarr's whole series list, cached the way the calendar is.
|
||||
//
|
||||
// Every caller here wants the same thing — the lifecycle, monitored flag and next airing
|
||||
// for one or two shows — and each was paying `/api/v3/series` in full to get it. On a
|
||||
// household with a few hundred followed shows that is a large response Sonarr assembles
|
||||
// from its own database, and it sat in front of things a viewer is waiting on: adding a
|
||||
// show to My Shows, which fetches it *after* the write, and the season-finale lookup on a
|
||||
// detail page. This is where the second or two came from.
|
||||
//
|
||||
// It is shared rather than per user — Sonarr's catalogue belongs to the household, not to
|
||||
// whoever asked — and it takes MEMBY_SONARR_TTL, the same five minutes the calendar rows
|
||||
// take. Short enough that following a show in Sonarr shows up on the next visit, long
|
||||
// enough that a viewer working through My Shows pays for it once.
|
||||
//
|
||||
// Every failure degrades to asking Sonarr directly: a cache that is down must cost latency,
|
||||
// never the answer.
|
||||
func (s *Server) sonarrSeriesCatalogue(ctx context.Context) ([]sonarr.Series, error) {
|
||||
if s.sonarr == nil {
|
||||
return nil, fmt.Errorf("sonarr: not configured")
|
||||
}
|
||||
if series := s.cachedSonarrSeries(ctx); series != nil {
|
||||
return series, nil
|
||||
}
|
||||
|
||||
// The same kind of shared lock the calendar takes, for the same reason: several
|
||||
// televisions opening together must not each stampede Sonarr on the one miss. Its own
|
||||
// mutex rather than sonarrMu, so an add to My Shows never waits behind a launcher
|
||||
// rebuilding the schedule row.
|
||||
s.sonarrSeriesMu.Lock()
|
||||
defer s.sonarrSeriesMu.Unlock()
|
||||
if series := s.cachedSonarrSeries(ctx); series != nil {
|
||||
return series, nil
|
||||
}
|
||||
|
||||
series, err := s.sonarr.Series(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body, marshalErr := json.Marshal(series); marshalErr == nil {
|
||||
if cacheErr := s.cache.Set(ctx, sonarrSeriesCacheKey, body, s.cfg.SonarrTTL); cacheErr != nil {
|
||||
s.loggerFor(ctx).Warn("sonarr series cache write failed", "error", cacheErr)
|
||||
}
|
||||
}
|
||||
return series, nil
|
||||
}
|
||||
|
||||
func (s *Server) cachedSonarrSeries(ctx context.Context) []sonarr.Series {
|
||||
raw, err := s.cache.Get(ctx, sonarrSeriesCacheKey)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var series []sonarr.Series
|
||||
if json.Unmarshal(raw, &series) != nil {
|
||||
return nil
|
||||
}
|
||||
return series
|
||||
}
|
||||
|
||||
type prerollScheduleResponse struct {
|
||||
Today []prerollScheduleEntry `json:"today"`
|
||||
ThisWeek []prerollScheduleEntry `json:"thisWeek"`
|
||||
|
||||
@@ -5,31 +5,35 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/bazarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/opensubtitles"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// Fetching a subtitle a title does not have.
|
||||
//
|
||||
// The whole feature rests on one property of Bazarr: it writes the subtitle file beside
|
||||
// the media file. So the gateway never stores a subtitle, never serves one, and never
|
||||
// learns a provider's credentials — it asks Bazarr to fetch, asks Emby to look again, and
|
||||
// the track then arrives down the same PlaybackInfo path as an embedded one. That is why
|
||||
// `playableSubtitle` needed no new shape and the player's existing selection rule works on
|
||||
// a downloaded track with no special case.
|
||||
// Two providers answer this now and they are not the same shape. **Bazarr** writes the
|
||||
// subtitle file beside the media file, so the gateway asks and forgets — Emby finds the
|
||||
// result on a refresh and the track arrives down the same PlaybackInfo path as an embedded
|
||||
// one, which is why `playableSubtitle` needed no new shape. **OpenSubtitles** hands back
|
||||
// bytes, and the gateway has no reach into the media directory, so a file fetched there is
|
||||
// stored by the gateway and served back as a sidecar. `subtitle_providers.go` holds that
|
||||
// difference and everything here is written against the one vocabulary.
|
||||
//
|
||||
// The hard part is not the download, it is the identity. Bazarr keys everything on the
|
||||
// *arr's id (`radarrid` for a film, Sonarr's `episodeid` for an episode) and Emby knows
|
||||
// nothing about either, so an Emby item has to be matched onto one by title, year and —
|
||||
// for an episode — season and episode number. That matching is pure and unit-tested
|
||||
// (`bazarrMovieFor`, `bazarrEpisodeFor`), because it is where a wrong answer is worst: a
|
||||
// mismatch downloads a subtitle for the wrong film and writes it next to this one.
|
||||
// The hard part is not the download, it is the identity, and the two providers make
|
||||
// opposite trades on it. Bazarr keys everything on the *arr's id (`radarrid` for a film,
|
||||
// Sonarr's `episodeid` for an episode) and Emby knows nothing about either, so an item has
|
||||
// to be matched onto one by title, year and — for an episode — season and episode number.
|
||||
// That matching is pure and unit-tested (`bazarrMovieFor`, `bazarrEpisodeFor`), because it
|
||||
// is where a wrong answer is worst: a mismatch downloads a subtitle for the wrong film and
|
||||
// writes it next to this one. OpenSubtitles keys on an imdb or tmdb id, which Memby
|
||||
// already holds — the library import asks Emby for `ProviderIds` so external ratings can
|
||||
// be looked up — so there is no guessing at all on that path.
|
||||
|
||||
const (
|
||||
bazarrMoviesCacheKey = "bazarr:movies"
|
||||
@@ -53,6 +57,12 @@ const (
|
||||
// expire underneath them, which on a set being operated by remote control is the more
|
||||
// likely failure of the two.
|
||||
type subtitleCandidate struct {
|
||||
// Source is which backend produced this row, and it is what the download call
|
||||
// dispatches on. It round-trips through the television with the token, because the two
|
||||
// providers' tokens are opaque in different ways and handing one to the other is a
|
||||
// mistake nothing downstream could detect. Empty means Bazarr: an app built before
|
||||
// there was a second provider sends no source, and its rows all came from one place.
|
||||
Source string `json:"source,omitempty"`
|
||||
Token string `json:"token"`
|
||||
Language string `json:"language"`
|
||||
LanguageLabel string `json:"languageLabel"`
|
||||
@@ -61,6 +71,17 @@ type subtitleCandidate struct {
|
||||
Forced bool `json:"forced"`
|
||||
HearingImpaired bool `json:"hearingImpaired"`
|
||||
OriginalFormat bool `json:"originalFormat"`
|
||||
// MachineOnly marks a translation nobody wrote. It is on the wire rather than folded
|
||||
// into the label alone because it is the one property that changes whether a viewer
|
||||
// wants the row at all, and the ranking sinks it below everything a person wrote.
|
||||
MachineOnly bool `json:"machineOnly,omitempty"`
|
||||
// Release is the file's own release string, carried for the log rather than the screen
|
||||
// — "Interstellar.2014.1080p.BluRay" means nothing across a lounge.
|
||||
Release string `json:"-"`
|
||||
// format is the extension the provider's file carries. Lower case and unexported: it
|
||||
// is the gateway's own bookkeeping for a file it is about to store, and there is
|
||||
// nothing for a television to do with it.
|
||||
format string
|
||||
// Label is what the drop-up prints. It is composed here rather than on the TV so an
|
||||
// older app renders a new wording correctly, the same reason alert labels are the
|
||||
// gateway's.
|
||||
@@ -96,11 +117,11 @@ type subtitleDownloadResponse struct {
|
||||
}
|
||||
|
||||
// subtitleDownloadAvailable is the one thing the television needs to know: whether to
|
||||
// offer the option at all. Both halves matter — an operator can turn the feature off on a
|
||||
// deployment that has Bazarr, and a deployment without Bazarr must never show a row that
|
||||
// cannot do anything.
|
||||
// offer the option at all. It is true when the feature is on and at least one provider is
|
||||
// both configured and switched on — a deployment with neither must never draw a row that
|
||||
// leads to a request nothing can answer.
|
||||
func (s *Server) subtitleDownloadAvailable(ctx context.Context) bool {
|
||||
return s.bazarr != nil && s.featureEnabled(ctx, featureSubtitleDownload)
|
||||
return s.subtitleSources(ctx).any()
|
||||
}
|
||||
|
||||
func (s *Server) handleSubtitleSearch(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
@@ -110,12 +131,13 @@ func (s *Server) handleSubtitleSearch(w http.ResponseWriter, r *http.Request, se
|
||||
writeError(w, http.StatusBadRequest, "item id is required")
|
||||
return
|
||||
}
|
||||
if !s.subtitleDownloadAvailable(ctx) {
|
||||
sources := s.subtitleSources(ctx)
|
||||
if !sources.any() {
|
||||
writeError(w, http.StatusNotFound, "subtitle downloads are not available")
|
||||
return
|
||||
}
|
||||
|
||||
target, err := s.resolveBazarrTarget(ctx, credentials(sess), itemID)
|
||||
target, err := s.resolveSubtitleTarget(ctx, credentials(sess), itemID, sources)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("subtitle target lookup failed", "item", itemID, "error", err)
|
||||
writeJSON(w, http.StatusOK, subtitleSearchResponse{
|
||||
@@ -125,33 +147,32 @@ func (s *Server) handleSubtitleSearch(w http.ResponseWriter, r *http.Request, se
|
||||
return
|
||||
}
|
||||
|
||||
found, err := s.searchBazarr(ctx, target)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("subtitle search failed",
|
||||
"item", itemID, "title", target.Title, "error", err,
|
||||
)
|
||||
writeJSON(w, http.StatusOK, subtitleSearchResponse{
|
||||
Results: []subtitleCandidate{},
|
||||
Message: "The subtitle service did not answer.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
language := strings.TrimSpace(r.URL.Query().Get("language"))
|
||||
if language == "" {
|
||||
_, language = s.subtitlePreferenceFor(ctx, sess)
|
||||
}
|
||||
results := rankSubtitleCandidates(found, language)
|
||||
found, failures := s.providerSubtitles(ctx, sources, target, language)
|
||||
for _, failure := range failures {
|
||||
s.loggerFor(ctx).Warn("subtitle search failed",
|
||||
"item", itemID, "title", target.Title, "error", failure,
|
||||
)
|
||||
}
|
||||
results := rankMergedCandidates(found, language)
|
||||
s.loggerFor(ctx).Info("subtitle search",
|
||||
"title", target.Title,
|
||||
"item", itemID,
|
||||
"language", clientLogValue(language),
|
||||
"bazarr", sources.Bazarr && target.hasBazarr,
|
||||
"opensubtitles", sources.OpenSubtitles && target.hasQuery,
|
||||
"found", len(found),
|
||||
"offered", len(results),
|
||||
)
|
||||
response := subtitleSearchResponse{Results: results}
|
||||
if len(results) == 0 {
|
||||
response.Message = "No subtitles were found for this release."
|
||||
// Which of the two empty answers this is matters to somebody standing in front of
|
||||
// the set: providers that found nothing is a different thing from providers that
|
||||
// did not answer, and an exhausted allowance is a third.
|
||||
response.Message = subtitleFailureMessage(failures)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
@@ -163,7 +184,8 @@ func (s *Server) handleSubtitleDownload(w http.ResponseWriter, r *http.Request,
|
||||
writeError(w, http.StatusBadRequest, "item id is required")
|
||||
return
|
||||
}
|
||||
if !s.subtitleDownloadAvailable(ctx) {
|
||||
sources := s.subtitleSources(ctx)
|
||||
if !sources.any() {
|
||||
writeError(w, http.StatusNotFound, "subtitle downloads are not available")
|
||||
return
|
||||
}
|
||||
@@ -173,54 +195,49 @@ func (s *Server) handleSubtitleDownload(w http.ResponseWriter, r *http.Request,
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(request.Candidate.Token) == "" {
|
||||
candidate := request.Candidate
|
||||
candidate.Language = normalizeSubtitleLanguage(candidate.Language)
|
||||
if strings.TrimSpace(candidate.Token) == "" {
|
||||
writeError(w, http.StatusBadRequest, "a subtitle is required")
|
||||
return
|
||||
}
|
||||
|
||||
cred := credentials(sess)
|
||||
target, err := s.resolveBazarrTarget(ctx, cred, itemID)
|
||||
target, err := s.resolveSubtitleTarget(ctx, cred, itemID, sources)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("subtitle target lookup failed", "item", itemID, "error", err)
|
||||
writeError(w, http.StatusNotFound, "Memby could not work out which title this is")
|
||||
return
|
||||
}
|
||||
|
||||
subtitle := bazarr.Subtitle{
|
||||
Language: request.Candidate.Language,
|
||||
Provider: request.Candidate.Provider,
|
||||
Token: request.Candidate.Token,
|
||||
Forced: request.Candidate.Forced,
|
||||
HearingImpaired: request.Candidate.HearingImpaired,
|
||||
OriginalFormat: request.Candidate.OriginalFormat,
|
||||
}
|
||||
if target.EpisodeID > 0 {
|
||||
err = s.bazarr.DownloadEpisode(ctx, target.SeriesID, target.EpisodeID, subtitle)
|
||||
} else {
|
||||
err = s.bazarr.DownloadMovie(ctx, target.RadarrID, subtitle)
|
||||
}
|
||||
fetched, err := s.fetchSubtitle(ctx, cred, itemID, target, candidate)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("subtitle download failed",
|
||||
"title", target.Title, "item", itemID,
|
||||
"provider", clientLogValue(subtitle.Provider), "error", err,
|
||||
"source", clientLogValue(candidate.Source),
|
||||
"provider", clientLogValue(candidate.Provider), "error", err,
|
||||
)
|
||||
writeError(w, http.StatusBadGateway, "the subtitle could not be downloaded")
|
||||
writeError(w, http.StatusBadGateway, subtitleDownloadFailureMessage(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Bazarr has written the file; Emby does not know it exists. Refreshing is what makes
|
||||
// the track appear, and it is best-effort: if it fails the file is still on disk and
|
||||
// the next ordinary scan picks it up, so the viewer is told to try again rather than
|
||||
// told the download failed when it did not.
|
||||
if refreshErr := s.emby.RefreshItem(ctx, cred, itemID); refreshErr != nil {
|
||||
s.loggerFor(ctx).Warn("emby refresh after subtitle download failed",
|
||||
"item", itemID, "error", refreshErr,
|
||||
)
|
||||
}
|
||||
select {
|
||||
case <-time.After(embyRefreshSettleDelay):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
// Bazarr has written a file Emby does not know exists, and refreshing is what makes
|
||||
// the track appear. It is best-effort: if it fails the file is still on disk and the
|
||||
// next ordinary scan picks it up, so the viewer is told to try again rather than told
|
||||
// the download failed when it did not. A subtitle the gateway serves itself needs none
|
||||
// of this, and waiting anyway would spend a couple of seconds of somebody's film on
|
||||
// nothing.
|
||||
if fetched.RefreshEmby {
|
||||
if refreshErr := s.emby.RefreshItem(ctx, cred, itemID); refreshErr != nil {
|
||||
s.loggerFor(ctx).Warn("emby refresh after subtitle download failed",
|
||||
"item", itemID, "error", refreshErr,
|
||||
)
|
||||
}
|
||||
select {
|
||||
case <-time.After(embyRefreshSettleDelay):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
subtitles, mediaSourceID, playSessionID, negotiatedURL, _ := s.playbackSubtitles(
|
||||
@@ -230,24 +247,43 @@ func (s *Server) handleSubtitleDownload(w http.ResponseWriter, r *http.Request,
|
||||
if negotiatedURL != "" {
|
||||
streamURL = negotiatedURL
|
||||
}
|
||||
// A file the gateway stored names itself, so the player can be pointed at exactly the
|
||||
// track that was just fetched. Only Bazarr's path has to guess, and it says so by
|
||||
// answering empty.
|
||||
selected := fetched.StoredID
|
||||
if selected == "" {
|
||||
selected = newestSubtitleID(subtitles, candidate)
|
||||
}
|
||||
|
||||
s.loggerFor(ctx).Info("subtitle downloaded",
|
||||
"title", target.Title,
|
||||
"item", itemID,
|
||||
"language", clientLogValue(subtitle.Language),
|
||||
"provider", clientLogValue(subtitle.Provider),
|
||||
"language", clientLogValue(candidate.Language),
|
||||
"source", clientLogValue(candidate.Source),
|
||||
"provider", clientLogValue(candidate.Provider),
|
||||
"release", clientLogValue(candidate.Release),
|
||||
"subtitles", len(subtitles),
|
||||
)
|
||||
writeJSON(w, http.StatusOK, subtitleDownloadResponse{
|
||||
Message: downloadedSubtitleMessage(request.Candidate),
|
||||
Message: downloadedSubtitleMessage(candidate),
|
||||
Subtitles: subtitles,
|
||||
SelectedSubtitleID: newestSubtitleID(subtitles, request.Candidate),
|
||||
SelectedSubtitleID: selected,
|
||||
MediaSourceID: mediaSourceID,
|
||||
PlaySessionID: playSessionID,
|
||||
URL: streamURL,
|
||||
})
|
||||
}
|
||||
|
||||
// subtitleDownloadFailureMessage is the sentence a television prints when a fetch fails.
|
||||
// The allowance running out keeps its own wording for the reason the search's does:
|
||||
// pressing the button again will not fix it, and nothing else on the set can say so.
|
||||
func subtitleDownloadFailureMessage(err error) string {
|
||||
if _, ok := err.(*opensubtitles.QuotaError); ok {
|
||||
return "today's subtitle downloads have been used up"
|
||||
}
|
||||
return "the subtitle could not be downloaded"
|
||||
}
|
||||
|
||||
// bazarrTarget is an Emby item resolved onto the ids Bazarr keys on. Exactly one of
|
||||
// RadarrID and EpisodeID is set.
|
||||
type bazarrTarget struct {
|
||||
@@ -419,83 +455,6 @@ func bazarrEpisodeFor(episodes []bazarr.Episode, season, number int) *bazarr.Epi
|
||||
return nil
|
||||
}
|
||||
|
||||
// rankSubtitleCandidates orders what the viewer sees and caps the list.
|
||||
//
|
||||
// The viewer's language comes first, because it is the only thing they asked for; within
|
||||
// that, Bazarr's own score decides, because it is the only number on the row that means
|
||||
// anything on a television. Forced and hearing-impaired tracks sort below plain ones in
|
||||
// the same language for the reason the selection rule already gives — somebody who chose
|
||||
// Italian wants the dialogue, not the signs.
|
||||
func rankSubtitleCandidates(found []bazarr.Subtitle, language string) []subtitleCandidate {
|
||||
preferred := normalizeSubtitleLanguage(language)
|
||||
if preferred == subtitleLanguageAuto {
|
||||
preferred = ""
|
||||
}
|
||||
ordered := make([]bazarr.Subtitle, len(found))
|
||||
copy(ordered, found)
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
left, right := ordered[i], ordered[j]
|
||||
leftPreferred := preferred != "" && normalizeSubtitleLanguage(left.Language) == preferred
|
||||
rightPreferred := preferred != "" && normalizeSubtitleLanguage(right.Language) == preferred
|
||||
if leftPreferred != rightPreferred {
|
||||
return leftPreferred
|
||||
}
|
||||
if leftVariant, rightVariant := subtitleVariantRank(left), subtitleVariantRank(right); leftVariant != rightVariant {
|
||||
return leftVariant < rightVariant
|
||||
}
|
||||
return left.Score > right.Score
|
||||
})
|
||||
if len(ordered) > maxSubtitleResults {
|
||||
ordered = ordered[:maxSubtitleResults]
|
||||
}
|
||||
results := make([]subtitleCandidate, 0, len(ordered))
|
||||
for _, subtitle := range ordered {
|
||||
if strings.TrimSpace(subtitle.Token) == "" {
|
||||
continue
|
||||
}
|
||||
results = append(results, subtitleCandidate{
|
||||
Token: subtitle.Token,
|
||||
Language: normalizeSubtitleLanguage(subtitle.Language),
|
||||
LanguageLabel: subtitleLanguageLabel(subtitle.Language),
|
||||
Provider: subtitle.Provider,
|
||||
Score: subtitle.Score,
|
||||
Forced: subtitle.Forced,
|
||||
HearingImpaired: subtitle.HearingImpaired,
|
||||
OriginalFormat: subtitle.OriginalFormat,
|
||||
Label: subtitleCandidateLabel(subtitle),
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func subtitleVariantRank(subtitle bazarr.Subtitle) int {
|
||||
switch {
|
||||
case subtitle.Forced:
|
||||
return 2
|
||||
case subtitle.HearingImpaired:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// subtitleCandidateLabel is what one row says: the language, what kind of track it is, and
|
||||
// how well Bazarr thinks it matches. The provider is deliberately absent — a viewer has no
|
||||
// way to prefer one and the name would only crowd the row.
|
||||
func subtitleCandidateLabel(subtitle bazarr.Subtitle) string {
|
||||
label := subtitleLanguageLabel(subtitle.Language)
|
||||
switch {
|
||||
case subtitle.Forced:
|
||||
label += " · Forced"
|
||||
case subtitle.HearingImpaired:
|
||||
label += " · Hearing impaired"
|
||||
}
|
||||
if subtitle.Score > 0 {
|
||||
label += fmt.Sprintf(" · %d%% match", clampPercent(subtitle.Score))
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
func clampPercent(value int) int {
|
||||
if value < 0 {
|
||||
return 0
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestRankSubtitleCandidatesPutsTheChosenLanguageFirst(t *testing.T) {
|
||||
{Language: "ita", Score: 98, Token: "it-forced", Forced: true},
|
||||
{Language: "ita", Score: 95, Token: "it-sdh", HearingImpaired: true},
|
||||
}
|
||||
results := rankSubtitleCandidates(found, "it")
|
||||
results := rankMergedCandidates(bazarrCandidates(found), "it")
|
||||
if len(results) != 4 {
|
||||
t.Fatalf("results = %+v", results)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func TestRankSubtitleCandidatesFallsBackToScoreWithNoPreference(t *testing.T) {
|
||||
{Language: "eng", Score: 92, Token: "high"},
|
||||
}
|
||||
for _, language := range []string{"", "auto"} {
|
||||
results := rankSubtitleCandidates(found, language)
|
||||
results := rankMergedCandidates(bazarrCandidates(found), language)
|
||||
if len(results) != 2 || results[0].Token != "high" {
|
||||
t.Fatalf("language %q gave %+v", language, results)
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func TestRankSubtitleCandidatesDropsTokenlessRowsAndCaps(t *testing.T) {
|
||||
for i := 0; i < maxSubtitleResults+5; i++ {
|
||||
found = append(found, bazarr.Subtitle{Language: "eng", Score: i, Token: "t"})
|
||||
}
|
||||
results := rankSubtitleCandidates(found, "en")
|
||||
results := rankMergedCandidates(bazarrCandidates(found), "en")
|
||||
if len(results) > maxSubtitleResults {
|
||||
t.Fatalf("len = %d", len(results))
|
||||
}
|
||||
@@ -138,21 +138,24 @@ func TestRankSubtitleCandidatesDropsTokenlessRowsAndCaps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleCandidateLabelNamesTheLanguageAndKind(t *testing.T) {
|
||||
func TestMergedCandidateLabelNamesTheLanguageAndKind(t *testing.T) {
|
||||
label := func(subtitle bazarr.Subtitle) string {
|
||||
return mergedCandidateLabel(bazarrCandidates([]bazarr.Subtitle{subtitle})[0])
|
||||
}
|
||||
for _, testCase := range []struct {
|
||||
subtitle bazarr.Subtitle
|
||||
want string
|
||||
}{
|
||||
{bazarr.Subtitle{Language: "ita", Score: 97}, "Italian · 97% match"},
|
||||
{bazarr.Subtitle{Language: "eng", Forced: true, Score: 80}, "English · Forced · 80% match"},
|
||||
{bazarr.Subtitle{Language: "eng", HearingImpaired: true}, "English · Hearing impaired"},
|
||||
{bazarr.Subtitle{Language: "ita", Score: 97, Token: "t"}, "Italian · 97% match"},
|
||||
{bazarr.Subtitle{Language: "eng", Forced: true, Score: 80, Token: "t"}, "English · Forced · 80% match"},
|
||||
{bazarr.Subtitle{Language: "eng", HearingImpaired: true, Token: "t"}, "English · Hearing impaired"},
|
||||
// A language Memby has no entry for must still name itself rather than go blank.
|
||||
{bazarr.Subtitle{Language: "mi"}, "MI"},
|
||||
{bazarr.Subtitle{}, "Unknown"},
|
||||
{bazarr.Subtitle{Language: "mi", Token: "t"}, "MI"},
|
||||
{bazarr.Subtitle{Token: "t"}, "Unknown"},
|
||||
// Bazarr has been seen to return scores above 100; a "112% match" reads as a bug.
|
||||
{bazarr.Subtitle{Language: "eng", Score: 112}, "English · 100% match"},
|
||||
{bazarr.Subtitle{Language: "eng", Score: 112, Token: "t"}, "English · 100% match"},
|
||||
} {
|
||||
if got := subtitleCandidateLabel(testCase.subtitle); got != testCase.want {
|
||||
if got := label(testCase.subtitle); got != testCase.want {
|
||||
t.Errorf("label = %q, want %q", got, testCase.want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
"github.com/ponzischeme89/memby/server/internal/subsync"
|
||||
)
|
||||
|
||||
// Fixing a subtitle's timing.
|
||||
//
|
||||
// The whole of the work is in internal/subsync, which is pure. What lives here is the part
|
||||
// that cannot be: choosing which track to measure against, reading both of them out of
|
||||
// wherever they happen to live, and storing the result as an ordinary sidecar so it is a
|
||||
// track on every later playback rather than something that exists only in this response.
|
||||
//
|
||||
// It is deliberately a separate route from the download one. A viewer whose subtitle is
|
||||
// out of sync already *has* the file they want, and sending them through a provider search
|
||||
// to get another copy of it — which may be equally out — is the wrong answer to what they
|
||||
// asked.
|
||||
|
||||
// subtitleFixSuffix marks the repaired copy. It is part of the stored id, so fixing the
|
||||
// same track twice replaces the earlier attempt instead of growing a third row that a
|
||||
// viewer has to tell apart from the other two by guessing.
|
||||
const subtitleFixSuffix = "fixed"
|
||||
|
||||
type subtitleFixRequest struct {
|
||||
SubtitleID string `json:"subtitleId"`
|
||||
}
|
||||
|
||||
type subtitleFixResponse struct {
|
||||
// SubtitleID is the new track to select, absent when nothing needed changing.
|
||||
SubtitleID string `json:"subtitleId,omitempty"`
|
||||
Message string `json:"message"`
|
||||
// Changed is false when the track was already in sync. It is on the wire rather than
|
||||
// inferred from an empty id because "already right" is a success a viewer should be
|
||||
// told about plainly, not a silent no-op that reads as the button having failed.
|
||||
Changed bool `json:"changed"`
|
||||
// OffsetMs and Reference are what was done and what it was judged against. A viewer
|
||||
// deciding whether to trust the result needs both.
|
||||
OffsetMs int64 `json:"offsetMs"`
|
||||
Reference string `json:"reference,omitempty"`
|
||||
}
|
||||
|
||||
// subtitleFixAvailable answers the smaller question the playback response needs: whether
|
||||
// opening the timing action can lead anywhere for this title. The handler remains
|
||||
// authoritative because a track can disappear or fail to parse between playback and the
|
||||
// press, but not advertising an impossible action avoids a guaranteed refusal in the menu.
|
||||
func (s *Server) subtitleFixAvailable(tracks []playableSubtitle) bool {
|
||||
if s.store == nil {
|
||||
return false
|
||||
}
|
||||
for _, target := range tracks {
|
||||
if len(referenceCandidates(target, tracks)) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) handleSubtitleFix(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
ctx := r.Context()
|
||||
itemID := r.PathValue("id")
|
||||
if s.store == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "this server cannot fix subtitle timing")
|
||||
return
|
||||
}
|
||||
|
||||
var req subtitleFixRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid subtitle fix request")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.SubtitleID) == "" {
|
||||
writeError(w, http.StatusBadRequest, "no subtitle was named")
|
||||
return
|
||||
}
|
||||
|
||||
cred := credentials(sess)
|
||||
tracks, mediaSourceID, _, _, _ := s.playbackSubtitles(
|
||||
ctx, cred, itemID, 0, nil, "", false, sessionPlaybackCapabilities(sess),
|
||||
)
|
||||
|
||||
target, ok := trackByID(tracks, req.SubtitleID)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "that subtitle is no longer on this title")
|
||||
return
|
||||
}
|
||||
|
||||
log := s.loggerFor(ctx)
|
||||
fixed, err := s.fixSubtitleTiming(ctx, cred, itemID, mediaSourceID, target, tracks)
|
||||
if err != nil {
|
||||
var refusal *subsync.ErrNoAlignment
|
||||
if errors.As(err, &refusal) {
|
||||
// A refusal is the ordinary answer, not a fault: most of subsync's job is
|
||||
// declining to guess. It is reported as a 200 carrying the reason, because
|
||||
// the viewer needs the sentence and an error status would have the client
|
||||
// print its own generic one over the top of it.
|
||||
log.Info("subtitle timing not fixed", "item_id", itemID,
|
||||
"subtitle", req.SubtitleID, "reason", refusal.Reason,
|
||||
"score", refusal.Score, "margin", refusal.Margin)
|
||||
writeJSON(w, http.StatusOK, subtitleFixResponse{Message: refusal.Reason})
|
||||
return
|
||||
}
|
||||
log.Warn("subtitle fix failed", "item_id", itemID, "subtitle", req.SubtitleID, "error", err)
|
||||
writeError(w, http.StatusBadGateway, subtitleFixFailureMessage(err))
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("subtitle timing fixed", "item_id", itemID, "subtitle", req.SubtitleID,
|
||||
"reference", fixed.Reference, "correction", fixed.Correction,
|
||||
"score", fixed.Score, "changed", fixed.Changed)
|
||||
writeJSON(w, http.StatusOK, fixed.response())
|
||||
}
|
||||
|
||||
type subtitleFixOutcome struct {
|
||||
StoredID string
|
||||
Reference string
|
||||
Correction string
|
||||
OffsetMs int64
|
||||
Score float64
|
||||
Changed bool
|
||||
}
|
||||
|
||||
func (o subtitleFixOutcome) response() subtitleFixResponse {
|
||||
if !o.Changed {
|
||||
return subtitleFixResponse{
|
||||
Message: "This subtitle is already in time with " + o.Reference + ".",
|
||||
Reference: o.Reference,
|
||||
}
|
||||
}
|
||||
return subtitleFixResponse{
|
||||
SubtitleID: o.StoredID,
|
||||
Message: fmt.Sprintf("Timing corrected by %s against %s. The fixed copy is in the list.",
|
||||
o.Correction, o.Reference),
|
||||
Changed: true,
|
||||
OffsetMs: o.OffsetMs,
|
||||
Reference: o.Reference,
|
||||
}
|
||||
}
|
||||
|
||||
// fixSubtitleTiming reads both tracks, aligns them, and stores the result.
|
||||
func (s *Server) fixSubtitleTiming(
|
||||
ctx context.Context, cred emby.Credentials, itemID, mediaSourceID string,
|
||||
target playableSubtitle, tracks []playableSubtitle,
|
||||
) (subtitleFixOutcome, error) {
|
||||
brokenRaw, err := s.subtitleContent(ctx, cred, itemID, mediaSourceID, target)
|
||||
if err != nil {
|
||||
return subtitleFixOutcome{}, fmt.Errorf("read the subtitle being fixed: %w", err)
|
||||
}
|
||||
broken, err := subsync.Parse(brokenRaw)
|
||||
if err != nil {
|
||||
return subtitleFixOutcome{}, fmt.Errorf("parse the subtitle being fixed: %w", err)
|
||||
}
|
||||
|
||||
reference, referenceTrack, err := s.referenceTrack(ctx, cred, itemID, mediaSourceID, target, tracks)
|
||||
if err != nil {
|
||||
return subtitleFixOutcome{}, err
|
||||
}
|
||||
|
||||
opts := subsync.DefaultOptions()
|
||||
result, err := subsync.Align(broken, reference, opts)
|
||||
if err != nil {
|
||||
return subtitleFixOutcome{}, err
|
||||
}
|
||||
|
||||
outcome := subtitleFixOutcome{
|
||||
Reference: subtitleTrackName(referenceTrack),
|
||||
Correction: result.String(),
|
||||
OffsetMs: result.Offset.Milliseconds(),
|
||||
Score: result.Score,
|
||||
Changed: result.Correction(),
|
||||
}
|
||||
if !outcome.Changed {
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
stored := store.DownloadedSubtitle{
|
||||
ID: fixedSubtitleID(itemID, target),
|
||||
ItemID: itemID,
|
||||
Language: target.Language,
|
||||
Label: fixedSubtitleLabel(target),
|
||||
Forced: target.IsForced,
|
||||
HearingImpaired: target.IsHearingImpaired,
|
||||
Format: "srt",
|
||||
Provider: store.SubtitleProviderMemby,
|
||||
Content: subsync.FormatSRT(subsync.Shift(broken, result.Offset, result.Scale)),
|
||||
}
|
||||
if err := s.store.PutDownloadedSubtitle(ctx, stored); err != nil {
|
||||
return subtitleFixOutcome{}, fmt.Errorf("store the fixed subtitle: %w", err)
|
||||
}
|
||||
outcome.StoredID = stored.ID
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// referenceTrack picks and reads the yardstick.
|
||||
//
|
||||
// Reading is the expensive half — an embedded track is an Emby request each — so the
|
||||
// candidates are read lazily in the order subsync.Reference would rank them, and the first
|
||||
// one that parses wins. A track that will not parse is simply not a reference; failing the
|
||||
// whole fix because the third-choice yardstick is malformed would be perverse.
|
||||
func (s *Server) referenceTrack(
|
||||
ctx context.Context, cred emby.Credentials, itemID, mediaSourceID string,
|
||||
target playableSubtitle, tracks []playableSubtitle,
|
||||
) ([]subsync.Cue, playableSubtitle, error) {
|
||||
candidates := referenceCandidates(target, tracks)
|
||||
if len(candidates) == 0 {
|
||||
return nil, playableSubtitle{}, &subsync.ErrNoAlignment{
|
||||
Reason: "there is no other subtitle on this title to check the timing against",
|
||||
}
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
raw, err := s.subtitleContent(ctx, cred, itemID, mediaSourceID, candidate)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Debug("reference subtitle unreadable",
|
||||
"item_id", itemID, "subtitle", candidate.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
cues, err := subsync.Parse(raw)
|
||||
if err != nil || len(cues) < subsync.DefaultOptions().MinCues {
|
||||
continue
|
||||
}
|
||||
return cues, candidate, nil
|
||||
}
|
||||
return nil, playableSubtitle{}, &subsync.ErrNoAlignment{
|
||||
Reason: "none of the other subtitles on this title could be read as a timing reference",
|
||||
}
|
||||
}
|
||||
|
||||
// referenceCandidates orders the tracks worth measuring against, best first.
|
||||
//
|
||||
// The rules are about what makes a usable yardstick rather than a good subtitle. A forced
|
||||
// track carries only what is foreign to the film's own audio, so it is mostly silence and
|
||||
// would agree with almost any shift — it is excluded rather than ranked last. A track the
|
||||
// gateway already fixed is preferred, since it has been checked against something. After
|
||||
// that, a track in the same language is the closest match in line breaks and therefore in
|
||||
// timing, and an embedded track outranks a downloaded one because it shipped with this
|
||||
// copy of the film.
|
||||
func referenceCandidates(target playableSubtitle, tracks []playableSubtitle) []playableSubtitle {
|
||||
out := make([]playableSubtitle, 0, len(tracks))
|
||||
for _, track := range tracks {
|
||||
if track.ID == target.ID || track.IsForced {
|
||||
continue
|
||||
}
|
||||
if !isStoredSubtitleID(track.ID) && track.URL == "" {
|
||||
// An embedded track Emby will not deliver as text: it is burned in or needs a
|
||||
// transcode, and there is nothing to read.
|
||||
continue
|
||||
}
|
||||
out = append(out, track)
|
||||
}
|
||||
rank := func(track playableSubtitle) int {
|
||||
switch {
|
||||
case strings.HasSuffix(track.ID, ":"+subtitleFixSuffix):
|
||||
return 0
|
||||
case target.Language != "" && strings.EqualFold(track.Language, target.Language):
|
||||
return 1
|
||||
case !isStoredSubtitleID(track.ID):
|
||||
return 2
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
// A stable sort, so tracks of equal rank keep the order Emby listed them in — which
|
||||
// puts the default track first, and the default is usually the one that is right.
|
||||
sort.SliceStable(out, func(i, j int) bool { return rank(out[i]) < rank(out[j]) })
|
||||
return out
|
||||
}
|
||||
|
||||
// subtitleContent reads a track from wherever it lives: the gateway's own table for one it
|
||||
// fetched, Emby for one that came with the film.
|
||||
func (s *Server) subtitleContent(
|
||||
ctx context.Context, cred emby.Credentials, itemID, mediaSourceID string,
|
||||
track playableSubtitle,
|
||||
) ([]byte, error) {
|
||||
if isStoredSubtitleID(track.ID) {
|
||||
stored, err := s.store.DownloadedSubtitle(ctx, track.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stored.Content, nil
|
||||
}
|
||||
index, err := strconv.Atoi(track.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("subtitle id %q is not an Emby stream index", track.ID)
|
||||
}
|
||||
return s.emby.SubtitleBytes(ctx, cred, itemID, mediaSourceID, index)
|
||||
}
|
||||
|
||||
func trackByID(tracks []playableSubtitle, id string) (playableSubtitle, bool) {
|
||||
for _, track := range tracks {
|
||||
if track.ID == id {
|
||||
return track, true
|
||||
}
|
||||
}
|
||||
return playableSubtitle{}, false
|
||||
}
|
||||
|
||||
// fixedSubtitleID keeps the repaired copy in the gateway's own namespace and distinct from
|
||||
// a downloaded file for the same language, so fixing a subtitle never overwrites the one it
|
||||
// was made from — a correction can be wrong, and the original has to still be there.
|
||||
func fixedSubtitleID(itemID string, target playableSubtitle) string {
|
||||
language := target.Language
|
||||
if language == "" {
|
||||
language = "und"
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"gw", itemID, language, subtitleFixSuffix, sanitiseIDPart(target.ID),
|
||||
}, ":")
|
||||
}
|
||||
|
||||
// sanitiseIDPart keeps a source track's id usable inside another id. A stored track's own
|
||||
// id already contains colons, and nesting them would make the parts ambiguous.
|
||||
func sanitiseIDPart(id string) string {
|
||||
return strings.ReplaceAll(strings.TrimPrefix(id, storedSubtitleIDPrefix), ":", "-")
|
||||
}
|
||||
|
||||
func fixedSubtitleLabel(target playableSubtitle) string {
|
||||
label := strings.TrimSpace(target.Label)
|
||||
if label == "" {
|
||||
label = subtitleLanguageLabel(target.Language)
|
||||
}
|
||||
// Named rather than silently substituted: this track sits in the menu beside the one
|
||||
// it was made from, and the two are otherwise indistinguishable.
|
||||
return label + " (timing fixed)"
|
||||
}
|
||||
|
||||
func subtitleTrackName(track playableSubtitle) string {
|
||||
if label := strings.TrimSpace(track.Label); label != "" {
|
||||
return label
|
||||
}
|
||||
if language := subtitleLanguageLabel(track.Language); language != "" {
|
||||
return language
|
||||
}
|
||||
return "another subtitle"
|
||||
}
|
||||
|
||||
// subtitleFixFailureMessage is the whole diagnosis. A television has no log and no support
|
||||
// channel, so the sentence has to say what happened and whether pressing again would help.
|
||||
func subtitleFixFailureMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, subsync.ErrNoCues):
|
||||
return "That subtitle could not be read, so its timing cannot be fixed."
|
||||
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled):
|
||||
return "Fixing the timing took too long. Try again."
|
||||
default:
|
||||
return "The timing could not be fixed just now."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
func TestSubtitleFixAvailableNeedsAUsableReference(t *testing.T) {
|
||||
server := &Server{store: &store.Store{}}
|
||||
english := playableSubtitle{ID: "1", Language: "eng", URL: "https://emby/subtitle/1.srt"}
|
||||
italian := playableSubtitle{ID: "2", Language: "ita", URL: "https://emby/subtitle/2.srt"}
|
||||
|
||||
if server.subtitleFixAvailable([]playableSubtitle{english}) {
|
||||
t.Fatal("one subtitle cannot be checked against itself")
|
||||
}
|
||||
if !server.subtitleFixAvailable([]playableSubtitle{english, italian}) {
|
||||
t.Fatal("two readable text tracks should make timing repair available")
|
||||
}
|
||||
if (&Server{}).subtitleFixAvailable([]playableSubtitle{english, italian}) {
|
||||
t.Fatal("a gateway with no store cannot keep the repaired copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleFixHandlerRejectsAnUnnamedTrack(t *testing.T) {
|
||||
server := &Server{store: &store.Store{}}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/v1/items/42/subtitles/fix",
|
||||
strings.NewReader(`{"subtitleId":" "}`),
|
||||
)
|
||||
request.SetPathValue("id", "42")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.handleSubtitleFix(recorder, request, store.Session{})
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", recorder.Code)
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "no subtitle was named") {
|
||||
t.Fatalf("body = %q", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/bazarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/buildinfo"
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/opensubtitles"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// Which backends may be asked for a subtitle, and how a candidate finds its way home.
|
||||
//
|
||||
// There are two providers now and they are not the same shape. Bazarr writes the file
|
||||
// beside the media file, so the gateway asks and forgets; OpenSubtitles hands back bytes,
|
||||
// which the gateway has to keep and serve itself. Everything above this file is written
|
||||
// against one vocabulary — search, download, a candidate carrying its source — and the
|
||||
// difference lives here and in `downloaded_subtitles`.
|
||||
//
|
||||
// The operator's switches are `store.SubtitlePolicy`, not environment variables, because
|
||||
// the two answer different questions and a household changes its mind about them. A
|
||||
// provider is offered only when it is configured *and* switched on *and* the
|
||||
// `subtitle_download` feature is on: an unconfigured deployment must never draw a row that
|
||||
// leads to a request nothing can answer.
|
||||
|
||||
// subtitleSources is which providers this request may use. Nothing downstream branches on
|
||||
// a client, a viewer or a title — a source is on for the household or it is not.
|
||||
type subtitleSources struct {
|
||||
Bazarr bool
|
||||
OpenSubtitles bool
|
||||
}
|
||||
|
||||
func (s subtitleSources) any() bool { return s.Bazarr || s.OpenSubtitles }
|
||||
|
||||
// subtitlePolicy reads the operator's document, falling back to the defaults rather than
|
||||
// to nothing: a store that will not answer must cost the console its switches, not a
|
||||
// household its subtitles.
|
||||
func (s *Server) subtitlePolicy(ctx context.Context) store.SubtitlePolicy {
|
||||
if s.store == nil {
|
||||
return store.DefaultSubtitlePolicy()
|
||||
}
|
||||
policy, err := s.store.SubtitlePolicy(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("subtitle policy unavailable; using defaults", "error", err)
|
||||
return store.DefaultSubtitlePolicy()
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func (s *Server) subtitleSources(ctx context.Context) subtitleSources {
|
||||
if !s.featureEnabled(ctx, featureSubtitleDownload) {
|
||||
return subtitleSources{}
|
||||
}
|
||||
policy := s.subtitlePolicy(ctx)
|
||||
return subtitleSources{
|
||||
// Bazarr needs an address, which is deployment configuration and stays an
|
||||
// environment variable — it is a service the household runs, not a credential
|
||||
// somebody pastes into a console.
|
||||
Bazarr: s.bazarr != nil && policy.BazarrEnabled,
|
||||
// OpenSubtitles needs only a key, which the console holds, so it can be turned on
|
||||
// without a redeployment. The store already refuses to record it as on with no key.
|
||||
OpenSubtitles: policy.OpenSubtitlesEnabled && policy.OpenSubtitlesAPIKey != "",
|
||||
}
|
||||
}
|
||||
|
||||
// openSubtitlesClient returns a client for the credentials currently saved, rebuilding it
|
||||
// when they change.
|
||||
//
|
||||
// It is cached rather than constructed per request for one reason that matters: the client
|
||||
// holds a login token, and logging in per download would spend a different allowance than
|
||||
// the one being conserved. The fingerprint is a hash so a credential never reaches a log
|
||||
// line or a comparison in a debugger.
|
||||
func (s *Server) openSubtitlesClient(ctx context.Context) *opensubtitles.Client {
|
||||
policy := s.subtitlePolicy(ctx)
|
||||
if !policy.OpenSubtitlesEnabled || policy.OpenSubtitlesAPIKey == "" {
|
||||
return nil
|
||||
}
|
||||
fingerprint := credentialFingerprint(
|
||||
policy.OpenSubtitlesAPIKey, policy.OpenSubtitlesUsername, policy.OpenSubtitlesPassword,
|
||||
)
|
||||
s.openSubtitlesMu.Lock()
|
||||
defer s.openSubtitlesMu.Unlock()
|
||||
if s.openSubtitles != nil && s.openSubtitlesKey == fingerprint {
|
||||
return s.openSubtitles
|
||||
}
|
||||
s.openSubtitles = opensubtitles.New(
|
||||
policy.OpenSubtitlesAPIKey, openSubtitlesUserAgent(),
|
||||
policy.OpenSubtitlesUsername, policy.OpenSubtitlesPassword,
|
||||
s.cfg.BazarrTimeout,
|
||||
)
|
||||
s.openSubtitlesKey = fingerprint
|
||||
return s.openSubtitles
|
||||
}
|
||||
|
||||
// openSubtitlesUserAgent names Memby to the provider. It carries the gateway's own version
|
||||
// rather than a television's: the request is the gateway's, and the API asks a consumer to
|
||||
// identify the build so it can be told when one misbehaves.
|
||||
func openSubtitlesUserAgent() string {
|
||||
return "Memby/" + buildinfo.Version()
|
||||
}
|
||||
|
||||
func credentialFingerprint(values ...string) string {
|
||||
sum := sha256.Sum256([]byte(strings.Join(values, "\x00")))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// subtitleTarget is one Emby item resolved onto everything either provider needs. It is
|
||||
// resolved once per search or download, because both providers want the same three facts
|
||||
// about a title and reading them twice would double the Emby traffic of a feature that
|
||||
// runs while somebody's film is paused.
|
||||
type subtitleTarget struct {
|
||||
Title string
|
||||
// Bazarr's ids. Exactly one of RadarrID and EpisodeID is set when Bazarr can be used.
|
||||
bazarr bazarrTarget
|
||||
hasBazarr bool
|
||||
// OpenSubtitles' identity, which is an external id rather than a title. Empty when
|
||||
// Emby knows no provider id for the item or its series.
|
||||
query opensubtitles.Query
|
||||
hasQuery bool
|
||||
}
|
||||
|
||||
// providerSubtitles searches every enabled provider at once and merges the answers.
|
||||
//
|
||||
// Concurrently, because a manual search is a live provider query measured in seconds and
|
||||
// running two in turn would double a wait somebody is standing in front of. A provider
|
||||
// that fails is dropped rather than failing the search: one working provider is a better
|
||||
// answer than an error, and the caller says so when both are empty.
|
||||
func (s *Server) providerSubtitles(
|
||||
ctx context.Context, sources subtitleSources, target subtitleTarget, language string,
|
||||
) ([]subtitleCandidate, []error) {
|
||||
type outcome struct {
|
||||
candidates []subtitleCandidate
|
||||
err error
|
||||
}
|
||||
results := make(chan outcome, 2)
|
||||
requested := 0
|
||||
|
||||
if sources.Bazarr && target.hasBazarr {
|
||||
requested++
|
||||
go func() {
|
||||
found, err := s.searchBazarr(ctx, target.bazarr)
|
||||
results <- outcome{candidates: bazarrCandidates(found), err: err}
|
||||
}()
|
||||
}
|
||||
if sources.OpenSubtitles && target.hasQuery {
|
||||
requested++
|
||||
client := s.openSubtitlesClient(ctx)
|
||||
go func() {
|
||||
if client == nil {
|
||||
results <- outcome{}
|
||||
return
|
||||
}
|
||||
query := target.query
|
||||
query.Languages = openSubtitlesLanguages(language)
|
||||
found, err := client.Search(ctx, query)
|
||||
results <- outcome{candidates: openSubtitlesCandidates(found), err: err}
|
||||
}()
|
||||
}
|
||||
|
||||
var candidates []subtitleCandidate
|
||||
var failures []error
|
||||
for range requested {
|
||||
result := <-results
|
||||
if result.err != nil {
|
||||
failures = append(failures, result.err)
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, result.candidates...)
|
||||
}
|
||||
return candidates, failures
|
||||
}
|
||||
|
||||
// openSubtitlesLanguages is what a search asks for.
|
||||
//
|
||||
// English rides along with whatever the viewer chose, deliberately. A household that has
|
||||
// never set a language gets the auto value, and a search restricted to nothing comes back
|
||||
// with every language on earth ordered by somebody else's idea of relevance — where a
|
||||
// search that names one or two produces a list a person can read on a television.
|
||||
func openSubtitlesLanguages(language string) []string {
|
||||
normalized := normalizeSubtitleLanguage(language)
|
||||
if normalized == "" || normalized == subtitleLanguageAuto {
|
||||
return []string{"en"}
|
||||
}
|
||||
if normalized == "en" {
|
||||
return []string{"en"}
|
||||
}
|
||||
return []string{normalized, "en"}
|
||||
}
|
||||
|
||||
func bazarrCandidates(found []bazarr.Subtitle) []subtitleCandidate {
|
||||
out := make([]subtitleCandidate, 0, len(found))
|
||||
for _, subtitle := range found {
|
||||
if strings.TrimSpace(subtitle.Token) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, subtitleCandidate{
|
||||
Source: store.SubtitleProviderBazarr,
|
||||
Token: subtitle.Token,
|
||||
Language: normalizeSubtitleLanguage(subtitle.Language),
|
||||
LanguageLabel: subtitleLanguageLabel(subtitle.Language),
|
||||
Provider: subtitle.Provider,
|
||||
Score: clampPercent(subtitle.Score),
|
||||
Forced: subtitle.Forced,
|
||||
HearingImpaired: subtitle.HearingImpaired,
|
||||
OriginalFormat: subtitle.OriginalFormat,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func openSubtitlesCandidates(found []opensubtitles.Subtitle) []subtitleCandidate {
|
||||
out := make([]subtitleCandidate, 0, len(found))
|
||||
for _, subtitle := range found {
|
||||
if subtitle.FileID <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, subtitleCandidate{
|
||||
Source: store.SubtitleProviderOpenSubtitles,
|
||||
// The token is the file id as a string, so one field carries both providers'
|
||||
// opaque handles and nothing above this file has to know the difference.
|
||||
Token: strconv.Itoa(subtitle.FileID),
|
||||
Language: normalizeSubtitleLanguage(subtitle.Language),
|
||||
LanguageLabel: subtitleLanguageLabel(subtitle.Language),
|
||||
Provider: "OpenSubtitles",
|
||||
Score: openSubtitlesScore(subtitle),
|
||||
Forced: subtitle.Forced,
|
||||
HearingImpaired: subtitle.HearingImpaired,
|
||||
MachineOnly: subtitle.MachineOnly,
|
||||
Release: subtitle.Release,
|
||||
format: subtitle.Format,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// openSubtitlesScore turns the provider's two numbers into the one Bazarr already gives,
|
||||
// so a merged list can be ordered by a single figure that means roughly the same thing on
|
||||
// every row: how much confidence there is in this file.
|
||||
//
|
||||
// The rating is the meaningful half and it is what a viewer would look at; the download
|
||||
// count only breaks ties, because it measures age as much as quality — a subtitle uploaded
|
||||
// last week for a film from 1994 cannot out-download one that has been there for years.
|
||||
// A file nobody has rated is not a bad file, so it lands mid-scale rather than at the
|
||||
// bottom, the same judgement `heroUnratedScore` makes.
|
||||
func openSubtitlesScore(subtitle opensubtitles.Subtitle) int {
|
||||
score := 55
|
||||
if subtitle.Rating > 0 {
|
||||
score = int(subtitle.Rating * 10)
|
||||
}
|
||||
if subtitle.FromTrusted {
|
||||
score += 5
|
||||
}
|
||||
if subtitle.Downloads >= 1000 {
|
||||
score += 3
|
||||
}
|
||||
// A machine translation is a real answer and sometimes the only one, so it is offered
|
||||
// — below everything a person wrote, and saying so on its own row.
|
||||
if subtitle.MachineOnly {
|
||||
score -= 25
|
||||
}
|
||||
return clampPercent(score)
|
||||
}
|
||||
|
||||
// resolveSubtitleTarget reads the item once and works out what each provider needs.
|
||||
//
|
||||
// Failure is per provider rather than for the whole request: a film Bazarr has never heard
|
||||
// of may still have an imdb id, and a title with no provider id at all may still be in
|
||||
// Bazarr's list. Only both failing is a failure.
|
||||
func (s *Server) resolveSubtitleTarget(
|
||||
ctx context.Context, cred emby.Credentials, itemID string, sources subtitleSources,
|
||||
) (subtitleTarget, error) {
|
||||
raw, err := s.emby.Item(ctx, cred, itemID,
|
||||
"ProductionYear,SeriesName,ParentIndexNumber,IndexNumber,ProviderIds")
|
||||
if err != nil {
|
||||
return subtitleTarget{}, err
|
||||
}
|
||||
var item struct {
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
SeriesID string `json:"SeriesId"`
|
||||
SeriesName string `json:"SeriesName"`
|
||||
ProductionYear int `json:"ProductionYear"`
|
||||
ParentIndexNumber int `json:"ParentIndexNumber"`
|
||||
IndexNumber int `json:"IndexNumber"`
|
||||
ProviderIDs map[string]string `json:"ProviderIds"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &item); err != nil {
|
||||
return subtitleTarget{}, fmt.Errorf("unreadable item from emby: %w", err)
|
||||
}
|
||||
|
||||
target := subtitleTarget{Title: strings.TrimSpace(item.Name)}
|
||||
episode := strings.EqualFold(item.Type, "Episode")
|
||||
if !episode && !strings.EqualFold(item.Type, "Movie") {
|
||||
return subtitleTarget{}, fmt.Errorf("subtitles cannot be fetched for a %q", item.Type)
|
||||
}
|
||||
|
||||
if sources.Bazarr {
|
||||
if resolved, err := s.resolveBazarrTarget(ctx, cred, itemID); err == nil {
|
||||
target.bazarr, target.hasBazarr = resolved, true
|
||||
target.Title = resolved.Title
|
||||
} else {
|
||||
s.loggerFor(ctx).Debug("no bazarr target", "item", itemID, "error", err)
|
||||
}
|
||||
}
|
||||
if sources.OpenSubtitles {
|
||||
query := opensubtitles.Query{
|
||||
IMDBID: providerID(item.ProviderIDs, "imdb"),
|
||||
TMDBID: providerID(item.ProviderIDs, "tmdb"),
|
||||
Query: strings.TrimSpace(item.Name),
|
||||
Type: "movie",
|
||||
}
|
||||
if episode {
|
||||
query.Type = "episode"
|
||||
query.Season = item.ParentIndexNumber
|
||||
query.Episode = item.IndexNumber
|
||||
query.Query = strings.TrimSpace(item.SeriesName)
|
||||
// A show carries an id far more often than each of its episodes does, and the
|
||||
// API takes the series' id with a season and episode number, so the parent is
|
||||
// read whenever there is one to read.
|
||||
if item.SeriesID != "" {
|
||||
parents := s.itemProviderIDs(ctx, cred, item.SeriesID)
|
||||
query.ParentIMDBID = providerID(parents, "imdb")
|
||||
query.ParentTMDBID = providerID(parents, "tmdb")
|
||||
}
|
||||
if target.Title == "" || item.SeriesName != "" {
|
||||
target.Title = fmt.Sprintf("%s S%02dE%02d",
|
||||
item.SeriesName, item.ParentIndexNumber, item.IndexNumber)
|
||||
}
|
||||
}
|
||||
// searchParams answers nil for a query with no identity, which is the same rule
|
||||
// stated in one place; asking it here is what keeps this honest.
|
||||
target.query, target.hasQuery = query, opensubtitles.CanSearch(query)
|
||||
}
|
||||
|
||||
if !target.hasBazarr && !target.hasQuery {
|
||||
return subtitleTarget{}, fmt.Errorf("no subtitle provider can identify %q", item.Name)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// itemProviderIDs reads one item's external ids. A failure is not fatal — it costs the
|
||||
// episode its parent's identity and the search falls back to the title.
|
||||
func (s *Server) itemProviderIDs(
|
||||
ctx context.Context, cred emby.Credentials, itemID string,
|
||||
) map[string]string {
|
||||
raw, err := s.emby.Item(ctx, cred, itemID, "ProviderIds")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var parsed struct {
|
||||
ProviderIDs map[string]string `json:"ProviderIds"`
|
||||
}
|
||||
if json.Unmarshal(raw, &parsed) != nil {
|
||||
return nil
|
||||
}
|
||||
return parsed.ProviderIDs
|
||||
}
|
||||
|
||||
// fetchSubtitle carries out one candidate's download and reports what the viewer should be
|
||||
// told. Which provider it goes to is the candidate's own `Source`; an unknown one is
|
||||
// refused rather than guessed at, since guessing means handing one provider's opaque token
|
||||
// to another.
|
||||
func (s *Server) fetchSubtitle(
|
||||
ctx context.Context, cred emby.Credentials, itemID string,
|
||||
target subtitleTarget, candidate subtitleCandidate,
|
||||
) (fetchedSubtitle, error) {
|
||||
switch candidate.Source {
|
||||
case store.SubtitleProviderOpenSubtitles:
|
||||
return s.fetchFromOpenSubtitles(ctx, itemID, candidate)
|
||||
case store.SubtitleProviderBazarr, "":
|
||||
return s.fetchFromBazarr(ctx, cred, itemID, target, candidate)
|
||||
default:
|
||||
return fetchedSubtitle{}, fmt.Errorf("unknown subtitle source %q", candidate.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchedSubtitle is what a download produced. StoredID is set only by the provider that
|
||||
// hands back bytes — it names the row the gateway now serves — and is what lets the
|
||||
// response point the player at the new track rather than at whatever Emby happened to
|
||||
// return in the same language.
|
||||
type fetchedSubtitle struct {
|
||||
StoredID string
|
||||
// RefreshEmby is true for a provider that wrote a file Emby has not noticed. Bazarr
|
||||
// needs it; a subtitle the gateway serves itself does not, and refreshing anyway would
|
||||
// spend a couple of seconds of somebody's film waiting for nothing.
|
||||
RefreshEmby bool
|
||||
}
|
||||
|
||||
func (s *Server) fetchFromBazarr(
|
||||
ctx context.Context, cred emby.Credentials, itemID string,
|
||||
target subtitleTarget, candidate subtitleCandidate,
|
||||
) (fetchedSubtitle, error) {
|
||||
if s.bazarr == nil || !target.hasBazarr {
|
||||
return fetchedSubtitle{}, fmt.Errorf("bazarr cannot fetch for this title")
|
||||
}
|
||||
subtitle := bazarr.Subtitle{
|
||||
Language: candidate.Language,
|
||||
Provider: candidate.Provider,
|
||||
Token: candidate.Token,
|
||||
Forced: candidate.Forced,
|
||||
HearingImpaired: candidate.HearingImpaired,
|
||||
OriginalFormat: candidate.OriginalFormat,
|
||||
}
|
||||
var err error
|
||||
if target.bazarr.EpisodeID > 0 {
|
||||
err = s.bazarr.DownloadEpisode(ctx, target.bazarr.SeriesID, target.bazarr.EpisodeID, subtitle)
|
||||
} else {
|
||||
err = s.bazarr.DownloadMovie(ctx, target.bazarr.RadarrID, subtitle)
|
||||
}
|
||||
if err != nil {
|
||||
return fetchedSubtitle{}, err
|
||||
}
|
||||
_ = cred // the refresh the caller makes needs it; the download does not.
|
||||
return fetchedSubtitle{RefreshEmby: true}, nil
|
||||
}
|
||||
|
||||
// fetchFromOpenSubtitles is the half of this feature that is not Bazarr-shaped: the
|
||||
// provider returns a file, the gateway keeps it, and it is served back as a sidecar. The
|
||||
// bytes are stored before anything is reported as successful, because a download that
|
||||
// spent the household's allowance and then lost the file is the worst outcome available.
|
||||
func (s *Server) fetchFromOpenSubtitles(
|
||||
ctx context.Context, itemID string, candidate subtitleCandidate,
|
||||
) (fetchedSubtitle, error) {
|
||||
client := s.openSubtitlesClient(ctx)
|
||||
if client == nil {
|
||||
return fetchedSubtitle{}, fmt.Errorf("opensubtitles is not configured")
|
||||
}
|
||||
fileID, err := strconv.Atoi(strings.TrimSpace(candidate.Token))
|
||||
if err != nil || fileID <= 0 {
|
||||
return fetchedSubtitle{}, fmt.Errorf("unusable opensubtitles file id")
|
||||
}
|
||||
name, content, err := client.Download(ctx, fileID)
|
||||
if err != nil {
|
||||
return fetchedSubtitle{}, err
|
||||
}
|
||||
format := candidate.format
|
||||
if format == "" {
|
||||
format = subtitleFormatFromName(name)
|
||||
}
|
||||
stored := store.DownloadedSubtitle{
|
||||
ID: storedSubtitleID(itemID, candidate),
|
||||
ItemID: itemID,
|
||||
Language: candidate.Language,
|
||||
Label: storedSubtitleLabel(candidate),
|
||||
Forced: candidate.Forced,
|
||||
HearingImpaired: candidate.HearingImpaired,
|
||||
Format: format,
|
||||
Provider: store.SubtitleProviderOpenSubtitles,
|
||||
Content: content,
|
||||
}
|
||||
if err := s.store.PutDownloadedSubtitle(ctx, stored); err != nil {
|
||||
return fetchedSubtitle{}, err
|
||||
}
|
||||
return fetchedSubtitle{StoredID: stored.ID}, nil
|
||||
}
|
||||
|
||||
// storedSubtitleID names one file. It is derived from what was asked for rather than being
|
||||
// random, so fetching the same language for the same title twice replaces the file instead
|
||||
// of growing a second track a viewer has to tell apart by guessing.
|
||||
func storedSubtitleID(itemID string, candidate subtitleCandidate) string {
|
||||
variant := "plain"
|
||||
switch {
|
||||
case candidate.Forced:
|
||||
variant = "forced"
|
||||
case candidate.HearingImpaired:
|
||||
variant = "sdh"
|
||||
}
|
||||
language := candidate.Language
|
||||
if language == "" {
|
||||
language = "und"
|
||||
}
|
||||
return strings.Join([]string{"gw", itemID, language, variant}, ":")
|
||||
}
|
||||
|
||||
// storedSubtitleIDPrefix is what marks a track as one the gateway serves rather than one
|
||||
// Emby knows about. The player matches on the id it was handed, so the two namespaces must
|
||||
// not be able to collide: Emby's are stream indices, which are plain numbers.
|
||||
const storedSubtitleIDPrefix = "gw:"
|
||||
|
||||
func isStoredSubtitleID(id string) bool {
|
||||
return strings.HasPrefix(id, storedSubtitleIDPrefix)
|
||||
}
|
||||
|
||||
func storedSubtitleLabel(candidate subtitleCandidate) string {
|
||||
label := candidate.LanguageLabel
|
||||
if strings.TrimSpace(label) == "" {
|
||||
label = subtitleLanguageLabel(candidate.Language)
|
||||
}
|
||||
switch {
|
||||
case candidate.Forced:
|
||||
label += " · Forced"
|
||||
case candidate.HearingImpaired:
|
||||
label += " · Hearing impaired"
|
||||
}
|
||||
// Named for where it came from, because a viewer looking at a track list should be
|
||||
// able to see which one arrived a minute ago and which was always in the file.
|
||||
return label + " · Downloaded"
|
||||
}
|
||||
|
||||
func subtitleFormatFromName(name string) string {
|
||||
if index := strings.LastIndex(name, "."); index >= 0 && index < len(name)-1 {
|
||||
switch extension := strings.ToLower(name[index+1:]); extension {
|
||||
case "srt", "vtt", "ass", "ssa":
|
||||
return extension
|
||||
}
|
||||
}
|
||||
return "srt"
|
||||
}
|
||||
|
||||
func storedSubtitleMIME(format string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "vtt":
|
||||
return "text/vtt"
|
||||
case "ass", "ssa":
|
||||
return "text/x-ssa"
|
||||
default:
|
||||
return "application/x-subrip"
|
||||
}
|
||||
}
|
||||
|
||||
// storedSubtitlesFor turns what the gateway holds for an item into playable tracks.
|
||||
//
|
||||
// The URL is a path rather than an absolute address on purpose: the gateway does not
|
||||
// reliably know its own externally reachable name, and the television does — it is talking
|
||||
// to it. The client resolves a relative subtitle URL against the gateway it is signed into
|
||||
// and appends its own token, exactly as it already does for artwork.
|
||||
func (s *Server) storedSubtitlesFor(ctx context.Context, itemID string) []playableSubtitle {
|
||||
if s.store == nil {
|
||||
return nil
|
||||
}
|
||||
held, err := s.store.DownloadedSubtitlesFor(ctx, itemID)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("stored subtitles unavailable", "item", itemID, "error", err)
|
||||
return nil
|
||||
}
|
||||
out := make([]playableSubtitle, 0, len(held))
|
||||
for _, subtitle := range held {
|
||||
out = append(out, playableSubtitle{
|
||||
ID: subtitle.ID,
|
||||
URL: storedSubtitlePath(subtitle),
|
||||
MimeType: storedSubtitleMIME(subtitle.Format),
|
||||
Language: subtitle.Language,
|
||||
Label: subtitle.Label,
|
||||
IsForced: subtitle.Forced,
|
||||
IsHearingImpaired: subtitle.HearingImpaired,
|
||||
DeliveryMethod: "External",
|
||||
Codec: subtitle.Format,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// storedSubtitlePath is the route the file is served from. The extension is on the end
|
||||
// because media3 sniffs one when a MIME type is missing or wrong, and a subtitle served
|
||||
// from a path with no extension is the kind of thing that works on one decoder.
|
||||
func storedSubtitlePath(subtitle store.DownloadedSubtitle) string {
|
||||
format := strings.ToLower(strings.TrimSpace(subtitle.Format))
|
||||
if format == "" {
|
||||
format = "srt"
|
||||
}
|
||||
return "/v1/subtitles/" + url.PathEscape(subtitle.ID) + "." + format
|
||||
}
|
||||
|
||||
// handleStoredSubtitle serves one file the gateway fetched.
|
||||
//
|
||||
// It is authenticated like everything else under /v1 — the token arrives in the query
|
||||
// string, the way artwork's does, because a media player fetching a sidecar sends no
|
||||
// headers of Memby's. The response is immutable: an id names one fetch, and a re-fetch
|
||||
// writes a new body under the same id only when a viewer deliberately downloads the same
|
||||
// language again, so a long cache is right and a revalidation per playback is not.
|
||||
func (s *Server) handleStoredSubtitle(w http.ResponseWriter, r *http.Request, _ store.Session) {
|
||||
name := r.PathValue("file")
|
||||
id := name
|
||||
if index := strings.LastIndex(name, "."); index > 0 {
|
||||
id = name[:index]
|
||||
}
|
||||
unescaped, err := url.PathUnescape(id)
|
||||
if err != nil || !isStoredSubtitleID(unescaped) {
|
||||
writeError(w, http.StatusNotFound, "unknown subtitle")
|
||||
return
|
||||
}
|
||||
subtitle, err := s.store.DownloadedSubtitle(r.Context(), unescaped)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "unknown subtitle")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", storedSubtitleMIME(subtitle.Format))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(subtitle.Content)))
|
||||
w.Header().Set("Cache-Control", "private, max-age=86400")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(subtitle.Content)
|
||||
}
|
||||
|
||||
// mergeSubtitleTracks puts the gateway's own tracks beside Emby's, dropping any it already
|
||||
// covers.
|
||||
//
|
||||
// The overlap is real and it is the reason this is not an append: a subtitle fetched
|
||||
// through Bazarr becomes an Emby track, and a household that has Bazarr on may still have
|
||||
// fetched the same language here first. Two identically labelled rows in a drop-up is the
|
||||
// kind of thing that makes a viewer distrust the whole menu, so where both exist Emby's
|
||||
// wins — it is the one in the file, and it survives this gateway being replaced.
|
||||
func mergeSubtitleTracks(embyTracks, stored []playableSubtitle) []playableSubtitle {
|
||||
if len(stored) == 0 {
|
||||
return embyTracks
|
||||
}
|
||||
covered := map[string]bool{}
|
||||
for _, track := range embyTracks {
|
||||
covered[subtitleVariantKey(track)] = true
|
||||
}
|
||||
out := embyTracks
|
||||
for _, track := range stored {
|
||||
if covered[subtitleVariantKey(track)] {
|
||||
continue
|
||||
}
|
||||
out = append(out, track)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func subtitleVariantKey(track playableSubtitle) string {
|
||||
return fmt.Sprintf("%s|%t|%t",
|
||||
normalizeSubtitleLanguage(track.Language), track.IsForced, track.IsHearingImpaired)
|
||||
}
|
||||
|
||||
// rankMergedCandidates orders what the viewer sees across both providers and caps the list.
|
||||
//
|
||||
// The viewer's language comes first, because it is the only thing they asked for. Within a
|
||||
// language a plain track beats a forced or hearing-impaired one, for the reason the
|
||||
// selection rule already gives — somebody who chose Italian wants the dialogue, not the
|
||||
// signs — and a machine translation sinks below everything a person wrote. Only then does
|
||||
// the score decide, so a provider cannot buy its way to the top of somebody's list with a
|
||||
// confident number about the wrong language.
|
||||
func rankMergedCandidates(found []subtitleCandidate, language string) []subtitleCandidate {
|
||||
preferred := normalizeSubtitleLanguage(language)
|
||||
if preferred == subtitleLanguageAuto {
|
||||
preferred = ""
|
||||
}
|
||||
ordered := make([]subtitleCandidate, len(found))
|
||||
copy(ordered, found)
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
left, right := ordered[i], ordered[j]
|
||||
leftPreferred := preferred != "" && left.Language == preferred
|
||||
rightPreferred := preferred != "" && right.Language == preferred
|
||||
if leftPreferred != rightPreferred {
|
||||
return leftPreferred
|
||||
}
|
||||
if left.MachineOnly != right.MachineOnly {
|
||||
return right.MachineOnly
|
||||
}
|
||||
if leftRank, rightRank := candidateVariantRank(left), candidateVariantRank(right); leftRank != rightRank {
|
||||
return leftRank < rightRank
|
||||
}
|
||||
return left.Score > right.Score
|
||||
})
|
||||
if len(ordered) > maxSubtitleResults {
|
||||
ordered = ordered[:maxSubtitleResults]
|
||||
}
|
||||
for i := range ordered {
|
||||
ordered[i].Label = mergedCandidateLabel(ordered[i])
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func candidateVariantRank(candidate subtitleCandidate) int {
|
||||
switch {
|
||||
case candidate.Forced:
|
||||
return 2
|
||||
case candidate.HearingImpaired:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// mergedCandidateLabel is what one row says. It is composed here rather than on the
|
||||
// television so an older app renders a new wording correctly, the same reason alert labels
|
||||
// are the gateway's.
|
||||
//
|
||||
// The provider is named now, where the single-provider version deliberately did not: with
|
||||
// two backends configured the same language appears twice and "which of these is which" is
|
||||
// a question the row has to answer. A machine translation says so, because it is the one
|
||||
// property of a subtitle that changes whether somebody wants it at all.
|
||||
func mergedCandidateLabel(candidate subtitleCandidate) string {
|
||||
label := candidate.LanguageLabel
|
||||
if strings.TrimSpace(label) == "" {
|
||||
label = subtitleLanguageLabel(candidate.Language)
|
||||
}
|
||||
switch {
|
||||
case candidate.Forced:
|
||||
label += " · Forced"
|
||||
case candidate.HearingImpaired:
|
||||
label += " · Hearing impaired"
|
||||
}
|
||||
if candidate.MachineOnly {
|
||||
label += " · Machine translated"
|
||||
}
|
||||
if candidate.Score > 0 {
|
||||
label += fmt.Sprintf(" · %d%% match", clampPercent(candidate.Score))
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
// subtitleFailureMessage turns what went wrong into the sentence printed over an empty
|
||||
// list. A television has no log and no support channel, so this is the whole diagnosis —
|
||||
// and the quota case is separated out because it is the only one where pressing the button
|
||||
// again is definitely not the answer.
|
||||
func subtitleFailureMessage(failures []error) string {
|
||||
for _, err := range failures {
|
||||
if _, ok := err.(*opensubtitles.QuotaError); ok {
|
||||
return "Today's subtitle downloads have been used up."
|
||||
}
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return "The subtitle service did not answer."
|
||||
}
|
||||
return "No subtitles were found for this release."
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/bazarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/opensubtitles"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// A row has to carry the backend it came from, because the two tokens are opaque in
|
||||
// different ways and handing one to the other is a mistake nothing downstream could catch.
|
||||
func TestCandidatesCarryTheirSource(t *testing.T) {
|
||||
fromBazarr := bazarrCandidates([]bazarr.Subtitle{{Language: "eng", Token: "opaque"}})
|
||||
if len(fromBazarr) != 1 || fromBazarr[0].Source != store.SubtitleProviderBazarr {
|
||||
t.Fatalf("bazarr candidate = %+v", fromBazarr)
|
||||
}
|
||||
fromOpen := openSubtitlesCandidates([]opensubtitles.Subtitle{{FileID: 42, Language: "en"}})
|
||||
if len(fromOpen) != 1 || fromOpen[0].Source != store.SubtitleProviderOpenSubtitles {
|
||||
t.Fatalf("opensubtitles candidate = %+v", fromOpen)
|
||||
}
|
||||
if fromOpen[0].Token != "42" {
|
||||
t.Fatalf("token = %q, want the file id", fromOpen[0].Token)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine translation is a real answer and sometimes the only one, so it is offered —
|
||||
// below everything a person wrote, whatever confidence the provider claims for it.
|
||||
func TestRankingSinksMachineTranslationsBelowHumanOnes(t *testing.T) {
|
||||
found := []subtitleCandidate{
|
||||
{Source: "opensubtitles", Token: "robot", Language: "en", Score: 99, MachineOnly: true},
|
||||
{Source: "bazarr", Token: "human", Language: "en", Score: 40},
|
||||
}
|
||||
results := rankMergedCandidates(found, "en")
|
||||
if results[0].Token != "human" {
|
||||
t.Fatalf("order = %q, %q", results[0].Token, results[1].Token)
|
||||
}
|
||||
if want := "English · Machine translated · 99% match"; results[1].Label != want {
|
||||
t.Fatalf("label = %q, want %q", results[1].Label, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The viewer's language still outranks everything, across providers as it did within one.
|
||||
func TestRankingKeepsTheChosenLanguageFirstAcrossProviders(t *testing.T) {
|
||||
found := []subtitleCandidate{
|
||||
{Source: "bazarr", Token: "en", Language: "en", Score: 99},
|
||||
{Source: "opensubtitles", Token: "it", Language: "it", Score: 20},
|
||||
}
|
||||
if got := rankMergedCandidates(found, "it"); got[0].Token != "it" {
|
||||
t.Fatalf("first row = %q, want the Italian one", got[0].Token)
|
||||
}
|
||||
}
|
||||
|
||||
// A file nobody has rated is not a bad file, so it lands mid-scale rather than at the
|
||||
// bottom — the judgement heroUnratedScore already makes about an unrated title.
|
||||
func TestOpenSubtitlesScoreIsMidScaleWhenUnrated(t *testing.T) {
|
||||
unrated := openSubtitlesScore(opensubtitles.Subtitle{})
|
||||
if unrated < 40 || unrated > 70 {
|
||||
t.Fatalf("unrated score = %d, want mid-scale", unrated)
|
||||
}
|
||||
if rated := openSubtitlesScore(opensubtitles.Subtitle{Rating: 9.4}); rated <= unrated {
|
||||
t.Fatalf("a well-rated file scored %d, below an unrated %d", rated, unrated)
|
||||
}
|
||||
if machine := openSubtitlesScore(opensubtitles.Subtitle{Rating: 9.4, MachineOnly: true}); machine >= 94 {
|
||||
t.Fatalf("a machine translation kept its full score (%d)", machine)
|
||||
}
|
||||
}
|
||||
|
||||
// Two identically labelled rows in one drop-up is what makes a viewer distrust the whole
|
||||
// menu, so where both exist Emby's track wins: it is the one in the file.
|
||||
func TestMergeSubtitleTracksDropsWhatEmbyAlreadyHas(t *testing.T) {
|
||||
emby := []playableSubtitle{{ID: "3", Language: "eng", DeliveryMethod: "External"}}
|
||||
stored := []playableSubtitle{
|
||||
{ID: "gw:1:en:plain", Language: "en", DeliveryMethod: "External"},
|
||||
{ID: "gw:1:it:plain", Language: "it", DeliveryMethod: "External"},
|
||||
}
|
||||
merged := mergeSubtitleTracks(emby, stored)
|
||||
if len(merged) != 2 {
|
||||
t.Fatalf("merged = %+v", merged)
|
||||
}
|
||||
if merged[1].ID != "gw:1:it:plain" {
|
||||
t.Fatalf("kept %q, want only the Italian one to survive", merged[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// A forced track is not the same track as a plain one in the same language, so it must not
|
||||
// be deduplicated away — that is exactly the subtitle somebody downloaded it for.
|
||||
func TestMergeSubtitleTracksKeepsADifferentVariant(t *testing.T) {
|
||||
emby := []playableSubtitle{{ID: "3", Language: "eng"}}
|
||||
stored := []playableSubtitle{{ID: "gw:1:en:forced", Language: "en", IsForced: true}}
|
||||
if merged := mergeSubtitleTracks(emby, stored); len(merged) != 2 {
|
||||
t.Fatalf("merged = %+v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
// The id is derived from what was asked for, so fetching the same language twice replaces
|
||||
// the file rather than growing a second track a viewer has to tell apart by guessing.
|
||||
func TestStoredSubtitleIDIsStablePerVariant(t *testing.T) {
|
||||
plain := storedSubtitleID("42", subtitleCandidate{Language: "it"})
|
||||
again := storedSubtitleID("42", subtitleCandidate{Language: "it", Provider: "elsewhere"})
|
||||
if plain != again {
|
||||
t.Fatalf("%q != %q for the same title and language", plain, again)
|
||||
}
|
||||
forced := storedSubtitleID("42", subtitleCandidate{Language: "it", Forced: true})
|
||||
if forced == plain {
|
||||
t.Fatal("a forced track took the plain track's id")
|
||||
}
|
||||
if !isStoredSubtitleID(plain) {
|
||||
t.Fatalf("%q is not recognised as the gateway's own", plain)
|
||||
}
|
||||
// Emby's ids are stream indices, which are plain numbers. The two namespaces must not
|
||||
// be able to collide, because the player matches a track on the id it was handed.
|
||||
if isStoredSubtitleID("3") {
|
||||
t.Fatal("an Emby stream index was read as a gateway subtitle")
|
||||
}
|
||||
}
|
||||
|
||||
// A search restricted to nothing returns every language on earth, which is unreadable on a
|
||||
// television; English rides along because a household that never set a language gets one.
|
||||
func TestOpenSubtitlesLanguagesAlwaysNameSomething(t *testing.T) {
|
||||
for _, language := range []string{"", "auto"} {
|
||||
if got := openSubtitlesLanguages(language); len(got) != 1 || got[0] != "en" {
|
||||
t.Fatalf("languages for %q = %v", language, got)
|
||||
}
|
||||
}
|
||||
if got := openSubtitlesLanguages("it"); len(got) != 2 || got[0] != "it" {
|
||||
t.Fatalf("languages for Italian = %v, want Italian first", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The allowance running out is the only failure where pressing the button again is
|
||||
// definitely not the answer, so it must not be flattened into "did not answer".
|
||||
func TestSubtitleFailureMessageSeparatesTheQuotaCase(t *testing.T) {
|
||||
if got := subtitleFailureMessage(nil); got != "No subtitles were found for this release." {
|
||||
t.Fatalf("no failures gave %q", got)
|
||||
}
|
||||
quota := subtitleFailureMessage([]error{&opensubtitles.QuotaError{}})
|
||||
other := subtitleFailureMessage([]error{&opensubtitles.APIError{StatusCode: 500}})
|
||||
if quota == other {
|
||||
t.Fatalf("an exhausted quota reads the same as any other failure: %q", quota)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"hash/fnv"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The colour a television paints itself, decided here rather than there.
|
||||
//
|
||||
// The whole feature is server-owned for the same reason the row composition and the
|
||||
// subtitle choice are: a palette that shipped in the APK could only change with a release,
|
||||
// and these sets are sideloaded one at a time. Deciding it here means an operator can hand
|
||||
// a household a new scheme, restrict what a particular viewer may choose, and — the part
|
||||
// that has to happen without anybody doing anything — put the whole house into a seasonal
|
||||
// theme on the right morning and take it away again afterwards.
|
||||
//
|
||||
// Two kinds of theme, and the difference is the point:
|
||||
//
|
||||
// - A **selectable** theme is the viewer's own choice, held as the ordinary synced
|
||||
// preference `themeId` and picked in Settings → Appearance from whatever the operator
|
||||
// has allowed them.
|
||||
// - A **seasonal** theme is not a choice at all. It is in force for its dates and nothing
|
||||
// on the television can decline it — there is no "off" in the picker, because a switch
|
||||
// for it is exactly what somebody would leave switched off in October and never think
|
||||
// about again. The one control that exists is the operator's, as a feature flag, and it
|
||||
// is all-or-nothing for the whole house.
|
||||
//
|
||||
// Both are palettes and nothing else. A theme changes colour; it never changes what a row
|
||||
// contains, where a control sits, or whether a feature exists — so a theme this build has
|
||||
// never heard of is at worst the wrong shade, never a launcher that will not draw.
|
||||
const themeSchemaVersion = 1
|
||||
|
||||
// themePalette is the whole vocabulary a theme may set, and it is deliberately the exact
|
||||
// token list in the television's ui/theme/DesignTokens.kt. A palette carrying a colour the
|
||||
// TV has no slot for would be a promise the client cannot keep; a palette missing one is a
|
||||
// theme that half-applies, which reads as a bug rather than as a design.
|
||||
//
|
||||
// Colours are "#RRGGBB" or "#AARRGGBB" — the alpha-first order Android writes, because that
|
||||
// is the one end that has to parse them.
|
||||
type themePalette struct {
|
||||
Surface string `json:"surface"`
|
||||
SurfaceRaised string `json:"surfaceRaised"`
|
||||
Accent string `json:"accent"`
|
||||
OnSurface string `json:"onSurface"`
|
||||
MutedText string `json:"mutedText"`
|
||||
QuietText string `json:"quietText"`
|
||||
Hairline string `json:"hairline"`
|
||||
RatingsSurface string `json:"ratingsSurface"`
|
||||
}
|
||||
|
||||
// The decorations a theme may ask a television to draw over its launcher. A slug rather
|
||||
// than a description of the animation: the drawing lives on the TV, in Compose, and the
|
||||
// gateway has no business describing shapes to it. A client that does not recognise one
|
||||
// draws nothing, which is why this can gain a decoration before the fleet has the build
|
||||
// that knows it — the MembyHeroLabel precedent.
|
||||
const (
|
||||
decorationSnow = "snow"
|
||||
decorationBats = "bats"
|
||||
decorationBlossom = "blossom"
|
||||
)
|
||||
|
||||
type themeDefinition struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// Description is what the picker prints under the name. One short line: the viewer is
|
||||
// reading it from across a room and the swatch is doing most of the work.
|
||||
Description string `json:"description"`
|
||||
// Seasonal themes are never offered in the picker and never stored as anybody's choice.
|
||||
Seasonal bool `json:"seasonal"`
|
||||
Palette themePalette `json:"palette"`
|
||||
// Decoration is what drifts over the launcher while this theme is on. Only seasonal
|
||||
// themes carry one: a scheme somebody chose to look at every day of the year must not
|
||||
// have things falling across it, and a viewer who wanted that would have no way to stop
|
||||
// it. It is empty on every selectable theme by construction rather than by a check at
|
||||
// the point of use.
|
||||
Decoration string `json:"decoration,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
themeMidnight = "midnight"
|
||||
themeGraphite = "graphite"
|
||||
themeMidnightB = "indigo"
|
||||
themeEmber = "ember"
|
||||
themeForest = "forest"
|
||||
themePlum = "plum"
|
||||
|
||||
themeHalloween = "halloween"
|
||||
themeChristmas = "christmas"
|
||||
themeEaster = "easter"
|
||||
)
|
||||
|
||||
// defaultThemeID is what a viewer who has never chosen gets, and what an illegal choice
|
||||
// falls back to. It is the palette the app shipped with before themes existed, so nothing
|
||||
// changes appearance on the day this lands.
|
||||
const defaultThemeID = themeMidnight
|
||||
|
||||
// themeCatalogue is the only place a theme is declared: the picker, the admin console's
|
||||
// allowlist editor and the set of legal `themeId` values all read it.
|
||||
//
|
||||
// The neutrals move with the accent rather than staying fixed. A single accent swapped into
|
||||
// one grey shell reads as a stray coloured button rather than as a theme, and on a panel
|
||||
// this dark a hairline that does not carry a hint of the accent disappears entirely.
|
||||
var themeCatalogue = []themeDefinition{
|
||||
{
|
||||
ID: themeMidnight, Name: "Midnight", Description: "The Memby original — near-black and Emby green.",
|
||||
Palette: themePalette{
|
||||
Surface: "#FF090B0D", SurfaceRaised: "#FF101418", Accent: "#FF52B54B",
|
||||
OnSurface: "#FFE2E5E8", MutedText: "#FFD0D6DB", QuietText: "#FFAEB7BF",
|
||||
Hairline: "#28FFFFFF", RatingsSurface: "#FF20252A",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: themeGraphite, Name: "Graphite", Description: "Warm grey and amber, easier on a bright room.",
|
||||
Palette: themePalette{
|
||||
Surface: "#FF0D0C0A", SurfaceRaised: "#FF181614", Accent: "#FFE0A33C",
|
||||
OnSurface: "#FFE9E5DE", MutedText: "#FFD8D2C8", QuietText: "#FFB6AEA1",
|
||||
Hairline: "#28FFF3DC", RatingsSurface: "#FF262320",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: themeMidnightB, Name: "Indigo", Description: "Deep blue with a cool electric accent.",
|
||||
Palette: themePalette{
|
||||
Surface: "#FF07090F", SurfaceRaised: "#FF111726", Accent: "#FF5C8DFF",
|
||||
OnSurface: "#FFE1E6F0", MutedText: "#FFCBD4E4", QuietText: "#FFA5B0C6",
|
||||
Hairline: "#28C7D8FF", RatingsSurface: "#FF1D2435",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: themeEmber, Name: "Ember", Description: "Charcoal and a low red, for watching in the dark.",
|
||||
Palette: themePalette{
|
||||
Surface: "#FF0C0808", SurfaceRaised: "#FF181111", Accent: "#FFE05B4A",
|
||||
OnSurface: "#FFEDE3E1", MutedText: "#FFDACECB", QuietText: "#FFB8A7A3",
|
||||
Hairline: "#28FFD5CE", RatingsSurface: "#FF261B1A",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: themeForest, Name: "Forest", Description: "Muted green on a near-black that leans warm.",
|
||||
Palette: themePalette{
|
||||
Surface: "#FF080B09", SurfaceRaised: "#FF111713", Accent: "#FF7FC08A",
|
||||
OnSurface: "#FFE3E8E3", MutedText: "#FFCFD8CF", QuietText: "#FFA9B5AA",
|
||||
Hairline: "#28D2F0D6", RatingsSurface: "#FF1E2620",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: themePlum, Name: "Plum", Description: "Aubergine and soft violet.",
|
||||
Palette: themePalette{
|
||||
Surface: "#FF0B080D", SurfaceRaised: "#FF171020", Accent: "#FFB37FE0",
|
||||
OnSurface: "#FFE7E2EC", MutedText: "#FFD5CCDD", QuietText: "#FFB0A4BC",
|
||||
Hairline: "#28E4D2FF", RatingsSurface: "#FF241B2D",
|
||||
},
|
||||
},
|
||||
|
||||
// --- Seasonal. Never offered, never stored, never declined. ------------------------
|
||||
{
|
||||
ID: themeHalloween, Name: "Halloween", Seasonal: true,
|
||||
Description: "Pumpkin orange on black, for the last week of October.",
|
||||
Decoration: decorationBats,
|
||||
Palette: themePalette{
|
||||
Surface: "#FF0A0704", SurfaceRaised: "#FF17100A", Accent: "#FFFF8A1F",
|
||||
OnSurface: "#FFF2E7DA", MutedText: "#FFE2D2BE", QuietText: "#FFBBA48C",
|
||||
Hairline: "#28FFB870", RatingsSurface: "#FF26190E",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: themeChristmas, Name: "Christmas", Seasonal: true,
|
||||
Description: "Pine and holly red, through December.",
|
||||
Decoration: decorationSnow,
|
||||
Palette: themePalette{
|
||||
Surface: "#FF060A07", SurfaceRaised: "#FF0E1710", Accent: "#FFE0403F",
|
||||
OnSurface: "#FFEAF0E9", MutedText: "#FFD6E0D5", QuietText: "#FFAEBCAE",
|
||||
Hairline: "#28CFE8CF", RatingsSurface: "#FF19261B",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: themeEaster, Name: "Easter", Seasonal: true,
|
||||
Description: "Pale spring colours over the Easter weekend.",
|
||||
Decoration: decorationBlossom,
|
||||
Palette: themePalette{
|
||||
Surface: "#FF0A0910", SurfaceRaised: "#FF15131F", Accent: "#FF9BD3F0",
|
||||
OnSurface: "#FFEDE9F2", MutedText: "#FFDCD6E4", QuietText: "#FFB6AEC4",
|
||||
Hairline: "#28D8E9F7", RatingsSurface: "#FF211E2E",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func themeDefinitionFor(id string) (themeDefinition, bool) {
|
||||
for _, theme := range themeCatalogue {
|
||||
if theme.ID == id {
|
||||
return theme, true
|
||||
}
|
||||
}
|
||||
return themeDefinition{}, false
|
||||
}
|
||||
|
||||
// selectableThemes is the catalogue a viewer could ever be offered, before the operator's
|
||||
// per-user allowlist narrows it. Seasonal themes are absent by construction rather than
|
||||
// filtered at the point of use, so there is no code path that can offer one as a choice.
|
||||
func selectableThemes() []themeDefinition {
|
||||
themes := make([]themeDefinition, 0, len(themeCatalogue))
|
||||
for _, theme := range themeCatalogue {
|
||||
if !theme.Seasonal {
|
||||
themes = append(themes, theme)
|
||||
}
|
||||
}
|
||||
return themes
|
||||
}
|
||||
|
||||
func selectableThemeIDs() []string {
|
||||
ids := make([]string, 0, len(themeCatalogue))
|
||||
for _, theme := range selectableThemes() {
|
||||
ids = append(ids, theme.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// themeOptions renders the selectable catalogue as preference options, so the `themeId`
|
||||
// entry in preferenceCatalogue cannot drift from the themes that actually exist.
|
||||
//
|
||||
// It lists every selectable theme rather than only the ones a given viewer may pick,
|
||||
// because normalizePreferences is pure and per-viewer policy is not a vocabulary question.
|
||||
// The allowlist is applied at resolution instead — see resolveTheme.
|
||||
func themeOptions() []preferenceOption {
|
||||
options := make([]preferenceOption, 0, len(themeCatalogue))
|
||||
for _, theme := range selectableThemes() {
|
||||
options = append(options, option(theme.ID, theme.Name))
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// --- The seasons ------------------------------------------------------------------------
|
||||
|
||||
// themeSeason is one window in the calendar and the theme it puts the house into.
|
||||
type themeSeason struct {
|
||||
theme string
|
||||
// contains answers for a local date. A function rather than a pair of dates because
|
||||
// Easter is not on one.
|
||||
contains func(year int, month time.Month, day int) bool
|
||||
}
|
||||
|
||||
// seasons are checked in order and the first match wins, which only matters if two windows
|
||||
// ever overlap. They do not today, and the ordering is what stops a future one silently
|
||||
// producing two answers.
|
||||
var seasons = []themeSeason{
|
||||
{
|
||||
// The last week of October and All Saints' Day. It starts a week out rather than on
|
||||
// the day: a theme nobody sees until the evening of the 31st is one nobody sees.
|
||||
theme: themeHalloween,
|
||||
contains: func(_ int, month time.Month, day int) bool {
|
||||
return (month == time.October && day >= 25) || (month == time.November && day == 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
// December up to and including Boxing Day. It stops before New Year deliberately —
|
||||
// the tree is down, and a red-and-green launcher on the 30th reads as a server
|
||||
// nobody is maintaining.
|
||||
theme: themeChristmas,
|
||||
contains: func(_ int, month time.Month, day int) bool {
|
||||
return month == time.December && day <= 26
|
||||
},
|
||||
},
|
||||
{
|
||||
// Good Friday to Easter Monday, computed rather than listed: Easter moves, and a
|
||||
// hard-coded table is a feature with an expiry date on it.
|
||||
theme: themeEaster,
|
||||
contains: func(year int, month time.Month, day int) bool {
|
||||
sunday := easterSunday(year)
|
||||
date := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
|
||||
return !date.Before(sunday.AddDate(0, 0, -2)) && !date.After(sunday.AddDate(0, 0, 1))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// easterSunday is the anonymous Gregorian computus. It is arithmetic with no calendar
|
||||
// library behind it and no table to go stale, which is the only reason Easter is affordable
|
||||
// as a season at all.
|
||||
func easterSunday(year int) time.Time {
|
||||
a := year % 19
|
||||
b := year / 100
|
||||
c := year % 100
|
||||
d := b / 4
|
||||
e := b % 4
|
||||
f := (b + 8) / 25
|
||||
g := (b - f + 1) / 3
|
||||
h := (19*a + b - d - g + 15) % 30
|
||||
i := c / 4
|
||||
k := c % 4
|
||||
l := (32 + 2*e + 2*i - h - k) % 7
|
||||
m := (a + 11*h + 22*l) / 451
|
||||
month := (h + l - 7*m + 114) / 31
|
||||
day := ((h + l - 7*m + 114) % 31) + 1
|
||||
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// seasonalThemeFor is the theme in force on a given day, or "" for most of the year.
|
||||
//
|
||||
// Pure, and takes the time rather than reading the clock, so every window can be tested at
|
||||
// both of its edges without waiting for October. The date is read in whatever location the
|
||||
// caller hands it in: the gateway runs on the household's own machine, and "Christmas" means
|
||||
// the calendar on the wall in that house, not a UTC instant.
|
||||
func seasonalThemeFor(now time.Time) string {
|
||||
year, month, day := now.Date()
|
||||
for _, season := range seasons {
|
||||
if season.contains(year, month, day) {
|
||||
return season.theme
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- Resolution -------------------------------------------------------------------------
|
||||
|
||||
// resolvedTheme is the answer a television is given: one palette, and enough about where it
|
||||
// came from for the picker to explain itself.
|
||||
type resolvedTheme struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Palette themePalette `json:"palette"`
|
||||
// Seasonal says this palette was not chosen by anybody.
|
||||
Seasonal bool `json:"seasonal"`
|
||||
// Locked is what the picker obeys: while it is true the viewer's own choice is still
|
||||
// stored and still shown, but it cannot be changed and is not what is on screen. It is
|
||||
// a separate field from Seasonal rather than the same one, because a future reason to
|
||||
// lock a theme (an operator pinning one, say) must not have to claim to be a season.
|
||||
Locked bool `json:"locked"`
|
||||
// Chosen is the viewer's own selection, still theirs underneath a season. Without it
|
||||
// the picker would have nothing to show as selected for the fortnight a season is up,
|
||||
// and would look as though the choice had been forgotten.
|
||||
Chosen string `json:"chosen"`
|
||||
// Decoration is what the launcher draws over itself: "snow", "bats", "blossom", or
|
||||
// empty for the whole rest of the year. Empty is also what an operator who has turned
|
||||
// the decorations off gets, which is why it is resolved here rather than read off the
|
||||
// theme by the television — a set holding a cached Christmas palette must not keep
|
||||
// snowing after the switch has been thrown.
|
||||
Decoration string `json:"decoration,omitempty"`
|
||||
// Reason is the sentence the picker prints while it is locked. The gateway's wording,
|
||||
// the MembyHeroLabel precedent, so a season invented later reads correctly on today's
|
||||
// build rather than as a blank space where an explanation should be.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// Revision changes whenever the bytes of this answer would change. The status poll
|
||||
// carries it and the television refetches only when it moves — which is what makes a
|
||||
// season arriving overnight cost one request rather than a palette on every poll.
|
||||
//
|
||||
// A string, not a number: it is a 64-bit hash, and JSON numbers are float64 in both
|
||||
// the admin console and anything else that reads this. It is only ever compared for
|
||||
// equality, so its being opaque costs nothing.
|
||||
Revision string `json:"revision"`
|
||||
}
|
||||
|
||||
// resolveTheme is the whole rule, and it is pure.
|
||||
//
|
||||
// Order matters and is the feature: a season outranks the viewer, the viewer outranks the
|
||||
// default, and the operator's allowlist is applied to the viewer's choice but never to a
|
||||
// season. That last part is what "cannot be removed or controlled by the user" means in
|
||||
// code — there is no argument to this function that a television could send which suppresses
|
||||
// a season. The only switch is seasonalEnabled, and that is the operator's feature flag.
|
||||
func resolveTheme(
|
||||
chosen string,
|
||||
allowed []string,
|
||||
seasonalEnabled bool,
|
||||
decorationsEnabled bool,
|
||||
now time.Time,
|
||||
) resolvedTheme {
|
||||
// The viewer's own choice first, so it is reported even while a season covers it.
|
||||
pick, ok := themeDefinitionFor(chosen)
|
||||
if !ok || pick.Seasonal || !themeAllowed(pick.ID, allowed) {
|
||||
pick, _ = themeDefinitionFor(defaultThemeID)
|
||||
}
|
||||
|
||||
applied, seasonal, reason := pick, false, ""
|
||||
if seasonalEnabled {
|
||||
if id := seasonalThemeFor(now); id != "" {
|
||||
if season, found := themeDefinitionFor(id); found {
|
||||
applied, seasonal = season, true
|
||||
reason = season.Name + " is on for everyone until it is over."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decorations are a second switch, not a consequence of the first. A household on a
|
||||
// weak box may well want the December palette and nothing moving over it, and a
|
||||
// decoration is by far the more expensive half — it is the only thing in the app that
|
||||
// animates continuously while somebody is browsing.
|
||||
decoration := ""
|
||||
if seasonal && decorationsEnabled {
|
||||
decoration = applied.Decoration
|
||||
}
|
||||
|
||||
resolved := resolvedTheme{
|
||||
ID: applied.ID, Name: applied.Name, Palette: applied.Palette,
|
||||
Seasonal: seasonal, Locked: seasonal, Chosen: pick.ID, Reason: reason,
|
||||
Decoration: decoration,
|
||||
}
|
||||
resolved.Revision = themeRevision(resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
// themeAllowed applies the operator's per-user list. An empty list is *permissive*: no row
|
||||
// has ever been written for the great majority of households, and reading that as "this
|
||||
// person may have no themes" would empty every picker in the house the day this ships.
|
||||
func themeAllowed(id string, allowed []string) bool {
|
||||
if len(allowed) == 0 {
|
||||
return true
|
||||
}
|
||||
return slices.Contains(allowed, id)
|
||||
}
|
||||
|
||||
// themeRevision is a hash of the answer rather than a counter in a table, because there is
|
||||
// no write to attach a counter to: this changes when the calendar turns over or when a
|
||||
// deployment edits the catalogue, and neither of those is a row anybody updates.
|
||||
func themeRevision(resolved resolvedTheme) string {
|
||||
digest := fnv.New64a()
|
||||
palette := resolved.Palette
|
||||
for _, part := range []string{
|
||||
strconv.Itoa(themeSchemaVersion), resolved.ID, resolved.Chosen,
|
||||
strconv.FormatBool(resolved.Seasonal), strconv.FormatBool(resolved.Locked), resolved.Reason,
|
||||
resolved.Decoration,
|
||||
palette.Surface, palette.SurfaceRaised, palette.Accent, palette.OnSurface,
|
||||
palette.MutedText, palette.QuietText, palette.Hairline, palette.RatingsSurface,
|
||||
} {
|
||||
_, _ = digest.Write([]byte(part))
|
||||
_, _ = digest.Write([]byte{0})
|
||||
}
|
||||
return strconv.FormatUint(digest.Sum64(), 10)
|
||||
}
|
||||
|
||||
// --- Serving ------------------------------------------------------------------------------
|
||||
|
||||
// themeFor resolves this viewer's theme from the three things it depends on: their stored
|
||||
// choice, the operator's allowlist for them, and the clock.
|
||||
//
|
||||
// Every read failure degrades to the default rather than to an error. A launcher that will
|
||||
// not open because a colour could not be looked up would be an absurd trade, and the
|
||||
// palette it falls back to is the one the app shipped with.
|
||||
func (s *Server) themeFor(ctx context.Context, sess store.Session) resolvedTheme {
|
||||
chosen, _ := preferenceDefault("themeId").(string)
|
||||
allowed := []string(nil)
|
||||
if s.store != nil && sess.EmbyUserID != "" {
|
||||
if stored, err := s.store.UserPreferences(ctx, sess.EmbyUserID); err == nil {
|
||||
if value, ok := decodePreferences(stored.Preferences)["themeId"].(string); ok {
|
||||
chosen = value
|
||||
}
|
||||
} else {
|
||||
s.loggerFor(ctx).Warn("theme preference unavailable", "error", err)
|
||||
}
|
||||
if list, err := s.store.UserThemes(ctx, sess.EmbyUserID); err == nil {
|
||||
allowed = list
|
||||
} else {
|
||||
s.loggerFor(ctx).Warn("theme allowlist unavailable", "error", err)
|
||||
}
|
||||
}
|
||||
return resolveTheme(
|
||||
chosen, allowed,
|
||||
s.featureEnabled(ctx, featureSeasonalThemes),
|
||||
s.featureEnabled(ctx, featureSeasonalDecorations),
|
||||
s.now(),
|
||||
)
|
||||
}
|
||||
|
||||
// now is the gateway's own clock, in its own location. Seasons are calendar dates in the
|
||||
// house the server sits in; see seasonalThemeFor.
|
||||
func (s *Server) now() time.Time { return time.Now() }
|
||||
|
||||
// themeStatus is the summary /v1/status carries: enough for a television to know whether
|
||||
// what it is painted with is still right, and nothing more.
|
||||
func themeStatus(resolved resolvedTheme) map[string]any {
|
||||
return map[string]any{
|
||||
"id": resolved.ID,
|
||||
"revision": resolved.Revision,
|
||||
"seasonal": resolved.Seasonal,
|
||||
"locked": resolved.Locked,
|
||||
}
|
||||
}
|
||||
|
||||
type themeResponse struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Theme resolvedTheme `json:"theme"`
|
||||
Available []themeDefinition `json:"available"`
|
||||
}
|
||||
|
||||
// handleTheme is the full document, fetched only when the revision on the status poll moves.
|
||||
//
|
||||
// It carries the *available* list as well as the applied palette, so the television's picker
|
||||
// is drawn from the server's answer for this particular viewer rather than from a catalogue
|
||||
// compiled into the APK. That is what makes the per-user allowlist real: a theme an operator
|
||||
// has withheld is not a greyed-out row on the TV, it is a row that was never sent.
|
||||
func (s *Server) handleTheme(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
resolved := s.themeFor(r.Context(), sess)
|
||||
allowed := []string(nil)
|
||||
if s.store != nil && sess.EmbyUserID != "" {
|
||||
if list, err := s.store.UserThemes(r.Context(), sess.EmbyUserID); err == nil {
|
||||
allowed = list
|
||||
}
|
||||
}
|
||||
available := []themeDefinition{}
|
||||
for _, theme := range selectableThemes() {
|
||||
if themeAllowed(theme.ID, allowed) {
|
||||
available = append(available, theme)
|
||||
}
|
||||
}
|
||||
// A viewer whose allowlist has been emptied down to nothing legal still gets the
|
||||
// default, or Settings → Appearance is a page with no rows on it and no way back to one.
|
||||
if len(available) == 0 {
|
||||
if fallback, ok := themeDefinitionFor(defaultThemeID); ok {
|
||||
available = append(available, fallback)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, themeResponse{
|
||||
SchemaVersion: themeSchemaVersion, Theme: resolved, Available: available,
|
||||
})
|
||||
}
|
||||
|
||||
// --- The operator's allowlist ---------------------------------------------------------------
|
||||
|
||||
// normalizeThemeAllowlist is what stands between a hand-edited admin request and a viewer
|
||||
// with a picker full of themes that do not exist. Unknown and seasonal ids are dropped —
|
||||
// a season is not a thing that can be granted or withheld per person — and the result is
|
||||
// ordered by the catalogue so two operators saving the same set store the same row.
|
||||
//
|
||||
// A list that selects everything selectable is stored as nothing at all, which keeps the
|
||||
// permissive default meaning one thing: "the operator has not restricted this person".
|
||||
func normalizeThemeAllowlist(ids []string) []string {
|
||||
kept := []string{}
|
||||
for _, theme := range selectableThemes() {
|
||||
if slices.Contains(ids, theme.ID) {
|
||||
kept = append(kept, theme.ID)
|
||||
}
|
||||
}
|
||||
if len(kept) == len(selectableThemes()) {
|
||||
return []string{}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
type adminThemesRequest struct {
|
||||
Themes []string `json:"themes"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUserThemes(w http.ResponseWriter, r *http.Request) {
|
||||
userID := strings.TrimSpace(r.PathValue("userID"))
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusBadRequest, "user is required")
|
||||
return
|
||||
}
|
||||
var req adminThemesRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
allowed := normalizeThemeAllowlist(req.Themes)
|
||||
if err := s.store.SetUserThemes(r.Context(), userID, allowed); err != nil {
|
||||
s.loggerFor(r.Context()).Error("theme allowlist write failed", "user", userID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save those themes")
|
||||
return
|
||||
}
|
||||
s.loggerFor(r.Context()).Info("themes allowed for viewer",
|
||||
"user", userID, "themes", themeListLabel(allowed))
|
||||
writeJSON(w, http.StatusOK, map[string]any{"themes": allowed})
|
||||
}
|
||||
|
||||
// themeListLabel is for the log line, where "all" says more than an empty array does.
|
||||
func themeListLabel(allowed []string) string {
|
||||
if len(allowed) == 0 {
|
||||
return "all"
|
||||
}
|
||||
sorted := append([]string{}, allowed...)
|
||||
sort.Strings(sorted)
|
||||
return strings.Join(sorted, ",")
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func date(year int, month time.Month, day int) time.Time {
|
||||
return time.Date(year, month, day, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// The windows are tested at both edges rather than in the middle, because the middle is
|
||||
// never what breaks: a season that starts a day late is a season nobody sees the start of,
|
||||
// and one that ends a day late is a red launcher on the 27th of December.
|
||||
func TestSeasonalThemeWindows(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
when time.Time
|
||||
want string
|
||||
}{
|
||||
{"the day before Halloween opens", date(2026, time.October, 24), ""},
|
||||
{"Halloween opens", date(2026, time.October, 25), themeHalloween},
|
||||
{"Halloween itself", date(2026, time.October, 31), themeHalloween},
|
||||
{"All Saints", date(2026, time.November, 1), themeHalloween},
|
||||
{"the day after Halloween closes", date(2026, time.November, 2), ""},
|
||||
|
||||
{"the day before December", date(2026, time.November, 30), ""},
|
||||
{"December opens", date(2026, time.December, 1), themeChristmas},
|
||||
{"Boxing Day", date(2026, time.December, 26), themeChristmas},
|
||||
{"the day after Boxing Day", date(2026, time.December, 27), ""},
|
||||
{"New Year's Eve", date(2026, time.December, 31), ""},
|
||||
|
||||
{"an ordinary day", date(2026, time.June, 14), ""},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := seasonalThemeFor(testCase.when); got != testCase.want {
|
||||
t.Fatalf("seasonalThemeFor(%s) = %q, want %q",
|
||||
testCase.when.Format(time.DateOnly), got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Easter moves, which is the entire reason it is computed rather than listed. These are the
|
||||
// real dates; if the computus is wrong the feature silently observes Easter on the wrong
|
||||
// weekend, which nobody would report as a bug.
|
||||
func TestEasterSunday(t *testing.T) {
|
||||
cases := map[int]string{
|
||||
2024: "2024-03-31", 2025: "2025-04-20", 2026: "2026-04-05",
|
||||
2027: "2027-03-28", 2030: "2030-04-21", 2038: "2038-04-25",
|
||||
}
|
||||
for year, want := range cases {
|
||||
if got := easterSunday(year).Format(time.DateOnly); got != want {
|
||||
t.Fatalf("easterSunday(%d) = %s, want %s", year, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasterWindowIsTheLongWeekend(t *testing.T) {
|
||||
// Easter Sunday 2026 is 5 April, so the window is Good Friday the 3rd to Easter
|
||||
// Monday the 6th.
|
||||
cases := []struct {
|
||||
day int
|
||||
want string
|
||||
}{
|
||||
{2, ""}, {3, themeEaster}, {4, themeEaster}, {5, themeEaster}, {6, themeEaster}, {7, ""},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
if got := seasonalThemeFor(date(2026, time.April, testCase.day)); got != testCase.want {
|
||||
t.Fatalf("2026-04-%02d = %q, want %q", testCase.day, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A season is the one thing on this feature nobody on a television can decline, so the
|
||||
// tests that matter most are the ones asserting that no argument suppresses it.
|
||||
func TestSeasonOutranksTheViewer(t *testing.T) {
|
||||
resolved := resolveTheme(themePlum, nil, true, true, date(2026, time.December, 20))
|
||||
if resolved.ID != themeChristmas {
|
||||
t.Fatalf("applied theme = %q, want %q", resolved.ID, themeChristmas)
|
||||
}
|
||||
if !resolved.Seasonal || !resolved.Locked {
|
||||
t.Fatalf("seasonal=%v locked=%v, want both true", resolved.Seasonal, resolved.Locked)
|
||||
}
|
||||
if resolved.Reason == "" {
|
||||
t.Fatal("a locked theme must say why; the picker has nothing else to print")
|
||||
}
|
||||
// The viewer's own choice survives underneath, or the picker would show nothing
|
||||
// selected for the length of the season and look as though it had been forgotten.
|
||||
if resolved.Chosen != themePlum {
|
||||
t.Fatalf("chosen = %q, want %q", resolved.Chosen, themePlum)
|
||||
}
|
||||
}
|
||||
|
||||
// An allowlist narrows what somebody may pick. It must not narrow a season: those are not
|
||||
// grantable per person, and an operator restricting a viewer to one palette must not be a
|
||||
// way of exempting them from Christmas.
|
||||
func TestAllowlistDoesNotApplyToSeasons(t *testing.T) {
|
||||
resolved := resolveTheme(themePlum, []string{themeEmber}, true, true, date(2026, time.October, 31))
|
||||
if resolved.ID != themeHalloween {
|
||||
t.Fatalf("applied theme = %q, want %q", resolved.ID, themeHalloween)
|
||||
}
|
||||
// The disallowed choice falls back to the default rather than being kept, so the
|
||||
// moment the season ends this set paints itself something the operator permits.
|
||||
if resolved.Chosen != defaultThemeID {
|
||||
t.Fatalf("chosen = %q, want the default %q", resolved.Chosen, defaultThemeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeasonsOffLeavesTheViewersChoice(t *testing.T) {
|
||||
resolved := resolveTheme(themeEmber, nil, false, true, date(2026, time.December, 20))
|
||||
if resolved.ID != themeEmber {
|
||||
t.Fatalf("applied theme = %q, want %q", resolved.ID, themeEmber)
|
||||
}
|
||||
if resolved.Seasonal || resolved.Locked {
|
||||
t.Fatal("with seasonal themes off nothing is locked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveThemeFallbacks(t *testing.T) {
|
||||
ordinary := date(2026, time.June, 14)
|
||||
cases := []struct {
|
||||
name string
|
||||
chosen string
|
||||
allowed []string
|
||||
want string
|
||||
}{
|
||||
{"nothing chosen", "", nil, defaultThemeID},
|
||||
{"a theme this server does not have", "chartreuse", nil, defaultThemeID},
|
||||
{"a theme the operator withheld", themePlum, []string{themeEmber}, defaultThemeID},
|
||||
{"a theme the operator allowed", themePlum, []string{themePlum, themeEmber}, themePlum},
|
||||
{"no restriction at all", themeForest, []string{}, themeForest},
|
||||
// A season named directly is not a way in. It is not selectable, so it is not a
|
||||
// legal stored value however it got into the document.
|
||||
{"a season asked for out of season", themeHalloween, nil, defaultThemeID},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := resolveTheme(testCase.chosen, testCase.allowed, true, true, ordinary)
|
||||
if got.ID != testCase.want {
|
||||
t.Fatalf("resolveTheme(%q, %v) = %q, want %q",
|
||||
testCase.chosen, testCase.allowed, got.ID, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The revision is the whole delivery mechanism: a television refetches the palette only when
|
||||
// this moves. If it did not move when a season began, no set in the house would repaint.
|
||||
func TestThemeRevisionTracksTheAnswer(t *testing.T) {
|
||||
ordinary := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 14))
|
||||
christmas := resolveTheme(themePlum, nil, true, true, date(2026, time.December, 20))
|
||||
if ordinary.Revision == christmas.Revision {
|
||||
t.Fatal("the revision must change when the season does, or nothing refetches")
|
||||
}
|
||||
// And it must be stable, or every poll would look like a change and every set would
|
||||
// fetch the palette six times a minute.
|
||||
again := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 15))
|
||||
if ordinary.Revision != again.Revision {
|
||||
t.Fatalf("the revision moved on an ordinary day: %s then %s", ordinary.Revision, again.Revision)
|
||||
}
|
||||
}
|
||||
|
||||
// The decoration is the operator's second switch. It must be able to come off without
|
||||
// taking the palette with it, and it must never appear on a theme somebody chose to look at
|
||||
// every day of the year.
|
||||
func TestDecorationsAreSeasonalAndSeparatelySwitchable(t *testing.T) {
|
||||
christmas := date(2026, time.December, 20)
|
||||
if got := resolveTheme(themePlum, nil, true, true, christmas); got.Decoration != decorationSnow {
|
||||
t.Fatalf("decoration = %q, want %q", got.Decoration, decorationSnow)
|
||||
}
|
||||
off := resolveTheme(themePlum, nil, true, false, christmas)
|
||||
if off.Decoration != "" {
|
||||
t.Fatalf("decorations off still returned %q", off.Decoration)
|
||||
}
|
||||
if off.ID != themeChristmas {
|
||||
t.Fatal("turning decorations off must keep the seasonal palette")
|
||||
}
|
||||
// The revision has to move, or a set already snowing is never told to stop.
|
||||
if off.Revision == resolveTheme(themePlum, nil, true, true, christmas).Revision {
|
||||
t.Fatal("the revision must change when the decoration does")
|
||||
}
|
||||
ordinary := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 14))
|
||||
if ordinary.Decoration != "" {
|
||||
t.Fatalf("a chosen theme carries a decoration: %q", ordinary.Decoration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlySeasonalThemesDeclareADecoration(t *testing.T) {
|
||||
for _, theme := range selectableThemes() {
|
||||
if theme.Decoration != "" {
|
||||
t.Fatalf("%q is selectable and must not decorate: %q", theme.ID, theme.Decoration)
|
||||
}
|
||||
}
|
||||
for _, theme := range themeCatalogue {
|
||||
if theme.Seasonal && theme.Decoration == "" {
|
||||
t.Fatalf("%q is a season with nothing to draw", theme.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectableThemesExcludeSeasons(t *testing.T) {
|
||||
for _, theme := range selectableThemes() {
|
||||
if theme.Seasonal {
|
||||
t.Fatalf("%q is seasonal and must never be offered as a choice", theme.ID)
|
||||
}
|
||||
}
|
||||
if !slices.Contains(selectableThemeIDs(), defaultThemeID) {
|
||||
t.Fatalf("the default theme %q must be selectable", defaultThemeID)
|
||||
}
|
||||
}
|
||||
|
||||
// Every theme must set every token. A palette missing one is a theme that half-applies,
|
||||
// which reads on a television as a rendering fault rather than as a colour scheme.
|
||||
func TestEveryThemeSetsEveryColour(t *testing.T) {
|
||||
for _, theme := range themeCatalogue {
|
||||
palette := theme.Palette
|
||||
for name, value := range map[string]string{
|
||||
"surface": palette.Surface, "surfaceRaised": palette.SurfaceRaised,
|
||||
"accent": palette.Accent, "onSurface": palette.OnSurface,
|
||||
"mutedText": palette.MutedText, "quietText": palette.QuietText,
|
||||
"hairline": palette.Hairline, "ratingsSurface": palette.RatingsSurface,
|
||||
} {
|
||||
if len(value) != 9 || value[0] != '#' {
|
||||
t.Fatalf("%s.%s = %q, want #AARRGGBB", theme.ID, name, value)
|
||||
}
|
||||
for _, digit := range value[1:] {
|
||||
if !((digit >= '0' && digit <= '9') || (digit >= 'A' && digit <= 'F')) {
|
||||
t.Fatalf("%s.%s = %q is not upper-case hex", theme.ID, name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if theme.Name == "" || theme.Description == "" {
|
||||
t.Fatalf("%s must carry a name and a line for the picker", theme.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue and the preference vocabulary have to agree, or a viewer can be offered a
|
||||
// theme the settings write will reject — which reads as a picker that does not work.
|
||||
func TestThemePreferenceOptionsMatchTheCatalogue(t *testing.T) {
|
||||
definition, ok := preferenceDefinitionFor("themeId")
|
||||
if !ok {
|
||||
t.Fatal("themeId is missing from the preference catalogue")
|
||||
}
|
||||
if len(definition.Options) != len(selectableThemes()) {
|
||||
t.Fatalf("themeId offers %d options for %d selectable themes",
|
||||
len(definition.Options), len(selectableThemes()))
|
||||
}
|
||||
for _, theme := range selectableThemes() {
|
||||
if !hasOption(definition.Options, theme.ID) {
|
||||
t.Fatalf("themeId does not offer %q", theme.ID)
|
||||
}
|
||||
}
|
||||
if normalizePreference(definition, themeHalloween) != defaultThemeID {
|
||||
t.Fatal("a seasonal id must not normalise to itself as a stored choice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeThemeAllowlist(t *testing.T) {
|
||||
everything := normalizeThemeAllowlist(selectableThemeIDs())
|
||||
if len(everything) != 0 {
|
||||
// "Every box ticked" and "never configured" are the same decision, and storing
|
||||
// them differently is how the two would drift apart as themes are added.
|
||||
t.Fatalf("an unrestricted list must store as empty, got %v", everything)
|
||||
}
|
||||
kept := normalizeThemeAllowlist([]string{themeEmber, "chartreuse", themeHalloween, themeEmber})
|
||||
if len(kept) != 1 || kept[0] != themeEmber {
|
||||
t.Fatalf("normalizeThemeAllowlist dropped the wrong things: %v", kept)
|
||||
}
|
||||
// Catalogue order, not request order, so two operators ticking the same boxes in a
|
||||
// different sequence store the same row.
|
||||
ordered := normalizeThemeAllowlist([]string{themePlum, themeMidnight})
|
||||
if !slices.Equal(ordered, []string{themeMidnight, themePlum}) {
|
||||
t.Fatalf("allowlist is not in catalogue order: %v", ordered)
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
0.1.23
|
||||
0.1.29
|
||||
@@ -373,6 +373,21 @@ func (c *Client) SetPlayed(ctx context.Context, cred Credentials, itemID string,
|
||||
return c.userDataCall(ctx, method, path, cred)
|
||||
}
|
||||
|
||||
// HideFromResume removes an item from resume and next-up feeds without marking it played.
|
||||
func (c *Client) HideFromResume(ctx context.Context, cred Credentials, itemID string) (json.RawMessage, error) {
|
||||
params := url.Values{"Hide": {"true"}}
|
||||
path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID) + "/HideFromResume"
|
||||
req, err := c.newRequest(ctx, http.MethodPost, path, params, cred, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw json.RawMessage
|
||||
if err := c.do(req, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// RefreshItem asks Emby to re-scan one item's files.
|
||||
//
|
||||
// It exists for the subtitle download: Bazarr writes the new .srt beside the media file
|
||||
@@ -501,6 +516,43 @@ func (c *Client) TrickplayBytes(
|
||||
return io.ReadAll(io.LimitReader(resp.Body, to-from+1))
|
||||
}
|
||||
|
||||
// MaxSubtitleBytes caps a subtitle read. A feature-length SubRip file is tens of
|
||||
// kilobytes; a megabyte is far past anything genuine and is what an Emby that answered
|
||||
// with the media file instead would send.
|
||||
const MaxSubtitleBytes = 4 << 20
|
||||
|
||||
// SubtitleBytes reads a text subtitle track's content.
|
||||
//
|
||||
// This is the one place the gateway pulls a subtitle *out* of Emby rather than pointing
|
||||
// the television at it, and it exists so a track can be used as a timing reference. SubRip
|
||||
// is asked for because that is what the sync reads and writes; Emby converts from whatever
|
||||
// the container holds.
|
||||
func (c *Client) SubtitleBytes(
|
||||
ctx context.Context, cred Credentials, itemID, mediaSourceID string, index int,
|
||||
) ([]byte, error) {
|
||||
if mediaSourceID == "" {
|
||||
mediaSourceID = itemID
|
||||
}
|
||||
path := fmt.Sprintf(
|
||||
"/Videos/%s/%s/Subtitles/%d/Stream.srt",
|
||||
url.PathEscape(itemID), url.PathEscape(mediaSourceID), index,
|
||||
)
|
||||
req, err := c.newRequest(ctx, http.MethodGet, path, nil, cred, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, MaxSubtitleBytes))
|
||||
}
|
||||
|
||||
// StreamURL is the direct-play URL handed to the TV. It points at the *public* Emby
|
||||
// address: video never flows through the gateway, only metadata does.
|
||||
func (c *Client) StreamURL(cred Credentials, itemID string) string {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package emby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHideFromResumeUsesDedicatedEmbyEndpoint(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "unexpected method: "+r.Method, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/Users/user-1/Items/item-2/HideFromResume" {
|
||||
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("Hide") != "true" {
|
||||
http.Error(w, "Hide must be true", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"Played":false,"PlaybackPositionTicks":0}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
client := New(upstream.URL, upstream.URL, "MbyATV", time.Second)
|
||||
got, err := client.HideFromResume(
|
||||
context.Background(),
|
||||
Credentials{UserID: "user-1", Token: "token"},
|
||||
"item-2",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != `{"Played":false,"PlaybackPositionTicks":0}` {
|
||||
t.Fatalf("response = %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
// Package opensubtitles provides the slice of opensubtitles.com Memby uses to fetch a
|
||||
// subtitle a title does not have.
|
||||
//
|
||||
// It is the second subtitle provider and it is not shaped like the first. Bazarr's whole
|
||||
// appeal is that it writes the file beside the media file, so the gateway asks and then
|
||||
// forgets — Emby finds the result and the track arrives down the ordinary PlaybackInfo
|
||||
// path. OpenSubtitles hands back bytes, and the gateway has no access to the media
|
||||
// directory, so a file fetched here is stored by the gateway and served back as a sidecar.
|
||||
// That difference is worth stating because everything else about the two providers is the
|
||||
// same shape, and the storage is the only reason `downloaded_subtitles` exists.
|
||||
//
|
||||
// Three things about the API are not obvious and each would otherwise be found as a bug:
|
||||
//
|
||||
// - Identity is an id, not a title. Searching takes an imdb or tmdb id, which is a far
|
||||
// better match than the title-and-year guessing the Bazarr path is stuck with — and
|
||||
// Memby already has those ids, because the library import asks Emby for ProviderIds
|
||||
// so external ratings can be looked up.
|
||||
// - The search result is not the file. A row carries a `file_id`, and turning that into
|
||||
// bytes is a second call to /download which returns a short-lived link. It is the
|
||||
// /download call that spends the account's daily allowance, never the search.
|
||||
// - /download wants a logged-in token in practice. An API key alone is accepted but the
|
||||
// anonymous allowance is a handful of files a day, which in front of a television
|
||||
// reads as the feature being broken. Credentials are optional here and the token is
|
||||
// cached, because logging in per download would spend a different quota instead.
|
||||
package opensubtitles
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultBaseURL is the REST API's home. It is a field on the client so the tests can
|
||||
// point at a fake, never so an operator can redirect credentials somewhere else.
|
||||
const DefaultBaseURL = "https://api.opensubtitles.com/api/v1"
|
||||
|
||||
// maxSubtitleBytes bounds what will be read from a download link. A subtitle is tens of
|
||||
// kilobytes; anything approaching this is not a subtitle, and the gateway stores what it
|
||||
// fetches, so an unbounded read here would be an unbounded row in Postgres.
|
||||
const maxSubtitleBytes = 4 << 20
|
||||
|
||||
// tokenLifetime is how long a login token is reused for. OpenSubtitles issues them for
|
||||
// about a day; renewing well inside that costs one request and avoids the case nobody
|
||||
// tests, which is the token expiring in the middle of somebody's film.
|
||||
const tokenLifetime = 12 * time.Hour
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
userAgent string
|
||||
username string
|
||||
password string
|
||||
http *http.Client
|
||||
|
||||
// The login token is shared by every viewer in the house, because the account is the
|
||||
// household's rather than anybody's. The mutex is held across the login request so a
|
||||
// launcher full of televisions cannot log in six times at once.
|
||||
mu sync.Mutex
|
||||
token string
|
||||
tokenExpiry time.Time
|
||||
}
|
||||
|
||||
// Subtitle is one candidate from a search.
|
||||
//
|
||||
// FileID is what /download takes and it is the only field that has to survive the round
|
||||
// trip through a television. Rating and Downloads are the two numbers a viewer could
|
||||
// sensibly choose by, and they are folded into a single Score by the caller so a row from
|
||||
// here reads the same as a row from Bazarr.
|
||||
type Subtitle struct {
|
||||
FileID int
|
||||
SubtitleID string
|
||||
Language string
|
||||
Release string
|
||||
FileName string
|
||||
Format string
|
||||
Forced bool
|
||||
HearingImpaired bool
|
||||
MachineOnly bool
|
||||
FromTrusted bool
|
||||
Downloads int
|
||||
Rating float64
|
||||
}
|
||||
|
||||
// Query is what identifies a title. Exactly one of IMDBID, TMDBID and Query is normally
|
||||
// set; for an episode, ParentIMDBID with Season and Episode is the reliable shape, since
|
||||
// far more shows carry an id on the series than on every episode.
|
||||
type Query struct {
|
||||
IMDBID string
|
||||
TMDBID string
|
||||
ParentIMDBID string
|
||||
ParentTMDBID string
|
||||
Query string
|
||||
Season int
|
||||
Episode int
|
||||
Languages []string
|
||||
// Type narrows the search to "movie" or "episode". It is worth sending: a query by
|
||||
// title alone otherwise returns a film and the show named after it together.
|
||||
Type string
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("opensubtitles: status %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
// QuotaError is the one failure worth telling a viewer about in its own words. Everything
|
||||
// else is "the provider did not answer"; this one is "you have used today's downloads",
|
||||
// which is not something pressing the button again will fix.
|
||||
type QuotaError struct {
|
||||
ResetTime string
|
||||
}
|
||||
|
||||
func (e *QuotaError) Error() string {
|
||||
if e.ResetTime != "" {
|
||||
return "opensubtitles: download quota exhausted, resets in " + e.ResetTime
|
||||
}
|
||||
return "opensubtitles: download quota exhausted"
|
||||
}
|
||||
|
||||
func New(apiKey, userAgent, username, password string, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
baseURL: DefaultBaseURL,
|
||||
apiKey: strings.TrimSpace(apiKey),
|
||||
userAgent: strings.TrimSpace(userAgent),
|
||||
username: strings.TrimSpace(username),
|
||||
password: password,
|
||||
http: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
MaxIdleConnsPerHost: 5,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SetBaseURL points the client somewhere else. It exists for the tests.
|
||||
func (c *Client) SetBaseURL(base string) {
|
||||
c.baseURL = strings.TrimRight(base, "/")
|
||||
}
|
||||
|
||||
// HasAccount reports whether a download will be made against the household's own
|
||||
// allowance rather than the anonymous one. The console shows it, because the difference
|
||||
// is the difference between a working feature and one that stops after a few files.
|
||||
func (c *Client) HasAccount() bool {
|
||||
return c.username != "" && c.password != ""
|
||||
}
|
||||
|
||||
// Search asks the providers what exists for one title.
|
||||
//
|
||||
// A search costs nothing against the download allowance, which is why the television is
|
||||
// allowed to run one per press. An empty result is an ordinary answer.
|
||||
func (c *Client) Search(ctx context.Context, query Query) ([]Subtitle, error) {
|
||||
params := searchParams(query)
|
||||
if len(params) == 0 {
|
||||
return nil, fmt.Errorf("opensubtitles: nothing to search by")
|
||||
}
|
||||
var payload struct {
|
||||
Data []struct {
|
||||
Attributes struct {
|
||||
SubtitleID string `json:"subtitle_id"`
|
||||
Language string `json:"language"`
|
||||
DownloadCount int `json:"download_count"`
|
||||
HearingImpaired bool `json:"hearing_impaired"`
|
||||
ForeignPartsOnly bool `json:"foreign_parts_only"`
|
||||
FromTrusted bool `json:"from_trusted"`
|
||||
AITranslated bool `json:"ai_translated"`
|
||||
MachineTranslated bool `json:"machine_translated"`
|
||||
Ratings float64 `json:"ratings"`
|
||||
Release string `json:"release"`
|
||||
Files []struct {
|
||||
FileID int `json:"file_id"`
|
||||
FileName string `json:"file_name"`
|
||||
} `json:"files"`
|
||||
} `json:"attributes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := c.get(ctx, "/subtitles", params, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Subtitle, 0, len(payload.Data))
|
||||
for _, row := range payload.Data {
|
||||
attributes := row.Attributes
|
||||
// A row with no file is not a candidate: there is nothing to hand to /download,
|
||||
// and a chooseable row that cannot be fetched is worse than one fewer row.
|
||||
if len(attributes.Files) == 0 || attributes.Files[0].FileID == 0 {
|
||||
continue
|
||||
}
|
||||
file := attributes.Files[0]
|
||||
out = append(out, Subtitle{
|
||||
FileID: file.FileID,
|
||||
SubtitleID: attributes.SubtitleID,
|
||||
Language: strings.TrimSpace(attributes.Language),
|
||||
Release: strings.TrimSpace(attributes.Release),
|
||||
FileName: strings.TrimSpace(file.FileName),
|
||||
Format: formatFromName(file.FileName),
|
||||
Forced: attributes.ForeignPartsOnly,
|
||||
HearingImpaired: attributes.HearingImpaired,
|
||||
MachineOnly: attributes.AITranslated || attributes.MachineTranslated,
|
||||
FromTrusted: attributes.FromTrusted,
|
||||
Downloads: attributes.DownloadCount,
|
||||
Rating: attributes.Ratings,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CanSearch reports whether a query identifies a title well enough to be worth sending.
|
||||
// It is the same rule searchParams applies, exported so a caller can decide not to offer
|
||||
// this provider for a title rather than sending a request that cannot answer.
|
||||
func CanSearch(query Query) bool {
|
||||
return len(searchParams(query)) > 0
|
||||
}
|
||||
|
||||
// searchParams is pure so the shape of a query can be pinned by a test. The parent ids are
|
||||
// only sent for an episode: on a film they mean nothing, and sending both an id and a
|
||||
// season number is how a search comes back empty for a title that plainly exists.
|
||||
func searchParams(query Query) url.Values {
|
||||
params := url.Values{}
|
||||
episode := query.Season > 0 || query.Episode > 0
|
||||
switch {
|
||||
case strings.TrimSpace(query.IMDBID) != "":
|
||||
params.Set("imdb_id", trimIMDB(query.IMDBID))
|
||||
case strings.TrimSpace(query.TMDBID) != "":
|
||||
params.Set("tmdb_id", strings.TrimSpace(query.TMDBID))
|
||||
case episode && strings.TrimSpace(query.ParentIMDBID) != "":
|
||||
params.Set("parent_imdb_id", trimIMDB(query.ParentIMDBID))
|
||||
case episode && strings.TrimSpace(query.ParentTMDBID) != "":
|
||||
params.Set("parent_tmdb_id", strings.TrimSpace(query.ParentTMDBID))
|
||||
case strings.TrimSpace(query.Query) != "":
|
||||
params.Set("query", strings.TrimSpace(query.Query))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if episode {
|
||||
if query.Season >= 0 && (query.Season > 0 || query.Episode > 0) {
|
||||
params.Set("season_number", strconv.Itoa(query.Season))
|
||||
}
|
||||
if query.Episode > 0 {
|
||||
params.Set("episode_number", strconv.Itoa(query.Episode))
|
||||
}
|
||||
}
|
||||
if languages := joinLanguages(query.Languages); languages != "" {
|
||||
params.Set("languages", languages)
|
||||
}
|
||||
if kind := strings.TrimSpace(query.Type); kind != "" {
|
||||
params.Set("type", kind)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// joinLanguages normalises the language list the API wants: lower case, comma separated,
|
||||
// sorted, and deduplicated. It is fussy about this — an unsorted list is rejected — which
|
||||
// is exactly the kind of thing that fails once in production and never in a review.
|
||||
func joinLanguages(languages []string) string {
|
||||
seen := map[string]bool{}
|
||||
values := make([]string, 0, len(languages))
|
||||
for _, language := range languages {
|
||||
language = strings.ToLower(strings.TrimSpace(language))
|
||||
if language == "" || seen[language] {
|
||||
continue
|
||||
}
|
||||
seen[language] = true
|
||||
values = append(values, language)
|
||||
}
|
||||
for i := 1; i < len(values); i++ {
|
||||
for j := i; j > 0 && values[j] < values[j-1]; j-- {
|
||||
values[j], values[j-1] = values[j-1], values[j]
|
||||
}
|
||||
}
|
||||
return strings.Join(values, ",")
|
||||
}
|
||||
|
||||
func trimIMDB(value string) string {
|
||||
return strings.TrimPrefix(strings.TrimSpace(value), "tt")
|
||||
}
|
||||
|
||||
func formatFromName(name string) string {
|
||||
if index := strings.LastIndex(name, "."); index >= 0 && index < len(name)-1 {
|
||||
extension := strings.ToLower(name[index+1:])
|
||||
if extension == "srt" || extension == "vtt" || extension == "ass" || extension == "ssa" {
|
||||
return extension
|
||||
}
|
||||
}
|
||||
return "srt"
|
||||
}
|
||||
|
||||
// Download turns a file id into bytes.
|
||||
//
|
||||
// It is two requests — a link, then the file — and it is the call that spends the
|
||||
// account's allowance, so it is never made speculatively and never on the playback path.
|
||||
func (c *Client) Download(ctx context.Context, fileID int) (name string, content []byte, err error) {
|
||||
body, err := json.Marshal(map[string]any{"file_id": fileID})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var payload struct {
|
||||
Link string `json:"link"`
|
||||
FileName string `json:"file_name"`
|
||||
Remaining int `json:"remaining"`
|
||||
ResetTime string `json:"reset_time"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := c.post(ctx, "/download", body, &payload); err != nil {
|
||||
var apiErr *APIError
|
||||
// 406 is what the API answers with when the allowance is gone. It is the one
|
||||
// failure a viewer can act on — by waiting — so it keeps its own type.
|
||||
if ok := asAPIError(err, &apiErr); ok && apiErr.StatusCode == http.StatusNotAcceptable {
|
||||
return "", nil, &QuotaError{}
|
||||
}
|
||||
return "", nil, err
|
||||
}
|
||||
if strings.TrimSpace(payload.Link) == "" {
|
||||
return "", nil, &QuotaError{ResetTime: payload.ResetTime}
|
||||
}
|
||||
|
||||
// The link is a plain file on a CDN and carries neither the API key nor the token.
|
||||
// Sending them would leak the household's credentials to a host that is not the API.
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, payload.Link, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("opensubtitles: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", nil, &APIError{StatusCode: resp.StatusCode, Body: "download link"}
|
||||
}
|
||||
content, err = io.ReadAll(io.LimitReader(resp.Body, maxSubtitleBytes))
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("opensubtitles: read subtitle: %w", err)
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return "", nil, fmt.Errorf("opensubtitles: empty subtitle file")
|
||||
}
|
||||
return payload.FileName, content, nil
|
||||
}
|
||||
|
||||
// Ping is the reachability and credential check the console uses. It reads the account
|
||||
// endpoint when there is an account and the plain info endpoint otherwise, so "the key
|
||||
// works" and "the login works" are two different answers.
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
if c.HasAccount() {
|
||||
if _, err := c.authToken(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var payload struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
return c.get(ctx, "/infos/formats", nil, &payload)
|
||||
}
|
||||
|
||||
// authToken returns a login token, logging in if the cached one is missing or old. An
|
||||
// account that will not log in is not fatal: the download is attempted anonymously, which
|
||||
// works until the small anonymous allowance runs out and is a better answer than refusing
|
||||
// to try.
|
||||
func (c *Client) authToken(ctx context.Context) (string, error) {
|
||||
if !c.HasAccount() {
|
||||
return "", nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.token != "" && time.Now().Before(c.tokenExpiry) {
|
||||
return c.token, nil
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{"username": c.username, "password": c.password})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
// Deliberately not through post(): that asks for a token, and this is how one is got.
|
||||
if err := c.do(ctx, http.MethodPost, "/login", nil, body, "", &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(payload.Token) == "" {
|
||||
return "", fmt.Errorf("opensubtitles: login returned no token")
|
||||
}
|
||||
c.token = payload.Token
|
||||
c.tokenExpiry = time.Now().Add(tokenLifetime)
|
||||
return c.token, nil
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, params url.Values, out any) error {
|
||||
token, _ := c.authToken(ctx)
|
||||
return c.do(ctx, http.MethodGet, path, params, nil, token, out)
|
||||
}
|
||||
|
||||
func (c *Client) post(ctx context.Context, path string, body []byte, out any) error {
|
||||
token, _ := c.authToken(ctx)
|
||||
return c.do(ctx, http.MethodPost, path, nil, body, token, out)
|
||||
}
|
||||
|
||||
func (c *Client) do(
|
||||
ctx context.Context, method, path string, params url.Values,
|
||||
body []byte, token string, out any,
|
||||
) error {
|
||||
endpoint := c.baseURL + path
|
||||
if len(params) > 0 {
|
||||
endpoint += "?" + params.Encode()
|
||||
}
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Api-Key", c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
// The API rejects a request with no User-Agent naming the consumer, and it is the one
|
||||
// header here that is about being a good citizen rather than about authentication.
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opensubtitles: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
return &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(raw))}
|
||||
}
|
||||
if out == nil {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("opensubtitles: decode response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func asAPIError(err error, target **APIError) bool {
|
||||
if converted, ok := err.(*APIError); ok {
|
||||
*target = converted
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package opensubtitles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSearchParamsPrefersItsOwnIDOverTheParents(t *testing.T) {
|
||||
params := searchParams(Query{
|
||||
IMDBID: "tt0903747", ParentIMDBID: "tt0999999", Season: 1, Episode: 2,
|
||||
Languages: []string{"it", "en", "en"}, Type: "episode",
|
||||
})
|
||||
if got := params.Get("imdb_id"); got != "0903747" {
|
||||
t.Fatalf("imdb_id = %q, want the tt stripped", got)
|
||||
}
|
||||
if params.Has("parent_imdb_id") {
|
||||
t.Fatal("parent_imdb_id was sent alongside the episode's own id")
|
||||
}
|
||||
if got := params.Get("season_number"); got != "1" {
|
||||
t.Fatalf("season_number = %q", got)
|
||||
}
|
||||
// Sorted and deduplicated: the API rejects the list in any other shape.
|
||||
if got := params.Get("languages"); got != "en,it" {
|
||||
t.Fatalf("languages = %q, want %q", got, "en,it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchParamsFallsBackToTheSeriesForAnEpisode(t *testing.T) {
|
||||
params := searchParams(Query{ParentIMDBID: "tt0903747", Season: 5, Episode: 14})
|
||||
if got := params.Get("parent_imdb_id"); got != "0903747" {
|
||||
t.Fatalf("parent_imdb_id = %q", got)
|
||||
}
|
||||
if got := params.Get("episode_number"); got != "14" {
|
||||
t.Fatalf("episode_number = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A film must never be sent a season number: an id plus a season is how a search comes
|
||||
// back empty for a title that plainly exists.
|
||||
func TestSearchParamsSendsNoSeasonForAFilm(t *testing.T) {
|
||||
params := searchParams(Query{TMDBID: "550", Type: "movie"})
|
||||
if params.Has("season_number") || params.Has("episode_number") {
|
||||
t.Fatal("a film was searched for with episode numbers")
|
||||
}
|
||||
if got := params.Get("tmdb_id"); got != "550" {
|
||||
t.Fatalf("tmdb_id = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchParamsRefusesAQueryItCannotIdentify(t *testing.T) {
|
||||
if params := searchParams(Query{Languages: []string{"en"}}); len(params) != 0 {
|
||||
t.Fatalf("searchParams answered %v for a query with no identity", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchDropsRowsWithNoFileToFetch(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Api-Key") != "key" {
|
||||
t.Errorf("api key header = %q", r.Header.Get("Api-Key"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{
|
||||
map[string]any{"attributes": map[string]any{
|
||||
"language": "en", "ratings": 8.5, "download_count": 120,
|
||||
"foreign_parts_only": true, "release": "BluRay",
|
||||
"files": []any{map[string]any{"file_id": 42, "file_name": "x.srt"}},
|
||||
}},
|
||||
map[string]any{"attributes": map[string]any{"language": "it", "files": []any{}}},
|
||||
}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New("key", "Memby/test", "", "", 5*time.Second)
|
||||
client.SetBaseURL(server.URL)
|
||||
found, err := client.Search(context.Background(), Query{IMDBID: "tt1", Languages: []string{"en"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Fatalf("got %d candidates, want the one with a file", len(found))
|
||||
}
|
||||
if found[0].FileID != 42 || !found[0].Forced || found[0].Downloads != 120 {
|
||||
t.Fatalf("candidate decoded as %+v", found[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFollowsTheLinkWithoutTheCredentials(t *testing.T) {
|
||||
var files *httptest.Server
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/login":
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"token": "jwt"})
|
||||
case "/download":
|
||||
if r.Header.Get("Authorization") != "Bearer jwt" {
|
||||
t.Errorf("download authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"link": files.URL + "/f.srt", "file_name": "f.srt", "remaining": 19,
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
files = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// The CDN is not the API: neither the key nor the token belongs on this request.
|
||||
if r.Header.Get("Api-Key") != "" || r.Header.Get("Authorization") != "" {
|
||||
t.Error("credentials were sent to the download host")
|
||||
}
|
||||
_, _ = w.Write([]byte("1\n00:00:01,000 --> 00:00:02,000\nhello\n"))
|
||||
}))
|
||||
defer files.Close()
|
||||
|
||||
client := New("key", "Memby/test", "someone", "secret", 5*time.Second)
|
||||
client.SetBaseURL(api.URL)
|
||||
name, content, err := client.Download(context.Background(), 42)
|
||||
if err != nil {
|
||||
t.Fatalf("Download: %v", err)
|
||||
}
|
||||
if name != "f.srt" || len(content) == 0 {
|
||||
t.Fatalf("Download returned %q / %d bytes", name, len(content))
|
||||
}
|
||||
}
|
||||
|
||||
// The allowance running out is the one failure a viewer can act on, so it must not be
|
||||
// flattened into "the provider did not answer".
|
||||
func TestDownloadReportsAnExhaustedQuota(t *testing.T) {
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotAcceptable)
|
||||
_, _ = w.Write([]byte(`{"message":"quota exceeded"}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
client := New("key", "Memby/test", "", "", 5*time.Second)
|
||||
client.SetBaseURL(api.URL)
|
||||
_, _, err := client.Download(context.Background(), 7)
|
||||
if _, ok := err.(*QuotaError); !ok {
|
||||
t.Fatalf("Download error = %v, want a QuotaError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginTokenIsFetchedOnce(t *testing.T) {
|
||||
logins := 0
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/login" {
|
||||
logins++
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"token": "jwt"})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}})
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
client := New("key", "Memby/test", "someone", "secret", 5*time.Second)
|
||||
client.SetBaseURL(api.URL)
|
||||
for range 3 {
|
||||
if _, err := client.Search(context.Background(), Query{IMDBID: "tt1"}); err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
}
|
||||
if logins != 1 {
|
||||
t.Fatalf("logged in %d times, want once", logins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFromNameFallsBackToSubRip(t *testing.T) {
|
||||
for name, want := range map[string]string{
|
||||
"a.srt": "srt", "b.VTT": "vtt", "c.ass": "ass", "d": "srt", "e.zip": "srt",
|
||||
} {
|
||||
if got := formatFromName(name); got != want {
|
||||
t.Errorf("formatFromName(%q) = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -403,3 +403,53 @@ CREATE TABLE IF NOT EXISTS user_preference_acks (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_preference_acks_user_revision_idx
|
||||
ON user_preference_acks (emby_user_id, revision DESC);
|
||||
|
||||
-- Which colour schemes an operator has decided a particular viewer may choose from.
|
||||
--
|
||||
-- Deliberately not part of user_preferences: that document is the viewer's own choices and
|
||||
-- is written by every television they own, where this is policy about them and is written
|
||||
-- only by the console. Keeping them apart is what stops a TV pushing itself a theme it was
|
||||
-- not offered simply by including the id in a settings write.
|
||||
--
|
||||
-- **A person with no rows here may choose anything.** Absence is permissive, because no row
|
||||
-- exists for anybody until an operator restricts somebody — reading it the other way round
|
||||
-- would empty every picker in the house the day this ships. It also means "allowed
|
||||
-- everything" and "never configured" are stored identically, which is correct: they are the
|
||||
-- same decision.
|
||||
--
|
||||
-- Seasonal themes are never in here. They are not grantable per person; the only switch is
|
||||
-- the seasonal_themes feature flag, and it is the operator's, for the whole household.
|
||||
CREATE TABLE IF NOT EXISTS user_themes (
|
||||
emby_user_id TEXT NOT NULL,
|
||||
theme_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (emby_user_id, theme_id)
|
||||
);
|
||||
|
||||
-- Subtitles the gateway fetched itself, which is the one place Memby holds a subtitle.
|
||||
--
|
||||
-- Bazarr does not need this: it writes the file beside the media file, so Emby finds it
|
||||
-- and the track arrives down the ordinary PlaybackInfo path. OpenSubtitles has no such
|
||||
-- reach — the gateway has no access to the media directory — so a file fetched from it is
|
||||
-- kept here and served back as a sidecar. That is the whole difference between the two
|
||||
-- providers, and it is why this table exists at all.
|
||||
--
|
||||
-- It is deliberately durable rather than a cache. A subtitle somebody fetched mid-film is
|
||||
-- one they will want again on the next episode of the same evening and on a rewatch a year
|
||||
-- later; spending a provider's daily download quota twice for the same file would be the
|
||||
-- feature working against the household. Rows are small — a subtitle is tens of kilobytes
|
||||
-- — and are deleted with nothing else, because nothing else knows the file exists.
|
||||
CREATE TABLE IF NOT EXISTS downloaded_subtitles (
|
||||
id TEXT PRIMARY KEY,
|
||||
item_id TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
forced BOOLEAN NOT NULL DEFAULT false,
|
||||
hearing_impaired BOOLEAN NOT NULL DEFAULT false,
|
||||
format TEXT NOT NULL DEFAULT 'srt',
|
||||
provider TEXT NOT NULL DEFAULT '',
|
||||
content BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS downloaded_subtitles_item_idx ON downloaded_subtitles (item_id);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Search history is the record of what a household looks for, and it has two readers with
|
||||
// quite different appetites: a television asking for one viewer's last few queries, and
|
||||
// the console asking what the house as a whole has been searching. Both read the one
|
||||
// table, which is why the writer's rules live here beside them.
|
||||
|
||||
// SearchDedupeWindow is how long an identical query counts as the same search.
|
||||
//
|
||||
// Two things write this table for one search — the gateway's own /v1/search handler and
|
||||
// the client's POST to /v1/search/history — and a television that repeats a query while
|
||||
// somebody re-reads the results is not a second search either. The window is short enough
|
||||
// that a query typed again a minute later is its own row, which is what makes the table a
|
||||
// record of what a household looks for rather than of how its remote behaves.
|
||||
const SearchDedupeWindow = 30 * time.Second
|
||||
|
||||
// SearchRetention is how far back the table goes. RecordSearch prunes to it on every
|
||||
// write, so it is also the honest ceiling on any window the console offers: a page
|
||||
// promising 90 days would draw a flat line for two thirds of it.
|
||||
const SearchRetention = 30 * 24 * time.Hour
|
||||
|
||||
// RecordSearch stores a normalized query for future per-user ranking analysis.
|
||||
//
|
||||
// Case-insensitive within the dedupe window, matching RecentSearches, which collapses
|
||||
// case-only duplicates when it reads them back.
|
||||
func (s *Store) RecordSearch(ctx context.Context, userID, query string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`WITH inserted AS (
|
||||
INSERT INTO search_history (emby_user_id, query)
|
||||
SELECT $1, $2
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM search_history
|
||||
WHERE emby_user_id = $1
|
||||
AND lower(query) = lower($2)
|
||||
AND occurred_at > now() - $3::interval
|
||||
)
|
||||
RETURNING id
|
||||
)
|
||||
DELETE FROM search_history
|
||||
WHERE emby_user_id = $1
|
||||
AND occurred_at < now() - $4::interval`,
|
||||
userID, query, SearchDedupeWindow.String(), SearchRetention.String())
|
||||
return err
|
||||
}
|
||||
|
||||
// RecentSearches returns a user's distinct queries in most-recently-used order.
|
||||
// Case-only duplicates collapse to the spelling used most recently.
|
||||
func (s *Store) RecentSearches(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
since time.Time,
|
||||
limit int,
|
||||
) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT query
|
||||
FROM (
|
||||
SELECT DISTINCT ON (lower(query)) query, occurred_at
|
||||
FROM search_history
|
||||
WHERE emby_user_id = $1 AND occurred_at >= $2
|
||||
ORDER BY lower(query), occurred_at DESC
|
||||
) AS latest
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT $3`,
|
||||
userID, since, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: recent searches: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
queries := make([]string, 0, limit)
|
||||
for rows.Next() {
|
||||
var query string
|
||||
if err := rows.Scan(&query); err != nil {
|
||||
return nil, fmt.Errorf("store: scan recent search: %w", err)
|
||||
}
|
||||
queries = append(queries, query)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: read recent searches: %w", err)
|
||||
}
|
||||
return queries, nil
|
||||
}
|
||||
|
||||
// SearchTerm is one query the household searched for, aggregated across everyone.
|
||||
type SearchTerm struct {
|
||||
Query string `json:"query"`
|
||||
Searches int `json:"searches"`
|
||||
Viewers int `json:"viewers"`
|
||||
LastAt time.Time `json:"lastAt"`
|
||||
}
|
||||
|
||||
// SearchEvent is one search as it happened: the log rather than the summary.
|
||||
type SearchEvent struct {
|
||||
Query string `json:"query"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
}
|
||||
|
||||
// SearchTotals describes a window as a whole. Counted separately from SearchTerms because
|
||||
// that list is capped — summing a top-twenty would report the top twenty's total as the
|
||||
// household's, which is wrong by however long the tail is.
|
||||
type SearchTotals struct {
|
||||
Searches int `json:"searches"`
|
||||
Queries int `json:"queries"`
|
||||
Viewers int `json:"viewers"`
|
||||
}
|
||||
|
||||
// SearchTerms aggregates the household's queries since a point in time, most-searched
|
||||
// first. Grouped case-insensitively and labelled with the spelling used most recently,
|
||||
// the same rule RecentSearches applies, so one query cannot appear as two rows because
|
||||
// somebody's on-screen keyboard capitalised it.
|
||||
func (s *Store) SearchTerms(ctx context.Context, since time.Time, limit int) ([]SearchTerm, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT (array_agg(query ORDER BY occurred_at DESC))[1] AS query,
|
||||
count(*) AS searches,
|
||||
count(DISTINCT emby_user_id) AS viewers,
|
||||
max(occurred_at) AS last_at
|
||||
FROM search_history
|
||||
WHERE occurred_at >= $1
|
||||
GROUP BY lower(query)
|
||||
ORDER BY searches DESC, last_at DESC
|
||||
LIMIT $2`, since, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: search terms: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
terms := []SearchTerm{}
|
||||
for rows.Next() {
|
||||
var term SearchTerm
|
||||
if err := rows.Scan(&term.Query, &term.Searches, &term.Viewers, &term.LastAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan search term: %w", err)
|
||||
}
|
||||
terms = append(terms, term)
|
||||
}
|
||||
return terms, rows.Err()
|
||||
}
|
||||
|
||||
// SearchEvents returns the raw log, newest first.
|
||||
//
|
||||
// Deliberately not collapsed: the summary above answers "what does this house look for",
|
||||
// and this answers "what happened just now" — which is the one an operator needs when
|
||||
// somebody says search is not finding something, because it shows the query exactly as it
|
||||
// was typed, by whom, and at what time. Usernames are resolved by the caller from
|
||||
// KnownUsers: they live in sessions and joining a log to them per row would make the
|
||||
// query's cost depend on how many televisions the household has ever signed in.
|
||||
func (s *Store) SearchEvents(ctx context.Context, since time.Time, limit int) ([]SearchEvent, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT query, emby_user_id, occurred_at
|
||||
FROM search_history
|
||||
WHERE occurred_at >= $1
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT $2`, since, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: search events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
events := []SearchEvent{}
|
||||
for rows.Next() {
|
||||
var event SearchEvent
|
||||
if err := rows.Scan(&event.Query, &event.UserID, &event.OccurredAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan search event: %w", err)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
// SearchTotals counts a window: searches made, distinct queries behind them, and how many
|
||||
// of the household did the searching.
|
||||
func (s *Store) SearchTotals(ctx context.Context, since time.Time) (SearchTotals, error) {
|
||||
var totals SearchTotals
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*), count(DISTINCT lower(query)), count(DISTINCT emby_user_id)
|
||||
FROM search_history
|
||||
WHERE occurred_at >= $1`, since).
|
||||
Scan(&totals.Searches, &totals.Queries, &totals.Viewers)
|
||||
if err != nil {
|
||||
return SearchTotals{}, fmt.Errorf("store: search totals: %w", err)
|
||||
}
|
||||
return totals, nil
|
||||
}
|
||||
@@ -310,58 +310,6 @@ func (s *Store) Close() { s.pool.Close() }
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
|
||||
|
||||
// RecordSearch stores a normalized query for future per-user ranking analysis.
|
||||
func (s *Store) RecordSearch(ctx context.Context, userID, query string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`WITH inserted AS (
|
||||
INSERT INTO search_history (emby_user_id, query) VALUES ($1, $2)
|
||||
RETURNING id
|
||||
)
|
||||
DELETE FROM search_history
|
||||
WHERE emby_user_id = $1
|
||||
AND occurred_at < now() - interval '30 days'`,
|
||||
userID, query)
|
||||
return err
|
||||
}
|
||||
|
||||
// RecentSearches returns a user's distinct queries in most-recently-used order.
|
||||
// Case-only duplicates collapse to the spelling used most recently.
|
||||
func (s *Store) RecentSearches(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
since time.Time,
|
||||
limit int,
|
||||
) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT query
|
||||
FROM (
|
||||
SELECT DISTINCT ON (lower(query)) query, occurred_at
|
||||
FROM search_history
|
||||
WHERE emby_user_id = $1 AND occurred_at >= $2
|
||||
ORDER BY lower(query), occurred_at DESC
|
||||
) AS latest
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT $3`,
|
||||
userID, since, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: recent searches: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
queries := make([]string, 0, limit)
|
||||
for rows.Next() {
|
||||
var query string
|
||||
if err := rows.Scan(&query); err != nil {
|
||||
return nil, fmt.Errorf("store: scan recent search: %w", err)
|
||||
}
|
||||
queries = append(queries, query)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: read recent searches: %w", err)
|
||||
}
|
||||
return queries, nil
|
||||
}
|
||||
|
||||
// Migrate applies the schema. It is idempotent, so it runs on every boot.
|
||||
func (s *Store) Migrate(ctx context.Context) error {
|
||||
if _, err := s.pool.Exec(ctx, schema); err != nil {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// SubtitlePolicyKey is the app_settings row deciding which subtitle providers a household
|
||||
// may fetch from, and holding the credentials for the one that needs them.
|
||||
//
|
||||
// It is an operator setting rather than an environment variable because the two providers
|
||||
// answer different questions and a household changes its mind about them: Bazarr is a
|
||||
// service somebody already runs, OpenSubtitles is an account with a daily allowance. Both
|
||||
// are optional and either can be turned off from the console without a redeployment.
|
||||
const SubtitlePolicyKey = "subtitle_policy"
|
||||
|
||||
// Subtitle provider identifiers. They travel on the wire — a candidate carries the source
|
||||
// it came from so the download call knows which backend to hand its token back to — so
|
||||
// they are values, not display strings, and must not be renamed.
|
||||
const (
|
||||
SubtitleProviderBazarr = "bazarr"
|
||||
SubtitleProviderOpenSubtitles = "opensubtitles"
|
||||
// SubtitleProviderMemby is a file the gateway made rather than fetched: a copy of an
|
||||
// existing track with its timing corrected. It is its own provider so the console can
|
||||
// tell a repair from a download, and so clearing the fetched files does not have to
|
||||
// decide what to do with work nobody can re-fetch.
|
||||
SubtitleProviderMemby = "memby"
|
||||
)
|
||||
|
||||
// SubtitlePolicy is what the console edits.
|
||||
//
|
||||
// The credentials live in this server-owned document and are never included in a client or
|
||||
// admin status payload — the console is told only whether a key is saved, the same stance
|
||||
// MDBList's takes.
|
||||
type SubtitlePolicy struct {
|
||||
// BazarrEnabled is honoured only where Bazarr is configured at all. A deployment with
|
||||
// no MEMBY_BAZARR_URL has nothing to turn on.
|
||||
BazarrEnabled bool `json:"bazarrEnabled"`
|
||||
|
||||
OpenSubtitlesEnabled bool `json:"openSubtitlesEnabled"`
|
||||
// OpenSubtitlesAPIKey is issued per consumer at opensubtitles.com. Searching needs
|
||||
// only this.
|
||||
OpenSubtitlesAPIKey string `json:"openSubtitlesApiKey"`
|
||||
// The account is optional and buys a download allowance. Searching works without one;
|
||||
// downloading against an anonymous key is quickly exhausted, which is a confusing
|
||||
// failure to meet in front of a television, so the console says so.
|
||||
OpenSubtitlesUsername string `json:"openSubtitlesUsername"`
|
||||
OpenSubtitlesPassword string `json:"openSubtitlesPassword"`
|
||||
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// DefaultSubtitlePolicy is what an untouched deployment gets: Bazarr on, because until
|
||||
// this document existed a configured Bazarr was already in use and an upgrade must not
|
||||
// quietly take a working feature away, and OpenSubtitles off, because it needs a key
|
||||
// nobody has entered yet.
|
||||
func DefaultSubtitlePolicy() SubtitlePolicy {
|
||||
return SubtitlePolicy{BazarrEnabled: true}
|
||||
}
|
||||
|
||||
func normalizeSubtitlePolicy(policy SubtitlePolicy) SubtitlePolicy {
|
||||
policy.OpenSubtitlesAPIKey = strings.TrimSpace(policy.OpenSubtitlesAPIKey)
|
||||
policy.OpenSubtitlesUsername = strings.TrimSpace(policy.OpenSubtitlesUsername)
|
||||
// A provider with no key cannot be on, whatever the document says. Storing the
|
||||
// contradiction would leave the console showing a switch that does nothing.
|
||||
if policy.OpenSubtitlesAPIKey == "" {
|
||||
policy.OpenSubtitlesEnabled = false
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func (s *Store) SubtitlePolicy(ctx context.Context) (SubtitlePolicy, error) {
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT value FROM app_settings WHERE key = $1`, SubtitlePolicyKey).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DefaultSubtitlePolicy(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return DefaultSubtitlePolicy(), fmt.Errorf("store: read subtitle policy: %w", err)
|
||||
}
|
||||
var policy SubtitlePolicy
|
||||
if err := json.Unmarshal(raw, &policy); err != nil {
|
||||
return DefaultSubtitlePolicy(), fmt.Errorf("store: decode subtitle policy: %w", err)
|
||||
}
|
||||
return normalizeSubtitlePolicy(policy), nil
|
||||
}
|
||||
|
||||
func (s *Store) SetSubtitlePolicy(ctx context.Context, policy SubtitlePolicy) error {
|
||||
policy = normalizeSubtitlePolicy(policy)
|
||||
policy.UpdatedAt = time.Now().UTC()
|
||||
raw, err := json.Marshal(policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES ($1, $2::jsonb, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
||||
SubtitlePolicyKey, string(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: write subtitle policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrSubtitleNotFound means the gateway holds no subtitle under that id.
|
||||
var ErrSubtitleNotFound = errors.New("store: subtitle not found")
|
||||
|
||||
// DownloadedSubtitle is one subtitle file the gateway fetched and now serves.
|
||||
//
|
||||
// Content is the file exactly as the provider sent it. Nothing here parses or re-encodes
|
||||
// it: media3 reads SubRip and WebVTT directly, and a gateway that rewrote a subtitle would
|
||||
// be a second thing that can be wrong about somebody's film.
|
||||
type DownloadedSubtitle struct {
|
||||
ID string
|
||||
ItemID string
|
||||
Language string
|
||||
Label string
|
||||
Forced bool
|
||||
HearingImpaired bool
|
||||
Format string
|
||||
Provider string
|
||||
Content []byte
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// PutDownloadedSubtitle stores a fetched subtitle, replacing any previous file under the
|
||||
// same id. The id is the gateway's own and is derived from what was asked for, so fetching
|
||||
// the same language for the same title twice replaces the file rather than growing a
|
||||
// second track the viewer has to tell apart.
|
||||
func (s *Store) PutDownloadedSubtitle(ctx context.Context, subtitle DownloadedSubtitle) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO downloaded_subtitles
|
||||
(id, item_id, language, label, forced, hearing_impaired, format, provider, content, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
item_id = EXCLUDED.item_id, language = EXCLUDED.language, label = EXCLUDED.label,
|
||||
forced = EXCLUDED.forced, hearing_impaired = EXCLUDED.hearing_impaired,
|
||||
format = EXCLUDED.format, provider = EXCLUDED.provider,
|
||||
content = EXCLUDED.content, created_at = now()`,
|
||||
subtitle.ID, subtitle.ItemID, subtitle.Language, subtitle.Label,
|
||||
subtitle.Forced, subtitle.HearingImpaired, subtitle.Format, subtitle.Provider,
|
||||
subtitle.Content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: write downloaded subtitle: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadedSubtitle returns one stored file, content and all. It is the read the serving
|
||||
// route makes, so it is a single primary-key lookup.
|
||||
func (s *Store) DownloadedSubtitle(ctx context.Context, id string) (DownloadedSubtitle, error) {
|
||||
var subtitle DownloadedSubtitle
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, item_id, language, label, forced, hearing_impaired, format, provider,
|
||||
content, created_at
|
||||
FROM downloaded_subtitles WHERE id = $1`, id).Scan(
|
||||
&subtitle.ID, &subtitle.ItemID, &subtitle.Language, &subtitle.Label,
|
||||
&subtitle.Forced, &subtitle.HearingImpaired, &subtitle.Format, &subtitle.Provider,
|
||||
&subtitle.Content, &subtitle.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DownloadedSubtitle{}, ErrSubtitleNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return DownloadedSubtitle{}, fmt.Errorf("store: read downloaded subtitle: %w", err)
|
||||
}
|
||||
return subtitle, nil
|
||||
}
|
||||
|
||||
// DownloadedSubtitlesFor lists what the gateway holds for one item, without the file
|
||||
// bodies. It runs on the playback path — every launch of a title asks — so it must never
|
||||
// read the content column.
|
||||
func (s *Store) DownloadedSubtitlesFor(ctx context.Context, itemID string) ([]DownloadedSubtitle, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, item_id, language, label, forced, hearing_impaired, format, provider, created_at
|
||||
FROM downloaded_subtitles WHERE item_id = $1 ORDER BY created_at`, itemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list downloaded subtitles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []DownloadedSubtitle
|
||||
for rows.Next() {
|
||||
var subtitle DownloadedSubtitle
|
||||
if err := rows.Scan(
|
||||
&subtitle.ID, &subtitle.ItemID, &subtitle.Language, &subtitle.Label,
|
||||
&subtitle.Forced, &subtitle.HearingImpaired, &subtitle.Format,
|
||||
&subtitle.Provider, &subtitle.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan downloaded subtitle: %w", err)
|
||||
}
|
||||
out = append(out, subtitle)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DownloadedSubtitleStats is what the console reports: how much has been fetched, and
|
||||
// when the last one was. Both are one aggregate query, because the page polls.
|
||||
type DownloadedSubtitleStats struct {
|
||||
Count int `json:"count"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Latest time.Time `json:"latest,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Store) DownloadedSubtitleStats(ctx context.Context) (DownloadedSubtitleStats, error) {
|
||||
var stats DownloadedSubtitleStats
|
||||
var latest *time.Time
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*), coalesce(sum(length(content)), 0), max(created_at)
|
||||
FROM downloaded_subtitles`).Scan(&stats.Count, &stats.Bytes, &latest)
|
||||
if err != nil {
|
||||
return DownloadedSubtitleStats{}, fmt.Errorf("store: downloaded subtitle stats: %w", err)
|
||||
}
|
||||
if latest != nil {
|
||||
stats.Latest = *latest
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ClearDownloadedSubtitles empties the store. It is the console's one destructive control
|
||||
// here, and it is safe in the way a cache purge is: every file can be fetched again, at
|
||||
// the cost of the provider allowance that fetched it.
|
||||
func (s *Store) ClearDownloadedSubtitles(ctx context.Context) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM downloaded_subtitles`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: clear downloaded subtitles: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Which colour schemes an operator has decided a viewer may choose from.
|
||||
//
|
||||
// The store deliberately knows nothing about what a theme *is*: the catalogue, the palettes
|
||||
// and the rule about seasons all live in internal/api next to the client contract, the same
|
||||
// division user_preferences draws. What is here is only the set of ids, and the one property
|
||||
// that has to be true at this level — that an empty result means "unrestricted", never "no
|
||||
// themes at all". See the table comment in schema.sql.
|
||||
|
||||
// UserThemes is the ids this viewer may pick between, or an empty slice for anybody the
|
||||
// operator has never restricted — which is everybody, until they do.
|
||||
func (s *Store) UserThemes(ctx context.Context, userID string) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT theme_id FROM user_themes WHERE emby_user_id = $1 ORDER BY theme_id`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: read user themes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
themes := []string{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("store: scan user theme: %w", err)
|
||||
}
|
||||
themes = append(themes, id)
|
||||
}
|
||||
return themes, rows.Err()
|
||||
}
|
||||
|
||||
// AllUserThemes is the admin console's read: one query for the whole accounts page rather
|
||||
// than one per person, since that page already fans out over every account it lists.
|
||||
func (s *Store) AllUserThemes(ctx context.Context) (map[string][]string, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT emby_user_id, theme_id FROM user_themes ORDER BY emby_user_id, theme_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list user themes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
all := map[string][]string{}
|
||||
for rows.Next() {
|
||||
var userID, themeID string
|
||||
if err := rows.Scan(&userID, &themeID); err != nil {
|
||||
return nil, fmt.Errorf("store: scan user themes: %w", err)
|
||||
}
|
||||
all[userID] = append(all[userID], themeID)
|
||||
}
|
||||
return all, rows.Err()
|
||||
}
|
||||
|
||||
// SetUserThemes replaces this viewer's allowlist wholesale.
|
||||
//
|
||||
// Replace rather than merge because the console sends the whole set of ticked boxes, and a
|
||||
// merge would make unticking one impossible. It is one transaction so a viewer is never
|
||||
// momentarily allowed nothing — a television resolving its theme in that window would be
|
||||
// told the default and would repaint itself for no reason.
|
||||
//
|
||||
// An empty list deletes the rows rather than storing anything, which is what keeps
|
||||
// "unrestricted" a single representation. api.normalizeThemeAllowlist is what turns "every
|
||||
// box ticked" into that empty list before it arrives here.
|
||||
func (s *Store) SetUserThemes(ctx context.Context, userID string, themes []string) error {
|
||||
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`DELETE FROM user_themes WHERE emby_user_id = $1`, userID); err != nil {
|
||||
return fmt.Errorf("store: clear user themes: %w", err)
|
||||
}
|
||||
for _, id := range themes {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO user_themes (emby_user_id, theme_id) VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING`, userID, id); err != nil {
|
||||
return fmt.Errorf("store: write user theme: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package subsync
|
||||
|
||||
import "math/bits"
|
||||
|
||||
// signal is "somebody is speaking" as one bit per time bin.
|
||||
//
|
||||
// A bitset rather than a []bool because the search is the whole cost of this feature: a
|
||||
// two-hour film at 50ms is around 144,000 bins, and every one of ~2,400 shifts has to
|
||||
// compare all of them against every stretch candidate. Packed into words, one comparison
|
||||
// is an AND and a population count over 2,250 words instead of 144,000 byte reads, which
|
||||
// is the difference between a button that answers while somebody is still looking at the
|
||||
// menu and one they wait on. math/bits.OnesCount64 compiles to a single instruction on
|
||||
// every architecture this runs on.
|
||||
type signal struct {
|
||||
words []uint64
|
||||
bins int
|
||||
on int
|
||||
}
|
||||
|
||||
func newSignal(bins int) *signal {
|
||||
if bins < 1 {
|
||||
bins = 1
|
||||
}
|
||||
return &signal{words: make([]uint64, (bins+63)/64), bins: bins}
|
||||
}
|
||||
|
||||
// set marks the half-open bin range [from, to) as speech.
|
||||
func (s *signal) set(from, to int) {
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
if to > s.bins {
|
||||
to = s.bins
|
||||
}
|
||||
for i := from; i < to; i++ {
|
||||
word, bit := i/64, uint(i%64)
|
||||
if s.words[word]&(1<<bit) == 0 {
|
||||
s.words[word] |= 1 << bit
|
||||
s.on++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// overlap counts the bins where both signals are speaking, with other displaced by shift
|
||||
// bins. A positive shift means other is read later — the value to add to this signal's
|
||||
// times to bring them onto other's.
|
||||
//
|
||||
// Negative shifts are answered by swapping the two, which is the same count from the other
|
||||
// side and saves writing the word arithmetic twice in mirror image.
|
||||
func (s *signal) overlap(other *signal, shift int) int {
|
||||
if shift < 0 {
|
||||
return other.overlap(s, -shift)
|
||||
}
|
||||
wordShift, bitShift := shift/64, uint(shift%64)
|
||||
total := 0
|
||||
for i := range s.words {
|
||||
mine := s.words[i]
|
||||
if mine == 0 {
|
||||
continue
|
||||
}
|
||||
j := i + wordShift
|
||||
if j >= len(other.words) {
|
||||
break
|
||||
}
|
||||
theirs := other.words[j] >> bitShift
|
||||
// Go defines a shift of 64 or more as zero, so this term vanishes when bitShift
|
||||
// is zero rather than needing a branch of its own.
|
||||
if j+1 < len(other.words) {
|
||||
theirs |= other.words[j+1] << (64 - bitShift)
|
||||
}
|
||||
total += bits.OnesCount64(mine & theirs)
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package subsync
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNoCues means nothing in the file looked like a subtitle.
|
||||
var ErrNoCues = errors.New("subsync: no subtitle cues found")
|
||||
|
||||
// Parse reads SRT or WebVTT.
|
||||
//
|
||||
// One parser for both because the only difference that matters here is a comma or a full
|
||||
// stop between the seconds and the milliseconds, and Emby will hand back either depending
|
||||
// on the route asked and the codec underneath. Everything a format carries that this
|
||||
// package does not need — cue identifiers, WebVTT positioning, styling blocks, the byte
|
||||
// order mark a Windows editor leaves behind — is skipped rather than rejected, because a
|
||||
// subtitle somebody is trying to fix is by definition one that is already not perfect.
|
||||
func Parse(data []byte) ([]Cue, error) {
|
||||
text := strings.ReplaceAll(string(data), "\r\n", "\n")
|
||||
text = strings.TrimPrefix(text, "\ufeff")
|
||||
|
||||
var cues []Cue
|
||||
lines := strings.Split(text, "\n")
|
||||
for i := 0; i < len(lines); i++ {
|
||||
start, end, ok := parseTimingLine(lines[i])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
body := []string{}
|
||||
for i++; i < len(lines) && strings.TrimSpace(lines[i]) != ""; i++ {
|
||||
// A timing line with no blank line before it ends the previous cue: some
|
||||
// files in the wild are written that way, and reading the next cue's timing
|
||||
// as this one's text would put the whole file one cue out.
|
||||
if _, _, isTiming := parseTimingLine(lines[i]); isTiming {
|
||||
i--
|
||||
break
|
||||
}
|
||||
body = append(body, lines[i])
|
||||
}
|
||||
cues = append(cues, Cue{Start: start, End: end, Text: strings.Join(body, "\n")})
|
||||
}
|
||||
if len(cues) == 0 {
|
||||
return nil, ErrNoCues
|
||||
}
|
||||
return Normalise(cues), nil
|
||||
}
|
||||
|
||||
func parseTimingLine(line string) (time.Duration, time.Duration, bool) {
|
||||
before, after, ok := strings.Cut(line, "-->")
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
start, ok := parseTimestamp(before)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
// WebVTT puts cue settings after the end timestamp ("align:start position:50%"), so
|
||||
// only the first field of what follows is a time.
|
||||
end, ok := parseTimestamp(strings.Fields(after)[0])
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
// parseTimestamp reads HH:MM:SS,mmm and every variation of it that turns up: a full stop
|
||||
// instead of the comma, the hours omitted as WebVTT allows, and fewer than three digits
|
||||
// after the separator.
|
||||
func parseTimestamp(field string) (time.Duration, bool) {
|
||||
text := strings.TrimSpace(field)
|
||||
if text == "" {
|
||||
return 0, false
|
||||
}
|
||||
seconds, fraction, _ := strings.Cut(strings.ReplaceAll(text, ",", "."), ".")
|
||||
|
||||
parts := strings.Split(seconds, ":")
|
||||
if len(parts) < 2 || len(parts) > 3 {
|
||||
return 0, false
|
||||
}
|
||||
var total time.Duration
|
||||
units := []time.Duration{time.Hour, time.Minute, time.Second}
|
||||
units = units[len(units)-len(parts):]
|
||||
for i, part := range parts {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(part))
|
||||
if err != nil || value < 0 {
|
||||
return 0, false
|
||||
}
|
||||
total += time.Duration(value) * units[i]
|
||||
}
|
||||
|
||||
if fraction != "" {
|
||||
digits := fraction
|
||||
if len(digits) > 3 {
|
||||
digits = digits[:3]
|
||||
}
|
||||
value, err := strconv.Atoi(digits)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
for len(digits) < 3 {
|
||||
value *= 10
|
||||
digits += "0"
|
||||
}
|
||||
total += time.Duration(value) * time.Millisecond
|
||||
}
|
||||
return total, true
|
||||
}
|
||||
|
||||
// FormatSRT writes cues back out as SubRip.
|
||||
//
|
||||
// SRT rather than the format that came in, because it is the one every player reads and
|
||||
// the one the stored-subtitle table already declares. Renumbered from one: the indices in
|
||||
// a file being repaired are frequently wrong already, and they carry no meaning worth
|
||||
// preserving.
|
||||
func FormatSRT(cues []Cue) []byte {
|
||||
var out strings.Builder
|
||||
for i, cue := range cues {
|
||||
fmt.Fprintf(&out, "%d\n%s --> %s\n%s\n\n",
|
||||
i+1, formatTimestamp(cue.Start), formatTimestamp(cue.End), cue.Text)
|
||||
}
|
||||
return []byte(out.String())
|
||||
}
|
||||
|
||||
func formatTimestamp(d time.Duration) string {
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
milliseconds := d.Milliseconds()
|
||||
return fmt.Sprintf("%02d:%02d:%02d,%03d",
|
||||
milliseconds/3_600_000,
|
||||
milliseconds/60_000%60,
|
||||
milliseconds/1000%60,
|
||||
milliseconds%1000)
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package subsync
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A film's worth of dialogue: irregularly spaced, varied line lengths, long gaps where
|
||||
// nothing is said. Regular cues would make every shift correlate with every other and
|
||||
// prove nothing about the search.
|
||||
func dialogue(count int, seed int64) []Cue {
|
||||
rng := rand.New(rand.NewSource(seed))
|
||||
cues := make([]Cue, 0, count)
|
||||
at := 12 * time.Second
|
||||
for range count {
|
||||
length := time.Duration(900+rng.Intn(2600)) * time.Millisecond
|
||||
cues = append(cues, Cue{Start: at, End: at + length, Text: "line"})
|
||||
gap := time.Duration(400+rng.Intn(4000)) * time.Millisecond
|
||||
if rng.Intn(11) == 0 {
|
||||
gap += time.Duration(6+rng.Intn(25)) * time.Second // a scene with no dialogue
|
||||
}
|
||||
at += length + gap
|
||||
}
|
||||
return cues
|
||||
}
|
||||
|
||||
func TestAlignRecoversAKnownOffset(t *testing.T) {
|
||||
reference := dialogue(400, 7)
|
||||
for _, offset := range []time.Duration{
|
||||
-42 * time.Second, -3500 * time.Millisecond, -700 * time.Millisecond,
|
||||
2 * time.Second, 11500 * time.Millisecond, 37 * time.Second,
|
||||
} {
|
||||
t.Run(offset.String(), func(t *testing.T) {
|
||||
// The broken track is the reference pushed the wrong way, so the correction
|
||||
// that fixes it is the opposite of what was applied.
|
||||
broken := Shift(reference, -offset, 1)
|
||||
|
||||
got, err := Align(broken, reference, DefaultOptions())
|
||||
if err != nil {
|
||||
t.Fatalf("Align: %v", err)
|
||||
}
|
||||
if got.Scale != 1 {
|
||||
t.Fatalf("scale = %v, want 1 for a pure displacement", got.Scale)
|
||||
}
|
||||
if diff := (got.Offset - offset).Abs(); diff > 100*time.Millisecond {
|
||||
t.Fatalf("offset = %v, want %v (out by %v)", got.Offset, offset, diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The case a plain offset cannot fix: a track authored against one transfer and played
|
||||
// against another runs further out as the film goes on.
|
||||
func TestAlignRecoversAFrameRateStretch(t *testing.T) {
|
||||
reference := dialogue(500, 11)
|
||||
scale := 25.0 / 24
|
||||
broken := Shift(reference, 0, 1/scale)
|
||||
|
||||
got, err := Align(broken, reference, DefaultOptions())
|
||||
if err != nil {
|
||||
t.Fatalf("Align: %v", err)
|
||||
}
|
||||
if got.Scale != scale {
|
||||
t.Fatalf("scale = %v, want %v", got.Scale, scale)
|
||||
}
|
||||
|
||||
// The real test is not the reported numbers but whether applying them lands the last
|
||||
// cue where it belongs — a stretch that is right at the start and wrong at the end is
|
||||
// exactly the fault being repaired.
|
||||
fixed := Shift(broken, got.Offset, got.Scale)
|
||||
drift := (fixed[len(fixed)-1].Start - reference[len(reference)-1].Start).Abs()
|
||||
if drift > 200*time.Millisecond {
|
||||
t.Fatalf("last cue is out by %v after correction", drift)
|
||||
}
|
||||
}
|
||||
|
||||
// Timing that survives a translation. Different languages break lines differently and
|
||||
// their cues do not start on the same frame, so the alignment has to hold when the two
|
||||
// tracks agree only roughly.
|
||||
func TestAlignSurvivesATranslatedReference(t *testing.T) {
|
||||
reference := dialogue(400, 3)
|
||||
rng := rand.New(rand.NewSource(99))
|
||||
translated := make([]Cue, 0, len(reference))
|
||||
for i, cue := range reference {
|
||||
if i%9 == 0 {
|
||||
continue // a line the other track merged into its neighbour
|
||||
}
|
||||
jitter := time.Duration(rng.Intn(500)-250) * time.Millisecond
|
||||
translated = append(translated, Cue{
|
||||
Start: cue.Start + jitter,
|
||||
End: cue.End + jitter + time.Duration(rng.Intn(600))*time.Millisecond,
|
||||
Text: "ligne",
|
||||
})
|
||||
}
|
||||
|
||||
offset := -8 * time.Second
|
||||
got, err := Align(Shift(translated, -offset, 1), reference, DefaultOptions())
|
||||
if err != nil {
|
||||
t.Fatalf("Align: %v", err)
|
||||
}
|
||||
if diff := (got.Offset - offset).Abs(); diff > 300*time.Millisecond {
|
||||
t.Fatalf("offset = %v, want %v", got.Offset, offset)
|
||||
}
|
||||
}
|
||||
|
||||
// Most of this package's job is producing no answer. A wrong correction is worse than
|
||||
// none: the viewer is told it worked and has no way to know the file is now further out.
|
||||
func TestAlignRefusesRatherThanGuess(t *testing.T) {
|
||||
reference := dialogue(400, 5)
|
||||
|
||||
t.Run("a different film", func(t *testing.T) {
|
||||
_, err := Align(dialogue(400, 6), reference, DefaultOptions())
|
||||
var refusal *ErrNoAlignment
|
||||
if !errors.As(err, &refusal) {
|
||||
t.Fatalf("err = %v, want a refusal", err)
|
||||
}
|
||||
if refusal.Reason == "" {
|
||||
t.Fatal("a refusal must carry something a viewer can read")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("too few lines to judge", func(t *testing.T) {
|
||||
_, err := Align(dialogue(6, 1), reference, DefaultOptions())
|
||||
var refusal *ErrNoAlignment
|
||||
if !errors.As(err, &refusal) {
|
||||
t.Fatalf("err = %v, want a refusal", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Evenly spaced cues fit their own reference at every multiple of the spacing, so a
|
||||
// dozen shifts score alike and none of them is the answer. This is the case the margin
|
||||
// test exists for — the score alone is a perfect 1.0 and would sail through.
|
||||
t.Run("timing that fits equally well in several places", func(t *testing.T) {
|
||||
metronome := func(offset time.Duration) []Cue {
|
||||
cues := make([]Cue, 0, 60)
|
||||
for i := range 60 {
|
||||
at := offset + time.Duration(i)*4*time.Second
|
||||
cues = append(cues, Cue{Start: at, End: at + 2*time.Second, Text: "line"})
|
||||
}
|
||||
return cues
|
||||
}
|
||||
|
||||
_, err := Align(metronome(9*time.Second), metronome(0), DefaultOptions())
|
||||
var refusal *ErrNoAlignment
|
||||
if !errors.As(err, &refusal) {
|
||||
t.Fatalf("err = %v, want a refusal", err)
|
||||
}
|
||||
if refusal.Score < 0.9 {
|
||||
t.Fatalf("score = %.2f — this case must be refused on margin, not on score",
|
||||
refusal.Score)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A sparse track is not by itself a bad one. Taking every seventeenth line of a correct
|
||||
// subtitle leaves something that still lines up in exactly one place, and refusing it
|
||||
// would cost the fix for a forced or hearing-impaired track that is merely displaced.
|
||||
// What disqualifies a sparse track is being the *reference*, which Reference handles.
|
||||
func TestAlignAcceptsASparseButUnambiguousTrack(t *testing.T) {
|
||||
reference := dialogue(400, 5)
|
||||
sparse := []Cue{}
|
||||
for i, cue := range reference {
|
||||
if i%17 == 0 {
|
||||
sparse = append(sparse, cue)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := Align(Shift(sparse, 6*time.Second, 1), reference, DefaultOptions())
|
||||
if err != nil {
|
||||
t.Fatalf("Align: %v", err)
|
||||
}
|
||||
if diff := (got.Offset + 6*time.Second).Abs(); diff > 100*time.Millisecond {
|
||||
t.Fatalf("offset = %v, want -6s", got.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
// A track already in sync must come back as no correction rather than a small nudge that
|
||||
// makes the file different for no reason.
|
||||
func TestAlignLeavesACorrectTrackAlone(t *testing.T) {
|
||||
reference := dialogue(300, 21)
|
||||
got, err := Align(reference, reference, DefaultOptions())
|
||||
if err != nil {
|
||||
t.Fatalf("Align: %v", err)
|
||||
}
|
||||
if got.Correction() {
|
||||
t.Fatalf("a matching track reported a correction of %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShiftClampsRatherThanDroppingTheOpening(t *testing.T) {
|
||||
cues := []Cue{
|
||||
{Start: time.Second, End: 3 * time.Second, Text: "first"},
|
||||
{Start: 10 * time.Second, End: 12 * time.Second, Text: "second"},
|
||||
}
|
||||
got := Shift(cues, -30*time.Second, 1)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("cue count = %d, want 2 — no line may be lost", len(got))
|
||||
}
|
||||
if got[0].Start < 0 || got[0].End < 0 {
|
||||
t.Fatalf("negative timing survived: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Which track to measure against is the one choice this package cannot check, so the rules
|
||||
// that make a track unusable as a yardstick are pinned.
|
||||
func TestReferencePrefersTheFullestUnforcedTrack(t *testing.T) {
|
||||
candidates := [][]Cue{
|
||||
dialogue(30, 1), // 0: the broken one
|
||||
dialogue(900, 2), // 1: forced, so unusable however long
|
||||
dialogue(400, 3), // 2: the answer
|
||||
dialogue(5, 4), // 3: too short to judge anything by
|
||||
}
|
||||
forced := []bool{false, true, false, false}
|
||||
|
||||
got, ok := Reference(candidates, 0, forced, 20)
|
||||
if !ok || got != 2 {
|
||||
t.Fatalf("reference = %d, ok = %v, want index 2", got, ok)
|
||||
}
|
||||
|
||||
if _, ok := Reference(candidates[:1], 0, nil, 20); ok {
|
||||
t.Fatal("a title whose only track is the broken one has no reference")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReadsBothFormats(t *testing.T) {
|
||||
srt := "1\n00:00:12,500 --> 00:00:14,900\nHello there\n\n" +
|
||||
"2\n00:01:02,000 --> 00:01:04,250\nSecond line\nover two rows\n"
|
||||
vtt := "WEBVTT\n\n00:00:12.500 --> 00:00:14.900 align:start position:50%\nHello there\n\n" +
|
||||
"01:02.000 --> 01:04.250\nSecond line\nover two rows\n"
|
||||
|
||||
for name, data := range map[string]string{"srt": srt, "vtt": vtt} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cues, err := Parse([]byte(data))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(cues) != 2 {
|
||||
t.Fatalf("cue count = %d, want 2", len(cues))
|
||||
}
|
||||
if cues[0].Start != 12500*time.Millisecond || cues[0].End != 14900*time.Millisecond {
|
||||
t.Fatalf("first cue = %+v", cues[0])
|
||||
}
|
||||
if cues[1].Start != 62*time.Second {
|
||||
t.Fatalf("second cue start = %v, want 1m2s", cues[1].Start)
|
||||
}
|
||||
if cues[1].Text != "Second line\nover two rows" {
|
||||
t.Fatalf("second cue text = %q", cues[1].Text)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHandlesFilesPeopleActuallyHave(t *testing.T) {
|
||||
t.Run("byte order mark and CRLF", func(t *testing.T) {
|
||||
data := "\ufeff1\r\n00:00:01,000 --> 00:00:02,000\r\nHi\r\n"
|
||||
cues, err := Parse([]byte(data))
|
||||
if err != nil || len(cues) != 1 || cues[0].Text != "Hi" {
|
||||
t.Fatalf("cues = %+v, err = %v", cues, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no blank line between cues", func(t *testing.T) {
|
||||
data := "1\n00:00:01,000 --> 00:00:02,000\nOne\n2\n00:00:03,000 --> 00:00:04,000\nTwo\n"
|
||||
cues, err := Parse([]byte(data))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(cues) != 2 || cues[1].Start != 3*time.Second {
|
||||
t.Fatalf("cues = %+v", cues)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nothing that looks like a subtitle", func(t *testing.T) {
|
||||
if _, err := Parse([]byte("this is not a subtitle at all")); !errors.Is(err, ErrNoCues) {
|
||||
t.Fatalf("err = %v, want ErrNoCues", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatSRTRoundTrips(t *testing.T) {
|
||||
cues := []Cue{
|
||||
{Start: 3661500 * time.Millisecond, End: 3663000 * time.Millisecond, Text: "Late in the film"},
|
||||
{Start: 12 * time.Second, End: 14 * time.Second, Text: "Two\nrows"},
|
||||
}
|
||||
out := FormatSRT(Normalise(cues))
|
||||
if !strings.Contains(string(out), "01:01:01,500 --> 01:01:03,000") {
|
||||
t.Fatalf("timestamps not written as SubRip:\n%s", out)
|
||||
}
|
||||
|
||||
back, err := Parse(out)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(back) != 2 || back[0].Start != 12*time.Second || back[1].Text != "Late in the film" {
|
||||
t.Fatalf("round trip changed the cues: %+v", back)
|
||||
}
|
||||
}
|
||||
|
||||
// The search runs while somebody is looking at a menu over their film, so its cost is part
|
||||
// of whether the feature is usable at all.
|
||||
func BenchmarkAlignFeatureLength(b *testing.B) {
|
||||
reference := dialogue(1400, 1)
|
||||
broken := Shift(reference, -9*time.Second, 1)
|
||||
opts := DefaultOptions()
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
if _, err := Align(broken, reference, opts); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// Package subsync fixes the timing of a subtitle by aligning it to one that is already
|
||||
// right.
|
||||
//
|
||||
// The idea is borrowed from ffsubsync and alass, which AutoSubSync drives — but not the
|
||||
// code, which is Python and Rust and needs FFmpeg to read a film's audio. This gateway's
|
||||
// image is distroless with no ffmpeg and no shell, so the audio path is closed to it. What
|
||||
// is open is the cheaper half of the same idea: both of those tools can align against a
|
||||
// *reference subtitle* instead of audio, and a household's copy of a film usually carries
|
||||
// a track that is already correct. Reduce both tracks to "somebody is speaking / nobody
|
||||
// is" on a fixed time grid and the offset is the shift where the two agree most.
|
||||
//
|
||||
// Nothing here touches the network, Emby or the database, which is what lets the whole
|
||||
// rule be tested against real subtitle text.
|
||||
package subsync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Cue is one subtitle line: when it appears, when it goes, and what it says.
|
||||
//
|
||||
// The text is carried through untouched. This package changes *when* a line is shown and
|
||||
// never what it says — a resynchronised subtitle that had also been reflowed or re-escaped
|
||||
// would be impossible to tell from a corrupted one.
|
||||
type Cue struct {
|
||||
Start time.Duration
|
||||
End time.Duration
|
||||
Text string
|
||||
}
|
||||
|
||||
// Duration is where the last cue ends, which stands in for the runtime the track was
|
||||
// written against.
|
||||
func Duration(cues []Cue) time.Duration {
|
||||
var last time.Duration
|
||||
for _, cue := range cues {
|
||||
if cue.End > last {
|
||||
last = cue.End
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// Shift moves every cue by the correction Align found.
|
||||
//
|
||||
// Scale is applied before the offset, in that order, because that is the order the damage
|
||||
// happened in: a subtitle written for a 25fps transfer and played against a 23.976 one
|
||||
// runs progressively further out, and whatever fixed offset remains sits on top of the
|
||||
// stretch. A cue dragged before zero is clamped rather than dropped — it belongs to a line
|
||||
// somebody is about to hear, and a track that silently lost its opening would look like a
|
||||
// worse fault than the one being fixed.
|
||||
func Shift(cues []Cue, offset time.Duration, scale float64) []Cue {
|
||||
if scale <= 0 {
|
||||
scale = 1
|
||||
}
|
||||
out := make([]Cue, 0, len(cues))
|
||||
for _, cue := range cues {
|
||||
start := scaleDuration(cue.Start, scale) + offset
|
||||
end := scaleDuration(cue.End, scale) + offset
|
||||
if end < 0 {
|
||||
// The whole cue was pushed off the front. Keep it at zero rather than
|
||||
// discarding it: losing a line is worse than showing one early.
|
||||
start, end = 0, 0
|
||||
} else if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
out = append(out, Cue{Start: start, End: end, Text: cue.Text})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Start < out[j].Start })
|
||||
return out
|
||||
}
|
||||
|
||||
func scaleDuration(d time.Duration, scale float64) time.Duration {
|
||||
return time.Duration(float64(d) * scale)
|
||||
}
|
||||
|
||||
// FrameRateScales are the stretch factors tried alongside a plain offset.
|
||||
//
|
||||
// They are the ratios between the frame rates films are actually delivered at, because
|
||||
// the classic broken subtitle is not merely late — it is a track authored against one
|
||||
// transfer and played against another, so it drifts, and no single offset can fix it. The
|
||||
// list is deliberately short: every extra candidate is another chance for a wrong answer
|
||||
// to win by luck, and these cover the transfers that exist.
|
||||
var FrameRateScales = []float64{
|
||||
1,
|
||||
23.976 / 24, 24 / 23.976,
|
||||
25.0 / 24, 24.0 / 25,
|
||||
25 / 23.976, 23.976 / 25,
|
||||
30 / 29.97, 29.97 / 30,
|
||||
}
|
||||
|
||||
// Options tunes the search. The zero value is not usable; use DefaultOptions.
|
||||
type Options struct {
|
||||
// Bin is the width of one cell of the time grid. Fine enough that a correction is
|
||||
// worth making at all, coarse enough that two tracks written by different people
|
||||
// still land in the same cells.
|
||||
Bin time.Duration
|
||||
// MaxShift bounds the search either way. A subtitle for a different release can be a
|
||||
// minute out; beyond that the two are far more likely to be different cuts of the
|
||||
// film, where a confident answer would be a wrong one.
|
||||
MaxShift time.Duration
|
||||
// MinCues is how much evidence is needed on each side before an answer is offered.
|
||||
MinCues int
|
||||
// MinScore is the share of the shorter track's speech that must line up.
|
||||
MinScore float64
|
||||
// MinMargin is how far the winning shift must beat the best rival outside its own
|
||||
// peak. This is what separates a real alignment from a track that correlates weakly
|
||||
// with everything, which is what a wrong reference looks like.
|
||||
MinMargin float64
|
||||
// Scales are the stretch candidates tried. Empty means offset only.
|
||||
Scales []float64
|
||||
}
|
||||
|
||||
// DefaultOptions is the tuning the gateway uses.
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
Bin: 50 * time.Millisecond,
|
||||
MaxShift: 60 * time.Second,
|
||||
MinCues: 20,
|
||||
MinScore: 0.45,
|
||||
MinMargin: 0.10,
|
||||
Scales: FrameRateScales,
|
||||
}
|
||||
}
|
||||
|
||||
// Result describes a correction.
|
||||
type Result struct {
|
||||
// Offset is what to add to every cue, after Scale.
|
||||
Offset time.Duration
|
||||
// Scale is the stretch applied first. 1 means the timing was merely displaced.
|
||||
Scale float64
|
||||
// Score is the share of the shorter track's speech that lines up once corrected —
|
||||
// how much the two agree, not how confident we are.
|
||||
Score float64
|
||||
// Margin is how far ahead of the best rival alignment this one finished. A high
|
||||
// score with no margin is a track that matches everything, which matches nothing.
|
||||
Margin float64
|
||||
}
|
||||
|
||||
// Correction reports whether this result actually changes anything a viewer would see.
|
||||
func (r Result) Correction() bool {
|
||||
return r.Offset.Abs() >= 100*time.Millisecond || r.Scale != 1
|
||||
}
|
||||
|
||||
// String renders the correction the way the console and the log want it.
|
||||
func (r Result) String() string {
|
||||
seconds := r.Offset.Seconds()
|
||||
out := fmt.Sprintf("%+.2fs", seconds)
|
||||
if r.Scale != 1 {
|
||||
out += fmt.Sprintf(" at %.4f×", r.Scale)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ErrNoAlignment is returned when the two tracks cannot be aligned with confidence.
|
||||
//
|
||||
// Most of this package is about producing this rather than a number. A subtitle nudged to
|
||||
// the wrong place is worse than one left alone: the viewer asked for a fix, would be told
|
||||
// it worked, and would have no way of knowing the file they now have is further out than
|
||||
// the one they started with.
|
||||
type ErrNoAlignment struct {
|
||||
// Reason is safe to show a viewer.
|
||||
Reason string
|
||||
// Score and Margin are what the search actually found, for the log.
|
||||
Score float64
|
||||
Margin float64
|
||||
}
|
||||
|
||||
func (e *ErrNoAlignment) Error() string {
|
||||
return fmt.Sprintf("subsync: %s (score %.2f, margin %.2f)", e.Reason, e.Score, e.Margin)
|
||||
}
|
||||
|
||||
// Align finds the correction that brings broken onto reference.
|
||||
//
|
||||
// The reference is assumed correct; nothing here checks that, because nothing could. The
|
||||
// caller chooses it, and choosing badly is the one failure this package cannot detect —
|
||||
// which is why the margin test exists, since a wrong reference tends to match everything
|
||||
// equally rather than matching one shift particularly well.
|
||||
func Align(broken, reference []Cue, opts Options) (Result, error) {
|
||||
if opts.Bin <= 0 {
|
||||
opts = DefaultOptions()
|
||||
}
|
||||
if len(broken) < opts.MinCues || len(reference) < opts.MinCues {
|
||||
return Result{}, &ErrNoAlignment{
|
||||
Reason: "there are not enough lines in one of these subtitles to compare them",
|
||||
}
|
||||
}
|
||||
|
||||
scales := opts.Scales
|
||||
if len(scales) == 0 {
|
||||
scales = []float64{1}
|
||||
}
|
||||
|
||||
// The reference is rasterised once; the broken track is rasterised per stretch
|
||||
// candidate, which is a handful of times rather than once per shift.
|
||||
ref := rasterise(reference, opts.Bin, 1)
|
||||
maxShiftBins := int(opts.MaxShift / opts.Bin)
|
||||
|
||||
best := Result{Scale: 1}
|
||||
bestBins := 0
|
||||
found := false
|
||||
// Rivals holds the best score at each shift far enough from the winner to be a
|
||||
// different answer rather than the same peak's shoulder.
|
||||
var runnerUp float64
|
||||
|
||||
for _, scale := range scales {
|
||||
signal := rasterise(broken, opts.Bin, scale)
|
||||
if signal.on == 0 {
|
||||
continue
|
||||
}
|
||||
floor := min(signal.on, ref.on)
|
||||
if floor == 0 {
|
||||
continue
|
||||
}
|
||||
for shift := -maxShiftBins; shift <= maxShiftBins; shift++ {
|
||||
score := float64(signal.overlap(ref, shift)) / float64(floor)
|
||||
switch {
|
||||
case !found || score > best.Score:
|
||||
// The old winner becomes a rival only if it is a different answer.
|
||||
if found && farApart(bestBins, shift, best.Scale, scale, opts.Bin) {
|
||||
runnerUp = max(runnerUp, best.Score)
|
||||
}
|
||||
found = true
|
||||
best = Result{
|
||||
Offset: time.Duration(shift) * opts.Bin,
|
||||
Scale: scale,
|
||||
Score: score,
|
||||
}
|
||||
bestBins = shift
|
||||
case farApart(bestBins, shift, best.Scale, scale, opts.Bin):
|
||||
runnerUp = max(runnerUp, score)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return Result{}, &ErrNoAlignment{Reason: "these subtitles have no speech in common"}
|
||||
}
|
||||
best.Margin = best.Score - runnerUp
|
||||
if best.Score < opts.MinScore {
|
||||
return Result{}, &ErrNoAlignment{
|
||||
Reason: "these two subtitles are too different to line up — they may be for " +
|
||||
"different cuts of this title",
|
||||
Score: best.Score, Margin: best.Margin,
|
||||
}
|
||||
}
|
||||
if best.Margin < opts.MinMargin {
|
||||
return Result{}, &ErrNoAlignment{
|
||||
Reason: "no single timing fits better than the others, so the result would be " +
|
||||
"a guess",
|
||||
Score: best.Score, Margin: best.Margin,
|
||||
}
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// farApart says whether two candidates are different answers rather than two samples of
|
||||
// one peak. Alignment produces a broad hill either side of the true shift, so the shifts
|
||||
// immediately around the winner are not rivals — treating them as rivals would collapse
|
||||
// every margin to nearly zero and refuse every correct answer.
|
||||
func farApart(bestBins, shift int, bestScale, scale float64, bin time.Duration) bool {
|
||||
if bestScale != scale {
|
||||
return true
|
||||
}
|
||||
guard := int(time.Second / bin)
|
||||
return abs(shift-bestBins) > guard
|
||||
}
|
||||
|
||||
func abs(v int) int {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Reference picks which of the tracks on offer to align against.
|
||||
//
|
||||
// The rules are about what makes a *usable* yardstick, not what makes a good subtitle. A
|
||||
// forced track carries only the lines that are foreign to the film's own audio, so it is
|
||||
// mostly silence and would correlate with almost any shift; a track with very few cues is
|
||||
// the same problem in a different shape. Longest wins because coverage is what the
|
||||
// correlation is measured against. It returns the index so the caller can name the track
|
||||
// it used, which is the one thing a viewer needs to judge the answer.
|
||||
func Reference(candidates [][]Cue, exclude int, forced []bool, minCues int) (int, bool) {
|
||||
best, bestCount := -1, 0
|
||||
for i, cues := range candidates {
|
||||
if i == exclude || len(cues) < minCues {
|
||||
continue
|
||||
}
|
||||
if i < len(forced) && forced[i] {
|
||||
continue
|
||||
}
|
||||
if len(cues) > bestCount {
|
||||
best, bestCount = i, len(cues)
|
||||
}
|
||||
}
|
||||
return best, best >= 0
|
||||
}
|
||||
|
||||
// rasterise turns cues into one bit per time bin: is anybody speaking in this cell.
|
||||
//
|
||||
// The text is thrown away deliberately. Two subtitles for one film are in different
|
||||
// languages as often as not, so the only thing they can be compared on is *when* lines
|
||||
// happen — which turns out to be plenty, because dialogue rhythm is a property of the
|
||||
// film rather than of the translation.
|
||||
func rasterise(cues []Cue, bin time.Duration, scale float64) *signal {
|
||||
end := time.Duration(0)
|
||||
for _, cue := range cues {
|
||||
if e := scaleDuration(cue.End, scale); e > end {
|
||||
end = e
|
||||
}
|
||||
}
|
||||
sig := newSignal(int(end/bin) + 2)
|
||||
for _, cue := range cues {
|
||||
start := scaleDuration(cue.Start, scale)
|
||||
stop := scaleDuration(cue.End, scale)
|
||||
if stop <= start {
|
||||
// A zero-length or reversed cue still says a line happened here.
|
||||
stop = start + bin
|
||||
}
|
||||
sig.set(int(start/bin), int(stop/bin))
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
// Normalise tidies a parsed track before it is compared or written back.
|
||||
//
|
||||
// Cues out of order, or overlapping, are ordinary in files people have edited by hand, and
|
||||
// both would put speech in the wrong cell.
|
||||
func Normalise(cues []Cue) []Cue {
|
||||
out := make([]Cue, 0, len(cues))
|
||||
for _, cue := range cues {
|
||||
if strings.TrimSpace(cue.Text) == "" {
|
||||
continue
|
||||
}
|
||||
if cue.End < cue.Start {
|
||||
cue.Start, cue.End = cue.End, cue.Start
|
||||
}
|
||||
out = append(out, cue)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Start < out[j].Start })
|
||||
return out
|
||||
}
|
||||
@@ -9,9 +9,11 @@
|
||||
// on a television. Read the first few kilobytes and every frame's byte range is known, so
|
||||
// showing one thumbnail costs a ranged request of about seven kilobytes rather than a
|
||||
// five-megabyte download nobody would wait for mid-seek. Emby answers ranged requests on
|
||||
// this route correctly, which it does not advertise: the response carries
|
||||
// "Accept-Ranges: none" and a Content-Length borrowed from the media file. Trust the 206,
|
||||
// not the headers.
|
||||
// this route correctly — measured against 4.10.0.21, which returns a 206 with
|
||||
// "Accept-Ranges: bytes" and a Content-Range naming the BIF's own length. Earlier builds
|
||||
// were reported to answer the range while advertising "Accept-Ranges: none", so callers
|
||||
// still trust the 206 rather than the headers and cap the read at what was asked for:
|
||||
// being wrong about that must not turn a press of Right into a multi-megabyte download.
|
||||
package trickplay
|
||||
|
||||
import (
|
||||
|
||||
Reference in New Issue
Block a user