feat(anime): support shared bridge installs and updates

- Prefer configured or package-managed bridge bundles before downloading
- Select compatible upstream releases and offer managed-bundle updates
- Document bridge configuration and package dependencies
This commit is contained in:
2026-09-01 23:14:16 -07:00
parent 1d1575f414
commit 2bcb6c7e98
33 changed files with 1136 additions and 229 deletions
+46 -13
View File
@@ -57,6 +57,7 @@ const banner = el<HTMLDivElement>('bridge-banner');
const bannerMessage = el<HTMLSpanElement>('bridge-message');
const bannerMeter = el<HTMLSpanElement>('bridge-meter');
const bannerMeterFill = el<HTMLElement>('bridge-meter-fill');
const bannerUpdate = el<HTMLButtonElement>('bridge-update');
const statusMessage = el<HTMLSpanElement>('status-message');
const browseTab = el<HTMLButtonElement>('tab-browse');
const extensionsTab = el<HTMLButtonElement>('tab-extensions');
@@ -121,31 +122,33 @@ const BRIDGE_LABELS: Record<AnimeBrowserBridgeState['stage'], string> = {
idle: 'Starting the extension bridge',
locating: 'Looking up the extension bridge release',
downloading: 'Downloading the extension bridge',
verifying: 'Verifying the download',
extracting: 'Unpacking the extension bridge',
starting: 'Starting the extension bridge',
ready: 'Bridge ready',
failed: 'Bridge failed to start',
};
const BUSY_STAGES = new Set([
'idle',
'locating',
'downloading',
'verifying',
'extracting',
'starting',
]);
const BUSY_STAGES = new Set(['idle', 'locating', 'downloading', 'extracting', 'starting']);
function renderBridgeState(state: AnimeBrowserBridgeState): void {
const busy = BUSY_STAGES.has(state.stage);
banner.dataset.stage = state.stage;
banner.dataset.busy = String(busy);
// 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;
// Once ready with nothing to report, the banner has nothing to say.
const hide = state.stage === 'ready' && state.message === null;
const hide = state.stage === 'ready' && state.message === null && update === null;
banner.classList.toggle('hidden', hide);
bannerMessage.textContent = state.message ?? BRIDGE_LABELS[state.stage];
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;
const showMeter = state.progress !== null;
bannerMeter.classList.toggle('hidden', !showMeter);
@@ -438,6 +441,31 @@ sourceSelect.addEventListener('change', () => {
loadMoreButton.addEventListener('click', () => void loadNextPage());
bannerUpdate.addEventListener('click', () => {
void (async () => {
bannerUpdate.disabled = true;
try {
const state = await api.updateBridge();
renderBridgeState(state);
if (state.stage === 'ready' && state.install?.updateAvailable === null) {
setStatus(
`Extension bridge updated to ${state.install.version ?? 'the pinned release'}.`,
'ok',
);
} else if (state.message !== null) {
setStatus(state.message, 'error');
}
// The bridge restarted, so the source list is fresh from disk.
const snapshot = await api.getSnapshot();
renderSources(snapshot.sources, snapshot.selectedSourceId);
if (currentView === 'extensions') await extensions.refresh();
} catch (error) {
setStatus(describe(error), 'error');
bannerUpdate.disabled = false;
}
})();
});
api.onBridgeState(renderBridgeState);
// The queue changes without this window asking: it advances by itself when an
@@ -450,7 +478,7 @@ api.onPlaybackState((state) => {
});
void (async () => {
renderBridgeState({ stage: 'idle', progress: null, message: null });
renderBridgeState({ stage: 'idle', progress: null, message: null, install: null });
// A queue survives the window being closed and reopened, so start from what
// the main process already holds rather than from empty.
void api.getQueue().then(
@@ -485,7 +513,12 @@ void (async () => {
} catch (error) {
// Without this the window keeps the "starting" banner up forever, with the
// search box disabled and nothing saying why.
renderBridgeState({ stage: 'failed', progress: null, message: describe(error) });
renderBridgeState({
stage: 'failed',
progress: null,
message: describe(error),
install: null,
});
setStatus(describe(error), 'error');
}
})();
+3 -1
View File
@@ -1,6 +1,6 @@
import { describe, el } from './dom';
import { buildIconIndex, iconMonogram, isSafeIconUrl, repoFaviconUrl } from './extension-icons';
import { describeInstalled } from './format';
import { describeBridgeInstall, describeInstalled } from './format';
import {
collectLanguages,
filterByLanguage,
@@ -121,6 +121,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
const { api, setStatus, onSourcesChanged } = options;
const extensionsDirLabel = el<HTMLSpanElement>('extensions-dir');
const bridgeInfo = el<HTMLParagraphElement>('bridge-info');
const installedList = el<HTMLDivElement>('installed-list');
const installedCount = el<HTMLSpanElement>('installed-count');
const availableList = el<HTMLDivElement>('extensions-list');
@@ -328,6 +329,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
async function refresh(): Promise<void> {
const snapshot = await api.getSnapshot();
extensionsDirLabel.textContent = snapshot.extensionsDir;
bridgeInfo.textContent = describeBridgeInstall(snapshot.bridge.install);
renderRepos(snapshot.repos);
repoFailures = [];
+32 -1
View File
@@ -1,6 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { describeInstalled, sourceOptionLabel, summarizeSearch } from './format';
import {
describeInstalled,
sourceOptionLabel,
summarizeSearch,
describeBridgeInstall,
} from './format';
import type { AnimeBrowserSearchResult } from '../types/anime-browser';
const result = (
@@ -58,3 +63,29 @@ test('describeInstalled falls back to the package alone when nothing loaded', ()
'broken',
);
});
test('describeBridgeInstall says who updates the bridge', () => {
assert.match(describeBridgeInstall(null), /not started/);
assert.match(
describeBridgeInstall({
origin: 'system',
version: 'v1.0.6.2',
dir: '/usr/share/mangatan/extension_server',
updateAvailable: null,
}),
/v1\.0\.6\.2 from \/usr\/share\/mangatan\/extension_server.*package manager/,
);
assert.match(
describeBridgeInstall({
origin: 'managed',
version: 'v1.0.5.0',
dir: '/home/u/.config/SubMiner/anime-bridge',
updateAvailable: 'v1.0.6.0',
}),
/v1\.0\.6\.0 is available/,
);
assert.match(
describeBridgeInstall({ origin: 'managed', version: null, dir: '/d', updateAvailable: null }),
/unknown version.*up to date/,
);
});
+14
View File
@@ -1,4 +1,5 @@
import type {
AnimeBrowserBridgeInstall,
AnimeBrowserSearchResult,
AnimeBrowserSource,
InstalledExtensionView,
@@ -33,3 +34,16 @@ export function describeInstalled(view: InstalledExtensionView): string {
if (view.langs.length > 0) parts.push(view.langs.join(', '));
return parts.join(' · ');
}
/** One line for the Extensions tab: which bridge is running and who updates it. */
export function describeBridgeInstall(install: AnimeBrowserBridgeInstall | null): string {
if (install === null) return 'The extension bridge has not started yet.';
const version = install.version ?? 'unknown version';
if (install.origin === 'system') {
return `M-Extension-Server ${version} from ${install.dir}, installed outside SubMiner (for example by your package manager), which is where updates come from.`;
}
if (install.updateAvailable !== null) {
return `M-Extension-Server ${version} in ${install.dir}, downloaded by SubMiner. ${install.updateAvailable} is available from the banner above.`;
}
return `M-Extension-Server ${version} in ${install.dir}, downloaded by SubMiner and up to date.`;
}
+9
View File
@@ -73,6 +73,12 @@
<span class="bridge-dot" id="bridge-dot"></span>
<span class="bridge-message" id="bridge-message"></span>
<span class="bridge-meter hidden" id="bridge-meter"><i id="bridge-meter-fill"></i></span>
<button
class="ghost-button bridge-update hidden"
id="bridge-update"
type="button"
title="Downloads the verified release and restarts the bridge. A playing episode's stream stops."
></button>
</div>
<section class="settings hidden" id="settings" role="tabpanel" aria-labelledby="tab-settings">
@@ -122,6 +128,9 @@
</h3>
<div class="lang-filter" id="lang-filter" role="group" aria-label="Filter by language"></div>
<div class="ext-list" id="extensions-list" aria-label="Available extensions"></div>
<h3 class="ext-group-title">Bridge</h3>
<p class="repo-hint" id="bridge-info"></p>
</section>
<main class="layout" id="layout" role="tabpanel" aria-labelledby="tab-browse">
+7
View File
@@ -304,6 +304,13 @@ body {
transition: width 0.2s ease;
}
.bridge-update {
margin-left: auto;
padding: 4px 12px;
border-radius: 8px;
font-size: 12px;
}
/* ---------- layout ---------- */
.layout {