mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-13 16:46:08 -08:00
Compare commits
12
Commits
f5e98dfa9d
...
336e9fb8a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
336e9fb8a5
|
||
|
|
dec7851a17
|
||
|
|
4eaaf5769f
|
||
|
|
0dfba67ae8
|
||
|
|
18e0200934
|
||
|
|
d7188a2155
|
||
|
|
1c50ff74cb
|
||
|
|
c2781a3f80
|
||
|
|
ff25e5cafa
|
||
|
|
7254db66ad
|
||
|
|
3c19228b35
|
||
|
|
2e34f01adf
|
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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());
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user