mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
refactor(tsukihime): rename animetosho integration to tsukihime
The animetosho backend was swapped for TsukiHime and never shipped in a release (the integration landed after v0.18.0), so no config migration or legacy aliases are needed. Renames the module, IPC channels, config key, session action, shortcut id, CLI flag, DOM ids, and CSS classes. "animetosho" is kept only where it names the upstream service: the imported-entry flag TsukiHime returns, the /tosho/ storage mirror path, and storage.animetosho.org, which that mirror currently redirects to. Also drops two stray NUL bytes that have sat inside the dedup-key template literals since the integration landed (#159). They were harmless (any separator groups correctly) but made grep treat the file as binary.
This commit is contained in:
@@ -1,45 +0,0 @@
|
||||
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');
|
||||
});
|
||||
@@ -115,7 +115,7 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
'--toggle-stats-overlay',
|
||||
'--mark-watched',
|
||||
'--open-jimaku',
|
||||
'--open-animetosho',
|
||||
'--open-tsukihime',
|
||||
'--open-youtube-picker',
|
||||
'--open-playlist-browser',
|
||||
'--toggle-primary-subtitle-bar',
|
||||
@@ -133,7 +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.openTsukihime, true);
|
||||
assert.equal(args.openYoutubePicker, true);
|
||||
assert.equal(args.openPlaylistBrowser, true);
|
||||
assert.equal(args.togglePrimarySubtitleBar, true);
|
||||
|
||||
+8
-8
@@ -37,7 +37,7 @@ export interface CliArgs {
|
||||
openControllerSelect: boolean;
|
||||
openControllerDebug: boolean;
|
||||
openJimaku: boolean;
|
||||
openAnimetosho: boolean;
|
||||
openTsukihime: boolean;
|
||||
openYoutubePicker: boolean;
|
||||
openPlaylistBrowser: boolean;
|
||||
replayCurrentSubtitle: boolean;
|
||||
@@ -146,7 +146,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openAnimetosho: false,
|
||||
openTsukihime: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
@@ -294,7 +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-tsukihime') args.openTsukihime = 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;
|
||||
@@ -567,7 +567,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openAnimetosho ||
|
||||
args.openTsukihime ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
@@ -645,7 +645,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.openControllerSelect &&
|
||||
!args.openControllerDebug &&
|
||||
!args.openJimaku &&
|
||||
!args.openAnimetosho &&
|
||||
!args.openTsukihime &&
|
||||
!args.openYoutubePicker &&
|
||||
!args.openPlaylistBrowser &&
|
||||
!args.replayCurrentSubtitle &&
|
||||
@@ -712,7 +712,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openAnimetosho ||
|
||||
args.openTsukihime ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
@@ -773,7 +773,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
!args.openControllerSelect &&
|
||||
!args.openControllerDebug &&
|
||||
!args.openJimaku &&
|
||||
!args.openAnimetosho &&
|
||||
!args.openTsukihime &&
|
||||
!args.openYoutubePicker &&
|
||||
!args.openPlaylistBrowser &&
|
||||
!args.replayCurrentSubtitle &&
|
||||
@@ -839,7 +839,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openAnimetosho ||
|
||||
args.openTsukihime ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
|
||||
@@ -40,7 +40,7 @@ const {
|
||||
const {
|
||||
ankiConnect,
|
||||
jimaku,
|
||||
animetosho,
|
||||
tsukihime,
|
||||
anilist,
|
||||
mpv,
|
||||
yomitan,
|
||||
@@ -73,7 +73,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitleSidebar,
|
||||
auto_start_overlay,
|
||||
jimaku,
|
||||
animetosho,
|
||||
tsukihime,
|
||||
anilist,
|
||||
mpv,
|
||||
yomitan,
|
||||
|
||||
@@ -98,7 +98,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
openCharacterDictionaryManager: 'CommandOrControl+D',
|
||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||
openJimaku: 'Ctrl+Shift+J',
|
||||
openAnimetosho: 'Ctrl+Shift+T',
|
||||
openTsukihime: 'Ctrl+Shift+T',
|
||||
openSessionHelp: 'CommandOrControl+Slash',
|
||||
openControllerSelect: 'Alt+C',
|
||||
openControllerDebug: 'Alt+Shift+C',
|
||||
|
||||
@@ -5,7 +5,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
ResolvedConfig,
|
||||
| 'ankiConnect'
|
||||
| 'jimaku'
|
||||
| 'animetosho'
|
||||
| 'tsukihime'
|
||||
| 'anilist'
|
||||
| 'mpv'
|
||||
| 'yomitan'
|
||||
@@ -97,7 +97,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
languagePreference: 'ja',
|
||||
maxEntryResults: 10,
|
||||
},
|
||||
animetosho: {
|
||||
tsukihime: {
|
||||
apiBaseUrl: 'https://api.tsukihime.org/v1',
|
||||
maxSearchResults: 10,
|
||||
},
|
||||
|
||||
@@ -616,9 +616,9 @@ export function buildCoreConfigOptionRegistry(
|
||||
description: 'Accelerator that opens the Jimaku subtitle search modal.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openAnimetosho',
|
||||
path: 'shortcuts.openTsukihime',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openAnimetosho,
|
||||
defaultValue: defaultConfig.shortcuts.openTsukihime,
|
||||
description:
|
||||
'Accelerator that opens the TsukiHime subtitle search modal (English/Japanese tabs).',
|
||||
},
|
||||
|
||||
@@ -401,16 +401,16 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description: 'Maximum Jimaku search results returned.',
|
||||
},
|
||||
{
|
||||
path: 'animetosho.apiBaseUrl',
|
||||
path: 'tsukihime.apiBaseUrl',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.animetosho.apiBaseUrl,
|
||||
defaultValue: defaultConfig.tsukihime.apiBaseUrl,
|
||||
description:
|
||||
'Base URL of the TsukiHime API (Animetosho successor). No API key required.',
|
||||
},
|
||||
{
|
||||
path: 'animetosho.maxSearchResults',
|
||||
path: 'tsukihime.maxSearchResults',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.animetosho.maxSearchResults,
|
||||
defaultValue: defaultConfig.tsukihime.maxSearchResults,
|
||||
description: 'Maximum TsukiHime search results returned.',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -53,7 +53,7 @@ export const SPECIAL_COMMANDS = {
|
||||
SUBSYNC_TRIGGER: '__subsync-trigger',
|
||||
RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
|
||||
JIMAKU_OPEN: '__jimaku-open',
|
||||
ANIMETOSHO_OPEN: '__animetosho-open',
|
||||
TSUKIHIME_OPEN: '__tsukihime-open',
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||
|
||||
@@ -151,10 +151,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
title: 'TsukiHime',
|
||||
description: [
|
||||
'TsukiHime subtitle search configuration (English and Japanese). No API key required.',
|
||||
'TsukiHime is the successor to Animetosho; the config key keeps the legacy name.',
|
||||
],
|
||||
notes: ['Hot-reload: TsukiHime changes apply to the next TsukiHime request.'],
|
||||
key: 'animetosho',
|
||||
key: 'tsukihime',
|
||||
},
|
||||
{
|
||||
title: 'YouTube Playback Settings',
|
||||
|
||||
@@ -236,7 +236,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'openCharacterDictionaryManager',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'openAnimetosho',
|
||||
'openTsukihime',
|
||||
'openSessionHelp',
|
||||
'openControllerSelect',
|
||||
'openControllerDebug',
|
||||
|
||||
@@ -80,18 +80,18 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.animetosho)) {
|
||||
const apiBaseUrl = asString(src.animetosho.apiBaseUrl);
|
||||
if (apiBaseUrl !== undefined) resolved.animetosho.apiBaseUrl = apiBaseUrl;
|
||||
if (isObject(src.tsukihime)) {
|
||||
const apiBaseUrl = asString(src.tsukihime.apiBaseUrl);
|
||||
if (apiBaseUrl !== undefined) resolved.tsukihime.apiBaseUrl = apiBaseUrl;
|
||||
|
||||
const maxSearchResults = asNumber(src.animetosho.maxSearchResults);
|
||||
const maxSearchResults = asNumber(src.tsukihime.maxSearchResults);
|
||||
if (maxSearchResults !== undefined && Math.floor(maxSearchResults) > 0) {
|
||||
resolved.animetosho.maxSearchResults = Math.floor(maxSearchResults);
|
||||
} else if (src.animetosho.maxSearchResults !== undefined) {
|
||||
resolved.tsukihime.maxSearchResults = Math.floor(maxSearchResults);
|
||||
} else if (src.tsukihime.maxSearchResults !== undefined) {
|
||||
warn(
|
||||
'animetosho.maxSearchResults',
|
||||
src.animetosho.maxSearchResults,
|
||||
resolved.animetosho.maxSearchResults,
|
||||
'tsukihime.maxSearchResults',
|
||||
src.tsukihime.maxSearchResults,
|
||||
resolved.tsukihime.maxSearchResults,
|
||||
'Expected positive number.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -594,7 +594,7 @@ function subsectionForPath(path: string): string | undefined {
|
||||
leaf === 'openCharacterDictionaryManager' ||
|
||||
leaf === 'openRuntimeOptions' ||
|
||||
leaf === 'openJimaku' ||
|
||||
leaf === 'openAnimetosho' ||
|
||||
leaf === 'openTsukihime' ||
|
||||
leaf === 'openSessionHelp' ||
|
||||
leaf === 'openControllerSelect' ||
|
||||
leaf === 'openControllerDebug'
|
||||
|
||||
@@ -55,10 +55,10 @@ 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'],
|
||||
searchTsukihimeEntries: async () => ({ ok: true, data: [] }),
|
||||
listTsukihimeFiles: async () => ({ ok: true, data: [] }),
|
||||
downloadTsukihimeSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
|
||||
getTsukihimeSecondaryLanguages: () => ['en'],
|
||||
onDownloadedSecondarySubtitle: () => {},
|
||||
},
|
||||
registrar,
|
||||
@@ -98,31 +98,31 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
|
||||
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, {
|
||||
const tsukihimeSearchHandler = handleHandlers.get(IPC_CHANNELS.request.tsukihimeSearchEntries);
|
||||
assert.ok(tsukihimeSearchHandler);
|
||||
const invalidTsukihimeSearch = await tsukihimeSearchHandler!({}, { query: 12 });
|
||||
assert.deepEqual(invalidTsukihimeSearch, {
|
||||
ok: false,
|
||||
error: { error: 'Invalid TsukiHime 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, {
|
||||
const tsukihimeFilesHandler = handleHandlers.get(IPC_CHANNELS.request.tsukihimeListFiles);
|
||||
assert.ok(tsukihimeFilesHandler);
|
||||
const invalidTsukihimeFiles = await tsukihimeFilesHandler!({}, { entryId: 'x' });
|
||||
assert.deepEqual(invalidTsukihimeFiles, {
|
||||
ok: false,
|
||||
error: { error: 'Invalid TsukiHime 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, {
|
||||
const tsukihimeDownloadHandler = handleHandlers.get(IPC_CHANNELS.request.tsukihimeDownloadFile);
|
||||
assert.ok(tsukihimeDownloadHandler);
|
||||
const invalidTsukihimeDownload = await tsukihimeDownloadHandler!({}, { entryId: 1, url: '/x' });
|
||||
assert.deepEqual(invalidTsukihimeDownload, {
|
||||
ok: false,
|
||||
error: { error: 'Invalid TsukiHime download query payload', code: 400 },
|
||||
});
|
||||
|
||||
const foreignUrlDownload = await animetoshoDownloadHandler!(
|
||||
const foreignUrlDownload = await tsukihimeDownloadHandler!(
|
||||
{},
|
||||
{ entryId: 1, url: 'https://evil.example/attach/00000001/1.xz', name: 'sub.ass' },
|
||||
);
|
||||
@@ -132,7 +132,7 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('animetosho downloads route by language: secondary for eng, primary for jpn', async () => {
|
||||
test('tsukihime downloads route by language: secondary for eng, primary for jpn', async () => {
|
||||
const { registrar, handleHandlers } = createFakeRegistrar();
|
||||
const primaryLoads: string[] = [];
|
||||
const secondaryLoads: string[] = [];
|
||||
@@ -160,10 +160,10 @@ test('animetosho downloads route by language: secondary for eng, primary for jpn
|
||||
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'],
|
||||
searchTsukihimeEntries: async () => ({ ok: true, data: [] }),
|
||||
listTsukihimeFiles: async () => ({ ok: true, data: [] }),
|
||||
downloadTsukihimeSubtitle: async (_url, destPath) => ({ ok: true, path: destPath }),
|
||||
getTsukihimeSecondaryLanguages: () => ['en'],
|
||||
onDownloadedSecondarySubtitle: (path) => {
|
||||
secondaryLoads.push(path);
|
||||
},
|
||||
@@ -171,7 +171,7 @@ test('animetosho downloads route by language: secondary for eng, primary for jpn
|
||||
registrar,
|
||||
);
|
||||
|
||||
const downloadHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoDownloadFile)!;
|
||||
const downloadHandler = handleHandlers.get(IPC_CHANNELS.request.tsukihimeDownloadFile)!;
|
||||
|
||||
const engResult = (await downloadHandler!(
|
||||
{},
|
||||
@@ -232,10 +232,10 @@ 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'],
|
||||
searchTsukihimeEntries: async () => ({ ok: true, data: [] }),
|
||||
listTsukihimeFiles: async () => ({ ok: true, data: [] }),
|
||||
downloadTsukihimeSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
|
||||
getTsukihimeSecondaryLanguages: () => ['en'],
|
||||
onDownloadedSecondarySubtitle: () => {},
|
||||
},
|
||||
registrar,
|
||||
|
||||
@@ -4,12 +4,12 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createLogger } from '../../logger';
|
||||
import {
|
||||
AnimetoshoApiResponse,
|
||||
AnimetoshoDownloadResult,
|
||||
AnimetoshoEntry,
|
||||
AnimetoshoFilesQuery,
|
||||
AnimetoshoSearchQuery,
|
||||
AnimetoshoSubtitleFile,
|
||||
TsukihimeApiResponse,
|
||||
TsukihimeDownloadResult,
|
||||
TsukihimeEntry,
|
||||
TsukihimeFilesQuery,
|
||||
TsukihimeSearchQuery,
|
||||
TsukihimeSubtitleFile,
|
||||
JimakuApiResponse,
|
||||
JimakuDownloadResult,
|
||||
JimakuEntry,
|
||||
@@ -23,9 +23,9 @@ import {
|
||||
} from '../../types';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
parseAnimetoshoDownloadQuery,
|
||||
parseAnimetoshoFilesQuery,
|
||||
parseAnimetoshoSearchQuery,
|
||||
parseTsukihimeDownloadQuery,
|
||||
parseTsukihimeFilesQuery,
|
||||
parseTsukihimeSearchQuery,
|
||||
parseJimakuDownloadQuery,
|
||||
parseJimakuFilesQuery,
|
||||
parseJimakuSearchQuery,
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
parseKikuMergePreviewRequest,
|
||||
} from '../../shared/ipc/validators';
|
||||
import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path';
|
||||
import { animetoshoLangToFilenameSuffix, isAnimetoshoDownloadUrl } from '../../animetosho/utils';
|
||||
import { tsukihimeLangToFilenameSuffix, isTsukihimeDownloadUrl } from '../../tsukihime/utils';
|
||||
|
||||
const { ipcMain } = electron;
|
||||
|
||||
@@ -57,14 +57,14 @@ 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[];
|
||||
searchTsukihimeEntries: (
|
||||
query: TsukihimeSearchQuery,
|
||||
) => Promise<TsukihimeApiResponse<TsukihimeEntry[]>>;
|
||||
listTsukihimeFiles: (
|
||||
query: TsukihimeFilesQuery,
|
||||
) => Promise<TsukihimeApiResponse<TsukihimeSubtitleFile[]>>;
|
||||
downloadTsukihimeSubtitle: (url: string, destPath: string) => Promise<TsukihimeDownloadResult>;
|
||||
getTsukihimeSecondaryLanguages: () => string[];
|
||||
onDownloadedSecondarySubtitle: (pathToSubtitle: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
@@ -206,39 +206,39 @@ export function registerAnkiJimakuIpcHandlers(
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.animetoshoGetSecondaryLanguages, (): string[] => {
|
||||
return deps.getAnimetoshoSecondaryLanguages();
|
||||
ipc.handle(IPC_CHANNELS.request.tsukihimeGetSecondaryLanguages, (): string[] => {
|
||||
return deps.getTsukihimeSecondaryLanguages();
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.animetoshoSearchEntries,
|
||||
async (_event, query: unknown): Promise<AnimetoshoApiResponse<AnimetoshoEntry[]>> => {
|
||||
const parsedQuery = parseAnimetoshoSearchQuery(query);
|
||||
IPC_CHANNELS.request.tsukihimeSearchEntries,
|
||||
async (_event, query: unknown): Promise<TsukihimeApiResponse<TsukihimeEntry[]>> => {
|
||||
const parsedQuery = parseTsukihimeSearchQuery(query);
|
||||
if (!parsedQuery) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { error: 'Invalid TsukiHime search query payload', code: 400 },
|
||||
};
|
||||
}
|
||||
return deps.searchAnimetoshoEntries(parsedQuery);
|
||||
return deps.searchTsukihimeEntries(parsedQuery);
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.animetoshoListFiles,
|
||||
async (_event, query: unknown): Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>> => {
|
||||
const parsedQuery = parseAnimetoshoFilesQuery(query);
|
||||
IPC_CHANNELS.request.tsukihimeListFiles,
|
||||
async (_event, query: unknown): Promise<TsukihimeApiResponse<TsukihimeSubtitleFile[]>> => {
|
||||
const parsedQuery = parseTsukihimeFilesQuery(query);
|
||||
if (!parsedQuery) {
|
||||
return { ok: false, error: { error: 'Invalid TsukiHime files query payload', code: 400 } };
|
||||
}
|
||||
return deps.listAnimetoshoFiles(parsedQuery);
|
||||
return deps.listTsukihimeFiles(parsedQuery);
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.animetoshoDownloadFile,
|
||||
async (_event, query: unknown): Promise<AnimetoshoDownloadResult> => {
|
||||
const parsedQuery = parseAnimetoshoDownloadQuery(query);
|
||||
IPC_CHANNELS.request.tsukihimeDownloadFile,
|
||||
async (_event, query: unknown): Promise<TsukihimeDownloadResult> => {
|
||||
const parsedQuery = parseTsukihimeDownloadQuery(query);
|
||||
if (!parsedQuery) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -246,7 +246,7 @@ export function registerAnkiJimakuIpcHandlers(
|
||||
};
|
||||
}
|
||||
|
||||
if (!isAnimetoshoDownloadUrl(parsedQuery.url)) {
|
||||
if (!isTsukihimeDownloadUrl(parsedQuery.url)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { error: 'Refusing to download subtitle from a non-TsukiHime URL.', code: 400 },
|
||||
@@ -259,13 +259,13 @@ export function registerAnkiJimakuIpcHandlers(
|
||||
}
|
||||
|
||||
const mediaDir = deps.isRemoteMediaPath(currentMediaPath)
|
||||
? fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-animetosho-'))
|
||||
? fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tsukihime-'))
|
||||
: path.dirname(path.resolve(currentMediaPath));
|
||||
const safeName = path.basename(parsedQuery.name);
|
||||
if (!safeName) {
|
||||
return { ok: false, error: { error: 'Invalid subtitle filename.' } };
|
||||
}
|
||||
const languageSuffix = animetoshoLangToFilenameSuffix(parsedQuery.lang);
|
||||
const languageSuffix = tsukihimeLangToFilenameSuffix(parsedQuery.lang);
|
||||
const subtitleFilename = buildJimakuSubtitleFilenameFromMediaPath(
|
||||
currentMediaPath,
|
||||
safeName,
|
||||
@@ -276,24 +276,24 @@ export function registerAnkiJimakuIpcHandlers(
|
||||
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}`);
|
||||
targetPath = path.join(mediaDir, `${baseName} (tsukihime-${parsedQuery.entryId})${ext}`);
|
||||
let counter = 2;
|
||||
while (fs.existsSync(targetPath)) {
|
||||
targetPath = path.join(
|
||||
mediaDir,
|
||||
`${baseName} (animetosho-${parsedQuery.entryId}-${counter})${ext}`,
|
||||
`${baseName} (tsukihime-${parsedQuery.entryId}-${counter})${ext}`,
|
||||
);
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[animetosho] download-file name="${parsedQuery.name}" entryId=${parsedQuery.entryId}`,
|
||||
`[tsukihime] download-file name="${parsedQuery.name}" entryId=${parsedQuery.entryId}`,
|
||||
);
|
||||
const result = await deps.downloadAnimetoshoSubtitle(parsedQuery.url, targetPath);
|
||||
const result = await deps.downloadTsukihimeSubtitle(parsedQuery.url, targetPath);
|
||||
|
||||
if (result.ok) {
|
||||
logger.info(`[animetosho] download-file saved to ${result.path}`);
|
||||
logger.info(`[tsukihime] download-file saved to ${result.path}`);
|
||||
// Japanese tracks take the primary slot; anything else loads as the
|
||||
// secondary subtitle so the Japanese primary stays in place.
|
||||
if (languageSuffix === 'ja') {
|
||||
@@ -303,7 +303,7 @@ export function registerAnkiJimakuIpcHandlers(
|
||||
}
|
||||
} else {
|
||||
logger.error(
|
||||
`[animetosho] download-file failed: ${result.error?.error ?? 'unknown error'}`,
|
||||
`[tsukihime] download-file failed: ${result.error?.error ?? 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ interface RuntimeHarness {
|
||||
patches: boolean[];
|
||||
broadcasts: number;
|
||||
fetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
||||
animetoshoFetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
||||
tsukihimeFetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
||||
sentCommands: Array<{ command: (string | number)[] }>;
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,7 @@ function createHarness(): RuntimeHarness {
|
||||
endpoint: string;
|
||||
query?: Record<string, unknown>;
|
||||
}>,
|
||||
animetoshoFetchCalls: [] as Array<{
|
||||
tsukihimeFetchCalls: [] as Array<{
|
||||
endpoint: string;
|
||||
query?: Record<string, unknown>;
|
||||
}>,
|
||||
@@ -37,10 +37,10 @@ function createHarness(): RuntimeHarness {
|
||||
patchAnkiConnectEnabled: (enabled) => {
|
||||
state.patches.push(enabled);
|
||||
},
|
||||
getResolvedConfig: () => ({ animetosho: { maxSearchResults: 2 } }),
|
||||
getResolvedConfig: () => ({ tsukihime: { maxSearchResults: 2 } }),
|
||||
getRuntimeOptionsManager: () => null,
|
||||
animetoshoFetchJson: async (endpoint, query) => {
|
||||
state.animetoshoFetchCalls.push({
|
||||
tsukihimeFetchJson: async (endpoint, query) => {
|
||||
state.tsukihimeFetchCalls.push({
|
||||
endpoint,
|
||||
query: query as Record<string, unknown>,
|
||||
});
|
||||
@@ -174,9 +174,9 @@ test('registerAnkiJimakuIpcRuntime provides full handler surface', () => {
|
||||
'isRemoteMediaPath',
|
||||
'downloadToFile',
|
||||
'onDownloadedSubtitle',
|
||||
'searchAnimetoshoEntries',
|
||||
'listAnimetoshoFiles',
|
||||
'downloadAnimetoshoSubtitle',
|
||||
'searchTsukihimeEntries',
|
||||
'listTsukihimeFiles',
|
||||
'downloadTsukihimeSubtitle',
|
||||
'onDownloadedSecondarySubtitle',
|
||||
];
|
||||
|
||||
@@ -367,11 +367,11 @@ test('onDownloadedSecondarySubtitle retries until mpv reports the new track', as
|
||||
});
|
||||
});
|
||||
|
||||
test('searchAnimetoshoEntries caps results using animetosho.maxSearchResults', async () => {
|
||||
test('searchTsukihimeEntries caps results using tsukihime.maxSearchResults', async () => {
|
||||
const { registered, state } = createHarness();
|
||||
|
||||
const searchResult = await registered.searchAnimetoshoEntries!({ query: 'frieren 28' });
|
||||
assert.deepEqual(state.animetoshoFetchCalls, [
|
||||
const searchResult = await registered.searchTsukihimeEntries!({ query: 'frieren 28' });
|
||||
assert.deepEqual(state.tsukihimeFetchCalls, [
|
||||
{
|
||||
endpoint: '/search/torrents',
|
||||
query: { q: 'frieren 28', limit: 2 },
|
||||
@@ -386,11 +386,11 @@ test('searchAnimetoshoEntries caps results using animetosho.maxSearchResults', a
|
||||
);
|
||||
});
|
||||
|
||||
test('listAnimetoshoFiles extracts subtitle attachments from torrent detail', async () => {
|
||||
test('listTsukihimeFiles extracts subtitle attachments from torrent detail', async () => {
|
||||
const { registered, state } = createHarness();
|
||||
|
||||
const filesResult = await registered.listAnimetoshoFiles!({ entryId: 606713 });
|
||||
assert.deepEqual(state.animetoshoFetchCalls, [
|
||||
const filesResult = await registered.listTsukihimeFiles!({ entryId: 606713 });
|
||||
assert.deepEqual(state.tsukihimeFetchCalls, [
|
||||
{
|
||||
endpoint: '/torrents/606713',
|
||||
query: {},
|
||||
|
||||
@@ -3,8 +3,8 @@ import { AnkiIntegration } from '../../anki-integration';
|
||||
import { mergeAiConfig } from '../../ai/config';
|
||||
import {
|
||||
AiConfig,
|
||||
AnimetoshoApiResponse,
|
||||
AnimetoshoConfig,
|
||||
TsukihimeApiResponse,
|
||||
TsukihimeConfig,
|
||||
AnkiConnectConfig,
|
||||
JimakuApiResponse,
|
||||
JimakuEntry,
|
||||
@@ -18,12 +18,12 @@ import {
|
||||
import { sortJimakuFiles } from '../../jimaku/utils';
|
||||
import {
|
||||
TSUKIHIME_API_BASE_URL,
|
||||
animetoshoFetchJson as animetoshoFetchJsonRequest,
|
||||
tsukihimeFetchJson as tsukihimeFetchJsonRequest,
|
||||
decompressXzFile,
|
||||
extractAnimetoshoSubtitleFiles,
|
||||
isAnimetoshoDownloadUrl,
|
||||
mapAnimetoshoSearchResults,
|
||||
} from '../../animetosho/utils';
|
||||
extractTsukihimeSubtitleFiles,
|
||||
isTsukihimeDownloadUrl,
|
||||
mapTsukihimeSearchResults,
|
||||
} from '../../tsukihime/utils';
|
||||
import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc';
|
||||
import { createLogger } from '../../logger';
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
getResolvedConfig: () => {
|
||||
ankiConnect?: AnkiConnectConfig;
|
||||
ai?: AiConfig;
|
||||
animetosho?: AnimetoshoConfig;
|
||||
tsukihime?: TsukihimeConfig;
|
||||
secondarySub?: { secondarySubLanguages?: string[] };
|
||||
};
|
||||
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
|
||||
@@ -77,10 +77,10 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
endpoint: string,
|
||||
query?: Record<string, string | number | boolean | null | undefined>,
|
||||
) => Promise<JimakuApiResponse<T>>;
|
||||
animetoshoFetchJson?: <T>(
|
||||
tsukihimeFetchJson?: <T>(
|
||||
endpoint: string,
|
||||
query?: Record<string, string | number | boolean | null | undefined>,
|
||||
) => Promise<AnimetoshoApiResponse<T>>;
|
||||
) => Promise<TsukihimeApiResponse<T>>;
|
||||
getJimakuMaxEntryResults: () => number;
|
||||
getJimakuLanguagePreference: () => JimakuLanguagePreference;
|
||||
resolveJimakuApiKey: () => Promise<string | null>;
|
||||
@@ -101,7 +101,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
|
||||
const logger = createLogger('main:anki-jimaku');
|
||||
|
||||
const DEFAULT_ANIMETOSHO_MAX_SEARCH_RESULTS = 10;
|
||||
const DEFAULT_TSUKIHIME_MAX_SEARCH_RESULTS = 10;
|
||||
const SECONDARY_TRACK_LOOKUP_ATTEMPTS = 5;
|
||||
const SECONDARY_TRACK_LOOKUP_RETRY_MS = 100;
|
||||
|
||||
@@ -109,24 +109,24 @@ function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function getAnimetoshoMaxSearchResults(options: AnkiJimakuIpcRuntimeOptions): number {
|
||||
const value = options.getResolvedConfig().animetosho?.maxSearchResults;
|
||||
function getTsukihimeMaxSearchResults(options: AnkiJimakuIpcRuntimeOptions): number {
|
||||
const value = options.getResolvedConfig().tsukihime?.maxSearchResults;
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
return DEFAULT_ANIMETOSHO_MAX_SEARCH_RESULTS;
|
||||
return DEFAULT_TSUKIHIME_MAX_SEARCH_RESULTS;
|
||||
}
|
||||
|
||||
function animetoshoFetch<T>(
|
||||
function tsukihimeFetch<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);
|
||||
): Promise<TsukihimeApiResponse<T>> {
|
||||
if (options.tsukihimeFetchJson) {
|
||||
return options.tsukihimeFetchJson<T>(endpoint, query);
|
||||
}
|
||||
const baseUrl = options.getResolvedConfig().animetosho?.apiBaseUrl || TSUKIHIME_API_BASE_URL;
|
||||
return animetoshoFetchJsonRequest<T>(endpoint, query, { baseUrl });
|
||||
const baseUrl = options.getResolvedConfig().tsukihime?.apiBaseUrl || TSUKIHIME_API_BASE_URL;
|
||||
return tsukihimeFetchJsonRequest<T>(endpoint, query, { baseUrl });
|
||||
}
|
||||
|
||||
export function registerAnkiJimakuIpcRuntime(
|
||||
@@ -242,41 +242,41 @@ export function registerAnkiJimakuIpcRuntime(
|
||||
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 maxResults = getAnimetoshoMaxSearchResults(options);
|
||||
const response = await animetoshoFetch<unknown>(options, '/search/torrents', {
|
||||
searchTsukihimeEntries: async (query) => {
|
||||
logger.info(`[tsukihime] search-entries query: "${query.query}"`);
|
||||
const maxResults = getTsukihimeMaxSearchResults(options);
|
||||
const response = await tsukihimeFetch<unknown>(options, '/search/torrents', {
|
||||
q: query.query,
|
||||
// The API caps limit at 100.
|
||||
limit: Math.min(maxResults, 100),
|
||||
});
|
||||
if (!response.ok) return response;
|
||||
const entries = mapAnimetoshoSearchResults(response.data, maxResults);
|
||||
logger.info(`[animetosho] search-entries returned ${entries.length} results`);
|
||||
const entries = mapTsukihimeSearchResults(response.data, maxResults);
|
||||
logger.info(`[tsukihime] 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>(
|
||||
listTsukihimeFiles: async (query) => {
|
||||
logger.info(`[tsukihime] list-files entryId=${query.entryId}`);
|
||||
const response = await tsukihimeFetch<unknown>(
|
||||
options,
|
||||
`/torrents/${encodeURIComponent(query.entryId)}`,
|
||||
{},
|
||||
);
|
||||
if (!response.ok) return response;
|
||||
const files = extractAnimetoshoSubtitleFiles(response.data);
|
||||
logger.info(`[animetosho] list-files returned ${files.length} subtitle attachments`);
|
||||
const files = extractTsukihimeSubtitleFiles(response.data);
|
||||
logger.info(`[tsukihime] list-files returned ${files.length} subtitle attachments`);
|
||||
return { ok: true, data: files };
|
||||
},
|
||||
getAnimetoshoSecondaryLanguages: () =>
|
||||
getTsukihimeSecondaryLanguages: () =>
|
||||
options.getResolvedConfig().secondarySub?.secondarySubLanguages ?? [],
|
||||
downloadAnimetoshoSubtitle: async (url, destPath) => {
|
||||
downloadTsukihimeSubtitle: 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) },
|
||||
// The /tosho/ mirror 302s to storage.animetosho.org; keep the hop in-allowlist.
|
||||
{ isAllowedRedirect: (redirectUrl) => isTsukihimeDownloadUrl(redirectUrl) },
|
||||
);
|
||||
if (!downloaded.ok) return downloaded;
|
||||
const result = await decompressXzFile(tempXzPath, destPath);
|
||||
@@ -312,14 +312,14 @@ export function registerAnkiJimakuIpcRuntime(
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[animetosho] failed to select downloaded subtitle as secondary:', error);
|
||||
logger.warn('[tsukihime] 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}`,
|
||||
`[tsukihime] could not find downloaded subtitle in track-list: ${pathToSubtitle}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openAnimetosho: false,
|
||||
openTsukihime: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
|
||||
@@ -44,7 +44,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openAnimetosho: false,
|
||||
openTsukihime: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
|
||||
@@ -539,11 +539,11 @@ export function handleCliCommand(
|
||||
);
|
||||
} else if (args.openJimaku) {
|
||||
dispatchCliSessionAction({ actionId: 'openJimaku' }, 'openJimaku', 'Open jimaku failed');
|
||||
} else if (args.openAnimetosho) {
|
||||
} else if (args.openTsukihime) {
|
||||
dispatchCliSessionAction(
|
||||
{ actionId: 'openAnimetosho' },
|
||||
'openAnimetosho',
|
||||
'Open animetosho failed',
|
||||
{ actionId: 'openTsukihime' },
|
||||
'openTsukihime',
|
||||
'Open tsukihime failed',
|
||||
);
|
||||
} else if (args.openYoutubePicker) {
|
||||
dispatchCliSessionAction(
|
||||
|
||||
@@ -12,7 +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',
|
||||
TSUKIHIME_OPEN: '__tsukihime-open',
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||
@@ -28,8 +28,8 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
openJimaku: () => {
|
||||
calls.push('jimaku');
|
||||
},
|
||||
openAnimetosho: () => {
|
||||
calls.push('animetosho');
|
||||
openTsukihime: () => {
|
||||
calls.push('tsukihime');
|
||||
},
|
||||
openYoutubeTrackPicker: () => {
|
||||
calls.push('youtube-picker');
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
SUBSYNC_TRIGGER: string;
|
||||
RUNTIME_OPTIONS_OPEN: string;
|
||||
JIMAKU_OPEN: string;
|
||||
ANIMETOSHO_OPEN: string;
|
||||
TSUKIHIME_OPEN: string;
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: string;
|
||||
REPLAY_SUBTITLE: string;
|
||||
PLAY_NEXT_SUBTITLE: string;
|
||||
@@ -20,7 +20,7 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
triggerSubsyncFromConfig: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openJimaku: () => void;
|
||||
openAnimetosho: () => void;
|
||||
openTsukihime: () => void;
|
||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||
openPlaylistBrowser: () => void | Promise<void>;
|
||||
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
@@ -114,8 +114,8 @@ export function handleMpvCommandFromIpc(
|
||||
return;
|
||||
}
|
||||
|
||||
if (first === options.specialCommands.ANIMETOSHO_OPEN) {
|
||||
options.openAnimetosho();
|
||||
if (first === options.specialCommands.TSUKIHIME_OPEN) {
|
||||
options.openTsukihime();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
|
||||
openCharacterDictionaryManager: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openAnimetosho: null,
|
||||
openTsukihime: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
@@ -55,8 +55,8 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
|
||||
openJimaku: () => {
|
||||
calls.push('openJimaku');
|
||||
},
|
||||
openAnimetosho: () => {
|
||||
calls.push('openAnimetosho');
|
||||
openTsukihime: () => {
|
||||
calls.push('openTsukihime');
|
||||
},
|
||||
markAudioCard: async () => {
|
||||
calls.push('markAudioCard');
|
||||
@@ -168,7 +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'),
|
||||
openTsukihime: () => handled.push('openTsukihime'),
|
||||
markAudioCard: () => handled.push('markAudioCard'),
|
||||
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||
copySubtitle: () => handled.push('copySubtitle'),
|
||||
@@ -202,7 +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'),
|
||||
openTsukihime: () => handled.push('openTsukihime'),
|
||||
markAudioCard: () => handled.push('markAudioCard'),
|
||||
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||
copySubtitle: () => handled.push('copySubtitle'),
|
||||
@@ -223,7 +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'),
|
||||
openTsukihime: () => handled.push('openTsukihime'),
|
||||
markAudioCard: () => handled.push('markAudioCard'),
|
||||
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||
copySubtitle: () => handled.push('copySubtitle'),
|
||||
@@ -261,7 +261,7 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
|
||||
openRuntimeOptions: () => {},
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openJimaku: () => {},
|
||||
openAnimetosho: () => {},
|
||||
openTsukihime: () => {},
|
||||
markAudioCard: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
copySubtitle: () => {},
|
||||
@@ -298,7 +298,7 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut',
|
||||
openRuntimeOptions: () => {},
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openJimaku: () => {},
|
||||
openAnimetosho: () => {},
|
||||
openTsukihime: () => {},
|
||||
markAudioCard: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
copySubtitle: () => {},
|
||||
@@ -331,7 +331,7 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', ()
|
||||
openJimaku: () => {
|
||||
called = true;
|
||||
},
|
||||
openAnimetosho: () => {
|
||||
openTsukihime: () => {
|
||||
called = true;
|
||||
},
|
||||
markAudioCard: () => {
|
||||
@@ -416,7 +416,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
openAnimetosho: () => {},
|
||||
openTsukihime: () => {},
|
||||
}),
|
||||
cancelPendingMultiCopy: () => {},
|
||||
cancelPendingMineSentenceMultiple: () => {},
|
||||
@@ -444,7 +444,7 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
openAnimetosho: () => {},
|
||||
openTsukihime: () => {},
|
||||
}),
|
||||
cancelPendingMultiCopy: () => {
|
||||
calls.push('cancel-multi-copy');
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface OverlayShortcutFallbackHandlers {
|
||||
openRuntimeOptions: () => void;
|
||||
openCharacterDictionaryManager: () => void;
|
||||
openJimaku: () => void;
|
||||
openAnimetosho: () => void;
|
||||
openTsukihime: () => void;
|
||||
markAudioCard: () => void;
|
||||
copySubtitleMultiple: (timeoutMs: number) => void;
|
||||
copySubtitle: () => void;
|
||||
@@ -25,7 +25,7 @@ export interface OverlayShortcutRuntimeDeps {
|
||||
openRuntimeOptions: () => void;
|
||||
openCharacterDictionaryManager: () => void;
|
||||
openJimaku: () => void;
|
||||
openAnimetosho: () => void;
|
||||
openTsukihime: () => void;
|
||||
markAudioCard: () => Promise<void>;
|
||||
copySubtitleMultiple: (timeoutMs: number) => void;
|
||||
copySubtitle: () => void;
|
||||
@@ -105,8 +105,8 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
|
||||
openJimaku: () => {
|
||||
deps.openJimaku();
|
||||
},
|
||||
openAnimetosho: () => {
|
||||
deps.openAnimetosho();
|
||||
openTsukihime: () => {
|
||||
deps.openTsukihime();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -114,7 +114,7 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
|
||||
openRuntimeOptions: overlayHandlers.openRuntimeOptions,
|
||||
openCharacterDictionaryManager: overlayHandlers.openCharacterDictionaryManager,
|
||||
openJimaku: overlayHandlers.openJimaku,
|
||||
openAnimetosho: overlayHandlers.openAnimetosho,
|
||||
openTsukihime: overlayHandlers.openTsukihime,
|
||||
markAudioCard: overlayHandlers.markAudioCard,
|
||||
copySubtitleMultiple: overlayHandlers.copySubtitleMultiple,
|
||||
copySubtitle: overlayHandlers.copySubtitle,
|
||||
@@ -160,9 +160,9 @@ export function runOverlayShortcutLocalFallback(
|
||||
allowWhenRegistered: true,
|
||||
},
|
||||
{
|
||||
accelerator: shortcuts.openAnimetosho,
|
||||
accelerator: shortcuts.openTsukihime,
|
||||
run: () => {
|
||||
handlers.openAnimetosho();
|
||||
handlers.openTsukihime();
|
||||
},
|
||||
allowWhenRegistered: true,
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openCharacterDictionaryManager: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openAnimetosho: null,
|
||||
openTsukihime: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
@@ -49,7 +49,7 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured'
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
openAnimetosho: () => {},
|
||||
openTsukihime: () => {},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -70,7 +70,7 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent'
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
openAnimetosho: () => {},
|
||||
openTsukihime: () => {},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
@@ -93,7 +93,7 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
openAnimetosho: () => {},
|
||||
openTsukihime: () => {},
|
||||
}),
|
||||
cancelPendingMultiCopy: () => {
|
||||
calls.push('cancel-multi-copy');
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface OverlayShortcutHandlers {
|
||||
openCharacterDictionaryManager: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJimaku: () => void;
|
||||
openAnimetosho: () => void;
|
||||
openTsukihime: () => void;
|
||||
}
|
||||
|
||||
export interface OverlayShortcutLifecycleDeps {
|
||||
@@ -36,7 +36,7 @@ const OVERLAY_SHORTCUT_KEYS: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTim
|
||||
'openCharacterDictionaryManager',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'openAnimetosho',
|
||||
'openTsukihime',
|
||||
];
|
||||
|
||||
function hasConfiguredOverlayShortcuts(shortcuts: ConfiguredShortcuts): boolean {
|
||||
|
||||
@@ -40,7 +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'),
|
||||
openTsukihime: () => calls.push('tsukihime'),
|
||||
openYoutubeTrackPicker: () => {
|
||||
calls.push('youtube');
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface SessionActionExecutorDeps {
|
||||
openControllerSelect: () => void;
|
||||
openControllerDebug: () => void;
|
||||
openJimaku: () => void;
|
||||
openAnimetosho: () => void;
|
||||
openTsukihime: () => void;
|
||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
|
||||
replayCurrentSubtitle: () => void;
|
||||
@@ -116,8 +116,8 @@ export async function dispatchSessionAction(
|
||||
case 'openJimaku':
|
||||
deps.openJimaku();
|
||||
return;
|
||||
case 'openAnimetosho':
|
||||
deps.openAnimetosho();
|
||||
case 'openTsukihime':
|
||||
deps.openTsukihime();
|
||||
return;
|
||||
case 'openYoutubePicker':
|
||||
await deps.openYoutubeTrackPicker();
|
||||
|
||||
@@ -22,7 +22,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openCharacterDictionaryManager: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openAnimetosho: null,
|
||||
openTsukihime: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
|
||||
@@ -55,7 +55,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
|
||||
{ key: 'openCharacterDictionaryManager', actionId: 'openCharacterDictionaryManager' },
|
||||
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
|
||||
{ key: 'openJimaku', actionId: 'openJimaku' },
|
||||
{ key: 'openAnimetosho', actionId: 'openAnimetosho' },
|
||||
{ key: 'openTsukihime', actionId: 'openTsukihime' },
|
||||
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
|
||||
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
|
||||
{ key: 'openControllerDebug', actionId: 'openControllerDebug' },
|
||||
@@ -305,9 +305,9 @@ function resolveCommandBinding(
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'openJimaku' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.ANIMETOSHO_OPEN) {
|
||||
if (first === SPECIAL_COMMANDS.TSUKIHIME_OPEN) {
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'openAnimetosho' };
|
||||
return { actionType: 'session-action', actionId: 'openTsukihime' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN) {
|
||||
if (command.length !== 1) return null;
|
||||
|
||||
@@ -39,7 +39,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openAnimetosho: false,
|
||||
openTsukihime: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface ConfiguredShortcuts {
|
||||
openCharacterDictionaryManager: string | null | undefined;
|
||||
openRuntimeOptions: string | null | undefined;
|
||||
openJimaku: string | null | undefined;
|
||||
openAnimetosho: string | null | undefined;
|
||||
openTsukihime: string | null | undefined;
|
||||
openSessionHelp: string | null | undefined;
|
||||
openControllerSelect: string | null | undefined;
|
||||
openControllerDebug: string | null | undefined;
|
||||
@@ -66,7 +66,7 @@ export function resolveConfiguredShortcuts(
|
||||
),
|
||||
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
|
||||
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
|
||||
openAnimetosho: normalizeShortcut(shortcutValue('openAnimetosho')),
|
||||
openTsukihime: normalizeShortcut(shortcutValue('openTsukihime')),
|
||||
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
|
||||
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
|
||||
openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')),
|
||||
|
||||
+7
-7
@@ -467,7 +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 { openTsukihimeModal as openTsukihimeModalRuntime } from './main/runtime/tsukihime-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';
|
||||
@@ -2061,8 +2061,8 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
|
||||
openJimaku: () => {
|
||||
openJimakuOverlay();
|
||||
},
|
||||
openAnimetosho: () => {
|
||||
openAnimetoshoOverlay();
|
||||
openTsukihime: () => {
|
||||
openTsukihimeOverlay();
|
||||
},
|
||||
markAudioCard: () => markLastCardAsAudioCard(),
|
||||
copySubtitleMultiple: (timeoutMs: number) => {
|
||||
@@ -2925,9 +2925,9 @@ function openJimakuOverlay(): void {
|
||||
);
|
||||
}
|
||||
|
||||
function openAnimetoshoOverlay(): void {
|
||||
function openTsukihimeOverlay(): void {
|
||||
openOverlayHostedModalWithOsd(
|
||||
openAnimetoshoModalRuntime,
|
||||
openTsukihimeModalRuntime,
|
||||
'TsukiHime overlay unavailable.',
|
||||
'Failed to open TsukiHime overlay.',
|
||||
);
|
||||
@@ -5448,7 +5448,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
},
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openJimaku: () => openJimakuOverlay(),
|
||||
openAnimetosho: () => openAnimetoshoOverlay(),
|
||||
openTsukihime: () => openTsukihimeOverlay(),
|
||||
openSessionHelp: () => openSessionHelpOverlay(),
|
||||
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
|
||||
openControllerSelect: () => openControllerSelectOverlay(),
|
||||
@@ -5482,7 +5482,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openJimaku: () => openJimakuOverlay(),
|
||||
openAnimetosho: () => openAnimetoshoOverlay(),
|
||||
openTsukihime: () => openTsukihimeOverlay(),
|
||||
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
||||
openPlaylistBrowser: () => openPlaylistBrowser(),
|
||||
cycleRuntimeOption: (id, direction) => {
|
||||
|
||||
@@ -228,7 +228,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
|
||||
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
|
||||
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
|
||||
openJimaku: HandleMpvCommandFromIpcOptions['openJimaku'];
|
||||
openAnimetosho: HandleMpvCommandFromIpcOptions['openAnimetosho'];
|
||||
openTsukihime: HandleMpvCommandFromIpcOptions['openTsukihime'];
|
||||
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
|
||||
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
|
||||
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
||||
@@ -435,7 +435,7 @@ export function createMpvCommandRuntimeServiceDeps(
|
||||
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
|
||||
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
|
||||
openJimaku: params.openJimaku,
|
||||
openAnimetosho: params.openAnimetosho,
|
||||
openTsukihime: params.openTsukihime,
|
||||
openYoutubeTrackPicker: params.openYoutubeTrackPicker,
|
||||
openPlaylistBrowser: params.openPlaylistBrowser,
|
||||
runtimeOptionsCycle: params.runtimeOptionsCycle,
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
|
||||
triggerSubsyncFromConfig: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openJimaku: () => void;
|
||||
openAnimetosho: () => void;
|
||||
openTsukihime: () => void;
|
||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||
openPlaylistBrowser: () => void | Promise<void>;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
@@ -39,7 +39,7 @@ export function handleMpvCommandFromIpcRuntime(
|
||||
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
openJimaku: deps.openJimaku,
|
||||
openAnimetosho: deps.openAnimetosho,
|
||||
openTsukihime: deps.openTsukihime,
|
||||
openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
|
||||
openPlaylistBrowser: deps.openPlaylistBrowser,
|
||||
runtimeOptionsCycle: deps.cycleRuntimeOption,
|
||||
|
||||
@@ -49,7 +49,7 @@ export interface OverlayModalRuntime {
|
||||
) => boolean;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openJimaku: () => void;
|
||||
openAnimetosho: () => void;
|
||||
openTsukihime: () => void;
|
||||
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
@@ -433,9 +433,9 @@ export function createOverlayModalRuntimeService(
|
||||
});
|
||||
};
|
||||
|
||||
const openAnimetosho = (): void => {
|
||||
sendToActiveOverlayWindow('animetosho:open', undefined, {
|
||||
restoreOnModalClose: 'animetosho',
|
||||
const openTsukihime = (): void => {
|
||||
sendToActiveOverlayWindow('tsukihime:open', undefined, {
|
||||
restoreOnModalClose: 'tsukihime',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -518,7 +518,7 @@ export function createOverlayModalRuntimeService(
|
||||
sendToActiveOverlayWindow,
|
||||
openRuntimeOptionsPalette,
|
||||
openJimaku,
|
||||
openAnimetosho,
|
||||
openTsukihime,
|
||||
handleOverlayModalClosed,
|
||||
notifyOverlayModalOpened,
|
||||
waitForModalOpen,
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface OverlayShortcutRuntimeServiceInput {
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openCharacterDictionaryManager: () => void;
|
||||
openJimaku: () => void;
|
||||
openAnimetosho: () => void;
|
||||
openTsukihime: () => void;
|
||||
markAudioCard: () => Promise<void>;
|
||||
copySubtitleMultiple: (timeoutMs: number) => void;
|
||||
copySubtitle: () => void;
|
||||
@@ -57,8 +57,8 @@ export function createOverlayShortcutsRuntimeService(
|
||||
openJimaku: () => {
|
||||
input.openJimaku();
|
||||
},
|
||||
openAnimetosho: () => {
|
||||
input.openAnimetosho();
|
||||
openTsukihime: () => {
|
||||
input.openTsukihime();
|
||||
},
|
||||
markAudioCard: () => {
|
||||
return input.markAudioCard();
|
||||
|
||||
@@ -11,7 +11,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openJimaku: () => {},
|
||||
openAnimetosho: () => {},
|
||||
openTsukihime: () => {},
|
||||
openYoutubeTrackPicker: () => {},
|
||||
openPlaylistBrowser: () => {},
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
|
||||
@@ -54,7 +54,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openAnimetosho: false,
|
||||
openTsukihime: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
|
||||
@@ -97,7 +97,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openAnimetosho ||
|
||||
args.openTsukihime ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
|
||||
@@ -19,7 +19,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
openCharacterDictionaryManager: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openAnimetosho: null,
|
||||
openTsukihime: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
|
||||
@@ -23,7 +23,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
openCharacterDictionaryManager: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openAnimetosho: null,
|
||||
openTsukihime: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
|
||||
@@ -14,7 +14,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openJimaku: () => {},
|
||||
openAnimetosho: () => {},
|
||||
openTsukihime: () => {},
|
||||
openYoutubeTrackPicker: () => {},
|
||||
openPlaylistBrowser: () => {},
|
||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||
|
||||
@@ -11,7 +11,7 @@ test('handle mpv command handler forwards command and built deps', () => {
|
||||
triggerSubsyncFromConfig: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openJimaku: () => {},
|
||||
openAnimetosho: () => {},
|
||||
openTsukihime: () => {},
|
||||
openYoutubeTrackPicker: () => {},
|
||||
openPlaylistBrowser: () => {},
|
||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||
|
||||
@@ -8,7 +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'),
|
||||
openTsukihime: () => calls.push('tsukihime'),
|
||||
openYoutubeTrackPicker: () => {
|
||||
calls.push('youtube-picker');
|
||||
},
|
||||
@@ -30,7 +30,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
deps.triggerSubsyncFromConfig();
|
||||
deps.openRuntimeOptionsPalette();
|
||||
deps.openJimaku();
|
||||
deps.openAnimetosho();
|
||||
deps.openTsukihime();
|
||||
void deps.openYoutubeTrackPicker();
|
||||
void deps.openPlaylistBrowser();
|
||||
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
|
||||
@@ -47,7 +47,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
'subsync',
|
||||
'palette',
|
||||
'jimaku',
|
||||
'animetosho',
|
||||
'tsukihime',
|
||||
'youtube-picker',
|
||||
'playlist-browser',
|
||||
'osd:hello',
|
||||
|
||||
@@ -10,7 +10,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
|
||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
openJimaku: () => deps.openJimaku(),
|
||||
openAnimetosho: () => deps.openAnimetosho(),
|
||||
openTsukihime: () => deps.openTsukihime(),
|
||||
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
|
||||
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
|
||||
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
|
||||
|
||||
@@ -18,7 +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'),
|
||||
openTsukihime: () => calls.push('tsukihime'),
|
||||
markAudioCard: async () => {
|
||||
calls.push('mark-audio');
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler(
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
openCharacterDictionaryManager: () => deps.openCharacterDictionaryManager(),
|
||||
openJimaku: () => deps.openJimaku(),
|
||||
openAnimetosho: () => deps.openAnimetosho(),
|
||||
openTsukihime: () => deps.openTsukihime(),
|
||||
markAudioCard: () => deps.markAudioCard(),
|
||||
copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs),
|
||||
copySubtitle: () => deps.copySubtitle(),
|
||||
|
||||
@@ -2,10 +2,10 @@ 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;
|
||||
const TSUKIHIME_MODAL: OverlayHostedModal = 'tsukihime';
|
||||
const TSUKIHIME_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export async function openAnimetoshoModal(deps: {
|
||||
export async function openTsukihimeModal(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
@@ -25,10 +25,10 @@ export async function openAnimetoshoModal(deps: {
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: ANIMETOSHO_MODAL,
|
||||
timeoutMs: ANIMETOSHO_OPEN_TIMEOUT_MS,
|
||||
modal: TSUKIHIME_MODAL,
|
||||
timeoutMs: TSUKIHIME_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Animetosho modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
'Tsukihime modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
@@ -38,8 +38,8 @@ export async function openAnimetoshoModal(deps: {
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.event.animetoshoOpen,
|
||||
modal: ANIMETOSHO_MODAL,
|
||||
channel: IPC_CHANNELS.event.tsukihimeOpen,
|
||||
modal: TSUKIHIME_MODAL,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
+21
-21
@@ -34,13 +34,13 @@ import type {
|
||||
JimakuFileEntry,
|
||||
JimakuApiResponse,
|
||||
JimakuDownloadResult,
|
||||
AnimetoshoSearchQuery,
|
||||
AnimetoshoFilesQuery,
|
||||
AnimetoshoDownloadQuery,
|
||||
AnimetoshoEntry,
|
||||
AnimetoshoSubtitleFile,
|
||||
AnimetoshoApiResponse,
|
||||
AnimetoshoDownloadResult,
|
||||
TsukihimeSearchQuery,
|
||||
TsukihimeFilesQuery,
|
||||
TsukihimeDownloadQuery,
|
||||
TsukihimeEntry,
|
||||
TsukihimeSubtitleFile,
|
||||
TsukihimeApiResponse,
|
||||
TsukihimeDownloadResult,
|
||||
SubsyncManualPayload,
|
||||
SubsyncManualRunRequest,
|
||||
SubsyncResult,
|
||||
@@ -174,7 +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 onOpenTsukihimeEvent = createQueuedIpcListener(IPC_CHANNELS.event.tsukihimeOpen);
|
||||
const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<YoutubePickerOpenPayload>(
|
||||
IPC_CHANNELS.event.youtubePickerOpen,
|
||||
(payload) => payload as YoutubePickerOpenPayload,
|
||||
@@ -358,18 +358,18 @@ 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),
|
||||
tsukihimeSearchEntries: (
|
||||
query: TsukihimeSearchQuery,
|
||||
): Promise<TsukihimeApiResponse<TsukihimeEntry[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.tsukihimeSearchEntries, query),
|
||||
tsukihimeListFiles: (
|
||||
query: TsukihimeFilesQuery,
|
||||
): Promise<TsukihimeApiResponse<TsukihimeSubtitleFile[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.tsukihimeListFiles, query),
|
||||
tsukihimeDownloadFile: (query: TsukihimeDownloadQuery): Promise<TsukihimeDownloadResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.tsukihimeDownloadFile, query),
|
||||
tsukihimeGetSecondaryLanguages: (): Promise<string[]> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.tsukihimeGetSecondaryLanguages),
|
||||
|
||||
quitApp: () => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.quitApp);
|
||||
@@ -450,7 +450,7 @@ const electronAPI: ElectronAPI = {
|
||||
onOpenControllerSelect: onOpenControllerSelectEvent,
|
||||
onOpenControllerDebug: onOpenControllerDebugEvent,
|
||||
onOpenJimaku: onOpenJimakuEvent,
|
||||
onOpenAnimetosho: onOpenAnimetoshoEvent,
|
||||
onOpenTsukihime: onOpenTsukihimeEvent,
|
||||
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
|
||||
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
|
||||
onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent,
|
||||
|
||||
@@ -90,7 +90,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
|
||||
openCharacterDictionaryManager: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openAnimetosho: null,
|
||||
openTsukihime: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
@@ -492,7 +492,7 @@ function createKeyboardHandlerHarness() {
|
||||
handleSubsyncKeydown: () => false,
|
||||
handleKikuKeydown: () => false,
|
||||
handleJimakuKeydown: () => false,
|
||||
handleAnimetoshoKeydown: () => false,
|
||||
handleTsukihimeKeydown: () => false,
|
||||
handleControllerSelectKeydown: () => {
|
||||
controllerSelectKeydownCount += 1;
|
||||
return true;
|
||||
|
||||
@@ -16,7 +16,7 @@ export function createKeyboardHandlers(
|
||||
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleAnimetoshoKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleTsukihimeKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
|
||||
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||
@@ -1120,8 +1120,8 @@ export function createKeyboardHandlers(
|
||||
options.handleJimakuKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.animetoshoModalOpen) {
|
||||
options.handleAnimetoshoKeydown(e);
|
||||
if (ctx.state.tsukihimeModalOpen) {
|
||||
options.handleTsukihimeKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.youtubePickerModalOpen) {
|
||||
|
||||
+13
-13
@@ -116,40 +116,40 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="animetoshoModal" class="modal hidden" aria-hidden="true">
|
||||
<div id="tsukihimeModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">TsukiHime Subtitles</div>
|
||||
<button id="animetoshoClose" class="modal-close" type="button">Close</button>
|
||||
<button id="tsukihimeClose" 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">
|
||||
<div class="tsukihime-tabs">
|
||||
<button id="tsukihimeTabEnglish" class="tsukihime-tab active" type="button">
|
||||
English
|
||||
</button>
|
||||
<button id="animetoshoTabJapanese" class="animetosho-tab" type="button">
|
||||
<button id="tsukihimeTabJapanese" class="tsukihime-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" />
|
||||
<input id="tsukihimeTitle" type="text" placeholder="Anime title" />
|
||||
</label>
|
||||
<label class="jimaku-field">
|
||||
<span>Episode</span>
|
||||
<input id="animetoshoEpisode" type="number" min="1" placeholder="1" />
|
||||
<input id="tsukihimeEpisode" type="number" min="1" placeholder="1" />
|
||||
</label>
|
||||
<button id="animetoshoSearch" class="jimaku-button" type="button">Search</button>
|
||||
<button id="tsukihimeSearch" class="jimaku-button" type="button">Search</button>
|
||||
</div>
|
||||
<div id="animetoshoStatus" class="jimaku-status"></div>
|
||||
<div id="animetoshoEntriesSection" class="jimaku-section hidden">
|
||||
<div id="tsukihimeStatus" class="jimaku-status"></div>
|
||||
<div id="tsukihimeEntriesSection" class="jimaku-section hidden">
|
||||
<div class="jimaku-section-title">Releases</div>
|
||||
<ul id="animetoshoEntries" class="jimaku-list"></ul>
|
||||
<ul id="tsukihimeEntries" class="jimaku-list"></ul>
|
||||
</div>
|
||||
<div id="animetoshoFilesSection" class="jimaku-section hidden">
|
||||
<div id="tsukihimeFilesSection" class="jimaku-section hidden">
|
||||
<div class="jimaku-section-title">Subtitle tracks</div>
|
||||
<ul id="animetoshoFiles" class="jimaku-list"></ul>
|
||||
<ul id="tsukihimeFiles" class="jimaku-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,489 +0,0 @@
|
||||
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 (entry.sublangs?.length) details.push(`subs: ${entry.sublangs.join(', ')}`);
|
||||
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);
|
||||
// TsukiHime does not report attachment sizes; skip a meaningless "0 B".
|
||||
if (file.size > 0) 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 TsukiHime...');
|
||||
|
||||
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,7 +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 TsukiHime';
|
||||
if (first === SPECIAL_COMMANDS.TSUKIHIME_OPEN) return 'Open TsukiHime';
|
||||
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';
|
||||
@@ -149,7 +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.TSUKIHIME_OPEN ||
|
||||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
|
||||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
|
||||
) {
|
||||
@@ -223,7 +223,7 @@ function describeSessionAction(
|
||||
return 'Open controller debug';
|
||||
case 'openJimaku':
|
||||
return 'Open jimaku';
|
||||
case 'openAnimetosho':
|
||||
case 'openTsukihime':
|
||||
return 'Open TsukiHime';
|
||||
case 'openYoutubePicker':
|
||||
return 'Open YouTube subtitle picker';
|
||||
@@ -264,7 +264,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
|
||||
return 'Subtitle sync';
|
||||
case 'openRuntimeOptions':
|
||||
case 'openJimaku':
|
||||
case 'openAnimetosho':
|
||||
case 'openTsukihime':
|
||||
case 'openCharacterDictionaryManager':
|
||||
case 'openControllerSelect':
|
||||
case 'openControllerDebug':
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AnimetoshoSubtitleFile, ElectronAPI } from '../../types';
|
||||
import type { TsukihimeSubtitleFile, ElectronAPI } from '../../types';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createAnimetoshoModal } from './animetosho.js';
|
||||
import { createTsukihimeModal } from './tsukihime.js';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
@@ -53,43 +53,43 @@ function flushAsyncWork(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const ENGLISH_TRACK: AnimetoshoSubtitleFile = {
|
||||
const ENGLISH_TRACK: TsukihimeSubtitleFile = {
|
||||
attachmentId: 1955356,
|
||||
filename: 'episode01.eng.ass',
|
||||
lang: 'eng',
|
||||
trackName: 'English subs',
|
||||
size: 33075,
|
||||
url: 'https://animetosho.org/storage/attach/001dd61c/1955356.xz',
|
||||
url: 'https://storage.tsukihime.org/attach/001dd61c/1955356.xz',
|
||||
sourceFilename: 'episode01.mkv',
|
||||
};
|
||||
|
||||
const JAPANESE_TRACK: AnimetoshoSubtitleFile = {
|
||||
const JAPANESE_TRACK: TsukihimeSubtitleFile = {
|
||||
attachmentId: 1955400,
|
||||
filename: 'episode01.jpn.ass',
|
||||
lang: 'jpn',
|
||||
trackName: 'Japanese subs',
|
||||
size: 41000,
|
||||
url: 'https://animetosho.org/storage/attach/001dd648/1955400.xz',
|
||||
url: 'https://storage.tsukihime.org/attach/001dd648/1955400.xz',
|
||||
sourceFilename: 'episode01.mkv',
|
||||
};
|
||||
|
||||
const GERMAN_TRACK: AnimetoshoSubtitleFile = {
|
||||
const GERMAN_TRACK: TsukihimeSubtitleFile = {
|
||||
attachmentId: 1955500,
|
||||
filename: 'episode01.ger.ass',
|
||||
lang: 'ger',
|
||||
trackName: 'Deutsch',
|
||||
size: 28000,
|
||||
url: 'https://animetosho.org/storage/attach/001dd6ac/1955500.xz',
|
||||
url: 'https://storage.tsukihime.org/attach/001dd6ac/1955500.xz',
|
||||
sourceFilename: 'episode01.mkv',
|
||||
};
|
||||
|
||||
interface ModalHarness {
|
||||
modal: ReturnType<typeof createAnimetoshoModal>;
|
||||
modal: ReturnType<typeof createTsukihimeModal>;
|
||||
state: ReturnType<typeof createRendererState>;
|
||||
downloadQueries: unknown[];
|
||||
modalCloseNotifications: string[];
|
||||
overlayClassList: ReturnType<typeof createClassList>;
|
||||
animetoshoModalClassList: ReturnType<typeof createClassList>;
|
||||
tsukihimeModalClassList: ReturnType<typeof createClassList>;
|
||||
titleInput: { value: string };
|
||||
status: { textContent: string; style: { color: string } };
|
||||
entriesList: ReturnType<typeof createListStub>;
|
||||
@@ -98,7 +98,7 @@ interface ModalHarness {
|
||||
}
|
||||
|
||||
function createModalHarness(
|
||||
files: AnimetoshoSubtitleFile[],
|
||||
files: TsukihimeSubtitleFile[],
|
||||
options: {
|
||||
secondaryLanguages?: string[];
|
||||
listFiles?: (entryId: number) => Promise<unknown>;
|
||||
@@ -115,14 +115,14 @@ function createModalHarness(
|
||||
const downloadQueries: unknown[] = [];
|
||||
|
||||
const electronAPI = {
|
||||
animetoshoDownloadFile: async (query: unknown) => {
|
||||
tsukihimeDownloadFile: 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 }) =>
|
||||
tsukihimeGetSecondaryLanguages: async () => options.secondaryLanguages ?? ['en', 'eng'],
|
||||
tsukihimeListFiles: async ({ entryId }: { entryId: number }) =>
|
||||
options.listFiles ? options.listFiles(entryId) : { ok: true, data: [] },
|
||||
animetoshoSearchEntries: async (query: unknown) =>
|
||||
tsukihimeSearchEntries: async (query: unknown) =>
|
||||
options.searchEntries ? options.searchEntries(query) : { ok: true, data: [] },
|
||||
getJimakuMediaInfo: async () => ({
|
||||
title: '',
|
||||
@@ -150,44 +150,44 @@ function createModalHarness(
|
||||
});
|
||||
|
||||
const overlayClassList = createClassList(['interactive']);
|
||||
const animetoshoModalClassList = createClassList();
|
||||
const tsukihimeModalClassList = createClassList();
|
||||
const state = createRendererState();
|
||||
state.animetoshoModalOpen = true;
|
||||
state.currentAnimetoshoEntryId = 606713;
|
||||
state.selectedAnimetoshoFileIndex = 0;
|
||||
state.animetoshoFiles = files;
|
||||
state.tsukihimeModalOpen = true;
|
||||
state.currentTsukihimeEntryId = 606713;
|
||||
state.selectedTsukihimeFileIndex = 0;
|
||||
state.tsukihimeFiles = files;
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: overlayClassList },
|
||||
animetoshoModal: {
|
||||
classList: animetoshoModalClassList,
|
||||
tsukihimeModal: {
|
||||
classList: tsukihimeModalClassList,
|
||||
setAttribute: () => {},
|
||||
},
|
||||
animetoshoTitleInput: { value: '' },
|
||||
animetoshoEpisodeInput: { value: '' },
|
||||
animetoshoSearchButton: { addEventListener: () => {} },
|
||||
animetoshoCloseButton: { addEventListener: () => {} },
|
||||
animetoshoTabEnglishButton: {
|
||||
tsukihimeTitleInput: { value: '' },
|
||||
tsukihimeEpisodeInput: { value: '' },
|
||||
tsukihimeSearchButton: { addEventListener: () => {} },
|
||||
tsukihimeCloseButton: { addEventListener: () => {} },
|
||||
tsukihimeTabEnglishButton: {
|
||||
textContent: 'English',
|
||||
classList: createClassList(['active']),
|
||||
addEventListener: () => {},
|
||||
},
|
||||
animetoshoTabJapaneseButton: {
|
||||
tsukihimeTabJapaneseButton: {
|
||||
textContent: 'Japanese',
|
||||
classList: createClassList(),
|
||||
addEventListener: () => {},
|
||||
},
|
||||
animetoshoStatus: { textContent: '', style: { color: '' } },
|
||||
animetoshoEntriesSection: { classList: createClassList(['hidden']) },
|
||||
animetoshoEntriesList: createListStub(),
|
||||
animetoshoFilesSection: { classList: createClassList() },
|
||||
animetoshoFilesList: createListStub(),
|
||||
tsukihimeStatus: { textContent: '', style: { color: '' } },
|
||||
tsukihimeEntriesSection: { classList: createClassList(['hidden']) },
|
||||
tsukihimeEntriesList: createListStub(),
|
||||
tsukihimeFilesSection: { classList: createClassList() },
|
||||
tsukihimeFilesList: createListStub(),
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createAnimetoshoModal(ctx as never, {
|
||||
const modal = createTsukihimeModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
@@ -198,11 +198,11 @@ function createModalHarness(
|
||||
downloadQueries,
|
||||
modalCloseNotifications,
|
||||
overlayClassList,
|
||||
animetoshoModalClassList,
|
||||
titleInput: ctx.dom.animetoshoTitleInput,
|
||||
status: ctx.dom.animetoshoStatus,
|
||||
entriesList: ctx.dom.animetoshoEntriesList,
|
||||
filesList: ctx.dom.animetoshoFilesList,
|
||||
tsukihimeModalClassList,
|
||||
titleInput: ctx.dom.tsukihimeTitleInput,
|
||||
status: ctx.dom.tsukihimeStatus,
|
||||
entriesList: ctx.dom.tsukihimeEntriesList,
|
||||
filesList: ctx.dom.tsukihimeFilesList,
|
||||
restoreGlobals: () => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
if (hadWindow) {
|
||||
@@ -224,7 +224,7 @@ function createModalHarness(
|
||||
|
||||
function pressKey(harness: ModalHarness, key: string): boolean {
|
||||
let prevented = false;
|
||||
harness.modal.handleAnimetoshoKeydown({
|
||||
harness.modal.handleTsukihimeKeydown({
|
||||
key,
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
@@ -233,17 +233,17 @@ function pressKey(harness: ModalHarness, key: string): boolean {
|
||||
return prevented;
|
||||
}
|
||||
|
||||
test('successful Animetosho subtitle selection closes modal', async () => {
|
||||
test('successful Tsukihime 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.state.tsukihimeModalOpen, false);
|
||||
assert.equal(harness.tsukihimeModalClassList.contains('hidden'), true);
|
||||
assert.equal(harness.overlayClassList.contains('interactive'), false);
|
||||
assert.deepEqual(harness.modalCloseNotifications, ['animetosho']);
|
||||
assert.deepEqual(harness.modalCloseNotifications, ['tsukihime']);
|
||||
assert.deepEqual(harness.downloadQueries, [
|
||||
{
|
||||
entryId: 606713,
|
||||
@@ -282,9 +282,9 @@ test('English tab hides non-English languages, not just Japanese', async () => {
|
||||
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');
|
||||
assert.equal(harness.state.tsukihimeActiveTab, 'en');
|
||||
pressKey(harness, 'ArrowRight');
|
||||
assert.equal(harness.state.animetoshoActiveTab, 'ja');
|
||||
assert.equal(harness.state.tsukihimeActiveTab, 'ja');
|
||||
|
||||
pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
@@ -308,12 +308,12 @@ test('secondary tab follows configured secondarySub languages', async () => {
|
||||
});
|
||||
try {
|
||||
// Re-open through the API so the modal fetches the configured languages.
|
||||
harness.state.animetoshoModalOpen = false;
|
||||
harness.modal.openAnimetoshoModal();
|
||||
harness.state.tsukihimeModalOpen = false;
|
||||
harness.modal.openTsukihimeModal();
|
||||
await flushAsyncWork();
|
||||
|
||||
harness.state.animetoshoFiles = [GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK];
|
||||
harness.state.currentAnimetoshoEntryId = 606713;
|
||||
harness.state.tsukihimeFiles = [GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK];
|
||||
harness.state.currentTsukihimeEntryId = 606713;
|
||||
|
||||
pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
@@ -332,12 +332,12 @@ test('secondary tab follows configured secondarySub languages', async () => {
|
||||
});
|
||||
|
||||
test('a slow release response does not overwrite the newly selected release', async () => {
|
||||
const STALE_TRACK: AnimetoshoSubtitleFile = {
|
||||
const STALE_TRACK: TsukihimeSubtitleFile = {
|
||||
...ENGLISH_TRACK,
|
||||
attachmentId: 999,
|
||||
filename: 'stale.eng.ass',
|
||||
};
|
||||
const SECOND_ENGLISH_TRACK: AnimetoshoSubtitleFile = {
|
||||
const SECOND_ENGLISH_TRACK: TsukihimeSubtitleFile = {
|
||||
...ENGLISH_TRACK,
|
||||
attachmentId: 1955357,
|
||||
filename: 'episode01.eng.sdh.ass',
|
||||
@@ -358,27 +358,27 @@ test('a slow release response does not overwrite the newly selected release', as
|
||||
});
|
||||
|
||||
try {
|
||||
harness.state.animetoshoEntries = [
|
||||
harness.state.tsukihimeEntries = [
|
||||
{ id: 1, title: 'slow release', timestamp: null, totalSize: null, numFiles: 1, sublangs: [] },
|
||||
{ id: 2, title: 'fast release', timestamp: null, totalSize: null, numFiles: 1, sublangs: [] },
|
||||
];
|
||||
|
||||
harness.modal.selectAnimetoshoEntry(0);
|
||||
harness.modal.selectAnimetoshoEntry(1);
|
||||
harness.modal.selectTsukihimeEntry(0);
|
||||
harness.modal.selectTsukihimeEntry(1);
|
||||
await flushAsyncWork();
|
||||
|
||||
// Entry 2's tracks are on screen; now entry 1 finally answers.
|
||||
assert.deepEqual(
|
||||
harness.state.animetoshoFiles.map((file) => file.attachmentId),
|
||||
harness.state.tsukihimeFiles.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.equal(harness.state.currentTsukihimeEntryId, 2);
|
||||
assert.deepEqual(
|
||||
harness.state.animetoshoFiles.map((file) => file.attachmentId),
|
||||
harness.state.tsukihimeFiles.map((file) => file.attachmentId),
|
||||
[ENGLISH_TRACK.attachmentId, SECOND_ENGLISH_TRACK.attachmentId],
|
||||
);
|
||||
} finally {
|
||||
@@ -390,9 +390,9 @@ 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');
|
||||
assert.equal(harness.state.tsukihimeActiveTab, 'ja');
|
||||
pressKey(harness, 'ArrowLeft');
|
||||
assert.equal(harness.state.animetoshoActiveTab, 'en');
|
||||
assert.equal(harness.state.tsukihimeActiveTab, 'en');
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
@@ -423,8 +423,8 @@ test('searching reports TsukiHime as the backend and lists sublangs per release'
|
||||
}),
|
||||
});
|
||||
try {
|
||||
harness.state.animetoshoFiles = [];
|
||||
harness.state.currentAnimetoshoEntryId = null;
|
||||
harness.state.tsukihimeFiles = [];
|
||||
harness.state.currentTsukihimeEntryId = null;
|
||||
harness.titleInput.value = 'Futsutsuka na Akujo';
|
||||
|
||||
const seenStatuses: string[] = [];
|
||||
@@ -444,7 +444,7 @@ test('searching reports TsukiHime as the backend and lists sublangs per release'
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
seenStatuses.some((message) => message.includes('Animetosho')),
|
||||
seenStatuses.some((message) => message.includes('Tsukihime')),
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -459,7 +459,7 @@ test('searching reports TsukiHime as the backend and lists sublangs per release'
|
||||
});
|
||||
|
||||
test('renderFiles omits the size detail when the API does not report one', () => {
|
||||
const zeroSizeTrack: AnimetoshoSubtitleFile = {
|
||||
const zeroSizeTrack: TsukihimeSubtitleFile = {
|
||||
...ENGLISH_TRACK,
|
||||
size: 0,
|
||||
};
|
||||
@@ -0,0 +1,489 @@
|
||||
import type {
|
||||
TsukihimeApiResponse,
|
||||
TsukihimeDownloadResult,
|
||||
TsukihimeEntry,
|
||||
TsukihimeSubtitleFile,
|
||||
JimakuMediaInfo,
|
||||
} from '../../types';
|
||||
import {
|
||||
tsukihimeTrackMatchesLanguages,
|
||||
describeTsukihimeTabLanguages,
|
||||
normalizeTsukihimeLangCode,
|
||||
} from '../../tsukihime/lang.js';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
export function createTsukihimeModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
function setTsukihimeStatus(message: string, isError = false): void {
|
||||
ctx.dom.tsukihimeStatus.textContent = message;
|
||||
ctx.dom.tsukihimeStatus.style.color = isError
|
||||
? 'rgba(255, 120, 120, 0.95)'
|
||||
: 'rgba(255, 255, 255, 0.8)';
|
||||
}
|
||||
|
||||
function resetTsukihimeLists(): void {
|
||||
ctx.state.tsukihimeEntries = [];
|
||||
ctx.state.tsukihimeFiles = [];
|
||||
ctx.state.selectedTsukihimeEntryIndex = 0;
|
||||
ctx.state.selectedTsukihimeFileIndex = 0;
|
||||
ctx.state.currentTsukihimeEntryId = null;
|
||||
|
||||
ctx.dom.tsukihimeEntriesList.innerHTML = '';
|
||||
ctx.dom.tsukihimeFilesList.innerHTML = '';
|
||||
ctx.dom.tsukihimeEntriesSection.classList.add('hidden');
|
||||
ctx.dom.tsukihimeFilesSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Defaults to English until the configured secondarySub languages arrive.
|
||||
let secondaryLanguages: string[] = ['en'];
|
||||
|
||||
function secondaryTabLabel(): string {
|
||||
return describeTsukihimeTabLanguages(secondaryLanguages);
|
||||
}
|
||||
|
||||
function isJapaneseTrack(file: TsukihimeSubtitleFile): boolean {
|
||||
return normalizeTsukihimeLangCode(file.lang) === 'ja';
|
||||
}
|
||||
|
||||
function getVisibleFiles(): TsukihimeSubtitleFile[] {
|
||||
if (ctx.state.tsukihimeActiveTab === 'ja') {
|
||||
return ctx.state.tsukihimeFiles.filter(isJapaneseTrack);
|
||||
}
|
||||
return ctx.state.tsukihimeFiles.filter(
|
||||
(file) =>
|
||||
!isJapaneseTrack(file) && tsukihimeTrackMatchesLanguages(file.lang, secondaryLanguages),
|
||||
);
|
||||
}
|
||||
|
||||
function renderTabs(): void {
|
||||
if (ctx.state.tsukihimeActiveTab === 'ja') {
|
||||
ctx.dom.tsukihimeTabEnglishButton.classList.remove('active');
|
||||
ctx.dom.tsukihimeTabJapaneseButton.classList.add('active');
|
||||
} else {
|
||||
ctx.dom.tsukihimeTabEnglishButton.classList.add('active');
|
||||
ctx.dom.tsukihimeTabJapaneseButton.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function describeEmptyTab(): string {
|
||||
const hiddenCount = ctx.state.tsukihimeFiles.length;
|
||||
if (ctx.state.tsukihimeActiveTab === '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.tsukihimeActiveTab === tab) return;
|
||||
ctx.state.tsukihimeActiveTab = tab;
|
||||
ctx.state.selectedTsukihimeFileIndex = 0;
|
||||
renderTabs();
|
||||
|
||||
if (ctx.state.tsukihimeFiles.length === 0) return;
|
||||
renderFiles();
|
||||
if (getVisibleFiles().length === 0) {
|
||||
setTsukihimeStatus(describeEmptyTab());
|
||||
} else {
|
||||
setTsukihimeStatus('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.tsukihimeEntriesList.innerHTML = '';
|
||||
if (ctx.state.tsukihimeEntries.length === 0) {
|
||||
ctx.dom.tsukihimeEntriesSection.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.tsukihimeEntriesSection.classList.remove('hidden');
|
||||
ctx.state.tsukihimeEntries.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 (entry.sublangs?.length) details.push(`subs: ${entry.sublangs.join(', ')}`);
|
||||
if (details.length > 0) {
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'jimaku-subtext';
|
||||
sub.textContent = details.join(' • ');
|
||||
li.appendChild(sub);
|
||||
}
|
||||
|
||||
if (index === ctx.state.selectedTsukihimeEntryIndex) {
|
||||
li.classList.add('active');
|
||||
}
|
||||
|
||||
li.addEventListener('click', () => {
|
||||
selectEntry(index);
|
||||
});
|
||||
|
||||
ctx.dom.tsukihimeEntriesList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderFiles(): void {
|
||||
ctx.dom.tsukihimeFilesList.innerHTML = '';
|
||||
const visibleFiles = getVisibleFiles();
|
||||
if (visibleFiles.length === 0) {
|
||||
ctx.dom.tsukihimeFilesSection.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.tsukihimeFilesSection.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);
|
||||
// TsukiHime does not report attachment sizes; skip a meaningless "0 B".
|
||||
if (file.size > 0) 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.selectedTsukihimeFileIndex) {
|
||||
li.classList.add('active');
|
||||
}
|
||||
|
||||
li.addEventListener('click', () => {
|
||||
void selectFile(index);
|
||||
});
|
||||
|
||||
ctx.dom.tsukihimeFilesList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function getSearchQuery(): string {
|
||||
const title = ctx.dom.tsukihimeTitleInput.value.trim();
|
||||
if (!title) return '';
|
||||
const episodeValue = ctx.dom.tsukihimeEpisodeInput.value
|
||||
? Number.parseInt(ctx.dom.tsukihimeEpisodeInput.value, 10)
|
||||
: null;
|
||||
if (episodeValue !== null && Number.isFinite(episodeValue)) {
|
||||
return `${title} ${String(episodeValue).padStart(2, '0')}`;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
async function performTsukihimeSearch(): Promise<void> {
|
||||
const query = getSearchQuery();
|
||||
if (!query) {
|
||||
setTsukihimeStatus('Enter a title before searching.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
resetTsukihimeLists();
|
||||
setTsukihimeStatus('Searching TsukiHime...');
|
||||
|
||||
const response: TsukihimeApiResponse<TsukihimeEntry[]> =
|
||||
await window.electronAPI.tsukihimeSearchEntries({ query });
|
||||
if (!response.ok) {
|
||||
setTsukihimeStatus(response.error.error, true);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.tsukihimeEntries = response.data;
|
||||
ctx.state.selectedTsukihimeEntryIndex = 0;
|
||||
|
||||
if (ctx.state.tsukihimeEntries.length === 0) {
|
||||
setTsukihimeStatus('No releases found.');
|
||||
return;
|
||||
}
|
||||
|
||||
setTsukihimeStatus('Select a release.');
|
||||
renderEntries();
|
||||
if (ctx.state.tsukihimeEntries.length === 1) {
|
||||
selectEntry(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFiles(entryId: number): Promise<void> {
|
||||
setTsukihimeStatus('Loading subtitle tracks...');
|
||||
ctx.state.tsukihimeFiles = [];
|
||||
ctx.state.selectedTsukihimeFileIndex = 0;
|
||||
|
||||
ctx.dom.tsukihimeFilesList.innerHTML = '';
|
||||
ctx.dom.tsukihimeFilesSection.classList.add('hidden');
|
||||
|
||||
const response: TsukihimeApiResponse<TsukihimeSubtitleFile[]> =
|
||||
await window.electronAPI.tsukihimeListFiles({ entryId });
|
||||
// The user may have picked another release while this was in flight.
|
||||
if (ctx.state.currentTsukihimeEntryId !== entryId) return;
|
||||
if (!response.ok) {
|
||||
setTsukihimeStatus(response.error.error, true);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.tsukihimeFiles = response.data;
|
||||
if (ctx.state.tsukihimeFiles.length === 0) {
|
||||
const entry = ctx.state.tsukihimeEntries.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) {
|
||||
setTsukihimeStatus(
|
||||
'Batch releases are not supported. Pick a single-episode release instead.',
|
||||
);
|
||||
} else {
|
||||
setTsukihimeStatus('No text subtitle tracks in this release. Try another one.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleFiles = getVisibleFiles();
|
||||
if (visibleFiles.length === 0) {
|
||||
setTsukihimeStatus(describeEmptyTab());
|
||||
return;
|
||||
}
|
||||
|
||||
setTsukihimeStatus('Select a subtitle track.');
|
||||
renderFiles();
|
||||
if (visibleFiles.length === 1) {
|
||||
await selectFile(0);
|
||||
}
|
||||
}
|
||||
|
||||
function selectEntry(index: number): void {
|
||||
if (index < 0 || index >= ctx.state.tsukihimeEntries.length) return;
|
||||
|
||||
ctx.state.selectedTsukihimeEntryIndex = index;
|
||||
ctx.state.currentTsukihimeEntryId = ctx.state.tsukihimeEntries[index]!.id;
|
||||
renderEntries();
|
||||
|
||||
if (ctx.state.currentTsukihimeEntryId !== null) {
|
||||
void loadFiles(ctx.state.currentTsukihimeEntryId);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFile(index: number): Promise<void> {
|
||||
const visibleFiles = getVisibleFiles();
|
||||
if (index < 0 || index >= visibleFiles.length) return;
|
||||
|
||||
ctx.state.selectedTsukihimeFileIndex = index;
|
||||
renderFiles();
|
||||
|
||||
if (ctx.state.currentTsukihimeEntryId === null) {
|
||||
setTsukihimeStatus('Select a release first.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = visibleFiles[index]!;
|
||||
setTsukihimeStatus('Downloading subtitle...');
|
||||
|
||||
const result: TsukihimeDownloadResult = await window.electronAPI.tsukihimeDownloadFile({
|
||||
entryId: ctx.state.currentTsukihimeEntryId,
|
||||
url: file.url,
|
||||
name: file.filename,
|
||||
lang: file.lang,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
setTsukihimeStatus(`Downloaded and loaded: ${result.path}`);
|
||||
closeTsukihimeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
setTsukihimeStatus(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.tsukihimeGetSecondaryLanguages();
|
||||
secondaryLanguages = languages.length > 0 ? languages : ['en'];
|
||||
} catch {
|
||||
secondaryLanguages = ['en'];
|
||||
}
|
||||
ctx.dom.tsukihimeTabEnglishButton.textContent = secondaryTabLabel();
|
||||
// Tracks may already be on screen if the languages arrived late.
|
||||
if (ctx.state.tsukihimeFiles.length > 0) {
|
||||
renderFiles();
|
||||
}
|
||||
}
|
||||
|
||||
function openTsukihimeModal(): void {
|
||||
if (ctx.state.tsukihimeModalOpen) return;
|
||||
|
||||
ctx.state.tsukihimeModalOpen = true;
|
||||
ctx.state.tsukihimeActiveTab = 'en';
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.tsukihimeModal.classList.remove('hidden');
|
||||
ctx.dom.tsukihimeModal.setAttribute('aria-hidden', 'false');
|
||||
|
||||
setTsukihimeStatus('Loading media info...');
|
||||
resetTsukihimeLists();
|
||||
renderTabs();
|
||||
|
||||
const secondaryLanguagesReady = loadSecondaryLanguages();
|
||||
|
||||
window.electronAPI
|
||||
.getJimakuMediaInfo()
|
||||
.then(async (info: JimakuMediaInfo) => {
|
||||
ctx.dom.tsukihimeTitleInput.value = info.title || '';
|
||||
ctx.dom.tsukihimeEpisodeInput.value = info.episode ? String(info.episode) : '';
|
||||
|
||||
if (info.confidence === 'high' && info.title && info.episode) {
|
||||
await secondaryLanguagesReady;
|
||||
void performTsukihimeSearch();
|
||||
} else if (info.title) {
|
||||
setTsukihimeStatus('Check title/episode and press Search.');
|
||||
} else {
|
||||
setTsukihimeStatus('Enter title/episode and press Search.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setTsukihimeStatus('Failed to load media info.', true);
|
||||
});
|
||||
}
|
||||
|
||||
function closeTsukihimeModal(): void {
|
||||
if (!ctx.state.tsukihimeModalOpen) return;
|
||||
|
||||
ctx.state.tsukihimeModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.tsukihimeModal.classList.add('hidden');
|
||||
ctx.dom.tsukihimeModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('tsukihime');
|
||||
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
|
||||
resetTsukihimeLists();
|
||||
}
|
||||
|
||||
function handleTsukihimeKeydown(e: KeyboardEvent): boolean {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeTsukihimeModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTextInputFocused()) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void performTsukihimeSearch();
|
||||
}
|
||||
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.selectedTsukihimeFileIndex = Math.min(
|
||||
visibleFiles.length - 1,
|
||||
ctx.state.selectedTsukihimeFileIndex + 1,
|
||||
);
|
||||
renderFiles();
|
||||
} else if (ctx.state.tsukihimeEntries.length > 0) {
|
||||
ctx.state.selectedTsukihimeEntryIndex = Math.min(
|
||||
ctx.state.tsukihimeEntries.length - 1,
|
||||
ctx.state.selectedTsukihimeEntryIndex + 1,
|
||||
);
|
||||
renderEntries();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (getVisibleFiles().length > 0) {
|
||||
ctx.state.selectedTsukihimeFileIndex = Math.max(
|
||||
0,
|
||||
ctx.state.selectedTsukihimeFileIndex - 1,
|
||||
);
|
||||
renderFiles();
|
||||
} else if (ctx.state.tsukihimeEntries.length > 0) {
|
||||
ctx.state.selectedTsukihimeEntryIndex = Math.max(
|
||||
0,
|
||||
ctx.state.selectedTsukihimeEntryIndex - 1,
|
||||
);
|
||||
renderEntries();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (getVisibleFiles().length > 0) {
|
||||
void selectFile(ctx.state.selectedTsukihimeFileIndex);
|
||||
} else if (ctx.state.tsukihimeEntries.length > 0) {
|
||||
selectEntry(ctx.state.selectedTsukihimeEntryIndex);
|
||||
} else {
|
||||
void performTsukihimeSearch();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.tsukihimeSearchButton.addEventListener('click', () => {
|
||||
void performTsukihimeSearch();
|
||||
});
|
||||
ctx.dom.tsukihimeCloseButton.addEventListener('click', () => {
|
||||
closeTsukihimeModal();
|
||||
});
|
||||
ctx.dom.tsukihimeTabEnglishButton.addEventListener('click', () => {
|
||||
setActiveTab('en');
|
||||
});
|
||||
ctx.dom.tsukihimeTabJapaneseButton.addEventListener('click', () => {
|
||||
setActiveTab('ja');
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeTsukihimeModal,
|
||||
handleTsukihimeKeydown,
|
||||
openTsukihimeModal,
|
||||
selectTsukihimeEntry: selectEntry,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
+11
-11
@@ -32,7 +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 { createTsukihimeModal } from './modals/tsukihime.js';
|
||||
import { createKikuModal } from './modals/kiku.js';
|
||||
import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
|
||||
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
|
||||
@@ -94,7 +94,7 @@ function isAnyModalOpen(): boolean {
|
||||
ctx.state.controllerSelectModalOpen ||
|
||||
ctx.state.controllerDebugModalOpen ||
|
||||
ctx.state.jimakuModalOpen ||
|
||||
ctx.state.animetoshoModalOpen ||
|
||||
ctx.state.tsukihimeModalOpen ||
|
||||
ctx.state.kikuModalOpen ||
|
||||
ctx.state.runtimeOptionsModalOpen ||
|
||||
ctx.state.characterDictionaryModalOpen ||
|
||||
@@ -174,7 +174,7 @@ const jimakuModal = createJimakuModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const animetoshoModal = createAnimetoshoModal(ctx, {
|
||||
const tsukihimeModal = createTsukihimeModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
@@ -205,7 +205,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||
handleAnimetoshoKeydown: animetoshoModal.handleAnimetoshoKeydown,
|
||||
handleTsukihimeKeydown: tsukihimeModal.handleTsukihimeKeydown,
|
||||
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
|
||||
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
|
||||
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
||||
@@ -279,8 +279,8 @@ function dismissActiveUiAfterError(): void {
|
||||
if (ctx.state.jimakuModalOpen) {
|
||||
jimakuModal.closeJimakuModal();
|
||||
}
|
||||
if (ctx.state.animetoshoModalOpen) {
|
||||
animetoshoModal.closeAnimetoshoModal();
|
||||
if (ctx.state.tsukihimeModalOpen) {
|
||||
tsukihimeModal.closeTsukihimeModal();
|
||||
}
|
||||
if (ctx.state.youtubePickerModalOpen) {
|
||||
youtubePickerModal.closeYoutubePickerModal();
|
||||
@@ -537,10 +537,10 @@ function registerModalOpenHandlers(): void {
|
||||
window.electronAPI.notifyOverlayModalOpened('jimaku');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenAnimetosho(() => {
|
||||
runGuarded('animetosho:open', () => {
|
||||
animetoshoModal.openAnimetoshoModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('animetosho');
|
||||
window.electronAPI.onOpenTsukihime(() => {
|
||||
runGuarded('tsukihime:open', () => {
|
||||
tsukihimeModal.openTsukihimeModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('tsukihime');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenYoutubeTrackPicker((payload) => {
|
||||
@@ -777,7 +777,7 @@ async function init(): Promise<void> {
|
||||
});
|
||||
|
||||
jimakuModal.wireDomEvents();
|
||||
animetoshoModal.wireDomEvents();
|
||||
tsukihimeModal.wireDomEvents();
|
||||
youtubePickerModal.wireDomEvents();
|
||||
playlistBrowserModal.wireDomEvents();
|
||||
kikuModal.wireDomEvents();
|
||||
|
||||
+16
-16
@@ -4,8 +4,8 @@ import type {
|
||||
ControllerButtonSnapshot,
|
||||
ControllerDeviceInfo,
|
||||
ResolvedControllerConfig,
|
||||
AnimetoshoEntry,
|
||||
AnimetoshoSubtitleFile,
|
||||
TsukihimeEntry,
|
||||
TsukihimeSubtitleFile,
|
||||
JimakuEntry,
|
||||
JimakuFileEntry,
|
||||
KikuDuplicateCardInfo,
|
||||
@@ -50,13 +50,13 @@ 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;
|
||||
tsukihimeModalOpen: boolean;
|
||||
tsukihimeActiveTab: 'en' | 'ja';
|
||||
tsukihimeEntries: TsukihimeEntry[];
|
||||
tsukihimeFiles: TsukihimeSubtitleFile[];
|
||||
selectedTsukihimeEntryIndex: number;
|
||||
selectedTsukihimeFileIndex: number;
|
||||
currentTsukihimeEntryId: number | null;
|
||||
|
||||
youtubePickerModalOpen: boolean;
|
||||
youtubePickerPayload: YoutubePickerOpenPayload | null;
|
||||
@@ -174,13 +174,13 @@ export function createRendererState(): RendererState {
|
||||
currentEpisodeFilter: null,
|
||||
currentEntryId: null,
|
||||
|
||||
animetoshoModalOpen: false,
|
||||
animetoshoActiveTab: 'en',
|
||||
animetoshoEntries: [],
|
||||
animetoshoFiles: [],
|
||||
selectedAnimetoshoEntryIndex: 0,
|
||||
selectedAnimetoshoFileIndex: 0,
|
||||
currentAnimetoshoEntryId: null,
|
||||
tsukihimeModalOpen: false,
|
||||
tsukihimeActiveTab: 'en',
|
||||
tsukihimeEntries: [],
|
||||
tsukihimeFiles: [],
|
||||
selectedTsukihimeEntryIndex: 0,
|
||||
selectedTsukihimeFileIndex: 0,
|
||||
currentTsukihimeEntryId: null,
|
||||
|
||||
youtubePickerModalOpen: false,
|
||||
youtubePickerPayload: null,
|
||||
|
||||
@@ -811,21 +811,21 @@ body:focus-visible,
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
#animetoshoModal {
|
||||
#tsukihimeModal {
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
#animetoshoModal .jimaku-form {
|
||||
#tsukihimeModal .jimaku-form {
|
||||
grid-template-columns: 1fr 120px auto;
|
||||
}
|
||||
|
||||
.animetosho-tabs {
|
||||
.tsukihime-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.animetosho-tab {
|
||||
.tsukihime-tab {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 7px 8px;
|
||||
@@ -842,14 +842,14 @@ body:focus-visible,
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.animetosho-tab:hover,
|
||||
.animetosho-tab:focus-visible {
|
||||
.tsukihime-tab:hover,
|
||||
.tsukihime-tab:focus-visible {
|
||||
border-color: rgba(138, 173, 244, 0.48);
|
||||
color: var(--ctp-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.animetosho-tab.active {
|
||||
.tsukihime-tab.active {
|
||||
border-color: rgba(238, 212, 159, 0.62);
|
||||
background: rgba(238, 212, 159, 0.16);
|
||||
color: var(--ctp-yellow);
|
||||
|
||||
+24
-24
@@ -22,18 +22,18 @@ 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;
|
||||
tsukihimeModal: HTMLDivElement;
|
||||
tsukihimeTitleInput: HTMLInputElement;
|
||||
tsukihimeEpisodeInput: HTMLInputElement;
|
||||
tsukihimeSearchButton: HTMLButtonElement;
|
||||
tsukihimeCloseButton: HTMLButtonElement;
|
||||
tsukihimeTabEnglishButton: HTMLButtonElement;
|
||||
tsukihimeTabJapaneseButton: HTMLButtonElement;
|
||||
tsukihimeStatus: HTMLDivElement;
|
||||
tsukihimeEntriesSection: HTMLDivElement;
|
||||
tsukihimeEntriesList: HTMLUListElement;
|
||||
tsukihimeFilesSection: HTMLDivElement;
|
||||
tsukihimeFilesList: HTMLUListElement;
|
||||
|
||||
youtubePickerModal: HTMLDivElement;
|
||||
youtubePickerTitle: HTMLDivElement;
|
||||
@@ -167,18 +167,18 @@ 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'),
|
||||
tsukihimeModal: getRequiredElement<HTMLDivElement>('tsukihimeModal'),
|
||||
tsukihimeTitleInput: getRequiredElement<HTMLInputElement>('tsukihimeTitle'),
|
||||
tsukihimeEpisodeInput: getRequiredElement<HTMLInputElement>('tsukihimeEpisode'),
|
||||
tsukihimeSearchButton: getRequiredElement<HTMLButtonElement>('tsukihimeSearch'),
|
||||
tsukihimeCloseButton: getRequiredElement<HTMLButtonElement>('tsukihimeClose'),
|
||||
tsukihimeTabEnglishButton: getRequiredElement<HTMLButtonElement>('tsukihimeTabEnglish'),
|
||||
tsukihimeTabJapaneseButton: getRequiredElement<HTMLButtonElement>('tsukihimeTabJapanese'),
|
||||
tsukihimeStatus: getRequiredElement<HTMLDivElement>('tsukihimeStatus'),
|
||||
tsukihimeEntriesSection: getRequiredElement<HTMLDivElement>('tsukihimeEntriesSection'),
|
||||
tsukihimeEntriesList: getRequiredElement<HTMLUListElement>('tsukihimeEntries'),
|
||||
tsukihimeFilesSection: getRequiredElement<HTMLDivElement>('tsukihimeFilesSection'),
|
||||
tsukihimeFilesList: getRequiredElement<HTMLUListElement>('tsukihimeFiles'),
|
||||
|
||||
youtubePickerModal: getRequiredElement<HTMLDivElement>('youtubePickerModal'),
|
||||
youtubePickerTitle: getRequiredElement<HTMLDivElement>('youtubePickerTitle'),
|
||||
|
||||
@@ -5,7 +5,7 @@ export const OVERLAY_HOSTED_MODALS = [
|
||||
'runtime-options',
|
||||
'subsync',
|
||||
'jimaku',
|
||||
'animetosho',
|
||||
'tsukihime',
|
||||
'youtube-track-picker',
|
||||
'playlist-browser',
|
||||
'kiku',
|
||||
@@ -96,10 +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',
|
||||
tsukihimeSearchEntries: 'tsukihime:search-entries',
|
||||
tsukihimeListFiles: 'tsukihime:list-files',
|
||||
tsukihimeDownloadFile: 'tsukihime:download-file',
|
||||
tsukihimeGetSecondaryLanguages: 'tsukihime:get-secondary-languages',
|
||||
kikuBuildMergePreview: 'kiku:build-merge-preview',
|
||||
statsGetOverview: 'stats:get-overview',
|
||||
statsGetDailyRollups: 'stats:get-daily-rollups',
|
||||
@@ -139,7 +139,7 @@ export const IPC_CHANNELS = {
|
||||
runtimeOptionsChanged: 'runtime-options:changed',
|
||||
runtimeOptionsOpen: 'runtime-options:open',
|
||||
jimakuOpen: 'jimaku:open',
|
||||
animetoshoOpen: 'animetosho:open',
|
||||
tsukihimeOpen: 'tsukihime:open',
|
||||
youtubePickerOpen: 'youtube:picker-open',
|
||||
youtubePickerCancel: 'youtube:picker-cancel',
|
||||
playlistBrowserOpen: 'playlist-browser:open',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { KikuFieldGroupingChoice, KikuMergePreviewRequest } from '../../types/anki';
|
||||
import type {
|
||||
AnimetoshoDownloadQuery,
|
||||
AnimetoshoFilesQuery,
|
||||
AnimetoshoSearchQuery,
|
||||
TsukihimeDownloadQuery,
|
||||
TsukihimeFilesQuery,
|
||||
TsukihimeSearchQuery,
|
||||
JimakuDownloadQuery,
|
||||
JimakuFilesQuery,
|
||||
JimakuSearchQuery,
|
||||
@@ -43,7 +43,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
'openControllerSelect',
|
||||
'openControllerDebug',
|
||||
'openJimaku',
|
||||
'openAnimetosho',
|
||||
'openTsukihime',
|
||||
'openYoutubePicker',
|
||||
'openPlaylistBrowser',
|
||||
'replayCurrentSubtitle',
|
||||
@@ -406,17 +406,17 @@ export function parseJimakuDownloadQuery(value: unknown): JimakuDownloadQuery |
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAnimetoshoSearchQuery(value: unknown): AnimetoshoSearchQuery | null {
|
||||
export function parseTsukihimeSearchQuery(value: unknown): TsukihimeSearchQuery | null {
|
||||
if (!isObject(value) || typeof value.query !== 'string') return null;
|
||||
return { query: value.query };
|
||||
}
|
||||
|
||||
export function parseAnimetoshoFilesQuery(value: unknown): AnimetoshoFilesQuery | null {
|
||||
export function parseTsukihimeFilesQuery(value: unknown): TsukihimeFilesQuery | null {
|
||||
if (!isObject(value) || !isInteger(value.entryId)) return null;
|
||||
return { entryId: value.entryId };
|
||||
}
|
||||
|
||||
export function parseAnimetoshoDownloadQuery(value: unknown): AnimetoshoDownloadQuery | null {
|
||||
export function parseTsukihimeDownloadQuery(value: unknown): TsukihimeDownloadQuery | null {
|
||||
if (!isObject(value)) return null;
|
||||
if (
|
||||
!isInteger(value.entryId) ||
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from 'node:test';
|
||||
import * as http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
import { animetoshoFetchJson } from './utils.js';
|
||||
import { tsukihimeFetchJson } from './utils.js';
|
||||
|
||||
interface TestServer {
|
||||
baseUrl: string;
|
||||
@@ -27,7 +27,7 @@ function startServer(handler: http.RequestListener): Promise<TestServer> {
|
||||
});
|
||||
}
|
||||
|
||||
test('animetoshoFetchJson gives up on a hanging response', async () => {
|
||||
test('tsukihimeFetchJson gives up on a hanging response', async () => {
|
||||
const server = await startServer((_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
// Never end the response.
|
||||
@@ -35,7 +35,7 @@ test('animetoshoFetchJson gives up on a hanging response', async () => {
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await animetoshoFetchJson(
|
||||
const result = await tsukihimeFetchJson(
|
||||
'/json',
|
||||
{},
|
||||
{ baseUrl: server.baseUrl, timeoutMs: 150 },
|
||||
@@ -49,14 +49,14 @@ test('animetoshoFetchJson gives up on a hanging response', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('animetoshoFetchJson rejects an oversized response', async () => {
|
||||
test('tsukihimeFetchJson 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(
|
||||
const result = await tsukihimeFetchJson(
|
||||
'/json',
|
||||
{},
|
||||
{ baseUrl: server.baseUrl, maxResponseBytes: 512 },
|
||||
@@ -70,7 +70,7 @@ test('animetoshoFetchJson rejects an oversized response', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('animetoshoFetchJson decodes multi-byte characters split across chunks', async () => {
|
||||
test('tsukihimeFetchJson 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.
|
||||
@@ -83,7 +83,7 @@ test('animetoshoFetchJson decodes multi-byte characters split across chunks', as
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await animetoshoFetchJson<Array<{ id: number; title: string }>>(
|
||||
const result = await tsukihimeFetchJson<Array<{ id: number; title: string }>>(
|
||||
'/json',
|
||||
{},
|
||||
{ baseUrl: server.baseUrl },
|
||||
@@ -97,14 +97,14 @@ test('animetoshoFetchJson decodes multi-byte characters split across chunks', as
|
||||
}
|
||||
});
|
||||
|
||||
test('animetoshoFetchJson still parses a normal response', async () => {
|
||||
test('tsukihimeFetchJson 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 }>>(
|
||||
const result = await tsukihimeFetchJson<Array<{ id: number }>>(
|
||||
'/json',
|
||||
{ q: 'x' },
|
||||
{ baseUrl: server.baseUrl },
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
tsukihimeLangToFilenameSuffix,
|
||||
tsukihimeTrackMatchesLanguages,
|
||||
describeTsukihimeTabLanguages,
|
||||
normalizeTsukihimeLangCode,
|
||||
} from './lang.js';
|
||||
|
||||
test('normalizeTsukihimeLangCode collapses 2/3-letter and region variants', () => {
|
||||
assert.equal(normalizeTsukihimeLangCode('eng'), 'en');
|
||||
assert.equal(normalizeTsukihimeLangCode('en'), 'en');
|
||||
assert.equal(normalizeTsukihimeLangCode('en-US'), 'en');
|
||||
assert.equal(normalizeTsukihimeLangCode('GER'), 'de');
|
||||
assert.equal(normalizeTsukihimeLangCode('jpn'), 'ja');
|
||||
assert.equal(normalizeTsukihimeLangCode('vie'), 'vie');
|
||||
assert.equal(normalizeTsukihimeLangCode(''), '');
|
||||
});
|
||||
|
||||
test('tsukihimeTrackMatchesLanguages matches across code forms', () => {
|
||||
assert.equal(tsukihimeTrackMatchesLanguages('eng', ['en']), true);
|
||||
assert.equal(tsukihimeTrackMatchesLanguages('eng', ['en', 'eng']), true);
|
||||
assert.equal(tsukihimeTrackMatchesLanguages('ger', ['de']), true);
|
||||
assert.equal(tsukihimeTrackMatchesLanguages('por', ['en']), false);
|
||||
assert.equal(tsukihimeTrackMatchesLanguages('spa', ['en', 'de']), false);
|
||||
});
|
||||
|
||||
test('tsukihimeTrackMatchesLanguages keeps unknown-language tracks visible', () => {
|
||||
assert.equal(tsukihimeTrackMatchesLanguages('', ['en']), true);
|
||||
assert.equal(tsukihimeTrackMatchesLanguages('und', ['en']), true);
|
||||
});
|
||||
|
||||
test('describeTsukihimeTabLanguages names common languages and dedupes', () => {
|
||||
assert.equal(describeTsukihimeTabLanguages(['en', 'eng']), 'English');
|
||||
assert.equal(describeTsukihimeTabLanguages(['de']), 'German');
|
||||
assert.equal(describeTsukihimeTabLanguages(['en', 'de']), 'English / German');
|
||||
assert.equal(describeTsukihimeTabLanguages(['vie']), 'VIE');
|
||||
assert.equal(describeTsukihimeTabLanguages([]), 'English');
|
||||
});
|
||||
|
||||
test('tsukihimeLangToFilenameSuffix is re-exported from the pure module', () => {
|
||||
assert.equal(tsukihimeLangToFilenameSuffix('jpn'), 'ja');
|
||||
assert.equal(tsukihimeLangToFilenameSuffix('eng'), 'en');
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Pure language-code helpers shared between the main-process Animetosho client
|
||||
// Pure language-code helpers shared between the main-process Tsukihime client
|
||||
// and the overlay renderer. Must stay free of node imports so the renderer
|
||||
// bundle can use it.
|
||||
|
||||
@@ -33,35 +33,35 @@ const LANG_DISPLAY_NAMES: Record<string, string> = {
|
||||
ko: 'Korean',
|
||||
};
|
||||
|
||||
export function normalizeAnimetoshoLangCode(code: string): string {
|
||||
export function normalizeTsukihimeLangCode(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 ?? '');
|
||||
export function tsukihimeLangToFilenameSuffix(lang: string | undefined): string {
|
||||
const normalized = normalizeTsukihimeLangCode(lang ?? '');
|
||||
if (!normalized || normalized === 'und') return 'en';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function animetoshoTrackMatchesLanguages(
|
||||
export function tsukihimeTrackMatchesLanguages(
|
||||
trackLang: string,
|
||||
configuredLanguages: string[],
|
||||
): boolean {
|
||||
const normalizedTrack = normalizeAnimetoshoLangCode(trackLang);
|
||||
const normalizedTrack = normalizeTsukihimeLangCode(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,
|
||||
(candidate) => normalizeTsukihimeLangCode(candidate) === normalizedTrack,
|
||||
);
|
||||
}
|
||||
|
||||
export function describeAnimetoshoTabLanguages(configuredLanguages: string[]): string {
|
||||
export function describeTsukihimeTabLanguages(configuredLanguages: string[]): string {
|
||||
const normalized = [
|
||||
...new Set(
|
||||
configuredLanguages
|
||||
.map((candidate) => normalizeAnimetoshoLangCode(candidate))
|
||||
.map((candidate) => normalizeTsukihimeLangCode(candidate))
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
@@ -6,72 +6,72 @@ import * as path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import {
|
||||
animetoshoLangToFilenameSuffix,
|
||||
buildAnimetoshoAttachmentUrl,
|
||||
tsukihimeLangToFilenameSuffix,
|
||||
buildTsukihimeAttachmentUrl,
|
||||
decompressXzFile,
|
||||
extractAnimetoshoSubtitleFiles,
|
||||
isAnimetoshoDownloadUrl,
|
||||
mapAnimetoshoSearchResults,
|
||||
extractTsukihimeSubtitleFiles,
|
||||
isTsukihimeDownloadUrl,
|
||||
mapTsukihimeSearchResults,
|
||||
} from './utils.js';
|
||||
|
||||
test('buildAnimetoshoAttachmentUrl pads the attachment id to 8 hex digits', () => {
|
||||
test('buildTsukihimeAttachmentUrl pads the attachment id to 8 hex digits', () => {
|
||||
assert.equal(
|
||||
buildAnimetoshoAttachmentUrl(96412),
|
||||
buildTsukihimeAttachmentUrl(96412),
|
||||
'https://storage.tsukihime.org/attach/0001789c/96412.xz',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildAnimetoshoAttachmentUrl uses the tosho mirror path for imported entries', () => {
|
||||
test('buildTsukihimeAttachmentUrl uses the tosho mirror path for imported entries', () => {
|
||||
assert.equal(
|
||||
buildAnimetoshoAttachmentUrl(2826332, { imported: true }),
|
||||
buildTsukihimeAttachmentUrl(2826332, { imported: true }),
|
||||
'https://storage.tsukihime.org/tosho/attach/002b205c/2826332.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('tsukihimeLangToFilenameSuffix maps common ISO 639-2 codes to two-letter suffixes', () => {
|
||||
assert.equal(tsukihimeLangToFilenameSuffix('eng'), 'en');
|
||||
assert.equal(tsukihimeLangToFilenameSuffix('jpn'), 'ja');
|
||||
assert.equal(tsukihimeLangToFilenameSuffix('ger'), 'de');
|
||||
assert.equal(tsukihimeLangToFilenameSuffix('spa'), 'es');
|
||||
assert.equal(tsukihimeLangToFilenameSuffix('POR'), 'pt');
|
||||
});
|
||||
|
||||
test('animetoshoLangToFilenameSuffix handles TsukiHime BCP-47 style codes', () => {
|
||||
assert.equal(animetoshoLangToFilenameSuffix('en-US'), 'en');
|
||||
assert.equal(animetoshoLangToFilenameSuffix('es-419'), 'es');
|
||||
assert.equal(animetoshoLangToFilenameSuffix('zh-Hant'), 'zh');
|
||||
assert.equal(animetoshoLangToFilenameSuffix('ja'), 'ja');
|
||||
test('tsukihimeLangToFilenameSuffix handles TsukiHime BCP-47 style codes', () => {
|
||||
assert.equal(tsukihimeLangToFilenameSuffix('en-US'), 'en');
|
||||
assert.equal(tsukihimeLangToFilenameSuffix('es-419'), 'es');
|
||||
assert.equal(tsukihimeLangToFilenameSuffix('zh-Hant'), 'zh');
|
||||
assert.equal(tsukihimeLangToFilenameSuffix('ja'), 'ja');
|
||||
});
|
||||
|
||||
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('tsukihimeLangToFilenameSuffix falls back to the raw code, and to en when unknown', () => {
|
||||
assert.equal(tsukihimeLangToFilenameSuffix('vie'), 'vie');
|
||||
assert.equal(tsukihimeLangToFilenameSuffix(''), 'en');
|
||||
assert.equal(tsukihimeLangToFilenameSuffix(undefined), 'en');
|
||||
assert.equal(tsukihimeLangToFilenameSuffix('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('buildTsukihimeAttachmentUrl rejects non-positive and non-integer ids', () => {
|
||||
assert.equal(buildTsukihimeAttachmentUrl(0), null);
|
||||
assert.equal(buildTsukihimeAttachmentUrl(-5), null);
|
||||
assert.equal(buildTsukihimeAttachmentUrl(1.5), null);
|
||||
assert.equal(buildTsukihimeAttachmentUrl(Number.NaN), null);
|
||||
});
|
||||
|
||||
test('isAnimetoshoDownloadUrl accepts tsukihime.org and animetosho.org hosts only', () => {
|
||||
assert.equal(isAnimetoshoDownloadUrl('https://storage.tsukihime.org/attach/0001789c/1.xz'), true);
|
||||
assert.equal(isAnimetoshoDownloadUrl('https://tsukihime.org/view/1'), true);
|
||||
test('isTsukihimeDownloadUrl accepts tsukihime.org and animetosho.org hosts only', () => {
|
||||
assert.equal(isTsukihimeDownloadUrl('https://storage.tsukihime.org/attach/0001789c/1.xz'), true);
|
||||
assert.equal(isTsukihimeDownloadUrl('https://tsukihime.org/view/1'), true);
|
||||
// The tosho mirror currently 302s to storage.animetosho.org; the hop must stay allowed.
|
||||
assert.equal(
|
||||
isAnimetoshoDownloadUrl('https://storage.animetosho.org/attach/002b205c/2826332.xz'),
|
||||
isTsukihimeDownloadUrl('https://storage.animetosho.org/attach/002b205c/2826332.xz'),
|
||||
true,
|
||||
);
|
||||
assert.equal(isAnimetoshoDownloadUrl('http://storage.tsukihime.org/attach/1/1.xz'), false);
|
||||
assert.equal(isAnimetoshoDownloadUrl('https://eviltsukihime.org/attach/1/1.xz'), false);
|
||||
assert.equal(isAnimetoshoDownloadUrl('https://example.com/1.xz'), false);
|
||||
assert.equal(isAnimetoshoDownloadUrl('not a url'), false);
|
||||
assert.equal(isTsukihimeDownloadUrl('http://storage.tsukihime.org/attach/1/1.xz'), false);
|
||||
assert.equal(isTsukihimeDownloadUrl('https://eviltsukihime.org/attach/1/1.xz'), false);
|
||||
assert.equal(isTsukihimeDownloadUrl('https://example.com/1.xz'), false);
|
||||
assert.equal(isTsukihimeDownloadUrl('not a url'), false);
|
||||
});
|
||||
|
||||
test('mapAnimetoshoSearchResults maps valid results and caps to maxResults', () => {
|
||||
test('mapTsukihimeSearchResults maps valid results and caps to maxResults', () => {
|
||||
const payload = {
|
||||
total: 4,
|
||||
start: 0,
|
||||
@@ -96,7 +96,7 @@ test('mapAnimetoshoSearchResults maps valid results and caps to maxResults', ()
|
||||
],
|
||||
};
|
||||
|
||||
const entries = mapAnimetoshoSearchResults(payload, 2);
|
||||
const entries = mapTsukihimeSearchResults(payload, 2);
|
||||
assert.equal(entries.length, 2);
|
||||
assert.deepEqual(entries[0], {
|
||||
id: 1000752022,
|
||||
@@ -112,11 +112,11 @@ test('mapAnimetoshoSearchResults maps valid results and caps to maxResults', ()
|
||||
assert.deepEqual(entries[1]!.sublangs, []);
|
||||
});
|
||||
|
||||
test('mapAnimetoshoSearchResults returns empty list for malformed payloads', () => {
|
||||
assert.deepEqual(mapAnimetoshoSearchResults({ error: 'nope' }, 10), []);
|
||||
assert.deepEqual(mapAnimetoshoSearchResults({ results: 'nope' }, 10), []);
|
||||
assert.deepEqual(mapAnimetoshoSearchResults(null, 10), []);
|
||||
assert.deepEqual(mapAnimetoshoSearchResults([], 10), []);
|
||||
test('mapTsukihimeSearchResults returns empty list for malformed payloads', () => {
|
||||
assert.deepEqual(mapTsukihimeSearchResults({ error: 'nope' }, 10), []);
|
||||
assert.deepEqual(mapTsukihimeSearchResults({ results: 'nope' }, 10), []);
|
||||
assert.deepEqual(mapTsukihimeSearchResults(null, 10), []);
|
||||
assert.deepEqual(mapTsukihimeSearchResults([], 10), []);
|
||||
});
|
||||
|
||||
// Trimmed from a real /v1/torrents/{id} response (native entry).
|
||||
@@ -144,8 +144,8 @@ const DETAIL_PAYLOAD = {
|
||||
],
|
||||
};
|
||||
|
||||
test('extractAnimetoshoSubtitleFiles keeps only text subtitle attachments with download urls', () => {
|
||||
const files = extractAnimetoshoSubtitleFiles(DETAIL_PAYLOAD);
|
||||
test('extractTsukihimeSubtitleFiles keeps only text subtitle attachments with download urls', () => {
|
||||
const files = extractTsukihimeSubtitleFiles(DETAIL_PAYLOAD);
|
||||
assert.equal(files.length, 1);
|
||||
const file = files[0]!;
|
||||
assert.equal(file.attachmentId, 96412);
|
||||
@@ -163,8 +163,8 @@ test('extractAnimetoshoSubtitleFiles keeps only text subtitle attachments with d
|
||||
);
|
||||
});
|
||||
|
||||
test('extractAnimetoshoSubtitleFiles uses the tosho mirror for animetosho-imported entries', () => {
|
||||
const files = extractAnimetoshoSubtitleFiles({
|
||||
test('extractTsukihimeSubtitleFiles uses the tosho mirror for animetosho-imported entries', () => {
|
||||
const files = extractTsukihimeSubtitleFiles({
|
||||
id: 1000752022,
|
||||
animetosho: true,
|
||||
files: [
|
||||
@@ -181,8 +181,8 @@ test('extractAnimetoshoSubtitleFiles uses the tosho mirror for animetosho-import
|
||||
assert.equal(files[0]!.url, 'https://storage.tsukihime.org/tosho/attach/002b205c/2826332.xz');
|
||||
});
|
||||
|
||||
test('extractAnimetoshoSubtitleFiles detects imported entries by id when the flag is absent', () => {
|
||||
const files = extractAnimetoshoSubtitleFiles({
|
||||
test('extractTsukihimeSubtitleFiles detects imported entries by id when the flag is absent', () => {
|
||||
const files = extractTsukihimeSubtitleFiles({
|
||||
id: 1000752022,
|
||||
files: [
|
||||
{
|
||||
@@ -195,8 +195,8 @@ test('extractAnimetoshoSubtitleFiles detects imported entries by id when the fla
|
||||
assert.equal(files[0]!.url, 'https://storage.tsukihime.org/tosho/attach/002b205c/2826332.xz');
|
||||
});
|
||||
|
||||
test('extractAnimetoshoSubtitleFiles skips image-based subtitle codecs', () => {
|
||||
const files = extractAnimetoshoSubtitleFiles({
|
||||
test('extractTsukihimeSubtitleFiles skips image-based subtitle codecs', () => {
|
||||
const files = extractTsukihimeSubtitleFiles({
|
||||
id: 1,
|
||||
files: [
|
||||
{
|
||||
@@ -217,8 +217,8 @@ test('extractAnimetoshoSubtitleFiles skips image-based subtitle codecs', () => {
|
||||
assert.equal(files[0]!.filename, 'movie.en.srt');
|
||||
});
|
||||
|
||||
test('extractAnimetoshoSubtitleFiles sorts English tracks first and disambiguates duplicates', () => {
|
||||
const files = extractAnimetoshoSubtitleFiles({
|
||||
test('extractTsukihimeSubtitleFiles sorts English tracks first and disambiguates duplicates', () => {
|
||||
const files = extractTsukihimeSubtitleFiles({
|
||||
id: 1,
|
||||
files: [
|
||||
{
|
||||
@@ -242,8 +242,8 @@ test('extractAnimetoshoSubtitleFiles sorts English tracks first and disambiguate
|
||||
assert.equal(files[2]!.filename, 'episode.de.ass');
|
||||
});
|
||||
|
||||
test('extractAnimetoshoSubtitleFiles tolerates missing info fields', () => {
|
||||
const files = extractAnimetoshoSubtitleFiles({
|
||||
test('extractTsukihimeSubtitleFiles tolerates missing info fields', () => {
|
||||
const files = extractTsukihimeSubtitleFiles({
|
||||
id: 1,
|
||||
files: [
|
||||
{
|
||||
@@ -268,7 +268,7 @@ const hasXz = (() => {
|
||||
})();
|
||||
|
||||
test('decompressXzFile round-trips an xz-compressed subtitle', { skip: !hasXz }, async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-animetosho-test-'));
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tsukihime-test-'));
|
||||
try {
|
||||
const plainPath = path.join(dir, 'sub.ass');
|
||||
const content = '[Script Info]\nTitle: test\n';
|
||||
@@ -288,7 +288,7 @@ test('decompressXzFile round-trips an xz-compressed subtitle', { skip: !hasXz },
|
||||
});
|
||||
|
||||
test('decompressXzFile reports an error for corrupt input', { skip: !hasXz }, async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-animetosho-test-'));
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tsukihime-test-'));
|
||||
try {
|
||||
const srcPath = path.join(dir, 'broken.xz');
|
||||
fs.writeFileSync(srcPath, 'not xz data');
|
||||
Binary file not shown.
+4
-4
@@ -10,7 +10,7 @@ import type {
|
||||
ImmersionTrackingConfig,
|
||||
ImmersionTrackingRetentionMode,
|
||||
ImmersionTrackingRetentionPreset,
|
||||
AnimetoshoConfig,
|
||||
TsukihimeConfig,
|
||||
JellyfinConfig,
|
||||
JimakuConfig,
|
||||
JimakuLanguagePreference,
|
||||
@@ -123,7 +123,7 @@ export interface ShortcutsConfig {
|
||||
openCharacterDictionaryManager?: string | null;
|
||||
openRuntimeOptions?: string | null;
|
||||
openJimaku?: string | null;
|
||||
openAnimetosho?: string | null;
|
||||
openTsukihime?: string | null;
|
||||
openSessionHelp?: string | null;
|
||||
openControllerSelect?: string | null;
|
||||
openControllerDebug?: string | null;
|
||||
@@ -149,7 +149,7 @@ export interface Config {
|
||||
subtitleSidebar?: SubtitleSidebarConfig;
|
||||
auto_start_overlay?: boolean;
|
||||
jimaku?: JimakuConfig;
|
||||
animetosho?: AnimetoshoConfig;
|
||||
tsukihime?: TsukihimeConfig;
|
||||
anilist?: AnilistConfig;
|
||||
yomitan?: YomitanConfig;
|
||||
jellyfin?: JellyfinConfig;
|
||||
@@ -306,7 +306,7 @@ export interface ResolvedConfig {
|
||||
languagePreference: JimakuLanguagePreference;
|
||||
maxEntryResults: number;
|
||||
};
|
||||
animetosho: AnimetoshoConfig & {
|
||||
tsukihime: TsukihimeConfig & {
|
||||
apiBaseUrl: string;
|
||||
maxSearchResults: number;
|
||||
};
|
||||
|
||||
@@ -241,11 +241,11 @@ export type JimakuDownloadResult =
|
||||
| { ok: true; path: string }
|
||||
| { ok: false; error: JimakuApiError };
|
||||
|
||||
export interface AnimetoshoSearchQuery {
|
||||
export interface TsukihimeSearchQuery {
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface AnimetoshoEntry {
|
||||
export interface TsukihimeEntry {
|
||||
id: number;
|
||||
title: string;
|
||||
timestamp: number | null;
|
||||
@@ -254,11 +254,11 @@ export interface AnimetoshoEntry {
|
||||
sublangs: string[];
|
||||
}
|
||||
|
||||
export interface AnimetoshoFilesQuery {
|
||||
export interface TsukihimeFilesQuery {
|
||||
entryId: number;
|
||||
}
|
||||
|
||||
export interface AnimetoshoSubtitleFile {
|
||||
export interface TsukihimeSubtitleFile {
|
||||
attachmentId: number;
|
||||
filename: string;
|
||||
lang: string;
|
||||
@@ -268,18 +268,18 @@ export interface AnimetoshoSubtitleFile {
|
||||
sourceFilename: string;
|
||||
}
|
||||
|
||||
export interface AnimetoshoDownloadQuery {
|
||||
export interface TsukihimeDownloadQuery {
|
||||
entryId: number;
|
||||
url: string;
|
||||
name: string;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export type AnimetoshoApiResponse<T> = JimakuApiResponse<T>;
|
||||
export type TsukihimeApiResponse<T> = JimakuApiResponse<T>;
|
||||
|
||||
export type AnimetoshoDownloadResult = JimakuDownloadResult;
|
||||
export type TsukihimeDownloadResult = JimakuDownloadResult;
|
||||
|
||||
export interface AnimetoshoConfig {
|
||||
export interface TsukihimeConfig {
|
||||
apiBaseUrl?: string;
|
||||
maxSearchResults?: number;
|
||||
}
|
||||
|
||||
+18
-18
@@ -12,13 +12,13 @@ import type {
|
||||
SessionBindingWarning,
|
||||
} from './session-bindings';
|
||||
import type {
|
||||
AnimetoshoApiResponse,
|
||||
AnimetoshoDownloadQuery,
|
||||
AnimetoshoDownloadResult,
|
||||
AnimetoshoEntry,
|
||||
AnimetoshoFilesQuery,
|
||||
AnimetoshoSearchQuery,
|
||||
AnimetoshoSubtitleFile,
|
||||
TsukihimeApiResponse,
|
||||
TsukihimeDownloadQuery,
|
||||
TsukihimeDownloadResult,
|
||||
TsukihimeEntry,
|
||||
TsukihimeFilesQuery,
|
||||
TsukihimeSearchQuery,
|
||||
TsukihimeSubtitleFile,
|
||||
JimakuApiResponse,
|
||||
JimakuDownloadQuery,
|
||||
JimakuDownloadResult,
|
||||
@@ -461,14 +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[]>;
|
||||
tsukihimeSearchEntries: (
|
||||
query: TsukihimeSearchQuery,
|
||||
) => Promise<TsukihimeApiResponse<TsukihimeEntry[]>>;
|
||||
tsukihimeListFiles: (
|
||||
query: TsukihimeFilesQuery,
|
||||
) => Promise<TsukihimeApiResponse<TsukihimeSubtitleFile[]>>;
|
||||
tsukihimeDownloadFile: (query: TsukihimeDownloadQuery) => Promise<TsukihimeDownloadResult>;
|
||||
tsukihimeGetSecondaryLanguages: () => Promise<string[]>;
|
||||
quitApp: () => void;
|
||||
toggleDevTools: () => void;
|
||||
toggleOverlay: () => void;
|
||||
@@ -501,7 +501,7 @@ export interface ElectronAPI {
|
||||
onOpenControllerSelect: (callback: () => void) => void;
|
||||
onOpenControllerDebug: (callback: () => void) => void;
|
||||
onOpenJimaku: (callback: () => void) => void;
|
||||
onOpenAnimetosho: (callback: () => void) => void;
|
||||
onOpenTsukihime: (callback: () => void) => void;
|
||||
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
|
||||
onOpenPlaylistBrowser: (callback: () => void) => void;
|
||||
onOpenCharacterDictionaryManager: (callback: () => void) => void;
|
||||
@@ -546,7 +546,7 @@ export interface ElectronAPI {
|
||||
| 'runtime-options'
|
||||
| 'subsync'
|
||||
| 'jimaku'
|
||||
| 'animetosho'
|
||||
| 'tsukihime'
|
||||
| 'youtube-track-picker'
|
||||
| 'playlist-browser'
|
||||
| 'kiku'
|
||||
@@ -561,7 +561,7 @@ export interface ElectronAPI {
|
||||
| 'runtime-options'
|
||||
| 'subsync'
|
||||
| 'jimaku'
|
||||
| 'animetosho'
|
||||
| 'tsukihime'
|
||||
| 'youtube-track-picker'
|
||||
| 'playlist-browser'
|
||||
| 'kiku'
|
||||
|
||||
@@ -22,7 +22,7 @@ export type SessionActionId =
|
||||
| 'openControllerSelect'
|
||||
| 'openControllerDebug'
|
||||
| 'openJimaku'
|
||||
| 'openAnimetosho'
|
||||
| 'openTsukihime'
|
||||
| 'openYoutubePicker'
|
||||
| 'openPlaylistBrowser'
|
||||
| 'replayCurrentSubtitle'
|
||||
|
||||
Reference in New Issue
Block a user