mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-04 19:21:33 -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.
|
||||
*
|
||||
|
||||
+103
-185
@@ -2,13 +2,23 @@ import { describe, el } from './dom';
|
||||
import { sourceOptionLabel, summarizeSearch } from './format';
|
||||
import { applySearchUpdate, idleSearchProgress, summarizeProgress } from './search-progress';
|
||||
import { createExtensionsPanel } from './extensions-panel';
|
||||
import { createDetailPanel } from './detail-panel';
|
||||
import { renderPreferences, renderPreferencesUnavailable } from './preferences-fields';
|
||||
import {
|
||||
beginBrowse,
|
||||
beginNextPage,
|
||||
createBrowseState,
|
||||
failBrowse,
|
||||
finishBrowse,
|
||||
soleBrowseRequest,
|
||||
takeUnseenEntries,
|
||||
} from './browse-state';
|
||||
import type { BrowseRequest } from './browse-state';
|
||||
import { ALL_SOURCES_ID } from '../types/anime-browser';
|
||||
import type {
|
||||
AnimeBrowserAPI,
|
||||
AnimeBrowserBridgeState,
|
||||
AnimeBrowserEntry,
|
||||
AnimeBrowserEpisode,
|
||||
AnimeBrowserSource,
|
||||
} from '../types/anime-browser';
|
||||
|
||||
@@ -26,15 +36,7 @@ const searchButton = el<HTMLButtonElement>('search-button');
|
||||
const sourceSelect = el<HTMLSelectElement>('source-select');
|
||||
const grid = el<HTMLDivElement>('grid');
|
||||
const gridEmpty = el<HTMLParagraphElement>('grid-empty');
|
||||
const results = el<HTMLElement>('results');
|
||||
const detail = el<HTMLElement>('detail');
|
||||
const detailBack = el<HTMLButtonElement>('detail-back');
|
||||
const detailCover = el<HTMLImageElement>('detail-cover');
|
||||
const detailTitle = el<HTMLHeadingElement>('detail-title');
|
||||
const detailChips = el<HTMLDivElement>('detail-chips');
|
||||
const detailDescription = el<HTMLParagraphElement>('detail-description');
|
||||
const episodes = el<HTMLOListElement>('episodes');
|
||||
const episodesCount = el<HTMLSpanElement>('episodes-count');
|
||||
const loadMoreButton = el<HTMLButtonElement>('load-more');
|
||||
const banner = el<HTMLDivElement>('bridge-banner');
|
||||
const bannerMessage = el<HTMLSpanElement>('bridge-message');
|
||||
const bannerMeter = el<HTMLSpanElement>('bridge-meter');
|
||||
@@ -49,11 +51,8 @@ const settingsPanel = el<HTMLElement>('settings');
|
||||
const settingsFields = el<HTMLDivElement>('settings-fields');
|
||||
const settingsTitle = el<HTMLHeadingElement>('settings-title');
|
||||
|
||||
/** The anime the detail page is showing, with the source that produced it. */
|
||||
let selectedAnime: { url: string; title: string; sourceId: string } | null = null;
|
||||
|
||||
/** Where the results grid was scrolled to before the detail page covered it. */
|
||||
let resultsScrollTop = 0;
|
||||
/** Last source accepted by the main process, used to roll back a rejected change. */
|
||||
let selectedSourceId: string | null = null;
|
||||
|
||||
/* ---------- tabs ---------- */
|
||||
|
||||
@@ -81,6 +80,8 @@ function setStatus(message: string, tone: 'info' | 'ok' | 'error' = 'info'): voi
|
||||
statusMessage.parentElement?.setAttribute('data-tone', tone);
|
||||
}
|
||||
|
||||
const detailPanel = createDetailPanel({ api, setStatus });
|
||||
|
||||
const BRIDGE_LABELS: Record<AnimeBrowserBridgeState['stage'], string> = {
|
||||
idle: 'Starting the extension bridge',
|
||||
locating: 'Looking up the extension bridge release',
|
||||
@@ -150,6 +151,7 @@ function renderSources(sources: AnimeBrowserSource[], selectedId: string | null)
|
||||
|
||||
sourceSelect.replaceChildren(...options);
|
||||
sourceSelect.disabled = options.length <= 1;
|
||||
selectedSourceId = selectedId;
|
||||
}
|
||||
|
||||
function searchingAllSources(): boolean {
|
||||
@@ -195,17 +197,18 @@ function createCard(entry: AnimeBrowserEntry, showSource: boolean): HTMLButtonEl
|
||||
card.append(art, title);
|
||||
card.title = showSource ? `${entry.title} — ${entry.sourceName}` : entry.title;
|
||||
card.addEventListener('click', () => {
|
||||
void openDetail(entry);
|
||||
void detailPanel.open(entry);
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderEntries(entries: AnimeBrowserEntry[], emptyMessage: string): void {
|
||||
// Which source a cover came from only matters when they are mixed together.
|
||||
const showSource = searchingAllSources();
|
||||
grid.replaceChildren(...entries.map((entry) => createCard(entry, showSource)));
|
||||
seenEntries.clear();
|
||||
grid.replaceChildren();
|
||||
appendEntries(entries);
|
||||
|
||||
const empty = entries.length === 0;
|
||||
const empty = grid.childElementCount === 0;
|
||||
gridEmpty.classList.toggle('hidden', !empty);
|
||||
gridEmpty.textContent = emptyMessage;
|
||||
}
|
||||
@@ -213,139 +216,9 @@ function renderEntries(entries: AnimeBrowserEntry[], emptyMessage: string): void
|
||||
/** Streamed results land at the end of the grid, in arrival order. */
|
||||
function appendEntries(entries: AnimeBrowserEntry[]): void {
|
||||
const showSource = searchingAllSources();
|
||||
grid.append(...entries.map((entry) => createCard(entry, showSource)));
|
||||
}
|
||||
|
||||
function formatEpisodeIndex(episode: AnimeBrowserEpisode, fallbackIndex: number): string {
|
||||
const value = episode.number ?? fallbackIndex;
|
||||
return Number.isInteger(value) ? String(value).padStart(2, '0') : value.toFixed(1);
|
||||
}
|
||||
|
||||
function renderEpisodes(list: AnimeBrowserEpisode[]): void {
|
||||
episodesCount.textContent = list.length === 0 ? '' : `${list.length}`;
|
||||
episodes.replaceChildren(
|
||||
...list.map((episode, index) => {
|
||||
const item = document.createElement('li');
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'cue';
|
||||
|
||||
const cueIndex = document.createElement('span');
|
||||
cueIndex.className = 'cue-index';
|
||||
cueIndex.textContent = formatEpisodeIndex(episode, list.length - index);
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'cue-name';
|
||||
name.textContent = episode.name;
|
||||
if (episode.uploadedAt !== null) {
|
||||
const sub = document.createElement('span');
|
||||
sub.className = 'cue-sub';
|
||||
sub.textContent = new Date(episode.uploadedAt).toISOString().slice(0, 10);
|
||||
name.append(sub);
|
||||
}
|
||||
|
||||
button.append(cueIndex, name);
|
||||
button.addEventListener('click', () => {
|
||||
void playEpisode(button, episode);
|
||||
});
|
||||
item.append(button);
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail page replaces the results grid rather than squeezing in beside
|
||||
* it. The grid stays in the DOM with its scroll position remembered, so Back
|
||||
* returns to the same results without re-running the search.
|
||||
*/
|
||||
async function openDetail(entry: AnimeBrowserEntry): Promise<void> {
|
||||
selectedAnime = { url: entry.url, title: entry.title, sourceId: entry.sourceId };
|
||||
resultsScrollTop = results.scrollTop;
|
||||
results.classList.add('hidden');
|
||||
detail.classList.remove('hidden');
|
||||
detail.scrollTop = 0;
|
||||
detailTitle.textContent = entry.title;
|
||||
detailDescription.textContent = 'Loading…';
|
||||
detailChips.replaceChildren();
|
||||
episodes.replaceChildren();
|
||||
episodesCount.textContent = '';
|
||||
detailCover.src = entry.thumbnailUrl ?? '';
|
||||
|
||||
try {
|
||||
// Always ask the entry's own source: after an all-sources search the
|
||||
// picker's selection says nothing about where this cover came from.
|
||||
const [details, episodeList] = await Promise.all([
|
||||
api.getDetails(entry.url, entry.sourceId),
|
||||
api.getEpisodes(entry.url, entry.sourceId),
|
||||
]);
|
||||
|
||||
detailTitle.textContent = details.title;
|
||||
detailDescription.textContent = details.description ?? 'No description from this source.';
|
||||
if (details.thumbnailUrl) detailCover.src = details.thumbnailUrl;
|
||||
|
||||
const chips: HTMLSpanElement[] = [];
|
||||
const source = document.createElement('span');
|
||||
source.className = 'chip source';
|
||||
source.textContent = entry.sourceName;
|
||||
chips.push(source);
|
||||
if (details.status !== 'unknown') {
|
||||
const status = document.createElement('span');
|
||||
status.className = 'chip status';
|
||||
status.textContent = details.status.replace(/-/g, ' ');
|
||||
chips.push(status);
|
||||
}
|
||||
for (const genre of details.genres.slice(0, 6)) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'chip';
|
||||
chip.textContent = genre;
|
||||
chips.push(chip);
|
||||
}
|
||||
detailChips.replaceChildren(...chips);
|
||||
|
||||
renderEpisodes(episodeList);
|
||||
setStatus(`${details.title} · ${episodeList.length} episodes`);
|
||||
} catch (error) {
|
||||
detailDescription.textContent = '';
|
||||
setStatus(describe(error), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail(): void {
|
||||
detail.classList.add('hidden');
|
||||
results.classList.remove('hidden');
|
||||
results.scrollTop = resultsScrollTop;
|
||||
selectedAnime = null;
|
||||
}
|
||||
|
||||
async function playEpisode(button: HTMLButtonElement, episode: AnimeBrowserEpisode): Promise<void> {
|
||||
if (!selectedAnime) return;
|
||||
|
||||
for (const other of episodes.querySelectorAll<HTMLButtonElement>('.cue')) {
|
||||
other.removeAttribute('data-state');
|
||||
}
|
||||
button.dataset.state = 'loading';
|
||||
setStatus(`Resolving ${episode.name}…`);
|
||||
|
||||
const result = await api.playEpisode({
|
||||
sourceId: selectedAnime.sourceId,
|
||||
animeUrl: selectedAnime.url,
|
||||
animeTitle: selectedAnime.title,
|
||||
episodeUrl: episode.url,
|
||||
episodeName: episode.name,
|
||||
episodeNumber: episode.number,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
button.dataset.state = 'playing';
|
||||
setStatus(
|
||||
result.quality ? `Playing ${episode.name} · ${result.quality}` : `Playing ${episode.name}`,
|
||||
'ok',
|
||||
);
|
||||
} else {
|
||||
button.removeAttribute('data-state');
|
||||
setStatus(result.error ?? 'Could not play that episode.', 'error');
|
||||
}
|
||||
const unseen = takeUnseenEntries(entries, seenEntries);
|
||||
grid.append(...unseen.map((entry) => createCard(entry, showSource)));
|
||||
if (unseen.length > 0) gridEmpty.classList.add('hidden');
|
||||
}
|
||||
|
||||
/* ---------- streamed search ---------- */
|
||||
@@ -358,8 +231,17 @@ async function playEpisode(button: HTMLButtonElement, episode: AnimeBrowserEpiso
|
||||
*/
|
||||
let progress = idleSearchProgress();
|
||||
|
||||
/** Orders runSearch calls so a slow search cannot finish over a newer one. */
|
||||
let searchRequest = 0;
|
||||
let browseState = createBrowseState();
|
||||
const inFlightBrowses = new Map<number, BrowseRequest>();
|
||||
let activeStreamRequestId = 0;
|
||||
const seenEntries = new Set<string>();
|
||||
|
||||
function renderLoadMore(): void {
|
||||
const loadingNextPage = browseState.loading && browseState.page > 1;
|
||||
loadMoreButton.classList.toggle('hidden', !browseState.hasNextPage && !loadingNextPage);
|
||||
loadMoreButton.disabled = browseState.loading;
|
||||
loadMoreButton.textContent = loadingNextPage ? 'Loading…' : 'Load more';
|
||||
}
|
||||
|
||||
api.onSearchUpdate((update) => {
|
||||
const applied = applySearchUpdate(progress, update);
|
||||
@@ -367,53 +249,88 @@ api.onSearchUpdate((update) => {
|
||||
progress = applied.progress;
|
||||
|
||||
if (applied.started) {
|
||||
grid.replaceChildren();
|
||||
gridEmpty.classList.add('hidden');
|
||||
// The runtime token does not carry the renderer request id. When calls
|
||||
// overlap their starts may arrive in either order, so stream only when the
|
||||
// association is unambiguous; the final response still backfills the grid.
|
||||
const request = soleBrowseRequest(inFlightBrowses);
|
||||
activeStreamRequestId = request?.id ?? 0;
|
||||
const append = request?.append === true;
|
||||
if (!append) {
|
||||
seenEntries.clear();
|
||||
grid.replaceChildren();
|
||||
gridEmpty.classList.add('hidden');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (activeStreamRequestId !== browseState.requestId) return;
|
||||
if (applied.entries.length > 0) appendEntries(applied.entries);
|
||||
if (!progress.done) {
|
||||
setStatus(summarizeProgress(progress), progress.failures.length > 0 ? 'error' : 'info');
|
||||
}
|
||||
});
|
||||
|
||||
async function runSearch(query: string): Promise<void> {
|
||||
const request = ++searchRequest;
|
||||
// A new search means new results; leave the detail page for them.
|
||||
if (!detail.classList.contains('hidden')) closeDetail();
|
||||
setStatus(query ? `Searching for “${query}”…` : 'Loading popular…');
|
||||
grid.replaceChildren();
|
||||
gridEmpty.classList.add('hidden');
|
||||
async function runBrowse(request: BrowseRequest): Promise<void> {
|
||||
inFlightBrowses.set(request.id, request);
|
||||
renderLoadMore();
|
||||
|
||||
try {
|
||||
const result = query ? await api.search(query) : await api.getPopular();
|
||||
// A newer search owns the grid now; this one's result is history.
|
||||
if (request !== searchRequest) return;
|
||||
const result = request.query
|
||||
? await api.search(request.query, request.page)
|
||||
: await api.getPopular(request.page);
|
||||
if (request.id !== browseState.requestId) return;
|
||||
browseState = finishBrowse(browseState, request.id, result.hasNextPage);
|
||||
renderLoadMore();
|
||||
|
||||
// Every source failing is an error, not an empty result set.
|
||||
if (result.entries.length === 0 && result.failures.length > 0) {
|
||||
const first = result.failures[0];
|
||||
renderEntries([], `${first?.sourceName}: ${first?.error}`);
|
||||
if (!request.append) renderEntries([], `${first?.sourceName}: ${first?.error}`);
|
||||
setStatus(summarizeSearch(result), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// The stream already filled the grid; only render from the result when no
|
||||
// update arrived (covers a host without streaming wired up).
|
||||
if (grid.childElementCount === 0 || result.entries.length === 0) {
|
||||
// The final response backfills a host without streaming. Entries already
|
||||
// pushed by the stream are filtered out, including sources that repeat a
|
||||
// final page while another source still has more.
|
||||
appendEntries(result.entries);
|
||||
if (!request.append && grid.childElementCount === 0) {
|
||||
renderEntries(
|
||||
result.entries,
|
||||
query ? `Nothing found for “${query}”.` : 'This source returned nothing.',
|
||||
[],
|
||||
request.query ? `Nothing found for “${request.query}”.` : 'This source returned nothing.',
|
||||
);
|
||||
}
|
||||
setStatus(summarizeSearch(result), result.failures.length > 0 ? 'error' : 'info');
|
||||
} catch (error) {
|
||||
if (request !== searchRequest) return;
|
||||
renderEntries([], describe(error));
|
||||
if (request.id !== browseState.requestId) return;
|
||||
browseState = failBrowse(browseState, request);
|
||||
renderLoadMore();
|
||||
if (!request.append) renderEntries([], describe(error));
|
||||
setStatus(describe(error), 'error');
|
||||
} finally {
|
||||
inFlightBrowses.delete(request.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function runSearch(query: string): Promise<void> {
|
||||
const started = beginBrowse(browseState, query);
|
||||
browseState = started.state;
|
||||
// A new search means new results; leave the detail page for them.
|
||||
if (detailPanel.isOpen()) detailPanel.close();
|
||||
setStatus(query ? `Searching for “${query}”…` : 'Loading popular…');
|
||||
seenEntries.clear();
|
||||
grid.replaceChildren();
|
||||
gridEmpty.classList.add('hidden');
|
||||
await runBrowse(started.request);
|
||||
}
|
||||
|
||||
async function loadNextPage(): Promise<void> {
|
||||
const started = beginNextPage(browseState);
|
||||
if (!started) return;
|
||||
browseState = started.state;
|
||||
setStatus(`Loading page ${started.request.page}…`);
|
||||
await runBrowse(started.request);
|
||||
}
|
||||
|
||||
/* ---------- source settings ---------- */
|
||||
|
||||
async function openSettings(): Promise<void> {
|
||||
@@ -470,20 +387,21 @@ searchForm.addEventListener('submit', (event) => {
|
||||
|
||||
sourceSelect.addEventListener('change', () => {
|
||||
void (async () => {
|
||||
await api.selectSource(sourceSelect.value);
|
||||
// Settings belong to the source, so reload them rather than showing stale fields.
|
||||
if (currentView === 'settings') await openSettings();
|
||||
await runSearch(searchInput.value.trim());
|
||||
const requestedSourceId = sourceSelect.value;
|
||||
try {
|
||||
await api.selectSource(requestedSourceId);
|
||||
selectedSourceId = requestedSourceId;
|
||||
// Settings belong to the source, so reload them rather than showing stale fields.
|
||||
if (currentView === 'settings') await openSettings();
|
||||
await runSearch(searchInput.value.trim());
|
||||
} catch (error) {
|
||||
if (selectedSourceId !== null) sourceSelect.value = selectedSourceId;
|
||||
setStatus(describe(error), 'error');
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
detailBack.addEventListener('click', closeDetail);
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && !detail.classList.contains('hidden')) {
|
||||
closeDetail();
|
||||
}
|
||||
});
|
||||
loadMoreButton.addEventListener('click', () => void loadNextPage());
|
||||
|
||||
api.onBridgeState(renderBridgeState);
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
beginBrowse,
|
||||
beginNextPage,
|
||||
capture,
|
||||
createBrowseState,
|
||||
failBrowse,
|
||||
finishBrowse,
|
||||
LatestRequest,
|
||||
safeUploadDate,
|
||||
soleBrowseRequest,
|
||||
takeUnseenEntries,
|
||||
} from './browse-state';
|
||||
import type { AnimeBrowserEntry } from '../types/anime-browser';
|
||||
|
||||
test('a new browse replaces results and starts from page one', () => {
|
||||
const started = beginBrowse(createBrowseState(), 'frieren');
|
||||
|
||||
assert.deepEqual(started.request, {
|
||||
id: 1,
|
||||
query: 'frieren',
|
||||
page: 1,
|
||||
append: false,
|
||||
});
|
||||
assert.equal(started.state.loading, true);
|
||||
});
|
||||
|
||||
test('the next page appends only when the current result has another page', () => {
|
||||
const first = beginBrowse(createBrowseState(), '');
|
||||
const ready = finishBrowse(first.state, first.request.id, true);
|
||||
const next = beginNextPage(ready);
|
||||
|
||||
assert.ok(next);
|
||||
assert.deepEqual(next.request, {
|
||||
id: 2,
|
||||
query: '',
|
||||
page: 2,
|
||||
append: true,
|
||||
});
|
||||
assert.equal(beginNextPage(next.state), null, 'cannot overlap page requests');
|
||||
assert.equal(beginNextPage(finishBrowse(next.state, next.request.id, false)), null);
|
||||
});
|
||||
|
||||
test('a stale page completion cannot change the active browse state', () => {
|
||||
const first = beginBrowse(createBrowseState(), 'old');
|
||||
const current = beginBrowse(first.state, 'new');
|
||||
|
||||
assert.equal(finishBrowse(current.state, first.request.id, true), current.state);
|
||||
});
|
||||
|
||||
test('stream updates are correlated only when one browse request is in flight', () => {
|
||||
const first = beginBrowse(createBrowseState(), 'old');
|
||||
const second = beginBrowse(first.state, 'new');
|
||||
const inFlight = new Map([
|
||||
[second.request.id, second.request],
|
||||
[first.request.id, first.request],
|
||||
]);
|
||||
|
||||
assert.equal(soleBrowseRequest(inFlight), null, 'start order is ambiguous while calls overlap');
|
||||
inFlight.delete(first.request.id);
|
||||
assert.equal(soleBrowseRequest(inFlight), second.request);
|
||||
});
|
||||
|
||||
test('a failed next page remains retryable', () => {
|
||||
const first = beginBrowse(createBrowseState(), '');
|
||||
const ready = finishBrowse(first.state, first.request.id, true);
|
||||
const next = beginNextPage(ready)!;
|
||||
const failed = failBrowse(next.state, next.request);
|
||||
const retry = beginNextPage(failed);
|
||||
|
||||
assert.ok(retry);
|
||||
assert.equal(retry.request.page, 2);
|
||||
});
|
||||
|
||||
test('latest request tokens invalidate closed and superseded details', () => {
|
||||
const requests = new LatestRequest();
|
||||
const first = requests.begin();
|
||||
const second = requests.begin();
|
||||
|
||||
assert.equal(requests.isCurrent(first), false);
|
||||
assert.equal(requests.isCurrent(second), true);
|
||||
requests.cancel();
|
||||
assert.equal(requests.isCurrent(second), false);
|
||||
});
|
||||
|
||||
test('safeUploadDate ignores malformed timestamps', () => {
|
||||
assert.equal(safeUploadDate(Date.UTC(2025, 3, 2)), '2025-04-02');
|
||||
assert.equal(safeUploadDate(Number.NaN), null);
|
||||
assert.equal(safeUploadDate(Number.POSITIVE_INFINITY), null);
|
||||
});
|
||||
|
||||
test('capture turns a rejected IPC operation into a displayable failure', async () => {
|
||||
const failure = new Error('bridge disconnected');
|
||||
const result = await capture(async () => Promise.reject(failure));
|
||||
|
||||
assert.deepEqual(result, { ok: false, error: failure });
|
||||
});
|
||||
|
||||
test('takeUnseenEntries deduplicates streamed and final-page entries', () => {
|
||||
const entry = (sourceId: string, url: string): AnimeBrowserEntry => ({
|
||||
sourceId,
|
||||
sourceName: sourceId,
|
||||
url,
|
||||
title: url,
|
||||
thumbnailUrl: null,
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
|
||||
assert.deepEqual(takeUnseenEntries([entry('one', '/a')], seen), [entry('one', '/a')]);
|
||||
assert.deepEqual(takeUnseenEntries([entry('one', '/a'), entry('two', '/a')], seen), [
|
||||
entry('two', '/a'),
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { AnimeBrowserEntry } from '../types/anime-browser';
|
||||
|
||||
export interface BrowseState {
|
||||
requestId: number;
|
||||
query: string;
|
||||
page: number;
|
||||
loading: boolean;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
|
||||
export interface BrowseRequest {
|
||||
id: number;
|
||||
query: string;
|
||||
page: number;
|
||||
append: boolean;
|
||||
}
|
||||
|
||||
export interface StartedBrowse {
|
||||
state: BrowseState;
|
||||
request: BrowseRequest;
|
||||
}
|
||||
|
||||
export function createBrowseState(): BrowseState {
|
||||
return { requestId: 0, query: '', page: 0, loading: false, hasNextPage: false };
|
||||
}
|
||||
|
||||
export function beginBrowse(state: BrowseState, query: string): StartedBrowse {
|
||||
const request: BrowseRequest = {
|
||||
id: state.requestId + 1,
|
||||
query,
|
||||
page: 1,
|
||||
append: false,
|
||||
};
|
||||
return {
|
||||
state: {
|
||||
requestId: request.id,
|
||||
query,
|
||||
page: request.page,
|
||||
loading: true,
|
||||
hasNextPage: false,
|
||||
},
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
||||
export function beginNextPage(state: BrowseState): StartedBrowse | null {
|
||||
if (state.loading || !state.hasNextPage) return null;
|
||||
const request: BrowseRequest = {
|
||||
id: state.requestId + 1,
|
||||
query: state.query,
|
||||
page: state.page + 1,
|
||||
append: true,
|
||||
};
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
requestId: request.id,
|
||||
page: request.page,
|
||||
loading: true,
|
||||
hasNextPage: false,
|
||||
},
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
||||
export function soleBrowseRequest(
|
||||
inFlight: ReadonlyMap<number, BrowseRequest>,
|
||||
): BrowseRequest | null {
|
||||
if (inFlight.size !== 1) return null;
|
||||
return inFlight.values().next().value ?? null;
|
||||
}
|
||||
|
||||
export function finishBrowse(
|
||||
state: BrowseState,
|
||||
requestId: number,
|
||||
hasNextPage: boolean,
|
||||
): BrowseState {
|
||||
if (requestId !== state.requestId) return state;
|
||||
return { ...state, loading: false, hasNextPage };
|
||||
}
|
||||
|
||||
export function failBrowse(state: BrowseState, request: BrowseRequest): BrowseState {
|
||||
if (request.id !== state.requestId) return state;
|
||||
return {
|
||||
...state,
|
||||
page: request.append ? request.page - 1 : request.page,
|
||||
loading: false,
|
||||
hasNextPage: request.append,
|
||||
};
|
||||
}
|
||||
|
||||
export class LatestRequest {
|
||||
private current = 0;
|
||||
|
||||
begin(): number {
|
||||
return ++this.current;
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.current += 1;
|
||||
}
|
||||
|
||||
isCurrent(request: number): boolean {
|
||||
return request === this.current;
|
||||
}
|
||||
}
|
||||
|
||||
export function safeUploadDate(uploadedAt: number): string | null {
|
||||
const date = new Date(uploadedAt);
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString().slice(0, 10) : null;
|
||||
}
|
||||
|
||||
export type Captured<T> = { ok: true; value: T } | { ok: false; error: unknown };
|
||||
|
||||
export async function capture<T>(operation: () => Promise<T>): Promise<Captured<T>> {
|
||||
try {
|
||||
return { ok: true, value: await operation() };
|
||||
} catch (error) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
export function takeUnseenEntries(
|
||||
entries: AnimeBrowserEntry[],
|
||||
seen: Set<string>,
|
||||
): AnimeBrowserEntry[] {
|
||||
return entries.filter((entry) => {
|
||||
const key = `${entry.sourceId}\0${entry.url}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { capture, LatestRequest, safeUploadDate } from './browse-state';
|
||||
import { describe, el } from './dom';
|
||||
import type {
|
||||
AnimeBrowserAPI,
|
||||
AnimeBrowserEntry,
|
||||
AnimeBrowserEpisode,
|
||||
} from '../types/anime-browser';
|
||||
|
||||
interface DetailPanelOptions {
|
||||
api: AnimeBrowserAPI;
|
||||
setStatus: (message: string, tone?: 'info' | 'ok' | 'error') => void;
|
||||
}
|
||||
|
||||
export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
|
||||
const results = el<HTMLElement>('results');
|
||||
const detail = el<HTMLElement>('detail');
|
||||
const detailBack = el<HTMLButtonElement>('detail-back');
|
||||
const detailCover = el<HTMLImageElement>('detail-cover');
|
||||
const detailTitle = el<HTMLHeadingElement>('detail-title');
|
||||
const detailChips = el<HTMLDivElement>('detail-chips');
|
||||
const detailDescription = el<HTMLParagraphElement>('detail-description');
|
||||
const episodes = el<HTMLOListElement>('episodes');
|
||||
const episodesCount = el<HTMLSpanElement>('episodes-count');
|
||||
|
||||
let selectedAnime: { url: string; title: string; sourceId: string } | null = null;
|
||||
let resultsScrollTop = 0;
|
||||
const requests = new LatestRequest();
|
||||
|
||||
function formatEpisodeIndex(episode: AnimeBrowserEpisode, fallbackIndex: number): string {
|
||||
const value = episode.number ?? fallbackIndex;
|
||||
return Number.isInteger(value) ? String(value).padStart(2, '0') : value.toFixed(1);
|
||||
}
|
||||
|
||||
async function playEpisode(
|
||||
button: HTMLButtonElement,
|
||||
episode: AnimeBrowserEpisode,
|
||||
): Promise<void> {
|
||||
const anime = selectedAnime;
|
||||
if (!anime) return;
|
||||
|
||||
for (const other of episodes.querySelectorAll<HTMLButtonElement>('.cue')) {
|
||||
other.removeAttribute('data-state');
|
||||
}
|
||||
button.dataset.state = 'loading';
|
||||
setStatus(`Resolving ${episode.name}…`);
|
||||
|
||||
const attempt = await capture(() =>
|
||||
api.playEpisode({
|
||||
sourceId: anime.sourceId,
|
||||
animeUrl: anime.url,
|
||||
animeTitle: anime.title,
|
||||
episodeUrl: episode.url,
|
||||
episodeName: episode.name,
|
||||
episodeNumber: episode.number,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!attempt.ok) {
|
||||
button.removeAttribute('data-state');
|
||||
setStatus(describe(attempt.error), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = attempt.value;
|
||||
if (result.ok) {
|
||||
button.dataset.state = 'playing';
|
||||
setStatus(
|
||||
result.quality ? `Playing ${episode.name} · ${result.quality}` : `Playing ${episode.name}`,
|
||||
'ok',
|
||||
);
|
||||
} else {
|
||||
button.removeAttribute('data-state');
|
||||
setStatus(result.error ?? 'Could not play that episode.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderEpisodes(list: AnimeBrowserEpisode[]): void {
|
||||
episodesCount.textContent = list.length === 0 ? '' : `${list.length}`;
|
||||
episodes.replaceChildren(
|
||||
...list.map((episode, index) => {
|
||||
const item = document.createElement('li');
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'cue';
|
||||
|
||||
const cueIndex = document.createElement('span');
|
||||
cueIndex.className = 'cue-index';
|
||||
cueIndex.textContent = formatEpisodeIndex(episode, list.length - index);
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'cue-name';
|
||||
name.textContent = episode.name;
|
||||
if (episode.uploadedAt !== null) {
|
||||
const uploaded = safeUploadDate(episode.uploadedAt);
|
||||
if (uploaded) {
|
||||
const sub = document.createElement('span');
|
||||
sub.className = 'cue-sub';
|
||||
sub.textContent = uploaded;
|
||||
name.append(sub);
|
||||
}
|
||||
}
|
||||
|
||||
button.append(cueIndex, name);
|
||||
button.addEventListener('click', () => void playEpisode(button, episode));
|
||||
item.append(button);
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function open(entry: AnimeBrowserEntry): Promise<void> {
|
||||
const request = requests.begin();
|
||||
selectedAnime = { url: entry.url, title: entry.title, sourceId: entry.sourceId };
|
||||
resultsScrollTop = results.scrollTop;
|
||||
results.classList.add('hidden');
|
||||
detail.classList.remove('hidden');
|
||||
detail.scrollTop = 0;
|
||||
detailTitle.textContent = entry.title;
|
||||
detailDescription.textContent = 'Loading…';
|
||||
detailChips.replaceChildren();
|
||||
episodes.replaceChildren();
|
||||
episodesCount.textContent = '';
|
||||
detailCover.src = entry.thumbnailUrl ?? '';
|
||||
|
||||
try {
|
||||
const [details, episodeList] = await Promise.all([
|
||||
api.getDetails(entry.url, entry.sourceId),
|
||||
api.getEpisodes(entry.url, entry.sourceId),
|
||||
]);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
|
||||
detailTitle.textContent = details.title;
|
||||
detailDescription.textContent = details.description ?? 'No description from this source.';
|
||||
if (details.thumbnailUrl) detailCover.src = details.thumbnailUrl;
|
||||
|
||||
const chips: HTMLSpanElement[] = [];
|
||||
const source = document.createElement('span');
|
||||
source.className = 'chip source';
|
||||
source.textContent = entry.sourceName;
|
||||
chips.push(source);
|
||||
if (details.status !== 'unknown') {
|
||||
const status = document.createElement('span');
|
||||
status.className = 'chip status';
|
||||
status.textContent = details.status.replace(/-/g, ' ');
|
||||
chips.push(status);
|
||||
}
|
||||
for (const genre of details.genres.slice(0, 6)) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'chip';
|
||||
chip.textContent = genre;
|
||||
chips.push(chip);
|
||||
}
|
||||
detailChips.replaceChildren(...chips);
|
||||
|
||||
renderEpisodes(episodeList);
|
||||
setStatus(`${details.title} · ${episodeList.length} episodes`);
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
detailDescription.textContent = '';
|
||||
setStatus(describe(error), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
requests.cancel();
|
||||
detail.classList.add('hidden');
|
||||
results.classList.remove('hidden');
|
||||
results.scrollTop = resultsScrollTop;
|
||||
selectedAnime = null;
|
||||
}
|
||||
|
||||
detailBack.addEventListener('click', close);
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && !detail.classList.contains('hidden')) close();
|
||||
});
|
||||
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
isOpen: (): boolean => !detail.classList.contains('hidden'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/* ---------- detail page ---------- */
|
||||
|
||||
/*
|
||||
* The detail view is a page, not a sidebar: it takes over the whole content
|
||||
* region while the results grid waits, hidden, behind the Back button.
|
||||
*/
|
||||
.detail {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
background: var(--panel);
|
||||
overflow-y: auto;
|
||||
padding: 20px clamp(20px, 5vw, 56px) 32px;
|
||||
animation: detail-in 0.28s ease both;
|
||||
}
|
||||
|
||||
@keyframes detail-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail-back {
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
display: flex;
|
||||
gap: clamp(18px, 3vw, 36px);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.detail-cover {
|
||||
flex: none;
|
||||
align-self: flex-start;
|
||||
width: clamp(140px, 18vw, 232px);
|
||||
height: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--ctp-crust);
|
||||
box-shadow: 0 18px 40px -24px var(--shadow);
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: clamp(19px, 2.4vw, 27px);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.detail-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chip.status {
|
||||
border-color: rgba(166, 218, 149, 0.4);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
/* Which extension answered — the one chip that is always present. */
|
||||
.chip.source {
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.detail-description {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
max-width: 72ch;
|
||||
color: var(--muted);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.episodes-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.episodes-title {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.episodes-count {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/*
|
||||
* The cue rail: episodes read as subtitle cues on a timeline, because that is
|
||||
* what they are about to become. The rail is the spine, the index is the cue
|
||||
* number, and the title is the cue text.
|
||||
*/
|
||||
|
||||
.cue-rail {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cue-rail::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 48px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.cue {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 34px 1fr;
|
||||
gap: 26px;
|
||||
align-items: baseline;
|
||||
width: 100%;
|
||||
padding: 9px 10px 9px 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.14s ease;
|
||||
}
|
||||
|
||||
.cue::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 43px;
|
||||
top: 15px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--line);
|
||||
transition:
|
||||
background 0.14s ease,
|
||||
border-color 0.14s ease;
|
||||
}
|
||||
|
||||
.cue:hover,
|
||||
.cue:focus-visible {
|
||||
background: var(--panel-elevated);
|
||||
}
|
||||
|
||||
.cue:hover::after,
|
||||
.cue:focus-visible::after {
|
||||
background: var(--mine);
|
||||
border-color: var(--mine);
|
||||
}
|
||||
|
||||
.cue-index {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cue-name {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cue-sub {
|
||||
display: block;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cue[data-state='loading'] {
|
||||
background: var(--panel-elevated);
|
||||
}
|
||||
|
||||
.cue[data-state='loading']::after {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
animation: pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.cue[data-state='playing']::after {
|
||||
background: var(--mine);
|
||||
border-color: var(--mine);
|
||||
}
|
||||
|
||||
/* ---------- status bar ---------- */
|
||||
|
||||
.statusbar {
|
||||
flex: none;
|
||||
padding: 7px 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--ctp-mantle);
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.statusbar[data-tone='error'] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.statusbar[data-tone='ok'] {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
/* ---------- scrollbars ---------- */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--ctp-surface0);
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--faint);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -186,8 +186,14 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
{
|
||||
label: 'Remove',
|
||||
onClick: async () => {
|
||||
await api.removeRepo(repoUrl);
|
||||
await refresh();
|
||||
setStatus('Removing repository…');
|
||||
try {
|
||||
await api.removeRepo(repoUrl);
|
||||
await refresh();
|
||||
setStatus('Repository removed', 'ok');
|
||||
} catch (error) {
|
||||
setStatus(describe(error), 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
/>
|
||||
<title>SubMiner Anime</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
<link rel="stylesheet" href="./detail.css" />
|
||||
<link rel="stylesheet" href="./panels.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -126,6 +128,9 @@
|
||||
<section class="results" id="results" aria-label="Results">
|
||||
<div class="grid" id="grid"></div>
|
||||
<p class="empty hidden" id="grid-empty"></p>
|
||||
<button class="ghost-button load-more hidden" id="load-more" type="button">
|
||||
Load more
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="detail hidden" id="detail" aria-label="Details">
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/* ---------- source settings ---------- */
|
||||
|
||||
.settings {
|
||||
/* A tab panel owns the whole content region; only one is ever visible. */
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px 18px;
|
||||
background: var(--panel-elevated);
|
||||
}
|
||||
|
||||
.settings-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.settings-note {
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.settings-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 14px 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field-summary {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-row .text-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-multi {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field-state {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
.field-state[data-tone='ok'] {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.field-state[data-tone='error'] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ---------- extensions panel ---------- */
|
||||
|
||||
.repo-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.repo-add .text-input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.repo-hint {
|
||||
margin: 0 0 14px;
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
max-width: 78ch;
|
||||
}
|
||||
|
||||
.repo-hint code {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ext-group-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* A rule between the groups, but not above the first one. */
|
||||
.ext-list + .ext-group-title {
|
||||
margin-top: 6px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.ext-group-count {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ext-group-count:empty {
|
||||
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;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ext-list:not(:empty) {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.ext-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.ext-row.is-error {
|
||||
border-color: rgba(237, 135, 150, 0.45);
|
||||
}
|
||||
|
||||
.ext-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ext-name {
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ext-sub {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ext-row.is-error .ext-sub {
|
||||
color: var(--danger);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.ext-tag {
|
||||
flex: none;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.ext-tag.installed {
|
||||
border-color: rgba(166, 218, 149, 0.4);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.ext-tag.nsfw {
|
||||
border-color: rgba(237, 135, 150, 0.4);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.ext-row .ghost-button,
|
||||
.ext-row .primary-button {
|
||||
flex: none;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ext-empty {
|
||||
padding: 14px 2px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
+9
-546
@@ -315,6 +315,15 @@ body {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.load-more {
|
||||
display: block;
|
||||
margin: 24px auto 4px;
|
||||
}
|
||||
|
||||
.load-more.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---------- cover cards ---------- */
|
||||
|
||||
.card {
|
||||
@@ -406,549 +415,3 @@ body {
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------- detail page ---------- */
|
||||
|
||||
/*
|
||||
* The detail view is a page, not a sidebar: it takes over the whole content
|
||||
* region while the results grid waits, hidden, behind the Back button.
|
||||
*/
|
||||
.detail {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
background: var(--panel);
|
||||
overflow-y: auto;
|
||||
padding: 20px clamp(20px, 5vw, 56px) 32px;
|
||||
animation: detail-in 0.28s ease both;
|
||||
}
|
||||
|
||||
@keyframes detail-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail-back {
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
display: flex;
|
||||
gap: clamp(18px, 3vw, 36px);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.detail-cover {
|
||||
flex: none;
|
||||
align-self: flex-start;
|
||||
width: clamp(140px, 18vw, 232px);
|
||||
height: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--ctp-crust);
|
||||
box-shadow: 0 18px 40px -24px var(--shadow);
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: clamp(19px, 2.4vw, 27px);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.detail-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chip.status {
|
||||
border-color: rgba(166, 218, 149, 0.4);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
/* Which extension answered — the one chip that is always present. */
|
||||
.chip.source {
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.detail-description {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
max-width: 72ch;
|
||||
color: var(--muted);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.episodes-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.episodes-title {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.episodes-count {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/*
|
||||
* The cue rail: episodes read as subtitle cues on a timeline, because that is
|
||||
* what they are about to become. The rail is the spine, the index is the cue
|
||||
* number, and the title is the cue text.
|
||||
*/
|
||||
|
||||
.cue-rail {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cue-rail::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 48px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.cue {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 34px 1fr;
|
||||
gap: 26px;
|
||||
align-items: baseline;
|
||||
width: 100%;
|
||||
padding: 9px 10px 9px 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.14s ease;
|
||||
}
|
||||
|
||||
.cue::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 43px;
|
||||
top: 15px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--line);
|
||||
transition:
|
||||
background 0.14s ease,
|
||||
border-color 0.14s ease;
|
||||
}
|
||||
|
||||
.cue:hover,
|
||||
.cue:focus-visible {
|
||||
background: var(--panel-elevated);
|
||||
}
|
||||
|
||||
.cue:hover::after,
|
||||
.cue:focus-visible::after {
|
||||
background: var(--mine);
|
||||
border-color: var(--mine);
|
||||
}
|
||||
|
||||
.cue-index {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cue-name {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cue-sub {
|
||||
display: block;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cue[data-state='loading'] {
|
||||
background: var(--panel-elevated);
|
||||
}
|
||||
|
||||
.cue[data-state='loading']::after {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
animation: pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.cue[data-state='playing']::after {
|
||||
background: var(--mine);
|
||||
border-color: var(--mine);
|
||||
}
|
||||
|
||||
/* ---------- status bar ---------- */
|
||||
|
||||
.statusbar {
|
||||
flex: none;
|
||||
padding: 7px 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--ctp-mantle);
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.statusbar[data-tone='error'] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.statusbar[data-tone='ok'] {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
/* ---------- scrollbars ---------- */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--ctp-surface0);
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--faint);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- source settings ---------- */
|
||||
|
||||
.settings {
|
||||
/* A tab panel owns the whole content region; only one is ever visible. */
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px 18px;
|
||||
background: var(--panel-elevated);
|
||||
}
|
||||
|
||||
.settings-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.settings-note {
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.settings-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 14px 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field-summary {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-row .text-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-multi {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field-state {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
.field-state[data-tone='ok'] {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.field-state[data-tone='error'] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ---------- extensions panel ---------- */
|
||||
|
||||
.repo-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.repo-add .text-input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.repo-hint {
|
||||
margin: 0 0 14px;
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
max-width: 78ch;
|
||||
}
|
||||
|
||||
.repo-hint code {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ext-group-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* A rule between the groups, but not above the first one. */
|
||||
.ext-list + .ext-group-title {
|
||||
margin-top: 6px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.ext-group-count {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ext-group-count:empty {
|
||||
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;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ext-list:not(:empty) {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.ext-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.ext-row.is-error {
|
||||
border-color: rgba(237, 135, 150, 0.45);
|
||||
}
|
||||
|
||||
.ext-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ext-name {
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ext-sub {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ext-row.is-error .ext-sub {
|
||||
color: var(--danger);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.ext-tag {
|
||||
flex: none;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.ext-tag.installed {
|
||||
border-color: rgba(166, 218, 149, 0.4);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.ext-tag.nsfw {
|
||||
border-color: rgba(237, 135, 150, 0.4);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.ext-row .ghost-button,
|
||||
.ext-row .primary-button {
|
||||
flex: none;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ext-empty {
|
||||
padding: 14px 2px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,8 @@ export function scheduleOverlayContentReadyFallback(deps: {
|
||||
delayMs?: number;
|
||||
}): void {
|
||||
const setTimeoutFn =
|
||||
deps.setTimeoutFn ?? ((callback: () => void, delayMs: number): unknown => setTimeout(callback, delayMs));
|
||||
deps.setTimeoutFn ??
|
||||
((callback: () => void, delayMs: number): unknown => setTimeout(callback, delayMs));
|
||||
setTimeoutFn(() => {
|
||||
if (deps.isContentReady() || deps.isDestroyed()) {
|
||||
return;
|
||||
|
||||
@@ -163,6 +163,34 @@ test('extractSubtitleTrackToFile rejects a missing local external track', async
|
||||
);
|
||||
});
|
||||
|
||||
test('internal WebVTT extraction uses ffmpeg webvtt muxer with a vtt output file', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-extract-'));
|
||||
const ffmpegPath = path.join(dir, 'ffmpeg-stub');
|
||||
const argsPath = path.join(dir, 'args.txt');
|
||||
fs.writeFileSync(
|
||||
ffmpegPath,
|
||||
`#!/bin/sh\nprintf '%s\\n' "$@" > '${argsPath}'\nfor arg in "$@"; do output="$arg"; done\n: > "$output"\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await extractSubtitleTrackToFile({
|
||||
resolveFfmpegPath: () => ffmpegPath,
|
||||
videoPath: path.join(dir, 'video.mkv'),
|
||||
track: { id: 2, type: 'sub', codec: 'webvtt', 'ff-index': 3 },
|
||||
httpHeaders: null,
|
||||
});
|
||||
const args = fs.readFileSync(argsPath, 'utf8').trimEnd().split('\n');
|
||||
const formatIndex = args.indexOf('-f');
|
||||
|
||||
assert.equal(args[formatIndex + 1], 'webvtt');
|
||||
assert.equal(path.extname(result.path), '.vtt');
|
||||
cleanupTemporaryFile(result);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('cleanupTemporaryFile preserves the retimed output sharing the temp directory', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-extract-'));
|
||||
const sourcePath = path.join(dir, 'remote_track.srt');
|
||||
|
||||
@@ -100,6 +100,7 @@ async function extractInternalTrack(input: SubtitleExtractionInput): Promise<Fil
|
||||
if (!extension) {
|
||||
throw new Error(`Unsupported subtitle codec: ${input.track.codec ?? 'unknown'}`);
|
||||
}
|
||||
const ffmpegMuxer = extension === 'vtt' ? 'webvtt' : extension;
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subsync-'));
|
||||
try {
|
||||
@@ -123,7 +124,7 @@ async function extractInternalTrack(input: SubtitleExtractionInput): Promise<Fil
|
||||
'-map',
|
||||
`0:${ffIndex}`,
|
||||
'-f',
|
||||
extension,
|
||||
ffmpegMuxer,
|
||||
outputPath,
|
||||
]);
|
||||
|
||||
|
||||
+9
-4
@@ -486,6 +486,7 @@ import {
|
||||
import { createMediaRuntimeService } from './main/media-runtime';
|
||||
import {
|
||||
createStreamPlaybackMetadataStore,
|
||||
matchRequestedStreamPlaybackMetadata,
|
||||
toAnilistMediaGuess,
|
||||
toJimakuMediaInfo,
|
||||
} from './main/runtime/stream-playback-metadata';
|
||||
@@ -2398,9 +2399,13 @@ const JELLYFIN_SUBTITLE_DELAYS_PATH = path.join(CONFIG_DIR, 'jellyfin-subtitle-d
|
||||
*/
|
||||
const streamPlaybackMetadata = createStreamPlaybackMetadataStore();
|
||||
|
||||
/** The stream metadata for whatever mpv currently has open, if it is a stream. */
|
||||
function getActiveStreamMetadata() {
|
||||
return streamPlaybackMetadata.match(appState.currentMediaPath);
|
||||
/** Stream metadata for the requested path, or current media when none was supplied. */
|
||||
function getActiveStreamMetadata(mediaPath: string | null = null) {
|
||||
return matchRequestedStreamPlaybackMetadata(
|
||||
streamPlaybackMetadata,
|
||||
mediaPath,
|
||||
appState.currentMediaPath,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2411,7 +2416,7 @@ async function guessAnilistMediaInfoForCurrentMedia(
|
||||
mediaPath: string | null,
|
||||
mediaTitle: string | null,
|
||||
): Promise<AnilistMediaGuess | null> {
|
||||
const stream = getActiveStreamMetadata();
|
||||
const stream = getActiveStreamMetadata(mediaPath);
|
||||
const streamGuess = stream ? toAnilistMediaGuess(stream) : null;
|
||||
return streamGuess ?? guessAnilistMediaInfo(mediaPath, mediaTitle);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { AnimeBridgeClient, BridgeSource } from '../../anime-bridge/bridge-client';
|
||||
import type { AnimeBrowserPlayRequest } from '../../types/anime-browser';
|
||||
import { createAnimeBrowserPlayback } from './anime-browser-playback';
|
||||
|
||||
const request: AnimeBrowserPlayRequest = {
|
||||
sourceId: 'source',
|
||||
animeUrl: '/anime',
|
||||
animeTitle: 'Anime',
|
||||
episodeUrl: '/episode-1',
|
||||
episodeName: 'Episode 1',
|
||||
episodeNumber: 1,
|
||||
};
|
||||
|
||||
test('playback returns the established error when an extension has no playable stream', async () => {
|
||||
let mpvConnections = 0;
|
||||
const client = { getVideoList: async () => [] } as unknown as AnimeBridgeClient;
|
||||
const playback = createAnimeBrowserPlayback({
|
||||
deps: {
|
||||
sendMpvCommand: () => undefined,
|
||||
ensureMpvConnected: async () => {
|
||||
mpvConnections += 1;
|
||||
return true;
|
||||
},
|
||||
log: () => undefined,
|
||||
},
|
||||
bridge: async () => ({ client, baseUrl: 'http://127.0.0.1:1234' }),
|
||||
sourceFor: async () => ({}) as BridgeSource,
|
||||
stripProxy: () => null,
|
||||
});
|
||||
|
||||
assert.deepEqual(await playback.playEpisode(request), {
|
||||
ok: false,
|
||||
error: 'That source returned no playable video.',
|
||||
quality: null,
|
||||
});
|
||||
assert.equal(mpvConnections, 0);
|
||||
await playback.dispose();
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { AnimeBridgeClient, BridgeSource } from '../../anime-bridge/bridge-client';
|
||||
import { buildAnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
|
||||
import { resolveStream } from '../../anime-bridge/headers';
|
||||
import { resolveBridgeMediaUrl, routeHlsThroughProxy } from '../../anime-bridge/media-url';
|
||||
import {
|
||||
buildPlaybackCommands,
|
||||
buildTrackCommands,
|
||||
selectPreferredStream,
|
||||
} from '../../anime-bridge/mpv-playback';
|
||||
import { watchPlaybackOutcome } from '../../anime-bridge/playback-outcome';
|
||||
import { cacheSubtitleTracks, removeSubtitleCache } from '../../anime-bridge/subtitle-cache';
|
||||
import type { StreamStripProxyHandle } from '../../anime-bridge/stream-strip-proxy';
|
||||
import type { AnimeBrowserPlayRequest, AnimeBrowserPlayResult } from '../../types/anime-browser';
|
||||
import type { AnimeBrowserPlaybackDeps } from './anime-browser-runtime-deps';
|
||||
|
||||
const TRACK_ATTACH_DELAY_MS = 300;
|
||||
|
||||
interface AnimeBrowserPlaybackOptions {
|
||||
deps: AnimeBrowserPlaybackDeps;
|
||||
bridge: () => Promise<{ client: AnimeBridgeClient; baseUrl: string }>;
|
||||
sourceFor: (sourceId: string) => Promise<BridgeSource>;
|
||||
stripProxy: () => StreamStripProxyHandle | null;
|
||||
}
|
||||
|
||||
export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions) {
|
||||
const { deps, bridge, sourceFor, stripProxy } = options;
|
||||
const wait =
|
||||
deps.wait ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
||||
let subtitleCacheDir: string | null = null;
|
||||
|
||||
async function clearSubtitleCache(): Promise<void> {
|
||||
const previousDir = subtitleCacheDir;
|
||||
subtitleCacheDir = null;
|
||||
await removeSubtitleCache(previousDir, deps.subtitleCacheIo);
|
||||
}
|
||||
|
||||
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 }));
|
||||
}
|
||||
|
||||
async function playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> {
|
||||
try {
|
||||
const { client, baseUrl } = await bridge();
|
||||
const videos = await client.getVideoList(
|
||||
await sourceFor(request.sourceId),
|
||||
request.episodeUrl,
|
||||
);
|
||||
const streams = videos
|
||||
.map((video) => resolveStream(video))
|
||||
.filter((stream): stream is NonNullable<typeof stream> => stream !== null)
|
||||
.map((stream) => ({
|
||||
...stream,
|
||||
url: resolveBridgeMediaUrl(baseUrl, stream.url),
|
||||
audios: stream.audios.map((track) => ({
|
||||
...track,
|
||||
url: resolveBridgeMediaUrl(baseUrl, track.url),
|
||||
})),
|
||||
subtitles: stream.subtitles.map((track) => ({
|
||||
...track,
|
||||
url: resolveBridgeMediaUrl(baseUrl, track.url),
|
||||
})),
|
||||
}));
|
||||
|
||||
const selected = selectPreferredStream(streams, deps.preferredQuality?.());
|
||||
if (!selected) {
|
||||
return { ok: false, error: 'That source returned no playable video.', quality: null };
|
||||
}
|
||||
const proxy = stripProxy();
|
||||
const stream = proxy
|
||||
? { ...selected, url: routeHlsThroughProxy(selected.url, baseUrl, proxy.origin) }
|
||||
: selected;
|
||||
|
||||
if (!(await deps.ensureMpvConnected())) {
|
||||
return { ok: false, error: 'mpv is not running and could not be started.', quality: null };
|
||||
}
|
||||
|
||||
const watch =
|
||||
deps.onPlaybackEndFile && deps.readMpvProperty
|
||||
? watchPlaybackOutcome({
|
||||
onEndFile: deps.onPlaybackEndFile,
|
||||
readProperty: deps.readMpvProperty,
|
||||
wait,
|
||||
})
|
||||
: null;
|
||||
|
||||
try {
|
||||
const metadata = buildAnimeStreamMetadata({
|
||||
sourceId: request.sourceId,
|
||||
animeUrl: request.animeUrl,
|
||||
animeTitle: request.animeTitle,
|
||||
episodeUrl: request.episodeUrl,
|
||||
episodeName: request.episodeName,
|
||||
episodeNumber: request.episodeNumber ?? null,
|
||||
mediaPath: stream.url,
|
||||
});
|
||||
const title = metadata.displayTitle;
|
||||
deps.onPlaybackMetadata?.(metadata);
|
||||
for (const command of buildPlaybackCommands({ stream, title })) {
|
||||
deps.sendMpvCommand(command);
|
||||
}
|
||||
await clearSubtitleCache();
|
||||
|
||||
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)`,
|
||||
);
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
async function dispose(): Promise<void> {
|
||||
const cacheDir = subtitleCacheDir;
|
||||
subtitleCacheDir = null;
|
||||
await removeSubtitleCache(cacheDir, deps.subtitleCacheIo);
|
||||
}
|
||||
|
||||
return { playEpisode, dispose };
|
||||
}
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
|
||||
import type { PlaybackEndFileEvent } from '../../anime-bridge/playback-outcome';
|
||||
import type { SubtitleCacheIo } from '../../anime-bridge/subtitle-cache';
|
||||
import type { BundleBinaries } from '../../anime-bridge/sidecar-bundle';
|
||||
import { startSidecar } from '../../anime-bridge/sidecar-process';
|
||||
import { startStreamStripProxy } from '../../anime-bridge/stream-strip-proxy';
|
||||
import type { AnimeBrowserBridgeState, AnimeBrowserSearchUpdate } from '../../types/anime-browser';
|
||||
import type { InstallProgress } from './anime-bridge-installer';
|
||||
|
||||
export interface AnimeBrowserRuntimeDeps {
|
||||
/** Where user-supplied Aniyomi extension APKs live. Read lazily so config edits apply. */
|
||||
extensionsDir: () => string;
|
||||
/** Configured repository index URLs. Empty unless the user added one. */
|
||||
repos: () => string[];
|
||||
/** Persists the repository list. Config stays the source of truth. */
|
||||
setRepos: (repos: string[]) => void;
|
||||
/** JSON file holding each source's saved preference values. */
|
||||
preferencesFile: string;
|
||||
ensureBinaries: (onProgress: (progress: InstallProgress) => void) => Promise<BundleBinaries>;
|
||||
/** Sends mpv an IPC command; same transport the Jellyfin path uses. */
|
||||
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 so playback startup can be confirmed. */
|
||||
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;
|
||||
/** Publishes stream identity before loadfile starts the stats session. */
|
||||
onPlaybackMetadata?: (metadata: AnimeStreamMetadata) => void;
|
||||
/** Lets tests drive the pause between loadfile and track attachment. */
|
||||
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. */
|
||||
onSearchUpdate?: (update: AnimeBrowserSearchUpdate) => void;
|
||||
preferredQuality?: () => string | undefined;
|
||||
log: (message: string) => void;
|
||||
/** Overrides process startup in focused runtime tests. */
|
||||
startSidecar?: typeof startSidecar;
|
||||
/** Overrides proxy startup in focused runtime tests. */
|
||||
startStreamStripProxy?: typeof startStreamStripProxy;
|
||||
}
|
||||
|
||||
export type AnimeBrowserPlaybackDeps = Pick<
|
||||
AnimeBrowserRuntimeDeps,
|
||||
| 'sendMpvCommand'
|
||||
| 'ensureMpvConnected'
|
||||
| 'onPlaybackEndFile'
|
||||
| 'readMpvProperty'
|
||||
| 'showMpvOsd'
|
||||
| 'showVisibleOverlay'
|
||||
| 'onPlaybackMetadata'
|
||||
| 'wait'
|
||||
| 'subtitleCacheIo'
|
||||
| 'preferredQuality'
|
||||
| 'log'
|
||||
>;
|
||||
@@ -0,0 +1,138 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { BridgePreference } from '../../anime-bridge/types';
|
||||
import { createAnimeBrowserRuntime } from './anime-browser-runtime';
|
||||
import type { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
|
||||
|
||||
function textPreference(key: string, value = ''): BridgePreference {
|
||||
return { key, editTextPreference: { title: key, value, text: value } };
|
||||
}
|
||||
|
||||
async function setupRuntime(
|
||||
client: Record<string, unknown>,
|
||||
packages: Record<string, string>,
|
||||
storedPreferences?: Record<string, BridgePreference[]>,
|
||||
) {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-anime-runtime-'));
|
||||
for (const [pkg, contents] of Object.entries(packages)) {
|
||||
await writeFile(path.join(dir, `${pkg}.apk`), contents);
|
||||
}
|
||||
const preferencesFile = path.join(dir, 'preferences.json');
|
||||
if (storedPreferences) await writeFile(preferencesFile, JSON.stringify(storedPreferences));
|
||||
const runtime = createAnimeBrowserRuntime({
|
||||
extensionsDir: () => dir,
|
||||
repos: () => [],
|
||||
setRepos: () => undefined,
|
||||
preferencesFile,
|
||||
ensureBinaries: async () => ({}) as never,
|
||||
sendMpvCommand: () => undefined,
|
||||
ensureMpvConnected: async () => true,
|
||||
onBridgeState: () => undefined,
|
||||
log: () => undefined,
|
||||
startSidecar: async () => ({
|
||||
client: client as unknown as AnimeBridgeClient,
|
||||
baseUrl: 'http://127.0.0.1:12345',
|
||||
port: 12345,
|
||||
stop: async () => undefined,
|
||||
onExit: () => undefined,
|
||||
}),
|
||||
startStreamStripProxy: async () => ({
|
||||
origin: 'http://127.0.0.1:12346',
|
||||
port: 12346,
|
||||
close: async () => undefined,
|
||||
}),
|
||||
});
|
||||
await runtime.ensureBridge();
|
||||
return { runtime, preferencesFile };
|
||||
}
|
||||
|
||||
test('setPreference overlays saved values onto the extension current schema', async () => {
|
||||
const saved = textPreference('address', 'saved-address');
|
||||
const current = [textPreference('address'), textPreference('new-setting')];
|
||||
let received: BridgePreference[] = [];
|
||||
let schemaCalls = 0;
|
||||
const pkgBytes = 'one';
|
||||
const client = {
|
||||
listAnimeSources: async () => [{ id: 'shared', name: 'One', lang: 'en' }],
|
||||
getSourcePreferences: async () => {
|
||||
schemaCalls += 1;
|
||||
return current;
|
||||
},
|
||||
setSourcePreference: async (source: { preferences?: BridgePreference[] }) => {
|
||||
received = source.preferences ?? [];
|
||||
return received;
|
||||
},
|
||||
};
|
||||
const { runtime } = await setupRuntime(
|
||||
client,
|
||||
{ 'pkg.one': pkgBytes },
|
||||
{ 'pkg.one:shared': [saved] },
|
||||
);
|
||||
assert.equal(runtime.getSnapshot().sources[0]?.id, 'pkg.one:shared');
|
||||
|
||||
await runtime.setPreference('pkg.one:shared', 'new-setting', 'chosen');
|
||||
|
||||
assert.equal(schemaCalls, 1);
|
||||
const address = received.find((entry) => entry.key === 'address')?.editTextPreference as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const added = received.find((entry) => entry.key === 'new-setting')?.editTextPreference as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
assert.equal(address?.value, 'saved-address');
|
||||
assert.equal(added?.value, 'chosen');
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test('colliding bridge ids keep package preferences isolated and uninstall clears only its package', async () => {
|
||||
const seen: Array<{ sourceId?: string; preferences?: BridgePreference[]; fingerprint: string }> =
|
||||
[];
|
||||
const client = {
|
||||
listAnimeSources: async () => [{ id: 'shared', name: 'Source', lang: 'en' }],
|
||||
getSourcePreferences: async (source: {
|
||||
sourceId?: string;
|
||||
preferences?: BridgePreference[];
|
||||
fingerprint: string;
|
||||
}) => {
|
||||
seen.push(source);
|
||||
return [textPreference('password')];
|
||||
},
|
||||
};
|
||||
const one = textPreference('password', 'one-secret');
|
||||
const two = textPreference('password', 'two-secret');
|
||||
const { runtime, preferencesFile } = await setupRuntime(
|
||||
client,
|
||||
{ 'pkg.one': 'one', 'pkg.two': 'two' },
|
||||
{ 'pkg.one:shared': [one], 'pkg.two:shared': [two] },
|
||||
);
|
||||
|
||||
const sourceIds = runtime.getSnapshot().sources.map((source) => source.id);
|
||||
assert.deepEqual(sourceIds, ['pkg.one:shared', 'pkg.two:shared']);
|
||||
const oneView = await runtime.getPreferences('pkg.one:shared');
|
||||
const twoView = await runtime.getPreferences('pkg.two:shared');
|
||||
|
||||
assert.equal(oneView[0]?.value, 'one-secret');
|
||||
assert.equal(twoView[0]?.value, 'two-secret');
|
||||
assert.deepEqual(
|
||||
seen.map((source) => source.sourceId),
|
||||
['shared', 'shared'],
|
||||
);
|
||||
assert.notEqual(seen[0]?.fingerprint, seen[1]?.fingerprint);
|
||||
assert.equal(
|
||||
(seen[0]?.preferences?.[0]?.editTextPreference as Record<string, unknown>)?.value,
|
||||
'one-secret',
|
||||
);
|
||||
assert.equal(
|
||||
(seen[1]?.preferences?.[0]?.editTextPreference as Record<string, unknown>)?.value,
|
||||
'two-secret',
|
||||
);
|
||||
|
||||
await runtime.removeExtension('pkg.one');
|
||||
const persisted = JSON.parse(await readFile(preferencesFile, 'utf8')) as Record<string, unknown>;
|
||||
assert.equal(persisted['pkg.one:shared'], undefined);
|
||||
assert.deepEqual(persisted['pkg.two:shared'], [two]);
|
||||
await runtime.dispose();
|
||||
});
|
||||
@@ -1,27 +1,9 @@
|
||||
import { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
|
||||
import { resolveStream } from '../../anime-bridge/headers';
|
||||
import {
|
||||
parseAnimeStatus,
|
||||
resolveBridgeMediaUrl,
|
||||
routeHlsThroughProxy,
|
||||
} from '../../anime-bridge/media-url';
|
||||
import {
|
||||
watchPlaybackOutcome,
|
||||
type PlaybackEndFileEvent,
|
||||
} from '../../anime-bridge/playback-outcome';
|
||||
import { parseAnimeStatus, resolveBridgeMediaUrl } from '../../anime-bridge/media-url';
|
||||
import {
|
||||
startStreamStripProxy,
|
||||
type StreamStripProxyHandle,
|
||||
} from '../../anime-bridge/stream-strip-proxy';
|
||||
import {
|
||||
buildPlaybackCommands,
|
||||
buildTrackCommands,
|
||||
selectPreferredStream,
|
||||
} from '../../anime-bridge/mpv-playback';
|
||||
import {
|
||||
buildAnimeStreamMetadata,
|
||||
type AnimeStreamMetadata,
|
||||
} from '../../anime-bridge/episode-metadata';
|
||||
import {
|
||||
listExtensionSources,
|
||||
readInstalledExtensions,
|
||||
@@ -30,11 +12,6 @@ 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 {
|
||||
@@ -49,74 +26,25 @@ import {
|
||||
import { PreferenceStore } from '../../anime-bridge/preference-store';
|
||||
import { applyPreferenceValue, parsePreferences } from '../../anime-bridge/preferences';
|
||||
import type { SourcePreferenceView } from '../../anime-bridge/preferences';
|
||||
import type { BundleBinaries } from '../../anime-bridge/sidecar-bundle';
|
||||
import type { InstallProgress } from './anime-bridge-installer';
|
||||
import { ALL_SOURCES_ID } from '../../types/anime-browser';
|
||||
import type {
|
||||
AnimeBrowserBridgeState,
|
||||
AnimeBrowserDetails,
|
||||
AnimeBrowserEntry,
|
||||
AnimeBrowserEpisode,
|
||||
AnimeBrowserPlayRequest,
|
||||
AnimeBrowserPlayResult,
|
||||
AnimeBrowserSearchResult,
|
||||
AnimeBrowserSearchUpdate,
|
||||
AnimeBrowserSnapshot,
|
||||
AvailableExtensionsResult,
|
||||
ExtensionLoadFailure,
|
||||
} from '../../types/anime-browser';
|
||||
import type { BridgeAnimePage } from '../../anime-bridge/types';
|
||||
|
||||
export interface AnimeBrowserRuntimeDeps {
|
||||
/** Where user-supplied Aniyomi extension APKs live. Read lazily so config edits apply. */
|
||||
extensionsDir: () => string;
|
||||
/** Configured repository index URLs. Empty unless the user added one. */
|
||||
repos: () => string[];
|
||||
/** Persists the repository list. Config stays the source of truth. */
|
||||
setRepos: (repos: string[]) => void;
|
||||
/** JSON file holding each source's saved preference values. */
|
||||
preferencesFile: string;
|
||||
ensureBinaries: (onProgress: (progress: InstallProgress) => void) => Promise<BundleBinaries>;
|
||||
/** Sends mpv an IPC command; same transport the Jellyfin path uses. */
|
||||
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;
|
||||
/**
|
||||
* Publishes what is about to play. Called *before* `loadfile` so the title
|
||||
* and the episode's identity are already known when mpv reports the path
|
||||
* change — otherwise stats sees only the proxy URL and groups every stream
|
||||
* under its file extension.
|
||||
*/
|
||||
onPlaybackMetadata?: (metadata: AnimeStreamMetadata) => 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
|
||||
* a host that has no window to push to can leave it out.
|
||||
*/
|
||||
onSearchUpdate?: (update: AnimeBrowserSearchUpdate) => void;
|
||||
preferredQuality?: () => string | undefined;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
import type { BridgeAnimePage, BridgePreference } from '../../anime-bridge/types';
|
||||
import { createAnimeBrowserPlayback } from './anime-browser-playback';
|
||||
import type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
|
||||
export type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
|
||||
|
||||
const IDLE_STATE: AnimeBrowserBridgeState = { stage: 'idle', progress: null, message: null };
|
||||
|
||||
/** How long to let `loadfile` settle before adding external tracks. */
|
||||
const TRACK_ATTACH_DELAY_MS = 300;
|
||||
|
||||
export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
let bridgeState: AnimeBrowserBridgeState = IDLE_STATE;
|
||||
let sidecar: SidecarHandle | null = null;
|
||||
@@ -128,53 +56,7 @@ 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;
|
||||
@@ -187,8 +69,19 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
const extension = extensions.find((candidate) => candidate.file === source.file);
|
||||
if (!extension) throw new Error(`Extension file missing for ${source.name}.`);
|
||||
// Saved values ride along on every call; the extension is stateless per request.
|
||||
const saved = await preferenceStore.get(source.id);
|
||||
return { ...toBridgeSource(extension, source.id), preferences: saved };
|
||||
const saved = await preferenceStore.get(source.pkg, source.bridgeId);
|
||||
return { ...toBridgeSource(extension, source.bridgeId), preferences: saved };
|
||||
}
|
||||
|
||||
function overlaySavedPreferences(
|
||||
schema: BridgePreference[],
|
||||
saved: BridgePreference[],
|
||||
): BridgePreference[] {
|
||||
let merged = schema;
|
||||
for (const preference of parsePreferences(saved)) {
|
||||
merged = applyPreferenceValue(merged, preference.key, preference.value);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,7 +104,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
);
|
||||
|
||||
setState({ stage: 'starting', progress: null, message: null });
|
||||
const handle = await startSidecar({
|
||||
const handle = await (deps.startSidecar ?? startSidecar)({
|
||||
binaries,
|
||||
onLog: (line) => deps.log(`[anime-bridge] ${line}`),
|
||||
});
|
||||
@@ -239,7 +132,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
});
|
||||
|
||||
try {
|
||||
const proxy = await startStreamStripProxy({
|
||||
const proxy = await (deps.startStreamStripProxy ?? startStreamStripProxy)({
|
||||
upstreamOrigin: () => sidecar?.baseUrl ?? handle.baseUrl,
|
||||
log: deps.log,
|
||||
});
|
||||
@@ -397,6 +290,13 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
};
|
||||
}
|
||||
|
||||
const playback = createAnimeBrowserPlayback({
|
||||
deps,
|
||||
bridge,
|
||||
sourceFor,
|
||||
stripProxy: () => stripProxy,
|
||||
});
|
||||
|
||||
return {
|
||||
getSnapshot(): AnimeBrowserSnapshot {
|
||||
return {
|
||||
@@ -501,8 +401,9 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
async getPreferences(sourceId: string): Promise<SourcePreferenceView[]> {
|
||||
const { client } = await bridge();
|
||||
const source = requireSource(sourceId);
|
||||
const schema = await client.getSourcePreferences(await sourceFor(source.id));
|
||||
return parsePreferences(schema);
|
||||
const bridgeSource = await sourceFor(source.id);
|
||||
const schema = await client.getSourcePreferences(bridgeSource);
|
||||
return parsePreferences(overlaySavedPreferences(schema, bridgeSource.preferences ?? []));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -515,20 +416,21 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
value: string | string[] | boolean,
|
||||
): Promise<SourcePreferenceView[]> {
|
||||
const { client } = await bridge();
|
||||
const source = await sourceFor(sourceId);
|
||||
const extensionSource = requireSource(sourceId);
|
||||
const source = await sourceFor(extensionSource.id);
|
||||
|
||||
// Start from the extension's own schema so saved values never go stale
|
||||
// against an updated extension.
|
||||
const current =
|
||||
source.preferences && source.preferences.length > 0
|
||||
? source.preferences
|
||||
: await client.getSourcePreferences(source);
|
||||
const schema = await client.getSourcePreferences(source);
|
||||
const current = overlaySavedPreferences(schema, source.preferences ?? []);
|
||||
|
||||
const updated = applyPreferenceValue(current, key, value);
|
||||
await preferenceStore.set(sourceId, updated);
|
||||
await preferenceStore.set(extensionSource.pkg, extensionSource.bridgeId, updated);
|
||||
|
||||
const refreshed = await client.setSourcePreference({ ...source, preferences: updated }, key);
|
||||
if (refreshed.length > 0) await preferenceStore.set(sourceId, refreshed);
|
||||
if (refreshed.length > 0) {
|
||||
await preferenceStore.set(extensionSource.pkg, extensionSource.bridgeId, refreshed);
|
||||
}
|
||||
return parsePreferences(refreshed.length > 0 ? refreshed : updated);
|
||||
},
|
||||
|
||||
@@ -573,127 +475,16 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
}));
|
||||
},
|
||||
|
||||
async playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> {
|
||||
try {
|
||||
const { client, baseUrl } = await bridge();
|
||||
const videos = await client.getVideoList(
|
||||
await sourceFor(request.sourceId),
|
||||
request.episodeUrl,
|
||||
);
|
||||
const streams = videos
|
||||
.map((video) => resolveStream(video))
|
||||
.filter((stream): stream is NonNullable<typeof stream> => stream !== null)
|
||||
// External tracks come off the same loopback proxy as the video, so
|
||||
// they need the same rebase onto the port the bridge really uses.
|
||||
.map((stream) => ({
|
||||
...stream,
|
||||
url: resolveBridgeMediaUrl(baseUrl, stream.url),
|
||||
audios: stream.audios.map((track) => ({
|
||||
...track,
|
||||
url: resolveBridgeMediaUrl(baseUrl, track.url),
|
||||
})),
|
||||
subtitles: stream.subtitles.map((track) => ({
|
||||
...track,
|
||||
url: resolveBridgeMediaUrl(baseUrl, track.url),
|
||||
})),
|
||||
}));
|
||||
|
||||
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 {
|
||||
ok: false,
|
||||
error: 'mpv is not running and could not be started.',
|
||||
quality: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
try {
|
||||
const metadata = buildAnimeStreamMetadata({
|
||||
sourceId: request.sourceId,
|
||||
animeUrl: request.animeUrl,
|
||||
animeTitle: request.animeTitle,
|
||||
episodeUrl: request.episodeUrl,
|
||||
episodeName: request.episodeName,
|
||||
episodeNumber: request.episodeNumber ?? null,
|
||||
mediaPath: stream.url,
|
||||
});
|
||||
const title = metadata.displayTitle;
|
||||
// Before loadfile: mpv's path change is what starts a stats session,
|
||||
// and it must find this already recorded.
|
||||
deps.onPlaybackMetadata?.(metadata);
|
||||
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();
|
||||
|
||||
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 };
|
||||
}
|
||||
},
|
||||
playEpisode: playback.playEpisode,
|
||||
|
||||
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 playback.dispose();
|
||||
await proxy?.close();
|
||||
await handle?.stop();
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStreamPlaybackMetadataStore,
|
||||
matchRequestedStreamPlaybackMetadata,
|
||||
toAnilistMediaGuess,
|
||||
toJimakuMediaInfo,
|
||||
} from './stream-playback-metadata';
|
||||
@@ -42,6 +43,18 @@ test('the store stops answering once the player moves on', () => {
|
||||
assert.equal(store.match(metadata().mediaPath), null);
|
||||
});
|
||||
|
||||
test('an explicit target path does not inherit the current stream metadata', () => {
|
||||
const store = createStreamPlaybackMetadataStore();
|
||||
const current = metadata();
|
||||
store.set(current);
|
||||
|
||||
assert.equal(
|
||||
matchRequestedStreamPlaybackMetadata(store, '/home/user/Videos/other.mkv', current.mediaPath),
|
||||
null,
|
||||
);
|
||||
assert.equal(matchRequestedStreamPlaybackMetadata(store, null, current.mediaPath), current);
|
||||
});
|
||||
|
||||
test('toJimakuMediaInfo prefills the modals with the source-reported fields', () => {
|
||||
assert.deepEqual(toJimakuMediaInfo(metadata()), {
|
||||
title: 'Mushoku Tensei: Jobless Reincarnation',
|
||||
@@ -60,6 +73,12 @@ test('toJimakuMediaInfo drops to low confidence when there is no episode', () =>
|
||||
assert.equal(info.title, 'Mushoku Tensei: Jobless Reincarnation');
|
||||
});
|
||||
|
||||
test('toJimakuMediaInfo reports low confidence when a fractional episode is omitted', () => {
|
||||
const info = toJimakuMediaInfo(metadata({ episodeNumber: 6.5 }));
|
||||
assert.equal(info.episode, null);
|
||||
assert.equal(info.confidence, 'low');
|
||||
});
|
||||
|
||||
test('toAnilistMediaGuess reports the stream fields verbatim', () => {
|
||||
assert.deepEqual(toAnilistMediaGuess(metadata()), {
|
||||
title: 'Mushoku Tensei: Jobless Reincarnation',
|
||||
|
||||
@@ -41,6 +41,18 @@ export function createStreamPlaybackMetadataStore(): StreamPlaybackMetadataStore
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Match metadata for the path a caller requested, falling back to the active
|
||||
* player path only when the caller has no path of its own.
|
||||
*/
|
||||
export function matchRequestedStreamPlaybackMetadata(
|
||||
store: StreamPlaybackMetadataStore,
|
||||
requestedMediaPath: string | null,
|
||||
currentMediaPath: string | null,
|
||||
): AnimeStreamMetadata | null {
|
||||
return store.match(requestedMediaPath ?? currentMediaPath);
|
||||
}
|
||||
|
||||
/** AniList counts whole episodes, so a special numbered 6.5 cannot drive it. */
|
||||
function wholeEpisode(episode: number | null): number | null {
|
||||
return typeof episode === 'number' && Number.isInteger(episode) && episode > 0 ? episode : null;
|
||||
@@ -52,11 +64,12 @@ function wholeEpisode(episode: number | null): number | null {
|
||||
* modals may search on them without waiting for the user to confirm.
|
||||
*/
|
||||
export function toJimakuMediaInfo(metadata: AnimeStreamMetadata): JimakuMediaInfo {
|
||||
const episode = wholeEpisode(metadata.episodeNumber);
|
||||
return {
|
||||
title: metadata.seriesTitle,
|
||||
season: metadata.seasonNumber,
|
||||
episode: wholeEpisode(metadata.episodeNumber),
|
||||
confidence: metadata.episodeNumber !== null ? 'high' : 'low',
|
||||
episode,
|
||||
confidence: episode !== null ? 'high' : 'low',
|
||||
filename: metadata.displayTitle,
|
||||
rawTitle: metadata.displayTitle,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user