feat(animetosho): add English/Japanese subtitle download integration (#159)

This commit is contained in:
2026-07-12 00:48:12 -07:00
committed by GitHub
parent 6ab3d823a4
commit 4b7f750919
80 changed files with 2647 additions and 13 deletions
+113
View File
@@ -55,6 +55,11 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
isRemoteMediaPath: () => false,
downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
onDownloadedSubtitle: () => {},
searchAnimetoshoEntries: async () => ({ ok: true, data: [] }),
listAnimetoshoFiles: async () => ({ ok: true, data: [] }),
downloadAnimetoshoSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
getAnimetoshoSecondaryLanguages: () => ['en'],
onDownloadedSecondarySubtitle: () => {},
},
registrar,
);
@@ -92,6 +97,109 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
ok: false,
error: { error: 'Invalid Jimaku download query payload', code: 400 },
});
const animetoshoSearchHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoSearchEntries);
assert.ok(animetoshoSearchHandler);
const invalidAnimetoshoSearch = await animetoshoSearchHandler!({}, { query: 12 });
assert.deepEqual(invalidAnimetoshoSearch, {
ok: false,
error: { error: 'Invalid Animetosho search query payload', code: 400 },
});
const animetoshoFilesHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoListFiles);
assert.ok(animetoshoFilesHandler);
const invalidAnimetoshoFiles = await animetoshoFilesHandler!({}, { entryId: 'x' });
assert.deepEqual(invalidAnimetoshoFiles, {
ok: false,
error: { error: 'Invalid Animetosho files query payload', code: 400 },
});
const animetoshoDownloadHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoDownloadFile);
assert.ok(animetoshoDownloadHandler);
const invalidAnimetoshoDownload = await animetoshoDownloadHandler!({}, { entryId: 1, url: '/x' });
assert.deepEqual(invalidAnimetoshoDownload, {
ok: false,
error: { error: 'Invalid Animetosho download query payload', code: 400 },
});
const foreignUrlDownload = await animetoshoDownloadHandler!(
{},
{ entryId: 1, url: 'https://evil.example/attach/00000001/1.xz', name: 'sub.ass' },
);
assert.deepEqual(foreignUrlDownload, {
ok: false,
error: { error: 'Refusing to download subtitle from a non-Animetosho URL.', code: 400 },
});
});
test('animetosho downloads route by language: secondary for eng, primary for jpn', async () => {
const { registrar, handleHandlers } = createFakeRegistrar();
const primaryLoads: string[] = [];
const secondaryLoads: string[] = [];
registerAnkiJimakuIpcHandlers(
{
setAnkiConnectEnabled: () => {},
clearAnkiHistory: () => {},
refreshKnownWords: async () => {},
respondFieldGrouping: () => {},
buildKikuMergePreview: async () => ({ ok: true }),
getJimakuMediaInfo: () => ({
title: 'x',
season: null,
episode: null,
confidence: 'high',
filename: 'x.mkv',
rawTitle: 'x',
}),
searchJimakuEntries: async () => ({ ok: true, data: [] }),
listJimakuFiles: async () => ({ ok: true, data: [] }),
resolveJimakuApiKey: async () => 'token',
getCurrentMediaPath: () => '/tmp/a.mkv',
isRemoteMediaPath: () => false,
downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
onDownloadedSubtitle: (path) => {
primaryLoads.push(path);
},
searchAnimetoshoEntries: async () => ({ ok: true, data: [] }),
listAnimetoshoFiles: async () => ({ ok: true, data: [] }),
downloadAnimetoshoSubtitle: async (_url, destPath) => ({ ok: true, path: destPath }),
getAnimetoshoSecondaryLanguages: () => ['en'],
onDownloadedSecondarySubtitle: (path) => {
secondaryLoads.push(path);
},
},
registrar,
);
const downloadHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoDownloadFile)!;
const engResult = (await downloadHandler!(
{},
{
entryId: 1,
url: 'https://animetosho.org/storage/attach/00000001/1.xz',
name: 'episode.eng.ass',
lang: 'eng',
},
)) as { ok: boolean };
assert.equal(engResult.ok, true);
assert.equal(primaryLoads.length, 0);
assert.equal(secondaryLoads.length, 1);
assert.match(secondaryLoads[0]!, /\.en.*\.ass$/);
const jpnResult = (await downloadHandler!(
{},
{
entryId: 1,
url: 'https://animetosho.org/storage/attach/00000002/2.xz',
name: 'episode.jpn.ass',
lang: 'jpn',
},
)) as { ok: boolean };
assert.equal(jpnResult.ok, true);
assert.equal(secondaryLoads.length, 1);
assert.equal(primaryLoads.length, 1);
assert.match(primaryLoads[0]!, /\.ja.*\.ass$/);
});
test('anki/jimaku IPC command handlers ignore malformed payloads', () => {
@@ -124,6 +232,11 @@ test('anki/jimaku IPC command handlers ignore malformed payloads', () => {
isRemoteMediaPath: () => false,
downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
onDownloadedSubtitle: () => {},
searchAnimetoshoEntries: async () => ({ ok: true, data: [] }),
listAnimetoshoFiles: async () => ({ ok: true, data: [] }),
downloadAnimetoshoSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
getAnimetoshoSecondaryLanguages: () => ['en'],
onDownloadedSecondarySubtitle: () => {},
},
registrar,
);
+124
View File
@@ -4,6 +4,12 @@ import * as path from 'path';
import * as os from 'os';
import { createLogger } from '../../logger';
import {
AnimetoshoApiResponse,
AnimetoshoDownloadResult,
AnimetoshoEntry,
AnimetoshoFilesQuery,
AnimetoshoSearchQuery,
AnimetoshoSubtitleFile,
JimakuApiResponse,
JimakuDownloadResult,
JimakuEntry,
@@ -17,6 +23,9 @@ import {
} from '../../types';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import {
parseAnimetoshoDownloadQuery,
parseAnimetoshoFilesQuery,
parseAnimetoshoSearchQuery,
parseJimakuDownloadQuery,
parseJimakuFilesQuery,
parseJimakuSearchQuery,
@@ -24,6 +33,7 @@ import {
parseKikuMergePreviewRequest,
} from '../../shared/ipc/validators';
import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path';
import { animetoshoLangToFilenameSuffix, isAnimetoshoDownloadUrl } from '../../animetosho/utils';
const { ipcMain } = electron;
@@ -47,6 +57,15 @@ export interface AnkiJimakuIpcDeps {
headers: Record<string, string>,
) => Promise<JimakuDownloadResult>;
onDownloadedSubtitle: (pathToSubtitle: string) => void;
searchAnimetoshoEntries: (
query: AnimetoshoSearchQuery,
) => Promise<AnimetoshoApiResponse<AnimetoshoEntry[]>>;
listAnimetoshoFiles: (
query: AnimetoshoFilesQuery,
) => Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>>;
downloadAnimetoshoSubtitle: (url: string, destPath: string) => Promise<AnimetoshoDownloadResult>;
getAnimetoshoSecondaryLanguages: () => string[];
onDownloadedSecondarySubtitle: (pathToSubtitle: string) => void | Promise<void>;
}
interface IpcMainRegistrar {
@@ -186,4 +205,109 @@ export function registerAnkiJimakuIpcHandlers(
return result;
},
);
ipc.handle(IPC_CHANNELS.request.animetoshoGetSecondaryLanguages, (): string[] => {
return deps.getAnimetoshoSecondaryLanguages();
});
ipc.handle(
IPC_CHANNELS.request.animetoshoSearchEntries,
async (_event, query: unknown): Promise<AnimetoshoApiResponse<AnimetoshoEntry[]>> => {
const parsedQuery = parseAnimetoshoSearchQuery(query);
if (!parsedQuery) {
return {
ok: false,
error: { error: 'Invalid Animetosho search query payload', code: 400 },
};
}
return deps.searchAnimetoshoEntries(parsedQuery);
},
);
ipc.handle(
IPC_CHANNELS.request.animetoshoListFiles,
async (_event, query: unknown): Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>> => {
const parsedQuery = parseAnimetoshoFilesQuery(query);
if (!parsedQuery) {
return { ok: false, error: { error: 'Invalid Animetosho files query payload', code: 400 } };
}
return deps.listAnimetoshoFiles(parsedQuery);
},
);
ipc.handle(
IPC_CHANNELS.request.animetoshoDownloadFile,
async (_event, query: unknown): Promise<AnimetoshoDownloadResult> => {
const parsedQuery = parseAnimetoshoDownloadQuery(query);
if (!parsedQuery) {
return {
ok: false,
error: { error: 'Invalid Animetosho download query payload', code: 400 },
};
}
if (!isAnimetoshoDownloadUrl(parsedQuery.url)) {
return {
ok: false,
error: { error: 'Refusing to download subtitle from a non-Animetosho 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-animetosho-'))
: path.dirname(path.resolve(currentMediaPath));
const safeName = path.basename(parsedQuery.name);
if (!safeName) {
return { ok: false, error: { error: 'Invalid subtitle filename.' } };
}
const languageSuffix = animetoshoLangToFilenameSuffix(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} (animetosho-${parsedQuery.entryId})${ext}`);
let counter = 2;
while (fs.existsSync(targetPath)) {
targetPath = path.join(
mediaDir,
`${baseName} (animetosho-${parsedQuery.entryId}-${counter})${ext}`,
);
counter += 1;
}
}
logger.info(
`[animetosho] download-file name="${parsedQuery.name}" entryId=${parsedQuery.entryId}`,
);
const result = await deps.downloadAnimetoshoSubtitle(parsedQuery.url, targetPath);
if (result.ok) {
logger.info(`[animetosho] 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(
`[animetosho] download-file failed: ${result.error?.error ?? 'unknown error'}`,
);
}
return result;
},
);
}
+151 -3
View File
@@ -11,7 +11,8 @@ interface RuntimeHarness {
patches: boolean[];
broadcasts: number;
fetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
sentCommands: Array<{ command: string[] }>;
animetoshoFetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
sentCommands: Array<{ command: (string | number)[] }>;
};
}
@@ -25,21 +26,75 @@ function createHarness(): RuntimeHarness {
endpoint: string;
query?: Record<string, unknown>;
}>,
sentCommands: [] as Array<{ command: string[] }>,
animetoshoFetchCalls: [] as Array<{
endpoint: string;
query?: Record<string, unknown>;
}>,
sentCommands: [] as Array<{ command: (string | number)[] }>,
};
const options: AnkiJimakuIpcRuntimeOptions = {
patchAnkiConnectEnabled: (enabled) => {
state.patches.push(enabled);
},
getResolvedConfig: () => ({}),
getResolvedConfig: () => ({ animetosho: { maxSearchResults: 2 } }),
getRuntimeOptionsManager: () => null,
animetoshoFetchJson: async (endpoint, query) => {
state.animetoshoFetchCalls.push({
endpoint,
query: query as Record<string, unknown>,
});
if ((query as Record<string, unknown>)?.show === 'torrent') {
return {
ok: true,
data: {
files: [
{
id: 9,
filename: 'episode.mkv',
attachments: [
{
id: 1955356,
type: 'subtitle',
info: { codec: 'ASS', lang: 'eng', name: 'English subs' },
size: 33075,
},
],
},
],
} as never,
};
}
return {
ok: true,
data: [
{ id: 1, title: 'release a' },
{ id: 2, title: 'release b' },
{ id: 3, title: 'release c' },
] as never,
};
},
getSubtitleTimingTracker: () => null,
getMpvClient: () => ({
connected: true,
send: (payload) => {
state.sentCommands.push(payload);
},
request: async (command: unknown[]) => {
state.sentCommands.push({ command } as never);
return {
data: [
{ id: 1, type: 'sub', selected: true },
{
id: 3,
type: 'sub',
lang: 'en',
external: true,
'external-filename': '/tmp/video.en.ass',
},
],
};
},
}),
getAnkiIntegration: () => state.ankiIntegration as never,
setAnkiIntegration: (integration) => {
@@ -117,6 +172,10 @@ test('registerAnkiJimakuIpcRuntime provides full handler surface', () => {
'isRemoteMediaPath',
'downloadToFile',
'onDownloadedSubtitle',
'searchAnimetoshoEntries',
'listAnimetoshoFiles',
'downloadAnimetoshoSubtitle',
'onDownloadedSecondarySubtitle',
];
for (const key of expected) {
@@ -253,3 +312,92 @@ test('searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to
registered.onDownloadedSubtitle!('/tmp/subtitle.ass');
assert.deepEqual(state.sentCommands, [{ command: ['sub-add', '/tmp/subtitle.ass', 'select'] }]);
});
test('onDownloadedSecondarySubtitle loads without stealing the primary track', async () => {
const { registered, state } = createHarness();
await registered.onDownloadedSecondarySubtitle!('/tmp/video.en.ass');
assert.deepEqual(state.sentCommands[0], { command: ['sub-add', '/tmp/video.en.ass', 'auto'] });
assert.deepEqual(state.sentCommands[1], { command: ['get_property', 'track-list'] });
assert.deepEqual(state.sentCommands[2], { command: ['set_property', 'secondary-sid', 3] });
});
test('onDownloadedSecondarySubtitle retries until mpv reports the new track', async () => {
const state = {
sentCommands: [] as Array<{ command: (string | number)[] }>,
trackListCalls: 0,
};
const options = {
...createHarness().options,
getMpvClient: () => ({
connected: true,
send: (payload: { command: (string | number)[] }) => {
state.sentCommands.push(payload);
},
request: async () => {
state.trackListCalls += 1;
// mpv has not registered the external file yet on the first poll.
if (state.trackListCalls < 2) {
return { data: [{ id: 1, type: 'sub', selected: true }] };
}
return {
data: [
{ id: 1, type: 'sub', selected: true },
{ id: 4, type: 'sub', external: true, 'external-filename': '/tmp/video.en.ass' },
],
};
},
}),
} as unknown as AnkiJimakuIpcRuntimeOptions;
let registered: Record<string, (...args: unknown[]) => unknown> = {};
registerAnkiJimakuIpcRuntime(options, (deps) => {
registered = deps as unknown as Record<string, (...args: unknown[]) => unknown>;
});
await registered.onDownloadedSecondarySubtitle!('/tmp/video.en.ass');
assert.equal(state.trackListCalls, 2);
assert.deepEqual(state.sentCommands.at(-1), {
command: ['set_property', 'secondary-sid', 4],
});
});
test('searchAnimetoshoEntries caps results using animetosho.maxSearchResults', async () => {
const { registered, state } = createHarness();
const searchResult = await registered.searchAnimetoshoEntries!({ query: 'frieren 28' });
assert.deepEqual(state.animetoshoFetchCalls, [
{
endpoint: '/json',
query: { q: 'frieren 28', qx: 1 },
},
]);
assert.equal((searchResult as { ok: boolean }).ok, true);
const entries = (searchResult as { data: Array<{ id: number }> }).data;
assert.equal(entries.length, 2);
assert.deepEqual(
entries.map((entry) => entry.id),
[1, 2],
);
});
test('listAnimetoshoFiles extracts subtitle attachments from torrent detail', async () => {
const { registered, state } = createHarness();
const filesResult = await registered.listAnimetoshoFiles!({ entryId: 606713 });
assert.deepEqual(state.animetoshoFetchCalls, [
{
endpoint: '/json',
query: { show: 'torrent', id: 606713 },
},
]);
assert.equal((filesResult as { ok: boolean }).ok, true);
const files = (filesResult as { data: Array<Record<string, unknown>> }).data;
assert.equal(files.length, 1);
assert.equal(files[0]!.attachmentId, 1955356);
assert.equal(files[0]!.filename, 'episode.eng.ass');
assert.equal(files[0]!.url, 'https://animetosho.org/storage/attach/001dd61c/1955356.xz');
});
+125 -2
View File
@@ -1,7 +1,10 @@
import * as fs from 'fs';
import { AnkiIntegration } from '../../anki-integration';
import { mergeAiConfig } from '../../ai/config';
import {
AiConfig,
AnimetoshoApiResponse,
AnimetoshoConfig,
AnkiConnectConfig,
JimakuApiResponse,
JimakuEntry,
@@ -13,6 +16,14 @@ import {
OverlayNotificationPayload,
} from '../../types';
import { sortJimakuFiles } from '../../jimaku/utils';
import {
ANIMETOSHO_FEED_BASE_URL,
animetoshoFetchJson as animetoshoFetchJsonRequest,
decompressXzFile,
extractAnimetoshoSubtitleFiles,
isAnimetoshoDownloadUrl,
mapAnimetoshoSearchResults,
} from '../../animetosho/utils';
import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc';
import { createLogger } from '../../logger';
@@ -20,7 +31,8 @@ export type RegisterAnkiJimakuIpcRuntimeHandler = (deps: AnkiJimakuIpcDeps) => v
interface MpvClientLike {
connected: boolean;
send: (payload: { command: string[] }) => void;
send: (payload: { command: (string | number)[] }) => void;
request?: (command: unknown[]) => Promise<{ data?: unknown }>;
}
interface RuntimeOptionsManagerLike {
@@ -33,7 +45,12 @@ interface SubtitleTimingTrackerLike {
export interface AnkiJimakuIpcRuntimeOptions {
patchAnkiConnectEnabled: (enabled: boolean) => void;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig };
getResolvedConfig: () => {
ankiConnect?: AnkiConnectConfig;
ai?: AiConfig;
animetosho?: AnimetoshoConfig;
secondarySub?: { secondarySubLanguages?: string[] };
};
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null;
getMpvClient: () => MpvClientLike | null;
@@ -60,6 +77,10 @@ export interface AnkiJimakuIpcRuntimeOptions {
endpoint: string,
query?: Record<string, string | number | boolean | null | undefined>,
) => Promise<JimakuApiResponse<T>>;
animetoshoFetchJson?: <T>(
endpoint: string,
query?: Record<string, string | number | boolean | null | undefined>,
) => Promise<AnimetoshoApiResponse<T>>;
getJimakuMaxEntryResults: () => number;
getJimakuLanguagePreference: () => JimakuLanguagePreference;
resolveJimakuApiKey: () => Promise<string | null>;
@@ -68,6 +89,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
url: string,
destPath: string,
headers: Record<string, string>,
downloadOptions?: { isAllowedRedirect?: (url: URL) => boolean },
) => Promise<
| { ok: true; path: string }
| {
@@ -79,6 +101,34 @@ export interface AnkiJimakuIpcRuntimeOptions {
const logger = createLogger('main:anki-jimaku');
const DEFAULT_ANIMETOSHO_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 getAnimetoshoMaxSearchResults(options: AnkiJimakuIpcRuntimeOptions): number {
const value = options.getResolvedConfig().animetosho?.maxSearchResults;
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
return DEFAULT_ANIMETOSHO_MAX_SEARCH_RESULTS;
}
function animetoshoFetch<T>(
options: AnkiJimakuIpcRuntimeOptions,
endpoint: string,
query: Record<string, string | number | boolean | null | undefined>,
): Promise<AnimetoshoApiResponse<T>> {
if (options.animetoshoFetchJson) {
return options.animetoshoFetchJson<T>(endpoint, query);
}
const baseUrl = options.getResolvedConfig().animetosho?.apiBaseUrl || ANIMETOSHO_FEED_BASE_URL;
return animetoshoFetchJsonRequest<T>(endpoint, query, { baseUrl });
}
export function registerAnkiJimakuIpcRuntime(
options: AnkiJimakuIpcRuntimeOptions,
registerHandlers: RegisterAnkiJimakuIpcRuntimeHandler,
@@ -191,11 +241,84 @@ export function registerAnkiJimakuIpcRuntime(
getCurrentMediaPath: () => options.getCurrentMediaPath(),
isRemoteMediaPath: (mediaPath) => options.isRemoteMediaPath(mediaPath),
downloadToFile: (url, destPath, headers) => options.downloadToFile(url, destPath, headers),
searchAnimetoshoEntries: async (query) => {
logger.info(`[animetosho] search-entries query: "${query.query}"`);
const response = await animetoshoFetch<unknown>(options, '/json', {
q: query.query,
qx: 1,
});
if (!response.ok) return response;
const maxResults = getAnimetoshoMaxSearchResults(options);
const entries = mapAnimetoshoSearchResults(response.data, maxResults);
logger.info(`[animetosho] search-entries returned ${entries.length} results`);
return { ok: true, data: entries };
},
listAnimetoshoFiles: async (query) => {
logger.info(`[animetosho] list-files entryId=${query.entryId}`);
const response = await animetoshoFetch<unknown>(options, '/json', {
show: 'torrent',
id: query.entryId,
});
if (!response.ok) return response;
const files = extractAnimetoshoSubtitleFiles(response.data);
logger.info(`[animetosho] list-files returned ${files.length} subtitle attachments`);
return { ok: true, data: files };
},
getAnimetoshoSecondaryLanguages: () =>
options.getResolvedConfig().secondarySub?.secondarySubLanguages ?? [],
downloadAnimetoshoSubtitle: async (url, destPath) => {
const tempXzPath = `${destPath}.xz`;
const downloaded = await options.downloadToFile(
url,
tempXzPath,
{ 'User-Agent': 'SubMiner' },
// animetosho.org redirects to storage.animetosho.org; keep the hop in-domain.
{ isAllowedRedirect: (redirectUrl) => isAnimetoshoDownloadUrl(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('[animetosho] failed to select downloaded subtitle as secondary:', error);
return;
}
await delay(SECONDARY_TRACK_LOOKUP_RETRY_MS);
}
logger.warn(
`[animetosho] could not find downloaded subtitle in track-list: ${pathToSubtitle}`,
);
},
});
}
+1
View File
@@ -39,6 +39,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openAnimetosho: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
+1
View File
@@ -44,6 +44,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openAnimetosho: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
togglePrimarySubtitleBar: false,
+6
View File
@@ -539,6 +539,12 @@ export function handleCliCommand(
);
} else if (args.openJimaku) {
dispatchCliSessionAction({ actionId: 'openJimaku' }, 'openJimaku', 'Open jimaku failed');
} else if (args.openAnimetosho) {
dispatchCliSessionAction(
{ actionId: 'openAnimetosho' },
'openAnimetosho',
'Open animetosho failed',
);
} else if (args.openYoutubePicker) {
dispatchCliSessionAction(
{ actionId: 'openYoutubePicker' },
+4
View File
@@ -12,6 +12,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
SUBSYNC_TRIGGER: '__subsync-trigger',
RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
JIMAKU_OPEN: '__jimaku-open',
ANIMETOSHO_OPEN: '__animetosho-open',
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
@@ -27,6 +28,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
openJimaku: () => {
calls.push('jimaku');
},
openAnimetosho: () => {
calls.push('animetosho');
},
openYoutubeTrackPicker: () => {
calls.push('youtube-picker');
},
+7
View File
@@ -10,6 +10,7 @@ export interface HandleMpvCommandFromIpcOptions {
SUBSYNC_TRIGGER: string;
RUNTIME_OPTIONS_OPEN: string;
JIMAKU_OPEN: string;
ANIMETOSHO_OPEN: string;
RUNTIME_OPTION_CYCLE_PREFIX: string;
REPLAY_SUBTITLE: string;
PLAY_NEXT_SUBTITLE: string;
@@ -19,6 +20,7 @@ export interface HandleMpvCommandFromIpcOptions {
triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void;
openJimaku: () => void;
openAnimetosho: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => void | Promise<void>;
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
@@ -112,6 +114,11 @@ export function handleMpvCommandFromIpc(
return;
}
if (first === options.specialCommands.ANIMETOSHO_OPEN) {
options.openAnimetosho();
return;
}
if (first === options.specialCommands.YOUTUBE_PICKER_OPEN) {
void options.openYoutubeTrackPicker();
return;
@@ -28,6 +28,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openAnimetosho: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
@@ -54,6 +55,9 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
openJimaku: () => {
calls.push('openJimaku');
},
openAnimetosho: () => {
calls.push('openAnimetosho');
},
markAudioCard: async () => {
calls.push('markAudioCard');
},
@@ -164,6 +168,7 @@ test('runOverlayShortcutLocalFallback dispatches matching single-step actions',
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'),
openAnimetosho: () => handled.push('openAnimetosho'),
markAudioCard: () => handled.push('markAudioCard'),
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
copySubtitle: () => handled.push('copySubtitle'),
@@ -197,6 +202,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'),
openAnimetosho: () => handled.push('openAnimetosho'),
markAudioCard: () => handled.push('markAudioCard'),
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
copySubtitle: () => handled.push('copySubtitle'),
@@ -217,6 +223,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'),
openAnimetosho: () => handled.push('openAnimetosho'),
markAudioCard: () => handled.push('markAudioCard'),
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
copySubtitle: () => handled.push('copySubtitle'),
@@ -254,6 +261,7 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
openRuntimeOptions: () => {},
openCharacterDictionaryManager: () => {},
openJimaku: () => {},
openAnimetosho: () => {},
markAudioCard: () => {},
copySubtitleMultiple: () => {},
copySubtitle: () => {},
@@ -290,6 +298,7 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut',
openRuntimeOptions: () => {},
openCharacterDictionaryManager: () => {},
openJimaku: () => {},
openAnimetosho: () => {},
markAudioCard: () => {},
copySubtitleMultiple: () => {},
copySubtitle: () => {},
@@ -322,6 +331,9 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', ()
openJimaku: () => {
called = true;
},
openAnimetosho: () => {
called = true;
},
markAudioCard: () => {
called = true;
},
@@ -404,6 +416,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
openAnimetosho: () => {},
}),
cancelPendingMultiCopy: () => {},
cancelPendingMineSentenceMultiple: () => {},
@@ -431,6 +444,7 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
openAnimetosho: () => {},
}),
cancelPendingMultiCopy: () => {
calls.push('cancel-multi-copy');
@@ -8,6 +8,7 @@ export interface OverlayShortcutFallbackHandlers {
openRuntimeOptions: () => void;
openCharacterDictionaryManager: () => void;
openJimaku: () => void;
openAnimetosho: () => void;
markAudioCard: () => void;
copySubtitleMultiple: (timeoutMs: number) => void;
copySubtitle: () => void;
@@ -24,6 +25,7 @@ export interface OverlayShortcutRuntimeDeps {
openRuntimeOptions: () => void;
openCharacterDictionaryManager: () => void;
openJimaku: () => void;
openAnimetosho: () => void;
markAudioCard: () => Promise<void>;
copySubtitleMultiple: (timeoutMs: number) => void;
copySubtitle: () => void;
@@ -103,12 +105,16 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
openJimaku: () => {
deps.openJimaku();
},
openAnimetosho: () => {
deps.openAnimetosho();
},
};
const fallbackHandlers: OverlayShortcutFallbackHandlers = {
openRuntimeOptions: overlayHandlers.openRuntimeOptions,
openCharacterDictionaryManager: overlayHandlers.openCharacterDictionaryManager,
openJimaku: overlayHandlers.openJimaku,
openAnimetosho: overlayHandlers.openAnimetosho,
markAudioCard: overlayHandlers.markAudioCard,
copySubtitleMultiple: overlayHandlers.copySubtitleMultiple,
copySubtitle: overlayHandlers.copySubtitle,
@@ -153,6 +159,13 @@ export function runOverlayShortcutLocalFallback(
},
allowWhenRegistered: true,
},
{
accelerator: shortcuts.openAnimetosho,
run: () => {
handlers.openAnimetosho();
},
allowWhenRegistered: true,
},
{
accelerator: shortcuts.markAudioCard,
run: () => {
@@ -23,6 +23,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openAnimetosho: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
@@ -48,6 +49,7 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured'
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
openAnimetosho: () => {},
}),
true,
);
@@ -68,6 +70,7 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent'
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
openAnimetosho: () => {},
}),
false,
);
@@ -90,6 +93,7 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
openAnimetosho: () => {},
}),
cancelPendingMultiCopy: () => {
calls.push('cancel-multi-copy');
+2
View File
@@ -13,6 +13,7 @@ export interface OverlayShortcutHandlers {
openCharacterDictionaryManager: () => void;
openRuntimeOptions: () => void;
openJimaku: () => void;
openAnimetosho: () => void;
}
export interface OverlayShortcutLifecycleDeps {
@@ -35,6 +36,7 @@ const OVERLAY_SHORTCUT_KEYS: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTim
'openCharacterDictionaryManager',
'openRuntimeOptions',
'openJimaku',
'openAnimetosho',
];
function hasConfiguredOverlayShortcuts(shortcuts: ConfiguredShortcuts): boolean {
@@ -40,6 +40,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
openControllerSelect: () => calls.push('controller-select'),
openControllerDebug: () => calls.push('controller-debug'),
openJimaku: () => calls.push('jimaku'),
openAnimetosho: () => calls.push('animetosho'),
openYoutubeTrackPicker: () => {
calls.push('youtube');
},
+4
View File
@@ -24,6 +24,7 @@ export interface SessionActionExecutorDeps {
openControllerSelect: () => void;
openControllerDebug: () => void;
openJimaku: () => void;
openAnimetosho: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
replayCurrentSubtitle: () => void;
@@ -115,6 +116,9 @@ export async function dispatchSessionAction(
case 'openJimaku':
deps.openJimaku();
return;
case 'openAnimetosho':
deps.openAnimetosho();
return;
case 'openYoutubePicker':
await deps.openYoutubeTrackPicker();
return;
@@ -22,6 +22,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openAnimetosho: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
+5
View File
@@ -55,6 +55,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
{ key: 'openCharacterDictionaryManager', actionId: 'openCharacterDictionaryManager' },
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
{ key: 'openJimaku', actionId: 'openJimaku' },
{ key: 'openAnimetosho', actionId: 'openAnimetosho' },
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
{ key: 'openControllerDebug', actionId: 'openControllerDebug' },
@@ -304,6 +305,10 @@ function resolveCommandBinding(
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'openJimaku' };
}
if (first === SPECIAL_COMMANDS.ANIMETOSHO_OPEN) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'openAnimetosho' };
}
if (first === SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'openYoutubePicker' };
@@ -39,6 +39,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openAnimetosho: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
+2
View File
@@ -15,6 +15,7 @@ export interface ConfiguredShortcuts {
openCharacterDictionaryManager: string | null | undefined;
openRuntimeOptions: string | null | undefined;
openJimaku: string | null | undefined;
openAnimetosho: string | null | undefined;
openSessionHelp: string | null | undefined;
openControllerSelect: string | null | undefined;
openControllerDebug: string | null | undefined;
@@ -65,6 +66,7 @@ export function resolveConfiguredShortcuts(
),
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
openAnimetosho: normalizeShortcut(shortcutValue('openAnimetosho')),
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')),