diff --git a/changes/anime-browser.md b/changes/anime-browser.md index 25ac230e..7180a9f6 100644 --- a/changes/anime-browser.md +++ b/changes/anime-browser.md @@ -2,6 +2,7 @@ type: added 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. +- 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. - Anime extensions are read from `/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. @@ -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. - 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. -- 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. diff --git a/changes/fix-cleanup-and-tsukihime-callbacks.md b/changes/fix-cleanup-and-tsukihime-callbacks.md new file mode 100644 index 00000000..ba5c5af0 --- /dev/null +++ b/changes/fix-cleanup-and-tsukihime-callbacks.md @@ -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. diff --git a/docs-site/anime-browser.md b/docs-site/anime-browser.md index 9c31c5da..1aa9daf3 100644 --- a/docs-site/anime-browser.md +++ b/docs-site/anime-browser.md @@ -273,10 +273,20 @@ use, its version, and who updates it. Only the copy SubMiner downloaded is ever updated by SubMiner. A package-manager install or an `anime.bridgeDir` belongs to whoever put it there. -SubMiner records the release it installed and, once the bridge is running, -asks GitHub for the newest one. When upstream has published a newer release, -the banner reads "Extension bridge v… is installed; v… is available" with an -**Update to v…** button. Clicking it downloads the new release beside the +Once the bridge is running, SubMiner asks GitHub for the newest release and +compares it with the installed version, including package-manager installs and +custom bridge directories. When upstream has published a newer release, 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, 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 @@ -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 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 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 diff --git a/src/anime-bridge/bridge-client.test.ts b/src/anime-bridge/bridge-client.test.ts index 9f0da069..5eb7ef4e 100644 --- a/src/anime-bridge/bridge-client.test.ts +++ b/src/anime-bridge/bridge-client.test.ts @@ -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 ['Proxy error', '', '{"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 }); diff --git a/src/anime-bridge/bridge-client.ts b/src/anime-bridge/bridge-client.ts index c8ba6e20..f0d5b0fe 100644 --- a/src/anime-bridge/bridge-client.ts +++ b/src/anime-bridge/bridge-client.ts @@ -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; +} diff --git a/src/animeui/animeui.ts b/src/animeui/animeui.ts index d1e8336a..c08f5c9f 100644 --- a/src/animeui/animeui.ts +++ b/src/animeui/animeui.ts @@ -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('bridge-message'); const bannerMeter = el('bridge-meter'); const bannerMeterFill = el('bridge-meter-fill'); const bannerUpdate = el('bridge-update'); -const statusMessage = el('status-message'); const browseTab = el('tab-browse'); const extensionsTab = el('tab-extensions'); const settingsTab = el('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)); } }); diff --git a/src/animeui/detail-panel.ts b/src/animeui/detail-panel.ts index 91f27e78..73ededb4 100644 --- a/src/animeui/detail-panel.ts +++ b/src/animeui/detail-panel.ts @@ -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 { diff --git a/src/animeui/detail.css b/src/animeui/detail.css index 8d83236f..3e66de62 100644 --- a/src/animeui/detail.css +++ b/src/animeui/detail.css @@ -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 { diff --git a/src/animeui/error-message.test.ts b/src/animeui/error-message.test.ts new file mode 100644 index 00000000..e1e99855 --- /dev/null +++ b/src/animeui/error-message.test.ts @@ -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, ''); +}); diff --git a/src/animeui/error-message.ts b/src/animeui/error-message.ts new file mode 100644 index 00000000..52b875dd --- /dev/null +++ b/src/animeui/error-message.ts @@ -0,0 +1,71 @@ +export interface AnimeBrowserErrorMessage { + title: string; + explanation: string; + guidance: string; + details: string; +} + +const OPERATIONS: Record = { + 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: '' }; +} diff --git a/src/animeui/format.test.ts b/src/animeui/format.test.ts index 1ee24fd9..52cac89d 100644 --- a/src/animeui/format.test.ts +++ b/src/animeui/format.test.ts @@ -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, + ); +}); diff --git a/src/animeui/format.ts b/src/animeui/format.ts index 3984a704..04f2b0ce 100644 --- a/src/animeui/format.ts +++ b/src/animeui/format.ts @@ -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 }; +} diff --git a/src/animeui/index.html b/src/animeui/index.html index 1b564cd4..30c5316e 100644 --- a/src/animeui/index.html +++ b/src/animeui/index.html @@ -192,7 +192,32 @@
- + +
diff --git a/src/animeui/status-panel.ts b/src/animeui/status-panel.ts new file mode 100644 index 00000000..7c263164 --- /dev/null +++ b/src/animeui/status-panel.ts @@ -0,0 +1,42 @@ +import { el } from './dom'; +import { describeAnimeBrowserError } from './error-message'; + +export function createStatusPanel() { + const status = el('status-message'); + const panel = el('request-error'); + const title = el('request-error-title'); + const explanation = el('request-error-explanation'); + const guidance = el('request-error-guidance'); + const disclosure = el('request-error-details'); + const technical = el('request-error-technical'); + const dismiss = el('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('.tab[aria-selected="true"]')?.focus(); + }); + + return { setStatus }; +} diff --git a/src/main/runtime/anime-bridge-installer.test.ts b/src/main/runtime/anime-bridge-installer.test.ts index 79ae5ce3..f9c8b601 100644 --- a/src/main/runtime/anime-bridge-installer.test.ts +++ b/src/main/runtime/anime-bridge-installer.test.ts @@ -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 () => { diff --git a/src/main/runtime/anime-bridge-installer.ts b/src/main/runtime/anime-bridge-installer.ts index f3584e5f..f7d9ff40 100644 --- a/src/main/runtime/anime-bridge-installer.ts +++ b/src/main/runtime/anime-bridge-installer.ts @@ -178,16 +178,16 @@ async function locateLatestBundle(options: BridgeReleaseOptions): Promise, options: BridgeReleaseOptions = {}, ): Promise { - 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; diff --git a/src/main/runtime/anime-browser-runtime-bridge-update.test.ts b/src/main/runtime/anime-browser-runtime-bridge-update.test.ts index bbe31136..53260206 100644 --- a/src/main/runtime/anime-browser-runtime-bridge-update.test.ts +++ b/src/main/runtime/anime-browser-runtime-bridge-update.test.ts @@ -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 () => { diff --git a/src/main/runtime/anime-browser-runtime.ts b/src/main/runtime/anime-browser-runtime.ts index a4354625..2ea48113 100644 --- a/src/main/runtime/anime-browser-runtime.ts +++ b/src/main/runtime/anime-browser-runtime.ts @@ -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 { - 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. diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts index cc391bad..7a879947 100644 --- a/src/main/runtime/app-lifecycle-actions.test.ts +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -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((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; diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts index e436483c..182d9f3d 100644 --- a/src/main/runtime/app-lifecycle-actions.ts +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -37,6 +37,7 @@ export function createOnWillQuitCleanupHandler(deps: { stopDiscordPresenceService: () => void; }) { return async (): Promise => { + 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]; }; } diff --git a/src/renderer/modals/tsukihime.test.ts b/src/renderer/modals/tsukihime.test.ts index 36214e2c..856ef4da 100644 --- a/src/renderer/modals/tsukihime.test.ts +++ b/src/renderer/modals/tsukihime.test.ts @@ -123,6 +123,7 @@ interface ModalHarness { function createModalHarness( files: TsukihimeSubtitleFile[], options: { + getMediaInfo?: ElectronAPI['getJimakuMediaInfo']; secondaryLanguages?: string[]; secondaryLanguagesGate?: Promise; downloadFile?: (query: unknown) => Promise; @@ -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>; + let resolveInfo!: (info: MediaInfo) => void; + let rejectInfo!: (error: Error) => void; + const pendingInfo = new Promise((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(); + } + }); + } +} diff --git a/src/renderer/modals/tsukihime.ts b/src/renderer/modals/tsukihime.ts index cff805fc..a9fb40c7 100644 --- a/src/renderer/modals/tsukihime.ts +++ b/src/renderer/modals/tsukihime.ts @@ -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); }); } diff --git a/src/types/anime-browser.ts b/src/types/anime-browser.ts index bddeb335..4dc98f3e 100644 --- a/src/types/anime-browser.ts +++ b/src/types/anime-browser.ts @@ -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; }