mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-08 19:21:32 -07:00
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:
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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, []), [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user