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

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