Compare commits

...
12 Commits
Author SHA1 Message Date
sudacode 336e9fb8a5 fix(anime): cap outcome polling and upstream stalls by wall clock
- watchPlaybackOutcome now tracks a real deadline so slow property reads eat the timeout budget instead of extending it, and a zero probe interval still terminates
- stream strip proxy aborts upstream GETs that go silent for 15s instead of hanging mpv on that segment forever
- anime browser runtime drops a stream proxy that finishes starting after its bridge already died, instead of leaking a listener pointed at nothing
2026-08-01 03:13:48 -07:00
sudacode dec7851a17 feat(anime): cache stream subtitles to disk for the alass reference
- Download a stream's subtitle tracks to a temp dir and load them into mpv as files instead of streaming from the source URL, so Subsync can use them as the alass reference (mirrors the Jellyfin subtitle preload)
- Detect format from content first, falling back to the URL extension
- A track that fails to download falls back to its URL so the episode still plays
- Remove the temp dir when the next episode loads or the runtime disposes
2026-08-01 02:32:25 -07:00
sudacode 4eaaf5769f fix(anime): report a failed bridge kill instead of a generic timeout
- Track kill errors separately from exit/spawn so a signal failure (e.g. EPERM) surfaces its own message instead of a bare "did not exit after SIGKILL"
- Attach stop() failures as the cause on the readiness-timeout error so a surviving child's kill failure isn't dropped
- Skip stop() entirely when spawn itself failed, and stop polling once the ready deadline has passed
- Add stopTimeoutMs test hook and a test covering a kill that fails without the child exiting
2026-08-01 01:50:19 -07:00
sudacode 0dfba67ae8 style(animeui): tighten source picker and cue rail layout
- Lay source picker label + input inline instead of stacked
- Let detail cover keep natural aspect ratio instead of forced 2:3 crop
- Widen cue rail gutter to match new cue number column width
2026-08-01 01:38:54 -07:00
sudacode 18e0200934 fix(overlay): fall back to did-finish-load when ready-to-show never fires
A hidden overlay window stops producing frames once it is explicitly
hidden and then reloaded (the Yomitan content-script reload right after
startup), so ready-to-show never fires and the content-ready gate keeps
the window hidden forever behind the Overlay loading spinner. Mark
content ready from did-finish-load after a 1.5s grace period so the
overlay always becomes showable; ready-to-show still wins when it fires.
2026-08-01 01:31:48 -07:00
sudacode d7188a2155 fix(anime): restart the bridge on the next request after it dies
The reported 'APK crash' turned out not to exist: repeated full scans of
all installed extensions load cleanly. The real incident was the app's
quit sequence stopping the bridge (its /stop endpoint) while requests
were in flight - and it exposed that a bridge dying for any reason left
the anime browser failing every call with a raw 'fetch failed' until
the whole app was restarted.

SidecarHandle now reports the child's exit (including to late
subscribers), and the runtime detects a death it did not order: it
drops the dead handle, closes the strip proxy, tells the UI, and the
next request transparently starts a fresh bridge. requireBridge became
an async bridge() that starts on demand, which also covers first use.

Verified live: search -> SIGKILL the JVM -> search recovers.
2026-08-01 01:05:40 -07:00
sudacode 1c50ff74cb fix(anime): never forward Range to the bridge so 206 replies cannot skip the strip
ffmpeg opens every HLS segment with 'Range: bytes=0-'. The bridge
answers some of those 206 (depending on its cache state), and the proxy
only rewrites full 200 bodies, so the PNG disguise passed through
untouched and whether an episode played was a coin flip: first attempts
died with "no audio or video data played", the same URL played fine
once the bridge had the segments cached.

Reproduced end to end against a private bridge + mpv driven with the
app's exact commands; with Range dropped every segment is stripped and
playback runs.
2026-08-01 00:40:34 -07:00
sudacode c2781a3f80 fix(anime): retry cold upstream fetches in the stream strip proxy
Right after an episode resolves, the bridge (or the host behind it) can
error on the very first segment fetches and be fine a moment later; mpv
sweeps the playlist of failing segments in ~2s and gives up with "no
audio or video data played" while a manual reload of the same URL plays.
Retry a failed GET once after 400ms and log upstream error statuses so
the next failure names the real cause in the app log.
2026-08-01 00:24:26 -07:00
sudacode ff25e5cafa fix(anime): strip disguised HLS segments and report real playback errors
Some hosts prepend a fake image header (a real 1x1 PNG) to every HLS
segment; ffmpeg probes the segment as a picture and mpv drops back to
idle with no window while the browser claims the episode is playing.

- Route bridge-served m3u8 streams through a local strip proxy that
  scans each segment for the first genuine MPEG-TS packet run and drops
  the junk in front of it; playlists get absolute origins rewritten so
  segment requests come back through the proxy. Non-TS bodies pass
  through untouched.
- Only report ok from playEpisode once mpv configures a video output;
  an end-file with reason error surfaces mpv's own message (e.g. "no
  audio or video data played") in the browser status bar instead of
  "Playing". New end-file event plumbed through the mpv IPC client.
2026-08-01 00:05:31 -07:00
sudacode 7254db66ad fix(anime): pin linux-x64 bridge checksum and fetch release by tag
- Add PINNED_BUNDLE_SHA256 entry for linux-x64-bundle.zip so Linux no longer refuses to start with "No pinned checksum"
- Fetch the bridge release by its pinned tag (releases/tags/<tag>) instead of listing newest releases, so an upstream publish can't swap in an unverified asset
- Update docs and changelog to describe the pin/tag behavior
2026-07-31 23:41:52 -07:00
sudacode 3c19228b35 feat(anime): add language filter to extensions available list
- Add a language chip row above the Available list; pick one or more languages to narrow the repo index, or "All" to clear
- Show language display names (e.g. "Japanese") instead of raw tags, and an "N of M" count on the Available heading
- Add language-filter.ts with collectLanguages/toggleLanguage/pruneSelection/filterByLanguage/languageLabel, plus tests
2026-07-31 23:41:14 -07:00
sudacode 2e34f01adf fix(anime): honor the sidecar readiness deadline and confirm shutdown
The capabilities probe used a fixed 5s timeout, so a readiness budget
shorter than that could be overrun by one stalled request. Pass the
remaining deadline down instead.

stop() also resolved after the SIGKILL wait even when the child had not
exited, letting a restart race a process still holding the port. Throw
in that case, and keep a failed shutdown from masking the readiness
error at startup.
2026-07-31 18:07:32 -07:00
31 changed files with 2124 additions and 120 deletions

No files matched your search

+8
View File
@@ -2,6 +2,14 @@
## Unreleased
### Added
- Anime Browser Language Filter: The Extensions tab's available list now has a language chip row above it. Pick one or more languages to narrow a repository index that otherwise lists every language it knows, or "All" to clear the filter — selecting a language replaces "All" rather than sitting beside it. Extension rows name the language ("Japanese" instead of `ja`), and the Available heading shows how many of the offered extensions the filter leaves.
### Changed
- Anime Browser Subtitles: A stream's subtitle tracks are now downloaded to a temp directory and loaded into mpv as files instead of streamed from the source URL, so they can serve as the alass reference in Subsync (the same way Jellyfin subtitles do) — a streamed track had no file on disk and was rejected by the source picker. The format is detected from the file's own content, a track that fails to download falls back to its URL so the episode still plays, and the directory is removed when the next episode starts or the app exits.
### Fixed
- Anime Browser Window Switching: Opening the anime browser now shows a tray icon on every platform and — on macOS — puts the app in the Cmd+Tab switcher (which requires the Dock icon; the two are inseparable on macOS), so you can switch between it and mpv. Previously the subtitle overlay's fullscreen support hid the whole app from the Dock and Cmd+Tab, leaving no way to reach the window. The Dock icon is released again when the window closes during playback.
+7
View File
@@ -13,3 +13,10 @@ area: anime
- Added `anime.repos`, `anime.extensionsDir`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery.
- Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
- The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English` → `en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead.
- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments, so streams ffmpeg would otherwise probe as "a PNG" and abandon now play in mpv.
- The strip proxy retries a failed segment fetch once after a short pause and logs upstream error statuses; a host that errors on the very first fetches right after an episode resolves no longer kills the whole playback.
- The strip proxy no longer forwards `Range` headers to the bridge: ffmpeg opens every HLS segment with `Range: bytes=0-`, the bridge answers some of those with 206, and a partial response bypassed the disguise strip, so whether an episode played depended on the bridge's cache state.
- A bridge that dies out from under the app (killed, crashed, or stopped mid-operation) no longer leaves the browser failing every request until an app restart: the exit is detected, surfaced in the status bar, and the bridge restarts on the next request.
- "Playing" is only reported once mpv actually configures a video output; when a stream fails to decode, the browser shows mpv's error instead of claiming playback started while no window ever appeared.
- The Linux x64 bridge bundle is verified and pinned, so the anime browser starts on Linux instead of refusing with "No pinned checksum for linux-x64-bundle.zip".
- The bridge bundle is fetched from the pinned release tag rather than whatever release is newest, so an upstream publish no longer breaks every install with a checksum mismatch.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed the overlay never loading (stuck on the "Overlay loading" OSD spinner) when the Yomitan content-script reload raced overlay window creation at startup, most visible when playing from the anime browser on Linux/Wayland: a hidden window stops painting after that reload, so the ready-to-show signal that gates showing the overlay never fired. Content-ready now falls back to did-finish-load after a short grace period.
+24 -5
View File
@@ -129,9 +129,11 @@ credentials, it is written with owner-only permissions.
## The bridge
The first launch downloads a platform bundle (~130 MB) containing the server and
a matching Java runtime, so no system JDK is required. It is verified against a
pinned SHA-256 before running, unpacked into `<userData>/anime-bridge`, and
reused after that. Progress appears in the banner at the top of the window.
a matching Java runtime, so no system JDK is required. SubMiner pins one
upstream release tag and the SHA-256 of each asset it has verified; the download
is checked against that hash before anything runs, then unpacked into
`<userData>/anime-bridge` and reused after that. Progress appears in the banner
at the top of the window.
The bridge stays running while the window is open. Resolved video URLs point at
its own loopback proxy so the extension's cookies and headers apply, which means
@@ -142,8 +144,10 @@ Two known limits:
- There is no Android WebView, so extensions that need one (typically for
Cloudflare challenges) will fail with an error from the source.
- Bundles are published for macOS (arm64, x64), Linux (x64), and Windows (x64).
Other platforms are unsupported.
- Bundles are published for macOS (arm64, x64), Linux (x64), and Windows (x64),
but only the ones we have hashed ourselves will run: currently macOS arm64 and
Linux x64. The rest stop with "No pinned checksum for …" until a maintainer
verifies them. Other platforms are unsupported outright.
## Playback
@@ -151,6 +155,21 @@ Selecting an episode resolves the best available stream, applies the source's
required headers as mpv `http-header-fields`, and loads it. The headers are
readable back off mpv, so Anki card audio and screenshots fetch correctly too.
HLS streams are routed through a small local proxy before mpv sees them. Some
hosts disguise their video segments by prepending a fake image header (a real
1x1 PNG) so scrapers back off; Aniyomi's own player strips this, but ffmpeg
probes the segment as a picture and playback dies with "no audio or video data
played". The proxy scans each segment for the first genuine MPEG-TS packet run
and drops whatever junk sits in front of it. Segments that are not TS (fMP4,
subtitles, encryption keys) pass through untouched, and direct-file streams
skip the proxy entirely.
"Playing" in the status bar means playing: after handing mpv the stream,
SubMiner waits until mpv actually configures a video output before reporting
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).
### Japanese audio, and switching tracks
Sources often return a dub and the original audio as two separate entries — or
+14
View File
@@ -55,6 +55,20 @@ test('isReady requires every capability the client depends on', async () => {
assert.equal(await partial.isReady(), false);
});
test('isReady caps the probe at the deadline the caller passes', async () => {
// A bridge that accepts the socket and then stalls: only the abort ends it.
const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => {
await new Promise((resolve) => init?.signal?.addEventListener('abort', resolve));
throw new Error('aborted');
}) as typeof fetch;
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
const started = Date.now();
assert.equal(await client.isReady(50), false);
// Well under the 5s default, so the per-call deadline is what applied.
assert.ok(Date.now() - started < 1000, 'probe outlived the caller deadline');
});
test('isReady reports false instead of throwing when the bridge is down', async () => {
const client = new AnimeBridgeClient({
baseUrl: 'http://127.0.0.1:9',
+8 -4
View File
@@ -74,9 +74,13 @@ export class AnimeBridgeClient {
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
}
async getCapabilities(): Promise<BridgeCapabilities> {
/**
* `timeoutMs` lets a caller with its own deadline (the readiness loop) cap the
* probe below the default, so a short readiness budget is actually honored.
*/
async getCapabilities(timeoutMs = CAPABILITIES_TIMEOUT_MS): Promise<BridgeCapabilities> {
const response = await this.fetchImpl(`${this.baseUrl}/capabilities`, {
signal: AbortSignal.timeout(Math.min(CAPABILITIES_TIMEOUT_MS, this.requestTimeoutMs)),
signal: AbortSignal.timeout(Math.max(0, Math.min(timeoutMs, this.requestTimeoutMs))),
});
if (!response.ok) {
throw new Error(`Anime bridge capabilities check failed (${response.status}).`);
@@ -85,9 +89,9 @@ export class AnimeBridgeClient {
}
/** True once the bridge is up and reports the features this client needs. */
async isReady(): Promise<boolean> {
async isReady(timeoutMs?: number): Promise<boolean> {
try {
const capabilities = await this.getCapabilities();
const capabilities = await this.getCapabilities(timeoutMs);
return (
capabilities.mangatanMihonBridge === 1 &&
capabilities.sourceFactory === true &&
+27 -1
View File
@@ -1,8 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseAnimeStatus, resolveBridgeMediaUrl } from './media-url';
import { parseAnimeStatus, resolveBridgeMediaUrl, routeHlsThroughProxy } from './media-url';
const BRIDGE = 'http://127.0.0.1:56037';
const PROXY = 'http://127.0.0.1:60001';
test('a bridge m3u8 stream is routed through the strip proxy', () => {
assert.equal(
routeHlsThroughProxy(`${BRIDGE}/video/master.m3u8?q=1080`, BRIDGE, PROXY),
`${PROXY}/video/master.m3u8?q=1080`,
);
});
test('non-HLS bridge streams stay on the bridge', () => {
const direct = `${BRIDGE}/video/movie-token`;
assert.equal(routeHlsThroughProxy(direct, BRIDGE, PROXY), direct);
});
test('external m3u8 streams are not routed through the proxy', () => {
const remote = 'https://cdn.example.com/hls/master.m3u8';
assert.equal(routeHlsThroughProxy(remote, BRIDGE, PROXY), remote);
});
test('unparseable stream urls pass through routeHlsThroughProxy unchanged', () => {
assert.equal(routeHlsThroughProxy('not a url', BRIDGE, PROXY), 'not a url');
assert.equal(
routeHlsThroughProxy(`${BRIDGE}/video/a.m3u8`, 'garbage', PROXY),
`${BRIDGE}/video/a.m3u8`,
);
});
test('a loopback proxy url is rebased onto the live bridge port', () => {
assert.equal(
+24
View File
@@ -47,6 +47,30 @@ export function resolveBridgeMediaUrl(bridgeBaseUrl: string, mediaUrl: string):
return rebased.toString();
}
/**
* Send a bridge-served HLS stream through the local strip proxy instead. Only
* `.m3u8` URLs on the bridge origin qualify: direct files need no fixing, and
* an external URL would not resolve through a proxy that forwards to the
* bridge. Anything unparseable comes back unchanged.
*/
export function routeHlsThroughProxy(
streamUrl: string,
bridgeBaseUrl: string,
proxyOrigin: string,
): string {
let stream: URL;
let bridge: URL;
try {
stream = new URL(streamUrl);
bridge = new URL(bridgeBaseUrl);
} catch {
return streamUrl;
}
if (stream.origin !== bridge.origin) return streamUrl;
if (!stream.pathname.endsWith('.m3u8')) return streamUrl;
return `${proxyOrigin}${stream.pathname}${stream.search}`;
}
/** Aniyomi's SAnime status constants. */
export type AnimeStatus =
| 'unknown'
+133
View File
@@ -0,0 +1,133 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { watchPlaybackOutcome, type PlaybackEndFileEvent } from './playback-outcome';
type Harness = {
emitEndFile: (event: PlaybackEndFileEvent) => void;
listenerCount: () => number;
setProperty: (name: string, value: unknown) => void;
failProperty: (name: string) => void;
/** Virtual milliseconds burned so far, by sleeps and by property reads. */
elapsed: () => number;
};
/**
* The clock is virtual and only moves when the code under test sleeps (or,
* with `readCostMs`, when it reads a property), so timeout behaviour is
* asserted without any real waiting.
*/
function createHarness(overrides?: {
timeoutMs?: number;
probeIntervalMs?: number;
readCostMs?: number;
}) {
const listeners = new Set<(event: PlaybackEndFileEvent) => void>();
const properties = new Map<string, unknown>();
const failing = new Set<string>();
const readCostMs = overrides?.readCostMs ?? 0;
let clock = 0;
const watch = watchPlaybackOutcome({
onEndFile: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
readProperty: async (name) => {
clock += readCostMs;
if (failing.has(name)) throw new Error(`Failed to read MPV property '${name}'`);
return properties.get(name);
},
wait: async (ms) => {
clock += ms;
},
now: () => clock,
timeoutMs: overrides?.timeoutMs ?? 1000,
probeIntervalMs: overrides?.probeIntervalMs ?? 100,
});
const harness: Harness = {
emitEndFile: (event) => {
for (const listener of listeners) listener(event);
},
listenerCount: () => listeners.size,
setProperty: (name, value) => properties.set(name, value),
failProperty: (name) => failing.add(name),
elapsed: () => clock,
};
return { watch, harness };
}
test('resolves ok once mpv configures a video output', async () => {
const { watch, harness } = createHarness();
harness.setProperty('vo-configured', true);
const outcome = await watch.wait();
assert.deepEqual(outcome, { ok: true });
watch.dispose();
});
test('resolves failure with the mpv error when the file ends in error', async () => {
const { watch, harness } = createHarness();
const pending = watch.wait();
harness.emitEndFile({ reason: 'error', fileError: 'no audio or video data played' });
const outcome = await pending;
assert.equal(outcome.ok, false);
assert.ok(!outcome.ok && outcome.error.includes('no audio or video data played'));
watch.dispose();
});
test('ignores the end-file fired for the file being replaced', async () => {
const { watch, harness } = createHarness();
const pending = watch.wait();
harness.emitEndFile({ reason: 'stop', fileError: null });
harness.setProperty('vo-configured', true);
const outcome = await pending;
assert.deepEqual(outcome, { ok: true });
watch.dispose();
});
test('times out with a failure when nothing ever starts', async () => {
const { watch, harness } = createHarness({ timeoutMs: 300, probeIntervalMs: 100 });
const outcome = await watch.wait();
assert.equal(outcome.ok, false);
assert.ok(!outcome.ok && outcome.error.length > 0);
assert.equal(harness.elapsed(), 300);
watch.dispose();
});
test('slow property reads eat the budget instead of extending it', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
probeIntervalMs: 100,
readCostMs: 250,
});
const outcome = await watch.wait();
assert.equal(outcome.ok, false);
// Two probes: 250 + 50 (the sleep clamped to what was left) then 250 again.
assert.ok(harness.elapsed() >= 300, 'gave up before the timeout');
assert.ok(harness.elapsed() < 900, 'read delays stretched the timeout');
watch.dispose();
});
test('a zero probe interval still terminates at the deadline', async () => {
const { watch } = createHarness({ timeoutMs: 200, probeIntervalMs: 0, readCostMs: 50 });
const outcome = await watch.wait();
assert.equal(outcome.ok, false);
watch.dispose();
});
test('keeps polling through property read failures', async () => {
const { watch, harness } = createHarness();
harness.failProperty('vo-configured');
const pending = watch.wait();
harness.emitEndFile({ reason: 'error', fileError: null });
const outcome = await pending;
assert.equal(outcome.ok, false);
watch.dispose();
});
test('dispose removes the end-file subscription', () => {
const { watch, harness } = createHarness();
assert.equal(harness.listenerCount(), 1);
watch.dispose();
assert.equal(harness.listenerCount(), 0);
});
+89
View File
@@ -0,0 +1,89 @@
/**
* Confirms that a `loadfile` handed to mpv actually turned into playback.
*
* Sending the command proves nothing: mpv accepts the file, fails to decode it
* (a dead host, a disguised stream the proxy could not fix), fires `end-file`
* with reason "error", and drops back to `--idle` — with no window, because
* idle mpv shows none. The UI would happily say "Playing" over a blank desktop.
*
* Success is mpv configuring a video output (`vo-configured`), which is
* literally "a window with frames in it". `file-loaded` is not enough — the
* broken stream in the wild reached it before dying. Failure is an `end-file`
* with reason "error"; the end-file of the file being *replaced* arrives with
* "stop"/"redirect" and is ignored.
*/
export interface PlaybackEndFileEvent {
reason: string;
fileError: string | null;
}
export type PlaybackOutcome = { ok: true } | { ok: false; error: string };
export interface WatchPlaybackOutcomeDeps {
/** Subscribe to mpv end-file events; returns the unsubscribe. */
onEndFile: (listener: (event: PlaybackEndFileEvent) => void) => () => void;
/** One-shot mpv property read; may reject while the file is still loading. */
readProperty: (name: string) => Promise<unknown>;
wait: (ms: number) => Promise<void>;
/** Injectable clock; the timeout is wall-clock, not a probe count. */
now?: () => number;
timeoutMs?: number;
probeIntervalMs?: number;
}
export interface PlaybackOutcomeWatch {
wait: () => Promise<PlaybackOutcome>;
dispose: () => void;
}
export const DEFAULT_PLAYBACK_OUTCOME_TIMEOUT_MS = 20_000;
const DEFAULT_PROBE_INTERVAL_MS = 500;
/**
* Call *before* sending `loadfile` so the error subscription cannot lose a
* race against a fast failure; await `wait()` after the commands went out.
*/
export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOutcomeWatch {
const timeoutMs = deps.timeoutMs ?? DEFAULT_PLAYBACK_OUTCOME_TIMEOUT_MS;
const probeIntervalMs = deps.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
let failure: PlaybackOutcome | null = null;
const unsubscribe = deps.onEndFile((event) => {
if (event.reason !== 'error') return;
failure = {
ok: false,
error: event.fileError
? `mpv could not play this stream: ${event.fileError}`
: 'mpv could not play this stream.',
};
});
async function wait(): Promise<PlaybackOutcome> {
// Wall-clock, not a probe count: a slow `readProperty` must eat into the
// budget rather than stretch it, and a zero probe interval must still end.
const now = deps.now ?? Date.now;
const deadline = now() + timeoutMs;
while (now() < deadline) {
if (failure) return failure;
try {
if ((await deps.readProperty('vo-configured')) === true) return { ok: true };
} catch {
// The property is unreadable while mpv is between files; keep polling.
}
if (failure) return failure;
// Sleeping past the deadline would only delay the timeout report.
const remaining = deadline - now();
if (remaining <= 0) break;
await deps.wait(Math.min(probeIntervalMs, remaining));
}
return (
failure ?? {
ok: false,
error: 'Playback did not start. mpv gave no error; try another server or quality.',
}
);
}
return { wait, dispose: unsubscribe };
}
+60 -18
View File
@@ -4,6 +4,7 @@ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
bundleReleaseUrl,
findBundleBinaries,
PINNED_BUNDLE_SHA256,
PINNED_BUNDLE_TAG,
@@ -26,34 +27,71 @@ test('resolveBundleAssetName returns null for unpublished combinations', () => {
assert.equal(resolveBundleAssetName('freebsd', 'x64'), null);
});
test('selectBundleAsset skips releases without a matching asset', () => {
const releases = [
// The iOS runtime release carries no desktop bundle.
{ tag_name: 'ios-runtime-v7', assets: [{ name: 'MExtensionServer-ios.jar' }] },
const PINNED_RELEASE = {
tag_name: PINNED_BUNDLE_TAG,
assets: [
{
tag_name: 'v1.0.6.0',
assets: [
{
name: 'macOS-arm64-bundle.zip',
browser_download_url: 'https://example.test/macOS-arm64-bundle.zip',
size: 133_058_560,
},
],
name: 'macOS-arm64-bundle.zip',
browser_download_url: 'https://example.test/macOS-arm64-bundle.zip',
size: 133_058_560,
},
];
],
};
const asset = selectBundleAsset(releases, 'macOS-arm64-bundle.zip');
assert.equal(asset?.tagName, 'v1.0.6.0');
test('selectBundleAsset reads the by-tag endpoint payload', () => {
const asset = selectBundleAsset(PINNED_RELEASE, 'macOS-arm64-bundle.zip');
assert.equal(asset?.tagName, PINNED_BUNDLE_TAG);
assert.equal(asset?.downloadUrl, 'https://example.test/macOS-arm64-bundle.zip');
assert.equal(asset?.sizeBytes, 133_058_560);
});
test('selectBundleAsset skips releases without a matching asset', () => {
const releases = [
// The iOS runtime release carries no desktop bundle.
{ tag_name: 'ios-runtime-v7', assets: [{ name: 'MExtensionServer-ios.jar' }] },
PINNED_RELEASE,
];
const asset = selectBundleAsset(releases, 'macOS-arm64-bundle.zip');
assert.equal(asset?.tagName, PINNED_BUNDLE_TAG);
});
test('selectBundleAsset ignores releases newer than the pin', () => {
const releases = [
{
tag_name: 'v9.9.9.9',
assets: [
{
name: 'macOS-arm64-bundle.zip',
browser_download_url: 'https://example.test/unpinned.zip',
size: 1,
},
],
},
PINNED_RELEASE,
];
const asset = selectBundleAsset(releases, 'macOS-arm64-bundle.zip');
assert.equal(asset?.tagName, PINNED_BUNDLE_TAG);
assert.equal(asset?.downloadUrl, 'https://example.test/macOS-arm64-bundle.zip');
});
test('selectBundleAsset returns null when nothing matches', () => {
assert.equal(selectBundleAsset([{ tag_name: 'v1', assets: [] }], 'linux-x64-bundle.zip'), null);
assert.equal(
selectBundleAsset([{ tag_name: PINNED_BUNDLE_TAG, assets: [] }], 'linux-x64-bundle.zip'),
null,
);
assert.equal(selectBundleAsset([], 'linux-x64-bundle.zip'), null);
assert.equal(selectBundleAsset({ message: 'rate limited' }, 'linux-x64-bundle.zip'), null);
});
test('bundleReleaseUrl targets the pinned tag', () => {
assert.equal(
bundleReleaseUrl(),
`https://api.github.com/repos/1Selxo/M-Extension-Server/releases/tags/${PINNED_BUNDLE_TAG}`,
);
});
test('findBundleBinaries locates the nested jre and jar', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'subminer-bundle-'));
await mkdir(path.join(root, 'jre', 'jre', 'bin'), { recursive: true });
@@ -103,15 +141,19 @@ test('verifyPinnedBundle accepts a matching hash and rejects a mismatch', () =>
});
test('verifyPinnedBundle refuses an asset that has no pin', () => {
const result = verifyPinnedBundle('linux-x64-bundle.zip', new Uint8Array([1, 2, 3]));
const result = verifyPinnedBundle('windows-x64-bundle.zip', new Uint8Array([1, 2, 3]));
assert.equal(result.ok, false);
assert.match((result as { reason: string }).reason, /No pinned checksum/);
});
test('the pinned tag and macOS arm64 hash are the verified release', () => {
test('the pinned tag and hashes are the verified release', () => {
assert.equal(PINNED_BUNDLE_TAG, 'v1.0.6.0');
assert.equal(
PINNED_BUNDLE_SHA256['macOS-arm64-bundle.zip'],
'5f4fb03abfe88bc46ddf5f4d8221156ee2d66b9cbad7c4bc3ade4baf3a4266e6',
);
assert.equal(
PINNED_BUNDLE_SHA256['linux-x64-bundle.zip'],
'c2b869d3905b06a308517fec0b44f70ff76f7212230c60710bba39a7025c3a69',
);
});
+26 -7
View File
@@ -8,8 +8,15 @@ import path from 'node:path';
* required.
*/
export const BUNDLE_RELEASES_URL =
'https://api.github.com/repos/1Selxo/M-Extension-Server/releases?page=1&per_page=10';
const BUNDLE_REPO_API = 'https://api.github.com/repos/1Selxo/M-Extension-Server';
/**
* Fetch the pinned release by tag rather than listing releases: upstream ships
* several a week, so a paged list would scroll the pinned tag off page one.
*/
export function bundleReleaseUrl(tagName: string = PINNED_BUNDLE_TAG): string {
return `${BUNDLE_REPO_API}/releases/tags/${encodeURIComponent(tagName)}`;
}
/**
* The bridge release this integration was verified against.
@@ -24,6 +31,7 @@ export const PINNED_BUNDLE_TAG = 'v1.0.6.0';
/** SHA-256 of each pinned asset, keyed by release asset name. */
export const PINNED_BUNDLE_SHA256: Readonly<Record<string, string>> = {
'macOS-arm64-bundle.zip': '5f4fb03abfe88bc46ddf5f4d8221156ee2d66b9cbad7c4bc3ade4baf3a4266e6',
'linux-x64-bundle.zip': 'c2b869d3905b06a308517fec0b44f70ff76f7212230c60710bba39a7025c3a69',
};
/**
@@ -130,12 +138,23 @@ interface GithubRelease {
}
/**
* Pick the newest release carrying an asset for this platform. Releases are
* returned newest-first, and some (the iOS runtime) carry no desktop bundle.
* Pick the asset for this platform from the pinned release. Selecting "newest"
* instead would download a release whose checksum we never computed, so every
* upstream publish would break the install with a mismatch.
*/
export function selectBundleAsset(releases: unknown, assetName: string): BundleAsset | null {
if (!Array.isArray(releases)) return null;
for (const release of releases as GithubRelease[]) {
export function selectBundleAsset(
releases: unknown,
assetName: string,
tagName: string = PINNED_BUNDLE_TAG,
): BundleAsset | null {
// Accepts either a single release (the by-tag endpoint) or a list.
const candidates = Array.isArray(releases)
? releases
: releases && typeof releases === 'object'
? [releases]
: [];
for (const release of candidates as GithubRelease[]) {
if (release.tag_name !== tagName) continue;
const asset = release.assets?.find((candidate) => candidate.name === assetName);
if (asset?.browser_download_url && release.tag_name) {
return {
+86 -3
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { EventEmitter } from 'node:events';
import type { spawn as spawnType, ChildProcess } from 'node:child_process';
import { allocatePort, startSidecar } from './sidecar-process';
@@ -10,10 +11,22 @@ const binaries: BundleBinaries = {
jarPath: '/tmp/MExtensionServer.jar',
};
/** A ChildProcess stand-in: an EventEmitter with the bits startSidecar touches. */
function fakeChild(): ChildProcess {
/**
* A ChildProcess stand-in: an EventEmitter with the bits startSidecar touches.
* A `pid` marks a child that did spawn, which is how a kill failure is told
* apart from a spawn failure.
*/
function fakeChild(onKill?: (child: EventEmitter) => void, pid?: number): ChildProcess {
const child = new EventEmitter();
Object.assign(child, { stdout: null, stderr: null, kill: () => true });
Object.assign(child, {
stdout: null,
stderr: null,
pid,
kill: () => {
onKill?.(child);
return true;
},
});
return child as unknown as ChildProcess;
}
@@ -32,6 +45,38 @@ test('a failed spawn rejects instead of throwing an unhandled error event', asyn
);
});
test('a readiness timeout shuts the child down and reports the timeout', async () => {
const port = await allocatePort();
// Never becomes ready, but does go down on the first signal.
const child = fakeChild((emitter) => queueMicrotask(() => emitter.emit('exit', 0, 'SIGTERM')));
const spawnImpl = (() => child) as unknown as typeof spawnType;
await assert.rejects(
() => startSidecar({ binaries, port, readyTimeoutMs: 50, spawnImpl }),
/did not become ready within 50ms/,
);
});
test('a kill that fails without an exit is reported, not counted as a shutdown', async () => {
const port = await allocatePort();
// Signalling fails (EPERM-style) and the child never goes: it may still hold
// the port, so the stop must not report success.
const child = fakeChild(
(emitter) => queueMicrotask(() => emitter.emit('error', new Error('kill EPERM'))),
4242,
);
const spawnImpl = (() => child) as unknown as typeof spawnType;
await assert.rejects(
() => startSidecar({ binaries, port, readyTimeoutMs: 50, stopTimeoutMs: 10, spawnImpl }),
(error: Error) => {
assert.match(error.message, /did not become ready within 50ms/);
assert.match((error.cause as Error).message, /could not be killed.*EPERM/);
return true;
},
);
});
test('an early exit is reported with its code rather than waiting out the deadline', async () => {
const port = await allocatePort();
const child = fakeChild();
@@ -45,3 +90,41 @@ test('an early exit is reported with its code rather than waiting out the deadli
/exited before becoming ready \(code 1/,
);
});
test('onExit reports a death after readiness, including to late subscribers', async () => {
const port = await allocatePort();
// Fake the bridge's capabilities endpoint so startSidecar reports ready.
const server = http.createServer((_req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({ mangatanMihonBridge: 1, sourceFactory: true, preferenceCallbacks: true }),
);
});
await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve));
const child = fakeChild();
const spawnImpl = (() => child) as unknown as typeof spawnType;
try {
const handle = await startSidecar({ binaries, port, readyTimeoutMs: 5000, spawnImpl });
const exits: Array<{ code: number | null; signal: NodeJS.Signals | null }> = [];
handle.onExit((info) => exits.push(info));
assert.equal(exits.length, 0);
child.emit('exit', 137, null);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual([...exits], [{ code: 137, signal: null }]);
// A listener attached after the death still hears about it.
handle.onExit((info) => exits.push(info));
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(
[...exits],
[
{ code: 137, signal: null },
{ code: 137, signal: null },
],
);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
+58 -11
View File
@@ -35,6 +35,14 @@ export async function allocatePort(): Promise<number> {
export interface SidecarHandle {
baseUrl: string;
port: number;
/**
* Fires once when the child process goes away, however it goes away —
* including deliberate stops. Fires immediately for a subscriber that
* attaches after the death, so a caller cannot miss it.
*/
onExit: (
listener: (info: { code: number | null; signal: NodeJS.Signals | null }) => void,
) => void;
client: AnimeBridgeClient;
stop: () => Promise<void>;
}
@@ -43,6 +51,8 @@ export interface StartSidecarOptions {
binaries: BundleBinaries;
port?: number;
readyTimeoutMs?: number;
/** How long to wait for the child to go after each stop signal. Tests only. */
stopTimeoutMs?: number;
spawnImpl?: typeof spawn;
onLog?: (line: string) => void;
}
@@ -75,6 +85,8 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
let exited: { code: number | null; signal: NodeJS.Signals | null } | null = null;
let spawnError: Error | null = null;
let killError: Error | null = null;
const stopTimeoutMs = options.stopTimeoutMs ?? STOP_TIMEOUT_MS;
const hasExited = new Promise<void>((resolve) => {
child.once('exit', (code, signal) => {
exited = { code, signal };
@@ -82,16 +94,24 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
});
// A ChildProcess is an EventEmitter: without this listener a failed spawn
// (a missing or non-executable java) throws in the main process instead of
// failing the readiness loop below.
child.once('error', (error: Error) => {
spawnError = error;
if (exited === null) exited = { code: null, signal: null };
resolve();
// failing the readiness loop below. `error` never proves the child is gone
// -- only `exit` does -- so it must never set `exited`, or a kill that
// failed would look like a clean shutdown. A spawn failure is told apart
// from a later kill failure by the missing pid.
child.on('error', (error: Error) => {
if (child.pid === undefined) {
spawnError = error;
resolve();
return;
}
killError = error;
});
});
const stop = async (): Promise<void> => {
if (exited !== null) return;
// Nothing was ever spawned, so there is nothing to signal or wait for.
if (spawnError !== null) return;
// Graceful first: the server exposes a shutdown endpoint.
try {
await fetch(`${baseUrl}/stop`, { signal: AbortSignal.timeout(2000) });
@@ -102,10 +122,21 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
child.kill();
// kill() only sends the signal. Wait for the process to actually go, so a
// restart cannot race the old one still holding the port.
await Promise.race([hasExited, delay(STOP_TIMEOUT_MS)]);
await Promise.race([hasExited, delay(stopTimeoutMs)]);
if (exited === null) {
child.kill('SIGKILL');
await Promise.race([hasExited, delay(STOP_TIMEOUT_MS)]);
await Promise.race([hasExited, delay(stopTimeoutMs)]);
}
// Never report success while the child may still hold the port: a caller
// that restarts on the same port would race the survivor.
if (exited === null) {
const failedKill = killError as Error | null;
if (failedKill !== null) {
throw new Error(`Anime bridge could not be killed: ${failedKill.message}`, {
cause: failedKill,
});
}
throw new Error('Anime bridge did not exit after SIGKILL.');
}
};
@@ -122,12 +153,28 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
`Anime bridge exited before becoming ready (code ${code}, signal ${signal}).`,
);
}
if (await client.isReady()) return { baseUrl, port, client, stop };
await delay(READY_POLL_INTERVAL_MS);
// Cap the probe at the time actually left, so a short readiness budget is
// not overrun by a single stalled capabilities request.
if (await client.isReady(deadline - Date.now())) {
const onExit: SidecarHandle['onExit'] = (listener) => {
void hasExited.then(() => listener(exited ?? { code: null, signal: null }));
};
return { baseUrl, port, client, stop, onExit };
}
// Sleeping past the deadline would only delay the timeout report.
const remaining = deadline - Date.now();
if (remaining <= 0) break;
await delay(Math.min(READY_POLL_INTERVAL_MS, remaining));
}
await stop();
throw new Error(
const timeout = new Error(
`Anime bridge did not become ready within ${options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms.`,
);
// A failed shutdown must not mask why startup failed, but it must not be
// dropped either: a surviving child still holds the port, which decides
// whether a caller may retry on it.
await stop().catch((error: unknown) => {
timeout.cause = error;
});
throw timeout;
}
+263
View File
@@ -0,0 +1,263 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import {
findTsSyncOffset,
rewritePlaylistOrigins,
startStreamStripProxy,
TS_PACKET_LENGTH,
} from './stream-strip-proxy';
/** A valid MPEG-TS payload: 0x47 sync byte every 188 bytes. */
function makeTsPackets(count: number): Buffer {
const data = Buffer.alloc(count * TS_PACKET_LENGTH, 0x11);
for (let i = 0; i < count; i++) data[i * TS_PACKET_LENGTH] = 0x47;
return data;
}
/** The disguise seen in the wild: a real PNG header glued before the TS data. */
const PNG_HEADER = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
]);
test('findTsSyncOffset returns 0 for clean TS data', () => {
assert.equal(findTsSyncOffset(makeTsPackets(6)), 0);
});
test('findTsSyncOffset finds TS data behind a PNG prefix', () => {
const disguised = Buffer.concat([PNG_HEADER, makeTsPackets(6)]);
assert.equal(findTsSyncOffset(disguised), PNG_HEADER.length);
});
test('findTsSyncOffset ignores a lone sync byte in the junk prefix', () => {
const junk = Buffer.alloc(64, 0x00);
junk[10] = 0x47; // decoy: no packet run follows it
const disguised = Buffer.concat([junk, makeTsPackets(6)]);
assert.equal(findTsSyncOffset(disguised), junk.length);
});
test('findTsSyncOffset returns null when no TS run exists', () => {
assert.equal(findTsSyncOffset(Buffer.alloc(4096, 0x42)), null);
});
test('findTsSyncOffset returns null when the run starts past the scan limit', () => {
const disguised = Buffer.concat([Buffer.alloc(600, 0x00), makeTsPackets(6)]);
assert.equal(findTsSyncOffset(disguised, 500), null);
});
test('rewritePlaylistOrigins swaps absolute upstream URLs and keeps relative lines', () => {
const body = [
'#EXTM3U',
'#EXTINF:6.006,',
'/video/aaa.ts',
'#EXTINF:4.463,',
'http://127.0.0.1:41569/video/bbb.ts',
].join('\n');
const rewritten = rewritePlaylistOrigins(body, 'http://127.0.0.1:41569', 'http://127.0.0.1:9999');
assert.ok(rewritten.includes('http://127.0.0.1:9999/video/bbb.ts'));
assert.ok(rewritten.includes('\n/video/aaa.ts\n'));
assert.ok(!rewritten.includes('41569'));
});
/* ---------- proxy end-to-end ---------- */
type Route = { status: number; contentType: string; body: Buffer };
/** Either a static routing table or a handler, for upstreams that need one. */
async function withProxy(
routes: Record<string, Route> | http.RequestListener,
run: (proxyOrigin: string, upstreamOrigin: string) => Promise<void>,
): Promise<void> {
const upstream = http.createServer((req, res) => {
if (typeof routes === 'function') {
routes(req, res);
return;
}
const route = routes[req.url ?? ''];
if (!route) {
res.writeHead(404).end('missing');
return;
}
res.writeHead(route.status, { 'content-type': route.contentType });
res.end(route.body);
});
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
const upstreamOrigin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`;
const proxy = await startStreamStripProxy({
upstreamOrigin: () => upstreamOrigin,
retryDelayMs: 5,
});
try {
await run(proxy.origin, upstreamOrigin);
} finally {
await proxy.close();
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
}
async function fetchBytes(url: string): Promise<{ status: number; body: Buffer }> {
const response = await fetch(url);
return { status: response.status, body: Buffer.from(await response.arrayBuffer()) };
}
test('proxy strips the PNG disguise off a segment', async () => {
const ts = makeTsPackets(8);
const disguised = Buffer.concat([PNG_HEADER, ts]);
await withProxy(
{ '/video/seg.ts': { status: 200, contentType: 'video/mp2t', body: disguised } },
async (origin) => {
const { status, body } = await fetchBytes(`${origin}/video/seg.ts`);
assert.equal(status, 200);
assert.deepEqual(body, ts);
},
);
});
test('proxy passes a clean segment through unchanged', async () => {
const ts = makeTsPackets(8);
await withProxy(
{ '/video/seg.ts': { status: 200, contentType: 'video/mp2t', body: ts } },
async (origin) => {
const { body } = await fetchBytes(`${origin}/video/seg.ts`);
assert.deepEqual(body, ts);
},
);
});
test('proxy leaves non-TS bodies alone', async () => {
const vtt = Buffer.from('WEBVTT\n\n00:00.000 --> 00:01.000\nhello\n');
await withProxy(
{ '/video/sub.vtt': { status: 200, contentType: 'text/vtt', body: vtt } },
async (origin) => {
const { body } = await fetchBytes(`${origin}/video/sub.vtt`);
assert.deepEqual(body, vtt);
},
);
});
test('proxy rewrites absolute upstream playlist entries to its own origin', async () => {
await withProxy(
(req, res) => {
if (req.url !== '/video/list.m3u8') {
res.writeHead(404).end();
return;
}
// The proxy rewrites the Host header to the upstream it dialled, so this
// is that origin — the one the playlist must not leak to mpv.
res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl' });
res.end(`#EXTM3U\n#EXTINF:6,\nhttp://${req.headers.host}/video/abs.ts\n`);
},
async (proxyOrigin, upstreamOrigin) => {
const { body } = await fetchBytes(`${proxyOrigin}/video/list.m3u8`);
const text = body.toString('utf8');
assert.ok(text.includes(`${proxyOrigin}/video/abs.ts`));
assert.ok(!text.includes(upstreamOrigin));
},
);
});
test('proxy strips even when the client asks for a byte range', async () => {
// ffmpeg opens every HLS segment with `Range: bytes=0-`. The proxy drops the
// header, so the upstream answers 200 with the whole body and the strip
// applies; a 206 would have been forwarded untouched.
const ts = makeTsPackets(8);
const disguised = Buffer.concat([PNG_HEADER, ts]);
await withProxy(
(req, res) => {
if (req.headers.range !== undefined) {
res.writeHead(206, {
'content-type': 'image/png',
'content-range': `bytes 0-${disguised.length - 1}/${disguised.length}`,
});
res.end(disguised);
return;
}
res.writeHead(200, { 'content-type': 'image/png' });
res.end(disguised);
},
async (proxyOrigin) => {
const response = await fetch(`${proxyOrigin}/video/seg.ts`, {
headers: { Range: 'bytes=0-' },
});
const body = Buffer.from(await response.arrayBuffer());
assert.equal(response.status, 200);
assert.deepEqual(body, ts);
},
);
});
test('proxy forwards error statuses without touching the body', async () => {
await withProxy(
{ '/video/gone.ts': { status: 404, contentType: 'text/plain', body: Buffer.from('nope') } },
async (origin) => {
const { status, body } = await fetchBytes(`${origin}/video/gone.ts`);
assert.equal(status, 404);
assert.equal(body.toString('utf8'), 'nope');
},
);
});
/**
* Upstream that fails the first `failures` hits per path, then serves the
* route. Mirrors the bridge right after an episode resolve: mpv's immediate
* segment fetch errors, the same fetch a moment later works.
*/
async function withFlakyUpstream(
failures: number,
failStatus: number,
route: Route,
run: (proxyOrigin: string, hits: () => number) => Promise<void>,
): Promise<void> {
let hits = 0;
const upstream = http.createServer((_req, res) => {
hits += 1;
if (hits <= failures) {
res.writeHead(failStatus, { 'content-type': 'text/plain' }).end('not ready');
return;
}
res.writeHead(route.status, { 'content-type': route.contentType });
res.end(route.body);
});
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
const upstreamOrigin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`;
const proxy = await startStreamStripProxy({
upstreamOrigin: () => upstreamOrigin,
retryDelayMs: 5,
});
try {
await run(proxy.origin, () => hits);
} finally {
await proxy.close();
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
}
test('proxy retries a failed segment fetch once and serves the retry', async () => {
const ts = makeTsPackets(8);
await withFlakyUpstream(
1,
404,
{ status: 200, contentType: 'video/mp2t', body: ts },
async (origin, hits) => {
const { status, body } = await fetchBytes(`${origin}/video/seg.ts`);
assert.equal(status, 200);
assert.deepEqual(body, ts);
assert.equal(hits(), 2);
},
);
});
test('proxy gives up after one retry and forwards the error', async () => {
await withFlakyUpstream(
Infinity,
503,
{ status: 200, contentType: 'video/mp2t', body: makeTsPackets(8) },
async (origin, hits) => {
const { status } = await fetchBytes(`${origin}/video/seg.ts`);
assert.equal(status, 503);
assert.equal(hits(), 2);
},
);
});
+296
View File
@@ -0,0 +1,296 @@
import http from 'node:http';
import type { AddressInfo } from 'node:net';
/**
* Loopback proxy between mpv and the anime bridge that undoes segment
* disguises. Some hosts prepend a real image header (a 1x1 PNG in the wild) to
* every HLS segment so scrapers see "an image"; ffmpeg then probes the segment
* as a picture and playback dies with "no audio or video data played". Aniyomi
* strips this in its player; mpv needs the bytes fixed before it sees them.
*
* Only bridge-origin `.m3u8` streams are routed through here (see
* anime-browser-runtime). Playlist bodies get their absolute upstream origins
* rewritten so segment requests come back through the proxy; segment bodies are
* scanned for the first genuine MPEG-TS packet run and any junk before it is
* dropped. Anything that is not TS (fMP4, VTT, keys) passes through untouched.
*/
export const TS_PACKET_LENGTH = 188;
const TS_SYNC_BYTE = 0x47;
/**
* Sync bytes that must repeat at exact packet spacing before an offset counts
* as TS data. One or two matches happen by chance in binary data; five in a
* row at 188-byte strides do not.
*/
const SYNC_RUN = 5;
/** A disguise prefix is small; give up scanning after this much. */
export const DEFAULT_SCAN_LIMIT_BYTES = 1024 * 1024;
/** Bytes needed to either find a run within the limit or rule one out. */
const DECISION_BYTES = DEFAULT_SCAN_LIMIT_BYTES + (SYNC_RUN - 1) * TS_PACKET_LENGTH + 1;
/**
* First offset at which a confirmed MPEG-TS packet run starts, or null when
* the data does not look like TS at all (within the scan limit).
*/
export function findTsSyncOffset(
data: Buffer,
scanLimit = DEFAULT_SCAN_LIMIT_BYTES,
): number | null {
const lastConfirmable = data.length - (SYNC_RUN - 1) * TS_PACKET_LENGTH - 1;
const end = Math.min(lastConfirmable, scanLimit);
for (let offset = 0; offset <= end; offset++) {
if (data[offset] !== TS_SYNC_BYTE) continue;
let confirmed = true;
for (let packet = 1; packet < SYNC_RUN; packet++) {
if (data[offset + packet * TS_PACKET_LENGTH] !== TS_SYNC_BYTE) {
confirmed = false;
break;
}
}
if (confirmed) return offset;
}
return null;
}
/**
* Point absolute playlist entries at the proxy. Relative entries already
* resolve against whatever origin served the playlist, so they need no help.
*/
export function rewritePlaylistOrigins(
body: string,
upstreamOrigin: string,
proxyOrigin: string,
): string {
return body.split(upstreamOrigin).join(proxyOrigin);
}
export interface StreamStripProxyOptions {
/** Read per request so a bridge restart on a new port keeps working. */
upstreamOrigin: () => string;
log?: (message: string) => void;
/** Pause before the single retry of a failed upstream GET. */
retryDelayMs?: number;
}
const DEFAULT_RETRY_DELAY_MS = 400;
/**
* Socket timeout on the upstream GET, cleared once its headers arrive. Node's
* http client has no deadline of its own, so a host that accepts the
* connection and then says nothing would hang mpv on that segment forever.
*/
const UPSTREAM_TIMEOUT_MS = 15_000;
export interface StreamStripProxyHandle {
origin: string;
port: number;
close: () => Promise<void>;
}
/** Response headers that must not be forwarded verbatim. */
const DROPPED_HEADERS = new Set([
'connection',
'keep-alive',
'transfer-encoding',
'content-length',
]);
function forwardableHeaders(headers: http.IncomingHttpHeaders): http.OutgoingHttpHeaders {
const result: http.OutgoingHttpHeaders = {};
for (const [name, value] of Object.entries(headers)) {
if (value === undefined || DROPPED_HEADERS.has(name.toLowerCase())) continue;
result[name] = value;
}
return result;
}
export function startStreamStripProxy(
options: StreamStripProxyOptions,
): Promise<StreamStripProxyHandle> {
const log = options.log ?? (() => {});
const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
const server = http.createServer((req, res) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405).end();
return;
}
let upstreamUrl: URL;
try {
upstreamUrl = new URL(req.url ?? '/', options.upstreamOrigin());
} catch {
res.writeHead(502).end();
return;
}
const requestHeaders = forwardableHeaders(req.headers);
delete requestHeaders.host;
// Never forward Range: ffmpeg opens every segment with `bytes=0-`, the
// bridge answers some of those 206, and a partial response cannot be
// stripped (only full 200 bodies are). Byte ranges into a resource whose
// bytes this proxy rewrites would be incoherent anyway.
delete requestHeaders.range;
res.on('error', () => {});
requestUpstream(req, res, upstreamUrl, requestHeaders, 0);
});
/**
* One delayed retry on a failed GET: right after an episode resolve, the
* bridge (or the host behind it) can error on the very first segment
* fetches and be fine a moment later — mpv treats a playlist full of failed
* segments as a dead file and gives up for good.
*/
function requestUpstream(
req: http.IncomingMessage,
res: http.ServerResponse,
upstreamUrl: URL,
requestHeaders: http.OutgoingHttpHeaders,
attempt: number,
): void {
const mayRetry = req.method === 'GET' && attempt === 0;
const retry = (): void => {
setTimeout(
() => requestUpstream(req, res, upstreamUrl, requestHeaders, attempt + 1),
retryDelayMs,
);
};
const upstreamRequest = http.request(
upstreamUrl,
{ method: req.method, headers: requestHeaders, timeout: UPSTREAM_TIMEOUT_MS },
(upstream) => {
// Body streaming has its own pace; only the wait for headers is capped.
upstreamRequest.setTimeout(0);
const status = upstream.statusCode ?? 502;
if (status === 404 || status >= 500) {
if (mayRetry) {
log(`[stream-proxy] upstream ${status} for ${upstreamUrl.pathname}; retrying once`);
upstream.resume();
retry();
return;
}
log(`[stream-proxy] upstream ${status} for ${upstreamUrl.pathname}`);
}
handleUpstreamResponse(req, res, upstream);
},
);
// Destroying with an error routes the stall through the retry/502 path.
upstreamRequest.on('timeout', () => {
upstreamRequest.destroy(new Error(`upstream silent for ${UPSTREAM_TIMEOUT_MS}ms`));
});
upstreamRequest.on('error', (error) => {
if (mayRetry) {
log(`[stream-proxy] upstream request failed: ${String(error)}; retrying once`);
retry();
return;
}
log(`[stream-proxy] upstream request failed: ${String(error)}`);
if (!res.headersSent) res.writeHead(502);
res.end();
});
upstreamRequest.end();
}
function handleUpstreamResponse(
req: http.IncomingMessage,
res: http.ServerResponse,
upstream: http.IncomingMessage,
): void {
const status = upstream.statusCode ?? 502;
const pathname = (req.url ?? '').split('?', 1)[0] ?? '';
const contentType = String(upstream.headers['content-type'] ?? '');
const isPlaylist = pathname.endsWith('.m3u8') || contentType.includes('mpegurl');
upstream.on('error', () => res.destroy());
// Only a full 200 body is safe to modify; everything else (errors, range
// responses, HEAD) forwards untouched.
if (status !== 200 || req.method === 'HEAD') {
res.writeHead(status, forwardableHeaders(upstream.headers));
upstream.pipe(res);
return;
}
if (isPlaylist) {
const chunks: Buffer[] = [];
upstream.on('data', (chunk: Buffer) => chunks.push(chunk));
upstream.on('end', () => {
const body = rewritePlaylistOrigins(
Buffer.concat(chunks).toString('utf8'),
options.upstreamOrigin(),
origin,
);
res.writeHead(status, {
...forwardableHeaders(upstream.headers),
'content-length': Buffer.byteLength(body, 'utf8'),
});
res.end(body);
});
return;
}
stripSegment(res, upstream);
}
/**
* Buffer just enough of the body to find (or rule out) a TS packet run,
* drop everything before it, then stream the rest through untouched.
*/
function stripSegment(res: http.ServerResponse, upstream: http.IncomingMessage): void {
const chunks: Buffer[] = [];
let buffered = 0;
const respond = (data: Buffer, remainderFollows: boolean): void => {
const offset = findTsSyncOffset(data) ?? 0;
if (offset > 0) log(`[stream-proxy] stripped ${offset} disguise bytes off a segment`);
const body = offset > 0 ? data.subarray(offset) : data;
const headers = forwardableHeaders(upstream.headers);
const upstreamLength = Number(upstream.headers['content-length']);
if (remainderFollows) {
if (Number.isFinite(upstreamLength)) headers['content-length'] = upstreamLength - offset;
} else {
headers['content-length'] = body.length;
}
res.writeHead(upstream.statusCode ?? 200, headers);
res.write(body);
};
const onData = (chunk: Buffer): void => {
chunks.push(chunk);
buffered += chunk.length;
if (buffered < DECISION_BYTES) return;
upstream.off('data', onData);
upstream.off('end', onEnd);
respond(Buffer.concat(chunks), true);
upstream.pipe(res);
};
const onEnd = (): void => {
respond(Buffer.concat(chunks), false);
res.end();
};
upstream.on('data', onData);
upstream.on('end', onEnd);
}
let origin = '';
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as AddressInfo;
origin = `http://127.0.0.1:${port}`;
resolve({
origin,
port,
close: () =>
new Promise<void>((resolveClose) => {
server.closeAllConnections?.();
server.close(() => resolveClose());
}),
});
});
});
}
+166
View File
@@ -0,0 +1,166 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as path from 'path';
import {
cacheSubtitleTracks,
removeSubtitleCache,
resolveSubtitleExtension,
sniffSubtitleExtension,
subtitleExtensionFromUrl,
type SubtitleCacheIo,
} from './subtitle-cache';
interface FakeIo extends SubtitleCacheIo {
written: Map<string, string>;
removed: string[];
requests: Array<{ url: string; headers: Record<string, string> }>;
}
function fakeIo(bodies: Record<string, string | { status: number }>): FakeIo {
const written = new Map<string, string>();
const removed: string[] = [];
const requests: Array<{ url: string; headers: Record<string, string> }> = [];
return {
written,
removed,
requests,
async fetch(url, init) {
requests.push({ url, headers: init.headers });
const body = bodies[url];
if (body === undefined) throw new Error(`unexpected fetch: ${url}`);
if (typeof body !== 'string') {
return { ok: false, status: body.status, arrayBuffer: async () => new ArrayBuffer(0) };
}
const bytes = Buffer.from(body, 'utf8');
return {
ok: true,
status: 200,
arrayBuffer: async () =>
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer,
};
},
async makeTempDir(prefix) {
return `${prefix}test`;
},
async writeFile(filePath, bytes) {
written.set(filePath, Buffer.from(bytes).toString('utf8'));
},
async removeDir(dir) {
removed.push(dir);
},
};
}
const SRT = '1\n00:00:01,000 --> 00:00:02,000\nこんにちは\n';
const ASS = '[Script Info]\nScriptType: v4.00+\n\n[Events]\n';
test('content decides the extension before the url does', () => {
assert.equal(sniffSubtitleExtension(ASS), 'ass');
assert.equal(sniffSubtitleExtension(SRT), 'srt');
assert.equal(sniffSubtitleExtension('WEBVTT\n\n00:01.000 --> 00:02.000\n'), 'vtt');
assert.equal(sniffSubtitleExtension('nothing recognisable'), null);
// An ASS body served from a .srt URL keeps the extension its parser needs.
assert.equal(resolveSubtitleExtension('http://host/sub.srt', ASS), 'ass');
});
test('a bom or leading whitespace does not hide the format marker', () => {
assert.equal(sniffSubtitleExtension(`${ASS}`), 'ass');
assert.equal(sniffSubtitleExtension(`\n\n${SRT}`), 'srt');
});
test('the url extension is the fallback, and only for formats we know', () => {
assert.equal(subtitleExtensionFromUrl('http://host/a/b.ASS?x=1'), 'ass');
assert.equal(subtitleExtensionFromUrl('http://host/video/token'), null);
assert.equal(subtitleExtensionFromUrl('http://host/a.mp4'), null);
// Nothing to go on: mpv can still probe past a wrong name.
assert.equal(resolveSubtitleExtension('http://host/video/token', 'unknown'), 'srt');
});
test('tracks are downloaded to a temp dir and handed back as file paths', async () => {
const io = fakeIo({ 'http://bridge/sub/ja': SRT, 'http://bridge/sub/en': ASS });
const result = await cacheSubtitleTracks({
tracks: [
{ url: 'http://bridge/sub/ja', lang: 'Japanese' },
{ url: 'http://bridge/sub/en', lang: 'English' },
],
headers: { Referer: 'https://host/' },
io,
});
assert.ok(result.dir);
assert.deepEqual(
result.tracks.map((track) => path.basename(track.url)),
['track-0.srt', 'track-1.ass'],
);
assert.ok(result.tracks.every((track) => track.local));
assert.equal(io.written.get(result.tracks[0]!.url), SRT);
// The stream's headers ride along; some hosts gate the subtitle URL too.
assert.deepEqual(io.requests[0]!.headers, { Referer: 'https://host/' });
});
test('a failed download keeps its url so the episode still plays', async () => {
const io = fakeIo({ 'http://bridge/sub/ja': SRT, 'http://bridge/sub/en': { status: 404 } });
const logged: string[] = [];
const result = await cacheSubtitleTracks({
tracks: [
{ url: 'http://bridge/sub/ja', lang: 'Japanese' },
{ url: 'http://bridge/sub/en', lang: 'English' },
],
io,
log: (message) => logged.push(message),
});
assert.equal(result.tracks[0]!.local, true);
assert.equal(result.tracks[1]!.local, false);
assert.equal(result.tracks[1]!.url, 'http://bridge/sub/en');
assert.ok(logged.some((message) => message.includes('404')));
// One track survived, so the directory stays.
assert.ok(result.dir);
assert.deepEqual(io.removed, []);
});
test('a directory with nothing in it is removed and not reported', async () => {
const io = fakeIo({ 'http://bridge/sub/ja': { status: 500 } });
const result = await cacheSubtitleTracks({
tracks: [{ url: 'http://bridge/sub/ja', lang: 'Japanese' }],
io,
});
assert.equal(result.dir, null);
assert.equal(result.tracks[0]!.local, false);
assert.equal(io.removed.length, 1);
});
test('duplicate and empty urls are dropped before anything is fetched', async () => {
const io = fakeIo({ 'http://bridge/sub/ja': SRT });
const result = await cacheSubtitleTracks({
tracks: [
{ url: 'http://bridge/sub/ja', lang: 'Japanese' },
{ url: 'http://bridge/sub/ja', lang: 'Japanese' },
{ url: '', lang: 'English' },
],
io,
});
assert.equal(result.tracks.length, 1);
assert.equal(io.requests.length, 1);
});
test('no tracks means no temp directory at all', async () => {
const io = fakeIo({});
const result = await cacheSubtitleTracks({ tracks: [], io });
assert.deepEqual(result, { dir: null, tracks: [] });
assert.equal(io.written.size, 0);
});
test('cleanup is best effort and never throws', async () => {
const io = fakeIo({});
io.removeDir = async () => {
throw new Error('EBUSY');
};
await removeSubtitleCache('/tmp/subminer-anime-subtitles-x', io);
// A null directory is the common case after a source with no subtitles.
await removeSubtitleCache(null, io);
});
+223
View File
@@ -0,0 +1,223 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
/**
* Extension subtitle tracks arrive as URLs on the bridge's loopback proxy, and
* mpv is perfectly happy to stream them. alass is not: it needs a file on disk
* to use as the timing reference, and the subsync path rejects an external
* track whose `external-filename` is not an existing file. So every track is
* downloaded to a temp directory first and mpv is given the local path, the
* same way the Jellyfin preload caches its delivery URLs.
*
* The directory outlives the `sub-add` — alass reads it mid-playback — and is
* removed when the next episode starts or the runtime shuts down.
*/
/** Extensions mpv and alass both recognise off a filename. */
const KNOWN_SUBTITLE_EXTENSIONS = new Set([
'srt',
'ass',
'ssa',
'vtt',
'sub',
'ttml',
'smi',
'sbv',
]);
/** What an unrecognisable track is named; mpv still probes the content. */
const DEFAULT_SUBTITLE_EXTENSION = 'srt';
const DOWNLOAD_TIMEOUT_MS = 15_000;
/** Anything this large is not a subtitle file, and is not worth buffering. */
const MAX_SUBTITLE_BYTES = 32 * 1024 * 1024;
/** How much of the body is decoded to guess the format. */
const SNIFF_BYTES = 1024;
export interface SubtitleTrackRef {
url: string;
lang: string;
}
export interface CachedSubtitleTrack extends SubtitleTrackRef {
/** Where the track came from, kept for logs. */
sourceUrl: string;
/** False when the download failed and `url` is still the remote URL. */
local: boolean;
}
export interface SubtitleCacheResult {
/** The temp directory to remove later, or null when nothing was cached. */
dir: string | null;
tracks: CachedSubtitleTrack[];
}
interface FetchResponseLike {
ok: boolean;
status: number;
arrayBuffer: () => Promise<ArrayBuffer>;
}
export interface SubtitleCacheIo {
fetch: (
url: string,
init: { headers: Record<string, string>; signal: AbortSignal },
) => Promise<FetchResponseLike>;
makeTempDir: (prefix: string) => Promise<string>;
writeFile: (filePath: string, bytes: Uint8Array) => Promise<void>;
removeDir: (dir: string) => Promise<void>;
}
export interface CacheSubtitleTracksOptions {
tracks: SubtitleTrackRef[];
/** Headers the stream was resolved with; some hosts gate subtitles too. */
headers?: Record<string, string>;
io?: SubtitleCacheIo;
log?: (message: string) => void;
}
export function createSubtitleCacheIo(): SubtitleCacheIo {
return {
fetch: (url, init) => fetch(url, init),
makeTempDir: (prefix) => fs.promises.mkdtemp(prefix),
writeFile: (filePath, bytes) => fs.promises.writeFile(filePath, bytes),
removeDir: (dir) => fs.promises.rm(dir, { recursive: true, force: true }),
};
}
/**
* Guess a subtitle format from the start of the file.
*
* Bridge subtitle URLs are opaque tokens far more often than they are
* filenames, so the content is the only reliable signal. mpv and alass both
* pick their parser off the extension, and a `.srt` holding ASS is a parse
* error rather than a mistimed subtitle.
*/
export function sniffSubtitleExtension(head: string): string | null {
const text = head.replace(/^\uFEFF/, '').trimStart();
if (/^\[(script info|v4\+? styles|events)\]/i.test(text)) return 'ass';
if (/^WEBVTT(\s|$)/.test(text)) return 'vtt';
if (/^<\?xml/i.test(text) && /<tt[\s>]|ttml/i.test(text)) return 'ttml';
// Cue-numbered and bare-timestamp SRT; the `.` separator is a common variant.
if (/^(\d+\s*\r?\n)?\d{1,3}:\d{2}:\d{2}[,.]\d{1,3}\s*-->/.test(text)) return 'srt';
return null;
}
/** The URL's own extension, when it names a format we know. */
export function subtitleExtensionFromUrl(url: string): string | null {
const urlPath = (() => {
try {
return new URL(url).pathname;
} catch {
return url;
}
})();
const extension = path.extname(urlPath).slice(1).toLowerCase();
return KNOWN_SUBTITLE_EXTENSIONS.has(extension) ? extension : null;
}
/** Content first, then the URL, then a guess mpv can still probe past. */
export function resolveSubtitleExtension(url: string, head: string): string {
return (
sniffSubtitleExtension(head) ?? subtitleExtensionFromUrl(url) ?? DEFAULT_SUBTITLE_EXTENSION
);
}
function dedupeByUrl(tracks: SubtitleTrackRef[]): SubtitleTrackRef[] {
const seen = new Set<string>();
return tracks.filter((track) => {
if (track.url.length === 0 || seen.has(track.url)) return false;
seen.add(track.url);
return true;
});
}
async function downloadTrack(
io: SubtitleCacheIo,
dir: string,
index: number,
track: SubtitleTrackRef,
headers: Record<string, string>,
): Promise<string> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
let bytes: Uint8Array;
try {
const response = await io.fetch(track.url, { headers, signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
bytes = new Uint8Array(await response.arrayBuffer());
} finally {
clearTimeout(timeoutId);
}
if (bytes.byteLength === 0) {
throw new Error('empty response');
}
if (bytes.byteLength > MAX_SUBTITLE_BYTES) {
throw new Error(`response too large (${bytes.byteLength} bytes)`);
}
const head = Buffer.from(bytes.subarray(0, SNIFF_BYTES)).toString('utf8');
const extension = resolveSubtitleExtension(track.url, head);
const filePath = path.join(dir, `track-${index}.${extension}`);
// Written byte for byte: re-encoding would corrupt a non-UTF-8 track that
// mpv's own charset detection would otherwise handle.
await io.writeFile(filePath, bytes);
return filePath;
}
/**
* Download every subtitle track to a fresh temp directory.
*
* A track that fails to download keeps its remote URL, so a dead subtitle
* server costs the alass reference rather than the episode.
*/
export async function cacheSubtitleTracks(
options: CacheSubtitleTracksOptions,
): Promise<SubtitleCacheResult> {
const io = options.io ?? createSubtitleCacheIo();
const tracks = dedupeByUrl(options.tracks);
if (tracks.length === 0) return { dir: null, tracks: [] };
const dir = await io.makeTempDir(path.join(os.tmpdir(), 'subminer-anime-subtitles-'));
const cached = await Promise.all(
tracks.map(async (track, index): Promise<CachedSubtitleTrack> => {
try {
const filePath = await downloadTrack(io, dir, index, track, options.headers ?? {});
return { url: filePath, lang: track.lang, sourceUrl: track.url, local: true };
} catch (error) {
options.log?.(
`[anime-browser] subtitle download failed (${track.lang || 'unknown'}): ` +
describeError(error),
);
return { url: track.url, lang: track.lang, sourceUrl: track.url, local: false };
}
}),
);
if (!cached.some((track) => track.local)) {
await removeSubtitleCache(dir, io);
return { dir: null, tracks: cached };
}
return { dir, tracks: cached };
}
/** Remove a cache directory. Never throws: cleanup is best effort. */
export async function removeSubtitleCache(
dir: string | null,
io: SubtitleCacheIo = createSubtitleCacheIo(),
): Promise<void> {
if (!dir) return;
try {
await io.removeDir(dir);
} catch {}
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+77 -18
View File
@@ -1,5 +1,12 @@
import { describe, el } from './dom';
import { describeInstalled } from './format';
import {
collectLanguages,
filterByLanguage,
languageLabel,
pruneSelection,
toggleLanguage,
} from './language-filter';
import type {
AnimeBrowserAPI,
AvailableExtension,
@@ -90,10 +97,20 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
const installedList = el<HTMLDivElement>('installed-list');
const installedCount = el<HTMLSpanElement>('installed-count');
const availableList = el<HTMLDivElement>('extensions-list');
const availableCount = el<HTMLSpanElement>('available-count');
const langFilter = el<HTMLDivElement>('lang-filter');
const repoInput = el<HTMLInputElement>('repo-input');
const repoAddButton = el<HTMLButtonElement>('repo-add');
const repoList = el<HTMLDivElement>('repo-list');
// What the last refresh found, kept so toggling a language chip re-renders
// the list without re-fetching every repository index.
let installable: AvailableExtension[] = [];
let repoFailures: Array<{ name: string; error: string }> = [];
let hasRepos = false;
/** Selected language codes; empty means "All". */
let selectedLangs = new Set<string>();
async function afterChange(extensionName: string, verb: string): Promise<void> {
await refresh();
await onSourcesChanged();
@@ -179,20 +196,62 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
);
}
function renderAvailable(
available: AvailableExtension[],
repoFailures: Array<{ name: string; error: string }>,
hasRepos: boolean,
): void {
function languageChip(label: string, active: boolean, onClick: () => void): HTMLButtonElement {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = active ? 'lang-chip is-active' : 'lang-chip';
chip.textContent = label;
chip.setAttribute('aria-pressed', active ? 'true' : 'false');
chip.addEventListener('click', onClick);
return chip;
}
function renderLanguageFilter(languages: string[]): void {
// One language, or none: there is nothing to choose between.
if (languages.length < 2) {
langFilter.replaceChildren();
return;
}
langFilter.replaceChildren(
// "All" is the empty selection, so picking a language always replaces it
// rather than sitting alongside it.
languageChip('All', selectedLangs.size === 0, () => {
if (selectedLangs.size === 0) return;
selectedLangs = new Set();
renderAvailable();
}),
...languages.map((code) =>
languageChip(languageLabel(code), selectedLangs.has(code), () => {
selectedLangs = toggleLanguage(selectedLangs, code);
renderAvailable();
}),
),
);
}
function renderAvailable(): void {
const languages = collectLanguages(installable);
selectedLangs = pruneSelection(selectedLangs, languages);
renderLanguageFilter(languages);
const shown = filterByLanguage(installable, selectedLangs);
availableCount.textContent =
shown.length === installable.length
? installable.length === 0
? ''
: String(installable.length)
: `${shown.length} of ${installable.length}`;
const rows: HTMLElement[] = repoFailures.map((failure) =>
extensionRow({ name: failure.name, sub: failure.error, isError: true }),
);
for (const extension of available) {
for (const extension of shown) {
rows.push(
extensionRow({
name: extension.name,
sub: `${extension.lang} · v${extension.version}`,
sub: `${languageLabel(extension.lang)} · v${extension.version}`,
tags: extension.nsfw ? [{ text: '18+', className: 'nsfw' }] : [],
actions: [
{
@@ -216,9 +275,11 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
if (rows.length === 0) {
rows.push(
emptyNote(
hasRepos
? 'Every extension the configured repositories offer is already installed.'
: 'No repository configured, so there is nothing to install from.',
selectedLangs.size > 0
? 'No available extension matches the selected languages.'
: hasRepos
? 'Every extension the configured repositories offer is already installed.'
: 'No repository configured, so there is nothing to install from.',
),
);
}
@@ -231,7 +292,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
extensionsDirLabel.textContent = snapshot.extensionsDir;
renderRepos(snapshot.repos);
const repoFailures: Array<{ name: string; error: string }> = [];
repoFailures = [];
let available;
try {
available = await api.listAvailableExtensions();
@@ -245,13 +306,11 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
const offeredPkgs = new Set(available.extensions.map((extension) => extension.pkg));
renderInstalled(snapshot.installed, offeredPkgs, snapshot.extensionsDir);
renderAvailable(
// Installed extensions have their own section; leaving them here too
// would list every one of them twice.
available.extensions.filter((extension) => !extension.installed),
repoFailures,
snapshot.repos.length > 0,
);
// Installed extensions have their own section; leaving them here too would
// list every one of them twice.
installable = available.extensions.filter((extension) => !extension.installed);
hasRepos = snapshot.repos.length > 0;
renderAvailable();
}
repoAddButton.addEventListener('click', () => {
+4 -1
View File
@@ -115,7 +115,10 @@
<div class="ext-list" id="repo-list" aria-label="Repositories"></div>
<h3 class="ext-group-title">Available</h3>
<h3 class="ext-group-title">
Available <span class="ext-group-count" id="available-count"></span>
</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>
</section>
+49
View File
@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
collectLanguages,
filterByLanguage,
languageLabel,
pruneSelection,
toggleLanguage,
} from './language-filter';
const ext = (lang: string, name = lang) => ({ lang, name });
test('languageLabel names known tags and falls back to the raw code', () => {
assert.equal(languageLabel('ja'), 'Japanese');
assert.equal(languageLabel('all'), 'Multi-language');
assert.equal(languageLabel('zzzz'), 'ZZZZ');
});
test('collectLanguages dedupes, puts multi-language first, then sorts by name', () => {
// English, German, Japanese — display names, not codes.
assert.deepEqual(collectLanguages([ext('ja'), ext('de'), ext('ja'), ext('all'), ext('en')]), [
'all',
'en',
'de',
'ja',
]);
});
test('toggleLanguage adds, removes, and empties back to All', () => {
const one = toggleLanguage(new Set(), 'ja');
assert.deepEqual([...one], ['ja']);
const two = toggleLanguage(one, 'en');
assert.deepEqual([...two].sort(), ['en', 'ja']);
assert.deepEqual([...toggleLanguage(two, 'ja')], ['en']);
assert.equal(toggleLanguage(one, 'ja').size, 0);
});
test('pruneSelection drops languages no repository offers any more', () => {
assert.deepEqual([...pruneSelection(new Set(['ja', 'de']), ['ja', 'en'])], ['ja']);
});
test('filterByLanguage keeps everything when nothing is selected', () => {
const extensions = [ext('ja'), ext('en'), ext('all')];
assert.deepEqual(filterByLanguage(extensions, new Set()), extensions);
assert.deepEqual(
filterByLanguage(extensions, new Set(['ja', 'all'])).map((extension) => extension.name),
['ja', 'all'],
);
});
+80
View File
@@ -0,0 +1,80 @@
/**
* The language filter for the available-extensions list.
*
* A repository index carries every language it knows about, which is far more
* than any one user reads. The selection is a set of language codes; the empty
* set means "All", so picking a language always replaces "All" rather than
* sitting alongside it.
*/
/** The code a repository uses for an extension whose sources span languages. */
export const MULTI_LANG = 'all';
let displayNames: Intl.DisplayNames | null | undefined;
function languageDisplayNames(): Intl.DisplayNames | null {
if (displayNames === undefined) {
try {
displayNames = new Intl.DisplayNames(['en'], { type: 'language' });
} catch {
displayNames = null;
}
}
return displayNames;
}
/** `ja` → `Japanese`, `pt-BR` → `Brazilian Portuguese`, `all` → `Multi-language`. */
export function languageLabel(code: string): string {
if (code === MULTI_LANG) return 'Multi-language';
try {
const name = languageDisplayNames()?.of(code);
// Intl echoes the input back when it knows no name for the tag.
if (name && name !== code) return name;
} catch {
// An invalid tag throws; fall through to the raw code.
}
return code.toUpperCase();
}
/**
* The languages offered, ordered for the chip row: multi-language first
* because it is the odd one out, then alphabetically by display name.
*/
export function collectLanguages(extensions: ReadonlyArray<{ lang: string }>): string[] {
const codes = [...new Set(extensions.map((extension) => extension.lang))];
return codes.sort((a, b) => {
if (a === MULTI_LANG || b === MULTI_LANG) return a === MULTI_LANG ? -1 : 1;
return languageLabel(a).localeCompare(languageLabel(b));
});
}
/**
* The selection after clicking a language chip. Toggling the last selected
* language off leaves the empty set, which is "All" again.
*/
export function toggleLanguage(selected: ReadonlySet<string>, code: string): Set<string> {
const next = new Set(selected);
if (!next.delete(code)) next.add(code);
return next;
}
/**
* Drop the codes no repository offers any more, so a language that disappears
* when a repository is removed does not keep filtering the list invisibly.
*/
export function pruneSelection(
selected: ReadonlySet<string>,
available: ReadonlyArray<string>,
): Set<string> {
const offered = new Set(available);
return new Set([...selected].filter((code) => offered.has(code)));
}
/** The extensions to show; an empty selection shows everything. */
export function filterByLanguage<T extends { lang: string }>(
extensions: ReadonlyArray<T>,
selected: ReadonlySet<string>,
): T[] {
if (selected.size === 0) return [...extensions];
return extensions.filter((extension) => selected.has(extension.lang));
}
+54 -8
View File
@@ -207,8 +207,8 @@ body {
.source-picker {
display: flex;
flex-direction: column;
gap: 3px;
align-items: center;
gap: 8px;
flex: none;
min-width: 190px;
}
@@ -218,6 +218,12 @@ body {
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--faint);
white-space: nowrap;
}
.source-picker .text-input {
flex: 1 1 auto;
width: auto;
}
/* ---------- bridge banner ---------- */
@@ -446,9 +452,9 @@ body {
.detail-cover {
flex: none;
align-self: flex-start;
width: clamp(140px, 18vw, 232px);
aspect-ratio: 2 / 3;
object-fit: cover;
height: auto;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--ctp-crust);
@@ -536,7 +542,7 @@ body {
.cue-rail::before {
content: '';
position: absolute;
left: 41px;
left: 48px;
top: 6px;
bottom: 6px;
width: 1px;
@@ -546,8 +552,8 @@ body {
.cue {
position: relative;
display: grid;
grid-template-columns: 40px 1fr;
gap: 14px;
grid-template-columns: 34px 1fr;
gap: 26px;
align-items: baseline;
width: 100%;
padding: 9px 10px 9px 0;
@@ -564,7 +570,7 @@ body {
.cue::after {
content: '';
position: absolute;
left: 37px;
left: 43px;
top: 15px;
width: 7px;
height: 7px;
@@ -823,6 +829,46 @@ body {
display: none;
}
.lang-filter {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.lang-filter:not(:empty) {
margin-bottom: 10px;
}
.lang-chip {
padding: 4px 11px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--ctp-crust);
color: var(--muted);
font: inherit;
font-size: 11px;
letter-spacing: 0.04em;
cursor: pointer;
transition:
color 120ms ease,
border-color 120ms ease;
}
.lang-chip:hover {
color: var(--text);
border-color: var(--accent);
}
.lang-chip.is-active {
border-color: var(--accent);
color: var(--accent);
}
.lang-chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.ext-list {
display: flex;
flex-direction: column;
+22
View File
@@ -351,6 +351,28 @@ test('visibility and boolean parsers handle text values', () => {
assert.equal(asBoolean('0', true), false);
});
test('dispatchMpvProtocolMessage emits end-file with reason and file error', async () => {
const received: Array<{ reason: string; fileError: string | null }> = [];
const { deps } = createDeps({
emitEndFile: (payload) => {
received.push(payload);
},
});
await dispatchMpvProtocolMessage(
{ event: 'end-file', reason: 'error', file_error: 'no audio or video data played' },
deps,
);
await dispatchMpvProtocolMessage({ event: 'end-file', reason: 'eof' }, deps);
await dispatchMpvProtocolMessage({ event: 'end-file' }, deps);
assert.deepEqual(received, [
{ reason: 'error', fileError: 'no audio or video data played' },
{ reason: 'eof', fileError: null },
{ reason: 'unknown', fileError: null },
]);
});
test('dispatchMpvProtocolMessage emits client-message string args', async () => {
const received: Array<{ args: string[] }> = [];
const { deps } = createDeps({
+9
View File
@@ -7,6 +7,8 @@ export type MpvMessage = {
args?: unknown;
request_id?: number;
error?: string;
reason?: unknown;
file_error?: unknown;
};
export const MPV_REQUEST_ID_SUBTEXT = 101;
@@ -96,6 +98,7 @@ export interface MpvProtocolHandleMessageDeps {
shouldQuitOnMpvShutdown: () => boolean;
requestAppQuit: () => void;
emitClientMessage?: (payload: { args: string[] }) => void;
emitEndFile?: (payload: { reason: string; fileError: string | null }) => void;
}
type SubtitleTrackCandidate = {
@@ -385,6 +388,12 @@ export async function dispatchMpvProtocolMessage(
if (args.length > 0) {
deps.emitClientMessage?.({ args });
}
} else if (msg.event === 'end-file') {
deps.emitEndFile?.({
reason: typeof msg.reason === 'string' ? msg.reason : 'unknown',
fileError:
typeof msg.file_error === 'string' && msg.file_error.length > 0 ? msg.file_error : null,
});
} else if (msg.event === 'shutdown') {
deps.restorePreviousSecondarySubVisibility();
if (deps.shouldQuitOnMpvShutdown()) {
+4
View File
@@ -131,6 +131,7 @@ export interface MpvIpcClientEventMap {
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
'secondary-subtitle-visibility': { visible: boolean };
'client-message': { args: string[] };
'end-file': { reason: string; fileError: string | null };
}
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
@@ -496,6 +497,9 @@ export class MpvIpcClient implements MpvClient {
emitClientMessage: (payload) => {
this.emit('client-message', payload);
},
emitEndFile: (payload) => {
this.emit('end-file', payload);
},
};
}
+62 -1
View File
@@ -1,6 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ensureOverlayWindowLevel, updateOverlayWindowBounds } from './overlay-window';
import {
ensureOverlayWindowLevel,
updateOverlayWindowBounds,
scheduleOverlayContentReadyFallback,
OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS,
} from './overlay-window';
import {
handleOverlayWindowBeforeInputEvent,
handleOverlayWindowBlurred,
@@ -322,3 +327,59 @@ test('updateOverlayWindowBounds aligns Linux overlay content bounds to mpv geome
assert.deepEqual(calls, [{ x: 0, y: -14, width: 3440, height: 1454 }]);
});
test('scheduleOverlayContentReadyFallback marks content ready when ready-to-show never fired', () => {
const events: string[] = [];
const scheduled: Array<() => void> = [];
scheduleOverlayContentReadyFallback({
isContentReady: () => false,
isDestroyed: () => false,
markContentReady: () => events.push('mark'),
setTimeoutFn: (callback, delayMs) => {
events.push(`schedule:${delayMs}`);
scheduled.push(callback);
return null;
},
});
assert.deepEqual(events, [`schedule:${OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS}`]);
scheduled[0]?.();
assert.deepEqual(events, [`schedule:${OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS}`, 'mark']);
});
test('scheduleOverlayContentReadyFallback no-ops once content is already ready', () => {
const scheduled: Array<() => void> = [];
const events: string[] = [];
scheduleOverlayContentReadyFallback({
isContentReady: () => true,
isDestroyed: () => false,
markContentReady: () => events.push('mark'),
setTimeoutFn: (callback) => {
scheduled.push(callback);
return null;
},
});
scheduled[0]?.();
assert.deepEqual(events, []);
});
test('scheduleOverlayContentReadyFallback no-ops after the window is destroyed', () => {
const scheduled: Array<() => void> = [];
const events: string[] = [];
scheduleOverlayContentReadyFallback({
isContentReady: () => false,
isDestroyed: () => true,
markContentReady: () => events.push('mark'),
setTimeoutFn: (callback) => {
scheduled.push(callback);
return null;
},
});
scheduled[0]?.();
assert.deepEqual(events, []);
});
+42 -7
View File
@@ -77,6 +77,31 @@ export function updateOverlayWindowBounds(
});
}
// ready-to-show is the primary content-ready signal, but it never fires when the
// page navigates while the window is hidden: an explicit hide() ends the
// paintWhenInitiallyHidden grace, the hidden widget stops producing frames, and
// the new document never paints. The Yomitan content-script reload right after
// startup hits exactly that, and content-ready gates showing the window, so
// without a fallback the overlay deadlocks behind the "Overlay loading" OSD.
export const OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS = 1500;
export function scheduleOverlayContentReadyFallback(deps: {
isContentReady: () => boolean;
isDestroyed: () => boolean;
markContentReady: () => void;
setTimeoutFn?: (callback: () => void, delayMs: number) => unknown;
delayMs?: number;
}): void {
const setTimeoutFn =
deps.setTimeoutFn ?? ((callback: () => void, delayMs: number): unknown => setTimeout(callback, delayMs));
setTimeoutFn(() => {
if (deps.isContentReady() || deps.isDestroyed()) {
return;
}
deps.markContentReady();
}, deps.delayMs ?? OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS);
}
export function ensureOverlayWindowLevel(window: BrowserWindow): void {
if (process.platform === 'darwin') {
window.setAlwaysOnTop(true, 'screen-saver', 1);
@@ -149,10 +174,26 @@ export function createOverlayWindow(
logger.error('Page failed to load:', errorCode, errorDescription, validatedURL);
});
const markContentReady = (): void => {
if (isOverlayWindowContentReady(window)) {
return;
}
overlayWindowContentReady.add(window);
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] = true;
options.onWindowContentReady?.();
};
window.webContents.on('did-finish-load', () => {
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
options.onRuntimeOptionsChanged();
options.onWindowDidFinishLoad?.();
scheduleOverlayContentReadyFallback({
isContentReady: () => isOverlayWindowContentReady(window),
isDestroyed: () => window.isDestroyed(),
markContentReady,
});
});
window.webContents.on('page-title-updated', (event) => {
@@ -160,13 +201,7 @@ export function createOverlayWindow(
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
});
window.once('ready-to-show', () => {
overlayWindowContentReady.add(window);
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] = true;
options.onWindowContentReady?.();
});
window.once('ready-to-show', markContentReady);
if (kind === 'visible') {
window.webContents.on('devtools-opened', () => {
+11
View File
@@ -3232,6 +3232,17 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
}),
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
ensureMpvConnected: () => ensureMpvConnectedForPlayback(),
onPlaybackEndFile: (listener) => {
const client = appState.mpvClient;
if (!client) return () => {};
client.on('end-file', listener);
return () => client.off('end-file', listener);
},
readMpvProperty: (name) => {
const client = appState.mpvClient;
if (!client) return Promise.reject(new Error('mpv is not connected.'));
return client.requestProperty(name);
},
showVisibleOverlay: () => {
// Launching a video turns a browse-only instance into a regular SubMiner
// playback session: tray icon plus the overlay runtime that
+7 -4
View File
@@ -2,8 +2,9 @@ import { spawn } from 'node:child_process';
import { chmod, mkdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
BUNDLE_RELEASES_URL,
bundleReleaseUrl,
findBundleBinaries,
PINNED_BUNDLE_TAG,
resolveBundleAssetName,
selectBundleAsset,
verifyPinnedBundle,
@@ -121,16 +122,18 @@ export async function ensureBridgeBinaries(options: EnsureBridgeOptions): Promis
}
options.onProgress?.({ stage: 'locating', progress: null });
const releasesResponse = await fetchImpl(BUNDLE_RELEASES_URL, {
const releasesResponse = await fetchImpl(bundleReleaseUrl(), {
headers: { Accept: 'application/vnd.github+json' },
signal: AbortSignal.timeout(RELEASES_TIMEOUT_MS),
});
if (!releasesResponse.ok) {
throw new Error(`Could not list anime bridge releases (${releasesResponse.status}).`);
throw new Error(
`Could not read anime bridge release ${PINNED_BUNDLE_TAG} (${releasesResponse.status}).`,
);
}
const asset = selectBundleAsset(await releasesResponse.json(), assetName);
if (asset === null) {
throw new Error(`No published anime bridge release contains ${assetName}.`);
throw new Error(`Anime bridge release ${PINNED_BUNDLE_TAG} has no ${assetName}.`);
}
options.onProgress?.({ stage: 'downloading', progress: 0 });
+187 -32
View File
@@ -1,6 +1,18 @@
import { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
import { resolveStream } from '../../anime-bridge/headers';
import { parseAnimeStatus, resolveBridgeMediaUrl } from '../../anime-bridge/media-url';
import {
parseAnimeStatus,
resolveBridgeMediaUrl,
routeHlsThroughProxy,
} from '../../anime-bridge/media-url';
import {
watchPlaybackOutcome,
type PlaybackEndFileEvent,
} from '../../anime-bridge/playback-outcome';
import {
startStreamStripProxy,
type StreamStripProxyHandle,
} from '../../anime-bridge/stream-strip-proxy';
import {
buildPlaybackCommands,
buildTrackCommands,
@@ -14,6 +26,11 @@ import {
type ExtensionSource,
type InstalledExtension,
} from '../../anime-bridge/extension-store';
import {
cacheSubtitleTracks,
removeSubtitleCache,
type SubtitleCacheIo,
} from '../../anime-bridge/subtitle-cache';
import { interleave, mapSourcesConcurrently } from '../../anime-bridge/multi-source-search';
import { startSidecar, type SidecarHandle } from '../../anime-bridge/sidecar-process';
import {
@@ -60,10 +77,20 @@ export interface AnimeBrowserRuntimeDeps {
sendMpvCommand: (command: Array<string | number>) => void;
/** Brings mpv up if it is not already connected. Resolves false on failure. */
ensureMpvConnected: () => Promise<boolean>;
/**
* Subscribe to mpv end-file events; returns the unsubscribe. Together with
* readMpvProperty this lets playEpisode confirm playback really started
* instead of reporting success the moment the commands were written.
*/
onPlaybackEndFile?: (listener: (event: PlaybackEndFileEvent) => void) => () => void;
/** One-shot mpv property read; rejects while the property is unavailable. */
readMpvProperty?: (name: string) => Promise<unknown>;
showMpvOsd?: (message: string) => void;
showVisibleOverlay?: () => void;
/** Lets tests drive the pause between `loadfile` and the track commands. */
wait?: (ms: number) => Promise<void>;
/** Overrides the filesystem/network the subtitle cache uses. Tests only. */
subtitleCacheIo?: SubtitleCacheIo;
onBridgeState: (state: AnimeBrowserBridgeState) => void;
/**
* Streams per-source progress while a search invoke is pending. Optional so
@@ -82,6 +109,7 @@ const TRACK_ATTACH_DELAY_MS = 300;
export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
let bridgeState: AnimeBrowserBridgeState = IDLE_STATE;
let sidecar: SidecarHandle | null = null;
let stripProxy: StreamStripProxyHandle | null = null;
let starting: Promise<SidecarHandle> | null = null;
let extensions: InstalledExtension[] = [];
let sources: ExtensionSource[] = [];
@@ -89,10 +117,54 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
let loadFailures: ExtensionLoadFailure[] = [];
// Monotonic; identifies the newest browse so stale ones stop emitting.
let searchToken = 0;
// Temp directory holding the playing episode's downloaded subtitles. Kept
// until the next episode replaces it, because alass reads it mid-playback.
let subtitleCacheDir: string | null = null;
const preferenceStore = new PreferenceStore(deps.preferencesFile);
const wait =
deps.wait ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
/**
* Drop the previous episode's downloaded subtitles.
*
* Deliberately not done at end-file: alass reads those files for as long as
* the episode is up, so they only go once another one replaces them.
*/
async function clearSubtitleCache(): Promise<void> {
const previousDir = subtitleCacheDir;
subtitleCacheDir = null;
await removeSubtitleCache(previousDir, deps.subtitleCacheIo);
}
/**
* Download the stream's subtitle tracks so mpv loads files instead of URLs.
*
* alass needs the reference track on disk, and the subsync picker rejects an
* external track whose `external-filename` is not a real file, so a streamed
* track cannot be used to retime anything.
*/
async function cacheStreamSubtitles(stream: {
headers: Record<string, string>;
subtitles: Array<{ url: string; lang: string }>;
}): Promise<Array<{ url: string; lang: string }>> {
const cached = await cacheSubtitleTracks({
tracks: stream.subtitles,
headers: stream.headers,
io: deps.subtitleCacheIo,
log: deps.log,
});
subtitleCacheDir = cached.dir;
const localCount = cached.tracks.filter((track) => track.local).length;
if (cached.tracks.length > 0) {
deps.log(
`[anime-browser] cached ${localCount}/${cached.tracks.length} subtitle track(s) to disk` +
(cached.dir ? ` in ${cached.dir}` : ''),
);
}
return cached.tracks.map((track) => ({ url: track.url, lang: track.lang }));
}
function setState(state: AnimeBrowserBridgeState): void {
bridgeState = state;
deps.onBridgeState(state);
@@ -108,8 +180,17 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
return { ...toBridgeSource(extension, source.id), preferences: saved };
}
function requireBridge(): { client: AnimeBridgeClient; baseUrl: string } {
if (!sidecar) throw new Error('The anime bridge is not running yet.');
/**
* The live bridge, starting (or restarting) it first when there is none.
* A bridge that died out from under the app — killed, crashed, stopped —
* comes back on the next request instead of failing every call until the
* app restarts.
*/
async function bridge(): Promise<{ client: AnimeBridgeClient; baseUrl: string }> {
if (!sidecar) await ensureBridge();
if (!sidecar) {
throw new Error(bridgeState.message ?? 'The anime bridge is not running.');
}
return { client: sidecar.client, baseUrl: sidecar.baseUrl };
}
@@ -125,6 +206,42 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
});
sidecar = handle;
// A deliberate stop detaches first (dispose nulls `sidecar` before
// stopping, a restart replaces it), so reaching the body means the bridge
// died out from under us and the next request should bring it back.
handle.onExit(({ code, signal }) => {
if (sidecar !== handle) return;
sidecar = null;
starting = null;
const proxy = stripProxy;
stripProxy = null;
void proxy?.close();
deps.log(
`[anime-browser] bridge exited unexpectedly (code ${code}, signal ${signal}); ` +
'it will restart on the next request',
);
setState({
stage: 'idle',
progress: null,
message: 'The anime bridge stopped. It restarts on the next search.',
});
});
try {
const proxy = await startStreamStripProxy({
upstreamOrigin: () => sidecar?.baseUrl ?? handle.baseUrl,
log: deps.log,
});
// The bridge can die while the proxy is coming up, in which case onExit
// already ran and cleared `stripProxy`; adopting this one would leak a
// listening server pointed at a dead upstream.
if (sidecar === handle) stripProxy = proxy;
else void proxy.close();
} catch (error) {
// Playback still works for undisguised streams; log and carry on.
deps.log(`[anime-browser] stream proxy failed to start: ${describeError(error)}`);
}
await scanExtensions(handle);
return handle;
}
@@ -225,7 +342,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
page: number,
) => Promise<BridgeAnimePage>,
): Promise<AnimeBrowserSearchResult> {
const { baseUrl } = requireBridge();
const { baseUrl } = await bridge();
const targets =
selectedSourceId === ALL_SOURCES_ID ? sources : [requireSource(selectedSourceId)];
if (targets.length === 0) throw new Error('No sources are installed.');
@@ -371,7 +488,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
* what it has no memory of between requests.
*/
async getPreferences(sourceId: string): Promise<SourcePreferenceView[]> {
const { client } = requireBridge();
const { client } = await bridge();
const source = requireSource(sourceId);
const schema = await client.getSourcePreferences(await sourceFor(source.id));
return parsePreferences(schema);
@@ -386,7 +503,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
key: string,
value: string | string[] | boolean,
): Promise<SourcePreferenceView[]> {
const { client } = requireBridge();
const { client } = await bridge();
const source = await sourceFor(sourceId);
// Start from the extension's own schema so saved values never go stale
@@ -405,19 +522,19 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
},
async search(query: string, page = 1): Promise<AnimeBrowserSearchResult> {
const { client } = requireBridge();
const { client } = await bridge();
return browse(page, (source, requestedPage) =>
client.searchAnime(source, query, requestedPage),
);
},
async getPopular(page = 1): Promise<AnimeBrowserSearchResult> {
const { client } = requireBridge();
const { client } = await bridge();
return browse(page, (source, requestedPage) => client.getPopularAnime(source, requestedPage));
},
async getDetails(animeUrl: string, sourceId?: string): Promise<AnimeBrowserDetails> {
const { client, baseUrl } = requireBridge();
const { client, baseUrl } = await bridge();
const source = requireSource(sourceId ?? selectedSourceId);
const details = await client.getAnimeDetails(await sourceFor(source.id), animeUrl);
return {
@@ -430,7 +547,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
},
async getEpisodes(animeUrl: string, sourceId?: string): Promise<AnimeBrowserEpisode[]> {
const { client } = requireBridge();
const { client } = await bridge();
const source = requireSource(sourceId ?? selectedSourceId);
const episodes = await client.getEpisodeList(await sourceFor(source.id), animeUrl);
return episodes.map((episode) => ({
@@ -447,7 +564,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
async playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> {
try {
const { client, baseUrl } = requireBridge();
const { client, baseUrl } = await bridge();
const videos = await client.getVideoList(
await sourceFor(request.sourceId),
request.episodeUrl,
@@ -470,10 +587,15 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
})),
}));
const stream = selectPreferredStream(streams, deps.preferredQuality?.());
if (!stream) {
const selected = selectPreferredStream(streams, deps.preferredQuality?.());
if (!selected) {
return { ok: false, error: 'That source returned no playable video.', quality: null };
}
// HLS goes through the local strip proxy, which undoes fake-image
// segment disguises mpv cannot decode around.
const stream = stripProxy
? { ...selected, url: routeHlsThroughProxy(selected.url, baseUrl, stripProxy.origin) }
: selected;
if (!(await deps.ensureMpvConnected())) {
return {
@@ -483,29 +605,56 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
};
}
const title = `${request.animeTitle} — ${request.episodeName}`;
for (const command of buildPlaybackCommands({ stream, title })) {
deps.sendMpvCommand(command);
}
// Subscribed before loadfile so a fast failure cannot slip past it.
const watch =
deps.onPlaybackEndFile && deps.readMpvProperty
? watchPlaybackOutcome({
onEndFile: deps.onPlaybackEndFile,
readProperty: deps.readMpvProperty,
wait,
})
: null;
const trackCommands = buildTrackCommands(stream);
if (trackCommands.length > 0) {
deps.log(
`[anime-browser] ${stream.audios.length} external audio, ` +
`${stream.subtitles.length} external subtitle track(s)`,
);
// mpv attaches added tracks to the file that is loading, so give the
// loadfile a moment to take effect first. Same pause the Jellyfin
// subtitle preload uses.
await wait(TRACK_ATTACH_DELAY_MS);
for (const command of trackCommands) {
try {
const title = `${request.animeTitle} — ${request.episodeName}`;
for (const command of buildPlaybackCommands({ stream, title })) {
deps.sendMpvCommand(command);
}
}
// The old episode's subtitles are dead the moment this one loads,
// whether or not the new one brings any of its own.
await clearSubtitleCache();
deps.showVisibleOverlay?.();
deps.showMpvOsd?.(title);
return { ok: true, error: null, quality: stream.quality || null };
if (stream.audios.length > 0 || stream.subtitles.length > 0) {
deps.log(
`[anime-browser] ${stream.audios.length} external audio, ` +
`${stream.subtitles.length} external subtitle track(s)`,
);
// mpv attaches added tracks to the file that is loading, so give the
// loadfile a moment to take effect first. Same pause the Jellyfin
// subtitle preload uses. The download runs inside that pause.
const [subtitles] = await Promise.all([
cacheStreamSubtitles(stream),
wait(TRACK_ATTACH_DELAY_MS),
]);
for (const command of buildTrackCommands({ ...stream, subtitles })) {
deps.sendMpvCommand(command);
}
}
if (watch) {
const outcome = await watch.wait();
if (!outcome.ok) {
deps.log(`[anime-browser] playback failed to start: ${outcome.error}`);
return { ok: false, error: outcome.error, quality: null };
}
}
deps.showVisibleOverlay?.();
deps.showMpvOsd?.(title);
return { ok: true, error: null, quality: stream.quality || null };
} finally {
watch?.dispose();
}
} catch (error) {
deps.log(`[anime-browser] playback failed: ${String(error)}`);
return { ok: false, error: describeError(error), quality: null };
@@ -514,9 +663,15 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
async dispose(): Promise<void> {
const handle = sidecar;
const proxy = stripProxy;
const cacheDir = subtitleCacheDir;
sidecar = null;
stripProxy = null;
starting = null;
subtitleCacheDir = null;
setState(IDLE_STATE);
await removeSubtitleCache(cacheDir, deps.subtitleCacheIo);
await proxy?.close();
await handle?.stop();
},
};