fix(anime): clarify bridge failures and update guidance

- Show actionable bridge errors and preserve episodes when details fail
- Check external bridges for updates without offering unsafe in-app installs
- Complete cleanup and ignore callbacks from closed Tsukihime sessions
This commit is contained in:
2026-09-16 23:25:21 -07:00
parent 93fb4eff3a
commit ca40cd6267
23 changed files with 653 additions and 149 deletions
+44
View File
@@ -168,6 +168,50 @@ test('searchAnime sends a 1-based page and returns the page payload', async () =
assert.equal(page.animes?.length, 1);
});
test('HTTP failures preserve the bridge error and status for diagnosis', async () => {
for (const detail of [
"'java.lang.Object eu.kanade.tachiyomi.animesource.online.AnimeHttpSource.getHosterList(eu.kanade.tachiyomi.animesource.model.SEpisode, kotlin.coroutines.Continuation)'",
'lateinit property url has not been initialized',
]) {
const { fetchImpl } = stubFetch(
() => new Response(JSON.stringify({ error: detail, code: 500 }), { status: 500 }),
);
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
await assert.rejects(
() => client.getVideoList(source, '/episode/301'),
(error: unknown) => {
assert.ok(error instanceof BridgeExtensionError);
assert.equal(error.code, 500);
assert.equal(error.message, `Anime bridge getVideoList failed (500). ${detail}`);
return true;
},
);
}
});
test('non-JSON and invalid bridge errors keep the HTTP fallback without exposing response bodies', async () => {
for (const body of ['<html>Proxy error</html>', '', '{"error":{}}', '{"error":" "}', 'null']) {
const { fetchImpl } = stubFetch(() => new Response(body, { status: 502 }));
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
await assert.rejects(() => client.getAnimeDetails(source, '/anime/1'), {
message: 'Anime bridge getDetailsAnime failed (502).',
});
}
});
test('bridge diagnostics normalize whitespace and bound long messages', async () => {
const { fetchImpl } = stubFetch(
() =>
new Response(JSON.stringify({ error: ` Missing field\n\t${'x'.repeat(3_000)}` }), {
status: 500,
}),
);
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
await assert.rejects(() => client.getAnimeDetails(source, '/anime/1'), {
message: `Anime bridge getDetailsAnime failed (500). ${`Missing field ${'x'.repeat(3_000)}`.slice(0, 1_999)}`,
});
});
test('getEpisodeList wraps the anime url in animeData', async () => {
const { fetchImpl, calls } = stubFetch(() => jsonResponse([{ name: 'Episode 1', url: '/ep/1' }]));
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
+19 -6
View File
@@ -45,7 +45,7 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
/** The readiness probe is a local health check; it should answer at once. */
const CAPABILITIES_TIMEOUT_MS = 5_000;
/** The bridge reports extension failures as HTTP 200 with an error body. */
/** Extension failures may arrive as HTTP errors or HTTP 200 with an error body. */
export class BridgeExtensionError extends Error {
readonly code?: number;
constructor(message: string, code?: number) {
@@ -194,7 +194,12 @@ export class AnimeBridgeClient {
}
if (!response.ok) {
throw new Error(`Anime bridge ${method} failed (${response.status}).`);
const body: unknown = await response.json().catch(() => null);
const detail = extensionErrorMessage(body);
throw new BridgeExtensionError(
`Anime bridge ${method} failed (${response.status}).${detail ? ` ${detail}` : ''}`,
response.status,
);
}
const returnedId = response.headers.get(EXTENSION_ID_HEADER)?.trim();
@@ -234,12 +239,20 @@ export class AnimeBridgeClient {
}
function assertNoExtensionError(body: unknown, method: string): void {
if (body === null || typeof body !== 'object' || Array.isArray(body)) return;
const error = (body as { error?: unknown }).error;
if (typeof error !== 'string') return;
const code = (body as { code?: unknown }).code;
const error = extensionErrorMessage(body);
if (error === null) return;
const code = body !== null && typeof body === 'object' && 'code' in body ? body.code : undefined;
throw new BridgeExtensionError(
`Anime bridge ${method} failed: ${error}`,
typeof code === 'number' ? code : undefined,
);
}
/** Only expose the bridge's JSON error field, never an HTML error page or stack object. */
function extensionErrorMessage(body: unknown): string | null {
if (body === null || typeof body !== 'object' || Array.isArray(body)) return null;
if (!('error' in body) || typeof body.error !== 'string') return null;
const message = body.error.replace(/\s+/g, ' ').trim();
if (!message) return null;
return message.length > 2_000 ? `${message.slice(0, 1_999)}` : message;
}
+10 -16
View File
@@ -1,5 +1,6 @@
import { describe, el } from './dom';
import { sourceOptionLabel, summarizeSearch } from './format';
import { createStatusPanel } from './status-panel';
import { describeBridgeUpdate, sourceOptionLabel, summarizeSearch } from './format';
import { applySearchUpdate, idleSearchProgress, summarizeProgress } from './search-progress';
import { createExtensionsPanel } from './extensions-panel';
import { createDetailPanel } from './detail-panel';
@@ -58,7 +59,6 @@ const bannerMessage = el<HTMLSpanElement>('bridge-message');
const bannerMeter = el<HTMLSpanElement>('bridge-meter');
const bannerMeterFill = el<HTMLElement>('bridge-meter-fill');
const bannerUpdate = el<HTMLButtonElement>('bridge-update');
const statusMessage = el<HTMLSpanElement>('status-message');
const browseTab = el<HTMLButtonElement>('tab-browse');
const extensionsTab = el<HTMLButtonElement>('tab-extensions');
const settingsTab = el<HTMLButtonElement>('tab-settings');
@@ -92,10 +92,7 @@ function setView(view: View): void {
settingsTab.setAttribute('aria-selected', String(view === 'settings'));
}
function setStatus(message: string, tone: 'info' | 'ok' | 'error' = 'info'): void {
statusMessage.textContent = message;
statusMessage.parentElement?.setAttribute('data-tone', tone);
}
const { setStatus } = createStatusPanel();
const detailPanel = createDetailPanel({ api, setStatus });
@@ -137,18 +134,15 @@ function renderBridgeState(state: AnimeBrowserBridgeState): void {
// An update is only offered from a running bridge; mid-start it would race
// the start it interrupts.
const update = state.stage === 'ready' ? (state.install?.updateAvailable ?? null) : null;
const update = describeBridgeUpdate(state);
// Once ready with nothing to report, the banner has nothing to say.
const hide = state.stage === 'ready' && state.message === null && update === null;
banner.classList.toggle('hidden', hide);
bannerMessage.textContent =
state.message ??
(update === null
? BRIDGE_LABELS[state.stage]
: `Extension bridge ${state.install?.version ?? 'of unknown version'} is installed; ${update} is available.`);
bannerUpdate.classList.toggle('hidden', update === null);
bannerUpdate.textContent = update === null ? '' : `Update to ${update}`;
bannerUpdate.disabled = busy;
bannerMessage.textContent = state.message ?? update?.message ?? BRIDGE_LABELS[state.stage];
const buttonLabel = update?.buttonLabel ?? null;
bannerUpdate.classList.toggle('hidden', buttonLabel === null);
bannerUpdate.textContent = buttonLabel ?? '';
bannerUpdate.disabled = busy || buttonLabel === null;
const showMeter = state.progress !== null;
bannerMeter.classList.toggle('hidden', !showMeter);
@@ -305,7 +299,7 @@ api.onSearchUpdate((update) => {
if (activeStreamRequestId !== browseState.requestId) return;
if (applied.entries.length > 0) appendEntries(applied.entries);
if (!progress.done) {
setStatus(summarizeProgress(progress), progress.failures.length > 0 ? 'error' : 'info');
setStatus(summarizeProgress(progress));
}
});
+50 -34
View File
@@ -42,44 +42,60 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
detailChips.replaceChildren();
episodeList.clear();
detailCover.src = entry.thumbnailUrl ?? '';
setStatus(`Loading ${entry.title}`);
try {
const [details, episodes] = await Promise.all([
api.getDetails(entry.url, entry.sourceId),
api.getEpisodes(entry.url, entry.sourceId),
]);
if (!requests.isCurrent(request)) return;
// Details and episodes are independent bridge calls. A source whose
// details call fails (a bridge that rejects the extension's metadata, a
// flaky page) can still list episodes, so a details failure only costs the
// description and chips, not the episode list.
const [detailsResult, episodesResult] = await Promise.allSettled([
api.getDetails(entry.url, entry.sourceId),
api.getEpisodes(entry.url, entry.sourceId),
]);
if (!requests.isCurrent(request)) return;
detailTitle.textContent = details.title;
detailDescription.textContent = details.description ?? 'No description from this source.';
if (details.thumbnailUrl) detailCover.src = details.thumbnailUrl;
const chips: HTMLSpanElement[] = [];
const source = document.createElement('span');
source.className = 'chip source';
source.textContent = entry.sourceName;
chips.push(source);
if (details.status !== 'unknown') {
const status = document.createElement('span');
status.className = 'chip status';
status.textContent = details.status.replace(/-/g, ' ');
chips.push(status);
}
for (const genre of details.genres.slice(0, 6)) {
const chip = document.createElement('span');
chip.className = 'chip';
chip.textContent = genre;
chips.push(chip);
}
detailChips.replaceChildren(...chips);
episodeList.render(episodes);
setStatus(`${details.title} · ${episodes.length} episodes`);
} catch (error) {
if (!requests.isCurrent(request)) return;
if (episodesResult.status === 'rejected') {
detailDescription.textContent = '';
setStatus(describe(error), 'error');
setStatus(describe(episodesResult.reason), 'error');
return;
}
const episodes = episodesResult.value;
const chips: HTMLSpanElement[] = [];
const source = document.createElement('span');
source.className = 'chip source';
source.textContent = entry.sourceName;
chips.push(source);
if (detailsResult.status === 'rejected') {
detailDescription.textContent = 'Details unavailable from this source.';
detailChips.replaceChildren(...chips);
episodeList.render(episodes);
setStatus(describe(detailsResult.reason), 'error');
return;
}
const details = detailsResult.value;
detailTitle.textContent = details.title;
detailDescription.textContent = details.description ?? 'No description from this source.';
if (details.thumbnailUrl) detailCover.src = details.thumbnailUrl;
if (details.status !== 'unknown') {
const status = document.createElement('span');
status.className = 'chip status';
status.textContent = details.status.replace(/-/g, ' ');
chips.push(status);
}
for (const genre of details.genres.slice(0, 6)) {
const chip = document.createElement('span');
chip.className = 'chip';
chip.textContent = genre;
chips.push(chip);
}
detailChips.replaceChildren(...chips);
episodeList.render(episodes);
setStatus(`${details.title} · ${episodes.length} episodes`);
}
function close(): void {
+67
View File
@@ -419,6 +419,73 @@
color: var(--ok);
}
.statusbar:has(.request-error:not(.hidden)) {
border-top-color: color-mix(in srgb, var(--danger) 45%, transparent);
padding-block: 14px;
max-height: 40vh;
overflow-y: auto;
}
.request-error {
border-left: 3px solid var(--danger);
padding-left: 14px;
color: var(--text);
}
.request-error-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
}
.request-error-heading h2 {
margin: 0;
font-size: 14px;
font-weight: 650;
color: var(--danger);
}
.request-error-heading button {
flex-shrink: 0;
padding: 3px 10px;
font-size: 11px;
}
.request-error p {
margin: 5px 0 0;
overflow-wrap: anywhere;
}
.request-error-guidance {
color: var(--muted);
max-width: 90ch;
}
.request-error-details {
margin-top: 10px;
color: var(--muted);
}
.request-error-details summary {
cursor: pointer;
width: fit-content;
font-size: 11px;
}
.request-error-details pre {
margin: 8px 0 0;
padding: 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--ctp-crust);
color: var(--text);
font: 11px/1.65 var(--mono);
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: text;
}
/* ---------- scrollbars ---------- */
::-webkit-scrollbar {
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { describeAnimeBrowserError } from './error-message';
test('missing bridge API explains incompatibility and preserves the method signature', () => {
const message =
"Anime bridge getVideoList failed (500). 'java.lang.Object eu.kanade.tachiyomi.animesource.online.AnimeHttpSource.getHosterList(eu.kanade.tachiyomi.animesource.model.SEpisode, kotlin.coroutines.Continuation)'";
const error = describeAnimeBrowserError(message);
assert.equal(error.title, 'Could not resolve the video');
assert.match(error.explanation, /installed extension bridge does not provide/);
assert.match(error.guidance, /If none are available/);
assert.equal(error.details, message);
});
test('incomplete extension data is distinct from a missing bridge API', () => {
const message =
'Anime bridge getDetailsAnime failed (500). lateinit property url has not been initialized';
const error = describeAnimeBrowserError(message);
assert.equal(error.title, 'Could not load anime details');
assert.match(error.explanation, /required field/);
assert.equal(error.details, message);
});
test('unknown bridge failures do not claim an incompatibility', () => {
for (const message of [
'Anime bridge getEpisodeList failed (500). Unexpected response',
'Anime bridge getEpisodeList failed (502).',
'Anime bridge getEpisodeList failed: Cloudflare challenge',
]) {
const error = describeAnimeBrowserError(message);
assert.equal(error.title, 'Could not load episodes');
assert.equal(error.explanation, 'The extension bridge could not complete this request.');
assert.equal(error.details, message);
}
});
test('playback and ordinary errors keep their own explanations', () => {
const playback = describeAnimeBrowserError('mpv could not play this stream: loading failed');
assert.equal(playback.title, 'Could not start playback');
assert.match(playback.guidance, /fresh stream/);
const other = describeAnimeBrowserError('Select a source first.');
assert.equal(other.explanation, 'Select a source first.');
assert.equal(other.details, '');
});
+71
View File
@@ -0,0 +1,71 @@
export interface AnimeBrowserErrorMessage {
title: string;
explanation: string;
guidance: string;
details: string;
}
const OPERATIONS: Record<string, string> = {
getDetailsAnime: 'Could not load anime details',
getEpisodeList: 'Could not load episodes',
getVideoList: 'Could not resolve the video',
getSearchAnime: 'Could not search this source',
getPopularAnime: 'Could not browse this source',
};
/** Error messages survive both Electron IPC and the embedded browser's transport. */
export function describeAnimeBrowserError(message: string): AnimeBrowserErrorMessage {
const bridge = /Anime bridge (\w+) failed(?: \(\d+\))?[.:]/.exec(message);
const operation = bridge?.[1];
const title = (operation && OPERATIONS[operation]) || 'Request failed';
const details = message;
if (
bridge &&
/NoSuchMethodError|NoClassDefFoundError|AbstractMethodError|(?:java\.lang\.Object|void|boolean) eu\.kanade\.[\w.$]+\(/.test(
message,
)
) {
return {
title,
explanation:
'This extension needs functionality that the installed extension bridge does not provide.',
guidance:
'Check Extensions for bridge updates. If none are available, try another source and include the technical details when reporting the issue.',
details,
};
}
if (bridge && /lateinit property \w+ has not been initialized/.test(message)) {
return {
title,
explanation:
'The extension bridge could not read a required field from the extensions data.',
guidance:
'Check Extensions for extension and bridge updates. If this continues, try another source and include the technical details when reporting the issue.',
details,
};
}
if (bridge) {
return {
title,
explanation: 'The extension bridge could not complete this request.',
guidance:
'Try the action again. If it keeps failing, check for updates in Extensions or try another source.',
details,
};
}
if (/^mpv could not play this stream|^Playback did not start\./.test(message)) {
return {
title: 'Could not start playback',
explanation: 'mpv could not start the selected stream.',
guidance:
'Try playing the episode again to request a fresh stream, or choose another source.',
details,
};
}
return { title, explanation: message, guidance: '', details: '' };
}
+42 -1
View File
@@ -5,8 +5,9 @@ import {
sourceOptionLabel,
summarizeSearch,
describeBridgeInstall,
describeBridgeUpdate,
} from './format';
import type { AnimeBrowserSearchResult } from '../types/anime-browser';
import type { AnimeBrowserBridgeState, AnimeBrowserSearchResult } from '../types/anime-browser';
const result = (
entryCount: number,
@@ -110,3 +111,43 @@ test('describeBridgeInstall does not treat an unchecked managed bridge as up to
assert.match(description, /unknown version.*checks this installation for updates after startup/);
assert.doesNotMatch(description, /up to date/);
});
test('bridge update notices direct AUR and custom installs outside the app', () => {
const state = {
stage: 'ready',
progress: null,
message: null,
install: {
origin: 'system',
version: 'v1.0.6.4',
updateAvailable: 'v1.0.6.6',
dir: '/usr/share/mangatan/extension_server',
},
} satisfies AnimeBrowserBridgeState;
const notice = describeBridgeUpdate(state);
assert.equal(notice?.buttonLabel, null);
assert.match(notice?.message ?? '', /v1\.0\.6\.4 is installed; v1\.0\.6\.6 is available/);
assert.match(notice?.message ?? '', /AUR helper.*paru -S mangatan-extension-server/);
const custom = describeBridgeUpdate({
...state,
install: { ...state.install, dir: '/custom/bridge' },
});
assert.equal(custom?.buttonLabel, null);
assert.match(custom?.message ?? '', /original installation method/);
assert.doesNotMatch(custom?.message ?? '', /AUR/);
const managed = describeBridgeUpdate({
...state,
install: { ...state.install, origin: 'managed' },
});
assert.equal(managed?.buttonLabel, 'Update to v1.0.6.6');
assert.doesNotMatch(managed?.message ?? '', /AUR/);
assert.equal(describeBridgeUpdate({ ...state, stage: 'downloading' }), null);
assert.equal(describeBridgeUpdate({ ...state, install: null }), null);
assert.equal(
describeBridgeUpdate({ ...state, install: { ...state.install, updateAvailable: null } }),
null,
);
});
+19
View File
@@ -1,5 +1,6 @@
import type {
AnimeBrowserBridgeInstall,
AnimeBrowserBridgeState,
AnimeBrowserSearchResult,
AnimeBrowserSource,
InstalledExtensionView,
@@ -47,3 +48,21 @@ export function describeBridgeInstall(install: AnimeBrowserBridgeInstall | null)
}
return `M-Extension-Server ${version} in ${install.dir}, downloaded by SubMiner. SubMiner checks this installation for updates after startup.`;
}
/** Update notices are informational for bridges installed outside SubMiner. */
export function describeBridgeUpdate(state: AnimeBrowserBridgeState): {
message: string;
buttonLabel: string | null;
} | null {
const install = state.install;
if (state.stage !== 'ready' || !install || install.updateAvailable === null) return null;
const message = `Extension bridge ${install.version ?? 'of unknown version'} is installed; ${install.updateAvailable} is available.`;
if (install.origin === 'managed') {
return { message, buttonLabel: `Update to ${install.updateAvailable}` };
}
const instruction =
install.dir.replace(/\/+$/, '') === '/usr/share/mangatan/extension_server'
? 'Update mangatan-extension-server through your AUR helper (for example: paru -S mangatan-extension-server), then restart SubMiner.'
: 'Update your bridge through your package manager or its original installation method, then restart SubMiner.';
return { message: `${message} ${instruction}`, buttonLabel: null };
}
+26 -1
View File
@@ -192,7 +192,32 @@
</main>
<footer class="statusbar">
<span id="status-message"></span>
<section
class="request-error hidden"
id="request-error"
aria-labelledby="request-error-title"
>
<div class="request-error-heading">
<h2 id="request-error-title"></h2>
<button
class="ghost-button"
id="request-error-dismiss"
type="button"
aria-label="Dismiss error"
>
Dismiss
</button>
</div>
<div role="alert" aria-atomic="true">
<p class="request-error-explanation" id="request-error-explanation"></p>
<p class="request-error-guidance" id="request-error-guidance"></p>
</div>
<details class="request-error-details" id="request-error-details">
<summary>Technical details</summary>
<pre id="request-error-technical"></pre>
</details>
</section>
<span id="status-message" role="status"></span>
</footer>
<script type="module" src="./animeui.js"></script>
+42
View File
@@ -0,0 +1,42 @@
import { el } from './dom';
import { describeAnimeBrowserError } from './error-message';
export function createStatusPanel() {
const status = el<HTMLElement>('status-message');
const panel = el<HTMLElement>('request-error');
const title = el<HTMLElement>('request-error-title');
const explanation = el<HTMLElement>('request-error-explanation');
const guidance = el<HTMLElement>('request-error-guidance');
const disclosure = el<HTMLDetailsElement>('request-error-details');
const technical = el<HTMLElement>('request-error-technical');
const dismiss = el<HTMLButtonElement>('request-error-dismiss');
function setStatus(message: string, tone: 'info' | 'ok' | 'error' = 'info'): void {
const failed = tone === 'error' && message.length > 0;
panel.classList.toggle('hidden', !failed);
status.parentElement?.setAttribute('data-tone', tone);
disclosure.open = false;
if (!failed) {
status.textContent = message;
technical.textContent = '';
return;
}
const error = describeAnimeBrowserError(message);
status.textContent = '';
title.textContent = error.title;
explanation.textContent = error.explanation;
guidance.textContent = error.guidance;
guidance.classList.toggle('hidden', !error.guidance);
technical.textContent = error.details;
disclosure.classList.toggle('hidden', !error.details);
}
dismiss.addEventListener('click', () => {
setStatus('');
// Keep keyboard focus in the browser after its dismiss button disappears.
document.querySelector<HTMLButtonElement>('.tab[aria-selected="true"]')?.focus();
});
return { setStatus };
}
@@ -182,7 +182,7 @@ test('with nothing installed the newest release is downloaded and marked', async
assert.ok(!(await readdir(managed)).some((entry) => entry.endsWith('.zip')));
});
test('findBridgeUpdate offers the newest release only to a managed install that is behind it', async () => {
test('findBridgeUpdate compares both managed and system installs with upstream', async () => {
const { calls, options } = fakeUpstream();
assert.equal(await findBridgeUpdate({ origin: 'managed', version: 'v1.0.6.0' }, options), LATEST);
@@ -192,9 +192,12 @@ test('findBridgeUpdate offers the newest release only to a managed install that
assert.equal(await findBridgeUpdate({ origin: 'managed', version: null }, options), LATEST);
assert.equal(calls.length, 4);
// A system install is pacman's, so upstream is not even asked.
assert.equal(await findBridgeUpdate({ origin: 'system', version: 'v1.0.0.0' }, options), null);
assert.equal(calls.length, 4);
assert.equal(await findBridgeUpdate({ origin: 'system', version: 'v1.0.0.0' }, options), LATEST);
assert.equal(await findBridgeUpdate({ origin: 'system', version: LATEST }, options), null);
assert.equal(await findBridgeUpdate({ origin: 'system', version: 'v1.0.7.0' }, options), null);
// An unknown external version is not evidence that an update is needed.
assert.equal(await findBridgeUpdate({ origin: 'system', version: null }, options), null);
assert.equal(calls.length, 7);
});
test('findBridgeUpdate propagates a failed release listing', async () => {
+5 -5
View File
@@ -178,16 +178,16 @@ async function locateLatestBundle(options: BridgeReleaseOptions): Promise<Bundle
}
/**
* The newest release a managed install could move to, or null when it is
* current or is not SubMiner's to update. An install whose version cannot be
* read is offered the newest release: re-downloading is the way back to a
* known state. Network errors propagate; the caller decides how loudly.
* The newest release an install could move to, or null when it is current.
* An unreadable managed install is offered the newest release so it can be
* repaired; an unreadable system install cannot be compared. Network errors
* propagate; the caller decides how loudly.
*/
export async function findBridgeUpdate(
install: Pick<AnimeBrowserBridgeInstall, 'origin' | 'version'>,
options: BridgeReleaseOptions = {},
): Promise<string | null> {
if (install.origin !== 'managed') return null;
if (install.origin === 'system' && install.version === null) return null;
const latest = await locateLatestBundle(options);
if (install.version === null) return latest.tagName;
return compareBundleVersions(latest.tagName, install.version) > 0 ? latest.tagName : null;
@@ -107,19 +107,28 @@ test('a failed update check is logged and leaves the bridge ready', async () =>
assert.ok(logged.some((line) => /update check failed: rate limited/.test(line)));
});
test('a system install is never asked about updates', async () => {
test('a system install broadcasts available updates without allowing installation', async () => {
let asked = 0;
const { runtime } = await setup({
let staged = false;
const { runtime, states, stopped } = await setup({
ensureBinaries: async () => ({ ...OLD, origin: 'system' }),
checkBridgeUpdate: async () => {
asked += 1;
return LATEST;
},
stageBridgeUpdate: async () => {
staged = true;
throw new Error('must not stage a system bridge update');
},
});
await runtime.ensureBridge();
await tick();
assert.equal(asked, 0);
assert.equal(runtime.getSnapshot().bridge.install?.updateAvailable, null);
assert.equal(asked, 1);
assert.equal(runtime.getSnapshot().bridge.install?.updateAvailable, LATEST);
assert.equal(states.at(-1)?.install?.updateAvailable, LATEST);
await assert.rejects(runtime.updateBridge(), /managed outside SubMiner/);
assert.equal(staged, false);
assert.deepEqual(stopped, []);
});
test('updateBridge stages, stops the old bridge, and restarts on the new install', async () => {
+2 -2
View File
@@ -218,12 +218,12 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
}
/**
* Ask upstream whether a managed install is behind, after the bridge is up
* Ask upstream whether the install is behind, after the bridge is up
* so a slow or failed GitHub call never delays a search. The answer lands
* in `install.updateAvailable` and is re-broadcast on the current state.
*/
async function checkForBridgeUpdate(handle: SidecarHandle): Promise<void> {
if (install === null || install.origin !== 'managed') return;
if (install === null) return;
try {
const latest = await deps.checkBridgeUpdate(install);
// The bridge may have been restarted or updated while we waited.
+72 -50
View File
@@ -67,57 +67,79 @@ test('on will quit cleanup handler runs all cleanup steps', async () => {
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
});
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', async () => {
const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler({
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
destroyMainOverlayWindow: () => {},
destroyModalOverlayWindow: () => {},
destroyYomitanParserWindow: () => {},
clearYomitanParserState: () => {},
stopWindowTracker: () => {},
flushMpvLog: () => {},
destroyMpvSocket: () => {},
clearReconnectTimer: () => {},
destroySubtitleTimingTracker: () => {},
destroyImmersionTracker: () => {},
destroyAnkiIntegration: () => {},
destroyAnilistSetupWindow: () => {},
clearAnilistSetupWindow: () => {},
destroyJellyfinSetupWindow: () => {},
clearJellyfinSetupWindow: () => {},
destroyFirstRunSetupWindow: () => {},
clearFirstRunSetupWindow: () => {},
destroyYomitanSettingsWindow: () => {},
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {
calls.push('stop-jellyfin-remote');
throw new Error('stop failed');
},
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
for (const failedStep of [
'stop-jellyfin-remote',
'cleanup-jellyfin-subtitles',
'cleanup-internal-subtitles',
'cleanup-youtube-subtitles',
'cleanup-youtube-media',
'cleanup-remote-media-windows',
'stop-discord-presence',
'stop-sync-auto-scheduler',
]) {
test(`on will quit cleanup finishes every independent step after ${failedStep} fails`, async () => {
const calls: string[] = [];
const firstError = new Error(`${failedStep} failed`);
const recordCleanup = (step: string): void => {
calls.push(step);
if (step === failedStep) throw firstError;
if (calls.includes(failedStep)) throw new Error(`${step} also failed`);
};
const cleanup = createOnWillQuitCleanupHandler({
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
recordCleanup('stop-sync-auto-scheduler');
},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
destroyMainOverlayWindow: () => {},
destroyModalOverlayWindow: () => {},
destroyYomitanParserWindow: () => {},
clearYomitanParserState: () => {},
stopWindowTracker: () => {},
flushMpvLog: () => {},
destroyMpvSocket: () => {},
clearReconnectTimer: () => {},
destroySubtitleTimingTracker: () => {},
destroyImmersionTracker: () => {},
destroyAnkiIntegration: () => {},
destroyAnilistSetupWindow: () => {},
clearAnilistSetupWindow: () => {},
destroyJellyfinSetupWindow: () => {},
clearJellyfinSetupWindow: () => {},
destroyFirstRunSetupWindow: () => {},
clearFirstRunSetupWindow: () => {},
destroyYomitanSettingsWindow: () => {},
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => recordCleanup('stop-jellyfin-remote'),
cleanupInternalSubtitleTrackCache: () => recordCleanup('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => recordCleanup('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => recordCleanup('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => recordCleanup('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => recordCleanup('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => recordCleanup('stop-discord-presence'),
});
await assert.rejects(cleanup(), /stop failed/);
assert.deepEqual(calls, [
'stop-jellyfin-remote',
'cleanup-jellyfin-subtitles',
'cleanup-internal-subtitles',
]);
});
await assert.rejects(cleanup(), (error) => error === firstError);
assert.deepEqual(calls, [
'stop-jellyfin-remote',
'cleanup-jellyfin-subtitles',
'cleanup-internal-subtitles',
'cleanup-youtube-subtitles',
'cleanup-youtube-media',
'cleanup-remote-media-windows',
'stop-discord-presence',
'stop-sync-auto-scheduler',
]);
});
}
test('should restore windows on activate requires initialized runtime and no windows', () => {
let initialized = false;
+22 -12
View File
@@ -37,6 +37,7 @@ export function createOnWillQuitCleanupHandler(deps: {
stopDiscordPresenceService: () => void;
}) {
return async (): Promise<void> => {
const cleanupErrors: unknown[] = [];
deps.destroyTray();
deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility();
@@ -44,7 +45,11 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
const stopSyncAutoScheduler = deps.stopSyncAutoScheduler();
const stopSyncAutoScheduler = Promise.resolve(deps.stopSyncAutoScheduler()).catch(
(error: unknown) => {
cleanupErrors.push(error);
},
);
deps.clearWindowsVisibleOverlayForegroundPollLoop();
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
deps.destroyMainOverlayWindow();
@@ -66,20 +71,25 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.clearFirstRunSetupWindow();
deps.destroyYomitanSettingsWindow();
deps.clearYomitanSettingsWindow();
try {
deps.stopJellyfinRemoteSession();
} finally {
const runCleanup = (cleanup: () => void): void => {
try {
deps.cleanupJellyfinSubtitleCache();
} finally {
deps.cleanupInternalSubtitleTrackCache();
cleanup();
} catch (error) {
cleanupErrors.push(error);
}
};
try {
runCleanup(deps.stopJellyfinRemoteSession);
runCleanup(deps.cleanupJellyfinSubtitleCache);
runCleanup(deps.cleanupInternalSubtitleTrackCache);
} finally {
runCleanup(deps.cleanupYoutubeSubtitleTempDirs);
runCleanup(deps.cleanupYoutubeMediaCache);
runCleanup(deps.cleanupRemoteMediaWindows);
runCleanup(deps.stopDiscordPresenceService);
await stopSyncAutoScheduler;
}
deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupYoutubeMediaCache();
deps.cleanupRemoteMediaWindows();
deps.stopDiscordPresenceService();
await stopSyncAutoScheduler;
if (cleanupErrors.length > 0) throw cleanupErrors[0];
};
}
+65 -8
View File
@@ -123,6 +123,7 @@ interface ModalHarness {
function createModalHarness(
files: TsukihimeSubtitleFile[],
options: {
getMediaInfo?: ElectronAPI['getJimakuMediaInfo'];
secondaryLanguages?: string[];
secondaryLanguagesGate?: Promise<void>;
downloadFile?: (query: unknown) => Promise<unknown>;
@@ -153,14 +154,16 @@ function createModalHarness(
options.listFiles ? options.listFiles(entryId) : { ok: true, data: [] },
tsukihimeSearchEntries: async (query: unknown) =>
options.searchEntries ? options.searchEntries(query) : { ok: true, data: [] },
getJimakuMediaInfo: async () => ({
title: '',
season: null,
episode: null,
confidence: 'low',
filename: '',
rawTitle: '',
}),
getJimakuMediaInfo:
options.getMediaInfo ??
(async () => ({
title: '',
season: null,
episode: null,
confidence: 'low',
filename: '',
rawTitle: '',
})),
notifyOverlayModalClosed: (modal: string) => {
modalCloseNotifications.push(modal);
},
@@ -823,3 +826,57 @@ test('a search from a prior modal session cannot repopulate a reopened modal', a
harness.restoreGlobals();
}
});
for (const reopen of [false, true]) {
for (const outcome of ['success', 'failure']) {
test(`stale media info ${outcome} is ignored after the modal is ${reopen ? 'reopened' : 'closed'}`, async () => {
type MediaInfo = Awaited<ReturnType<ElectronAPI['getJimakuMediaInfo']>>;
let resolveInfo!: (info: MediaInfo) => void;
let rejectInfo!: (error: Error) => void;
const pendingInfo = new Promise<MediaInfo>((resolve, reject) => {
resolveInfo = resolve;
rejectInfo = reject;
});
const currentInfo: MediaInfo = {
title: 'Current title',
season: null,
episode: null,
confidence: 'low',
filename: '',
rawTitle: '',
};
let mediaInfoCalls = 0;
let searchCalls = 0;
const harness = createModalHarness([], {
getMediaInfo: () => (++mediaInfoCalls === 1 ? pendingInfo : Promise.resolve(currentInfo)),
searchEntries: async () => {
searchCalls += 1;
return { ok: true, data: [] };
},
});
try {
harness.state.tsukihimeModalOpen = false;
harness.modal.openTsukihimeModal();
harness.modal.closeTsukihimeModal();
if (reopen) harness.modal.openTsukihimeModal();
await flushAsyncWork();
if (reopen) assert.equal(harness.titleInput.value, 'Current title');
const title = harness.titleInput.value;
const status = harness.status.textContent;
if (outcome === 'success') {
resolveInfo({ ...currentInfo, title: 'Stale title', confidence: 'high', episode: 1 });
} else {
rejectInfo(new Error('Old media info failed'));
}
await flushAsyncWork();
assert.equal(harness.titleInput.value, title);
assert.equal(harness.status.textContent, status);
assert.equal(searchCalls, 0);
} finally {
harness.restoreGlobals();
}
});
}
}
+3
View File
@@ -458,9 +458,11 @@ export function createTsukihimeModal(
secondaryLanguagesReady = loadSecondaryLanguages();
const searchToken = activeSearchToken;
window.electronAPI
.getJimakuMediaInfo()
.then((info: JimakuMediaInfo) => {
if (searchToken !== activeSearchToken || !ctx.state.tsukihimeModalOpen) return;
ctx.dom.tsukihimeTitleInput.value = info.title || '';
ctx.dom.tsukihimeSeasonInput.value = info.season ? String(info.season) : '';
ctx.dom.tsukihimeEpisodeInput.value = info.episode ? String(info.episode) : '';
@@ -474,6 +476,7 @@ export function createTsukihimeModal(
}
})
.catch(() => {
if (searchToken !== activeSearchToken || !ctx.state.tsukihimeModalOpen) return;
setTsukihimeStatus('Failed to load media info.', true);
});
}
+2 -1
View File
@@ -147,8 +147,9 @@ export interface AnimeBrowserBridgeInstall {
dir: string;
/**
* The newest upstream release with a bundle for this platform, when it is
* newer than a managed install; null when current, not managed, or not yet
* newer than this install; null when current, not comparable, or not yet
* checked. Filled in once the bridge is running, since it needs the network.
* Only managed installs can apply the update through SubMiner.
*/
updateAvailable: string | null;
}