mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 01:55:51 -07:00
289c74da35
- 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
131 lines
4.4 KiB
TypeScript
131 lines
4.4 KiB
TypeScript
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 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.
|
|
*/
|
|
export class PreferenceStore {
|
|
private readonly file: string;
|
|
private cache: Record<string, BridgePreference[]> | null = null;
|
|
/**
|
|
* Mutations run one at a time. Two concurrent load-modify-persist cycles
|
|
* starting on a cold cache would each read their own object, and the later
|
|
* write would drop the earlier one's edit.
|
|
*/
|
|
private queue: Promise<unknown> = Promise.resolve();
|
|
|
|
constructor(file: string) {
|
|
this.file = file;
|
|
}
|
|
|
|
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
|
const result = this.queue.then(operation, operation);
|
|
// Keep the chain alive after a rejection so one failure cannot wedge it.
|
|
this.queue = result.catch(() => undefined);
|
|
return result;
|
|
}
|
|
|
|
private async load(): Promise<Record<string, BridgePreference[]>> {
|
|
if (this.cache !== null) return this.cache;
|
|
try {
|
|
const parsed = JSON.parse(await readFile(this.file, 'utf8')) as unknown;
|
|
this.cache = parseStoredPreferences(parsed);
|
|
} catch {
|
|
// Missing or corrupt file starts empty rather than blocking the browser.
|
|
this.cache = {};
|
|
}
|
|
return this.cache;
|
|
}
|
|
|
|
async get(pkg: string, sourceId: string): Promise<BridgePreference[]> {
|
|
return this.enqueue(async () => {
|
|
const all = await this.load();
|
|
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(pkg: string, sourceId: string, preferences: BridgePreference[]): Promise<void> {
|
|
await this.enqueue(async () => {
|
|
const all = await this.load();
|
|
all[`${pkg}:${sourceId}`] = preferences;
|
|
await this.persist(all);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Drop every saved value whose key starts with `prefix`.
|
|
*
|
|
* Removing an extension should not leave its credentials on disk, and a
|
|
* source id is not knowable once the APK is gone — so callers pass the
|
|
* package name and this clears anything recorded under it.
|
|
*/
|
|
async clear(prefix: string): Promise<void> {
|
|
await this.enqueue(async () => {
|
|
const all = await this.load();
|
|
let changed = false;
|
|
for (const key of Object.keys(all)) {
|
|
if (key === prefix || key.startsWith(`${prefix}:`)) {
|
|
delete all[key];
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) await this.persist(all);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Write through a temporary file and rename into place.
|
|
*
|
|
* A write interrupted partway would otherwise leave truncated JSON, and
|
|
* `load()` treats unparseable content as empty — which would quietly discard
|
|
* every saved credential.
|
|
*/
|
|
private async persist(all: Record<string, BridgePreference[]>): Promise<void> {
|
|
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);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|