fix(anime): scope preferences, paginate sources, harden installs

- Preference store keys entries by extension package + bridge source id; legacy unscoped entries are discarded once instead of being handed to whichever extension asks first
- Source picker's "Load more" appends the next page without duplicating streamed results
- Repository index fetches and subtitle/APK downloads now time out and are size-bounded instead of hanging or growing unbounded
- APK installs are staged to a temp file and renamed into place
- Stream metadata lookup matches the requested path, not only the currently playing one
- Reworked animeui into browse-state/detail-panel/panels.css modules
- Reverted premature CHANGELOG unreleased entries; refreshed anime-browser docs
This commit is contained in:
2026-08-02 00:50:14 -07:00
parent 30f79857d8
commit c07ba665ba
52 changed files with 2471 additions and 1598 deletions
+56 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
@@ -164,6 +164,61 @@ test('the byte limit stops the read instead of buffering the whole body', async
assert.ok(pushed <= 4, `read ${pushed} chunks before aborting`);
});
test('a failed reader cancellation does not hide the size-limit error', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
const body = new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(new Uint8Array(1025));
},
async cancel() {
throw new Error('cancel failed');
},
});
const fetchImpl = (async () => new Response(body, { status: 200 })) as typeof fetch;
await assert.rejects(
() =>
installExtension({
extensionsDir: dir,
extension: repoExtension(),
fetchImpl,
maxBytes: 1024,
}),
/larger than the 1024 byte limit/,
);
});
test('a failed staged write preserves the installed apk and removes the partial file', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
const target = path.join(dir, `${PKG}.apk`);
await writeFile(target, apkBytes('OLD'));
let stagedPath = '';
await assert.rejects(
() =>
installExtension({
extensionsDir: dir,
extension: repoExtension({ version: '2.0.0', versionCode: 20 }),
fetchImpl: respondWith(apkBytes('NEW')),
fileIo: {
mkdir: (dirPath) => mkdir(dirPath, { recursive: true }),
async writeFile(filePath, bytes) {
stagedPath = filePath;
await writeFile(filePath, bytes.subarray(0, 5));
throw new Error('simulated disk write failure');
},
rename,
removeFile: (filePath) => rm(filePath, { force: true }),
},
}),
/simulated disk write failure/,
);
assert.match((await readFile(target)).toString(), /OLD/);
assert.notEqual(stagedPath, target);
assert.equal(existsSync(stagedPath), false);
});
test('a package name carrying path separators cannot escape the extensions dir', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
const dir = path.join(root, 'extensions');
+32 -4
View File
@@ -1,4 +1,5 @@
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { extensionFileName, type RepoExtension } from './extension-repo';
@@ -19,8 +20,24 @@ export interface InstallExtensionOptions {
signal?: AbortSignal;
/** Applied when no `signal` is given, so a download can never hang forever. */
timeoutMs?: number;
/** Injectable filesystem boundary for failure-path tests. */
fileIo?: ExtensionInstallerFileIo;
}
export interface ExtensionInstallerFileIo {
mkdir: (dir: string) => Promise<unknown>;
writeFile: (filePath: string, bytes: Uint8Array) => Promise<void>;
rename: (from: string, to: string) => Promise<void>;
removeFile: (filePath: string) => Promise<void>;
}
const DEFAULT_FILE_IO: ExtensionInstallerFileIo = {
mkdir: (dir) => mkdir(dir, { recursive: true }),
writeFile: (filePath, bytes) => writeFile(filePath, bytes),
rename,
removeFile: (filePath) => rm(filePath, { force: true }),
};
/** APKs are a few MB; anything far past that is not an extension. */
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
@@ -41,6 +58,7 @@ export function looksLikeApk(bytes: Uint8Array): boolean {
*/
export async function installExtension(options: InstallExtensionOptions): Promise<string> {
const fetchImpl = options.fetchImpl ?? fetch;
const fileIo = options.fileIo ?? DEFAULT_FILE_IO;
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const signal = options.signal ?? AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
@@ -59,9 +77,17 @@ export async function installExtension(options: InstallExtensionOptions): Promis
throw new Error(`${options.extension.name} did not download as an APK.`);
}
await mkdir(options.extensionsDir, { recursive: true });
await fileIo.mkdir(options.extensionsDir);
const target = resolveTarget(options.extensionsDir, options.extension.pkg);
await writeFile(target, bytes);
const staged = `${target}.${randomUUID()}.tmp`;
try {
await fileIo.writeFile(staged, bytes);
await fileIo.rename(staged, target);
} finally {
try {
await fileIo.removeFile(staged);
} catch {}
}
return target;
}
@@ -93,7 +119,9 @@ async function readBounded(
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel();
try {
await reader.cancel();
} catch {}
throw new Error(`${name} is larger than the ${maxBytes} byte limit.`);
}
chunks.push(value);
+12
View File
@@ -144,6 +144,18 @@ test('fetchRepoIndex surfaces a non-ok response', async () => {
await assert.rejects(() => fetchRepoIndex(INDEX, { fetchImpl }), /404/);
});
test('fetchRepoIndex applies a deadline when the caller supplies no signal', async () => {
let receivedSignal: AbortSignal | undefined;
const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => {
receivedSignal = init?.signal instanceof AbortSignal ? init.signal : undefined;
return new Response('[]');
}) as typeof fetch;
await fetchRepoIndex(INDEX, { fetchImpl, timeoutMs: 50 });
assert.ok(receivedSignal, 'repository request should receive a deadline signal');
});
test('fetchRepoCatalogue merges repos and keeps the highest version code', async () => {
const second = 'https://other.example/anime/index.min.json';
const fetchImpl = (async (input: RequestInfo | URL) => {
+7 -1
View File
@@ -115,8 +115,12 @@ export function parseRepoIndex(indexUrl: string, payload: unknown): RepoExtensio
export interface FetchRepoOptions {
fetchImpl?: typeof fetch;
signal?: AbortSignal;
/** Applied when no signal is supplied, so one stalled repo cannot block the catalogue. */
timeoutMs?: number;
}
const DEFAULT_REPO_TIMEOUT_MS = 15_000;
/** Fetch and parse one repository index. */
export async function fetchRepoIndex(
indexUrl: string,
@@ -126,9 +130,11 @@ export async function fetchRepoIndex(
throw new Error(`Not a valid repository index URL: ${indexUrl}`);
}
const fetchImpl = options.fetchImpl ?? fetch;
const signal =
options.signal ?? AbortSignal.timeout(options.timeoutMs ?? DEFAULT_REPO_TIMEOUT_MS);
const response = await fetchImpl(indexUrl.trim(), {
headers: { Accept: 'application/json' },
...(options.signal ? { signal: options.signal } : {}),
signal,
});
if (!response.ok) {
throw new Error(`Repository returned ${response.status} for ${indexUrl}`);
+23 -4
View File
@@ -87,12 +87,31 @@ test('listExtensionSources flattens every source a factory apk provides', async
const sources = await listExtensionSources(client, extensions);
assert.equal(sources.length, 2);
// Numeric ids are normalized to strings so they can key UI state.
assert.equal(sources[0]?.id, '101');
// Numeric bridge ids are normalized and package-qualified for UI state.
assert.equal(sources[0]?.id, 'multi:101');
assert.equal(sources[0]?.bridgeId, '101');
assert.equal(sources[0]?.name, 'Source One');
assert.equal(sources[1]?.lang, 'ja');
});
test('sources with the same bridge id in different packages have distinct runtime ids', async () => {
const extensions: InstalledExtension[] = [
{ file: '/x/one.apk', fallbackName: 'pkg.one', sha256: 'hash-one' },
{ file: '/x/two.apk', fallbackName: 'pkg.two', sha256: 'hash-two' },
];
const client = fakeClient(async () => [{ id: 'shared', name: 'Source', lang: 'en' }]);
const sources = await listExtensionSources(client, extensions);
assert.deepEqual(
sources.map(({ id, bridgeId, pkg }) => ({ id, bridgeId, pkg })),
[
{ id: 'pkg.one:shared', bridgeId: 'shared', pkg: 'pkg.one' },
{ id: 'pkg.two:shared', bridgeId: 'shared', pkg: 'pkg.two' },
],
);
});
test('listExtensionSources falls back to the file name and a default language', async () => {
const extensions: InstalledExtension[] = [
{ file: '/x/my-ext.apk', fallbackName: 'my-ext', sha256: 'hash-a' },
@@ -117,8 +136,8 @@ test('toInstalledExtensionViews names an extension after the sources it provides
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' },
];
const sources: ExtensionSource[] = [
{ id: '1', name: 'One', lang: 'en', pkg: 'multi', file: '/x/multi.apk' },
{ id: '2', name: 'Two', lang: 'ja', pkg: 'multi', file: '/x/multi.apk' },
{ id: 'multi:1', bridgeId: '1', name: 'One', lang: 'en', pkg: 'multi', file: '/x/multi.apk' },
{ id: 'multi:2', bridgeId: '2', name: 'Two', lang: 'ja', pkg: 'multi', file: '/x/multi.apk' },
];
assert.deepEqual(toInstalledExtensionViews(extensions, sources, []), [
+7 -4
View File
@@ -26,8 +26,10 @@ export interface InstalledExtension {
}
export interface ExtensionSource {
/** Stable id: the bridge source id, which selects it inside a factory APK. */
/** Package-qualified id used by the UI and runtime. */
id: string;
/** Raw bridge id, which selects this source inside a factory APK. */
bridgeId: string;
name: string;
lang: string;
pkg: string;
@@ -126,10 +128,11 @@ export async function listExtensionSources(
try {
const descriptors = await client.listAnimeSources(toBridgeSource(extension));
for (const descriptor of descriptors) {
const id = descriptor.id === undefined ? null : String(descriptor.id);
if (id === null || id.length === 0) continue;
const bridgeId = descriptor.id === undefined ? null : String(descriptor.id);
if (bridgeId === null || bridgeId.length === 0) continue;
sources.push({
id,
id: `${extension.fallbackName}:${bridgeId}`,
bridgeId,
name: descriptor.name?.trim() || extension.fallbackName,
lang: descriptor.lang ?? 'all',
pkg: extension.fallbackName,
+76 -17
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
import { chmod, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { PreferenceStore } from './preference-store';
@@ -12,9 +12,49 @@ async function storeFile(): Promise<string> {
test('values round-trip through the file', async () => {
const file = await storeFile();
await new PreferenceStore(file).set('src-1', [{ key: 'address' }]);
await new PreferenceStore(file).set('pkg', 'src-1', [{ key: 'address' }]);
assert.deepEqual(await new PreferenceStore(file).get('src-1'), [{ key: 'address' }]);
assert.deepEqual(await new PreferenceStore(file).get('pkg', 'src-1'), [{ key: 'address' }]);
});
test('the same bridge source id is isolated between extension packages', async () => {
const file = await storeFile();
const store = new PreferenceStore(file);
await store.set('pkg.one', 'shared-source', [{ key: 'password', value: 'one-secret' }]);
await store.set('pkg.two', 'shared-source', [{ key: 'password', value: 'two-secret' }]);
const reloaded = new PreferenceStore(file);
assert.deepEqual(await reloaded.get('pkg.one', 'shared-source'), [
{ key: 'password', value: 'one-secret' },
]);
assert.deepEqual(await reloaded.get('pkg.two', 'shared-source'), [
{ key: 'password', value: 'two-secret' },
]);
});
test('a legacy bare source id is discarded rather than assigned to an unproven package', async () => {
const file = await storeFile();
await writeFile(file, JSON.stringify({ 'legacy-source': [{ key: 'address', value: 'saved' }] }));
const store = new PreferenceStore(file);
assert.deepEqual(await store.get('pkg.one', 'legacy-source'), []);
const persisted = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown>;
assert.equal(persisted['legacy-source'], undefined);
assert.equal(persisted['pkg.one:legacy-source'], undefined);
});
test('an ambiguous legacy source id is discarded instead of exposed to either package', async () => {
const file = await storeFile();
await writeFile(file, JSON.stringify({ shared: [{ key: 'password', value: 'old-secret' }] }));
const store = new PreferenceStore(file);
assert.deepEqual(await store.get('pkg.one', 'shared'), []);
assert.deepEqual(await store.get('pkg.two', 'shared'), []);
const persisted = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown>;
assert.equal(persisted.shared, undefined);
});
test('concurrent writes on a cold cache do not lose an update', async () => {
@@ -23,30 +63,34 @@ test('concurrent writes on a cold cache do not lose an update', async () => {
// Both start before either has loaded; unserialized they would each get their
// own object and the later persist would drop the other's entry.
await Promise.all([store.set('src-1', [{ key: 'a' }]), store.set('src-2', [{ key: 'b' }])]);
await Promise.all([
store.set('pkg', 'src-1', [{ key: 'a' }]),
store.set('pkg', 'src-2', [{ key: 'b' }]),
]);
const reloaded = new PreferenceStore(file);
assert.deepEqual(await reloaded.get('src-1'), [{ key: 'a' }]);
assert.deepEqual(await reloaded.get('src-2'), [{ key: 'b' }]);
assert.deepEqual(await reloaded.get('pkg', 'src-1'), [{ key: 'a' }]);
assert.deepEqual(await reloaded.get('pkg', 'src-2'), [{ key: 'b' }]);
});
test('a clear racing a set is applied in order', async () => {
const file = await storeFile();
const store = new PreferenceStore(file);
await store.set('pkg:src', [{ key: 'password' }]);
await store.set('pkg', 'src', [{ key: 'password' }]);
await Promise.all([store.clear('pkg'), store.set('other:src', [{ key: 'x' }])]);
await Promise.all([store.clear('pkg'), store.set('other', 'src', [{ key: 'x' }])]);
const reloaded = new PreferenceStore(file);
assert.deepEqual(await reloaded.get('pkg:src'), []);
assert.deepEqual(await reloaded.get('other:src'), [{ key: 'x' }]);
assert.deepEqual(await reloaded.get('pkg', 'src'), []);
assert.deepEqual(await reloaded.get('other', 'src'), [{ key: 'x' }]);
});
test('the file is written owner-only and leaves no temporary behind', async () => {
test('the file is written owner-only even when an existing temporary file is permissive', async () => {
const file = await storeFile();
await new PreferenceStore(file).set('src-1', [{ key: 'password' }]);
await writeFile(`${file}.tmp`, 'stale');
await chmod(`${file}.tmp`, 0o666);
await new PreferenceStore(file).set('pkg', 'src-1', [{ key: 'password' }]);
const { stat } = await import('node:fs/promises');
assert.equal((await stat(file)).mode & 0o777, 0o600);
assert.deepEqual(await readdir(path.dirname(file)), [path.basename(file)]);
});
@@ -55,15 +99,30 @@ test('a corrupt file starts empty rather than blocking the browser', async () =>
const file = await storeFile();
await writeFile(file, '{ not json');
assert.deepEqual(await new PreferenceStore(file).get('src-1'), []);
assert.deepEqual(await new PreferenceStore(file).get('pkg', 'src-1'), []);
});
test('malformed persisted values are filtered to preference objects with string keys', async () => {
const file = await storeFile();
await writeFile(
file,
JSON.stringify({
'pkg:source': [null, { key: 42 }, 'bad', { key: 'valid', value: 'kept' }],
'pkg:not-an-array': { key: 'invalid-container' },
}),
);
const store = new PreferenceStore(file);
assert.deepEqual(await store.get('pkg', 'source'), [{ key: 'valid', value: 'kept' }]);
assert.deepEqual(await store.get('pkg', 'not-an-array'), []);
});
test('a write replaces the previous contents wholesale', async () => {
const file = await storeFile();
const store = new PreferenceStore(file);
await store.set('src-1', [{ key: 'first' }]);
await store.set('src-1', [{ key: 'second' }]);
await store.set('pkg', 'src-1', [{ key: 'first' }]);
await store.set('pkg', 'src-1', [{ key: 'second' }]);
const parsed = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown[]>;
assert.deepEqual(parsed['src-1'], [{ key: 'second' }]);
assert.deepEqual(parsed['pkg:src-1'], [{ key: 'second' }]);
});
+39 -10
View File
@@ -1,9 +1,27 @@
import { readFile, writeFile, rename, rm, mkdir } from 'node:fs/promises';
import { chmod, readFile, writeFile, rename, rm, mkdir } from 'node:fs/promises';
import path from 'node:path';
import type { BridgePreference } from './types';
function parseStoredPreferences(value: unknown): Record<string, BridgePreference[]> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {};
const parsed: Record<string, BridgePreference[]> = {};
for (const [key, entries] of Object.entries(value)) {
if (!Array.isArray(entries)) continue;
parsed[key] = entries.filter(
(entry): entry is BridgePreference =>
entry !== null &&
typeof entry === 'object' &&
!Array.isArray(entry) &&
typeof (entry as Record<string, unknown>).key === 'string',
);
}
return parsed;
}
/**
* Persists each source's preference array verbatim, keyed by bridge source id.
* Persists each source's preference array verbatim, keyed by extension package
* and bridge source id.
*
* Extensions keep credentials in here (the Jellyfin source stores a password),
* so the file is written with owner-only permissions.
@@ -33,10 +51,7 @@ export class PreferenceStore {
if (this.cache !== null) return this.cache;
try {
const parsed = JSON.parse(await readFile(this.file, 'utf8')) as unknown;
this.cache =
parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, BridgePreference[]>)
: {};
this.cache = parseStoredPreferences(parsed);
} catch {
// Missing or corrupt file starts empty rather than blocking the browser.
this.cache = {};
@@ -44,17 +59,27 @@ export class PreferenceStore {
return this.cache;
}
async get(sourceId: string): Promise<BridgePreference[]> {
async get(pkg: string, sourceId: string): Promise<BridgePreference[]> {
return this.enqueue(async () => {
const all = await this.load();
return all[sourceId] ?? [];
const key = `${pkg}:${sourceId}`;
if (all[key]) return all[key];
// Bare source IDs predate package scoping and have no trustworthy owner.
// Never assign their credentials to whichever package happens to ask first.
const legacy = all[sourceId];
if (legacy) {
delete all[sourceId];
await this.persist(all);
}
return [];
});
}
async set(sourceId: string, preferences: BridgePreference[]): Promise<void> {
async set(pkg: string, sourceId: string, preferences: BridgePreference[]): Promise<void> {
await this.enqueue(async () => {
const all = await this.load();
all[sourceId] = preferences;
all[`${pkg}:${sourceId}`] = preferences;
await this.persist(all);
});
}
@@ -91,7 +116,11 @@ export class PreferenceStore {
await mkdir(path.dirname(this.file), { recursive: true });
const temporary = `${this.file}.tmp`;
try {
await chmod(temporary, 0o600).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
await writeFile(temporary, JSON.stringify(all, null, 2), { mode: 0o600 });
await chmod(temporary, 0o600);
await rename(temporary, this.file);
} catch (error) {
await rm(temporary, { force: true }).catch(() => undefined);
+45
View File
@@ -120,6 +120,51 @@ test('a failed download keeps its url so the episode still plays', async () => {
assert.deepEqual(io.removed, []);
});
test('an oversized streamed subtitle stops early and falls back to its remote url', async () => {
const io = fakeIo({});
const logged: string[] = [];
let chunksRead = 0;
let buffered = false;
const chunk = new Uint8Array(20 * 1024 * 1024);
const body = new ReadableStream<Uint8Array>(
{
pull(controller) {
chunksRead += 1;
controller.enqueue(chunk);
},
},
{ highWaterMark: 0 },
);
io.fetch = async () => ({
ok: true,
status: 200,
body,
async arrayBuffer() {
buffered = true;
throw new Error('stream should not be buffered');
},
});
const result = await cacheSubtitleTracks({
tracks: [{ url: 'http://bridge/sub/oversized', lang: 'Japanese' }],
io,
log: (message) => logged.push(message),
});
assert.equal(buffered, false);
assert.equal(chunksRead, 2);
assert.equal(result.dir, null);
assert.deepEqual(result.tracks, [
{
url: 'http://bridge/sub/oversized',
lang: 'Japanese',
sourceUrl: 'http://bridge/sub/oversized',
local: false,
},
]);
assert.ok(logged.some((message) => message.includes('response too large')));
});
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({
+36 -1
View File
@@ -58,6 +58,7 @@ export interface SubtitleCacheResult {
interface FetchResponseLike {
ok: boolean;
status: number;
body?: ReadableStream<Uint8Array> | null;
arrayBuffer: () => Promise<ArrayBuffer>;
}
@@ -150,7 +151,7 @@ async function downloadTrack(
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
bytes = new Uint8Array(await response.arrayBuffer());
bytes = await readBounded(response, MAX_SUBTITLE_BYTES);
} finally {
clearTimeout(timeoutId);
}
@@ -171,6 +172,40 @@ async function downloadTrack(
return filePath;
}
async function readBounded(response: FetchResponseLike, maxBytes: number): Promise<Uint8Array> {
const reader = response.body?.getReader();
if (!reader) {
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > maxBytes) {
throw new Error(`response too large (${bytes.byteLength} bytes)`);
}
return bytes;
}
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
try {
await reader.cancel();
} catch {}
throw new Error(`response too large (${total} bytes)`);
}
chunks.push(value);
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}
/**
* Download every subtitle track to a fresh temp directory.
*