mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 13:55:51 -07:00
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import * as fs from 'fs';
|
|
import { AnkiIntegration } from '../../anki-integration';
|
|
import { mergeAiConfig } from '../../ai/config';
|
|
import {
|
|
AiConfig,
|
|
TsukihimeApiResponse,
|
|
TsukihimeConfig,
|
|
AnkiConnectConfig,
|
|
JimakuApiResponse,
|
|
JimakuEntry,
|
|
JimakuFileEntry,
|
|
JimakuLanguagePreference,
|
|
JimakuMediaInfo,
|
|
KikuFieldGroupingChoice,
|
|
KikuFieldGroupingRequestData,
|
|
OverlayNotificationPayload,
|
|
} from '../../types';
|
|
import { sortJimakuFiles } from '../../jimaku/utils';
|
|
import {
|
|
TSUKIHIME_API_BASE_URL,
|
|
tsukihimeFetchJson as tsukihimeFetchJsonRequest,
|
|
decompressXzFile,
|
|
extractTsukihimeSubtitleFiles,
|
|
isTsukihimeDownloadUrl,
|
|
mapTsukihimeSearchResults,
|
|
} from '../../tsukihime/utils';
|
|
import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc';
|
|
import { createLogger } from '../../logger';
|
|
|
|
export type RegisterAnkiJimakuIpcRuntimeHandler = (deps: AnkiJimakuIpcDeps) => void;
|
|
|
|
interface MpvClientLike {
|
|
connected: boolean;
|
|
send: (payload: { command: (string | number)[] }) => void;
|
|
request?: (command: unknown[]) => Promise<{ data?: unknown }>;
|
|
}
|
|
|
|
interface RuntimeOptionsManagerLike {
|
|
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
|
}
|
|
|
|
interface SubtitleTimingTrackerLike {
|
|
cleanup: () => void;
|
|
}
|
|
|
|
export interface AnkiJimakuIpcRuntimeOptions {
|
|
patchAnkiConnectEnabled: (enabled: boolean) => void;
|
|
getResolvedConfig: () => {
|
|
ankiConnect?: AnkiConnectConfig;
|
|
ai?: AiConfig;
|
|
tsukihime?: TsukihimeConfig;
|
|
secondarySub?: { secondarySubLanguages?: string[] };
|
|
};
|
|
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
|
|
getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null;
|
|
getMpvClient: () => MpvClientLike | null;
|
|
getAnkiIntegration: () => AnkiIntegration | null;
|
|
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
|
|
getKnownWordCacheStatePath: () => string;
|
|
getCachedMediaPath?: (
|
|
currentVideoPath: string,
|
|
kind: 'audio' | 'video',
|
|
) => Promise<string | null>;
|
|
shouldRequireRemoteMediaCache?: () => boolean;
|
|
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
|
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
|
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
|
createFieldGroupingCallback: () => (
|
|
data: KikuFieldGroupingRequestData,
|
|
) => Promise<KikuFieldGroupingChoice>;
|
|
broadcastRuntimeOptionsChanged: () => void;
|
|
getFieldGroupingResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
|
setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
|
parseMediaInfo: (mediaPath: string | null) => JimakuMediaInfo;
|
|
getCurrentMediaPath: () => string | null;
|
|
jimakuFetchJson: <T>(
|
|
endpoint: string,
|
|
query?: Record<string, string | number | boolean | null | undefined>,
|
|
) => Promise<JimakuApiResponse<T>>;
|
|
tsukihimeFetchJson?: <T>(
|
|
endpoint: string,
|
|
query?: Record<string, string | number | boolean | null | undefined>,
|
|
) => Promise<TsukihimeApiResponse<T>>;
|
|
getJimakuMaxEntryResults: () => number;
|
|
getJimakuLanguagePreference: () => JimakuLanguagePreference;
|
|
resolveJimakuApiKey: () => Promise<string | null>;
|
|
isRemoteMediaPath: (mediaPath: string) => boolean;
|
|
downloadToFile: (
|
|
url: string,
|
|
destPath: string,
|
|
headers: Record<string, string>,
|
|
downloadOptions?: { isAllowedRedirect?: (url: URL) => boolean },
|
|
) => Promise<
|
|
| { ok: true; path: string }
|
|
| {
|
|
ok: false;
|
|
error: { error: string; code?: number; retryAfter?: number };
|
|
}
|
|
>;
|
|
}
|
|
|
|
const logger = createLogger('main:anki-jimaku');
|
|
|
|
const DEFAULT_TSUKIHIME_MAX_SEARCH_RESULTS = 10;
|
|
const SECONDARY_TRACK_LOOKUP_ATTEMPTS = 5;
|
|
const SECONDARY_TRACK_LOOKUP_RETRY_MS = 100;
|
|
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function getTsukihimeMaxSearchResults(options: AnkiJimakuIpcRuntimeOptions): number {
|
|
const value = options.getResolvedConfig().tsukihime?.maxSearchResults;
|
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
return Math.floor(value);
|
|
}
|
|
return DEFAULT_TSUKIHIME_MAX_SEARCH_RESULTS;
|
|
}
|
|
|
|
function tsukihimeFetch<T>(
|
|
options: AnkiJimakuIpcRuntimeOptions,
|
|
endpoint: string,
|
|
query: Record<string, string | number | boolean | null | undefined>,
|
|
): Promise<TsukihimeApiResponse<T>> {
|
|
if (options.tsukihimeFetchJson) {
|
|
return options.tsukihimeFetchJson<T>(endpoint, query);
|
|
}
|
|
const baseUrl = options.getResolvedConfig().tsukihime?.apiBaseUrl || TSUKIHIME_API_BASE_URL;
|
|
return tsukihimeFetchJsonRequest<T>(endpoint, query, { baseUrl });
|
|
}
|
|
|
|
export function registerAnkiJimakuIpcRuntime(
|
|
options: AnkiJimakuIpcRuntimeOptions,
|
|
registerHandlers: RegisterAnkiJimakuIpcRuntimeHandler,
|
|
): void {
|
|
registerHandlers({
|
|
setAnkiConnectEnabled: (enabled) => {
|
|
options.patchAnkiConnectEnabled(enabled);
|
|
const config = options.getResolvedConfig();
|
|
const subtitleTimingTracker = options.getSubtitleTimingTracker();
|
|
const mpvClient = options.getMpvClient();
|
|
const ankiIntegration = options.getAnkiIntegration();
|
|
|
|
if (enabled && !ankiIntegration && subtitleTimingTracker && mpvClient) {
|
|
const runtimeOptionsManager = options.getRuntimeOptionsManager();
|
|
const effectiveAnkiConfig = runtimeOptionsManager
|
|
? runtimeOptionsManager.getEffectiveAnkiConnectConfig(config.ankiConnect)
|
|
: config.ankiConnect;
|
|
const integration = new AnkiIntegration(
|
|
effectiveAnkiConfig as never,
|
|
subtitleTimingTracker as never,
|
|
mpvClient as never,
|
|
(text: string) => {
|
|
if (mpvClient) {
|
|
mpvClient.send({
|
|
command: ['show-text', text, '3000'],
|
|
});
|
|
}
|
|
},
|
|
options.showDesktopNotification,
|
|
options.createFieldGroupingCallback(),
|
|
options.getKnownWordCacheStatePath(),
|
|
mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig,
|
|
undefined,
|
|
options.showOverlayNotification,
|
|
options.getCachedMediaPath,
|
|
options.shouldRequireRemoteMediaCache,
|
|
options.getYoutubeMediaSourceUrl,
|
|
);
|
|
integration.start();
|
|
options.setAnkiIntegration(integration);
|
|
logger.info('AnkiConnect integration enabled');
|
|
} else if (!enabled && ankiIntegration) {
|
|
ankiIntegration.destroy();
|
|
options.setAnkiIntegration(null);
|
|
logger.info('AnkiConnect integration disabled');
|
|
}
|
|
|
|
options.broadcastRuntimeOptionsChanged();
|
|
},
|
|
clearAnkiHistory: () => {
|
|
const subtitleTimingTracker = options.getSubtitleTimingTracker();
|
|
if (subtitleTimingTracker) {
|
|
subtitleTimingTracker.cleanup();
|
|
logger.info('AnkiConnect subtitle timing history cleared');
|
|
}
|
|
},
|
|
refreshKnownWords: async () => {
|
|
const integration = options.getAnkiIntegration();
|
|
if (!integration) {
|
|
throw new Error('AnkiConnect integration not enabled');
|
|
}
|
|
await integration.refreshKnownWordCache();
|
|
},
|
|
respondFieldGrouping: (choice) => {
|
|
const resolver = options.getFieldGroupingResolver();
|
|
if (resolver) {
|
|
resolver(choice);
|
|
options.setFieldGroupingResolver(null);
|
|
}
|
|
},
|
|
buildKikuMergePreview: async (request) => {
|
|
const integration = options.getAnkiIntegration();
|
|
if (!integration) {
|
|
return { ok: false, error: 'AnkiConnect integration not enabled' };
|
|
}
|
|
return integration.buildFieldGroupingPreview(
|
|
request.keepNoteId,
|
|
request.deleteNoteId,
|
|
request.deleteDuplicate,
|
|
);
|
|
},
|
|
getJimakuMediaInfo: () => options.parseMediaInfo(options.getCurrentMediaPath()),
|
|
searchJimakuEntries: async (query) => {
|
|
logger.info(`[jimaku] search-entries query: "${query.query}"`);
|
|
const response = await options.jimakuFetchJson<JimakuEntry[]>('/api/entries/search', {
|
|
anime: true,
|
|
query: query.query,
|
|
});
|
|
if (!response.ok) return response;
|
|
const maxResults = options.getJimakuMaxEntryResults();
|
|
logger.info(
|
|
`[jimaku] search-entries returned ${response.data.length} results (capped to ${maxResults})`,
|
|
);
|
|
return { ok: true, data: response.data.slice(0, maxResults) };
|
|
},
|
|
listJimakuFiles: async (query) => {
|
|
logger.info(`[jimaku] list-files entryId=${query.entryId} episode=${query.episode ?? 'all'}`);
|
|
const response = await options.jimakuFetchJson<JimakuFileEntry[]>(
|
|
`/api/entries/${query.entryId}/files`,
|
|
{
|
|
episode: query.episode ?? undefined,
|
|
},
|
|
);
|
|
if (!response.ok) return response;
|
|
const sorted = sortJimakuFiles(response.data, options.getJimakuLanguagePreference());
|
|
logger.info(`[jimaku] list-files returned ${sorted.length} files`);
|
|
return { ok: true, data: sorted };
|
|
},
|
|
resolveJimakuApiKey: () => options.resolveJimakuApiKey(),
|
|
getCurrentMediaPath: () => options.getCurrentMediaPath(),
|
|
isRemoteMediaPath: (mediaPath) => options.isRemoteMediaPath(mediaPath),
|
|
downloadToFile: (url, destPath, headers) => options.downloadToFile(url, destPath, headers),
|
|
|
|
searchTsukihimeEntries: async (query) => {
|
|
logger.info(`[tsukihime] search-entries query: "${query.query}"`);
|
|
const maxResults = getTsukihimeMaxSearchResults(options);
|
|
const response = await tsukihimeFetch<unknown>(options, '/search/torrents', {
|
|
q: query.query,
|
|
// The API caps limit at 100.
|
|
limit: Math.min(maxResults, 100),
|
|
});
|
|
if (!response.ok) return response;
|
|
const entries = mapTsukihimeSearchResults(response.data, maxResults);
|
|
logger.info(`[tsukihime] search-entries returned ${entries.length} results`);
|
|
return { ok: true, data: entries };
|
|
},
|
|
listTsukihimeFiles: async (query) => {
|
|
logger.info(`[tsukihime] list-files entryId=${query.entryId}`);
|
|
const response = await tsukihimeFetch<unknown>(
|
|
options,
|
|
`/torrents/${encodeURIComponent(query.entryId)}`,
|
|
{},
|
|
);
|
|
if (!response.ok) return response;
|
|
const files = extractTsukihimeSubtitleFiles(response.data);
|
|
logger.info(`[tsukihime] list-files returned ${files.length} subtitle attachments`);
|
|
return { ok: true, data: files };
|
|
},
|
|
getTsukihimeSecondaryLanguages: () =>
|
|
options.getResolvedConfig().secondarySub?.secondarySubLanguages ?? [],
|
|
downloadTsukihimeSubtitle: async (url, destPath) => {
|
|
const tempXzPath = `${destPath}.xz`;
|
|
const downloaded = await options.downloadToFile(
|
|
url,
|
|
tempXzPath,
|
|
{ 'User-Agent': 'SubMiner' },
|
|
// The /tosho/ mirror 302s to storage.animetosho.org; keep the hop in-allowlist.
|
|
{ isAllowedRedirect: (redirectUrl) => isTsukihimeDownloadUrl(redirectUrl) },
|
|
);
|
|
if (!downloaded.ok) return downloaded;
|
|
const result = await decompressXzFile(tempXzPath, destPath);
|
|
fs.promises.unlink(tempXzPath).catch(() => {});
|
|
return result;
|
|
},
|
|
onDownloadedSubtitle: (pathToSubtitle) => {
|
|
const mpvClient = options.getMpvClient();
|
|
if (mpvClient && mpvClient.connected) {
|
|
mpvClient.send({ command: ['sub-add', pathToSubtitle, 'select'] });
|
|
}
|
|
},
|
|
onDownloadedSecondarySubtitle: async (pathToSubtitle) => {
|
|
const mpvClient = options.getMpvClient();
|
|
if (!mpvClient || !mpvClient.connected) return;
|
|
mpvClient.send({ command: ['sub-add', pathToSubtitle, 'auto'] });
|
|
const request = mpvClient.request;
|
|
if (!request) return;
|
|
|
|
// sub-add is queued, so the track may not appear in the first track-list
|
|
// reply; poll briefly before giving up.
|
|
for (let attempt = 0; attempt < SECONDARY_TRACK_LOOKUP_ATTEMPTS; attempt += 1) {
|
|
try {
|
|
const response = await request(['get_property', 'track-list']);
|
|
const tracks = Array.isArray(response?.data)
|
|
? (response.data as Array<Record<string, unknown>>)
|
|
: [];
|
|
const added = tracks.find(
|
|
(track) => track?.type === 'sub' && track['external-filename'] === pathToSubtitle,
|
|
);
|
|
if (added && typeof added.id === 'number') {
|
|
mpvClient.send({ command: ['set_property', 'secondary-sid', added.id] });
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
logger.warn('[tsukihime] failed to select downloaded subtitle as secondary:', error);
|
|
return;
|
|
}
|
|
await delay(SECONDARY_TRACK_LOOKUP_RETRY_MS);
|
|
}
|
|
|
|
logger.warn(
|
|
`[tsukihime] could not find downloaded subtitle in track-list: ${pathToSubtitle}`,
|
|
);
|
|
},
|
|
});
|
|
}
|