Files
SubMiner/src/anime-bridge/extension-repo.ts
T
sudacode c2917ac1a5 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
2026-08-03 02:16:22 -07:00

201 lines
6.3 KiB
TypeScript

/**
* Client for Aniyomi-format extension repositories.
*
* SubMiner ships no repositories and performs no discovery. A repository only
* exists once the user adds its index URL, and only extensions from those
* repositories are ever listed or downloaded.
*/
/** Aniyomi extension packages carry this prefix; manga packages are ignored. */
const ANIME_PACKAGE_PREFIX = 'eu.kanade.tachiyomi.animeextension';
/**
* A package name becomes the on-disk APK file name, and a repository index is
* unauthenticated content the user pointed us at. Only plain dotted identifiers
* are accepted, so nothing in an index can carry `/` or `..` into a file path.
*/
const PACKAGE_NAME_PATTERN = /^[A-Za-z0-9_.]+$/;
/** The APK file name is appended to the repo URL, so keep it a bare name. */
const APK_FILE_NAME_PATTERN = /^[A-Za-z0-9_.+-]+$/;
/**
* Repos are identified by their index URL. The file name is not fixed:
* `index.min.json` is the Aniyomi convention, but repositories publish under
* other names too (e.g. `video.min.json`), so only https and a `.json` file
* name are required.
*/
const INDEX_URL_PATTERN = /^https:\/\/[^\s/]+(?:\/[^\s/]*)*\/[^\s/]+\.json$/;
export interface RepoExtension {
/** Package name, the stable identity of an extension across versions. */
pkg: string;
name: string;
lang: string;
version: string;
/** Monotonic version code; the comparison basis for updates. */
versionCode: number;
nsfw: boolean;
apkUrl: string;
iconUrl: string;
/** Index URL of the repo this came from. */
repoUrl: string;
/** Source names the package provides, when the index declares them. */
sourceNames: string[];
}
/** `true` when `url` is a usable Aniyomi index URL. */
export function isValidRepoUrl(url: string): boolean {
return INDEX_URL_PATTERN.test(url.trim());
}
/** Strip the index file name to get the repo root. */
export function repoBaseUrl(indexUrl: string): string {
return indexUrl.trim().replace(/\/[^/]*$/, '');
}
interface RawEntry {
name?: unknown;
pkg?: unknown;
apk?: unknown;
lang?: unknown;
code?: unknown;
version?: unknown;
nsfw?: unknown;
sources?: unknown;
}
function readSourceNames(sources: unknown): string[] {
if (!Array.isArray(sources)) return [];
return sources
.map((source) =>
source !== null && typeof source === 'object'
? (source as { name?: unknown }).name
: undefined,
)
.filter((name): name is string => typeof name === 'string' && name.length > 0);
}
/**
* Parse an index payload into anime extensions.
*
* Entries that are malformed, or that are manga rather than anime packages,
* are skipped rather than failing the whole repo.
*/
export function parseRepoIndex(indexUrl: string, payload: unknown): RepoExtension[] {
if (!Array.isArray(payload)) return [];
const base = repoBaseUrl(indexUrl);
const extensions: RepoExtension[] = [];
for (const raw of payload as RawEntry[]) {
if (raw === null || typeof raw !== 'object') continue;
const pkg = typeof raw.pkg === 'string' ? raw.pkg : '';
const apk = typeof raw.apk === 'string' ? raw.apk : '';
if (!pkg.startsWith(ANIME_PACKAGE_PREFIX) || !PACKAGE_NAME_PATTERN.test(pkg)) continue;
if (apk.length === 0 || !APK_FILE_NAME_PATTERN.test(apk)) continue;
const versionCode = Number(raw.code);
extensions.push({
pkg,
// Repo entries are prefixed "Aniyomi: "; the app supplies its own context.
name: (typeof raw.name === 'string' ? raw.name : pkg).replace(/^Aniyomi:\s*/, ''),
lang: typeof raw.lang === 'string' ? raw.lang : 'all',
version: typeof raw.version === 'string' ? raw.version : '0',
versionCode: Number.isFinite(versionCode) ? versionCode : 0,
nsfw: Number(raw.nsfw) === 1,
apkUrl: `${base}/apk/${apk}`,
iconUrl: `${base}/icon/${pkg}.png`,
repoUrl: indexUrl,
sourceNames: readSourceNames(raw.sources),
});
}
return extensions;
}
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,
options: FetchRepoOptions = {},
): Promise<RepoExtension[]> {
if (!isValidRepoUrl(indexUrl)) {
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' },
signal,
});
if (!response.ok) {
throw new Error(`Repository returned ${response.status} for ${indexUrl}`);
}
return parseRepoIndex(indexUrl, await response.json());
}
export interface RepoFetchFailure {
repoUrl: string;
error: string;
}
export interface RepoCatalogue {
extensions: RepoExtension[];
failures: RepoFetchFailure[];
}
/**
* Fetch every configured repository.
*
* When two repos publish the same package, the higher version code wins, so a
* user's preferred repo ordering does not silently pin an older build.
*/
export async function fetchRepoCatalogue(
indexUrls: string[],
options: FetchRepoOptions = {},
): Promise<RepoCatalogue> {
const failures: RepoFetchFailure[] = [];
const byPackage = new Map<string, RepoExtension>();
const results = await Promise.all(
indexUrls.map(async (indexUrl) => {
try {
return { indexUrl, extensions: await fetchRepoIndex(indexUrl, options) };
} catch (error) {
failures.push({
repoUrl: indexUrl,
error: error instanceof Error ? error.message : String(error),
});
return { indexUrl, extensions: [] as RepoExtension[] };
}
}),
);
for (const { extensions } of results) {
for (const extension of extensions) {
const existing = byPackage.get(extension.pkg);
if (!existing || extension.versionCode > existing.versionCode) {
byPackage.set(extension.pkg, extension);
}
}
}
return {
extensions: [...byPackage.values()].sort((a, b) => a.name.localeCompare(b.name)),
failures,
};
}
/** File name an extension is stored under, so updates replace in place. */
export function extensionFileName(pkg: string): string {
return `${pkg}.apk`;
}