mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-18 05:16:21 -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:
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
@@ -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));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+50
-34
@@ -42,44 +42,60 @@ 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
|
||||||
api.getDetails(entry.url, entry.sourceId),
|
// flaky page) can still list episodes, so a details failure only costs the
|
||||||
api.getEpisodes(entry.url, entry.sourceId),
|
// description and chips, not the episode list.
|
||||||
]);
|
const [detailsResult, episodesResult] = await Promise.allSettled([
|
||||||
if (!requests.isCurrent(request)) return;
|
api.getDetails(entry.url, entry.sourceId),
|
||||||
|
api.getEpisodes(entry.url, entry.sourceId),
|
||||||
|
]);
|
||||||
|
if (!requests.isCurrent(request)) return;
|
||||||
|
|
||||||
detailTitle.textContent = details.title;
|
if (episodesResult.status === 'rejected') {
|
||||||
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;
|
|
||||||
detailDescription.textContent = '';
|
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 {
|
function close(): void {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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'));
|
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 [
|
||||||
const calls: string[] = [];
|
'stop-jellyfin-remote',
|
||||||
const cleanup = createOnWillQuitCleanupHandler({
|
'cleanup-jellyfin-subtitles',
|
||||||
destroyTray: () => {},
|
'cleanup-internal-subtitles',
|
||||||
stopConfigHotReload: () => {},
|
'cleanup-youtube-subtitles',
|
||||||
restorePreviousSecondarySubVisibility: () => {},
|
'cleanup-youtube-media',
|
||||||
restoreMpvSubVisibility: () => {},
|
'cleanup-remote-media-windows',
|
||||||
unregisterAllGlobalShortcuts: () => {},
|
'stop-discord-presence',
|
||||||
stopSubtitleWebsocket: () => {},
|
'stop-sync-auto-scheduler',
|
||||||
stopTexthookerService: () => {},
|
]) {
|
||||||
stopSyncAutoScheduler: () => {},
|
test(`on will quit cleanup finishes every independent step after ${failedStep} fails`, async () => {
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
const calls: string[] = [];
|
||||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
const firstError = new Error(`${failedStep} failed`);
|
||||||
destroyMainOverlayWindow: () => {},
|
const recordCleanup = (step: string): void => {
|
||||||
destroyModalOverlayWindow: () => {},
|
calls.push(step);
|
||||||
destroyYomitanParserWindow: () => {},
|
if (step === failedStep) throw firstError;
|
||||||
clearYomitanParserState: () => {},
|
if (calls.includes(failedStep)) throw new Error(`${step} also failed`);
|
||||||
stopWindowTracker: () => {},
|
};
|
||||||
flushMpvLog: () => {},
|
const cleanup = createOnWillQuitCleanupHandler({
|
||||||
destroyMpvSocket: () => {},
|
destroyTray: () => {},
|
||||||
clearReconnectTimer: () => {},
|
stopConfigHotReload: () => {},
|
||||||
destroySubtitleTimingTracker: () => {},
|
restorePreviousSecondarySubVisibility: () => {},
|
||||||
destroyImmersionTracker: () => {},
|
restoreMpvSubVisibility: () => {},
|
||||||
destroyAnkiIntegration: () => {},
|
unregisterAllGlobalShortcuts: () => {},
|
||||||
destroyAnilistSetupWindow: () => {},
|
stopSubtitleWebsocket: () => {},
|
||||||
clearAnilistSetupWindow: () => {},
|
stopTexthookerService: () => {},
|
||||||
destroyJellyfinSetupWindow: () => {},
|
stopSyncAutoScheduler: async () => {
|
||||||
clearJellyfinSetupWindow: () => {},
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
destroyFirstRunSetupWindow: () => {},
|
recordCleanup('stop-sync-auto-scheduler');
|
||||||
clearFirstRunSetupWindow: () => {},
|
},
|
||||||
destroyYomitanSettingsWindow: () => {},
|
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||||
clearYomitanSettingsWindow: () => {},
|
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
||||||
stopJellyfinRemoteSession: () => {
|
destroyMainOverlayWindow: () => {},
|
||||||
calls.push('stop-jellyfin-remote');
|
destroyModalOverlayWindow: () => {},
|
||||||
throw new Error('stop failed');
|
destroyYomitanParserWindow: () => {},
|
||||||
},
|
clearYomitanParserState: () => {},
|
||||||
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
|
stopWindowTracker: () => {},
|
||||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
flushMpvLog: () => {},
|
||||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
destroyMpvSocket: () => {},
|
||||||
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
|
clearReconnectTimer: () => {},
|
||||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
destroySubtitleTimingTracker: () => {},
|
||||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
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/);
|
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;
|
||||||
|
|||||||
@@ -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();
|
||||||
try {
|
const runCleanup = (cleanup: () => void): void => {
|
||||||
deps.stopJellyfinRemoteSession();
|
|
||||||
} finally {
|
|
||||||
try {
|
try {
|
||||||
deps.cleanupJellyfinSubtitleCache();
|
cleanup();
|
||||||
} finally {
|
} catch (error) {
|
||||||
deps.cleanupInternalSubtitleTrackCache();
|
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();
|
if (cleanupErrors.length > 0) throw cleanupErrors[0];
|
||||||
deps.cleanupYoutubeMediaCache();
|
|
||||||
deps.cleanupRemoteMediaWindows();
|
|
||||||
deps.stopDiscordPresenceService();
|
|
||||||
await stopSyncAutoScheduler;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
title: '',
|
options.getMediaInfo ??
|
||||||
season: null,
|
(async () => ({
|
||||||
episode: null,
|
title: '',
|
||||||
confidence: 'low',
|
season: null,
|
||||||
filename: '',
|
episode: null,
|
||||||
rawTitle: '',
|
confidence: 'low',
|
||||||
}),
|
filename: '',
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user