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
+3 -1
View File
@@ -2,6 +2,7 @@ type: added
area: anime area: anime
- Added an anime browser window that searches Aniyomi extension sources, shows cover art and episode lists, and plays an episode in mpv so the overlay and mining tools attach as usual. - Added an anime browser window that searches Aniyomi extension sources, shows cover art and episode lists, and plays an episode in mpv so the overlay and mining tools attach as usual.
- Browser failures show a dismissible explanation with next steps and expandable technical details, including the bridge's error response instead of only its HTTP status.
- Added `subminer anime` and the `--anime` flag to open the browser, plus a "Browse Anime" tray entry. - Added `subminer anime` and the `--anime` flag to open the browser, plus a "Browse Anime" tray entry.
- Anime extensions are read from `<userData>/anime-extensions`; drop Aniyomi `.apk` files there to add sources. - Anime extensions are read from `<userData>/anime-extensions`; drop Aniyomi `.apk` files there to add sources.
- Added a source settings tab so extensions that need configuration (server address, credentials, quality) can be set up from the browser; values persist per extension and source, and updated extension schemas replace stale saved field definitions without losing values. Older unscoped preferences are discarded once because their package ownership cannot be proven safely. - Added a source settings tab so extensions that need configuration (server address, credentials, quality) can be set up from the browser; values persist per extension and source, and updated extension schemas replace stale saved field definitions without losing values. Older unscoped preferences are discarded once because their package ownership cannot be proven safely.
@@ -31,4 +32,5 @@ area: anime
- Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays. - Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays.
- Added an in-player Anime Browser modal on `Ctrl+Alt+A`. The shortcut toggles it without losing its page or scroll position. It stays within the player bounds and shares the active episode, playback queue, source configuration, and watch history with the standalone browser, while each surface keeps independent search and navigation state. The modal validates its embedded page before changing overlay state, so a load setup failure leaves it closed rather than revealing a broken modal. The SubMiner logo identifies the Anime Browser in both the modal and standalone window. - Added an in-player Anime Browser modal on `Ctrl+Alt+A`. The shortcut toggles it without losing its page or scroll position. It stays within the player bounds and shares the active episode, playback queue, source configuration, and watch history with the standalone browser, while each surface keeps independent search and navigation state. The modal validates its embedded page before changing overlay state, so a load setup failure leaves it closed rather than revealing a broken modal. The SubMiner logo identifies the Anime Browser in both the modal and standalone window.
- The bridge is reused from a package-manager install when one exists: on Arch the AUR `mangatan-extension-server` package (shared with Mangatan) is picked up from `/usr/share/mangatan/extension_server`, so nothing is downloaded and pacman keeps it current. `anime.bridgeDir` points SubMiner at a bundle anywhere else. `subminer-bin` lists the package as an optional dependency. - The bridge is reused from a package-manager install when one exists: on Arch the AUR `mangatan-extension-server` package (shared with Mangatan) is picked up from `/usr/share/mangatan/extension_server`, so nothing is downloaded and pacman keeps it current. `anime.bridgeDir` points SubMiner at a bundle anywhere else. `subminer-bin` lists the package as an optional dependency.
- SubMiner records which bridge release it installed and, once the bridge is running, checks GitHub for a newer one; when there is, the banner offers an **Update to vX** button. The new release is downloaded beside the running bridge, then the bridge restarts on it. The updater waits for a bridge still starting to stop and keeps the previous bundle until the replacement is active. The Extensions tab shows the bridge version, where it lives, and who updates it without describing an unchecked install as current. - Once the bridge is running, SubMiner checks GitHub for a newer release, including for AUR and custom installations. AUR update notices direct users to their AUR helper; custom installs get manual-update guidance. Only SubMiner-managed copies offer an **Update to vX** button. The new release is downloaded beside the running bridge, then the bridge restarts on it. The updater waits for a bridge still starting to stop and keeps the previous bundle until the replacement is active. The Extensions tab shows the bridge version, where it lives, and who updates it without describing an unchecked install as current.
- The detail page keeps its episode list when only the details call fails: the episodes render with a "Details unavailable" note and the error in the status bar, instead of an empty page.
@@ -0,0 +1,5 @@
type: fixed
area: runtime
- Shutdown finishes independent media and Discord cleanup and waits for sync shutdown even when Jellyfin cleanup fails, then reports the first cleanup error.
- Tsukihime ignores delayed media-info results and errors from a closed modal session.
+20 -4
View File
@@ -273,10 +273,20 @@ use, its version, and who updates it.
Only the copy SubMiner downloaded is ever updated by SubMiner. A package-manager Only the copy SubMiner downloaded is ever updated by SubMiner. A package-manager
install or an `anime.bridgeDir` belongs to whoever put it there. install or an `anime.bridgeDir` belongs to whoever put it there.
SubMiner records the release it installed and, once the bridge is running, Once the bridge is running, SubMiner asks GitHub for the newest release and
asks GitHub for the newest one. When upstream has published a newer release, compares it with the installed version, including package-manager installs and
the banner reads "Extension bridge v… is installed; v… is available" with an custom bridge directories. When upstream has published a newer release, the
**Update to v…** button. Clicking it downloads the new release beside the banner reads "Extension bridge v… is installed; v… is available".
For an AUR bridge, the banner directs you to update `mangatan-extension-server`
through your AUR helper, for example `paru -S mangatan-extension-server`, then
restart SubMiner. Custom installations get a reminder to use their package
manager or original installation method. These notices have no in-app update
button. The check uses upstream releases, so AUR packaging may lag behind the
notice. External installations with an unreadable version do not show a notice.
For SubMiner's own copy, the banner includes an **Update to v…** button.
Clicking it downloads the new release beside the
running bridge, so a failed download changes nothing, then stops the bridge, running bridge, so a failed download changes nothing, then stops the bridge,
swaps the directories, and starts it again. The restart takes a few seconds swaps the directories, and starts it again. The restart takes a few seconds
and kills the stream of an episode that is playing, the same as when the and kills the stream of an episode that is playing, the same as when the
@@ -330,6 +340,12 @@ success. If mpv gives up instead — a dead host, an undecodable stream — the
browser shows mpv's error rather than pretending playback started (a failed browser shows mpv's error rather than pretending playback started (a failed
load leaves no mpv window, because the player idles windowless). load leaves no mpv window, because the player idles windowless).
Failed requests appear in a dismissible panel at the bottom of the browser.
It explains the failed action and suggests a next step. Expand **Technical
details** to read the bridge or mpv error when reporting a problem. Missing
bridge APIs and incomplete extension data have distinct explanations; an
update may be needed, but a compatible release may not yet be available.
Choosing **Queue** resolves the episode and appends the playable stream to mpv's Choosing **Queue** resolves the episode and appends the playable stream to mpv's
own playlist immediately, while its subtitle tracks cache in the background. own playlist immediately, while its subtitle tracks cache in the background.
That makes mpv's next command available at once and lets the next episode begin That makes mpv's next command available at once and lets the next episode begin
+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); 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 () => { test('getEpisodeList wraps the anime url in animeData', async () => {
const { fetchImpl, calls } = stubFetch(() => jsonResponse([{ name: 'Episode 1', url: '/ep/1' }])); const { fetchImpl, calls } = stubFetch(() => jsonResponse([{ name: 'Episode 1', url: '/ep/1' }]));
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl }); 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. */ /** The readiness probe is a local health check; it should answer at once. */
const CAPABILITIES_TIMEOUT_MS = 5_000; 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 { export class BridgeExtensionError extends Error {
readonly code?: number; readonly code?: number;
constructor(message: string, code?: number) { constructor(message: string, code?: number) {
@@ -194,7 +194,12 @@ export class AnimeBridgeClient {
} }
if (!response.ok) { 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(); const returnedId = response.headers.get(EXTENSION_ID_HEADER)?.trim();
@@ -234,12 +239,20 @@ export class AnimeBridgeClient {
} }
function assertNoExtensionError(body: unknown, method: string): void { function assertNoExtensionError(body: unknown, method: string): void {
if (body === null || typeof body !== 'object' || Array.isArray(body)) return; const error = extensionErrorMessage(body);
const error = (body as { error?: unknown }).error; if (error === null) return;
if (typeof error !== 'string') return; const code = body !== null && typeof body === 'object' && 'code' in body ? body.code : undefined;
const code = (body as { code?: unknown }).code;
throw new BridgeExtensionError( throw new BridgeExtensionError(
`Anime bridge ${method} failed: ${error}`, `Anime bridge ${method} failed: ${error}`,
typeof code === 'number' ? code : undefined, 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 { 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 { applySearchUpdate, idleSearchProgress, summarizeProgress } from './search-progress';
import { createExtensionsPanel } from './extensions-panel'; import { createExtensionsPanel } from './extensions-panel';
import { createDetailPanel } from './detail-panel'; import { createDetailPanel } from './detail-panel';
@@ -58,7 +59,6 @@ const bannerMessage = el<HTMLSpanElement>('bridge-message');
const bannerMeter = el<HTMLSpanElement>('bridge-meter'); const bannerMeter = el<HTMLSpanElement>('bridge-meter');
const bannerMeterFill = el<HTMLElement>('bridge-meter-fill'); const bannerMeterFill = el<HTMLElement>('bridge-meter-fill');
const bannerUpdate = el<HTMLButtonElement>('bridge-update'); const bannerUpdate = el<HTMLButtonElement>('bridge-update');
const statusMessage = el<HTMLSpanElement>('status-message');
const browseTab = el<HTMLButtonElement>('tab-browse'); const browseTab = el<HTMLButtonElement>('tab-browse');
const extensionsTab = el<HTMLButtonElement>('tab-extensions'); const extensionsTab = el<HTMLButtonElement>('tab-extensions');
const settingsTab = el<HTMLButtonElement>('tab-settings'); const settingsTab = el<HTMLButtonElement>('tab-settings');
@@ -92,10 +92,7 @@ function setView(view: View): void {
settingsTab.setAttribute('aria-selected', String(view === 'settings')); settingsTab.setAttribute('aria-selected', String(view === 'settings'));
} }
function setStatus(message: string, tone: 'info' | 'ok' | 'error' = 'info'): void { const { setStatus } = createStatusPanel();
statusMessage.textContent = message;
statusMessage.parentElement?.setAttribute('data-tone', tone);
}
const detailPanel = createDetailPanel({ api, setStatus }); 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 // An update is only offered from a running bridge; mid-start it would race
// the start it interrupts. // 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. // Once ready with nothing to report, the banner has nothing to say.
const hide = state.stage === 'ready' && state.message === null && update === null; const hide = state.stage === 'ready' && state.message === null && update === null;
banner.classList.toggle('hidden', hide); banner.classList.toggle('hidden', hide);
bannerMessage.textContent = bannerMessage.textContent = state.message ?? update?.message ?? BRIDGE_LABELS[state.stage];
state.message ?? const buttonLabel = update?.buttonLabel ?? null;
(update === null bannerUpdate.classList.toggle('hidden', buttonLabel === null);
? BRIDGE_LABELS[state.stage] bannerUpdate.textContent = buttonLabel ?? '';
: `Extension bridge ${state.install?.version ?? 'of unknown version'} is installed; ${update} is available.`); bannerUpdate.disabled = busy || buttonLabel === null;
bannerUpdate.classList.toggle('hidden', update === null);
bannerUpdate.textContent = update === null ? '' : `Update to ${update}`;
bannerUpdate.disabled = busy;
const showMeter = state.progress !== null; const showMeter = state.progress !== null;
bannerMeter.classList.toggle('hidden', !showMeter); bannerMeter.classList.toggle('hidden', !showMeter);
@@ -305,7 +299,7 @@ api.onSearchUpdate((update) => {
if (activeStreamRequestId !== browseState.requestId) return; if (activeStreamRequestId !== browseState.requestId) return;
if (applied.entries.length > 0) appendEntries(applied.entries); if (applied.entries.length > 0) appendEntries(applied.entries);
if (!progress.done) { if (!progress.done) {
setStatus(summarizeProgress(progress), progress.failures.length > 0 ? 'error' : 'info'); setStatus(summarizeProgress(progress));
} }
}); });
+26 -10
View File
@@ -42,23 +42,44 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
detailChips.replaceChildren(); detailChips.replaceChildren();
episodeList.clear(); episodeList.clear();
detailCover.src = entry.thumbnailUrl ?? ''; detailCover.src = entry.thumbnailUrl ?? '';
setStatus(`Loading ${entry.title}`);
try { // Details and episodes are independent bridge calls. A source whose
const [details, episodes] = await Promise.all([ // 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.getDetails(entry.url, entry.sourceId),
api.getEpisodes(entry.url, entry.sourceId), api.getEpisodes(entry.url, entry.sourceId),
]); ]);
if (!requests.isCurrent(request)) return; if (!requests.isCurrent(request)) return;
detailTitle.textContent = details.title; if (episodesResult.status === 'rejected') {
detailDescription.textContent = details.description ?? 'No description from this source.'; detailDescription.textContent = '';
if (details.thumbnailUrl) detailCover.src = details.thumbnailUrl; setStatus(describe(episodesResult.reason), 'error');
return;
}
const episodes = episodesResult.value;
const chips: HTMLSpanElement[] = []; const chips: HTMLSpanElement[] = [];
const source = document.createElement('span'); const source = document.createElement('span');
source.className = 'chip source'; source.className = 'chip source';
source.textContent = entry.sourceName; source.textContent = entry.sourceName;
chips.push(source); 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') { if (details.status !== 'unknown') {
const status = document.createElement('span'); const status = document.createElement('span');
status.className = 'chip status'; status.className = 'chip status';
@@ -75,11 +96,6 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
episodeList.render(episodes); episodeList.render(episodes);
setStatus(`${details.title} · ${episodes.length} episodes`); setStatus(`${details.title} · ${episodes.length} episodes`);
} catch (error) {
if (!requests.isCurrent(request)) return;
detailDescription.textContent = '';
setStatus(describe(error), 'error');
}
} }
function close(): void { function close(): void {
+67
View File
@@ -419,6 +419,73 @@
color: var(--ok); 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 ---------- */ /* ---------- scrollbars ---------- */
::-webkit-scrollbar { ::-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, sourceOptionLabel,
summarizeSearch, summarizeSearch,
describeBridgeInstall, describeBridgeInstall,
describeBridgeUpdate,
} from './format'; } from './format';
import type { AnimeBrowserSearchResult } from '../types/anime-browser'; import type { AnimeBrowserBridgeState, AnimeBrowserSearchResult } from '../types/anime-browser';
const result = ( const result = (
entryCount: number, 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.match(description, /unknown version.*checks this installation for updates after startup/);
assert.doesNotMatch(description, /up to date/); 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 { import type {
AnimeBrowserBridgeInstall, AnimeBrowserBridgeInstall,
AnimeBrowserBridgeState,
AnimeBrowserSearchResult, AnimeBrowserSearchResult,
AnimeBrowserSource, AnimeBrowserSource,
InstalledExtensionView, 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.`; 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> </main>
<footer class="statusbar"> <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> </footer>
<script type="module" src="./animeui.js"></script> <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'))); 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(); const { calls, options } = fakeUpstream();
assert.equal(await findBridgeUpdate({ origin: 'managed', version: 'v1.0.6.0' }, options), LATEST); 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(await findBridgeUpdate({ origin: 'managed', version: null }, options), LATEST);
assert.equal(calls.length, 4); 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), LATEST);
assert.equal(await findBridgeUpdate({ origin: 'system', version: 'v1.0.0.0' }, options), null); assert.equal(await findBridgeUpdate({ origin: 'system', version: LATEST }, options), null);
assert.equal(calls.length, 4); 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 () => { 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 * The newest release an install could move to, or null when it is current.
* current or is not SubMiner's to update. An install whose version cannot be * An unreadable managed install is offered the newest release so it can be
* read is offered the newest release: re-downloading is the way back to a * repaired; an unreadable system install cannot be compared. Network errors
* known state. Network errors propagate; the caller decides how loudly. * propagate; the caller decides how loudly.
*/ */
export async function findBridgeUpdate( export async function findBridgeUpdate(
install: Pick<AnimeBrowserBridgeInstall, 'origin' | 'version'>, install: Pick<AnimeBrowserBridgeInstall, 'origin' | 'version'>,
options: BridgeReleaseOptions = {}, options: BridgeReleaseOptions = {},
): Promise<string | null> { ): Promise<string | null> {
if (install.origin !== 'managed') return null; if (install.origin === 'system' && install.version === null) return null;
const latest = await locateLatestBundle(options); const latest = await locateLatestBundle(options);
if (install.version === null) return latest.tagName; if (install.version === null) return latest.tagName;
return compareBundleVersions(latest.tagName, install.version) > 0 ? latest.tagName : null; 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))); 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; let asked = 0;
const { runtime } = await setup({ let staged = false;
const { runtime, states, stopped } = await setup({
ensureBinaries: async () => ({ ...OLD, origin: 'system' }), ensureBinaries: async () => ({ ...OLD, origin: 'system' }),
checkBridgeUpdate: async () => { checkBridgeUpdate: async () => {
asked += 1; asked += 1;
return LATEST; return LATEST;
}, },
stageBridgeUpdate: async () => {
staged = true;
throw new Error('must not stage a system bridge update');
},
}); });
await runtime.ensureBridge(); await runtime.ensureBridge();
await tick(); await tick();
assert.equal(asked, 0); assert.equal(asked, 1);
assert.equal(runtime.getSnapshot().bridge.install?.updateAvailable, null); 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 () => { 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 * 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. * in `install.updateAvailable` and is re-broadcast on the current state.
*/ */
async function checkForBridgeUpdate(handle: SidecarHandle): Promise<void> { async function checkForBridgeUpdate(handle: SidecarHandle): Promise<void> {
if (install === null || install.origin !== 'managed') return; if (install === null) return;
try { try {
const latest = await deps.checkBridgeUpdate(install); const latest = await deps.checkBridgeUpdate(install);
// The bridge may have been restarted or updated while we waited. // The bridge may have been restarted or updated while we waited.
+35 -13
View File
@@ -67,8 +67,24 @@ test('on will quit cleanup handler runs all cleanup steps', async () => {
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket')); 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 () => { 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 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({ const cleanup = createOnWillQuitCleanupHandler({
destroyTray: () => {}, destroyTray: () => {},
stopConfigHotReload: () => {}, stopConfigHotReload: () => {},
@@ -77,7 +93,10 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
unregisterAllGlobalShortcuts: () => {}, unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {}, stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {}, stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {}, stopSyncAutoScheduler: async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
recordCleanup('stop-sync-auto-scheduler');
},
clearWindowsVisibleOverlayForegroundPollLoop: () => {}, clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {}, clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
destroyMainOverlayWindow: () => {}, destroyMainOverlayWindow: () => {},
@@ -99,25 +118,28 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
clearFirstRunSetupWindow: () => {}, clearFirstRunSetupWindow: () => {},
destroyYomitanSettingsWindow: () => {}, destroyYomitanSettingsWindow: () => {},
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => { stopJellyfinRemoteSession: () => recordCleanup('stop-jellyfin-remote'),
calls.push('stop-jellyfin-remote'); cleanupInternalSubtitleTrackCache: () => recordCleanup('cleanup-internal-subtitles'),
throw new Error('stop failed'); cleanupYoutubeSubtitleTempDirs: () => recordCleanup('cleanup-youtube-subtitles'),
}, cleanupYoutubeMediaCache: () => recordCleanup('cleanup-youtube-media'),
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'), cleanupRemoteMediaWindows: () => recordCleanup('cleanup-remote-media-windows'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), cleanupJellyfinSubtitleCache: () => recordCleanup('cleanup-jellyfin-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'), stopDiscordPresenceService: () => recordCleanup('stop-discord-presence'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
}); });
await assert.rejects(cleanup(), /stop failed/); await assert.rejects(cleanup(), (error) => error === firstError);
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'stop-jellyfin-remote', 'stop-jellyfin-remote',
'cleanup-jellyfin-subtitles', 'cleanup-jellyfin-subtitles',
'cleanup-internal-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', () => { test('should restore windows on activate requires initialized runtime and no windows', () => {
let initialized = false; let initialized = false;
+21 -11
View File
@@ -37,6 +37,7 @@ export function createOnWillQuitCleanupHandler(deps: {
stopDiscordPresenceService: () => void; stopDiscordPresenceService: () => void;
}) { }) {
return async (): Promise<void> => { return async (): Promise<void> => {
const cleanupErrors: unknown[] = [];
deps.destroyTray(); deps.destroyTray();
deps.stopConfigHotReload(); deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility(); deps.restorePreviousSecondarySubVisibility();
@@ -44,7 +45,11 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts(); deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket(); deps.stopSubtitleWebsocket();
deps.stopTexthookerService(); deps.stopTexthookerService();
const stopSyncAutoScheduler = deps.stopSyncAutoScheduler(); const stopSyncAutoScheduler = Promise.resolve(deps.stopSyncAutoScheduler()).catch(
(error: unknown) => {
cleanupErrors.push(error);
},
);
deps.clearWindowsVisibleOverlayForegroundPollLoop(); deps.clearWindowsVisibleOverlayForegroundPollLoop();
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts(); deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
deps.destroyMainOverlayWindow(); deps.destroyMainOverlayWindow();
@@ -66,20 +71,25 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.clearFirstRunSetupWindow(); deps.clearFirstRunSetupWindow();
deps.destroyYomitanSettingsWindow(); deps.destroyYomitanSettingsWindow();
deps.clearYomitanSettingsWindow(); deps.clearYomitanSettingsWindow();
const runCleanup = (cleanup: () => void): void => {
try { try {
deps.stopJellyfinRemoteSession(); cleanup();
} finally { } catch (error) {
cleanupErrors.push(error);
}
};
try { try {
deps.cleanupJellyfinSubtitleCache(); runCleanup(deps.stopJellyfinRemoteSession);
runCleanup(deps.cleanupJellyfinSubtitleCache);
runCleanup(deps.cleanupInternalSubtitleTrackCache);
} finally { } finally {
deps.cleanupInternalSubtitleTrackCache(); runCleanup(deps.cleanupYoutubeSubtitleTempDirs);
} runCleanup(deps.cleanupYoutubeMediaCache);
} runCleanup(deps.cleanupRemoteMediaWindows);
deps.cleanupYoutubeSubtitleTempDirs(); runCleanup(deps.stopDiscordPresenceService);
deps.cleanupYoutubeMediaCache();
deps.cleanupRemoteMediaWindows();
deps.stopDiscordPresenceService();
await stopSyncAutoScheduler; await stopSyncAutoScheduler;
}
if (cleanupErrors.length > 0) throw cleanupErrors[0];
}; };
} }
+59 -2
View File
@@ -123,6 +123,7 @@ interface ModalHarness {
function createModalHarness( function createModalHarness(
files: TsukihimeSubtitleFile[], files: TsukihimeSubtitleFile[],
options: { options: {
getMediaInfo?: ElectronAPI['getJimakuMediaInfo'];
secondaryLanguages?: string[]; secondaryLanguages?: string[];
secondaryLanguagesGate?: Promise<void>; secondaryLanguagesGate?: Promise<void>;
downloadFile?: (query: unknown) => Promise<unknown>; downloadFile?: (query: unknown) => Promise<unknown>;
@@ -153,14 +154,16 @@ function createModalHarness(
options.listFiles ? options.listFiles(entryId) : { ok: true, data: [] }, options.listFiles ? options.listFiles(entryId) : { ok: true, data: [] },
tsukihimeSearchEntries: async (query: unknown) => tsukihimeSearchEntries: async (query: unknown) =>
options.searchEntries ? options.searchEntries(query) : { ok: true, data: [] }, options.searchEntries ? options.searchEntries(query) : { ok: true, data: [] },
getJimakuMediaInfo: async () => ({ getJimakuMediaInfo:
options.getMediaInfo ??
(async () => ({
title: '', title: '',
season: null, season: null,
episode: null, episode: null,
confidence: 'low', confidence: 'low',
filename: '', filename: '',
rawTitle: '', rawTitle: '',
}), })),
notifyOverlayModalClosed: (modal: string) => { notifyOverlayModalClosed: (modal: string) => {
modalCloseNotifications.push(modal); modalCloseNotifications.push(modal);
}, },
@@ -823,3 +826,57 @@ test('a search from a prior modal session cannot repopulate a reopened modal', a
harness.restoreGlobals(); 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(); secondaryLanguagesReady = loadSecondaryLanguages();
const searchToken = activeSearchToken;
window.electronAPI window.electronAPI
.getJimakuMediaInfo() .getJimakuMediaInfo()
.then((info: JimakuMediaInfo) => { .then((info: JimakuMediaInfo) => {
if (searchToken !== activeSearchToken || !ctx.state.tsukihimeModalOpen) return;
ctx.dom.tsukihimeTitleInput.value = info.title || ''; ctx.dom.tsukihimeTitleInput.value = info.title || '';
ctx.dom.tsukihimeSeasonInput.value = info.season ? String(info.season) : ''; ctx.dom.tsukihimeSeasonInput.value = info.season ? String(info.season) : '';
ctx.dom.tsukihimeEpisodeInput.value = info.episode ? String(info.episode) : ''; ctx.dom.tsukihimeEpisodeInput.value = info.episode ? String(info.episode) : '';
@@ -474,6 +476,7 @@ export function createTsukihimeModal(
} }
}) })
.catch(() => { .catch(() => {
if (searchToken !== activeSearchToken || !ctx.state.tsukihimeModalOpen) return;
setTsukihimeStatus('Failed to load media info.', true); setTsukihimeStatus('Failed to load media info.', true);
}); });
} }
+2 -1
View File
@@ -147,8 +147,9 @@ export interface AnimeBrowserBridgeInstall {
dir: string; dir: string;
/** /**
* The newest upstream release with a bundle for this platform, when it is * 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. * 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; updateAvailable: string | null;
} }