mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
refactor(tsukihime): swap Animetosho backend for TsukiHime API (#165)
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');
|
||||
});
|
||||
@@ -1,233 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import {
|
||||
animetoshoLangToFilenameSuffix,
|
||||
buildAnimetoshoAttachmentUrl,
|
||||
decompressXzFile,
|
||||
extractAnimetoshoSubtitleFiles,
|
||||
mapAnimetoshoSearchResults,
|
||||
} from './utils.js';
|
||||
|
||||
test('buildAnimetoshoAttachmentUrl pads the attachment id to 8 hex digits', () => {
|
||||
assert.equal(
|
||||
buildAnimetoshoAttachmentUrl(1955356),
|
||||
'https://animetosho.org/storage/attach/001dd61c/1955356.xz',
|
||||
);
|
||||
});
|
||||
|
||||
test('animetoshoLangToFilenameSuffix maps common ISO 639-2 codes to two-letter suffixes', () => {
|
||||
assert.equal(animetoshoLangToFilenameSuffix('eng'), 'en');
|
||||
assert.equal(animetoshoLangToFilenameSuffix('jpn'), 'ja');
|
||||
assert.equal(animetoshoLangToFilenameSuffix('ger'), 'de');
|
||||
assert.equal(animetoshoLangToFilenameSuffix('spa'), 'es');
|
||||
assert.equal(animetoshoLangToFilenameSuffix('POR'), 'pt');
|
||||
});
|
||||
|
||||
test('animetoshoLangToFilenameSuffix falls back to the raw code, and to en when unknown', () => {
|
||||
assert.equal(animetoshoLangToFilenameSuffix('vie'), 'vie');
|
||||
assert.equal(animetoshoLangToFilenameSuffix(''), 'en');
|
||||
assert.equal(animetoshoLangToFilenameSuffix(undefined), 'en');
|
||||
assert.equal(animetoshoLangToFilenameSuffix('und'), 'en');
|
||||
});
|
||||
|
||||
test('buildAnimetoshoAttachmentUrl rejects non-positive and non-integer ids', () => {
|
||||
assert.equal(buildAnimetoshoAttachmentUrl(0), null);
|
||||
assert.equal(buildAnimetoshoAttachmentUrl(-5), null);
|
||||
assert.equal(buildAnimetoshoAttachmentUrl(1.5), null);
|
||||
assert.equal(buildAnimetoshoAttachmentUrl(Number.NaN), null);
|
||||
});
|
||||
|
||||
test('mapAnimetoshoSearchResults maps valid entries and caps to maxResults', () => {
|
||||
const payload = [
|
||||
{
|
||||
id: 606713,
|
||||
title: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
|
||||
timestamp: 1710000000,
|
||||
total_size: 1490354395,
|
||||
num_files: 1,
|
||||
},
|
||||
{ id: 'bogus', title: 'missing numeric id' },
|
||||
{ id: 606714, title: '[Erai-raws] Sousou no Frieren - 28 [1080p].mkv' },
|
||||
{ id: 606715, title: 'capped away' },
|
||||
];
|
||||
|
||||
const entries = mapAnimetoshoSearchResults(payload, 2);
|
||||
assert.equal(entries.length, 2);
|
||||
assert.deepEqual(entries[0], {
|
||||
id: 606713,
|
||||
title: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
|
||||
timestamp: 1710000000,
|
||||
totalSize: 1490354395,
|
||||
numFiles: 1,
|
||||
});
|
||||
assert.equal(entries[1]!.id, 606714);
|
||||
assert.equal(entries[1]!.totalSize, null);
|
||||
assert.equal(entries[1]!.numFiles, null);
|
||||
});
|
||||
|
||||
test('mapAnimetoshoSearchResults returns empty list for non-array payloads', () => {
|
||||
assert.deepEqual(mapAnimetoshoSearchResults({ error: 'nope' }, 10), []);
|
||||
assert.deepEqual(mapAnimetoshoSearchResults(null, 10), []);
|
||||
});
|
||||
|
||||
const DETAIL_PAYLOAD = {
|
||||
id: 606713,
|
||||
title: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
|
||||
files: [
|
||||
{
|
||||
id: 1151711,
|
||||
filename: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
|
||||
attachments: [
|
||||
{
|
||||
id: 1955355,
|
||||
type: 'font',
|
||||
info: { name: 'arial.ttf' },
|
||||
size: 300000,
|
||||
},
|
||||
{
|
||||
id: 1955356,
|
||||
type: 'subtitle',
|
||||
info: { codec: 'ASS', lang: 'eng', name: 'English subs', trackid: 2 },
|
||||
size: 33075,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('extractAnimetoshoSubtitleFiles keeps only text subtitle attachments with download urls', () => {
|
||||
const files = extractAnimetoshoSubtitleFiles(DETAIL_PAYLOAD);
|
||||
assert.equal(files.length, 1);
|
||||
const file = files[0]!;
|
||||
assert.equal(file.attachmentId, 1955356);
|
||||
assert.equal(file.lang, 'eng');
|
||||
assert.equal(file.trackName, 'English subs');
|
||||
assert.equal(file.size, 33075);
|
||||
assert.equal(file.url, 'https://animetosho.org/storage/attach/001dd61c/1955356.xz');
|
||||
assert.equal(file.sourceFilename, '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv');
|
||||
assert.equal(file.filename, '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].eng.ass');
|
||||
});
|
||||
|
||||
test('extractAnimetoshoSubtitleFiles skips image-based subtitle codecs', () => {
|
||||
const files = extractAnimetoshoSubtitleFiles({
|
||||
files: [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'movie.mkv',
|
||||
attachments: [
|
||||
{ id: 10, type: 'subtitle', info: { codec: 'PGS', lang: 'eng' }, size: 100 },
|
||||
{ id: 11, type: 'subtitle', info: { codec: 'VobSub', lang: 'eng' }, size: 100 },
|
||||
{ id: 12, type: 'subtitle', info: { codec: 'SRT', lang: 'eng' }, size: 100 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
files.map((f) => f.attachmentId),
|
||||
[12],
|
||||
);
|
||||
assert.equal(files[0]!.filename, 'movie.eng.srt');
|
||||
});
|
||||
|
||||
test('extractAnimetoshoSubtitleFiles sorts English tracks first and disambiguates duplicates', () => {
|
||||
const files = extractAnimetoshoSubtitleFiles({
|
||||
files: [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'episode.mkv',
|
||||
attachments: [
|
||||
{
|
||||
id: 21,
|
||||
type: 'subtitle',
|
||||
info: { codec: 'ASS', lang: 'ger', name: 'Deutsch' },
|
||||
size: 1,
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
type: 'subtitle',
|
||||
info: { codec: 'ASS', lang: 'eng', name: 'Signs & Songs' },
|
||||
size: 2,
|
||||
},
|
||||
{
|
||||
id: 23,
|
||||
type: 'subtitle',
|
||||
info: { codec: 'ASS', lang: 'eng', name: 'Full Subtitles' },
|
||||
size: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
files.map((f) => f.attachmentId),
|
||||
[22, 23, 21],
|
||||
);
|
||||
assert.equal(files[0]!.filename, 'episode.eng.signs-songs.ass');
|
||||
assert.equal(files[1]!.filename, 'episode.eng.full-subtitles.ass');
|
||||
assert.equal(files[2]!.filename, 'episode.ger.ass');
|
||||
});
|
||||
|
||||
test('extractAnimetoshoSubtitleFiles tolerates missing info fields', () => {
|
||||
const files = extractAnimetoshoSubtitleFiles({
|
||||
files: [
|
||||
{
|
||||
id: 1,
|
||||
attachments: [{ id: 31, type: 'subtitle', info: { codec: 'ASS' }, size: 5 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(files[0]!.lang, '');
|
||||
assert.equal(files[0]!.trackName, null);
|
||||
assert.equal(files[0]!.filename, 'subtitle.ass');
|
||||
});
|
||||
|
||||
const hasXz = (() => {
|
||||
try {
|
||||
execFileSync('xz', ['--version'], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
test('decompressXzFile round-trips an xz-compressed subtitle', { skip: !hasXz }, async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-animetosho-test-'));
|
||||
try {
|
||||
const plainPath = path.join(dir, 'sub.ass');
|
||||
const content = '[Script Info]\nTitle: test\n';
|
||||
fs.writeFileSync(plainPath, content, 'utf8');
|
||||
execFileSync('xz', ['-z', plainPath]);
|
||||
|
||||
const destPath = path.join(dir, 'out.ass');
|
||||
const result = await decompressXzFile(`${plainPath}.xz`, destPath);
|
||||
assert.equal(result.ok, true);
|
||||
if (result.ok) {
|
||||
assert.equal(result.path, destPath);
|
||||
}
|
||||
assert.equal(fs.readFileSync(destPath, 'utf8'), content);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('decompressXzFile reports an error for corrupt input', { skip: !hasXz }, async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-animetosho-test-'));
|
||||
try {
|
||||
const srcPath = path.join(dir, 'broken.xz');
|
||||
fs.writeFileSync(srcPath, 'not xz data');
|
||||
const result = await decompressXzFile(srcPath, path.join(dir, 'out.ass'));
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) {
|
||||
assert.match(result.error.error, /xz|decompress/i);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+10
-2
@@ -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);
|
||||
@@ -148,6 +148,14 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
});
|
||||
|
||||
test('parseArgs keeps the legacy Animetosho open flag as a TsukiHime alias', () => {
|
||||
const args = parseArgs(['--open-animetosho']);
|
||||
|
||||
assert.equal(args.openTsukihime, true);
|
||||
assert.equal(hasExplicitCommand(args), true);
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
});
|
||||
|
||||
test('parseArgs ignores retired subtitle delay shift flags', () => {
|
||||
const args = parseArgs(['--shift-sub-delay-prev-line', '--shift-sub-delay-next-line']);
|
||||
|
||||
|
||||
+10
-9
@@ -38,7 +38,7 @@ export interface CliArgs {
|
||||
openControllerSelect: boolean;
|
||||
openControllerDebug: boolean;
|
||||
openJimaku: boolean;
|
||||
openAnimetosho: boolean;
|
||||
openTsukihime: boolean;
|
||||
openYoutubePicker: boolean;
|
||||
openPlaylistBrowser: boolean;
|
||||
replayCurrentSubtitle: boolean;
|
||||
@@ -148,7 +148,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openAnimetosho: false,
|
||||
openTsukihime: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
@@ -297,8 +297,9 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--open-controller-select') args.openControllerSelect = true;
|
||||
else if (arg === '--open-controller-debug') args.openControllerDebug = true;
|
||||
else if (arg === '--open-jimaku') args.openJimaku = true;
|
||||
else if (arg === '--open-animetosho') args.openAnimetosho = true;
|
||||
else if (arg === '--open-youtube-picker') args.openYoutubePicker = true;
|
||||
else if (arg === '--open-tsukihime' || arg === '--open-animetosho') {
|
||||
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;
|
||||
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
|
||||
@@ -571,7 +572,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openAnimetosho ||
|
||||
args.openTsukihime ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
@@ -650,7 +651,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.openControllerSelect &&
|
||||
!args.openControllerDebug &&
|
||||
!args.openJimaku &&
|
||||
!args.openAnimetosho &&
|
||||
!args.openTsukihime &&
|
||||
!args.openYoutubePicker &&
|
||||
!args.openPlaylistBrowser &&
|
||||
!args.replayCurrentSubtitle &&
|
||||
@@ -718,7 +719,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openAnimetosho ||
|
||||
args.openTsukihime ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
@@ -780,7 +781,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
!args.openControllerSelect &&
|
||||
!args.openControllerDebug &&
|
||||
!args.openJimaku &&
|
||||
!args.openAnimetosho &&
|
||||
!args.openTsukihime &&
|
||||
!args.openYoutubePicker &&
|
||||
!args.openPlaylistBrowser &&
|
||||
!args.replayCurrentSubtitle &&
|
||||
@@ -846,7 +847,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,8 +97,8 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
languagePreference: 'ja',
|
||||
maxEntryResults: 10,
|
||||
},
|
||||
animetosho: {
|
||||
apiBaseUrl: 'https://feed.animetosho.org',
|
||||
tsukihime: {
|
||||
apiBaseUrl: 'https://api.tsukihime.org/v1',
|
||||
maxSearchResults: 10,
|
||||
},
|
||||
mpv: {
|
||||
|
||||
@@ -616,11 +616,11 @@ 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 Animetosho subtitle search modal (English/Japanese tabs).',
|
||||
'Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openSessionHelp',
|
||||
|
||||
@@ -401,16 +401,17 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description: 'Maximum Jimaku search results returned.',
|
||||
},
|
||||
{
|
||||
path: 'animetosho.apiBaseUrl',
|
||||
path: 'tsukihime.apiBaseUrl',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.animetosho.apiBaseUrl,
|
||||
description: 'Base URL of the Animetosho JSON feed API. No API key required.',
|
||||
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,
|
||||
description: 'Maximum Animetosho search results returned.',
|
||||
defaultValue: defaultConfig.tsukihime.maxSearchResults,
|
||||
description: 'Maximum TsukiHime search results returned.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.enabled',
|
||||
|
||||
@@ -53,7 +53,9 @@ export const SPECIAL_COMMANDS = {
|
||||
SUBSYNC_TRIGGER: '__subsync-trigger',
|
||||
RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
|
||||
JIMAKU_OPEN: '__jimaku-open',
|
||||
/** @deprecated Use TSUKIHIME_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',
|
||||
|
||||
@@ -148,12 +148,12 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
key: 'jimaku',
|
||||
},
|
||||
{
|
||||
title: 'Animetosho',
|
||||
title: 'TsukiHime',
|
||||
description: [
|
||||
'Animetosho subtitle search configuration (English and Japanese). No API key required.',
|
||||
'TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.',
|
||||
],
|
||||
notes: ['Hot-reload: Animetosho changes apply to the next Animetosho request.'],
|
||||
key: 'animetosho',
|
||||
notes: ['Hot-reload: TsukiHime changes apply to the next TsukiHime request.'],
|
||||
key: 'tsukihime',
|
||||
},
|
||||
{
|
||||
title: 'YouTube Playback Settings',
|
||||
|
||||
@@ -236,7 +236,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'openCharacterDictionaryManager',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'openAnimetosho',
|
||||
'openTsukihime',
|
||||
'openSessionHelp',
|
||||
'openControllerSelect',
|
||||
'openControllerDebug',
|
||||
@@ -254,6 +254,20 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (src.shortcuts.openTsukihime === undefined) {
|
||||
const legacyOpenTsukihime = src.shortcuts.openAnimetosho;
|
||||
if (typeof legacyOpenTsukihime === 'string' || legacyOpenTsukihime === null) {
|
||||
resolved.shortcuts.openTsukihime = legacyOpenTsukihime;
|
||||
} else if (legacyOpenTsukihime !== undefined) {
|
||||
warn(
|
||||
'shortcuts.openAnimetosho',
|
||||
legacyOpenTsukihime,
|
||||
resolved.shortcuts.openTsukihime,
|
||||
'Expected string or null.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = asNumber(src.shortcuts.multiCopyTimeoutMs);
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
resolved.shortcuts.multiCopyTimeoutMs = Math.floor(timeout);
|
||||
|
||||
@@ -80,18 +80,27 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.animetosho)) {
|
||||
const apiBaseUrl = asString(src.animetosho.apiBaseUrl);
|
||||
if (apiBaseUrl !== undefined) resolved.animetosho.apiBaseUrl = apiBaseUrl;
|
||||
const currentTsukihimeSource = isObject(src.tsukihime) ? src.tsukihime : null;
|
||||
if (src.tsukihime !== undefined && !currentTsukihimeSource) {
|
||||
warn('tsukihime', src.tsukihime, resolved.tsukihime, 'Expected object.');
|
||||
}
|
||||
|
||||
const maxSearchResults = asNumber(src.animetosho.maxSearchResults);
|
||||
const legacyTsukihimeSource =
|
||||
src.tsukihime === undefined && isObject(src.animetosho) ? src.animetosho : null;
|
||||
const tsukihimeSource = currentTsukihimeSource ?? legacyTsukihimeSource;
|
||||
const tsukihimeSourcePath = currentTsukihimeSource ? 'tsukihime' : 'animetosho';
|
||||
if (tsukihimeSource) {
|
||||
const apiBaseUrl = asString(tsukihimeSource.apiBaseUrl);
|
||||
if (apiBaseUrl !== undefined) resolved.tsukihime.apiBaseUrl = apiBaseUrl;
|
||||
|
||||
const maxSearchResults = asNumber(tsukihimeSource.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 (tsukihimeSource.maxSearchResults !== undefined) {
|
||||
warn(
|
||||
'animetosho.maxSearchResults',
|
||||
src.animetosho.maxSearchResults,
|
||||
resolved.animetosho.maxSearchResults,
|
||||
`${tsukihimeSourcePath}.maxSearchResults`,
|
||||
tsukihimeSource.maxSearchResults,
|
||||
resolved.tsukihime.maxSearchResults,
|
||||
'Expected positive number.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { asBoolean } from './shared';
|
||||
|
||||
export function applyTopLevelConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
const knownTopLevelKeys = new Set(Object.keys(resolved));
|
||||
const knownTopLevelKeys = new Set([...Object.keys(resolved), 'animetosho']);
|
||||
for (const key of Object.keys(src)) {
|
||||
if (!knownTopLevelKeys.has(key)) {
|
||||
warn(key, src[key], undefined, 'Unknown top-level config key; ignored.');
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveConfig } from '../resolve';
|
||||
|
||||
test('resolveConfig maps legacy Animetosho settings to TsukiHime', () => {
|
||||
const { resolved, warnings } = resolveConfig({
|
||||
animetosho: {
|
||||
apiBaseUrl: 'https://legacy.example/v1',
|
||||
maxSearchResults: 23,
|
||||
},
|
||||
shortcuts: {
|
||||
openAnimetosho: 'Ctrl+Alt+T',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resolved.tsukihime.apiBaseUrl, 'https://legacy.example/v1');
|
||||
assert.equal(resolved.tsukihime.maxSearchResults, 23);
|
||||
assert.equal(resolved.shortcuts.openTsukihime, 'Ctrl+Alt+T');
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test('resolveConfig gives current TsukiHime settings precedence over legacy aliases', () => {
|
||||
const { resolved, warnings } = resolveConfig({
|
||||
animetosho: {
|
||||
apiBaseUrl: 'https://legacy.example/v1',
|
||||
maxSearchResults: 23,
|
||||
},
|
||||
tsukihime: {
|
||||
apiBaseUrl: 'https://current.example/v1',
|
||||
maxSearchResults: 7,
|
||||
},
|
||||
shortcuts: {
|
||||
openAnimetosho: 'Ctrl+Alt+T',
|
||||
openTsukihime: null,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resolved.tsukihime.apiBaseUrl, 'https://current.example/v1');
|
||||
assert.equal(resolved.tsukihime.maxSearchResults, 7);
|
||||
assert.equal(resolved.shortcuts.openTsukihime, null);
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test('resolveConfig does not fall back when the current TsukiHime setting is invalid', () => {
|
||||
const { resolved, warnings } = resolveConfig({
|
||||
animetosho: {
|
||||
apiBaseUrl: 'https://legacy.example/v1',
|
||||
maxSearchResults: 23,
|
||||
},
|
||||
tsukihime: 'invalid' as never,
|
||||
});
|
||||
|
||||
assert.equal(resolved.tsukihime.apiBaseUrl, 'https://api.tsukihime.org/v1');
|
||||
assert.equal(resolved.tsukihime.maxSearchResults, 10);
|
||||
assert.deepEqual(warnings, [
|
||||
{
|
||||
path: 'tsukihime',
|
||||
value: 'invalid',
|
||||
fallback: resolved.tsukihime,
|
||||
message: 'Expected object.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -247,6 +247,8 @@ test('settings registry routes playback-related integrations into integrations',
|
||||
assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku');
|
||||
assert.equal(field('subsync.replace').category, 'integrations');
|
||||
assert.equal(field('subsync.replace').section, 'Subtitle Sync');
|
||||
assert.equal(field('tsukihime.apiBaseUrl').category, 'integrations');
|
||||
assert.equal(field('tsukihime.apiBaseUrl').section, 'TsukiHime');
|
||||
});
|
||||
|
||||
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
|
||||
|
||||
@@ -422,7 +422,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
|
||||
return { category: 'behavior', section: topSection(path) };
|
||||
}
|
||||
if (path.startsWith('jimaku.')) {
|
||||
if (path.startsWith('jimaku.') || path.startsWith('tsukihime.')) {
|
||||
return { category: 'integrations', section: topSection(path) };
|
||||
}
|
||||
if (path.startsWith('subsync.')) {
|
||||
@@ -486,6 +486,7 @@ function topSection(path: string): string {
|
||||
notifications: 'Notifications',
|
||||
subsync: 'Subtitle Sync',
|
||||
texthooker: 'Texthooker',
|
||||
tsukihime: 'TsukiHime',
|
||||
updates: 'Updates',
|
||||
websocket: 'WebSocket server',
|
||||
yomitan: 'Yomitan',
|
||||
@@ -594,7 +595,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,41 +98,41 @@ 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 Animetosho search query payload', code: 400 },
|
||||
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 Animetosho files query payload', code: 400 },
|
||||
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 Animetosho download query payload', code: 400 },
|
||||
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' },
|
||||
);
|
||||
assert.deepEqual(foreignUrlDownload, {
|
||||
ok: false,
|
||||
error: { error: 'Refusing to download subtitle from a non-Animetosho URL.', code: 400 },
|
||||
error: { error: 'Refusing to download subtitle from a non-TsukiHime URL.', code: 400 },
|
||||
});
|
||||
});
|
||||
|
||||
test('animetosho downloads route by language: secondary for eng, primary for jpn', async () => {
|
||||
test('tsukihime downloads always route Japanese as primary', 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: () => ['ja'],
|
||||
onDownloadedSecondarySubtitle: (path) => {
|
||||
secondaryLoads.push(path);
|
||||
},
|
||||
@@ -171,13 +171,13 @@ 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!(
|
||||
{},
|
||||
{
|
||||
entryId: 1,
|
||||
url: 'https://animetosho.org/storage/attach/00000001/1.xz',
|
||||
url: 'https://storage.tsukihime.org/attach/00000001/1.xz',
|
||||
name: 'episode.eng.ass',
|
||||
lang: 'eng',
|
||||
},
|
||||
@@ -191,7 +191,7 @@ test('animetosho downloads route by language: secondary for eng, primary for jpn
|
||||
{},
|
||||
{
|
||||
entryId: 1,
|
||||
url: 'https://animetosho.org/storage/attach/00000002/2.xz',
|
||||
url: 'https://storage.tsukihime.org/attach/00000002/2.xz',
|
||||
name: 'episode.jpn.ass',
|
||||
lang: 'jpn',
|
||||
},
|
||||
@@ -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,50 +206,50 @@ 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 Animetosho search query payload', code: 400 },
|
||||
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 Animetosho files query payload', code: 400 } };
|
||||
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,
|
||||
error: { error: 'Invalid Animetosho download query payload', code: 400 },
|
||||
error: { error: 'Invalid TsukiHime download query payload', code: 400 },
|
||||
};
|
||||
}
|
||||
|
||||
if (!isAnimetoshoDownloadUrl(parsedQuery.url)) {
|
||||
if (!isTsukihimeDownloadUrl(parsedQuery.url)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { error: 'Refusing to download subtitle from a non-Animetosho URL.', code: 400 },
|
||||
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,17 +37,18 @@ 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>,
|
||||
});
|
||||
if ((query as Record<string, unknown>)?.show === 'torrent') {
|
||||
if (endpoint.startsWith('/torrents/')) {
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
id: 606713,
|
||||
files: [
|
||||
{
|
||||
id: 9,
|
||||
@@ -55,9 +56,8 @@ function createHarness(): RuntimeHarness {
|
||||
attachments: [
|
||||
{
|
||||
id: 1955356,
|
||||
type: 'subtitle',
|
||||
info: { codec: 'ASS', lang: 'eng', name: 'English subs' },
|
||||
size: 33075,
|
||||
type: 1,
|
||||
info: { codec: 'ASS', lang: 'en', name: 'English subs' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -67,11 +67,13 @@ function createHarness(): RuntimeHarness {
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
data: [
|
||||
{ id: 1, title: 'release a' },
|
||||
{ id: 2, title: 'release b' },
|
||||
{ id: 3, title: 'release c' },
|
||||
] as never,
|
||||
data: {
|
||||
results: [
|
||||
{ id: 1, name: 'release a' },
|
||||
{ id: 2, name: 'release b' },
|
||||
{ id: 3, name: 'release c' },
|
||||
],
|
||||
} as never,
|
||||
};
|
||||
},
|
||||
getSubtitleTimingTracker: () => null,
|
||||
@@ -172,9 +174,9 @@ test('registerAnkiJimakuIpcRuntime provides full handler surface', () => {
|
||||
'isRemoteMediaPath',
|
||||
'downloadToFile',
|
||||
'onDownloadedSubtitle',
|
||||
'searchAnimetoshoEntries',
|
||||
'listAnimetoshoFiles',
|
||||
'downloadAnimetoshoSubtitle',
|
||||
'searchTsukihimeEntries',
|
||||
'listTsukihimeFiles',
|
||||
'downloadTsukihimeSubtitle',
|
||||
'onDownloadedSecondarySubtitle',
|
||||
];
|
||||
|
||||
@@ -365,14 +367,14 @@ 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: '/json',
|
||||
query: { q: 'frieren 28', qx: 1 },
|
||||
endpoint: '/search/torrents',
|
||||
query: { q: 'frieren 28', limit: 2 },
|
||||
},
|
||||
]);
|
||||
assert.equal((searchResult as { ok: boolean }).ok, true);
|
||||
@@ -384,20 +386,20 @@ 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: '/json',
|
||||
query: { show: 'torrent', id: 606713 },
|
||||
endpoint: '/torrents/606713',
|
||||
query: {},
|
||||
},
|
||||
]);
|
||||
assert.equal((filesResult as { ok: boolean }).ok, true);
|
||||
const files = (filesResult as { data: Array<Record<string, unknown>> }).data;
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(files[0]!.attachmentId, 1955356);
|
||||
assert.equal(files[0]!.filename, 'episode.eng.ass');
|
||||
assert.equal(files[0]!.url, 'https://animetosho.org/storage/attach/001dd61c/1955356.xz');
|
||||
assert.equal(files[0]!.filename, 'episode.en.ass');
|
||||
assert.equal(files[0]!.url, 'https://storage.tsukihime.org/attach/001dd61c/1955356.xz');
|
||||
});
|
||||
|
||||
@@ -3,8 +3,8 @@ import { AnkiIntegration } from '../../anki-integration';
|
||||
import { mergeAiConfig } from '../../ai/config';
|
||||
import {
|
||||
AiConfig,
|
||||
AnimetoshoApiResponse,
|
||||
AnimetoshoConfig,
|
||||
TsukihimeApiResponse,
|
||||
TsukihimeConfig,
|
||||
AnkiConnectConfig,
|
||||
JimakuApiResponse,
|
||||
JimakuEntry,
|
||||
@@ -17,13 +17,13 @@ import {
|
||||
} from '../../types';
|
||||
import { sortJimakuFiles } from '../../jimaku/utils';
|
||||
import {
|
||||
ANIMETOSHO_FEED_BASE_URL,
|
||||
animetoshoFetchJson as animetoshoFetchJsonRequest,
|
||||
TSUKIHIME_API_BASE_URL,
|
||||
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 || ANIMETOSHO_FEED_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,39 +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 response = await animetoshoFetch<unknown>(options, '/json', {
|
||||
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,
|
||||
qx: 1,
|
||||
// The API caps limit at 100.
|
||||
limit: Math.min(maxResults, 100),
|
||||
});
|
||||
if (!response.ok) return response;
|
||||
const maxResults = getAnimetoshoMaxSearchResults(options);
|
||||
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>(options, '/json', {
|
||||
show: 'torrent',
|
||||
id: query.entryId,
|
||||
});
|
||||
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);
|
||||
@@ -310,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}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openAnimetosho: false,
|
||||
openTsukihime: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
|
||||
@@ -45,7 +45,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openAnimetosho: false,
|
||||
openTsukihime: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
|
||||
@@ -544,11 +544,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(
|
||||
|
||||
@@ -13,6 +13,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
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 +29,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');
|
||||
@@ -152,6 +153,15 @@ test('handleMpvCommandFromIpc dispatches special jimaku open command', () => {
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc keeps the legacy Animetosho command as a TsukiHime alias', () => {
|
||||
const { options, calls, sentCommands } = createOptions();
|
||||
|
||||
handleMpvCommandFromIpc(['__animetosho-open'], options);
|
||||
|
||||
assert.deepEqual(calls, ['tsukihime']);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc dispatches special playlist browser open command', async () => {
|
||||
const { options, calls, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['__playlist-browser-open'], options);
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
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 +21,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 +115,11 @@ export function handleMpvCommandFromIpc(
|
||||
return;
|
||||
}
|
||||
|
||||
if (first === options.specialCommands.ANIMETOSHO_OPEN) {
|
||||
options.openAnimetosho();
|
||||
if (
|
||||
first === options.specialCommands.TSUKIHIME_OPEN ||
|
||||
first === options.specialCommands.ANIMETOSHO_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,
|
||||
@@ -320,6 +320,21 @@ test('compileSessionBindings wires every default keybinding to an overlay or mpv
|
||||
}
|
||||
});
|
||||
|
||||
test('compileSessionBindings maps the legacy Animetosho command to the TsukiHime action', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts(),
|
||||
keybindings: [createKeybinding('Ctrl+Alt+T', ['__animetosho-open'])],
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.equal(result.bindings[0]?.actionType, 'session-action');
|
||||
assert.equal(
|
||||
result.bindings[0]?.actionType === 'session-action' ? result.bindings[0].actionId : null,
|
||||
'openTsukihime',
|
||||
);
|
||||
});
|
||||
|
||||
test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts(),
|
||||
|
||||
@@ -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 || first === SPECIAL_COMMANDS.ANIMETOSHO_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;
|
||||
|
||||
@@ -40,7 +40,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')),
|
||||
|
||||
+9
-9
@@ -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';
|
||||
@@ -2068,8 +2068,8 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
|
||||
openJimaku: () => {
|
||||
openJimakuOverlay();
|
||||
},
|
||||
openAnimetosho: () => {
|
||||
openAnimetoshoOverlay();
|
||||
openTsukihime: () => {
|
||||
openTsukihimeOverlay();
|
||||
},
|
||||
markAudioCard: () => markLastCardAsAudioCard(),
|
||||
copySubtitleMultiple: (timeoutMs: number) => {
|
||||
@@ -3001,11 +3001,11 @@ function openJimakuOverlay(): void {
|
||||
);
|
||||
}
|
||||
|
||||
function openAnimetoshoOverlay(): void {
|
||||
function openTsukihimeOverlay(): void {
|
||||
openOverlayHostedModalWithOsd(
|
||||
openAnimetoshoModalRuntime,
|
||||
'Animetosho overlay unavailable.',
|
||||
'Failed to open Animetosho overlay.',
|
||||
openTsukihimeModalRuntime,
|
||||
'TsukiHime overlay unavailable.',
|
||||
'Failed to open TsukiHime overlay.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5528,7 +5528,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
},
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openJimaku: () => openJimakuOverlay(),
|
||||
openAnimetosho: () => openAnimetoshoOverlay(),
|
||||
openTsukihime: () => openTsukihimeOverlay(),
|
||||
openSessionHelp: () => openSessionHelpOverlay(),
|
||||
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
|
||||
openControllerSelect: () => openControllerSelectOverlay(),
|
||||
@@ -5562,7 +5562,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openJimaku: () => openJimakuOverlay(),
|
||||
openAnimetosho: () => openAnimetoshoOverlay(),
|
||||
openTsukihime: () => openTsukihimeOverlay(),
|
||||
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
||||
openPlaylistBrowser: () => openPlaylistBrowser(),
|
||||
cycleRuntimeOption: (id, direction) => {
|
||||
|
||||
@@ -229,7 +229,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'];
|
||||
@@ -437,7 +437,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 }),
|
||||
|
||||
@@ -55,7 +55,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) {
|
||||
|
||||
+26
-14
@@ -116,40 +116,52 @@
|
||||
</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">Animetosho Subtitles</div>
|
||||
<button id="animetoshoClose" class="modal-close" type="button">Close</button>
|
||||
<div class="modal-title">TsukiHime Subtitles</div>
|
||||
<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" role="tablist">
|
||||
<button
|
||||
id="tsukihimeTabSecondary"
|
||||
class="tsukihime-tab active"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button id="animetoshoTabJapanese" class="animetosho-tab" type="button">
|
||||
<button
|
||||
id="tsukihimeTabPrimary"
|
||||
class="tsukihime-tab"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
>
|
||||
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,388 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AnimetoshoSubtitleFile, ElectronAPI } from '../../types';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createAnimetoshoModal } from './animetosho.js';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
add: (...entries: string[]) => {
|
||||
for (const entry of entries) {
|
||||
tokens.add(entry);
|
||||
}
|
||||
},
|
||||
remove: (...entries: string[]) => {
|
||||
for (const entry of entries) {
|
||||
tokens.delete(entry);
|
||||
}
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
};
|
||||
}
|
||||
|
||||
function createElementStub() {
|
||||
const classList = createClassList();
|
||||
return {
|
||||
textContent: '',
|
||||
className: '',
|
||||
style: {},
|
||||
classList,
|
||||
children: [] as unknown[],
|
||||
appendChild(child: unknown) {
|
||||
this.children.push(child);
|
||||
},
|
||||
addEventListener: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function createListStub() {
|
||||
return {
|
||||
innerHTML: '',
|
||||
children: [] as unknown[],
|
||||
appendChild(child: unknown) {
|
||||
this.children.push(child);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function flushAsyncWork(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
const ENGLISH_TRACK: AnimetoshoSubtitleFile = {
|
||||
attachmentId: 1955356,
|
||||
filename: 'episode01.eng.ass',
|
||||
lang: 'eng',
|
||||
trackName: 'English subs',
|
||||
size: 33075,
|
||||
url: 'https://animetosho.org/storage/attach/001dd61c/1955356.xz',
|
||||
sourceFilename: 'episode01.mkv',
|
||||
};
|
||||
|
||||
const JAPANESE_TRACK: AnimetoshoSubtitleFile = {
|
||||
attachmentId: 1955400,
|
||||
filename: 'episode01.jpn.ass',
|
||||
lang: 'jpn',
|
||||
trackName: 'Japanese subs',
|
||||
size: 41000,
|
||||
url: 'https://animetosho.org/storage/attach/001dd648/1955400.xz',
|
||||
sourceFilename: 'episode01.mkv',
|
||||
};
|
||||
|
||||
const GERMAN_TRACK: AnimetoshoSubtitleFile = {
|
||||
attachmentId: 1955500,
|
||||
filename: 'episode01.ger.ass',
|
||||
lang: 'ger',
|
||||
trackName: 'Deutsch',
|
||||
size: 28000,
|
||||
url: 'https://animetosho.org/storage/attach/001dd6ac/1955500.xz',
|
||||
sourceFilename: 'episode01.mkv',
|
||||
};
|
||||
|
||||
interface ModalHarness {
|
||||
modal: ReturnType<typeof createAnimetoshoModal>;
|
||||
state: ReturnType<typeof createRendererState>;
|
||||
downloadQueries: unknown[];
|
||||
modalCloseNotifications: string[];
|
||||
overlayClassList: ReturnType<typeof createClassList>;
|
||||
animetoshoModalClassList: ReturnType<typeof createClassList>;
|
||||
restoreGlobals: () => void;
|
||||
}
|
||||
|
||||
function createModalHarness(
|
||||
files: AnimetoshoSubtitleFile[],
|
||||
options: {
|
||||
secondaryLanguages?: string[];
|
||||
listFiles?: (entryId: number) => Promise<unknown>;
|
||||
} = {},
|
||||
): ModalHarness {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, 'window');
|
||||
const hadDocument = Object.prototype.hasOwnProperty.call(globalThis, 'document');
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const modalCloseNotifications: string[] = [];
|
||||
const downloadQueries: unknown[] = [];
|
||||
|
||||
const electronAPI = {
|
||||
animetoshoDownloadFile: async (query: unknown) => {
|
||||
downloadQueries.push(query);
|
||||
return { ok: true, path: '/tmp/subtitles/episode01.en.ass' };
|
||||
},
|
||||
animetoshoGetSecondaryLanguages: async () => options.secondaryLanguages ?? ['en', 'eng'],
|
||||
animetoshoListFiles: async ({ entryId }: { entryId: number }) =>
|
||||
options.listFiles ? options.listFiles(entryId) : { ok: true, data: [] },
|
||||
getJimakuMediaInfo: async () => ({
|
||||
title: '',
|
||||
season: null,
|
||||
episode: null,
|
||||
confidence: 'low',
|
||||
filename: '',
|
||||
rawTitle: '',
|
||||
}),
|
||||
notifyOverlayModalClosed: (modal: string) => {
|
||||
modalCloseNotifications.push(modal);
|
||||
},
|
||||
} as unknown as ElectronAPI;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { electronAPI },
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
activeElement: null,
|
||||
createElement: () => createElementStub(),
|
||||
},
|
||||
});
|
||||
|
||||
const overlayClassList = createClassList(['interactive']);
|
||||
const animetoshoModalClassList = createClassList();
|
||||
const state = createRendererState();
|
||||
state.animetoshoModalOpen = true;
|
||||
state.currentAnimetoshoEntryId = 606713;
|
||||
state.selectedAnimetoshoFileIndex = 0;
|
||||
state.animetoshoFiles = files;
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: overlayClassList },
|
||||
animetoshoModal: {
|
||||
classList: animetoshoModalClassList,
|
||||
setAttribute: () => {},
|
||||
},
|
||||
animetoshoTitleInput: { value: '' },
|
||||
animetoshoEpisodeInput: { value: '' },
|
||||
animetoshoSearchButton: { addEventListener: () => {} },
|
||||
animetoshoCloseButton: { addEventListener: () => {} },
|
||||
animetoshoTabEnglishButton: {
|
||||
textContent: 'English',
|
||||
classList: createClassList(['active']),
|
||||
addEventListener: () => {},
|
||||
},
|
||||
animetoshoTabJapaneseButton: {
|
||||
textContent: 'Japanese',
|
||||
classList: createClassList(),
|
||||
addEventListener: () => {},
|
||||
},
|
||||
animetoshoStatus: { textContent: '', style: { color: '' } },
|
||||
animetoshoEntriesSection: { classList: createClassList(['hidden']) },
|
||||
animetoshoEntriesList: createListStub(),
|
||||
animetoshoFilesSection: { classList: createClassList() },
|
||||
animetoshoFilesList: createListStub(),
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createAnimetoshoModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
return {
|
||||
modal,
|
||||
state,
|
||||
downloadQueries,
|
||||
modalCloseNotifications,
|
||||
overlayClassList,
|
||||
animetoshoModalClassList,
|
||||
restoreGlobals: () => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
if (hadWindow) {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
} else {
|
||||
delete target.window;
|
||||
}
|
||||
if (hadDocument) {
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: previousDocument,
|
||||
});
|
||||
} else {
|
||||
delete target.document;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pressKey(harness: ModalHarness, key: string): boolean {
|
||||
let prevented = false;
|
||||
harness.modal.handleAnimetoshoKeydown({
|
||||
key,
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
} as KeyboardEvent);
|
||||
return prevented;
|
||||
}
|
||||
|
||||
test('successful Animetosho subtitle selection closes modal', async () => {
|
||||
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
|
||||
try {
|
||||
const prevented = pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(harness.state.animetoshoModalOpen, false);
|
||||
assert.equal(harness.animetoshoModalClassList.contains('hidden'), true);
|
||||
assert.equal(harness.overlayClassList.contains('interactive'), false);
|
||||
assert.deepEqual(harness.modalCloseNotifications, ['animetosho']);
|
||||
assert.deepEqual(harness.downloadQueries, [
|
||||
{
|
||||
entryId: 606713,
|
||||
url: ENGLISH_TRACK.url,
|
||||
name: ENGLISH_TRACK.filename,
|
||||
lang: 'eng',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('English tab hides non-English languages, not just Japanese', async () => {
|
||||
const harness = createModalHarness([GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK]);
|
||||
try {
|
||||
// With German visible this would move selection onto it; English-only
|
||||
// filtering must clamp to the single English track instead.
|
||||
pressKey(harness, 'ArrowDown');
|
||||
pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.deepEqual(harness.downloadQueries, [
|
||||
{
|
||||
entryId: 606713,
|
||||
url: ENGLISH_TRACK.url,
|
||||
name: ENGLISH_TRACK.filename,
|
||||
lang: 'eng',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('Japanese tab filters tracks so Enter downloads the Japanese one', async () => {
|
||||
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
|
||||
try {
|
||||
assert.equal(harness.state.animetoshoActiveTab, 'en');
|
||||
pressKey(harness, 'ArrowRight');
|
||||
assert.equal(harness.state.animetoshoActiveTab, 'ja');
|
||||
|
||||
pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.deepEqual(harness.downloadQueries, [
|
||||
{
|
||||
entryId: 606713,
|
||||
url: JAPANESE_TRACK.url,
|
||||
name: JAPANESE_TRACK.filename,
|
||||
lang: 'jpn',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('secondary tab follows configured secondarySub languages', async () => {
|
||||
const harness = createModalHarness([GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK], {
|
||||
secondaryLanguages: ['de'],
|
||||
});
|
||||
try {
|
||||
// Re-open through the API so the modal fetches the configured languages.
|
||||
harness.state.animetoshoModalOpen = false;
|
||||
harness.modal.openAnimetoshoModal();
|
||||
await flushAsyncWork();
|
||||
|
||||
harness.state.animetoshoFiles = [GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK];
|
||||
harness.state.currentAnimetoshoEntryId = 606713;
|
||||
|
||||
pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.deepEqual(harness.downloadQueries, [
|
||||
{
|
||||
entryId: 606713,
|
||||
url: GERMAN_TRACK.url,
|
||||
name: GERMAN_TRACK.filename,
|
||||
lang: 'ger',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('a slow release response does not overwrite the newly selected release', async () => {
|
||||
const STALE_TRACK: AnimetoshoSubtitleFile = {
|
||||
...ENGLISH_TRACK,
|
||||
attachmentId: 999,
|
||||
filename: 'stale.eng.ass',
|
||||
};
|
||||
const SECOND_ENGLISH_TRACK: AnimetoshoSubtitleFile = {
|
||||
...ENGLISH_TRACK,
|
||||
attachmentId: 1955357,
|
||||
filename: 'episode01.eng.sdh.ass',
|
||||
};
|
||||
const resolvers: Array<(value: unknown) => void> = [];
|
||||
|
||||
const harness = createModalHarness([], {
|
||||
listFiles: (entryId) =>
|
||||
new Promise((resolve) => {
|
||||
if (entryId === 1) {
|
||||
// Entry 1 answers late, after the user has moved on to entry 2.
|
||||
resolvers.push(() => resolve({ ok: true, data: [STALE_TRACK] }));
|
||||
} else {
|
||||
// Two tracks, so the modal does not auto-download a lone match.
|
||||
resolve({ ok: true, data: [ENGLISH_TRACK, SECOND_ENGLISH_TRACK] });
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
harness.state.animetoshoEntries = [
|
||||
{ id: 1, title: 'slow release', timestamp: null, totalSize: null, numFiles: 1 },
|
||||
{ id: 2, title: 'fast release', timestamp: null, totalSize: null, numFiles: 1 },
|
||||
];
|
||||
|
||||
harness.modal.selectAnimetoshoEntry(0);
|
||||
harness.modal.selectAnimetoshoEntry(1);
|
||||
await flushAsyncWork();
|
||||
|
||||
// Entry 2's tracks are on screen; now entry 1 finally answers.
|
||||
assert.deepEqual(
|
||||
harness.state.animetoshoFiles.map((file) => file.attachmentId),
|
||||
[ENGLISH_TRACK.attachmentId, SECOND_ENGLISH_TRACK.attachmentId],
|
||||
);
|
||||
|
||||
resolvers.forEach((resolve) => resolve(undefined));
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(harness.state.currentAnimetoshoEntryId, 2);
|
||||
assert.deepEqual(
|
||||
harness.state.animetoshoFiles.map((file) => file.attachmentId),
|
||||
[ENGLISH_TRACK.attachmentId, SECOND_ENGLISH_TRACK.attachmentId],
|
||||
);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('ArrowLeft switches back to the English tab', () => {
|
||||
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
|
||||
try {
|
||||
pressKey(harness, 'ArrowRight');
|
||||
assert.equal(harness.state.animetoshoActiveTab, 'ja');
|
||||
pressKey(harness, 'ArrowLeft');
|
||||
assert.equal(harness.state.animetoshoActiveTab, 'en');
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
@@ -1,487 +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 (details.length > 0) {
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'jimaku-subtext';
|
||||
sub.textContent = details.join(' • ');
|
||||
li.appendChild(sub);
|
||||
}
|
||||
|
||||
if (index === ctx.state.selectedAnimetoshoEntryIndex) {
|
||||
li.classList.add('active');
|
||||
}
|
||||
|
||||
li.addEventListener('click', () => {
|
||||
selectEntry(index);
|
||||
});
|
||||
|
||||
ctx.dom.animetoshoEntriesList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderFiles(): void {
|
||||
ctx.dom.animetoshoFilesList.innerHTML = '';
|
||||
const visibleFiles = getVisibleFiles();
|
||||
if (visibleFiles.length === 0) {
|
||||
ctx.dom.animetoshoFilesSection.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.animetoshoFilesSection.classList.remove('hidden');
|
||||
visibleFiles.forEach((file, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = file.filename;
|
||||
|
||||
const details: string[] = [];
|
||||
if (file.lang) details.push(file.lang);
|
||||
if (file.trackName) details.push(file.trackName);
|
||||
details.push(formatBytes(file.size));
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'jimaku-subtext';
|
||||
sub.textContent = details.filter(Boolean).join(' • ');
|
||||
li.appendChild(sub);
|
||||
|
||||
if (index === ctx.state.selectedAnimetoshoFileIndex) {
|
||||
li.classList.add('active');
|
||||
}
|
||||
|
||||
li.addEventListener('click', () => {
|
||||
void selectFile(index);
|
||||
});
|
||||
|
||||
ctx.dom.animetoshoFilesList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function getSearchQuery(): string {
|
||||
const title = ctx.dom.animetoshoTitleInput.value.trim();
|
||||
if (!title) return '';
|
||||
const episodeValue = ctx.dom.animetoshoEpisodeInput.value
|
||||
? Number.parseInt(ctx.dom.animetoshoEpisodeInput.value, 10)
|
||||
: null;
|
||||
if (episodeValue !== null && Number.isFinite(episodeValue)) {
|
||||
return `${title} ${String(episodeValue).padStart(2, '0')}`;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
async function performAnimetoshoSearch(): Promise<void> {
|
||||
const query = getSearchQuery();
|
||||
if (!query) {
|
||||
setAnimetoshoStatus('Enter a title before searching.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
resetAnimetoshoLists();
|
||||
setAnimetoshoStatus('Searching Animetosho...');
|
||||
|
||||
const response: AnimetoshoApiResponse<AnimetoshoEntry[]> =
|
||||
await window.electronAPI.animetoshoSearchEntries({ query });
|
||||
if (!response.ok) {
|
||||
setAnimetoshoStatus(response.error.error, true);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.animetoshoEntries = response.data;
|
||||
ctx.state.selectedAnimetoshoEntryIndex = 0;
|
||||
|
||||
if (ctx.state.animetoshoEntries.length === 0) {
|
||||
setAnimetoshoStatus('No releases found.');
|
||||
return;
|
||||
}
|
||||
|
||||
setAnimetoshoStatus('Select a release.');
|
||||
renderEntries();
|
||||
if (ctx.state.animetoshoEntries.length === 1) {
|
||||
selectEntry(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFiles(entryId: number): Promise<void> {
|
||||
setAnimetoshoStatus('Loading subtitle tracks...');
|
||||
ctx.state.animetoshoFiles = [];
|
||||
ctx.state.selectedAnimetoshoFileIndex = 0;
|
||||
|
||||
ctx.dom.animetoshoFilesList.innerHTML = '';
|
||||
ctx.dom.animetoshoFilesSection.classList.add('hidden');
|
||||
|
||||
const response: AnimetoshoApiResponse<AnimetoshoSubtitleFile[]> =
|
||||
await window.electronAPI.animetoshoListFiles({ entryId });
|
||||
// The user may have picked another release while this was in flight.
|
||||
if (ctx.state.currentAnimetoshoEntryId !== entryId) return;
|
||||
if (!response.ok) {
|
||||
setAnimetoshoStatus(response.error.error, true);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.animetoshoFiles = response.data;
|
||||
if (ctx.state.animetoshoFiles.length === 0) {
|
||||
const entry = ctx.state.animetoshoEntries.find((candidate) => candidate.id === entryId);
|
||||
// The feed API omits per-file attachment data for multi-file torrents.
|
||||
if (entry && entry.numFiles !== null && entry.numFiles > 1) {
|
||||
setAnimetoshoStatus(
|
||||
'Batch releases are not supported. Pick a single-episode release instead.',
|
||||
);
|
||||
} else {
|
||||
setAnimetoshoStatus('No text subtitle tracks in this release. Try another one.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleFiles = getVisibleFiles();
|
||||
if (visibleFiles.length === 0) {
|
||||
setAnimetoshoStatus(describeEmptyTab());
|
||||
return;
|
||||
}
|
||||
|
||||
setAnimetoshoStatus('Select a subtitle track.');
|
||||
renderFiles();
|
||||
if (visibleFiles.length === 1) {
|
||||
await selectFile(0);
|
||||
}
|
||||
}
|
||||
|
||||
function selectEntry(index: number): void {
|
||||
if (index < 0 || index >= ctx.state.animetoshoEntries.length) return;
|
||||
|
||||
ctx.state.selectedAnimetoshoEntryIndex = index;
|
||||
ctx.state.currentAnimetoshoEntryId = ctx.state.animetoshoEntries[index]!.id;
|
||||
renderEntries();
|
||||
|
||||
if (ctx.state.currentAnimetoshoEntryId !== null) {
|
||||
void loadFiles(ctx.state.currentAnimetoshoEntryId);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFile(index: number): Promise<void> {
|
||||
const visibleFiles = getVisibleFiles();
|
||||
if (index < 0 || index >= visibleFiles.length) return;
|
||||
|
||||
ctx.state.selectedAnimetoshoFileIndex = index;
|
||||
renderFiles();
|
||||
|
||||
if (ctx.state.currentAnimetoshoEntryId === null) {
|
||||
setAnimetoshoStatus('Select a release first.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = visibleFiles[index]!;
|
||||
setAnimetoshoStatus('Downloading subtitle...');
|
||||
|
||||
const result: AnimetoshoDownloadResult = await window.electronAPI.animetoshoDownloadFile({
|
||||
entryId: ctx.state.currentAnimetoshoEntryId,
|
||||
url: file.url,
|
||||
name: file.filename,
|
||||
lang: file.lang,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
setAnimetoshoStatus(`Downloaded and loaded: ${result.path}`);
|
||||
closeAnimetoshoModal();
|
||||
return;
|
||||
}
|
||||
|
||||
setAnimetoshoStatus(result.error.error, true);
|
||||
}
|
||||
|
||||
function isTextInputFocused(): boolean {
|
||||
const active = document.activeElement;
|
||||
if (!active) return false;
|
||||
const tag = active.tagName.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea';
|
||||
}
|
||||
|
||||
async function loadSecondaryLanguages(): Promise<void> {
|
||||
try {
|
||||
const languages = await window.electronAPI.animetoshoGetSecondaryLanguages();
|
||||
secondaryLanguages = languages.length > 0 ? languages : ['en'];
|
||||
} catch {
|
||||
secondaryLanguages = ['en'];
|
||||
}
|
||||
ctx.dom.animetoshoTabEnglishButton.textContent = secondaryTabLabel();
|
||||
// Tracks may already be on screen if the languages arrived late.
|
||||
if (ctx.state.animetoshoFiles.length > 0) {
|
||||
renderFiles();
|
||||
}
|
||||
}
|
||||
|
||||
function openAnimetoshoModal(): void {
|
||||
if (ctx.state.animetoshoModalOpen) return;
|
||||
|
||||
ctx.state.animetoshoModalOpen = true;
|
||||
ctx.state.animetoshoActiveTab = 'en';
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.animetoshoModal.classList.remove('hidden');
|
||||
ctx.dom.animetoshoModal.setAttribute('aria-hidden', 'false');
|
||||
|
||||
setAnimetoshoStatus('Loading media info...');
|
||||
resetAnimetoshoLists();
|
||||
renderTabs();
|
||||
|
||||
const secondaryLanguagesReady = loadSecondaryLanguages();
|
||||
|
||||
window.electronAPI
|
||||
.getJimakuMediaInfo()
|
||||
.then(async (info: JimakuMediaInfo) => {
|
||||
ctx.dom.animetoshoTitleInput.value = info.title || '';
|
||||
ctx.dom.animetoshoEpisodeInput.value = info.episode ? String(info.episode) : '';
|
||||
|
||||
if (info.confidence === 'high' && info.title && info.episode) {
|
||||
await secondaryLanguagesReady;
|
||||
void performAnimetoshoSearch();
|
||||
} else if (info.title) {
|
||||
setAnimetoshoStatus('Check title/episode and press Search.');
|
||||
} else {
|
||||
setAnimetoshoStatus('Enter title/episode and press Search.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setAnimetoshoStatus('Failed to load media info.', true);
|
||||
});
|
||||
}
|
||||
|
||||
function closeAnimetoshoModal(): void {
|
||||
if (!ctx.state.animetoshoModalOpen) return;
|
||||
|
||||
ctx.state.animetoshoModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.animetoshoModal.classList.add('hidden');
|
||||
ctx.dom.animetoshoModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('animetosho');
|
||||
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
|
||||
resetAnimetoshoLists();
|
||||
}
|
||||
|
||||
function handleAnimetoshoKeydown(e: KeyboardEvent): boolean {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAnimetoshoModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTextInputFocused()) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void performAnimetoshoSearch();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
setActiveTab('en');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
setActiveTab('ja');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const visibleFiles = getVisibleFiles();
|
||||
if (visibleFiles.length > 0) {
|
||||
ctx.state.selectedAnimetoshoFileIndex = Math.min(
|
||||
visibleFiles.length - 1,
|
||||
ctx.state.selectedAnimetoshoFileIndex + 1,
|
||||
);
|
||||
renderFiles();
|
||||
} else if (ctx.state.animetoshoEntries.length > 0) {
|
||||
ctx.state.selectedAnimetoshoEntryIndex = Math.min(
|
||||
ctx.state.animetoshoEntries.length - 1,
|
||||
ctx.state.selectedAnimetoshoEntryIndex + 1,
|
||||
);
|
||||
renderEntries();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (getVisibleFiles().length > 0) {
|
||||
ctx.state.selectedAnimetoshoFileIndex = Math.max(
|
||||
0,
|
||||
ctx.state.selectedAnimetoshoFileIndex - 1,
|
||||
);
|
||||
renderFiles();
|
||||
} else if (ctx.state.animetoshoEntries.length > 0) {
|
||||
ctx.state.selectedAnimetoshoEntryIndex = Math.max(
|
||||
0,
|
||||
ctx.state.selectedAnimetoshoEntryIndex - 1,
|
||||
);
|
||||
renderEntries();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (getVisibleFiles().length > 0) {
|
||||
void selectFile(ctx.state.selectedAnimetoshoFileIndex);
|
||||
} else if (ctx.state.animetoshoEntries.length > 0) {
|
||||
selectEntry(ctx.state.selectedAnimetoshoEntryIndex);
|
||||
} else {
|
||||
void performAnimetoshoSearch();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.animetoshoSearchButton.addEventListener('click', () => {
|
||||
void performAnimetoshoSearch();
|
||||
});
|
||||
ctx.dom.animetoshoCloseButton.addEventListener('click', () => {
|
||||
closeAnimetoshoModal();
|
||||
});
|
||||
ctx.dom.animetoshoTabEnglishButton.addEventListener('click', () => {
|
||||
setActiveTab('en');
|
||||
});
|
||||
ctx.dom.animetoshoTabJapaneseButton.addEventListener('click', () => {
|
||||
setActiveTab('ja');
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeAnimetoshoModal,
|
||||
handleAnimetoshoKeydown,
|
||||
openAnimetoshoModal,
|
||||
selectAnimetoshoEntry: selectEntry,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
@@ -104,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 animetosho';
|
||||
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,8 +223,8 @@ function describeSessionAction(
|
||||
return 'Open controller debug';
|
||||
case 'openJimaku':
|
||||
return 'Open jimaku';
|
||||
case 'openAnimetosho':
|
||||
return 'Open animetosho';
|
||||
case 'openTsukihime':
|
||||
return 'Open TsukiHime';
|
||||
case 'openYoutubePicker':
|
||||
return 'Open YouTube subtitle picker';
|
||||
case 'openPlaylistBrowser':
|
||||
@@ -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':
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { TsukihimeSubtitleFile, ElectronAPI } from '../../types';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createTsukihimeModal } from './tsukihime.js';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
add: (...entries: string[]) => {
|
||||
for (const entry of entries) {
|
||||
tokens.add(entry);
|
||||
}
|
||||
},
|
||||
remove: (...entries: string[]) => {
|
||||
for (const entry of entries) {
|
||||
tokens.delete(entry);
|
||||
}
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
};
|
||||
}
|
||||
|
||||
function createElementStub() {
|
||||
const classList = createClassList();
|
||||
return {
|
||||
textContent: '',
|
||||
className: '',
|
||||
style: {},
|
||||
classList,
|
||||
children: [] as unknown[],
|
||||
appendChild(child: unknown) {
|
||||
this.children.push(child);
|
||||
},
|
||||
addEventListener: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function createListStub() {
|
||||
return {
|
||||
innerHTML: '',
|
||||
children: [] as unknown[],
|
||||
appendChild(child: unknown) {
|
||||
this.children.push(child);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTabStub(active: boolean) {
|
||||
const attributes = new Map<string, string>();
|
||||
return {
|
||||
textContent: '',
|
||||
classList: createClassList(active ? ['active'] : []),
|
||||
attributes,
|
||||
setAttribute(name: string, value: string) {
|
||||
attributes.set(name, value);
|
||||
},
|
||||
addEventListener: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function flushAsyncWork(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
const ENGLISH_TRACK: TsukihimeSubtitleFile = {
|
||||
attachmentId: 1955356,
|
||||
filename: 'episode01.eng.ass',
|
||||
lang: 'eng',
|
||||
trackName: 'English subs',
|
||||
size: 33075,
|
||||
url: 'https://storage.tsukihime.org/attach/001dd61c/1955356.xz',
|
||||
sourceFilename: 'episode01.mkv',
|
||||
};
|
||||
|
||||
const JAPANESE_TRACK: TsukihimeSubtitleFile = {
|
||||
attachmentId: 1955400,
|
||||
filename: 'episode01.jpn.ass',
|
||||
lang: 'jpn',
|
||||
trackName: 'Japanese subs',
|
||||
size: 41000,
|
||||
url: 'https://storage.tsukihime.org/attach/001dd648/1955400.xz',
|
||||
sourceFilename: 'episode01.mkv',
|
||||
};
|
||||
|
||||
const GERMAN_TRACK: TsukihimeSubtitleFile = {
|
||||
attachmentId: 1955500,
|
||||
filename: 'episode01.ger.ass',
|
||||
lang: 'ger',
|
||||
trackName: 'Deutsch',
|
||||
size: 28000,
|
||||
url: 'https://storage.tsukihime.org/attach/001dd6ac/1955500.xz',
|
||||
sourceFilename: 'episode01.mkv',
|
||||
};
|
||||
|
||||
interface ModalHarness {
|
||||
modal: ReturnType<typeof createTsukihimeModal>;
|
||||
state: ReturnType<typeof createRendererState>;
|
||||
downloadQueries: unknown[];
|
||||
modalCloseNotifications: string[];
|
||||
overlayClassList: ReturnType<typeof createClassList>;
|
||||
tsukihimeModalClassList: ReturnType<typeof createClassList>;
|
||||
titleInput: { value: string };
|
||||
status: { textContent: string; style: { color: string } };
|
||||
entriesList: ReturnType<typeof createListStub>;
|
||||
filesList: ReturnType<typeof createListStub>;
|
||||
secondaryTab: ReturnType<typeof createTabStub>;
|
||||
primaryTab: ReturnType<typeof createTabStub>;
|
||||
restoreGlobals: () => void;
|
||||
}
|
||||
|
||||
function createModalHarness(
|
||||
files: TsukihimeSubtitleFile[],
|
||||
options: {
|
||||
secondaryLanguages?: string[];
|
||||
downloadFile?: (query: unknown) => Promise<unknown>;
|
||||
listFiles?: (entryId: number) => Promise<unknown>;
|
||||
searchEntries?: (query: unknown) => Promise<unknown>;
|
||||
} = {},
|
||||
): ModalHarness {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, 'window');
|
||||
const hadDocument = Object.prototype.hasOwnProperty.call(globalThis, 'document');
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const modalCloseNotifications: string[] = [];
|
||||
const downloadQueries: unknown[] = [];
|
||||
|
||||
const electronAPI = {
|
||||
tsukihimeDownloadFile: async (query: unknown) => {
|
||||
downloadQueries.push(query);
|
||||
if (options.downloadFile) return options.downloadFile(query);
|
||||
return { ok: true, path: '/tmp/subtitles/episode01.en.ass' };
|
||||
},
|
||||
tsukihimeGetSecondaryLanguages: async () => options.secondaryLanguages ?? ['en', 'eng'],
|
||||
tsukihimeListFiles: async ({ entryId }: { entryId: number }) =>
|
||||
options.listFiles ? options.listFiles(entryId) : { ok: true, data: [] },
|
||||
tsukihimeSearchEntries: async (query: unknown) =>
|
||||
options.searchEntries ? options.searchEntries(query) : { ok: true, data: [] },
|
||||
getJimakuMediaInfo: async () => ({
|
||||
title: '',
|
||||
season: null,
|
||||
episode: null,
|
||||
confidence: 'low',
|
||||
filename: '',
|
||||
rawTitle: '',
|
||||
}),
|
||||
notifyOverlayModalClosed: (modal: string) => {
|
||||
modalCloseNotifications.push(modal);
|
||||
},
|
||||
} as unknown as ElectronAPI;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { electronAPI },
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
activeElement: null,
|
||||
createElement: () => createElementStub(),
|
||||
},
|
||||
});
|
||||
|
||||
const overlayClassList = createClassList(['interactive']);
|
||||
const tsukihimeModalClassList = createClassList();
|
||||
const state = createRendererState();
|
||||
state.tsukihimeModalOpen = true;
|
||||
state.currentTsukihimeEntryId = 606713;
|
||||
state.selectedTsukihimeFileIndex = 0;
|
||||
state.tsukihimeFiles = files;
|
||||
const secondaryTab = createTabStub(true);
|
||||
secondaryTab.textContent = 'English';
|
||||
const primaryTab = createTabStub(false);
|
||||
primaryTab.textContent = 'Japanese';
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: overlayClassList },
|
||||
tsukihimeModal: {
|
||||
classList: tsukihimeModalClassList,
|
||||
setAttribute: () => {},
|
||||
},
|
||||
tsukihimeTitleInput: { value: '' },
|
||||
tsukihimeEpisodeInput: { value: '' },
|
||||
tsukihimeSearchButton: { addEventListener: () => {} },
|
||||
tsukihimeCloseButton: { addEventListener: () => {} },
|
||||
tsukihimeTabSecondaryButton: secondaryTab,
|
||||
tsukihimeTabPrimaryButton: primaryTab,
|
||||
tsukihimeStatus: { textContent: '', style: { color: '' } },
|
||||
tsukihimeEntriesSection: { classList: createClassList(['hidden']) },
|
||||
tsukihimeEntriesList: createListStub(),
|
||||
tsukihimeFilesSection: { classList: createClassList() },
|
||||
tsukihimeFilesList: createListStub(),
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createTsukihimeModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
return {
|
||||
modal,
|
||||
state,
|
||||
downloadQueries,
|
||||
modalCloseNotifications,
|
||||
overlayClassList,
|
||||
tsukihimeModalClassList,
|
||||
titleInput: ctx.dom.tsukihimeTitleInput,
|
||||
status: ctx.dom.tsukihimeStatus,
|
||||
entriesList: ctx.dom.tsukihimeEntriesList,
|
||||
filesList: ctx.dom.tsukihimeFilesList,
|
||||
secondaryTab,
|
||||
primaryTab,
|
||||
restoreGlobals: () => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
if (hadWindow) {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
} else {
|
||||
delete target.window;
|
||||
}
|
||||
if (hadDocument) {
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: previousDocument,
|
||||
});
|
||||
} else {
|
||||
delete target.document;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('TsukiHime language tabs expose tab semantics in the renderer markup', () => {
|
||||
const html = fs.readFileSync(path.join(process.cwd(), 'src', 'renderer', 'index.html'), 'utf8');
|
||||
const tabs = html.match(/<div class="tsukihime-tabs"[\s\S]*?<\/div>/)?.[0];
|
||||
|
||||
assert.ok(tabs);
|
||||
assert.match(tabs, /<div class="tsukihime-tabs" role="tablist">/);
|
||||
assert.match(tabs, /id="tsukihimeTabSecondary"[\s\S]*?role="tab"[\s\S]*?aria-selected="true"/);
|
||||
assert.match(tabs, /id="tsukihimeTabPrimary"[\s\S]*?role="tab"[\s\S]*?aria-selected="false"/);
|
||||
});
|
||||
|
||||
test('switching TsukiHime language tabs synchronizes aria-selected', () => {
|
||||
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
|
||||
try {
|
||||
pressKey(harness, 'ArrowRight');
|
||||
|
||||
assert.equal(harness.secondaryTab.attributes.get('aria-selected'), 'false');
|
||||
assert.equal(harness.primaryTab.attributes.get('aria-selected'), 'true');
|
||||
|
||||
pressKey(harness, 'ArrowLeft');
|
||||
|
||||
assert.equal(harness.secondaryTab.attributes.get('aria-selected'), 'true');
|
||||
assert.equal(harness.primaryTab.attributes.get('aria-selected'), 'false');
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
function pressKey(harness: ModalHarness, key: string): boolean {
|
||||
let prevented = false;
|
||||
harness.modal.handleTsukihimeKeydown({
|
||||
key,
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
} as KeyboardEvent);
|
||||
return prevented;
|
||||
}
|
||||
|
||||
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.tsukihimeModalOpen, false);
|
||||
assert.equal(harness.tsukihimeModalClassList.contains('hidden'), true);
|
||||
assert.equal(harness.overlayClassList.contains('interactive'), false);
|
||||
assert.deepEqual(harness.modalCloseNotifications, ['tsukihime']);
|
||||
assert.deepEqual(harness.downloadQueries, [
|
||||
{
|
||||
entryId: 606713,
|
||||
url: ENGLISH_TRACK.url,
|
||||
name: ENGLISH_TRACK.filename,
|
||||
lang: 'eng',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('a download from a prior modal session cannot close a reopened modal', async () => {
|
||||
let resolveDownload!: (value: unknown) => void;
|
||||
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK], {
|
||||
downloadFile: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveDownload = resolve;
|
||||
}),
|
||||
});
|
||||
try {
|
||||
pressKey(harness, 'Enter');
|
||||
harness.modal.closeTsukihimeModal();
|
||||
harness.modal.openTsukihimeModal();
|
||||
await flushAsyncWork();
|
||||
harness.state.currentTsukihimeEntryId = 606713;
|
||||
harness.status.textContent = 'Fresh modal session';
|
||||
|
||||
resolveDownload({ ok: true, path: '/tmp/subtitles/stale.ass' });
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(harness.state.tsukihimeModalOpen, true);
|
||||
assert.equal(harness.status.textContent, 'Fresh modal session');
|
||||
assert.deepEqual(harness.modalCloseNotifications, ['tsukihime']);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('a download cannot affect a newly selected release', async () => {
|
||||
let resolveDownload!: (value: unknown) => void;
|
||||
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK], {
|
||||
downloadFile: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveDownload = resolve;
|
||||
}),
|
||||
});
|
||||
try {
|
||||
pressKey(harness, 'Enter');
|
||||
harness.state.tsukihimeEntries = [
|
||||
{
|
||||
id: 999,
|
||||
title: 'new release',
|
||||
timestamp: null,
|
||||
totalSize: null,
|
||||
numFiles: 1,
|
||||
sublangs: [],
|
||||
},
|
||||
];
|
||||
harness.modal.selectTsukihimeEntry(0);
|
||||
await flushAsyncWork();
|
||||
const currentStatus = harness.status.textContent;
|
||||
|
||||
resolveDownload({ ok: false, error: { error: 'stale failure' } });
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(harness.state.tsukihimeModalOpen, true);
|
||||
assert.equal(harness.state.currentTsukihimeEntryId, 999);
|
||||
assert.equal(harness.status.textContent, currentStatus);
|
||||
assert.deepEqual(harness.modalCloseNotifications, []);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('secondary tab hides languages outside its configured defaults', async () => {
|
||||
const harness = createModalHarness([GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK]);
|
||||
try {
|
||||
// With German visible this would move selection onto it; English-only
|
||||
// filtering must clamp to the single English track instead.
|
||||
pressKey(harness, 'ArrowDown');
|
||||
pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.deepEqual(harness.downloadQueries, [
|
||||
{
|
||||
entryId: 606713,
|
||||
url: ENGLISH_TRACK.url,
|
||||
name: ENGLISH_TRACK.filename,
|
||||
lang: 'eng',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('primary tab defaults to Japanese tracks', async () => {
|
||||
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
|
||||
try {
|
||||
assert.equal(harness.state.tsukihimeActiveTab, 'secondary');
|
||||
pressKey(harness, 'ArrowRight');
|
||||
assert.equal(harness.state.tsukihimeActiveTab, 'primary');
|
||||
|
||||
pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.deepEqual(harness.downloadQueries, [
|
||||
{
|
||||
entryId: 606713,
|
||||
url: JAPANESE_TRACK.url,
|
||||
name: JAPANESE_TRACK.filename,
|
||||
lang: 'jpn',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('secondary tab follows configured secondarySub languages', async () => {
|
||||
const harness = createModalHarness([GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK], {
|
||||
secondaryLanguages: ['de'],
|
||||
});
|
||||
try {
|
||||
// Re-open through the API so the modal fetches the configured languages.
|
||||
harness.state.tsukihimeModalOpen = false;
|
||||
harness.modal.openTsukihimeModal();
|
||||
await flushAsyncWork();
|
||||
|
||||
harness.state.tsukihimeFiles = [GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK];
|
||||
harness.state.currentTsukihimeEntryId = 606713;
|
||||
|
||||
pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.deepEqual(harness.downloadQueries, [
|
||||
{
|
||||
entryId: 606713,
|
||||
url: GERMAN_TRACK.url,
|
||||
name: GERMAN_TRACK.filename,
|
||||
lang: 'ger',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('primary tab remains Japanese when the release includes other languages', async () => {
|
||||
const harness = createModalHarness([GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK], {
|
||||
secondaryLanguages: ['en'],
|
||||
});
|
||||
try {
|
||||
harness.state.tsukihimeModalOpen = false;
|
||||
harness.modal.openTsukihimeModal();
|
||||
await flushAsyncWork();
|
||||
|
||||
harness.state.tsukihimeFiles = [GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK];
|
||||
harness.state.currentTsukihimeEntryId = 606713;
|
||||
|
||||
pressKey(harness, 'ArrowRight');
|
||||
pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.deepEqual(harness.downloadQueries, [
|
||||
{
|
||||
entryId: 606713,
|
||||
url: JAPANESE_TRACK.url,
|
||||
name: JAPANESE_TRACK.filename,
|
||||
lang: 'jpn',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('a slow release response does not overwrite the newly selected release', async () => {
|
||||
const STALE_TRACK: TsukihimeSubtitleFile = {
|
||||
...ENGLISH_TRACK,
|
||||
attachmentId: 999,
|
||||
filename: 'stale.eng.ass',
|
||||
};
|
||||
const SECOND_ENGLISH_TRACK: TsukihimeSubtitleFile = {
|
||||
...ENGLISH_TRACK,
|
||||
attachmentId: 1955357,
|
||||
filename: 'episode01.eng.sdh.ass',
|
||||
};
|
||||
const resolvers: Array<(value: unknown) => void> = [];
|
||||
|
||||
const harness = createModalHarness([], {
|
||||
listFiles: (entryId) =>
|
||||
new Promise((resolve) => {
|
||||
if (entryId === 1) {
|
||||
// Entry 1 answers late, after the user has moved on to entry 2.
|
||||
resolvers.push(() => resolve({ ok: true, data: [STALE_TRACK] }));
|
||||
} else {
|
||||
// Two tracks, so the modal does not auto-download a lone match.
|
||||
resolve({ ok: true, data: [ENGLISH_TRACK, SECOND_ENGLISH_TRACK] });
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
harness.state.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.selectTsukihimeEntry(0);
|
||||
harness.modal.selectTsukihimeEntry(1);
|
||||
await flushAsyncWork();
|
||||
|
||||
// Entry 2's tracks are on screen; now entry 1 finally answers.
|
||||
assert.deepEqual(
|
||||
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.currentTsukihimeEntryId, 2);
|
||||
assert.deepEqual(
|
||||
harness.state.tsukihimeFiles.map((file) => file.attachmentId),
|
||||
[ENGLISH_TRACK.attachmentId, SECOND_ENGLISH_TRACK.attachmentId],
|
||||
);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('ArrowLeft switches back to the secondary-language tab', () => {
|
||||
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
|
||||
try {
|
||||
pressKey(harness, 'ArrowRight');
|
||||
assert.equal(harness.state.tsukihimeActiveTab, 'primary');
|
||||
pressKey(harness, 'ArrowLeft');
|
||||
assert.equal(harness.state.tsukihimeActiveTab, 'secondary');
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('searching reports TsukiHime as the backend and lists sublangs per release', async () => {
|
||||
const harness = createModalHarness([], {
|
||||
searchEntries: async () => ({
|
||||
ok: true,
|
||||
data: [
|
||||
{
|
||||
id: 12255,
|
||||
title: '[DKB] Futsutsuka na Akujo - S01E01 [Multi-Subs]',
|
||||
timestamp: null,
|
||||
totalSize: 423868898,
|
||||
numFiles: 1,
|
||||
sublangs: ['en-US', 'ja'],
|
||||
},
|
||||
{
|
||||
id: 12256,
|
||||
title: 'release without langs',
|
||||
timestamp: null,
|
||||
totalSize: null,
|
||||
numFiles: null,
|
||||
sublangs: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
try {
|
||||
harness.state.tsukihimeFiles = [];
|
||||
harness.state.currentTsukihimeEntryId = null;
|
||||
harness.titleInput.value = 'Futsutsuka na Akujo';
|
||||
|
||||
const seenStatuses: string[] = [];
|
||||
const status = harness.status;
|
||||
Object.defineProperty(status, 'textContent', {
|
||||
get: () => seenStatuses.at(-1) ?? '',
|
||||
set: (value: string) => {
|
||||
seenStatuses.push(value);
|
||||
},
|
||||
});
|
||||
|
||||
pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(
|
||||
seenStatuses.some((message) => message.includes('TsukiHime')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
seenStatuses.some((message) => message.includes('Tsukihime')),
|
||||
false,
|
||||
);
|
||||
|
||||
const firstEntry = harness.entriesList.children[0] as {
|
||||
children: Array<{ textContent: string }>;
|
||||
};
|
||||
assert.equal(firstEntry.children.length, 1);
|
||||
assert.match(firstEntry.children[0]!.textContent, /en-US, ja/);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('renderFiles omits the size detail when the API does not report one', () => {
|
||||
const zeroSizeTrack: TsukihimeSubtitleFile = {
|
||||
...ENGLISH_TRACK,
|
||||
size: 0,
|
||||
};
|
||||
const harness = createModalHarness([zeroSizeTrack, JAPANESE_TRACK]);
|
||||
try {
|
||||
pressKey(harness, 'ArrowDown');
|
||||
|
||||
const firstFile = harness.filesList.children[0] as {
|
||||
children: Array<{ textContent: string }>;
|
||||
};
|
||||
assert.equal(firstFile.children.length, 1);
|
||||
assert.equal(firstFile.children[0]!.textContent.includes('0 B'), false);
|
||||
assert.match(firstFile.children[0]!.textContent, /English subs/);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
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 secondary languages arrive.
|
||||
let secondaryLanguages: string[] = ['en'];
|
||||
let activeDownloadToken = 0;
|
||||
|
||||
function secondaryTabLabel(): string {
|
||||
return describeTsukihimeTabLanguages(secondaryLanguages);
|
||||
}
|
||||
|
||||
function isPrimaryTrack(file: TsukihimeSubtitleFile): boolean {
|
||||
return normalizeTsukihimeLangCode(file.lang) === 'ja';
|
||||
}
|
||||
|
||||
function getVisibleFiles(): TsukihimeSubtitleFile[] {
|
||||
if (ctx.state.tsukihimeActiveTab === 'primary') {
|
||||
return ctx.state.tsukihimeFiles.filter(isPrimaryTrack);
|
||||
}
|
||||
return ctx.state.tsukihimeFiles.filter(
|
||||
(file) =>
|
||||
!isPrimaryTrack(file) && tsukihimeTrackMatchesLanguages(file.lang, secondaryLanguages),
|
||||
);
|
||||
}
|
||||
|
||||
function renderTabs(): void {
|
||||
const primaryActive = ctx.state.tsukihimeActiveTab === 'primary';
|
||||
ctx.dom.tsukihimeTabSecondaryButton.setAttribute(
|
||||
'aria-selected',
|
||||
primaryActive ? 'false' : 'true',
|
||||
);
|
||||
ctx.dom.tsukihimeTabPrimaryButton.setAttribute(
|
||||
'aria-selected',
|
||||
primaryActive ? 'true' : 'false',
|
||||
);
|
||||
if (primaryActive) {
|
||||
ctx.dom.tsukihimeTabSecondaryButton.classList.remove('active');
|
||||
ctx.dom.tsukihimeTabPrimaryButton.classList.add('active');
|
||||
} else {
|
||||
ctx.dom.tsukihimeTabSecondaryButton.classList.add('active');
|
||||
ctx.dom.tsukihimeTabPrimaryButton.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function describeEmptyTab(): string {
|
||||
const hiddenCount = ctx.state.tsukihimeFiles.length;
|
||||
if (ctx.state.tsukihimeActiveTab === 'primary') {
|
||||
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: 'secondary' | 'primary'): 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 entryId = ctx.state.currentTsukihimeEntryId;
|
||||
const file = visibleFiles[index]!;
|
||||
const downloadToken = ++activeDownloadToken;
|
||||
setTsukihimeStatus('Downloading subtitle...');
|
||||
|
||||
const result: TsukihimeDownloadResult = await window.electronAPI.tsukihimeDownloadFile({
|
||||
entryId,
|
||||
url: file.url,
|
||||
name: file.filename,
|
||||
lang: file.lang,
|
||||
});
|
||||
if (downloadToken !== activeDownloadToken || ctx.state.currentTsukihimeEntryId !== entryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 configuredSecondary = await window.electronAPI.tsukihimeGetSecondaryLanguages();
|
||||
secondaryLanguages = configuredSecondary.length > 0 ? configuredSecondary : ['en'];
|
||||
} catch {
|
||||
secondaryLanguages = ['en'];
|
||||
}
|
||||
ctx.dom.tsukihimeTabSecondaryButton.textContent = secondaryTabLabel();
|
||||
ctx.dom.tsukihimeTabPrimaryButton.textContent = 'Japanese';
|
||||
// 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 = 'secondary';
|
||||
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;
|
||||
|
||||
activeDownloadToken += 1;
|
||||
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('secondary');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
setActiveTab('primary');
|
||||
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.tsukihimeTabSecondaryButton.addEventListener('click', () => {
|
||||
setActiveTab('secondary');
|
||||
});
|
||||
ctx.dom.tsukihimeTabPrimaryButton.addEventListener('click', () => {
|
||||
setActiveTab('primary');
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeTsukihimeModal,
|
||||
handleTsukihimeKeydown,
|
||||
openTsukihimeModal,
|
||||
selectTsukihimeEntry: selectEntry,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
+11
-11
@@ -33,7 +33,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';
|
||||
@@ -102,9 +102,9 @@ const modalDescriptors = [
|
||||
suppressesSubtitles: true,
|
||||
},
|
||||
{
|
||||
id: 'animetosho',
|
||||
isOpen: () => ctx.state.animetoshoModalOpen,
|
||||
close: () => animetoshoModal.closeAnimetoshoModal(),
|
||||
id: 'tsukihime',
|
||||
isOpen: () => ctx.state.tsukihimeModalOpen,
|
||||
close: () => tsukihimeModal.closeTsukihimeModal(),
|
||||
suppressesSubtitles: false,
|
||||
},
|
||||
{
|
||||
@@ -229,7 +229,7 @@ const jimakuModal = createJimakuModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const animetoshoModal = createAnimetoshoModal(ctx, {
|
||||
const tsukihimeModal = createTsukihimeModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
@@ -260,7 +260,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,
|
||||
@@ -545,10 +545,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) => {
|
||||
@@ -785,7 +785,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: 'secondary' | 'primary';
|
||||
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: 'secondary',
|
||||
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;
|
||||
tsukihimeTabSecondaryButton: HTMLButtonElement;
|
||||
tsukihimeTabPrimaryButton: 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'),
|
||||
tsukihimeTabSecondaryButton: getRequiredElement<HTMLButtonElement>('tsukihimeTabSecondary'),
|
||||
tsukihimeTabPrimaryButton: getRequiredElement<HTMLButtonElement>('tsukihimeTabPrimary'),
|
||||
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',
|
||||
getConfigSettingsSnapshot: 'config:get-settings-snapshot',
|
||||
saveConfigSettingsPatch: 'config:save-settings-patch',
|
||||
@@ -138,7 +138,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 },
|
||||
@@ -117,3 +117,19 @@ test('animetoshoFetchJson still parses a normal response', async () => {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('tsukihimeFetchJson reports a structured error for a malformed base URL', async () => {
|
||||
const result = await tsukihimeFetchJson('/search/torrents', {}, { baseUrl: 'not a url' });
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) {
|
||||
assert.match(result.error.error, /base url/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('tsukihimeFetchJson rejects non-HTTP(S) base URLs', async () => {
|
||||
const result = await tsukihimeFetchJson('/search/torrents', {}, { baseUrl: 'file:///etc' });
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) {
|
||||
assert.match(result.error.error, /http/i);
|
||||
}
|
||||
});
|
||||
@@ -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),
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,349 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import {
|
||||
tsukihimeLangToFilenameSuffix,
|
||||
buildTsukihimeAttachmentUrl,
|
||||
decompressXzFile,
|
||||
extractTsukihimeSubtitleFiles,
|
||||
isTsukihimeDownloadUrl,
|
||||
mapTsukihimeSearchResults,
|
||||
} from './utils.js';
|
||||
|
||||
test('buildTsukihimeAttachmentUrl pads the attachment id to 8 hex digits', () => {
|
||||
assert.equal(
|
||||
buildTsukihimeAttachmentUrl(96412),
|
||||
'https://storage.tsukihime.org/attach/0001789c/96412.xz',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildTsukihimeAttachmentUrl uses the tosho mirror path for imported entries', () => {
|
||||
assert.equal(
|
||||
buildTsukihimeAttachmentUrl(2826332, { imported: true }),
|
||||
'https://storage.tsukihime.org/tosho/attach/002b205c/2826332.xz',
|
||||
);
|
||||
});
|
||||
|
||||
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('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('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('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('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(
|
||||
isTsukihimeDownloadUrl('https://storage.animetosho.org/attach/002b205c/2826332.xz'),
|
||||
true,
|
||||
);
|
||||
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('mapTsukihimeSearchResults maps valid results and caps to maxResults', () => {
|
||||
const payload = {
|
||||
total: 4,
|
||||
start: 0,
|
||||
limit: 50,
|
||||
error: false,
|
||||
results: [
|
||||
{
|
||||
id: 1000752022,
|
||||
state: 'completed',
|
||||
name: '[SubsPlease] Sousou no Frieren S2 - 10 (1080p) [7D35515E].mkv',
|
||||
totalsize: 1457009569,
|
||||
filecount: 1,
|
||||
sublangs: ['en'],
|
||||
audiolangs: ['ja'],
|
||||
source_date: 1774623761,
|
||||
added_date: 1774624861,
|
||||
animetosho: true,
|
||||
},
|
||||
{ id: 'bogus', name: 'missing numeric id' },
|
||||
{ id: 12255, name: '[DKB] Futsutsuka na Akujo - S01E01 [Multi-Subs]' },
|
||||
{ id: 12256, name: 'capped away' },
|
||||
],
|
||||
};
|
||||
|
||||
const entries = mapTsukihimeSearchResults(payload, 2);
|
||||
assert.equal(entries.length, 2);
|
||||
assert.deepEqual(entries[0], {
|
||||
id: 1000752022,
|
||||
title: '[SubsPlease] Sousou no Frieren S2 - 10 (1080p) [7D35515E].mkv',
|
||||
timestamp: 1774624861,
|
||||
totalSize: 1457009569,
|
||||
numFiles: 1,
|
||||
sublangs: ['en'],
|
||||
});
|
||||
assert.equal(entries[1]!.id, 12255);
|
||||
assert.equal(entries[1]!.totalSize, null);
|
||||
assert.equal(entries[1]!.numFiles, null);
|
||||
assert.deepEqual(entries[1]!.sublangs, []);
|
||||
});
|
||||
|
||||
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).
|
||||
const DETAIL_PAYLOAD = {
|
||||
id: 12255,
|
||||
name: '[DKB] Futsutsuka na Akujo dewa Gozaimasu ga - S01E01 [Multi-Subs]',
|
||||
files: [
|
||||
{
|
||||
id: 16179,
|
||||
filename: '[DKB] Futsutsuka na Akujo dewa Gozaimasu ga - S01E01 [Multi-Subs].mkv',
|
||||
attachments: [
|
||||
{
|
||||
id: 96400,
|
||||
type: 0,
|
||||
info: { name: 'arial.ttf', mime: 'application/x-truetype-font' },
|
||||
},
|
||||
{
|
||||
id: 96412,
|
||||
type: 1,
|
||||
info: { codec: 'ASS', lang: 'en-US', name: 'English subs', trackid: 2, tracknum: 3 },
|
||||
},
|
||||
{ id: 96499, type: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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);
|
||||
assert.equal(file.lang, 'en-us');
|
||||
assert.equal(file.trackName, 'English subs');
|
||||
assert.equal(file.size, 0);
|
||||
assert.equal(file.url, 'https://storage.tsukihime.org/attach/0001789c/96412.xz');
|
||||
assert.equal(
|
||||
file.sourceFilename,
|
||||
'[DKB] Futsutsuka na Akujo dewa Gozaimasu ga - S01E01 [Multi-Subs].mkv',
|
||||
);
|
||||
assert.equal(
|
||||
file.filename,
|
||||
'[DKB] Futsutsuka na Akujo dewa Gozaimasu ga - S01E01 [Multi-Subs].en-us.ass',
|
||||
);
|
||||
});
|
||||
|
||||
test('extractTsukihimeSubtitleFiles uses the tosho mirror for animetosho-imported entries', () => {
|
||||
const files = extractTsukihimeSubtitleFiles({
|
||||
id: 1000752022,
|
||||
animetosho: true,
|
||||
files: [
|
||||
{
|
||||
id: 1001361385,
|
||||
filename: '[SubsPlease] Sousou no Frieren S2 - 10 (1080p) [7D35515E].mkv',
|
||||
attachments: [
|
||||
{ id: 2826332, type: 1, info: { codec: 'ASS', lang: 'en', name: 'English subs' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(files[0]!.url, 'https://storage.tsukihime.org/tosho/attach/002b205c/2826332.xz');
|
||||
});
|
||||
|
||||
test('extractTsukihimeSubtitleFiles detects imported entries by id when the flag is absent', () => {
|
||||
const files = extractTsukihimeSubtitleFiles({
|
||||
id: 1000752022,
|
||||
files: [
|
||||
{
|
||||
id: 1001361385,
|
||||
filename: 'episode.mkv',
|
||||
attachments: [{ id: 2826332, type: 1, info: { codec: 'ASS', lang: 'en' } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(files[0]!.url, 'https://storage.tsukihime.org/tosho/attach/002b205c/2826332.xz');
|
||||
});
|
||||
|
||||
test('extractTsukihimeSubtitleFiles skips image-based subtitle codecs', () => {
|
||||
const files = extractTsukihimeSubtitleFiles({
|
||||
id: 1,
|
||||
files: [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'movie.mkv',
|
||||
attachments: [
|
||||
{ id: 10, type: 1, info: { codec: 'PGS', lang: 'en' } },
|
||||
{ id: 11, type: 1, info: { codec: 'VobSub', lang: 'en' } },
|
||||
{ id: 12, type: 1, info: { codec: 'SRT', lang: 'en' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
files.map((f) => f.attachmentId),
|
||||
[12],
|
||||
);
|
||||
assert.equal(files[0]!.filename, 'movie.en.srt');
|
||||
});
|
||||
|
||||
test('extractTsukihimeSubtitleFiles sorts English tracks first and disambiguates duplicates', () => {
|
||||
const files = extractTsukihimeSubtitleFiles({
|
||||
id: 1,
|
||||
files: [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'episode.mkv',
|
||||
attachments: [
|
||||
{ id: 21, type: 1, info: { codec: 'ASS', lang: 'de', name: 'Deutsch' } },
|
||||
{ id: 22, type: 1, info: { codec: 'ASS', lang: 'en', name: 'Signs & Songs' } },
|
||||
{ id: 23, type: 1, info: { codec: 'ASS', lang: 'en', name: 'Full Subtitles' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
files.map((f) => f.attachmentId),
|
||||
[22, 23, 21],
|
||||
);
|
||||
assert.equal(files[0]!.filename, 'episode.en.signs-songs.ass');
|
||||
assert.equal(files[1]!.filename, 'episode.en.full-subtitles.ass');
|
||||
assert.equal(files[2]!.filename, 'episode.de.ass');
|
||||
});
|
||||
|
||||
test('extractTsukihimeSubtitleFiles tolerates missing info fields', () => {
|
||||
const files = extractTsukihimeSubtitleFiles({
|
||||
id: 1,
|
||||
files: [
|
||||
{
|
||||
id: 1,
|
||||
attachments: [{ id: 31, type: 1, info: { codec: 'ASS' } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(files[0]!.lang, '');
|
||||
assert.equal(files[0]!.trackName, null);
|
||||
assert.equal(files[0]!.filename, 'subtitle.ass');
|
||||
});
|
||||
|
||||
const hasXz = (() => {
|
||||
try {
|
||||
execFileSync('xz', ['--version'], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
test('decompressXzFile round-trips an xz-compressed subtitle', { skip: !hasXz }, async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tsukihime-test-'));
|
||||
try {
|
||||
const plainPath = path.join(dir, 'sub.ass');
|
||||
const content = '[Script Info]\nTitle: test\n';
|
||||
fs.writeFileSync(plainPath, content, 'utf8');
|
||||
execFileSync('xz', ['-z', plainPath]);
|
||||
|
||||
const destPath = path.join(dir, 'out.ass');
|
||||
const result = await decompressXzFile(`${plainPath}.xz`, destPath);
|
||||
assert.equal(result.ok, true);
|
||||
if (result.ok) {
|
||||
assert.equal(result.path, destPath);
|
||||
}
|
||||
assert.equal(fs.readFileSync(destPath, 'utf8'), content);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('decompressXzFile reports an error for corrupt input', { skip: !hasXz }, async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tsukihime-test-'));
|
||||
try {
|
||||
const srcPath = path.join(dir, 'broken.xz');
|
||||
fs.writeFileSync(srcPath, 'not xz data');
|
||||
const result = await decompressXzFile(srcPath, path.join(dir, 'out.ass'));
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) {
|
||||
assert.match(result.error.error, /xz|decompress/i);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('extractTsukihimeSubtitleFiles disambiguates duplicate tracks without slug characters', () => {
|
||||
const files = extractTsukihimeSubtitleFiles({
|
||||
id: 1,
|
||||
files: [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'episode.mkv',
|
||||
// Both names slugify to an empty string.
|
||||
attachments: [
|
||||
{ id: 41, type: 1, info: { codec: 'ASS', lang: 'ja', name: '日本語字幕' } },
|
||||
{ id: 42, type: 1, info: { codec: 'ASS', lang: 'ja' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(files.length, 2);
|
||||
for (const file of files) {
|
||||
assert.doesNotMatch(file.filename, /\.\./, `double dot in ${file.filename}`);
|
||||
}
|
||||
assert.notEqual(files[0]!.filename, files[1]!.filename);
|
||||
assert.equal(files.find((f) => f.attachmentId === 41)!.filename, 'episode.ja.41.ass');
|
||||
assert.equal(files.find((f) => f.attachmentId === 42)!.filename, 'episode.ja.42.ass');
|
||||
});
|
||||
|
||||
test('extractTsukihimeSubtitleFiles disambiguates duplicate identical track names', () => {
|
||||
const files = extractTsukihimeSubtitleFiles({
|
||||
id: 1,
|
||||
files: [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'episode.mkv',
|
||||
attachments: [
|
||||
{ id: 51, type: 1, info: { codec: 'ASS', lang: 'ja', name: 'Signs' } },
|
||||
{ id: 52, type: 1, info: { codec: 'ASS', lang: 'ja', name: 'Signs' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
files.map((file) => file.filename),
|
||||
['episode.ja.51.ass', 'episode.ja.52.ass'],
|
||||
);
|
||||
});
|
||||
Binary file not shown.
+12
-5
@@ -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;
|
||||
@@ -132,6 +132,11 @@ export interface ShortcutsConfig {
|
||||
appendClipboardVideoToQueue?: string | null;
|
||||
}
|
||||
|
||||
export interface RawShortcutsConfig extends ShortcutsConfig {
|
||||
/** @deprecated Use openTsukihime. */
|
||||
openAnimetosho?: string | null;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
subtitlePosition?: SubtitlePosition;
|
||||
keybindings?: Keybinding[];
|
||||
@@ -141,7 +146,7 @@ export interface Config {
|
||||
mpv?: MpvConfig;
|
||||
controller?: ControllerConfig;
|
||||
ankiConnect?: AnkiConnectConfig;
|
||||
shortcuts?: ShortcutsConfig;
|
||||
shortcuts?: RawShortcutsConfig;
|
||||
secondarySub?: SecondarySubConfig;
|
||||
subsync?: SubsyncConfig;
|
||||
startupWarmups?: StartupWarmupsConfig;
|
||||
@@ -149,7 +154,9 @@ export interface Config {
|
||||
subtitleSidebar?: SubtitleSidebarConfig;
|
||||
auto_start_overlay?: boolean;
|
||||
jimaku?: JimakuConfig;
|
||||
animetosho?: AnimetoshoConfig;
|
||||
/** @deprecated Use tsukihime. */
|
||||
animetosho?: TsukihimeConfig;
|
||||
tsukihime?: TsukihimeConfig;
|
||||
anilist?: AnilistConfig;
|
||||
yomitan?: YomitanConfig;
|
||||
jellyfin?: JellyfinConfig;
|
||||
@@ -306,7 +313,7 @@ export interface ResolvedConfig {
|
||||
languagePreference: JimakuLanguagePreference;
|
||||
maxEntryResults: number;
|
||||
};
|
||||
animetosho: AnimetoshoConfig & {
|
||||
tsukihime: TsukihimeConfig & {
|
||||
apiBaseUrl: string;
|
||||
maxSearchResults: number;
|
||||
};
|
||||
|
||||
@@ -241,23 +241,24 @@ 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;
|
||||
totalSize: number | null;
|
||||
numFiles: number | null;
|
||||
sublangs: string[];
|
||||
}
|
||||
|
||||
export interface AnimetoshoFilesQuery {
|
||||
export interface TsukihimeFilesQuery {
|
||||
entryId: number;
|
||||
}
|
||||
|
||||
export interface AnimetoshoSubtitleFile {
|
||||
export interface TsukihimeSubtitleFile {
|
||||
attachmentId: number;
|
||||
filename: string;
|
||||
lang: string;
|
||||
@@ -267,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