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 f5369b8b24
commit 289c74da35
48 changed files with 2158 additions and 1288 deletions
+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);