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
+119
View File
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as http from 'node:http';
import type { AddressInfo } from 'node:net';
import { animetoshoFetchJson } from './utils.js';
interface TestServer {
baseUrl: string;
close: () => Promise<void>;
}
function startServer(handler: http.RequestListener): Promise<TestServer> {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as AddressInfo;
resolve({
baseUrl: `http://127.0.0.1:${port}`,
close: () =>
new Promise((done) => {
server.closeAllConnections?.();
server.close(() => done());
}),
});
});
});
}
test('animetoshoFetchJson gives up on a hanging response', async () => {
const server = await startServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
// Never end the response.
res.write('[');
});
try {
const result = await animetoshoFetchJson(
'/json',
{},
{ baseUrl: server.baseUrl, timeoutMs: 150 },
);
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.error.error, /timed out/i);
}
} finally {
await server.close();
}
});
test('animetoshoFetchJson rejects an oversized response', async () => {
const server = await startServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(Array.from({ length: 2000 }, (_, index) => ({ id: index }))));
});
try {
const result = await animetoshoFetchJson(
'/json',
{},
{ baseUrl: server.baseUrl, maxResponseBytes: 512 },
);
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.error.error, /too large/i);
}
} finally {
await server.close();
}
});
test('animetoshoFetchJson decodes multi-byte characters split across chunks', async () => {
const title = '葬送のフリーレン';
const body = Buffer.from(JSON.stringify([{ id: 1, title }]), 'utf8');
// Split inside the first Japanese character's 3-byte UTF-8 sequence.
const splitAt = body.indexOf(Buffer.from(title, 'utf8')) + 1;
const server = await startServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.write(body.subarray(0, splitAt));
setTimeout(() => res.end(body.subarray(splitAt)), 10);
});
try {
const result = await animetoshoFetchJson<Array<{ id: number; title: string }>>(
'/json',
{},
{ baseUrl: server.baseUrl },
);
assert.equal(result.ok, true);
if (result.ok) {
assert.equal(result.data[0]!.title, title);
}
} finally {
await server.close();
}
});
test('animetoshoFetchJson still parses a normal response', async () => {
const server = await startServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify([{ id: 1, title: 'ok' }]));
});
try {
const result = await animetoshoFetchJson<Array<{ id: number }>>(
'/json',
{ q: 'x' },
{ baseUrl: server.baseUrl },
);
assert.equal(result.ok, true);
if (result.ok) {
assert.deepEqual(result.data, [{ id: 1, title: 'ok' }]);
}
} finally {
await server.close();
}
});
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
animetoshoLangToFilenameSuffix,
animetoshoTrackMatchesLanguages,
describeAnimetoshoTabLanguages,
normalizeAnimetoshoLangCode,
} from './lang.js';
test('normalizeAnimetoshoLangCode collapses 2/3-letter and region variants', () => {
assert.equal(normalizeAnimetoshoLangCode('eng'), 'en');
assert.equal(normalizeAnimetoshoLangCode('en'), 'en');
assert.equal(normalizeAnimetoshoLangCode('en-US'), 'en');
assert.equal(normalizeAnimetoshoLangCode('GER'), 'de');
assert.equal(normalizeAnimetoshoLangCode('jpn'), 'ja');
assert.equal(normalizeAnimetoshoLangCode('vie'), 'vie');
assert.equal(normalizeAnimetoshoLangCode(''), '');
});
test('animetoshoTrackMatchesLanguages matches across code forms', () => {
assert.equal(animetoshoTrackMatchesLanguages('eng', ['en']), true);
assert.equal(animetoshoTrackMatchesLanguages('eng', ['en', 'eng']), true);
assert.equal(animetoshoTrackMatchesLanguages('ger', ['de']), true);
assert.equal(animetoshoTrackMatchesLanguages('por', ['en']), false);
assert.equal(animetoshoTrackMatchesLanguages('spa', ['en', 'de']), false);
});
test('animetoshoTrackMatchesLanguages keeps unknown-language tracks visible', () => {
assert.equal(animetoshoTrackMatchesLanguages('', ['en']), true);
assert.equal(animetoshoTrackMatchesLanguages('und', ['en']), true);
});
test('describeAnimetoshoTabLanguages names common languages and dedupes', () => {
assert.equal(describeAnimetoshoTabLanguages(['en', 'eng']), 'English');
assert.equal(describeAnimetoshoTabLanguages(['de']), 'German');
assert.equal(describeAnimetoshoTabLanguages(['en', 'de']), 'English / German');
assert.equal(describeAnimetoshoTabLanguages(['vie']), 'VIE');
assert.equal(describeAnimetoshoTabLanguages([]), 'English');
});
test('animetoshoLangToFilenameSuffix is re-exported from the pure module', () => {
assert.equal(animetoshoLangToFilenameSuffix('jpn'), 'ja');
assert.equal(animetoshoLangToFilenameSuffix('eng'), 'en');
});
+70
View File
@@ -0,0 +1,70 @@
// Pure language-code helpers shared between the main-process Animetosho client
// and the overlay renderer. Must stay free of node imports so the renderer
// bundle can use it.
const LANG_FILENAME_SUFFIXES: Record<string, string> = {
eng: 'en',
jpn: 'ja',
ger: 'de',
deu: 'de',
spa: 'es',
por: 'pt',
fre: 'fr',
fra: 'fr',
ita: 'it',
rus: 'ru',
ara: 'ar',
chi: 'zh',
zho: 'zh',
kor: 'ko',
};
const LANG_DISPLAY_NAMES: Record<string, string> = {
en: 'English',
ja: 'Japanese',
de: 'German',
es: 'Spanish',
pt: 'Portuguese',
fr: 'French',
it: 'Italian',
ru: 'Russian',
ar: 'Arabic',
zh: 'Chinese',
ko: 'Korean',
};
export function normalizeAnimetoshoLangCode(code: string): string {
const base = code.trim().toLowerCase().split(/[-_]/)[0] ?? '';
return LANG_FILENAME_SUFFIXES[base] ?? base;
}
export function animetoshoLangToFilenameSuffix(lang: string | undefined): string {
const normalized = normalizeAnimetoshoLangCode(lang ?? '');
if (!normalized || normalized === 'und') return 'en';
return normalized;
}
export function animetoshoTrackMatchesLanguages(
trackLang: string,
configuredLanguages: string[],
): boolean {
const normalizedTrack = normalizeAnimetoshoLangCode(trackLang);
// Unlabeled tracks cannot be classified; keep them visible rather than
// hiding them behind a tab that never shows them.
if (!normalizedTrack || normalizedTrack === 'und') return true;
return configuredLanguages.some(
(candidate) => normalizeAnimetoshoLangCode(candidate) === normalizedTrack,
);
}
export function describeAnimetoshoTabLanguages(configuredLanguages: string[]): string {
const normalized = [
...new Set(
configuredLanguages
.map((candidate) => normalizeAnimetoshoLangCode(candidate))
.filter(Boolean),
),
];
if (normalized.length === 0) return 'English';
return normalized.map((code) => LANG_DISPLAY_NAMES[code] ?? code.toUpperCase()).join(' / ');
}
+233
View File
@@ -0,0 +1,233 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
import {
animetoshoLangToFilenameSuffix,
buildAnimetoshoAttachmentUrl,
decompressXzFile,
extractAnimetoshoSubtitleFiles,
mapAnimetoshoSearchResults,
} from './utils.js';
test('buildAnimetoshoAttachmentUrl pads the attachment id to 8 hex digits', () => {
assert.equal(
buildAnimetoshoAttachmentUrl(1955356),
'https://animetosho.org/storage/attach/001dd61c/1955356.xz',
);
});
test('animetoshoLangToFilenameSuffix maps common ISO 639-2 codes to two-letter suffixes', () => {
assert.equal(animetoshoLangToFilenameSuffix('eng'), 'en');
assert.equal(animetoshoLangToFilenameSuffix('jpn'), 'ja');
assert.equal(animetoshoLangToFilenameSuffix('ger'), 'de');
assert.equal(animetoshoLangToFilenameSuffix('spa'), 'es');
assert.equal(animetoshoLangToFilenameSuffix('POR'), 'pt');
});
test('animetoshoLangToFilenameSuffix falls back to the raw code, and to en when unknown', () => {
assert.equal(animetoshoLangToFilenameSuffix('vie'), 'vie');
assert.equal(animetoshoLangToFilenameSuffix(''), 'en');
assert.equal(animetoshoLangToFilenameSuffix(undefined), 'en');
assert.equal(animetoshoLangToFilenameSuffix('und'), 'en');
});
test('buildAnimetoshoAttachmentUrl rejects non-positive and non-integer ids', () => {
assert.equal(buildAnimetoshoAttachmentUrl(0), null);
assert.equal(buildAnimetoshoAttachmentUrl(-5), null);
assert.equal(buildAnimetoshoAttachmentUrl(1.5), null);
assert.equal(buildAnimetoshoAttachmentUrl(Number.NaN), null);
});
test('mapAnimetoshoSearchResults maps valid entries and caps to maxResults', () => {
const payload = [
{
id: 606713,
title: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
timestamp: 1710000000,
total_size: 1490354395,
num_files: 1,
},
{ id: 'bogus', title: 'missing numeric id' },
{ id: 606714, title: '[Erai-raws] Sousou no Frieren - 28 [1080p].mkv' },
{ id: 606715, title: 'capped away' },
];
const entries = mapAnimetoshoSearchResults(payload, 2);
assert.equal(entries.length, 2);
assert.deepEqual(entries[0], {
id: 606713,
title: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
timestamp: 1710000000,
totalSize: 1490354395,
numFiles: 1,
});
assert.equal(entries[1]!.id, 606714);
assert.equal(entries[1]!.totalSize, null);
assert.equal(entries[1]!.numFiles, null);
});
test('mapAnimetoshoSearchResults returns empty list for non-array payloads', () => {
assert.deepEqual(mapAnimetoshoSearchResults({ error: 'nope' }, 10), []);
assert.deepEqual(mapAnimetoshoSearchResults(null, 10), []);
});
const DETAIL_PAYLOAD = {
id: 606713,
title: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
files: [
{
id: 1151711,
filename: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
attachments: [
{
id: 1955355,
type: 'font',
info: { name: 'arial.ttf' },
size: 300000,
},
{
id: 1955356,
type: 'subtitle',
info: { codec: 'ASS', lang: 'eng', name: 'English subs', trackid: 2 },
size: 33075,
},
],
},
],
};
test('extractAnimetoshoSubtitleFiles keeps only text subtitle attachments with download urls', () => {
const files = extractAnimetoshoSubtitleFiles(DETAIL_PAYLOAD);
assert.equal(files.length, 1);
const file = files[0]!;
assert.equal(file.attachmentId, 1955356);
assert.equal(file.lang, 'eng');
assert.equal(file.trackName, 'English subs');
assert.equal(file.size, 33075);
assert.equal(file.url, 'https://animetosho.org/storage/attach/001dd61c/1955356.xz');
assert.equal(file.sourceFilename, '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv');
assert.equal(file.filename, '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].eng.ass');
});
test('extractAnimetoshoSubtitleFiles skips image-based subtitle codecs', () => {
const files = extractAnimetoshoSubtitleFiles({
files: [
{
id: 1,
filename: 'movie.mkv',
attachments: [
{ id: 10, type: 'subtitle', info: { codec: 'PGS', lang: 'eng' }, size: 100 },
{ id: 11, type: 'subtitle', info: { codec: 'VobSub', lang: 'eng' }, size: 100 },
{ id: 12, type: 'subtitle', info: { codec: 'SRT', lang: 'eng' }, size: 100 },
],
},
],
});
assert.deepEqual(
files.map((f) => f.attachmentId),
[12],
);
assert.equal(files[0]!.filename, 'movie.eng.srt');
});
test('extractAnimetoshoSubtitleFiles sorts English tracks first and disambiguates duplicates', () => {
const files = extractAnimetoshoSubtitleFiles({
files: [
{
id: 1,
filename: 'episode.mkv',
attachments: [
{
id: 21,
type: 'subtitle',
info: { codec: 'ASS', lang: 'ger', name: 'Deutsch' },
size: 1,
},
{
id: 22,
type: 'subtitle',
info: { codec: 'ASS', lang: 'eng', name: 'Signs & Songs' },
size: 2,
},
{
id: 23,
type: 'subtitle',
info: { codec: 'ASS', lang: 'eng', name: 'Full Subtitles' },
size: 3,
},
],
},
],
});
assert.deepEqual(
files.map((f) => f.attachmentId),
[22, 23, 21],
);
assert.equal(files[0]!.filename, 'episode.eng.signs-songs.ass');
assert.equal(files[1]!.filename, 'episode.eng.full-subtitles.ass');
assert.equal(files[2]!.filename, 'episode.ger.ass');
});
test('extractAnimetoshoSubtitleFiles tolerates missing info fields', () => {
const files = extractAnimetoshoSubtitleFiles({
files: [
{
id: 1,
attachments: [{ id: 31, type: 'subtitle', info: { codec: 'ASS' }, size: 5 }],
},
],
});
assert.equal(files.length, 1);
assert.equal(files[0]!.lang, '');
assert.equal(files[0]!.trackName, null);
assert.equal(files[0]!.filename, 'subtitle.ass');
});
const hasXz = (() => {
try {
execFileSync('xz', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
})();
test('decompressXzFile round-trips an xz-compressed subtitle', { skip: !hasXz }, async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-animetosho-test-'));
try {
const plainPath = path.join(dir, 'sub.ass');
const content = '[Script Info]\nTitle: test\n';
fs.writeFileSync(plainPath, content, 'utf8');
execFileSync('xz', ['-z', plainPath]);
const destPath = path.join(dir, 'out.ass');
const result = await decompressXzFile(`${plainPath}.xz`, destPath);
assert.equal(result.ok, true);
if (result.ok) {
assert.equal(result.path, destPath);
}
assert.equal(fs.readFileSync(destPath, 'utf8'), content);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('decompressXzFile reports an error for corrupt input', { skip: !hasXz }, async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-animetosho-test-'));
try {
const srcPath = path.join(dir, 'broken.xz');
fs.writeFileSync(srcPath, 'not xz data');
const result = await decompressXzFile(srcPath, path.join(dir, 'out.ass'));
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.error.error, /xz|decompress/i);
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
Binary file not shown.
+2
View File
@@ -115,6 +115,7 @@ test('parseArgs captures session action forwarding flags', () => {
'--toggle-stats-overlay',
'--mark-watched',
'--open-jimaku',
'--open-animetosho',
'--open-youtube-picker',
'--open-playlist-browser',
'--toggle-primary-subtitle-bar',
@@ -132,6 +133,7 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(args.toggleStatsOverlay, true);
assert.equal(args.markWatched, true);
assert.equal(args.openJimaku, true);
assert.equal(args.openAnimetosho, true);
assert.equal(args.openYoutubePicker, true);
assert.equal(args.openPlaylistBrowser, true);
assert.equal(args.togglePrimarySubtitleBar, true);
+8
View File
@@ -37,6 +37,7 @@ export interface CliArgs {
openControllerSelect: boolean;
openControllerDebug: boolean;
openJimaku: boolean;
openAnimetosho: boolean;
openYoutubePicker: boolean;
openPlaylistBrowser: boolean;
replayCurrentSubtitle: boolean;
@@ -145,6 +146,7 @@ export function parseArgs(argv: string[]): CliArgs {
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openAnimetosho: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
@@ -292,6 +294,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--open-controller-select') args.openControllerSelect = true;
else if (arg === '--open-controller-debug') args.openControllerDebug = true;
else if (arg === '--open-jimaku') args.openJimaku = true;
else if (arg === '--open-animetosho') args.openAnimetosho = true;
else if (arg === '--open-youtube-picker') args.openYoutubePicker = true;
else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true;
else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true;
@@ -564,6 +567,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
args.openAnimetosho ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
@@ -641,6 +645,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.openControllerSelect &&
!args.openControllerDebug &&
!args.openJimaku &&
!args.openAnimetosho &&
!args.openYoutubePicker &&
!args.openPlaylistBrowser &&
!args.replayCurrentSubtitle &&
@@ -707,6 +712,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
args.openAnimetosho ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
@@ -767,6 +773,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.openControllerSelect &&
!args.openControllerDebug &&
!args.openJimaku &&
!args.openAnimetosho &&
!args.openYoutubePicker &&
!args.openPlaylistBrowser &&
!args.replayCurrentSubtitle &&
@@ -832,6 +839,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
args.openAnimetosho ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
+13 -2
View File
@@ -37,8 +37,18 @@ const {
notifications,
auto_start_overlay,
} = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
INTEGRATIONS_DEFAULT_CONFIG;
const {
ankiConnect,
jimaku,
animetosho,
anilist,
mpv,
yomitan,
jellyfin,
discordPresence,
ai,
youtubeSubgen,
} = INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
const { stats } = STATS_DEFAULT_CONFIG;
@@ -63,6 +73,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
subtitleSidebar,
auto_start_overlay,
jimaku,
animetosho,
anilist,
mpv,
yomitan,
+1
View File
@@ -98,6 +98,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
openCharacterDictionaryManager: 'CommandOrControl+D',
openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J',
openAnimetosho: 'Ctrl+Shift+T',
openSessionHelp: 'CommandOrControl+Slash',
openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C',
@@ -5,6 +5,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
ResolvedConfig,
| 'ankiConnect'
| 'jimaku'
| 'animetosho'
| 'anilist'
| 'mpv'
| 'yomitan'
@@ -96,6 +97,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
languagePreference: 'ja',
maxEntryResults: 10,
},
animetosho: {
apiBaseUrl: 'https://feed.animetosho.org',
maxSearchResults: 10,
},
mpv: {
executablePath: '',
launchMode: 'normal',
+7
View File
@@ -615,6 +615,13 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.shortcuts.openJimaku,
description: 'Accelerator that opens the Jimaku subtitle search modal.',
},
{
path: 'shortcuts.openAnimetosho',
kind: 'string',
defaultValue: defaultConfig.shortcuts.openAnimetosho,
description:
'Accelerator that opens the Animetosho subtitle search modal (English/Japanese tabs).',
},
{
path: 'shortcuts.openSessionHelp',
kind: 'string',
@@ -400,6 +400,18 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.jimaku.maxEntryResults,
description: 'Maximum Jimaku search results returned.',
},
{
path: 'animetosho.apiBaseUrl',
kind: 'string',
defaultValue: defaultConfig.animetosho.apiBaseUrl,
description: 'Base URL of the Animetosho JSON feed API. No API key required.',
},
{
path: 'animetosho.maxSearchResults',
kind: 'number',
defaultValue: defaultConfig.animetosho.maxSearchResults,
description: 'Maximum Animetosho search results returned.',
},
{
path: 'anilist.enabled',
kind: 'boolean',
+1
View File
@@ -53,6 +53,7 @@ export const SPECIAL_COMMANDS = {
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',
@@ -147,6 +147,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: Jimaku changes apply to the next Jimaku request.'],
key: 'jimaku',
},
{
title: 'Animetosho',
description: [
'Animetosho subtitle search configuration (English and Japanese). No API key required.',
],
notes: ['Hot-reload: Animetosho changes apply to the next Animetosho request.'],
key: 'animetosho',
},
{
title: 'YouTube Playback Settings',
description: [
+1
View File
@@ -236,6 +236,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'openCharacterDictionaryManager',
'openRuntimeOptions',
'openJimaku',
'openAnimetosho',
'openSessionHelp',
'openControllerSelect',
'openControllerDebug',
+17
View File
@@ -80,6 +80,23 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
}
}
if (isObject(src.animetosho)) {
const apiBaseUrl = asString(src.animetosho.apiBaseUrl);
if (apiBaseUrl !== undefined) resolved.animetosho.apiBaseUrl = apiBaseUrl;
const maxSearchResults = asNumber(src.animetosho.maxSearchResults);
if (maxSearchResults !== undefined && Math.floor(maxSearchResults) > 0) {
resolved.animetosho.maxSearchResults = Math.floor(maxSearchResults);
} else if (src.animetosho.maxSearchResults !== undefined) {
warn(
'animetosho.maxSearchResults',
src.animetosho.maxSearchResults,
resolved.animetosho.maxSearchResults,
'Expected positive number.',
);
}
}
if (isObject(src.youtubeSubgen)) {
const whisperBin = asString(src.youtubeSubgen.whisperBin);
if (whisperBin !== undefined) {
+1
View File
@@ -594,6 +594,7 @@ function subsectionForPath(path: string): string | undefined {
leaf === 'openCharacterDictionaryManager' ||
leaf === 'openRuntimeOptions' ||
leaf === 'openJimaku' ||
leaf === 'openAnimetosho' ||
leaf === 'openSessionHelp' ||
leaf === 'openControllerSelect' ||
leaf === 'openControllerDebug'
+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')),
+28
View File
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { describeDownloadError } from './utils.js';
test('describeDownloadError prefers the error message', () => {
assert.equal(describeDownloadError(new Error('socket hang up')), 'socket hang up');
});
test('describeDownloadError falls back to the error code when the message is empty', () => {
const err = new Error('') as NodeJS.ErrnoException;
err.code = 'ECONNRESET';
assert.equal(describeDownloadError(err), 'ECONNRESET');
});
test('describeDownloadError unwraps empty-message AggregateErrors', () => {
const v4 = new Error('connect ECONNREFUSED 1.2.3.4:443') as NodeJS.ErrnoException;
v4.code = 'ECONNREFUSED';
const v6 = new Error('') as NodeJS.ErrnoException;
v6.code = 'ENETUNREACH';
const aggregate = new AggregateError([v4, v6], '');
assert.equal(describeDownloadError(aggregate), 'connect ECONNREFUSED 1.2.3.4:443; ENETUNREACH');
});
test('describeDownloadError never returns an empty string', () => {
assert.equal(describeDownloadError(new Error('')), 'Error');
assert.equal(describeDownloadError('boom'), 'boom');
});
+94
View File
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as fs from 'node:fs';
import * as http from 'node:http';
import * as os from 'node:os';
import * as path from 'node:path';
import type { AddressInfo } from 'node:net';
import { downloadToFile } from './utils.js';
interface TestServer {
port: number;
close: () => Promise<void>;
}
function startServer(handler: http.RequestListener): Promise<TestServer> {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as AddressInfo;
resolve({
port,
close: () =>
new Promise((done) => {
server.close(() => done());
}),
});
});
});
}
test('downloadToFile follows redirects that pass the allow-list', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-redirect-test-'));
const server = await startServer((req, res) => {
if (req.url === '/start') {
res.writeHead(302, { Location: '/final' });
res.end();
return;
}
res.writeHead(200);
res.end('subtitle body');
});
try {
const destPath = path.join(dir, 'sub.ass');
const result = await downloadToFile(
`http://127.0.0.1:${server.port}/start`,
destPath,
{},
{ isAllowedRedirect: (url) => url.hostname === '127.0.0.1' },
);
assert.equal(result.ok, true);
assert.equal(fs.readFileSync(destPath, 'utf8'), 'subtitle body');
} finally {
await server.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('downloadToFile refuses redirects to a host outside the allow-list', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-redirect-test-'));
let finalHits = 0;
const server = await startServer((req, res) => {
if (req.url === '/start') {
res.writeHead(302, { Location: 'http://localhost.localdomain/evil' });
res.end();
return;
}
finalHits += 1;
res.writeHead(200);
res.end('should never be fetched');
});
try {
const destPath = path.join(dir, 'sub.ass');
const result = await downloadToFile(
`http://127.0.0.1:${server.port}/start`,
destPath,
{},
{ isAllowedRedirect: (url) => url.hostname === '127.0.0.1' },
);
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.error.error, /redirect/i);
}
assert.equal(finalHits, 0);
assert.equal(fs.existsSync(destPath), false);
} finally {
await server.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
+43 -4
View File
@@ -306,12 +306,35 @@ export function isRemoteMediaPath(mediaPath: string): boolean {
return /^[a-z][a-z0-9+.-]*:\/\//i.test(mediaPath);
}
export function describeDownloadError(err: unknown): string {
if (err instanceof AggregateError) {
const parts = err.errors
.map((inner) => describeDownloadError(inner))
.filter((part) => part && part !== 'Error');
if (parts.length > 0) return parts.join('; ');
}
if (err instanceof Error) {
if (err.message) return err.message;
const code = (err as NodeJS.ErrnoException).code;
if (code) return code;
return err.name || 'Error';
}
return String(err) || 'Unknown error';
}
export interface DownloadToFileOptions {
// Guards where a redirect may land. Without it any Location header is followed.
isAllowedRedirect?: (url: URL) => boolean;
redirectCount?: number;
}
export async function downloadToFile(
url: string,
destPath: string,
headers: Record<string, string>,
redirectCount = 0,
options: DownloadToFileOptions = {},
): Promise<JimakuDownloadResult> {
const redirectCount = options.redirectCount ?? 0;
if (redirectCount > 3) {
return {
ok: false,
@@ -326,9 +349,23 @@ export async function downloadToFile(
const req = transport.get(parsedUrl, { headers }, (res) => {
const status = res.statusCode || 0;
if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) {
const redirectUrl = new URL(res.headers.location, parsedUrl).toString();
const redirectUrl = new URL(res.headers.location, parsedUrl);
res.resume();
downloadToFile(redirectUrl, destPath, headers, redirectCount + 1).then(resolve);
if (options.isAllowedRedirect && !options.isAllowedRedirect(redirectUrl)) {
logger.error(`Refusing redirect to disallowed host: ${redirectUrl.href}`);
resolve({
ok: false,
error: {
error: `Refusing to follow subtitle redirect to ${redirectUrl.host}.`,
code: status,
},
});
return;
}
downloadToFile(redirectUrl.toString(), destPath, headers, {
...options,
redirectCount: redirectCount + 1,
}).then(resolve);
return;
}
@@ -362,9 +399,11 @@ export async function downloadToFile(
});
req.on('error', (err) => {
const reason = describeDownloadError(err);
logger.error(`Download request failed for ${url}: ${reason}`);
resolve({
ok: false,
error: { error: `Download request failed: ${(err as Error).message}` },
error: { error: `Download request failed: ${reason}` },
});
});
});
+20 -2
View File
@@ -467,6 +467,7 @@ import { createOverlayModalInputState } from './main/runtime/overlay-modal-input
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
import { openAnimetoshoModal as openAnimetoshoModalRuntime } from './main/runtime/animetosho-open';
import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open';
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
import { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open';
@@ -2058,6 +2059,9 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
openJimaku: () => {
openJimakuOverlay();
},
openAnimetosho: () => {
openAnimetoshoOverlay();
},
markAudioCard: () => markLastCardAsAudioCard(),
copySubtitleMultiple: (timeoutMs: number) => {
startPendingMultiCopy(timeoutMs);
@@ -2919,6 +2923,14 @@ function openJimakuOverlay(): void {
);
}
function openAnimetoshoOverlay(): void {
openOverlayHostedModalWithOsd(
openAnimetoshoModalRuntime,
'Animetosho overlay unavailable.',
'Failed to open Animetosho overlay.',
);
}
function openSessionHelpOverlay(): void {
openOverlayHostedModalWithOsd(
openSessionHelpModalRuntime,
@@ -5434,6 +5446,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
},
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => openJimakuOverlay(),
openAnimetosho: () => openAnimetoshoOverlay(),
openSessionHelp: () => openSessionHelpOverlay(),
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
openControllerSelect: () => openControllerSelectOverlay(),
@@ -5467,6 +5480,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => openJimakuOverlay(),
openAnimetosho: () => openAnimetoshoOverlay(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
openPlaylistBrowser: () => openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => {
@@ -5896,8 +5910,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getJimakuLanguagePreference: () => configDerivedRuntime.getJimakuLanguagePreference(),
resolveJimakuApiKey: () => configDerivedRuntime.resolveJimakuApiKey(),
isRemoteMediaPath: (mediaPath: string) => isRemoteMediaPath(mediaPath),
downloadToFile: (url: string, destPath: string, headers: Record<string, string>) =>
downloadToFile(url, destPath, headers),
downloadToFile: (
url: string,
destPath: string,
headers: Record<string, string>,
downloadOptions?: { isAllowedRedirect?: (url: URL) => boolean },
) => downloadToFile(url, destPath, headers, downloadOptions),
}),
registerIpcRuntimeServices,
},
+2
View File
@@ -228,6 +228,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
openJimaku: HandleMpvCommandFromIpcOptions['openJimaku'];
openAnimetosho: HandleMpvCommandFromIpcOptions['openAnimetosho'];
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
@@ -434,6 +435,7 @@ export function createMpvCommandRuntimeServiceDeps(
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
openJimaku: params.openJimaku,
openAnimetosho: params.openAnimetosho,
openYoutubeTrackPicker: params.openYoutubeTrackPicker,
openPlaylistBrowser: params.openPlaylistBrowser,
runtimeOptionsCycle: params.runtimeOptionsCycle,
+2
View File
@@ -13,6 +13,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void;
openJimaku: () => void;
openAnimetosho: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => void | Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
@@ -38,6 +39,7 @@ export function handleMpvCommandFromIpcRuntime(
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openJimaku: deps.openJimaku,
openAnimetosho: deps.openAnimetosho,
openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
openPlaylistBrowser: deps.openPlaylistBrowser,
runtimeOptionsCycle: deps.cycleRuntimeOption,
+8
View File
@@ -49,6 +49,7 @@ export interface OverlayModalRuntime {
) => boolean;
openRuntimeOptionsPalette: () => void;
openJimaku: () => void;
openAnimetosho: () => void;
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
@@ -432,6 +433,12 @@ export function createOverlayModalRuntimeService(
});
};
const openAnimetosho = (): void => {
sendToActiveOverlayWindow('animetosho:open', undefined, {
restoreOnModalClose: 'animetosho',
});
};
const handleOverlayModalClosed = (modal: OverlayHostedModal): void => {
openedModals.delete(modal);
if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
@@ -511,6 +518,7 @@ export function createOverlayModalRuntimeService(
sendToActiveOverlayWindow,
openRuntimeOptionsPalette,
openJimaku,
openAnimetosho,
handleOverlayModalClosed,
notifyOverlayModalOpened,
waitForModalOpen,
+4
View File
@@ -21,6 +21,7 @@ export interface OverlayShortcutRuntimeServiceInput {
openRuntimeOptionsPalette: () => void;
openCharacterDictionaryManager: () => void;
openJimaku: () => void;
openAnimetosho: () => void;
markAudioCard: () => Promise<void>;
copySubtitleMultiple: (timeoutMs: number) => void;
copySubtitle: () => void;
@@ -56,6 +57,9 @@ export function createOverlayShortcutsRuntimeService(
openJimaku: () => {
input.openJimaku();
},
openAnimetosho: () => {
input.openAnimetosho();
},
markAudioCard: () => {
return input.markAudioCard();
},
+48
View File
@@ -0,0 +1,48 @@
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
const ANIMETOSHO_MODAL: OverlayHostedModal = 'animetosho';
const ANIMETOSHO_OPEN_TIMEOUT_MS = 1500;
export async function openAnimetoshoModal(deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
}): Promise<boolean> {
return await retryOverlayModalOpen(
{
waitForModalOpen: deps.waitForModalOpen,
logWarn: deps.logWarn,
},
{
modal: ANIMETOSHO_MODAL,
timeoutMs: ANIMETOSHO_OPEN_TIMEOUT_MS,
retryWarning:
'Animetosho modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
sendOpen: () =>
openOverlayHostedModal(
{
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
ensureOverlayWindowsReadyForVisibilityActions:
deps.ensureOverlayWindowsReadyForVisibilityActions,
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
},
{
channel: IPC_CHANNELS.event.animetoshoOpen,
modal: ANIMETOSHO_MODAL,
preferModalWindow: true,
},
),
},
);
}
@@ -11,6 +11,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {},
openJimaku: () => {},
openAnimetosho: () => {},
openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: true }),
@@ -54,6 +54,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openAnimetosho: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
@@ -97,6 +97,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
args.openAnimetosho ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
@@ -19,6 +19,7 @@ function createShortcuts(): ConfiguredShortcuts {
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openAnimetosho: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
@@ -23,6 +23,7 @@ function createShortcuts(): ConfiguredShortcuts {
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openAnimetosho: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
@@ -14,6 +14,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {},
openJimaku: () => {},
openAnimetosho: () => {},
openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
@@ -11,6 +11,7 @@ test('handle mpv command handler forwards command and built deps', () => {
triggerSubsyncFromConfig: () => {},
openRuntimeOptionsPalette: () => {},
openJimaku: () => {},
openAnimetosho: () => {},
openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
@@ -8,6 +8,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
triggerSubsyncFromConfig: () => calls.push('subsync'),
openRuntimeOptionsPalette: () => calls.push('palette'),
openJimaku: () => calls.push('jimaku'),
openAnimetosho: () => calls.push('animetosho'),
openYoutubeTrackPicker: () => {
calls.push('youtube-picker');
},
@@ -29,6 +30,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
deps.triggerSubsyncFromConfig();
deps.openRuntimeOptionsPalette();
deps.openJimaku();
deps.openAnimetosho();
void deps.openYoutubeTrackPicker();
void deps.openPlaylistBrowser();
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
@@ -45,6 +47,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
'subsync',
'palette',
'jimaku',
'animetosho',
'youtube-picker',
'playlist-browser',
'osd:hello',
@@ -10,6 +10,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openJimaku: () => deps.openJimaku(),
openAnimetosho: () => deps.openAnimetosho(),
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
@@ -18,6 +18,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openCharacterDictionaryManager: () => calls.push('character-dictionary-manager'),
openJimaku: () => calls.push('jimaku'),
openAnimetosho: () => calls.push('animetosho'),
markAudioCard: async () => {
calls.push('mark-audio');
},
@@ -13,6 +13,7 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler(
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openCharacterDictionaryManager: () => deps.openCharacterDictionaryManager(),
openJimaku: () => deps.openJimaku(),
openAnimetosho: () => deps.openAnimetosho(),
markAudioCard: () => deps.markAudioCard(),
copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs),
copySubtitle: () => deps.copySubtitle(),
+22
View File
@@ -34,6 +34,13 @@ import type {
JimakuFileEntry,
JimakuApiResponse,
JimakuDownloadResult,
AnimetoshoSearchQuery,
AnimetoshoFilesQuery,
AnimetoshoDownloadQuery,
AnimetoshoEntry,
AnimetoshoSubtitleFile,
AnimetoshoApiResponse,
AnimetoshoDownloadResult,
SubsyncManualPayload,
SubsyncManualRunRequest,
SubsyncResult,
@@ -167,6 +174,7 @@ const onOpenControllerSelectEvent = createQueuedIpcListener(
);
const onOpenControllerDebugEvent = createQueuedIpcListener(IPC_CHANNELS.event.controllerDebugOpen);
const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen);
const onOpenAnimetoshoEvent = createQueuedIpcListener(IPC_CHANNELS.event.animetoshoOpen);
const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<YoutubePickerOpenPayload>(
IPC_CHANNELS.event.youtubePickerOpen,
(payload) => payload as YoutubePickerOpenPayload,
@@ -350,6 +358,19 @@ const electronAPI: ElectronAPI = {
jimakuDownloadFile: (query: JimakuDownloadQuery): Promise<JimakuDownloadResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuDownloadFile, query),
animetoshoSearchEntries: (
query: AnimetoshoSearchQuery,
): Promise<AnimetoshoApiResponse<AnimetoshoEntry[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.request.animetoshoSearchEntries, query),
animetoshoListFiles: (
query: AnimetoshoFilesQuery,
): Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.request.animetoshoListFiles, query),
animetoshoDownloadFile: (query: AnimetoshoDownloadQuery): Promise<AnimetoshoDownloadResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.animetoshoDownloadFile, query),
animetoshoGetSecondaryLanguages: (): Promise<string[]> =>
ipcRenderer.invoke(IPC_CHANNELS.request.animetoshoGetSecondaryLanguages),
quitApp: () => {
ipcRenderer.send(IPC_CHANNELS.command.quitApp);
},
@@ -429,6 +450,7 @@ const electronAPI: ElectronAPI = {
onOpenControllerSelect: onOpenControllerSelectEvent,
onOpenControllerDebug: onOpenControllerDebugEvent,
onOpenJimaku: onOpenJimakuEvent,
onOpenAnimetosho: onOpenAnimetoshoEvent,
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent,
+2
View File
@@ -90,6 +90,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openAnimetosho: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
@@ -491,6 +492,7 @@ function createKeyboardHandlerHarness() {
handleSubsyncKeydown: () => false,
handleKikuKeydown: () => false,
handleJimakuKeydown: () => false,
handleAnimetoshoKeydown: () => false,
handleControllerSelectKeydown: () => {
controllerSelectKeydownCount += 1;
return true;
+5
View File
@@ -16,6 +16,7 @@ export function createKeyboardHandlers(
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
handleKikuKeydown: (e: KeyboardEvent) => boolean;
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
handleAnimetoshoKeydown: (e: KeyboardEvent) => boolean;
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
@@ -1119,6 +1120,10 @@ export function createKeyboardHandlers(
options.handleJimakuKeydown(e);
return;
}
if (ctx.state.animetoshoModalOpen) {
options.handleAnimetoshoKeydown(e);
return;
}
if (ctx.state.youtubePickerModalOpen) {
if (options.handleYoutubePickerKeydown(e)) {
return;
+38
View File
@@ -116,6 +116,44 @@
</div>
</div>
</div>
<div id="animetoshoModal" class="modal hidden" aria-hidden="true">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title">Animetosho Subtitles</div>
<button id="animetoshoClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div class="animetosho-tabs">
<button id="animetoshoTabEnglish" class="animetosho-tab active" type="button">
English
</button>
<button id="animetoshoTabJapanese" class="animetosho-tab" type="button">
Japanese
</button>
</div>
<div class="jimaku-form">
<label class="jimaku-field">
<span>Title</span>
<input id="animetoshoTitle" type="text" placeholder="Anime title" />
</label>
<label class="jimaku-field">
<span>Episode</span>
<input id="animetoshoEpisode" type="number" min="1" placeholder="1" />
</label>
<button id="animetoshoSearch" class="jimaku-button" type="button">Search</button>
</div>
<div id="animetoshoStatus" class="jimaku-status"></div>
<div id="animetoshoEntriesSection" class="jimaku-section hidden">
<div class="jimaku-section-title">Releases</div>
<ul id="animetoshoEntries" class="jimaku-list"></ul>
</div>
<div id="animetoshoFilesSection" class="jimaku-section hidden">
<div class="jimaku-section-title">Subtitle tracks</div>
<ul id="animetoshoFiles" class="jimaku-list"></ul>
</div>
</div>
</div>
</div>
<div id="youtubePickerModal" class="modal hidden" aria-hidden="true">
<div class="modal-content youtube-picker-content">
<div class="modal-header">
+388
View File
@@ -0,0 +1,388 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AnimetoshoSubtitleFile, ElectronAPI } from '../../types';
import { createRendererState } from '../state.js';
import { createAnimetoshoModal } from './animetosho.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) {
tokens.add(entry);
}
},
remove: (...entries: string[]) => {
for (const entry of entries) {
tokens.delete(entry);
}
},
contains: (entry: string) => tokens.has(entry),
};
}
function createElementStub() {
const classList = createClassList();
return {
textContent: '',
className: '',
style: {},
classList,
children: [] as unknown[],
appendChild(child: unknown) {
this.children.push(child);
},
addEventListener: () => {},
};
}
function createListStub() {
return {
innerHTML: '',
children: [] as unknown[],
appendChild(child: unknown) {
this.children.push(child);
},
};
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
const ENGLISH_TRACK: AnimetoshoSubtitleFile = {
attachmentId: 1955356,
filename: 'episode01.eng.ass',
lang: 'eng',
trackName: 'English subs',
size: 33075,
url: 'https://animetosho.org/storage/attach/001dd61c/1955356.xz',
sourceFilename: 'episode01.mkv',
};
const JAPANESE_TRACK: AnimetoshoSubtitleFile = {
attachmentId: 1955400,
filename: 'episode01.jpn.ass',
lang: 'jpn',
trackName: 'Japanese subs',
size: 41000,
url: 'https://animetosho.org/storage/attach/001dd648/1955400.xz',
sourceFilename: 'episode01.mkv',
};
const GERMAN_TRACK: AnimetoshoSubtitleFile = {
attachmentId: 1955500,
filename: 'episode01.ger.ass',
lang: 'ger',
trackName: 'Deutsch',
size: 28000,
url: 'https://animetosho.org/storage/attach/001dd6ac/1955500.xz',
sourceFilename: 'episode01.mkv',
};
interface ModalHarness {
modal: ReturnType<typeof createAnimetoshoModal>;
state: ReturnType<typeof createRendererState>;
downloadQueries: unknown[];
modalCloseNotifications: string[];
overlayClassList: ReturnType<typeof createClassList>;
animetoshoModalClassList: ReturnType<typeof createClassList>;
restoreGlobals: () => void;
}
function createModalHarness(
files: AnimetoshoSubtitleFile[],
options: {
secondaryLanguages?: string[];
listFiles?: (entryId: number) => Promise<unknown>;
} = {},
): ModalHarness {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, 'window');
const hadDocument = Object.prototype.hasOwnProperty.call(globalThis, 'document');
const previousWindow = globals.window;
const previousDocument = globals.document;
const modalCloseNotifications: string[] = [];
const downloadQueries: unknown[] = [];
const electronAPI = {
animetoshoDownloadFile: async (query: unknown) => {
downloadQueries.push(query);
return { ok: true, path: '/tmp/subtitles/episode01.en.ass' };
},
animetoshoGetSecondaryLanguages: async () => options.secondaryLanguages ?? ['en', 'eng'],
animetoshoListFiles: async ({ entryId }: { entryId: number }) =>
options.listFiles ? options.listFiles(entryId) : { ok: true, data: [] },
getJimakuMediaInfo: async () => ({
title: '',
season: null,
episode: null,
confidence: 'low',
filename: '',
rawTitle: '',
}),
notifyOverlayModalClosed: (modal: string) => {
modalCloseNotifications.push(modal);
},
} as unknown as ElectronAPI;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { electronAPI },
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
activeElement: null,
createElement: () => createElementStub(),
},
});
const overlayClassList = createClassList(['interactive']);
const animetoshoModalClassList = createClassList();
const state = createRendererState();
state.animetoshoModalOpen = true;
state.currentAnimetoshoEntryId = 606713;
state.selectedAnimetoshoFileIndex = 0;
state.animetoshoFiles = files;
const ctx = {
dom: {
overlay: { classList: overlayClassList },
animetoshoModal: {
classList: animetoshoModalClassList,
setAttribute: () => {},
},
animetoshoTitleInput: { value: '' },
animetoshoEpisodeInput: { value: '' },
animetoshoSearchButton: { addEventListener: () => {} },
animetoshoCloseButton: { addEventListener: () => {} },
animetoshoTabEnglishButton: {
textContent: 'English',
classList: createClassList(['active']),
addEventListener: () => {},
},
animetoshoTabJapaneseButton: {
textContent: 'Japanese',
classList: createClassList(),
addEventListener: () => {},
},
animetoshoStatus: { textContent: '', style: { color: '' } },
animetoshoEntriesSection: { classList: createClassList(['hidden']) },
animetoshoEntriesList: createListStub(),
animetoshoFilesSection: { classList: createClassList() },
animetoshoFilesList: createListStub(),
},
state,
};
const modal = createAnimetoshoModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
return {
modal,
state,
downloadQueries,
modalCloseNotifications,
overlayClassList,
animetoshoModalClassList,
restoreGlobals: () => {
const target = globalThis as unknown as Record<string, unknown>;
if (hadWindow) {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
} else {
delete target.window;
}
if (hadDocument) {
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: previousDocument,
});
} else {
delete target.document;
}
},
};
}
function pressKey(harness: ModalHarness, key: string): boolean {
let prevented = false;
harness.modal.handleAnimetoshoKeydown({
key,
preventDefault: () => {
prevented = true;
},
} as KeyboardEvent);
return prevented;
}
test('successful Animetosho subtitle selection closes modal', async () => {
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
try {
const prevented = pressKey(harness, 'Enter');
await flushAsyncWork();
assert.equal(prevented, true);
assert.equal(harness.state.animetoshoModalOpen, false);
assert.equal(harness.animetoshoModalClassList.contains('hidden'), true);
assert.equal(harness.overlayClassList.contains('interactive'), false);
assert.deepEqual(harness.modalCloseNotifications, ['animetosho']);
assert.deepEqual(harness.downloadQueries, [
{
entryId: 606713,
url: ENGLISH_TRACK.url,
name: ENGLISH_TRACK.filename,
lang: 'eng',
},
]);
} finally {
harness.restoreGlobals();
}
});
test('English tab hides non-English languages, not just Japanese', async () => {
const harness = createModalHarness([GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK]);
try {
// With German visible this would move selection onto it; English-only
// filtering must clamp to the single English track instead.
pressKey(harness, 'ArrowDown');
pressKey(harness, 'Enter');
await flushAsyncWork();
assert.deepEqual(harness.downloadQueries, [
{
entryId: 606713,
url: ENGLISH_TRACK.url,
name: ENGLISH_TRACK.filename,
lang: 'eng',
},
]);
} finally {
harness.restoreGlobals();
}
});
test('Japanese tab filters tracks so Enter downloads the Japanese one', async () => {
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
try {
assert.equal(harness.state.animetoshoActiveTab, 'en');
pressKey(harness, 'ArrowRight');
assert.equal(harness.state.animetoshoActiveTab, 'ja');
pressKey(harness, 'Enter');
await flushAsyncWork();
assert.deepEqual(harness.downloadQueries, [
{
entryId: 606713,
url: JAPANESE_TRACK.url,
name: JAPANESE_TRACK.filename,
lang: 'jpn',
},
]);
} finally {
harness.restoreGlobals();
}
});
test('secondary tab follows configured secondarySub languages', async () => {
const harness = createModalHarness([GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK], {
secondaryLanguages: ['de'],
});
try {
// Re-open through the API so the modal fetches the configured languages.
harness.state.animetoshoModalOpen = false;
harness.modal.openAnimetoshoModal();
await flushAsyncWork();
harness.state.animetoshoFiles = [GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK];
harness.state.currentAnimetoshoEntryId = 606713;
pressKey(harness, 'Enter');
await flushAsyncWork();
assert.deepEqual(harness.downloadQueries, [
{
entryId: 606713,
url: GERMAN_TRACK.url,
name: GERMAN_TRACK.filename,
lang: 'ger',
},
]);
} finally {
harness.restoreGlobals();
}
});
test('a slow release response does not overwrite the newly selected release', async () => {
const STALE_TRACK: AnimetoshoSubtitleFile = {
...ENGLISH_TRACK,
attachmentId: 999,
filename: 'stale.eng.ass',
};
const SECOND_ENGLISH_TRACK: AnimetoshoSubtitleFile = {
...ENGLISH_TRACK,
attachmentId: 1955357,
filename: 'episode01.eng.sdh.ass',
};
const resolvers: Array<(value: unknown) => void> = [];
const harness = createModalHarness([], {
listFiles: (entryId) =>
new Promise((resolve) => {
if (entryId === 1) {
// Entry 1 answers late, after the user has moved on to entry 2.
resolvers.push(() => resolve({ ok: true, data: [STALE_TRACK] }));
} else {
// Two tracks, so the modal does not auto-download a lone match.
resolve({ ok: true, data: [ENGLISH_TRACK, SECOND_ENGLISH_TRACK] });
}
}),
});
try {
harness.state.animetoshoEntries = [
{ id: 1, title: 'slow release', timestamp: null, totalSize: null, numFiles: 1 },
{ id: 2, title: 'fast release', timestamp: null, totalSize: null, numFiles: 1 },
];
harness.modal.selectAnimetoshoEntry(0);
harness.modal.selectAnimetoshoEntry(1);
await flushAsyncWork();
// Entry 2's tracks are on screen; now entry 1 finally answers.
assert.deepEqual(
harness.state.animetoshoFiles.map((file) => file.attachmentId),
[ENGLISH_TRACK.attachmentId, SECOND_ENGLISH_TRACK.attachmentId],
);
resolvers.forEach((resolve) => resolve(undefined));
await flushAsyncWork();
assert.equal(harness.state.currentAnimetoshoEntryId, 2);
assert.deepEqual(
harness.state.animetoshoFiles.map((file) => file.attachmentId),
[ENGLISH_TRACK.attachmentId, SECOND_ENGLISH_TRACK.attachmentId],
);
} finally {
harness.restoreGlobals();
}
});
test('ArrowLeft switches back to the English tab', () => {
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
try {
pressKey(harness, 'ArrowRight');
assert.equal(harness.state.animetoshoActiveTab, 'ja');
pressKey(harness, 'ArrowLeft');
assert.equal(harness.state.animetoshoActiveTab, 'en');
} finally {
harness.restoreGlobals();
}
});
+487
View File
@@ -0,0 +1,487 @@
import type {
AnimetoshoApiResponse,
AnimetoshoDownloadResult,
AnimetoshoEntry,
AnimetoshoSubtitleFile,
JimakuMediaInfo,
} from '../../types';
import {
animetoshoTrackMatchesLanguages,
describeAnimetoshoTabLanguages,
normalizeAnimetoshoLangCode,
} from '../../animetosho/lang.js';
import type { ModalStateReader, RendererContext } from '../context';
export function createAnimetoshoModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
function setAnimetoshoStatus(message: string, isError = false): void {
ctx.dom.animetoshoStatus.textContent = message;
ctx.dom.animetoshoStatus.style.color = isError
? 'rgba(255, 120, 120, 0.95)'
: 'rgba(255, 255, 255, 0.8)';
}
function resetAnimetoshoLists(): void {
ctx.state.animetoshoEntries = [];
ctx.state.animetoshoFiles = [];
ctx.state.selectedAnimetoshoEntryIndex = 0;
ctx.state.selectedAnimetoshoFileIndex = 0;
ctx.state.currentAnimetoshoEntryId = null;
ctx.dom.animetoshoEntriesList.innerHTML = '';
ctx.dom.animetoshoFilesList.innerHTML = '';
ctx.dom.animetoshoEntriesSection.classList.add('hidden');
ctx.dom.animetoshoFilesSection.classList.add('hidden');
}
// Defaults to English until the configured secondarySub languages arrive.
let secondaryLanguages: string[] = ['en'];
function secondaryTabLabel(): string {
return describeAnimetoshoTabLanguages(secondaryLanguages);
}
function isJapaneseTrack(file: AnimetoshoSubtitleFile): boolean {
return normalizeAnimetoshoLangCode(file.lang) === 'ja';
}
function getVisibleFiles(): AnimetoshoSubtitleFile[] {
if (ctx.state.animetoshoActiveTab === 'ja') {
return ctx.state.animetoshoFiles.filter(isJapaneseTrack);
}
return ctx.state.animetoshoFiles.filter(
(file) =>
!isJapaneseTrack(file) && animetoshoTrackMatchesLanguages(file.lang, secondaryLanguages),
);
}
function renderTabs(): void {
if (ctx.state.animetoshoActiveTab === 'ja') {
ctx.dom.animetoshoTabEnglishButton.classList.remove('active');
ctx.dom.animetoshoTabJapaneseButton.classList.add('active');
} else {
ctx.dom.animetoshoTabEnglishButton.classList.add('active');
ctx.dom.animetoshoTabJapaneseButton.classList.remove('active');
}
}
function describeEmptyTab(): string {
const hiddenCount = ctx.state.animetoshoFiles.length;
if (ctx.state.animetoshoActiveTab === 'ja') {
return hiddenCount > 0
? `No Japanese tracks in this release. Switch to the ${secondaryTabLabel()} tab.`
: 'No Japanese tracks in this release.';
}
return hiddenCount > 0
? `No ${secondaryTabLabel()} tracks in this release. Switch to the Japanese tab.`
: `No ${secondaryTabLabel()} tracks in this release.`;
}
function setActiveTab(tab: 'en' | 'ja'): void {
if (ctx.state.animetoshoActiveTab === tab) return;
ctx.state.animetoshoActiveTab = tab;
ctx.state.selectedAnimetoshoFileIndex = 0;
renderTabs();
if (ctx.state.animetoshoFiles.length === 0) return;
renderFiles();
if (getVisibleFiles().length === 0) {
setAnimetoshoStatus(describeEmptyTab());
} else {
setAnimetoshoStatus('Select a subtitle track.');
}
}
function formatBytes(size: number): string {
if (!Number.isFinite(size)) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let value = size;
let idx = 0;
while (value >= 1024 && idx < units.length - 1) {
value /= 1024;
idx += 1;
}
return `${value.toFixed(value >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`;
}
function renderEntries(): void {
ctx.dom.animetoshoEntriesList.innerHTML = '';
if (ctx.state.animetoshoEntries.length === 0) {
ctx.dom.animetoshoEntriesSection.classList.add('hidden');
return;
}
ctx.dom.animetoshoEntriesSection.classList.remove('hidden');
ctx.state.animetoshoEntries.forEach((entry, index) => {
const li = document.createElement('li');
li.textContent = entry.title;
const details: string[] = [];
if (entry.totalSize !== null) details.push(formatBytes(entry.totalSize));
if (entry.numFiles !== null) {
details.push(`${entry.numFiles} file${entry.numFiles === 1 ? '' : 's'}`);
}
if (details.length > 0) {
const sub = document.createElement('div');
sub.className = 'jimaku-subtext';
sub.textContent = details.join(' • ');
li.appendChild(sub);
}
if (index === ctx.state.selectedAnimetoshoEntryIndex) {
li.classList.add('active');
}
li.addEventListener('click', () => {
selectEntry(index);
});
ctx.dom.animetoshoEntriesList.appendChild(li);
});
}
function renderFiles(): void {
ctx.dom.animetoshoFilesList.innerHTML = '';
const visibleFiles = getVisibleFiles();
if (visibleFiles.length === 0) {
ctx.dom.animetoshoFilesSection.classList.add('hidden');
return;
}
ctx.dom.animetoshoFilesSection.classList.remove('hidden');
visibleFiles.forEach((file, index) => {
const li = document.createElement('li');
li.textContent = file.filename;
const details: string[] = [];
if (file.lang) details.push(file.lang);
if (file.trackName) details.push(file.trackName);
details.push(formatBytes(file.size));
const sub = document.createElement('div');
sub.className = 'jimaku-subtext';
sub.textContent = details.filter(Boolean).join(' • ');
li.appendChild(sub);
if (index === ctx.state.selectedAnimetoshoFileIndex) {
li.classList.add('active');
}
li.addEventListener('click', () => {
void selectFile(index);
});
ctx.dom.animetoshoFilesList.appendChild(li);
});
}
function getSearchQuery(): string {
const title = ctx.dom.animetoshoTitleInput.value.trim();
if (!title) return '';
const episodeValue = ctx.dom.animetoshoEpisodeInput.value
? Number.parseInt(ctx.dom.animetoshoEpisodeInput.value, 10)
: null;
if (episodeValue !== null && Number.isFinite(episodeValue)) {
return `${title} ${String(episodeValue).padStart(2, '0')}`;
}
return title;
}
async function performAnimetoshoSearch(): Promise<void> {
const query = getSearchQuery();
if (!query) {
setAnimetoshoStatus('Enter a title before searching.', true);
return;
}
resetAnimetoshoLists();
setAnimetoshoStatus('Searching Animetosho...');
const response: AnimetoshoApiResponse<AnimetoshoEntry[]> =
await window.electronAPI.animetoshoSearchEntries({ query });
if (!response.ok) {
setAnimetoshoStatus(response.error.error, true);
return;
}
ctx.state.animetoshoEntries = response.data;
ctx.state.selectedAnimetoshoEntryIndex = 0;
if (ctx.state.animetoshoEntries.length === 0) {
setAnimetoshoStatus('No releases found.');
return;
}
setAnimetoshoStatus('Select a release.');
renderEntries();
if (ctx.state.animetoshoEntries.length === 1) {
selectEntry(0);
}
}
async function loadFiles(entryId: number): Promise<void> {
setAnimetoshoStatus('Loading subtitle tracks...');
ctx.state.animetoshoFiles = [];
ctx.state.selectedAnimetoshoFileIndex = 0;
ctx.dom.animetoshoFilesList.innerHTML = '';
ctx.dom.animetoshoFilesSection.classList.add('hidden');
const response: AnimetoshoApiResponse<AnimetoshoSubtitleFile[]> =
await window.electronAPI.animetoshoListFiles({ entryId });
// The user may have picked another release while this was in flight.
if (ctx.state.currentAnimetoshoEntryId !== entryId) return;
if (!response.ok) {
setAnimetoshoStatus(response.error.error, true);
return;
}
ctx.state.animetoshoFiles = response.data;
if (ctx.state.animetoshoFiles.length === 0) {
const entry = ctx.state.animetoshoEntries.find((candidate) => candidate.id === entryId);
// The feed API omits per-file attachment data for multi-file torrents.
if (entry && entry.numFiles !== null && entry.numFiles > 1) {
setAnimetoshoStatus(
'Batch releases are not supported. Pick a single-episode release instead.',
);
} else {
setAnimetoshoStatus('No text subtitle tracks in this release. Try another one.');
}
return;
}
const visibleFiles = getVisibleFiles();
if (visibleFiles.length === 0) {
setAnimetoshoStatus(describeEmptyTab());
return;
}
setAnimetoshoStatus('Select a subtitle track.');
renderFiles();
if (visibleFiles.length === 1) {
await selectFile(0);
}
}
function selectEntry(index: number): void {
if (index < 0 || index >= ctx.state.animetoshoEntries.length) return;
ctx.state.selectedAnimetoshoEntryIndex = index;
ctx.state.currentAnimetoshoEntryId = ctx.state.animetoshoEntries[index]!.id;
renderEntries();
if (ctx.state.currentAnimetoshoEntryId !== null) {
void loadFiles(ctx.state.currentAnimetoshoEntryId);
}
}
async function selectFile(index: number): Promise<void> {
const visibleFiles = getVisibleFiles();
if (index < 0 || index >= visibleFiles.length) return;
ctx.state.selectedAnimetoshoFileIndex = index;
renderFiles();
if (ctx.state.currentAnimetoshoEntryId === null) {
setAnimetoshoStatus('Select a release first.', true);
return;
}
const file = visibleFiles[index]!;
setAnimetoshoStatus('Downloading subtitle...');
const result: AnimetoshoDownloadResult = await window.electronAPI.animetoshoDownloadFile({
entryId: ctx.state.currentAnimetoshoEntryId,
url: file.url,
name: file.filename,
lang: file.lang,
});
if (result.ok) {
setAnimetoshoStatus(`Downloaded and loaded: ${result.path}`);
closeAnimetoshoModal();
return;
}
setAnimetoshoStatus(result.error.error, true);
}
function isTextInputFocused(): boolean {
const active = document.activeElement;
if (!active) return false;
const tag = active.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea';
}
async function loadSecondaryLanguages(): Promise<void> {
try {
const languages = await window.electronAPI.animetoshoGetSecondaryLanguages();
secondaryLanguages = languages.length > 0 ? languages : ['en'];
} catch {
secondaryLanguages = ['en'];
}
ctx.dom.animetoshoTabEnglishButton.textContent = secondaryTabLabel();
// Tracks may already be on screen if the languages arrived late.
if (ctx.state.animetoshoFiles.length > 0) {
renderFiles();
}
}
function openAnimetoshoModal(): void {
if (ctx.state.animetoshoModalOpen) return;
ctx.state.animetoshoModalOpen = true;
ctx.state.animetoshoActiveTab = 'en';
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.animetoshoModal.classList.remove('hidden');
ctx.dom.animetoshoModal.setAttribute('aria-hidden', 'false');
setAnimetoshoStatus('Loading media info...');
resetAnimetoshoLists();
renderTabs();
const secondaryLanguagesReady = loadSecondaryLanguages();
window.electronAPI
.getJimakuMediaInfo()
.then(async (info: JimakuMediaInfo) => {
ctx.dom.animetoshoTitleInput.value = info.title || '';
ctx.dom.animetoshoEpisodeInput.value = info.episode ? String(info.episode) : '';
if (info.confidence === 'high' && info.title && info.episode) {
await secondaryLanguagesReady;
void performAnimetoshoSearch();
} else if (info.title) {
setAnimetoshoStatus('Check title/episode and press Search.');
} else {
setAnimetoshoStatus('Enter title/episode and press Search.');
}
})
.catch(() => {
setAnimetoshoStatus('Failed to load media info.', true);
});
}
function closeAnimetoshoModal(): void {
if (!ctx.state.animetoshoModalOpen) return;
ctx.state.animetoshoModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.animetoshoModal.classList.add('hidden');
ctx.dom.animetoshoModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('animetosho');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
resetAnimetoshoLists();
}
function handleAnimetoshoKeydown(e: KeyboardEvent): boolean {
if (e.key === 'Escape') {
e.preventDefault();
closeAnimetoshoModal();
return true;
}
if (isTextInputFocused()) {
if (e.key === 'Enter') {
e.preventDefault();
void performAnimetoshoSearch();
}
return true;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
setActiveTab('en');
return true;
}
if (e.key === 'ArrowRight') {
e.preventDefault();
setActiveTab('ja');
return true;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
const visibleFiles = getVisibleFiles();
if (visibleFiles.length > 0) {
ctx.state.selectedAnimetoshoFileIndex = Math.min(
visibleFiles.length - 1,
ctx.state.selectedAnimetoshoFileIndex + 1,
);
renderFiles();
} else if (ctx.state.animetoshoEntries.length > 0) {
ctx.state.selectedAnimetoshoEntryIndex = Math.min(
ctx.state.animetoshoEntries.length - 1,
ctx.state.selectedAnimetoshoEntryIndex + 1,
);
renderEntries();
}
return true;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (getVisibleFiles().length > 0) {
ctx.state.selectedAnimetoshoFileIndex = Math.max(
0,
ctx.state.selectedAnimetoshoFileIndex - 1,
);
renderFiles();
} else if (ctx.state.animetoshoEntries.length > 0) {
ctx.state.selectedAnimetoshoEntryIndex = Math.max(
0,
ctx.state.selectedAnimetoshoEntryIndex - 1,
);
renderEntries();
}
return true;
}
if (e.key === 'Enter') {
e.preventDefault();
if (getVisibleFiles().length > 0) {
void selectFile(ctx.state.selectedAnimetoshoFileIndex);
} else if (ctx.state.animetoshoEntries.length > 0) {
selectEntry(ctx.state.selectedAnimetoshoEntryIndex);
} else {
void performAnimetoshoSearch();
}
return true;
}
return true;
}
function wireDomEvents(): void {
ctx.dom.animetoshoSearchButton.addEventListener('click', () => {
void performAnimetoshoSearch();
});
ctx.dom.animetoshoCloseButton.addEventListener('click', () => {
closeAnimetoshoModal();
});
ctx.dom.animetoshoTabEnglishButton.addEventListener('click', () => {
setActiveTab('en');
});
ctx.dom.animetoshoTabJapaneseButton.addEventListener('click', () => {
setActiveTab('ja');
});
}
return {
closeAnimetoshoModal,
handleAnimetoshoKeydown,
openAnimetoshoModal,
selectAnimetoshoEntry: selectEntry,
wireDomEvents,
};
}
@@ -104,6 +104,7 @@ function describeCommand(command: (string | number)[]): string {
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
if (first === SPECIAL_COMMANDS.ANIMETOSHO_OPEN) return 'Open animetosho';
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
@@ -148,6 +149,7 @@ function sectionForCommand(command: (string | number)[]): string {
if (
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
first === SPECIAL_COMMANDS.JIMAKU_OPEN ||
first === SPECIAL_COMMANDS.ANIMETOSHO_OPEN ||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
) {
@@ -221,6 +223,8 @@ function describeSessionAction(
return 'Open controller debug';
case 'openJimaku':
return 'Open jimaku';
case 'openAnimetosho':
return 'Open animetosho';
case 'openYoutubePicker':
return 'Open YouTube subtitle picker';
case 'openPlaylistBrowser':
@@ -260,6 +264,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
return 'Subtitle sync';
case 'openRuntimeOptions':
case 'openJimaku':
case 'openAnimetosho':
case 'openCharacterDictionaryManager':
case 'openControllerSelect':
case 'openControllerDebug':
+17
View File
@@ -32,6 +32,7 @@ import { createControllerStatusIndicator } from './controller-status-indicator.j
import { createControllerDebugModal } from './modals/controller-debug.js';
import { createControllerSelectModal } from './modals/controller-select.js';
import { createJimakuModal } from './modals/jimaku.js';
import { createAnimetoshoModal } from './modals/animetosho.js';
import { createKikuModal } from './modals/kiku.js';
import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
@@ -93,6 +94,7 @@ function isAnyModalOpen(): boolean {
ctx.state.controllerSelectModalOpen ||
ctx.state.controllerDebugModalOpen ||
ctx.state.jimakuModalOpen ||
ctx.state.animetoshoModalOpen ||
ctx.state.kikuModalOpen ||
ctx.state.runtimeOptionsModalOpen ||
ctx.state.characterDictionaryModalOpen ||
@@ -172,6 +174,10 @@ const jimakuModal = createJimakuModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const animetoshoModal = createAnimetoshoModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const mouseHandlers = createMouseHandlers(ctx, {
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
applyYPercent: positioning.applyYPercent,
@@ -199,6 +205,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
handleKikuKeydown: kikuModal.handleKikuKeydown,
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
handleAnimetoshoKeydown: animetoshoModal.handleAnimetoshoKeydown,
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
@@ -272,6 +279,9 @@ function dismissActiveUiAfterError(): void {
if (ctx.state.jimakuModalOpen) {
jimakuModal.closeJimakuModal();
}
if (ctx.state.animetoshoModalOpen) {
animetoshoModal.closeAnimetoshoModal();
}
if (ctx.state.youtubePickerModalOpen) {
youtubePickerModal.closeYoutubePickerModal();
}
@@ -527,6 +537,12 @@ function registerModalOpenHandlers(): void {
window.electronAPI.notifyOverlayModalOpened('jimaku');
});
});
window.electronAPI.onOpenAnimetosho(() => {
runGuarded('animetosho:open', () => {
animetoshoModal.openAnimetoshoModal();
window.electronAPI.notifyOverlayModalOpened('animetosho');
});
});
window.electronAPI.onOpenYoutubeTrackPicker((payload) => {
runGuarded('youtube:picker-open', () => {
youtubePickerModal.openYoutubePickerModal(payload);
@@ -761,6 +777,7 @@ async function init(): Promise<void> {
});
jimakuModal.wireDomEvents();
animetoshoModal.wireDomEvents();
youtubePickerModal.wireDomEvents();
playlistBrowserModal.wireDomEvents();
kikuModal.wireDomEvents();
+18
View File
@@ -4,6 +4,8 @@ import type {
ControllerButtonSnapshot,
ControllerDeviceInfo,
ResolvedControllerConfig,
AnimetoshoEntry,
AnimetoshoSubtitleFile,
JimakuEntry,
JimakuFileEntry,
KikuDuplicateCardInfo,
@@ -48,6 +50,14 @@ export type RendererState = {
currentEpisodeFilter: number | null;
currentEntryId: number | null;
animetoshoModalOpen: boolean;
animetoshoActiveTab: 'en' | 'ja';
animetoshoEntries: AnimetoshoEntry[];
animetoshoFiles: AnimetoshoSubtitleFile[];
selectedAnimetoshoEntryIndex: number;
selectedAnimetoshoFileIndex: number;
currentAnimetoshoEntryId: number | null;
youtubePickerModalOpen: boolean;
youtubePickerPayload: YoutubePickerOpenPayload | null;
youtubePickerPrimaryTrackId: string | null;
@@ -164,6 +174,14 @@ export function createRendererState(): RendererState {
currentEpisodeFilter: null,
currentEntryId: null,
animetoshoModalOpen: false,
animetoshoActiveTab: 'en',
animetoshoEntries: [],
animetoshoFiles: [],
selectedAnimetoshoEntryIndex: 0,
selectedAnimetoshoFileIndex: 0,
currentAnimetoshoEntryId: null,
youtubePickerModalOpen: false,
youtubePickerPayload: null,
youtubePickerPrimaryTrackId: null,
+44
View File
@@ -811,6 +811,50 @@ body:focus-visible,
z-index: 1100;
}
#animetoshoModal {
z-index: 1100;
}
#animetoshoModal .jimaku-form {
grid-template-columns: 1fr 120px auto;
}
.animetosho-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.animetosho-tab {
min-width: 0;
min-height: 34px;
padding: 7px 8px;
border-radius: 7px;
border: 1px solid rgba(110, 115, 141, 0.22);
background: rgba(49, 50, 68, 0.76);
color: var(--ctp-subtext1);
font-size: 12px;
font-weight: 700;
line-height: 1.15;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.animetosho-tab:hover,
.animetosho-tab:focus-visible {
border-color: rgba(138, 173, 244, 0.48);
color: var(--ctp-text);
outline: none;
}
.animetosho-tab.active {
border-color: rgba(238, 212, 159, 0.62);
background: rgba(238, 212, 159, 0.16);
color: var(--ctp-yellow);
}
#youtubePickerModal {
z-index: 1110;
}
+26
View File
@@ -22,6 +22,19 @@ export type RendererDom = {
jimakuFilesList: HTMLUListElement;
jimakuBroadenButton: HTMLButtonElement;
animetoshoModal: HTMLDivElement;
animetoshoTitleInput: HTMLInputElement;
animetoshoEpisodeInput: HTMLInputElement;
animetoshoSearchButton: HTMLButtonElement;
animetoshoCloseButton: HTMLButtonElement;
animetoshoTabEnglishButton: HTMLButtonElement;
animetoshoTabJapaneseButton: HTMLButtonElement;
animetoshoStatus: HTMLDivElement;
animetoshoEntriesSection: HTMLDivElement;
animetoshoEntriesList: HTMLUListElement;
animetoshoFilesSection: HTMLDivElement;
animetoshoFilesList: HTMLUListElement;
youtubePickerModal: HTMLDivElement;
youtubePickerTitle: HTMLDivElement;
youtubePickerPrimarySelect: HTMLSelectElement;
@@ -154,6 +167,19 @@ export function resolveRendererDom(): RendererDom {
jimakuFilesList: getRequiredElement<HTMLUListElement>('jimakuFiles'),
jimakuBroadenButton: getRequiredElement<HTMLButtonElement>('jimakuBroaden'),
animetoshoModal: getRequiredElement<HTMLDivElement>('animetoshoModal'),
animetoshoTitleInput: getRequiredElement<HTMLInputElement>('animetoshoTitle'),
animetoshoEpisodeInput: getRequiredElement<HTMLInputElement>('animetoshoEpisode'),
animetoshoSearchButton: getRequiredElement<HTMLButtonElement>('animetoshoSearch'),
animetoshoCloseButton: getRequiredElement<HTMLButtonElement>('animetoshoClose'),
animetoshoTabEnglishButton: getRequiredElement<HTMLButtonElement>('animetoshoTabEnglish'),
animetoshoTabJapaneseButton: getRequiredElement<HTMLButtonElement>('animetoshoTabJapanese'),
animetoshoStatus: getRequiredElement<HTMLDivElement>('animetoshoStatus'),
animetoshoEntriesSection: getRequiredElement<HTMLDivElement>('animetoshoEntriesSection'),
animetoshoEntriesList: getRequiredElement<HTMLUListElement>('animetoshoEntries'),
animetoshoFilesSection: getRequiredElement<HTMLDivElement>('animetoshoFilesSection'),
animetoshoFilesList: getRequiredElement<HTMLUListElement>('animetoshoFiles'),
youtubePickerModal: getRequiredElement<HTMLDivElement>('youtubePickerModal'),
youtubePickerTitle: getRequiredElement<HTMLDivElement>('youtubePickerTitle'),
youtubePickerPrimarySelect: getRequiredElement<HTMLSelectElement>('youtubePickerPrimarySelect'),
+6
View File
@@ -5,6 +5,7 @@ export const OVERLAY_HOSTED_MODALS = [
'runtime-options',
'subsync',
'jimaku',
'animetosho',
'youtube-track-picker',
'playlist-browser',
'kiku',
@@ -95,6 +96,10 @@ export const IPC_CHANNELS = {
jimakuSearchEntries: 'jimaku:search-entries',
jimakuListFiles: 'jimaku:list-files',
jimakuDownloadFile: 'jimaku:download-file',
animetoshoSearchEntries: 'animetosho:search-entries',
animetoshoListFiles: 'animetosho:list-files',
animetoshoDownloadFile: 'animetosho:download-file',
animetoshoGetSecondaryLanguages: 'animetosho:get-secondary-languages',
kikuBuildMergePreview: 'kiku:build-merge-preview',
statsGetOverview: 'stats:get-overview',
statsGetDailyRollups: 'stats:get-daily-rollups',
@@ -134,6 +139,7 @@ export const IPC_CHANNELS = {
runtimeOptionsChanged: 'runtime-options:changed',
runtimeOptionsOpen: 'runtime-options:open',
jimakuOpen: 'jimaku:open',
animetoshoOpen: 'animetosho:open',
youtubePickerOpen: 'youtube:picker-open',
youtubePickerCancel: 'youtube:picker-cancel',
playlistBrowserOpen: 'playlist-browser:open',
+34
View File
@@ -1,5 +1,8 @@
import type { KikuFieldGroupingChoice, KikuMergePreviewRequest } from '../../types/anki';
import type {
AnimetoshoDownloadQuery,
AnimetoshoFilesQuery,
AnimetoshoSearchQuery,
JimakuDownloadQuery,
JimakuFilesQuery,
JimakuSearchQuery,
@@ -40,6 +43,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
'openControllerSelect',
'openControllerDebug',
'openJimaku',
'openAnimetosho',
'openYoutubePicker',
'openPlaylistBrowser',
'replayCurrentSubtitle',
@@ -402,6 +406,36 @@ export function parseJimakuDownloadQuery(value: unknown): JimakuDownloadQuery |
};
}
export function parseAnimetoshoSearchQuery(value: unknown): AnimetoshoSearchQuery | null {
if (!isObject(value) || typeof value.query !== 'string') return null;
return { query: value.query };
}
export function parseAnimetoshoFilesQuery(value: unknown): AnimetoshoFilesQuery | null {
if (!isObject(value) || !isInteger(value.entryId)) return null;
return { entryId: value.entryId };
}
export function parseAnimetoshoDownloadQuery(value: unknown): AnimetoshoDownloadQuery | null {
if (!isObject(value)) return null;
if (
!isInteger(value.entryId) ||
typeof value.url !== 'string' ||
typeof value.name !== 'string'
) {
return null;
}
if (value.lang !== undefined && typeof value.lang !== 'string') {
return null;
}
return {
entryId: value.entryId,
url: value.url,
name: value.name,
lang: value.lang,
};
}
export function parseYoutubePickerResolveRequest(
value: unknown,
): YoutubePickerResolveRequest | null {
+7
View File
@@ -10,6 +10,7 @@ import type {
ImmersionTrackingConfig,
ImmersionTrackingRetentionMode,
ImmersionTrackingRetentionPreset,
AnimetoshoConfig,
JellyfinConfig,
JimakuConfig,
JimakuLanguagePreference,
@@ -122,6 +123,7 @@ export interface ShortcutsConfig {
openCharacterDictionaryManager?: string | null;
openRuntimeOptions?: string | null;
openJimaku?: string | null;
openAnimetosho?: string | null;
openSessionHelp?: string | null;
openControllerSelect?: string | null;
openControllerDebug?: string | null;
@@ -147,6 +149,7 @@ export interface Config {
subtitleSidebar?: SubtitleSidebarConfig;
auto_start_overlay?: boolean;
jimaku?: JimakuConfig;
animetosho?: AnimetoshoConfig;
anilist?: AnilistConfig;
yomitan?: YomitanConfig;
jellyfin?: JellyfinConfig;
@@ -303,6 +306,10 @@ export interface ResolvedConfig {
languagePreference: JimakuLanguagePreference;
maxEntryResults: number;
};
animetosho: AnimetoshoConfig & {
apiBaseUrl: string;
maxSearchResults: number;
};
anilist: {
enabled: boolean;
accessToken: string;
+42
View File
@@ -240,3 +240,45 @@ export type JimakuApiResponse<T> = { ok: true; data: T } | { ok: false; error: J
export type JimakuDownloadResult =
| { ok: true; path: string }
| { ok: false; error: JimakuApiError };
export interface AnimetoshoSearchQuery {
query: string;
}
export interface AnimetoshoEntry {
id: number;
title: string;
timestamp: number | null;
totalSize: number | null;
numFiles: number | null;
}
export interface AnimetoshoFilesQuery {
entryId: number;
}
export interface AnimetoshoSubtitleFile {
attachmentId: number;
filename: string;
lang: string;
trackName: string | null;
size: number;
url: string;
sourceFilename: string;
}
export interface AnimetoshoDownloadQuery {
entryId: number;
url: string;
name: string;
lang?: string;
}
export type AnimetoshoApiResponse<T> = JimakuApiResponse<T>;
export type AnimetoshoDownloadResult = JimakuDownloadResult;
export interface AnimetoshoConfig {
apiBaseUrl?: string;
maxSearchResults?: number;
}
+18
View File
@@ -12,6 +12,13 @@ import type {
SessionBindingWarning,
} from './session-bindings';
import type {
AnimetoshoApiResponse,
AnimetoshoDownloadQuery,
AnimetoshoDownloadResult,
AnimetoshoEntry,
AnimetoshoFilesQuery,
AnimetoshoSearchQuery,
AnimetoshoSubtitleFile,
JimakuApiResponse,
JimakuDownloadQuery,
JimakuDownloadResult,
@@ -454,6 +461,14 @@ export interface ElectronAPI {
jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
jimakuListFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
jimakuDownloadFile: (query: JimakuDownloadQuery) => Promise<JimakuDownloadResult>;
animetoshoSearchEntries: (
query: AnimetoshoSearchQuery,
) => Promise<AnimetoshoApiResponse<AnimetoshoEntry[]>>;
animetoshoListFiles: (
query: AnimetoshoFilesQuery,
) => Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>>;
animetoshoDownloadFile: (query: AnimetoshoDownloadQuery) => Promise<AnimetoshoDownloadResult>;
animetoshoGetSecondaryLanguages: () => Promise<string[]>;
quitApp: () => void;
toggleDevTools: () => void;
toggleOverlay: () => void;
@@ -486,6 +501,7 @@ export interface ElectronAPI {
onOpenControllerSelect: (callback: () => void) => void;
onOpenControllerDebug: (callback: () => void) => void;
onOpenJimaku: (callback: () => void) => void;
onOpenAnimetosho: (callback: () => void) => void;
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
onOpenPlaylistBrowser: (callback: () => void) => void;
onOpenCharacterDictionaryManager: (callback: () => void) => void;
@@ -530,6 +546,7 @@ export interface ElectronAPI {
| 'runtime-options'
| 'subsync'
| 'jimaku'
| 'animetosho'
| 'youtube-track-picker'
| 'playlist-browser'
| 'kiku'
@@ -544,6 +561,7 @@ export interface ElectronAPI {
| 'runtime-options'
| 'subsync'
| 'jimaku'
| 'animetosho'
| 'youtube-track-picker'
| 'playlist-browser'
| 'kiku'
+1
View File
@@ -22,6 +22,7 @@ export type SessionActionId =
| 'openControllerSelect'
| 'openControllerDebug'
| 'openJimaku'
| 'openAnimetosho'
| 'openYoutubePicker'
| 'openPlaylistBrowser'
| 'replayCurrentSubtitle'