mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
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:
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, '');
|
||||
});
|
||||
@@ -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 extension’s 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: '' };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user