mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-07 19:21:32 -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:
@@ -51,6 +51,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openAnimeBrowserWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -137,6 +138,7 @@ function createCliCommandDepsFromContext(
|
||||
openYomitanSettings: context.openYomitanSettings,
|
||||
openConfigSettingsWindow: context.openConfigSettingsWindow,
|
||||
openSyncUiWindow: context.openSyncUiWindow,
|
||||
openAnimeBrowserWindow: context.openAnimeBrowserWindow,
|
||||
cycleSecondarySubMode: context.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
|
||||
printHelp: context.printHelp,
|
||||
|
||||
@@ -212,6 +212,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
||||
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
|
||||
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
|
||||
openAnimeBrowserWindow: CliCommandDepsRuntimeOptions['ui']['openAnimeBrowserWindow'];
|
||||
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
|
||||
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
|
||||
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
|
||||
@@ -418,6 +419,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
openYomitanSettings: params.ui.openYomitanSettings,
|
||||
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
|
||||
openSyncUiWindow: params.ui.openSyncUiWindow,
|
||||
openAnimeBrowserWindow: params.ui.openAnimeBrowserWindow,
|
||||
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
|
||||
printHelp: params.ui.printHelp,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import type { AnimeBrowserRuntime } from './anime-browser-runtime';
|
||||
import type { AnimeBrowserPlayRequest } from '../../types/anime-browser';
|
||||
|
||||
export interface AnimeBrowserIpcDeps {
|
||||
// Structurally typed so tests can pass a fake without importing Electron.
|
||||
ipcMain: {
|
||||
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
|
||||
};
|
||||
runtime: AnimeBrowserRuntime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge the renderer to the anime runtime. Page arguments arrive as `unknown`
|
||||
* from the renderer, so they are coerced here rather than trusted.
|
||||
*/
|
||||
export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void {
|
||||
const channels = IPC_CHANNELS.request;
|
||||
const { runtime } = deps;
|
||||
const handle = (
|
||||
channel: string,
|
||||
listener: (event: unknown, ...args: unknown[]) => unknown,
|
||||
): void => {
|
||||
deps.ipcMain.handle(channel, listener);
|
||||
};
|
||||
|
||||
handle(channels.animeBrowserGetSnapshot, () => runtime.getSnapshot());
|
||||
handle(channels.animeBrowserEnsureBridge, () => runtime.ensureBridge());
|
||||
handle(channels.animeBrowserSelectSource, (_event, sourceId) =>
|
||||
runtime.selectSource(String(sourceId)),
|
||||
);
|
||||
handle(channels.animeBrowserSearch, (_event, query, page) =>
|
||||
runtime.search(String(query ?? ''), toPage(page)),
|
||||
);
|
||||
handle(channels.animeBrowserGetPopular, (_event, page) => runtime.getPopular(toPage(page)));
|
||||
handle(channels.animeBrowserGetDetails, (_event, animeUrl, sourceId) =>
|
||||
runtime.getDetails(String(animeUrl), toOptionalId(sourceId)),
|
||||
);
|
||||
handle(channels.animeBrowserGetEpisodes, (_event, animeUrl, sourceId) =>
|
||||
runtime.getEpisodes(String(animeUrl), toOptionalId(sourceId)),
|
||||
);
|
||||
handle(channels.animeBrowserListAvailableExtensions, () => runtime.listAvailableExtensions());
|
||||
handle(channels.animeBrowserInstallExtension, (_event, pkg) =>
|
||||
runtime.installExtension(String(pkg)),
|
||||
);
|
||||
handle(channels.animeBrowserRemoveExtension, (_event, pkg) =>
|
||||
runtime.removeExtension(String(pkg)),
|
||||
);
|
||||
handle(channels.animeBrowserRescanExtensions, () => runtime.rescanExtensions());
|
||||
handle(channels.animeBrowserAddRepo, (_event, url) => runtime.addRepo(String(url)));
|
||||
handle(channels.animeBrowserRemoveRepo, (_event, url) => runtime.removeRepo(String(url)));
|
||||
handle(channels.animeBrowserPlayEpisode, (_event, request) =>
|
||||
runtime.playEpisode(request as AnimeBrowserPlayRequest),
|
||||
);
|
||||
handle(channels.animeBrowserGetPreferences, (_event, sourceId) =>
|
||||
runtime.getPreferences(String(sourceId)),
|
||||
);
|
||||
handle(channels.animeBrowserSetPreference, (_event, sourceId, key, value) =>
|
||||
runtime.setPreference(String(sourceId), String(key), value as string | string[] | boolean),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A source id the renderer may omit. Absent means "use the current selection",
|
||||
* so an empty value must stay undefined rather than becoming the string "".
|
||||
*/
|
||||
function toOptionalId(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** Bridge pages are 1-based; anything unusable falls back to the first page. */
|
||||
function toPage(value: unknown): number {
|
||||
const page = Number(value);
|
||||
return Number.isFinite(page) && page >= 1 ? Math.floor(page) : 1;
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
import { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
|
||||
import { resolveStream } from '../../anime-bridge/headers';
|
||||
import { parseAnimeStatus, resolveBridgeMediaUrl } from '../../anime-bridge/media-url';
|
||||
import {
|
||||
buildPlaybackCommands,
|
||||
buildTrackCommands,
|
||||
selectPreferredStream,
|
||||
} from '../../anime-bridge/mpv-playback';
|
||||
import {
|
||||
listExtensionSources,
|
||||
readInstalledExtensions,
|
||||
toBridgeSource,
|
||||
toInstalledExtensionViews,
|
||||
type ExtensionSource,
|
||||
type InstalledExtension,
|
||||
} from '../../anime-bridge/extension-store';
|
||||
import { interleave, mapSourcesConcurrently } from '../../anime-bridge/multi-source-search';
|
||||
import { startSidecar, type SidecarHandle } from '../../anime-bridge/sidecar-process';
|
||||
import {
|
||||
fetchRepoCatalogue,
|
||||
isValidRepoUrl,
|
||||
type RepoExtension,
|
||||
} from '../../anime-bridge/extension-repo';
|
||||
import {
|
||||
installExtension,
|
||||
removeExtension as removeExtensionFile,
|
||||
} from '../../anime-bridge/extension-installer';
|
||||
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>;
|
||||
showMpvOsd?: (message: string) => void;
|
||||
showVisibleOverlay?: () => void;
|
||||
/** Lets tests drive the pause between `loadfile` and the track commands. */
|
||||
wait?: (ms: number) => Promise<void>;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
let starting: Promise<SidecarHandle> | null = null;
|
||||
let extensions: InstalledExtension[] = [];
|
||||
let sources: ExtensionSource[] = [];
|
||||
let selectedSourceId: string | null = null;
|
||||
let loadFailures: ExtensionLoadFailure[] = [];
|
||||
// Monotonic; identifies the newest browse so stale ones stop emitting.
|
||||
let searchToken = 0;
|
||||
const preferenceStore = new PreferenceStore(deps.preferencesFile);
|
||||
const wait =
|
||||
deps.wait ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
||||
|
||||
function setState(state: AnimeBrowserBridgeState): void {
|
||||
bridgeState = state;
|
||||
deps.onBridgeState(state);
|
||||
}
|
||||
|
||||
async function sourceFor(sourceId: string) {
|
||||
const source = sources.find((candidate) => candidate.id === sourceId);
|
||||
if (!source) throw new Error('That source is no longer installed. Rescan and try again.');
|
||||
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 };
|
||||
}
|
||||
|
||||
function requireBridge(): { client: AnimeBridgeClient; baseUrl: string } {
|
||||
if (!sidecar) throw new Error('The anime bridge is not running yet.');
|
||||
return { client: sidecar.client, baseUrl: sidecar.baseUrl };
|
||||
}
|
||||
|
||||
async function startBridge(): Promise<SidecarHandle> {
|
||||
const binaries = await deps.ensureBinaries((progress) =>
|
||||
setState({ stage: progress.stage, progress: progress.progress, message: null }),
|
||||
);
|
||||
|
||||
setState({ stage: 'starting', progress: null, message: null });
|
||||
const handle = await startSidecar({
|
||||
binaries,
|
||||
onLog: (line) => deps.log(`[anime-bridge] ${line}`),
|
||||
});
|
||||
sidecar = handle;
|
||||
|
||||
await scanExtensions(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the extensions directory and ask the bridge what each APK provides.
|
||||
* Called on start and after any install or removal.
|
||||
*/
|
||||
async function scanExtensions(handle: SidecarHandle): Promise<void> {
|
||||
const directory = deps.extensionsDir();
|
||||
loadFailures = [];
|
||||
extensions = await readInstalledExtensions(directory);
|
||||
sources = await listExtensionSources(handle.client, extensions, (extension, error) => {
|
||||
const message = describeError(error);
|
||||
loadFailures.push({ pkg: extension.fallbackName, error: message });
|
||||
deps.log(`[anime-bridge] extension ${extension.fallbackName} failed to load: ${message}`);
|
||||
});
|
||||
|
||||
// Keep the current selection if it survived the rescan. "All sources"
|
||||
// survives as long as anything is installed.
|
||||
const keptAll = selectedSourceId === ALL_SOURCES_ID && sources.length > 0;
|
||||
if (!keptAll && !sources.some((source) => source.id === selectedSourceId)) {
|
||||
selectedSourceId = sources[0]?.id ?? null;
|
||||
}
|
||||
|
||||
setState({
|
||||
stage: 'ready',
|
||||
progress: null,
|
||||
message:
|
||||
sources.length === 0
|
||||
? `No anime extensions installed. Add a repository or put .apk files in ${directory}.`
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureBridge(): Promise<AnimeBrowserBridgeState> {
|
||||
if (sidecar) return bridgeState;
|
||||
// Collapse concurrent callers onto one start; the UI calls this eagerly.
|
||||
if (!starting) {
|
||||
starting = startBridge().catch((error: unknown) => {
|
||||
setState({ stage: 'failed', progress: null, message: describeError(error) });
|
||||
starting = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
try {
|
||||
await starting;
|
||||
} catch {
|
||||
return bridgeState;
|
||||
}
|
||||
return bridgeState;
|
||||
}
|
||||
|
||||
async function installExtensionFrom(extension: RepoExtension): Promise<void> {
|
||||
await installExtension({ extensionsDir: deps.extensionsDir(), extension });
|
||||
if (sidecar) await scanExtensions(sidecar);
|
||||
}
|
||||
|
||||
function toEntry(
|
||||
baseUrl: string,
|
||||
anime: { url?: string; title?: string; thumbnail_url?: string },
|
||||
source: ExtensionSource,
|
||||
) {
|
||||
return {
|
||||
url: anime.url ?? '',
|
||||
title: anime.title ?? 'Untitled',
|
||||
thumbnailUrl: anime.thumbnail_url
|
||||
? resolveBridgeMediaUrl(baseUrl, anime.thumbnail_url)
|
||||
: null,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
} satisfies AnimeBrowserEntry;
|
||||
}
|
||||
|
||||
/** The one source a per-anime call must run against. */
|
||||
function requireSource(sourceId: string | null): ExtensionSource {
|
||||
const source = sources.find((candidate) => candidate.id === sourceId);
|
||||
if (!source) {
|
||||
throw new Error(
|
||||
sourceId === ALL_SOURCES_ID ? 'Pick a single source for this.' : 'Select a source first.',
|
||||
);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a listing call against the selected source, or against every installed
|
||||
* source when "all sources" is selected.
|
||||
*
|
||||
* With one source a failure rejects, as it always has. Across all of them a
|
||||
* failure is reported alongside the sources that did answer, so one broken
|
||||
* extension cannot blank the grid.
|
||||
*/
|
||||
async function browse(
|
||||
page: number,
|
||||
fetchPage: (
|
||||
source: Awaited<ReturnType<typeof sourceFor>>,
|
||||
page: number,
|
||||
) => Promise<BridgeAnimePage>,
|
||||
): Promise<AnimeBrowserSearchResult> {
|
||||
const { baseUrl } = requireBridge();
|
||||
const targets =
|
||||
selectedSourceId === ALL_SOURCES_ID ? sources : [requireSource(selectedSourceId)];
|
||||
if (targets.length === 0) throw new Error('No sources are installed.');
|
||||
|
||||
// Each source's answer is pushed the moment it lands, so a fast source is
|
||||
// on screen while a slow one is still resolving. Guarded by the token: a
|
||||
// superseded search stops emitting, and its remaining sources run out
|
||||
// quietly.
|
||||
const token = ++searchToken;
|
||||
const emit = (update: AnimeBrowserSearchUpdate): void => {
|
||||
if (token === searchToken) deps.onSearchUpdate?.(update);
|
||||
};
|
||||
emit({ kind: 'start', token, sourceCount: targets.length });
|
||||
|
||||
const { results, failures } = await mapSourcesConcurrently(targets, async (source) => {
|
||||
try {
|
||||
const response = await fetchPage(await sourceFor(source.id), page);
|
||||
const entries = (response.animes ?? []).map((anime) => toEntry(baseUrl, anime, source));
|
||||
emit({ kind: 'result', token, sourceId: source.id, sourceName: source.name, entries });
|
||||
return { entries, hasNextPage: response.hasNextPage === true };
|
||||
} catch (error) {
|
||||
emit({
|
||||
kind: 'failure',
|
||||
token,
|
||||
failure: { sourceId: source.id, sourceName: source.name, error: describeError(error) },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
emit({ kind: 'done', token });
|
||||
|
||||
// A single-source browse has no other source to fall back on, so surface
|
||||
// the error the way a direct call would.
|
||||
if (targets.length === 1 && failures[0]) throw new Error(failures[0].error);
|
||||
|
||||
return {
|
||||
entries: interleave(results.map((result) => result.entries)),
|
||||
hasNextPage: results.some((result) => result.hasNextPage),
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getSnapshot(): AnimeBrowserSnapshot {
|
||||
return {
|
||||
bridge: bridgeState,
|
||||
sources: sources.map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
lang: source.lang,
|
||||
pkg: source.pkg,
|
||||
})),
|
||||
selectedSourceId,
|
||||
loadFailures,
|
||||
installed: toInstalledExtensionViews(extensions, sources, loadFailures),
|
||||
extensionsDir: deps.extensionsDir(),
|
||||
repos: deps.repos(),
|
||||
};
|
||||
},
|
||||
|
||||
ensureBridge,
|
||||
|
||||
/**
|
||||
* Extensions available from the configured repositories, annotated with
|
||||
* what is installed. Returns nothing when no repository is configured —
|
||||
* SubMiner never supplies one.
|
||||
*/
|
||||
async listAvailableExtensions(): Promise<AvailableExtensionsResult> {
|
||||
const repos = deps.repos();
|
||||
if (repos.length === 0) return { extensions: [], failures: [] };
|
||||
|
||||
const catalogue = await fetchRepoCatalogue(repos);
|
||||
const installedPkgs = new Set(extensions.map((extension) => extension.fallbackName));
|
||||
return {
|
||||
extensions: catalogue.extensions.map((extension) => ({
|
||||
pkg: extension.pkg,
|
||||
name: extension.name,
|
||||
lang: extension.lang,
|
||||
version: extension.version,
|
||||
nsfw: extension.nsfw,
|
||||
repoUrl: extension.repoUrl,
|
||||
sourceNames: extension.sourceNames,
|
||||
installed: installedPkgs.has(extension.pkg),
|
||||
})),
|
||||
failures: catalogue.failures,
|
||||
};
|
||||
},
|
||||
|
||||
/** Download an extension by package name, then rescan. */
|
||||
async installExtension(pkg: string): Promise<void> {
|
||||
const repos = deps.repos();
|
||||
if (repos.length === 0) throw new Error('No extension repository is configured.');
|
||||
|
||||
const catalogue = await fetchRepoCatalogue(repos);
|
||||
const match = catalogue.extensions.find((candidate) => candidate.pkg === pkg);
|
||||
if (!match) throw new Error(`${pkg} is not offered by any configured repository.`);
|
||||
|
||||
await installExtensionFrom(match);
|
||||
},
|
||||
|
||||
/** Remove an installed extension, then rescan. */
|
||||
async removeExtension(pkg: string): Promise<void> {
|
||||
await removeExtensionFile(deps.extensionsDir(), pkg);
|
||||
await preferenceStore.clear(pkg).catch(() => undefined);
|
||||
if (sidecar) await scanExtensions(sidecar);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a repository index URL. Rejected unless it is an https index URL, so
|
||||
* a typo surfaces immediately instead of failing later at fetch time.
|
||||
*/
|
||||
addRepo(url: string): void {
|
||||
const trimmed = url.trim();
|
||||
if (!isValidRepoUrl(trimmed)) {
|
||||
throw new Error('A repository URL must be https and point at a .json index file.');
|
||||
}
|
||||
const repos = deps.repos();
|
||||
if (repos.includes(trimmed)) return;
|
||||
deps.setRepos([...repos, trimmed]);
|
||||
},
|
||||
|
||||
removeRepo(url: string): void {
|
||||
deps.setRepos(deps.repos().filter((candidate) => candidate !== url));
|
||||
},
|
||||
|
||||
/** Re-read the extensions directory without restarting the bridge. */
|
||||
async rescanExtensions(): Promise<void> {
|
||||
if (sidecar) await scanExtensions(sidecar);
|
||||
},
|
||||
|
||||
selectSource(sourceId: string): void {
|
||||
if (sourceId === ALL_SOURCES_ID && sources.length > 0) {
|
||||
selectedSourceId = ALL_SOURCES_ID;
|
||||
return;
|
||||
}
|
||||
if (sources.some((source) => source.id === sourceId)) selectedSourceId = sourceId;
|
||||
},
|
||||
|
||||
/**
|
||||
* The extension's settings schema, merged with anything saved locally. The
|
||||
* extension is the source of truth for structure; saved values only supply
|
||||
* what it has no memory of between requests.
|
||||
*/
|
||||
async getPreferences(sourceId: string): Promise<SourcePreferenceView[]> {
|
||||
const { client } = requireBridge();
|
||||
const source = requireSource(sourceId);
|
||||
const schema = await client.getSourcePreferences(await sourceFor(source.id));
|
||||
return parsePreferences(schema);
|
||||
},
|
||||
|
||||
/**
|
||||
* Persist one preference and hand the whole array back to the extension so
|
||||
* it can react (Jellyfin logs in and populates its library list here).
|
||||
*/
|
||||
async setPreference(
|
||||
sourceId: string,
|
||||
key: string,
|
||||
value: string | string[] | boolean,
|
||||
): Promise<SourcePreferenceView[]> {
|
||||
const { client } = requireBridge();
|
||||
const source = await sourceFor(sourceId);
|
||||
|
||||
// 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 updated = applyPreferenceValue(current, key, value);
|
||||
await preferenceStore.set(sourceId, updated);
|
||||
|
||||
const refreshed = await client.setSourcePreference({ ...source, preferences: updated }, key);
|
||||
if (refreshed.length > 0) await preferenceStore.set(sourceId, refreshed);
|
||||
return parsePreferences(refreshed.length > 0 ? refreshed : updated);
|
||||
},
|
||||
|
||||
async search(query: string, page = 1): Promise<AnimeBrowserSearchResult> {
|
||||
const { client } = requireBridge();
|
||||
return browse(page, (source, requestedPage) =>
|
||||
client.searchAnime(source, query, requestedPage),
|
||||
);
|
||||
},
|
||||
|
||||
async getPopular(page = 1): Promise<AnimeBrowserSearchResult> {
|
||||
const { client } = requireBridge();
|
||||
return browse(page, (source, requestedPage) => client.getPopularAnime(source, requestedPage));
|
||||
},
|
||||
|
||||
async getDetails(animeUrl: string, sourceId?: string): Promise<AnimeBrowserDetails> {
|
||||
const { client, baseUrl } = requireBridge();
|
||||
const source = requireSource(sourceId ?? selectedSourceId);
|
||||
const details = await client.getAnimeDetails(await sourceFor(source.id), animeUrl);
|
||||
return {
|
||||
...toEntry(baseUrl, { ...details, url: details.url ?? animeUrl }, source),
|
||||
description: details.description ?? null,
|
||||
author: details.author ?? null,
|
||||
genres: details.genres ?? [],
|
||||
status: parseAnimeStatus(details.status),
|
||||
};
|
||||
},
|
||||
|
||||
async getEpisodes(animeUrl: string, sourceId?: string): Promise<AnimeBrowserEpisode[]> {
|
||||
const { client } = requireBridge();
|
||||
const source = requireSource(sourceId ?? selectedSourceId);
|
||||
const episodes = await client.getEpisodeList(await sourceFor(source.id), animeUrl);
|
||||
return episodes.map((episode) => ({
|
||||
url: episode.url ?? '',
|
||||
name: episode.name ?? 'Episode',
|
||||
number: typeof episode.episode_number === 'number' ? episode.episode_number : null,
|
||||
uploadedAt:
|
||||
typeof episode.date_upload === 'number' && episode.date_upload > 0
|
||||
? episode.date_upload
|
||||
: null,
|
||||
scanlator: episode.scanlator ?? null,
|
||||
}));
|
||||
},
|
||||
|
||||
async playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> {
|
||||
try {
|
||||
const { client, baseUrl } = requireBridge();
|
||||
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 stream = selectPreferredStream(streams, deps.preferredQuality?.());
|
||||
if (!stream) {
|
||||
return { ok: false, error: 'That source returned no playable video.', quality: null };
|
||||
}
|
||||
|
||||
if (!(await deps.ensureMpvConnected())) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'mpv is not running and could not be started.',
|
||||
quality: null,
|
||||
};
|
||||
}
|
||||
|
||||
const title = `${request.animeTitle} — ${request.episodeName}`;
|
||||
for (const command of buildPlaybackCommands({ stream, title })) {
|
||||
deps.sendMpvCommand(command);
|
||||
}
|
||||
|
||||
const trackCommands = buildTrackCommands(stream);
|
||||
if (trackCommands.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.
|
||||
await wait(TRACK_ATTACH_DELAY_MS);
|
||||
for (const command of trackCommands) {
|
||||
deps.sendMpvCommand(command);
|
||||
}
|
||||
}
|
||||
|
||||
deps.showVisibleOverlay?.();
|
||||
deps.showMpvOsd?.(title);
|
||||
return { ok: true, error: null, quality: stream.quality || null };
|
||||
} catch (error) {
|
||||
deps.log(`[anime-browser] playback failed: ${String(error)}`);
|
||||
return { ok: false, error: describeError(error), quality: null };
|
||||
}
|
||||
},
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
const handle = sidecar;
|
||||
sidecar = null;
|
||||
starting = null;
|
||||
setState(IDLE_STATE);
|
||||
await handle?.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type AnimeBrowserRuntime = ReturnType<typeof createAnimeBrowserRuntime>;
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -75,6 +75,7 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('config-settings'),
|
||||
openSyncUiWindow: () => {},
|
||||
openAnimeBrowserWindow: () => {},
|
||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
printHelp: () => calls.push('help'),
|
||||
|
||||
@@ -49,6 +49,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openAnimeBrowserWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -109,6 +110,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
openAnimeBrowserWindow: deps.openAnimeBrowserWindow,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
printHelp: deps.printHelp,
|
||||
|
||||
@@ -77,6 +77,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
openAnimeBrowserWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
@@ -106,6 +106,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('open-config-settings'),
|
||||
openSyncUiWindow: () => {},
|
||||
openAnimeBrowserWindow: () => {},
|
||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
|
||||
printHelp: () => calls.push('help'),
|
||||
|
||||
@@ -66,6 +66,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openAnimeBrowserWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -146,6 +147,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
|
||||
openSyncUiWindow: () => deps.openSyncUiWindow(),
|
||||
openAnimeBrowserWindow: () => deps.openAnimeBrowserWindow(),
|
||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
printHelp: () => deps.printHelp(),
|
||||
|
||||
@@ -59,6 +59,7 @@ function createDeps() {
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
openAnimeBrowserWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
@@ -54,6 +54,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openAnimeBrowserWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -136,6 +137,7 @@ export function createCliCommandContext(
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
openAnimeBrowserWindow: deps.openAnimeBrowserWindow,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
printHelp: deps.printHelp,
|
||||
|
||||
@@ -53,6 +53,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
openAnimeBrowserWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
@@ -130,6 +130,9 @@ export type JellyfinRuntimeComposerOptions = ComposerInputs<{
|
||||
export type JellyfinRuntimeComposerResult = ComposerOutputs<{
|
||||
getResolvedJellyfinConfig: ReturnType<typeof createGetResolvedJellyfinConfigHandler>;
|
||||
getJellyfinClientInfo: ReturnType<typeof createGetJellyfinClientInfoHandler>;
|
||||
ensureMpvConnectedForPlayback: ReturnType<
|
||||
typeof createEnsureMpvConnectedForJellyfinPlaybackHandler
|
||||
>;
|
||||
reportJellyfinRemoteProgress: ReturnType<
|
||||
typeof composeJellyfinRemoteHandlers
|
||||
>['reportJellyfinRemoteProgress'];
|
||||
@@ -297,6 +300,9 @@ export function composeJellyfinRuntimeHandlers(
|
||||
return {
|
||||
getResolvedJellyfinConfig,
|
||||
getJellyfinClientInfo,
|
||||
// Shared so other playback sources (the anime browser) reuse the same
|
||||
// auto-launch in-flight guard instead of racing a second mpv launch.
|
||||
ensureMpvConnectedForPlayback: ensureMpvConnectedForJellyfinPlayback,
|
||||
reportJellyfinRemoteProgress,
|
||||
reportJellyfinRemoteStopped,
|
||||
handleJellyfinRemotePlay,
|
||||
|
||||
@@ -32,6 +32,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
animeBrowser: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
|
||||
@@ -99,3 +99,19 @@ export function createCreateSyncUiWindowHandler<TWindow>(deps: {
|
||||
backgroundColor: '#24273a',
|
||||
});
|
||||
}
|
||||
|
||||
export function createCreateAnimeBrowserWindowHandler<TWindow>(deps: {
|
||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
|
||||
preloadPath: string;
|
||||
}) {
|
||||
return createSetupWindowHandler(deps, {
|
||||
// Wider than the other surfaces: this one is a cover-art grid.
|
||||
width: 1180,
|
||||
height: 820,
|
||||
title: 'SubMiner Anime',
|
||||
show: false,
|
||||
resizable: true,
|
||||
preloadPath: deps.preloadPath,
|
||||
backgroundColor: '#24273a',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openSyncUiWindow: () => calls.push('sync-ui'),
|
||||
openAnimeBrowserWindow: () => calls.push('anime-browser'),
|
||||
exportLogs: () => calls.push('export-logs'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => true,
|
||||
@@ -130,6 +131,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openSyncUiWindow: () => calls.push('configuration'),
|
||||
openAnimeBrowserWindow: () => calls.push('anime-browser'),
|
||||
exportLogs: () => calls.push('export-logs'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => false,
|
||||
|
||||
@@ -49,6 +49,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
openAnimeBrowser: () => void;
|
||||
exportLogs: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
@@ -70,6 +71,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openAnimeBrowserWindow: () => void;
|
||||
exportLogs: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
isJellyfinConfigured: () => boolean;
|
||||
@@ -113,6 +115,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openConfigSettings: () => {
|
||||
deps.openConfigSettingsWindow();
|
||||
},
|
||||
openAnimeBrowser: () => {
|
||||
deps.openAnimeBrowserWindow();
|
||||
},
|
||||
openSyncUi: () => {
|
||||
deps.openSyncUiWindow();
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openSyncUiWindow: () => calls.push('sync-ui'),
|
||||
openAnimeBrowserWindow: () => {},
|
||||
exportLogs: () => calls.push('export-logs'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => true,
|
||||
@@ -61,6 +62,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openConfigSettings: () => calls.push('open-configuration'),
|
||||
openSyncUi: () => {},
|
||||
openAnimeBrowser: () => {},
|
||||
exportLogs: () => calls.push('open-export-logs'),
|
||||
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
|
||||
@@ -39,6 +39,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
openAnimeBrowser: () => void;
|
||||
exportLogs: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
@@ -60,6 +61,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openAnimeBrowserWindow: () => void;
|
||||
exportLogs: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
isJellyfinConfigured: () => boolean;
|
||||
@@ -85,6 +87,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
openAnimeBrowserWindow: deps.openAnimeBrowserWindow,
|
||||
exportLogs: deps.exportLogs,
|
||||
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
|
||||
isJellyfinConfigured: deps.isJellyfinConfigured,
|
||||
|
||||
@@ -34,6 +34,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
openAnimeBrowserWindow: () => {},
|
||||
exportLogs: () => {},
|
||||
openJellyfinSetupWindow: () => {},
|
||||
isJellyfinConfigured: () => false,
|
||||
|
||||
@@ -40,6 +40,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettings: () => calls.push('configuration'),
|
||||
openSyncUi: () => calls.push('sync-ui'),
|
||||
openAnimeBrowser: () => calls.push('anime-browser'),
|
||||
exportLogs: () => calls.push('export-logs'),
|
||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
@@ -69,6 +70,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
'Open Yomitan Settings',
|
||||
'Open SubMiner Settings',
|
||||
'Sync Stats && History',
|
||||
'Browse Anime',
|
||||
'Export Logs',
|
||||
'Configure Jellyfin',
|
||||
'Jellyfin Discovery',
|
||||
@@ -88,6 +90,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
entryFor('View Changelog').click?.();
|
||||
entryFor('Open Texthooker').click?.();
|
||||
entryFor('Sync Stats && History').click?.();
|
||||
entryFor('Browse Anime').click?.();
|
||||
entryFor('Export Logs').click?.();
|
||||
entryFor('Check for Updates').click?.();
|
||||
calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad');
|
||||
@@ -99,6 +102,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
'changelog',
|
||||
'texthooker',
|
||||
'sync-ui',
|
||||
'anime-browser',
|
||||
'export-logs',
|
||||
'updates',
|
||||
'separator',
|
||||
@@ -119,6 +123,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
openAnimeBrowser: () => undefined,
|
||||
exportLogs: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: false,
|
||||
@@ -149,6 +154,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
openAnimeBrowser: () => undefined,
|
||||
exportLogs: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: false,
|
||||
@@ -177,6 +183,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
openAnimeBrowser: () => undefined,
|
||||
exportLogs: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: true,
|
||||
@@ -206,6 +213,7 @@ test('tray menu template renders a visible linux discovery check mark when activ
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
openAnimeBrowser: () => undefined,
|
||||
exportLogs: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: true,
|
||||
|
||||
@@ -42,6 +42,7 @@ export type TrayMenuActionHandlers = {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
openAnimeBrowser: () => void;
|
||||
exportLogs: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
@@ -113,6 +114,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
label: 'Sync Stats && History',
|
||||
click: handlers.openSyncUi,
|
||||
},
|
||||
{
|
||||
label: 'Browse Anime',
|
||||
click: handlers.openAnimeBrowser,
|
||||
},
|
||||
{
|
||||
label: 'Export Logs',
|
||||
click: handlers.exportLogs,
|
||||
|
||||
@@ -153,6 +153,7 @@ export interface AppState {
|
||||
firstRunSetupWindow: BrowserWindow | null;
|
||||
configSettingsWindow: BrowserWindow | null;
|
||||
syncUiWindow: BrowserWindow | null;
|
||||
animeBrowserWindow: BrowserWindow | null;
|
||||
yomitanParserReadyPromise: Promise<void> | null;
|
||||
yomitanParserInitPromise: Promise<boolean> | null;
|
||||
mpvClient: MpvIpcClient | null;
|
||||
@@ -240,6 +241,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
||||
firstRunSetupWindow: null,
|
||||
configSettingsWindow: null,
|
||||
syncUiWindow: null,
|
||||
animeBrowserWindow: null,
|
||||
yomitanParserReadyPromise: null,
|
||||
yomitanParserInitPromise: null,
|
||||
mpvClient: null,
|
||||
|
||||
Reference in New Issue
Block a user