Files
SubMiner/src/core/services/anki-jimaku-ipc.ts
T

312 lines
11 KiB
TypeScript

import electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createLogger } from '../../logger';
import {
TsukihimeApiResponse,
TsukihimeDownloadResult,
TsukihimeEntry,
TsukihimeFilesQuery,
TsukihimeSearchQuery,
TsukihimeSubtitleFile,
JimakuApiResponse,
JimakuDownloadResult,
JimakuEntry,
JimakuFileEntry,
JimakuFilesQuery,
JimakuMediaInfo,
JimakuSearchQuery,
KikuFieldGroupingChoice,
KikuMergePreviewRequest,
KikuMergePreviewResponse,
} from '../../types';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import {
parseTsukihimeDownloadQuery,
parseTsukihimeFilesQuery,
parseTsukihimeSearchQuery,
parseJimakuDownloadQuery,
parseJimakuFilesQuery,
parseJimakuSearchQuery,
parseKikuFieldGroupingChoice,
parseKikuMergePreviewRequest,
} from '../../shared/ipc/validators';
import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path';
import { tsukihimeLangToFilenameSuffix, isTsukihimeDownloadUrl } from '../../tsukihime/utils';
const { ipcMain } = electron;
const logger = createLogger('main:anki-jimaku-ipc');
export interface AnkiJimakuIpcDeps {
setAnkiConnectEnabled: (enabled: boolean) => void;
clearAnkiHistory: () => void;
refreshKnownWords: () => Promise<void> | void;
respondFieldGrouping: (choice: KikuFieldGroupingChoice) => void;
buildKikuMergePreview: (request: KikuMergePreviewRequest) => Promise<KikuMergePreviewResponse>;
getJimakuMediaInfo: () => JimakuMediaInfo;
searchJimakuEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
listJimakuFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
resolveJimakuApiKey: () => Promise<string | null>;
getCurrentMediaPath: () => string | null;
isRemoteMediaPath: (mediaPath: string) => boolean;
downloadToFile: (
url: string,
destPath: string,
headers: Record<string, string>,
) => Promise<JimakuDownloadResult>;
onDownloadedSubtitle: (pathToSubtitle: string) => void;
searchTsukihimeEntries: (
query: TsukihimeSearchQuery,
) => Promise<TsukihimeApiResponse<TsukihimeEntry[]>>;
listTsukihimeFiles: (
query: TsukihimeFilesQuery,
) => Promise<TsukihimeApiResponse<TsukihimeSubtitleFile[]>>;
downloadTsukihimeSubtitle: (url: string, destPath: string) => Promise<TsukihimeDownloadResult>;
getTsukihimeSecondaryLanguages: () => string[];
onDownloadedSecondarySubtitle: (pathToSubtitle: string) => void | Promise<void>;
}
interface IpcMainRegistrar {
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void;
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void;
}
export function registerAnkiJimakuIpcHandlers(
deps: AnkiJimakuIpcDeps,
ipc: IpcMainRegistrar = ipcMain,
): void {
ipc.on(IPC_CHANNELS.command.setAnkiConnectEnabled, (_event: unknown, enabled: unknown) => {
if (typeof enabled !== 'boolean') return;
deps.setAnkiConnectEnabled(enabled);
});
ipc.on(IPC_CHANNELS.command.clearAnkiConnectHistory, () => {
deps.clearAnkiHistory();
});
ipc.on(IPC_CHANNELS.command.refreshKnownWords, async () => {
await deps.refreshKnownWords();
});
ipc.on(IPC_CHANNELS.command.kikuFieldGroupingRespond, (_event: unknown, choice: unknown) => {
const parsedChoice = parseKikuFieldGroupingChoice(choice);
if (!parsedChoice) return;
deps.respondFieldGrouping(parsedChoice);
});
ipc.handle(
IPC_CHANNELS.request.kikuBuildMergePreview,
async (_event, request: unknown): Promise<KikuMergePreviewResponse> => {
const parsedRequest = parseKikuMergePreviewRequest(request);
if (!parsedRequest) {
return { ok: false, error: 'Invalid merge preview request payload' };
}
return deps.buildKikuMergePreview(parsedRequest);
},
);
ipc.handle(IPC_CHANNELS.request.jimakuGetMediaInfo, (): JimakuMediaInfo => {
return deps.getJimakuMediaInfo();
});
ipc.handle(
IPC_CHANNELS.request.jimakuSearchEntries,
async (_event, query: unknown): Promise<JimakuApiResponse<JimakuEntry[]>> => {
const parsedQuery = parseJimakuSearchQuery(query);
if (!parsedQuery) {
return { ok: false, error: { error: 'Invalid Jimaku search query payload', code: 400 } };
}
return deps.searchJimakuEntries(parsedQuery);
},
);
ipc.handle(
IPC_CHANNELS.request.jimakuListFiles,
async (_event, query: unknown): Promise<JimakuApiResponse<JimakuFileEntry[]>> => {
const parsedQuery = parseJimakuFilesQuery(query);
if (!parsedQuery) {
return { ok: false, error: { error: 'Invalid Jimaku files query payload', code: 400 } };
}
return deps.listJimakuFiles(parsedQuery);
},
);
ipc.handle(
IPC_CHANNELS.request.jimakuDownloadFile,
async (_event, query: unknown): Promise<JimakuDownloadResult> => {
const parsedQuery = parseJimakuDownloadQuery(query);
if (!parsedQuery) {
return {
ok: false,
error: {
error: 'Invalid Jimaku download query payload',
code: 400,
},
};
}
const apiKey = await deps.resolveJimakuApiKey();
if (!apiKey) {
return {
ok: false,
error: {
error: 'Jimaku API key not set. Configure jimaku.apiKey or jimaku.apiKeyCommand.',
code: 401,
},
};
}
const currentMediaPath = deps.getCurrentMediaPath();
if (!currentMediaPath) {
return { ok: false, error: { error: 'No media file loaded in MPV.' } };
}
const mediaDir = deps.isRemoteMediaPath(currentMediaPath)
? fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jimaku-'))
: path.dirname(path.resolve(currentMediaPath));
const safeName = path.basename(parsedQuery.name);
if (!safeName) {
return { ok: false, error: { error: 'Invalid subtitle filename.' } };
}
const subtitleFilename = buildJimakuSubtitleFilenameFromMediaPath(currentMediaPath, safeName);
const ext = path.extname(subtitleFilename);
const baseName = ext ? subtitleFilename.slice(0, -ext.length) : subtitleFilename;
let targetPath = path.join(mediaDir, subtitleFilename);
if (fs.existsSync(targetPath)) {
targetPath = path.join(mediaDir, `${baseName} (jimaku-${parsedQuery.entryId})${ext}`);
let counter = 2;
while (fs.existsSync(targetPath)) {
targetPath = path.join(
mediaDir,
`${baseName} (jimaku-${parsedQuery.entryId}-${counter})${ext}`,
);
counter += 1;
}
}
logger.info(
`[jimaku] download-file name="${parsedQuery.name}" entryId=${parsedQuery.entryId}`,
);
const result = await deps.downloadToFile(parsedQuery.url, targetPath, {
Authorization: apiKey,
'User-Agent': 'SubMiner',
});
if (result.ok) {
logger.info(`[jimaku] download-file saved to ${result.path}`);
deps.onDownloadedSubtitle(result.path);
} else {
logger.error(`[jimaku] download-file failed: ${result.error?.error ?? 'unknown error'}`);
}
return result;
},
);
ipc.handle(IPC_CHANNELS.request.tsukihimeGetSecondaryLanguages, (): string[] => {
return deps.getTsukihimeSecondaryLanguages();
});
ipc.handle(
IPC_CHANNELS.request.tsukihimeSearchEntries,
async (_event, query: unknown): Promise<TsukihimeApiResponse<TsukihimeEntry[]>> => {
const parsedQuery = parseTsukihimeSearchQuery(query);
if (!parsedQuery) {
return {
ok: false,
error: { error: 'Invalid TsukiHime search query payload', code: 400 },
};
}
return deps.searchTsukihimeEntries(parsedQuery);
},
);
ipc.handle(
IPC_CHANNELS.request.tsukihimeListFiles,
async (_event, query: unknown): Promise<TsukihimeApiResponse<TsukihimeSubtitleFile[]>> => {
const parsedQuery = parseTsukihimeFilesQuery(query);
if (!parsedQuery) {
return { ok: false, error: { error: 'Invalid TsukiHime files query payload', code: 400 } };
}
return deps.listTsukihimeFiles(parsedQuery);
},
);
ipc.handle(
IPC_CHANNELS.request.tsukihimeDownloadFile,
async (_event, query: unknown): Promise<TsukihimeDownloadResult> => {
const parsedQuery = parseTsukihimeDownloadQuery(query);
if (!parsedQuery) {
return {
ok: false,
error: { error: 'Invalid TsukiHime download query payload', code: 400 },
};
}
if (!isTsukihimeDownloadUrl(parsedQuery.url)) {
return {
ok: false,
error: { error: 'Refusing to download subtitle from a non-TsukiHime URL.', code: 400 },
};
}
const currentMediaPath = deps.getCurrentMediaPath();
if (!currentMediaPath) {
return { ok: false, error: { error: 'No media file loaded in MPV.' } };
}
const mediaDir = deps.isRemoteMediaPath(currentMediaPath)
? fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tsukihime-'))
: path.dirname(path.resolve(currentMediaPath));
const safeName = path.basename(parsedQuery.name);
if (!safeName) {
return { ok: false, error: { error: 'Invalid subtitle filename.' } };
}
const languageSuffix = tsukihimeLangToFilenameSuffix(parsedQuery.lang);
const subtitleFilename = buildJimakuSubtitleFilenameFromMediaPath(
currentMediaPath,
safeName,
languageSuffix,
);
const ext = path.extname(subtitleFilename);
const baseName = ext ? subtitleFilename.slice(0, -ext.length) : subtitleFilename;
let targetPath = path.join(mediaDir, subtitleFilename);
if (fs.existsSync(targetPath)) {
targetPath = path.join(mediaDir, `${baseName} (tsukihime-${parsedQuery.entryId})${ext}`);
let counter = 2;
while (fs.existsSync(targetPath)) {
targetPath = path.join(
mediaDir,
`${baseName} (tsukihime-${parsedQuery.entryId}-${counter})${ext}`,
);
counter += 1;
}
}
logger.info(
`[tsukihime] download-file name="${parsedQuery.name}" entryId=${parsedQuery.entryId}`,
);
const result = await deps.downloadTsukihimeSubtitle(parsedQuery.url, targetPath);
if (result.ok) {
logger.info(`[tsukihime] download-file saved to ${result.path}`);
// Japanese tracks take the primary slot; anything else loads as the
// secondary subtitle so the Japanese primary stays in place.
if (languageSuffix === 'ja') {
deps.onDownloadedSubtitle(result.path);
} else {
await deps.onDownloadedSecondarySubtitle(result.path);
}
} else {
logger.error(`[tsukihime] download-file failed: ${result.error?.error ?? 'unknown error'}`);
}
return result;
},
);
}