mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-08 07:21:31 -07:00
feat(anime): add anime browser powered by Aniyomi extensions
- Add `subminer anime` / `--anime` and a tray entry to open a browser that searches installed Aniyomi extension sources, shows cover art and episodes, and plays into mpv with overlay/mining attached - Add an Extensions tab to add repos and install/update/remove sources, and per-source settings for sources needing config - Support searching all sources at once with streaming, per-source results and status - Prefer Japanese audio/subtitle tracks from the source and keep the primary subtitle slot reserved for Japanese - Fix window/tray/Dock handling so the browser and mpv can be switched between without quitting the app or losing the Dock icon - Add anime.repos, anime.extensionsDir, anime.preferredQuality config keys (no bundled repos or discovery)
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { chmod, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
BUNDLE_RELEASES_URL,
|
||||
findBundleBinaries,
|
||||
resolveBundleAssetName,
|
||||
selectBundleAsset,
|
||||
verifyPinnedBundle,
|
||||
type BundleBinaries,
|
||||
} from '../../anime-bridge/sidecar-bundle';
|
||||
|
||||
/**
|
||||
* Downloads and unpacks the M-Extension-Server bundle that runs Aniyomi
|
||||
* extension APKs. The bundle ships its own JRE, so no system Java is needed.
|
||||
*/
|
||||
|
||||
export type InstallStage = 'locating' | 'downloading' | 'verifying' | 'extracting';
|
||||
|
||||
export interface InstallProgress {
|
||||
stage: InstallStage;
|
||||
/** 0-1 during download, otherwise null. */
|
||||
progress: number | null;
|
||||
}
|
||||
|
||||
export interface EnsureBridgeOptions {
|
||||
/** Directory the bundle is unpacked into, e.g. `<userData>/anime-bridge`. */
|
||||
installDir: string;
|
||||
platform?: string;
|
||||
arch?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
onProgress?: (progress: InstallProgress) => void;
|
||||
}
|
||||
|
||||
/** Extract a zip without adding a dependency, mirroring scripts/build-yomitan.mjs. */
|
||||
async function extractZip(zipPath: string, targetDir: string): Promise<void> {
|
||||
const attempts: Array<[string, string[]]> = [
|
||||
['unzip', ['-qo', zipPath, '-d', targetDir]],
|
||||
// bsdtar ships with macOS and Windows 10+ and reads zip archives.
|
||||
['tar', ['-xf', zipPath, '-C', targetDir]],
|
||||
];
|
||||
|
||||
let lastError = '';
|
||||
for (const [command, args] of attempts) {
|
||||
const result = await runCommand(command, args);
|
||||
if (result.ok) return;
|
||||
lastError = result.error;
|
||||
}
|
||||
throw new Error(`Could not extract the anime bridge bundle. ${lastError}`);
|
||||
}
|
||||
|
||||
function runCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let stderr = '';
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.once('error', (error) => resolve({ ok: false, error: `${command}: ${error.message}` }));
|
||||
child.once('exit', (code) => {
|
||||
if (code === 0) resolve({ ok: true });
|
||||
else resolve({ ok: false, error: `${command} exited ${code}. ${stderr.trim()}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadWithProgress(
|
||||
response: Response,
|
||||
onProgress: ((fraction: number) => void) | undefined,
|
||||
): Promise<Uint8Array> {
|
||||
const declared = Number(response.headers.get('content-length') ?? '0');
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return new Uint8Array(await response.arrayBuffer());
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
if (declared > 0) onProgress?.(Math.min(1, received / declared));
|
||||
}
|
||||
}
|
||||
|
||||
const merged = new Uint8Array(received);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the bundle's java + jar paths, downloading the release first if the
|
||||
* install directory does not already hold a usable copy.
|
||||
*/
|
||||
export async function ensureBridgeBinaries(options: EnsureBridgeOptions): Promise<BundleBinaries> {
|
||||
const existing = await findBundleBinaries(options.installDir);
|
||||
if (existing) return existing;
|
||||
|
||||
const platform = options.platform ?? process.platform;
|
||||
const arch = options.arch ?? process.arch;
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
|
||||
const assetName = resolveBundleAssetName(platform, arch);
|
||||
if (assetName === null) {
|
||||
throw new Error(
|
||||
`No anime bridge build is published for ${platform}/${arch}. ` +
|
||||
'Supported: macOS (arm64, x64), Linux (x64), Windows (x64).',
|
||||
);
|
||||
}
|
||||
|
||||
options.onProgress?.({ stage: 'locating', progress: null });
|
||||
const releasesResponse = await fetchImpl(BUNDLE_RELEASES_URL, {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
});
|
||||
if (!releasesResponse.ok) {
|
||||
throw new Error(`Could not list anime bridge releases (${releasesResponse.status}).`);
|
||||
}
|
||||
const asset = selectBundleAsset(await releasesResponse.json(), assetName);
|
||||
if (asset === null) {
|
||||
throw new Error(`No published anime bridge release contains ${assetName}.`);
|
||||
}
|
||||
|
||||
options.onProgress?.({ stage: 'downloading', progress: 0 });
|
||||
const downloadResponse = await fetchImpl(asset.downloadUrl);
|
||||
if (!downloadResponse.ok) {
|
||||
throw new Error(`Downloading the anime bridge failed (${downloadResponse.status}).`);
|
||||
}
|
||||
const bytes = await downloadWithProgress(downloadResponse, (fraction) =>
|
||||
options.onProgress?.({ stage: 'downloading', progress: fraction }),
|
||||
);
|
||||
|
||||
options.onProgress?.({ stage: 'verifying', progress: null });
|
||||
const verification = verifyPinnedBundle(assetName, bytes);
|
||||
if (!verification.ok) throw new Error(verification.reason);
|
||||
|
||||
options.onProgress?.({ stage: 'extracting', progress: null });
|
||||
await mkdir(options.installDir, { recursive: true });
|
||||
const zipPath = path.join(options.installDir, assetName);
|
||||
await writeFile(zipPath, bytes);
|
||||
try {
|
||||
await extractZip(zipPath, options.installDir);
|
||||
} finally {
|
||||
await rm(zipPath, { force: true });
|
||||
}
|
||||
|
||||
const binaries = await findBundleBinaries(options.installDir);
|
||||
if (!binaries) {
|
||||
throw new Error('The anime bridge bundle unpacked without a java runtime or server jar.');
|
||||
}
|
||||
// Some extractors drop the executable bit; restore it rather than failing at spawn.
|
||||
if (platform !== 'win32') {
|
||||
await chmod(binaries.javaPath, 0o755).catch(() => undefined);
|
||||
}
|
||||
return binaries;
|
||||
}
|
||||
Reference in New Issue
Block a user