mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-26 16:19:26 -07:00
feat: add manual AniList selection for character dictionaries
This commit is contained in:
@@ -212,6 +212,18 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(hasExplicitCommand(anilistRetryQueue), true);
|
||||
assert.equal(shouldStartApp(anilistRetryQueue), false);
|
||||
|
||||
const dictionaryCandidates = parseArgs(['--dictionary-candidates', '--dictionary-target', '/tmp/a.mkv']);
|
||||
assert.equal(dictionaryCandidates.dictionaryCandidates, true);
|
||||
assert.equal(dictionaryCandidates.dictionaryTarget, '/tmp/a.mkv');
|
||||
assert.equal(hasExplicitCommand(dictionaryCandidates), true);
|
||||
assert.equal(shouldStartApp(dictionaryCandidates), true);
|
||||
|
||||
const dictionarySelect = parseArgs(['--dictionary-select', '--dictionary-anilist-id', '21355']);
|
||||
assert.equal(dictionarySelect.dictionarySelect, true);
|
||||
assert.equal(dictionarySelect.dictionaryAnilistId, 21355);
|
||||
assert.equal(hasExplicitCommand(dictionarySelect), true);
|
||||
assert.equal(shouldStartApp(dictionarySelect), true);
|
||||
|
||||
const toggleStatsOverlay = parseArgs(['--toggle-stats-overlay']);
|
||||
assert.equal(toggleStatsOverlay.toggleStatsOverlay, true);
|
||||
assert.equal(hasExplicitCommand(toggleStatsOverlay), true);
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface CliArgs {
|
||||
toggleSubtitleSidebar: boolean;
|
||||
openRuntimeOptions: boolean;
|
||||
openSessionHelp: boolean;
|
||||
openCharacterDictionary: boolean;
|
||||
openControllerSelect: boolean;
|
||||
openControllerDebug: boolean;
|
||||
openJimaku: boolean;
|
||||
@@ -46,6 +47,9 @@ export interface CliArgs {
|
||||
anilistSetup: boolean;
|
||||
anilistRetryQueue: boolean;
|
||||
dictionary: boolean;
|
||||
dictionaryCandidates: boolean;
|
||||
dictionarySelect: boolean;
|
||||
dictionaryAnilistId?: number;
|
||||
dictionaryTarget?: string;
|
||||
stats: boolean;
|
||||
statsBackground?: boolean;
|
||||
@@ -122,6 +126,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
toggleSubtitleSidebar: false,
|
||||
openRuntimeOptions: false,
|
||||
openSessionHelp: false,
|
||||
openCharacterDictionary: false,
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
@@ -136,6 +141,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
stats: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
@@ -232,6 +239,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--toggle-subtitle-sidebar') args.toggleSubtitleSidebar = true;
|
||||
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
|
||||
else if (arg === '--open-session-help') args.openSessionHelp = true;
|
||||
else if (arg === '--open-character-dictionary') args.openCharacterDictionary = true;
|
||||
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;
|
||||
@@ -270,7 +278,15 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--anilist-setup') args.anilistSetup = true;
|
||||
else if (arg === '--anilist-retry-queue') args.anilistRetryQueue = true;
|
||||
else if (arg === '--dictionary') args.dictionary = true;
|
||||
else if (arg.startsWith('--dictionary-target=')) {
|
||||
else if (arg === '--dictionary-candidates') args.dictionaryCandidates = true;
|
||||
else if (arg === '--dictionary-select') args.dictionarySelect = true;
|
||||
else if (arg.startsWith('--dictionary-anilist-id=')) {
|
||||
const value = Number(arg.split('=', 2)[1]);
|
||||
if (Number.isInteger(value) && value > 0) args.dictionaryAnilistId = value;
|
||||
} else if (arg === '--dictionary-anilist-id') {
|
||||
const value = Number(readValue(argv[i + 1]));
|
||||
if (Number.isInteger(value) && value > 0) args.dictionaryAnilistId = value;
|
||||
} else if (arg.startsWith('--dictionary-target=')) {
|
||||
const value = arg.split('=', 2)[1];
|
||||
if (value) args.dictionaryTarget = value;
|
||||
} else if (arg === '--dictionary-target') {
|
||||
@@ -460,6 +476,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.toggleSubtitleSidebar ||
|
||||
args.openRuntimeOptions ||
|
||||
args.openSessionHelp ||
|
||||
args.openCharacterDictionary ||
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
@@ -477,6 +494,8 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.anilistSetup ||
|
||||
args.anilistRetryQueue ||
|
||||
args.dictionary ||
|
||||
args.dictionaryCandidates ||
|
||||
args.dictionarySelect ||
|
||||
args.stats ||
|
||||
args.jellyfin ||
|
||||
args.jellyfinLogin ||
|
||||
@@ -527,6 +546,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.toggleSubtitleSidebar &&
|
||||
!args.openRuntimeOptions &&
|
||||
!args.openSessionHelp &&
|
||||
!args.openCharacterDictionary &&
|
||||
!args.openControllerSelect &&
|
||||
!args.openControllerDebug &&
|
||||
!args.openJimaku &&
|
||||
@@ -544,6 +564,8 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.anilistSetup &&
|
||||
!args.anilistRetryQueue &&
|
||||
!args.dictionary &&
|
||||
!args.dictionaryCandidates &&
|
||||
!args.dictionarySelect &&
|
||||
!args.stats &&
|
||||
!args.jellyfin &&
|
||||
!args.jellyfinLogin &&
|
||||
@@ -585,6 +607,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.toggleSubtitleSidebar ||
|
||||
args.openRuntimeOptions ||
|
||||
args.openSessionHelp ||
|
||||
args.openCharacterDictionary ||
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
@@ -598,6 +621,8 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.copySubtitleCount !== undefined ||
|
||||
args.mineSentenceCount !== undefined ||
|
||||
args.dictionary ||
|
||||
args.dictionaryCandidates ||
|
||||
args.dictionarySelect ||
|
||||
args.stats ||
|
||||
args.jellyfin ||
|
||||
args.jellyfinPlay ||
|
||||
@@ -638,6 +663,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.toggleSubtitleSidebar &&
|
||||
!args.openRuntimeOptions &&
|
||||
!args.openSessionHelp &&
|
||||
!args.openCharacterDictionary &&
|
||||
!args.openControllerSelect &&
|
||||
!args.openControllerDebug &&
|
||||
!args.openJimaku &&
|
||||
@@ -655,6 +681,8 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.anilistSetup &&
|
||||
!args.anilistRetryQueue &&
|
||||
!args.dictionary &&
|
||||
!args.dictionaryCandidates &&
|
||||
!args.dictionarySelect &&
|
||||
!args.stats &&
|
||||
!args.jellyfin &&
|
||||
!args.jellyfinLogin &&
|
||||
@@ -696,6 +724,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||
args.markAudioCard ||
|
||||
args.openRuntimeOptions ||
|
||||
args.openSessionHelp ||
|
||||
args.openCharacterDictionary ||
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
|
||||
@@ -38,6 +38,7 @@ ${B}Mining${R}
|
||||
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
|
||||
--open-runtime-options Open runtime options palette
|
||||
--open-session-help Open session help modal
|
||||
--open-character-dictionary Open character dictionary anime selection modal
|
||||
--open-controller-select Open controller select modal
|
||||
--open-controller-debug Open controller debug modal
|
||||
|
||||
@@ -47,6 +48,9 @@ ${B}AniList${R}
|
||||
--anilist-logout Clear stored AniList token
|
||||
--anilist-retry-queue Retry next queued update
|
||||
--dictionary Generate character dictionary ZIP for current anime
|
||||
--dictionary-candidates Show character dictionary AniList candidates
|
||||
--dictionary-select Save manual character dictionary AniList selection
|
||||
--dictionary-anilist-id ${D}ID${R} AniList media ID for --dictionary-select
|
||||
--dictionary-target ${D}PATH${R} Override dictionary source path (file or directory)
|
||||
|
||||
${B}Jellyfin${R}
|
||||
|
||||
@@ -50,6 +50,8 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.startupWarmups.yomitanExtension, true);
|
||||
assert.equal(config.startupWarmups.subtitleDictionaries, true);
|
||||
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
|
||||
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
|
||||
assert.equal(config.shortcuts.openCharacterDictionary, 'CommandOrControl+Alt+A');
|
||||
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
|
||||
assert.equal(config.discordPresence.enabled, true);
|
||||
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
|
||||
|
||||
@@ -86,6 +86,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
multiCopyTimeoutMs: 3000,
|
||||
toggleSecondarySub: 'CommandOrControl+Shift+V',
|
||||
markAudioCard: 'CommandOrControl+Shift+A',
|
||||
openCharacterDictionary: 'CommandOrControl+Alt+A',
|
||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||
openJimaku: 'Ctrl+Shift+J',
|
||||
openSessionHelp: 'CommandOrControl+Shift+H',
|
||||
|
||||
@@ -490,6 +490,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'mineSentenceMultiple',
|
||||
'toggleSecondarySub',
|
||||
'markAudioCard',
|
||||
'openCharacterDictionary',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
] as const;
|
||||
|
||||
@@ -76,6 +76,32 @@ test('guessAnilistMediaInfo joins multi-part guessit titles', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('guessAnilistMediaInfo preserves useful guessit alternative title for ambiguous Re ZERO filenames', async () => {
|
||||
const result = await guessAnilistMediaInfo(
|
||||
'/tmp/Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv',
|
||||
null,
|
||||
{
|
||||
runGuessit: async () =>
|
||||
JSON.stringify({
|
||||
title: 'Re',
|
||||
alternative_title: 'ZERO, Starting Life in Another World',
|
||||
year: 2016,
|
||||
season: 1,
|
||||
episode: 1,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
title: 'Re ZERO, Starting Life in Another World',
|
||||
alternativeTitle: 'ZERO, Starting Life in Another World',
|
||||
year: 2016,
|
||||
season: 1,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
});
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress updates progress when behind', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
|
||||
@@ -7,6 +7,8 @@ const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||
|
||||
export interface AnilistMediaGuess {
|
||||
title: string;
|
||||
alternativeTitle?: string;
|
||||
year?: number;
|
||||
season: number | null;
|
||||
episode: number | null;
|
||||
source: 'guessit' | 'fallback';
|
||||
@@ -131,6 +133,20 @@ function firstPositiveInteger(value: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstYear(value: unknown): number | undefined {
|
||||
const candidate = firstPositiveInteger(value);
|
||||
if (candidate === null) return undefined;
|
||||
return candidate >= 1900 && candidate <= 2200 ? candidate : undefined;
|
||||
}
|
||||
|
||||
function buildGuessitTitle(title: string, alternativeTitle: string | null): string {
|
||||
if (!alternativeTitle) return title;
|
||||
if (title.length <= 3) {
|
||||
return `${title} ${alternativeTitle}`.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
function normalizeTitle(text: string): string {
|
||||
return text.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
@@ -215,10 +231,19 @@ export async function guessAnilistMediaInfo(
|
||||
const stdout = await deps.runGuessit(guessitTarget);
|
||||
const parsed = JSON.parse(stdout) as Record<string, unknown>;
|
||||
const title = readGuessitTitle(parsed.title);
|
||||
const alternativeTitle = readGuessitTitle(parsed.alternative_title);
|
||||
const episode = firstPositiveInteger(parsed.episode);
|
||||
const season = firstPositiveInteger(parsed.season);
|
||||
const year = firstYear(parsed.year);
|
||||
if (title) {
|
||||
return { title, season, episode, source: 'guessit' };
|
||||
return {
|
||||
title: buildGuessitTitle(title, alternativeTitle),
|
||||
...(alternativeTitle ? { alternativeTitle } : {}),
|
||||
...(year ? { year } : {}),
|
||||
season,
|
||||
episode,
|
||||
source: 'guessit',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore guessit failures and fall back to internal parser.
|
||||
|
||||
@@ -37,6 +37,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
openCharacterDictionary: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
@@ -48,6 +49,9 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: undefined,
|
||||
stats: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
|
||||
@@ -34,6 +34,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
refreshKnownWords: false,
|
||||
openRuntimeOptions: false,
|
||||
openSessionHelp: false,
|
||||
openCharacterDictionary: false,
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
@@ -50,6 +51,9 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: undefined,
|
||||
stats: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
@@ -199,6 +203,19 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 10,
|
||||
}),
|
||||
getCharacterDictionarySelection: async () => ({
|
||||
seriesKey: 'test',
|
||||
guessTitle: 'Test',
|
||||
current: { id: 1, title: 'Test', episodes: 12 },
|
||||
override: null,
|
||||
candidates: [{ id: 1, title: 'Test', episodes: 12 }],
|
||||
}),
|
||||
setCharacterDictionarySelection: async () => ({
|
||||
ok: true,
|
||||
seriesKey: 'test',
|
||||
selected: { id: 1, title: 'Test', episodes: 12 },
|
||||
staleMediaIds: [],
|
||||
}),
|
||||
runStatsCommand: async () => {
|
||||
calls.push('runStatsCommand');
|
||||
},
|
||||
@@ -624,6 +641,77 @@ test('handleCliCommand forwards --dictionary-target to dictionary runtime', asyn
|
||||
assert.equal(receivedTarget, '/tmp/example-video.mkv');
|
||||
});
|
||||
|
||||
test('handleCliCommand lists character dictionary AniList candidates', async () => {
|
||||
const { calls, deps } = createDeps({
|
||||
getCharacterDictionarySelection: async (targetPath?: string) => {
|
||||
calls.push(`getCharacterDictionarySelection:${targetPath ?? ''}`);
|
||||
return {
|
||||
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||
guessTitle: 'Re ZERO, Starting Life in Another World',
|
||||
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: null },
|
||||
override: null,
|
||||
candidates: [
|
||||
{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
|
||||
{ id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommand(
|
||||
makeArgs({ dictionaryCandidates: true, dictionaryTarget: '/tmp/re-zero.mkv' }),
|
||||
'initial',
|
||||
deps,
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.ok(calls.includes('getCharacterDictionarySelection:/tmp/re-zero.mkv'));
|
||||
assert.ok(
|
||||
calls.includes(
|
||||
'log:Character dictionary series key: re-zero-starting-life-in-another-world-2016',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
calls.includes('log:Candidate: 21355 - Re:ZERO -Starting Life in Another World- (25 episodes)'),
|
||||
);
|
||||
});
|
||||
|
||||
test('handleCliCommand sets character dictionary manual AniList selection', async () => {
|
||||
const { calls, deps } = createDeps({
|
||||
setCharacterDictionarySelection: async (request) => {
|
||||
calls.push(`setCharacterDictionarySelection:${request.mediaId}:${request.targetPath ?? ''}`);
|
||||
return {
|
||||
ok: true,
|
||||
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||
selected: {
|
||||
id: request.mediaId,
|
||||
title: 'Re:ZERO -Starting Life in Another World-',
|
||||
episodes: 25,
|
||||
},
|
||||
staleMediaIds: [10607],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommand(
|
||||
makeArgs({
|
||||
dictionarySelect: true,
|
||||
dictionaryAnilistId: 21355,
|
||||
dictionaryTarget: '/tmp/re-zero.mkv',
|
||||
}),
|
||||
'initial',
|
||||
deps,
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.ok(calls.includes('setCharacterDictionarySelection:21355:/tmp/re-zero.mkv'));
|
||||
assert.ok(
|
||||
calls.includes(
|
||||
'log:Character dictionary override saved: re-zero-starting-life-in-another-world-2016 -> 21355 - Re:ZERO -Starting Life in Another World-',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands', () => {
|
||||
const nonJellyfinArgs: Array<Partial<CliArgs>> = [
|
||||
{ start: true },
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args';
|
||||
import type { SessionActionDispatchRequest } from '../../types/runtime';
|
||||
|
||||
export type CharacterDictionaryCandidate = {
|
||||
id: number;
|
||||
title: string;
|
||||
episodes: number | null;
|
||||
};
|
||||
|
||||
export type CharacterDictionarySelectionSnapshot = {
|
||||
seriesKey: string;
|
||||
guessTitle: string | null;
|
||||
current: CharacterDictionaryCandidate | null;
|
||||
override: CharacterDictionaryCandidate | null;
|
||||
candidates: CharacterDictionaryCandidate[];
|
||||
};
|
||||
|
||||
export type CharacterDictionarySelectionResult = {
|
||||
ok: boolean;
|
||||
seriesKey: string;
|
||||
selected: CharacterDictionaryCandidate;
|
||||
staleMediaIds: number[];
|
||||
};
|
||||
|
||||
export interface CliCommandServiceDeps {
|
||||
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
|
||||
getMpvSocketPath: () => string;
|
||||
@@ -64,6 +85,13 @@ export interface CliCommandServiceDeps {
|
||||
mediaTitle: string;
|
||||
entryCount: number;
|
||||
}>;
|
||||
getCharacterDictionarySelection: (
|
||||
targetPath?: string,
|
||||
) => Promise<CharacterDictionarySelectionSnapshot>;
|
||||
setCharacterDictionarySelection: (request: {
|
||||
targetPath?: string;
|
||||
mediaId: number;
|
||||
}) => Promise<CharacterDictionarySelectionResult>;
|
||||
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runYoutubePlaybackFlow: (request: {
|
||||
@@ -162,6 +190,11 @@ export interface CliCommandDepsRuntimeOptions {
|
||||
mediaTitle: string;
|
||||
entryCount: number;
|
||||
}>;
|
||||
getSelection: (targetPath?: string) => Promise<CharacterDictionarySelectionSnapshot>;
|
||||
setSelection: (request: {
|
||||
targetPath?: string;
|
||||
mediaId: number;
|
||||
}) => Promise<CharacterDictionarySelectionResult>;
|
||||
};
|
||||
jellyfin: {
|
||||
openSetup: () => void;
|
||||
@@ -237,6 +270,8 @@ export function createCliCommandDepsRuntime(
|
||||
getAnilistQueueStatus: options.anilist.getQueueStatus,
|
||||
retryAnilistQueue: options.anilist.retryQueueNow,
|
||||
generateCharacterDictionary: options.dictionary.generate,
|
||||
getCharacterDictionarySelection: options.dictionary.getSelection,
|
||||
setCharacterDictionarySelection: options.dictionary.setSelection,
|
||||
runStatsCommand: options.jellyfin.runStatsCommand,
|
||||
runJellyfinCommand: options.jellyfin.runCommand,
|
||||
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
|
||||
@@ -267,6 +302,14 @@ function runAsyncWithOsd(
|
||||
});
|
||||
}
|
||||
|
||||
function formatCandidate(candidate: CharacterDictionaryCandidate): string {
|
||||
const episodeLabel =
|
||||
typeof candidate.episodes === 'number' && candidate.episodes > 0
|
||||
? `${candidate.episodes} episodes`
|
||||
: 'episodes unknown';
|
||||
return `${candidate.id} - ${candidate.title} (${episodeLabel})`;
|
||||
}
|
||||
|
||||
export function handleCliCommand(
|
||||
args: CliArgs,
|
||||
source: CliCommandSource = 'initial',
|
||||
@@ -411,6 +454,12 @@ export function handleCliCommand(
|
||||
'openSessionHelp',
|
||||
'Open session help failed',
|
||||
);
|
||||
} else if (args.openCharacterDictionary) {
|
||||
dispatchCliSessionAction(
|
||||
{ actionId: 'openCharacterDictionary' },
|
||||
'openCharacterDictionary',
|
||||
'Open character dictionary failed',
|
||||
);
|
||||
} else if (args.openControllerSelect) {
|
||||
dispatchCliSessionAction(
|
||||
{ actionId: 'openControllerSelect' },
|
||||
@@ -546,6 +595,71 @@ export function handleCliCommand(
|
||||
deps.stopApp();
|
||||
}
|
||||
});
|
||||
} else if (args.dictionaryCandidates) {
|
||||
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
|
||||
deps
|
||||
.getCharacterDictionarySelection(args.dictionaryTarget)
|
||||
.then((selection) => {
|
||||
deps.log(`Character dictionary series key: ${selection.seriesKey}`);
|
||||
if (selection.guessTitle) {
|
||||
deps.log(`Guess: ${selection.guessTitle}`);
|
||||
}
|
||||
if (selection.current) {
|
||||
deps.log(`Current match: ${formatCandidate(selection.current)}`);
|
||||
}
|
||||
if (selection.override) {
|
||||
deps.log(`Manual override: ${formatCandidate(selection.override)}`);
|
||||
}
|
||||
for (const candidate of selection.candidates) {
|
||||
deps.log(`Candidate: ${formatCandidate(candidate)}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
deps.error('getCharacterDictionarySelection failed:', error);
|
||||
deps.warn(
|
||||
`Character dictionary candidate lookup failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (shouldStopAfterRun) {
|
||||
deps.stopApp();
|
||||
}
|
||||
});
|
||||
} else if (args.dictionarySelect) {
|
||||
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
|
||||
if (!args.dictionaryAnilistId) {
|
||||
deps.warn('--dictionary-select requires --dictionary-anilist-id <ID>.');
|
||||
if (shouldStopAfterRun) deps.stopApp();
|
||||
return;
|
||||
}
|
||||
deps
|
||||
.setCharacterDictionarySelection({
|
||||
targetPath: args.dictionaryTarget,
|
||||
mediaId: args.dictionaryAnilistId,
|
||||
})
|
||||
.then((result) => {
|
||||
deps.log(
|
||||
`Character dictionary override saved: ${result.seriesKey} -> ${result.selected.id} - ${result.selected.title}`,
|
||||
);
|
||||
if (result.staleMediaIds.length > 0) {
|
||||
deps.log(`Removed stale AniList IDs: ${result.staleMediaIds.join(', ')}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
deps.error('setCharacterDictionarySelection failed:', error);
|
||||
deps.warn(
|
||||
`Character dictionary override failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (shouldStopAfterRun) {
|
||||
deps.stopApp();
|
||||
}
|
||||
});
|
||||
} else if (args.stats) {
|
||||
void deps.runStatsCommand(args, source);
|
||||
} else if (args.anilistRetryQueue) {
|
||||
|
||||
@@ -1023,3 +1023,58 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||
}, /Invalid controller preference payload/);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers exposes character dictionary selection handlers', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: number[] = [];
|
||||
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
getCharacterDictionarySelection: async () => ({
|
||||
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||
guessTitle: 'Re ZERO, Starting Life in Another World',
|
||||
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
|
||||
override: null,
|
||||
candidates: [
|
||||
{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
|
||||
],
|
||||
}),
|
||||
setCharacterDictionarySelection: async (mediaId) => {
|
||||
calls.push(mediaId);
|
||||
return {
|
||||
ok: true,
|
||||
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||
selected: {
|
||||
id: mediaId,
|
||||
title: 'Re:ZERO -Starting Life in Another World-',
|
||||
episodes: 25,
|
||||
},
|
||||
staleMediaIds: [10607],
|
||||
};
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const getHandler = handlers.handle.get(IPC_CHANNELS.request.getCharacterDictionarySelection);
|
||||
const setHandler = handlers.handle.get(IPC_CHANNELS.request.setCharacterDictionarySelection);
|
||||
|
||||
assert.deepEqual(await getHandler!({}), {
|
||||
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||
guessTitle: 'Re ZERO, Starting Life in Another World',
|
||||
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
|
||||
override: null,
|
||||
candidates: [{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 }],
|
||||
});
|
||||
assert.deepEqual(await setHandler!({}, 0), {
|
||||
ok: false,
|
||||
message: 'Invalid AniList media ID.',
|
||||
});
|
||||
assert.deepEqual(await setHandler!({}, 21355), {
|
||||
ok: true,
|
||||
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||
selected: { id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
|
||||
staleMediaIds: [10607],
|
||||
});
|
||||
assert.deepEqual(calls, [21355]);
|
||||
});
|
||||
|
||||
@@ -90,6 +90,8 @@ export interface IpcServiceDeps {
|
||||
openAnilistSetup: () => void;
|
||||
getAnilistQueueStatus: () => unknown;
|
||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||
getCharacterDictionarySelection?: () => Promise<unknown>;
|
||||
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||
@@ -211,6 +213,8 @@ export interface IpcDepsRuntimeOptions {
|
||||
openAnilistSetup: () => void;
|
||||
getAnilistQueueStatus: () => unknown;
|
||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||
getCharacterDictionarySelection?: () => Promise<unknown>;
|
||||
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||
@@ -284,6 +288,23 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
openAnilistSetup: options.openAnilistSetup,
|
||||
getAnilistQueueStatus: options.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: options.retryAnilistQueueNow,
|
||||
getCharacterDictionarySelection:
|
||||
options.getCharacterDictionarySelection ??
|
||||
(async () => ({
|
||||
seriesKey: '',
|
||||
guessTitle: null,
|
||||
current: null,
|
||||
override: null,
|
||||
candidates: [],
|
||||
})),
|
||||
setCharacterDictionarySelection:
|
||||
options.setCharacterDictionarySelection ??
|
||||
(async () => ({
|
||||
ok: false,
|
||||
seriesKey: '',
|
||||
selected: { id: 0, title: '', episodes: null },
|
||||
staleMediaIds: [],
|
||||
})),
|
||||
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
|
||||
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
|
||||
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
|
||||
@@ -570,6 +591,31 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return await deps.retryAnilistQueueNow();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getCharacterDictionarySelection, async () => {
|
||||
return await (deps.getCharacterDictionarySelection?.() ??
|
||||
Promise.resolve({
|
||||
seriesKey: '',
|
||||
guessTitle: null,
|
||||
current: null,
|
||||
override: null,
|
||||
candidates: [],
|
||||
}));
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.setCharacterDictionarySelection,
|
||||
async (_event, mediaId: unknown) => {
|
||||
if (!Number.isSafeInteger(mediaId) || (mediaId as number) <= 0) {
|
||||
return { ok: false, message: 'Invalid AniList media ID.' };
|
||||
}
|
||||
return await (deps.setCharacterDictionarySelection?.(mediaId as number) ??
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
message: 'Character dictionary selection unavailable.',
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => {
|
||||
return deps.appendClipboardVideoToQueue();
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
|
||||
multiCopyTimeoutMs: 2500,
|
||||
toggleSecondarySub: null,
|
||||
markAudioCard: null,
|
||||
openCharacterDictionary: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openSessionHelp: null,
|
||||
@@ -45,6 +46,9 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
|
||||
openRuntimeOptions: () => {
|
||||
calls.push('openRuntimeOptions');
|
||||
},
|
||||
openCharacterDictionary: () => {
|
||||
calls.push('openCharacterDictionary');
|
||||
},
|
||||
openJimaku: () => {
|
||||
calls.push('openJimaku');
|
||||
},
|
||||
@@ -154,6 +158,7 @@ test('runOverlayShortcutLocalFallback dispatches matching single-step actions',
|
||||
},
|
||||
{
|
||||
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
|
||||
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
|
||||
openJimaku: () => handled.push('openJimaku'),
|
||||
markAudioCard: () => handled.push('markAudioCard'),
|
||||
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||
@@ -186,6 +191,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
|
||||
(_input, accelerator) => accelerator === 'Ctrl+M',
|
||||
{
|
||||
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
|
||||
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
|
||||
openJimaku: () => handled.push('openJimaku'),
|
||||
markAudioCard: () => handled.push('markAudioCard'),
|
||||
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||
@@ -205,6 +211,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
|
||||
(_input, accelerator) => accelerator === 'Ctrl+N',
|
||||
{
|
||||
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
|
||||
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
|
||||
openJimaku: () => handled.push('openJimaku'),
|
||||
markAudioCard: () => handled.push('markAudioCard'),
|
||||
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||
@@ -241,6 +248,7 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
|
||||
},
|
||||
{
|
||||
openRuntimeOptions: () => {},
|
||||
openCharacterDictionary: () => {},
|
||||
openJimaku: () => {},
|
||||
markAudioCard: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
@@ -276,6 +284,7 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut',
|
||||
},
|
||||
{
|
||||
openRuntimeOptions: () => {},
|
||||
openCharacterDictionary: () => {},
|
||||
openJimaku: () => {},
|
||||
markAudioCard: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
@@ -303,6 +312,9 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', ()
|
||||
openRuntimeOptions: () => {
|
||||
called = true;
|
||||
},
|
||||
openCharacterDictionary: () => {
|
||||
called = true;
|
||||
},
|
||||
openJimaku: () => {
|
||||
called = true;
|
||||
},
|
||||
@@ -385,6 +397,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
|
||||
mineSentenceMultiple: () => {},
|
||||
toggleSecondarySub: () => {},
|
||||
markAudioCard: () => {},
|
||||
openCharacterDictionary: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
}),
|
||||
@@ -411,6 +424,7 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active
|
||||
mineSentenceMultiple: () => {},
|
||||
toggleSecondarySub: () => {},
|
||||
markAudioCard: () => {},
|
||||
openCharacterDictionary: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,7 @@ const logger = createLogger('main:overlay-shortcut-handler');
|
||||
|
||||
export interface OverlayShortcutFallbackHandlers {
|
||||
openRuntimeOptions: () => void;
|
||||
openCharacterDictionary: () => void;
|
||||
openJimaku: () => void;
|
||||
markAudioCard: () => void;
|
||||
copySubtitleMultiple: (timeoutMs: number) => void;
|
||||
@@ -21,6 +22,7 @@ export interface OverlayShortcutFallbackHandlers {
|
||||
export interface OverlayShortcutRuntimeDeps {
|
||||
showMpvOsd: (text: string) => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openCharacterDictionary: () => void;
|
||||
openJimaku: () => void;
|
||||
markAudioCard: () => Promise<void>;
|
||||
copySubtitleMultiple: (timeoutMs: number) => void;
|
||||
@@ -95,6 +97,9 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
|
||||
openRuntimeOptions: () => {
|
||||
deps.openRuntimeOptions();
|
||||
},
|
||||
openCharacterDictionary: () => {
|
||||
deps.openCharacterDictionary();
|
||||
},
|
||||
openJimaku: () => {
|
||||
deps.openJimaku();
|
||||
},
|
||||
@@ -102,6 +107,7 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
|
||||
|
||||
const fallbackHandlers: OverlayShortcutFallbackHandlers = {
|
||||
openRuntimeOptions: overlayHandlers.openRuntimeOptions,
|
||||
openCharacterDictionary: overlayHandlers.openCharacterDictionary,
|
||||
openJimaku: overlayHandlers.openJimaku,
|
||||
markAudioCard: overlayHandlers.markAudioCard,
|
||||
copySubtitleMultiple: overlayHandlers.copySubtitleMultiple,
|
||||
@@ -134,6 +140,12 @@ export function runOverlayShortcutLocalFallback(
|
||||
handlers.openRuntimeOptions();
|
||||
},
|
||||
},
|
||||
{
|
||||
accelerator: shortcuts.openCharacterDictionary,
|
||||
run: () => {
|
||||
handlers.openCharacterDictionary();
|
||||
},
|
||||
},
|
||||
{
|
||||
accelerator: shortcuts.openJimaku,
|
||||
run: () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
multiCopyTimeoutMs: 2500,
|
||||
toggleSecondarySub: null,
|
||||
markAudioCard: null,
|
||||
openCharacterDictionary: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openSessionHelp: null,
|
||||
@@ -42,6 +43,7 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured'
|
||||
mineSentenceMultiple: () => {},
|
||||
toggleSecondarySub: () => {},
|
||||
markAudioCard: () => {},
|
||||
openCharacterDictionary: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
}),
|
||||
@@ -61,6 +63,7 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent'
|
||||
mineSentenceMultiple: () => {},
|
||||
toggleSecondarySub: () => {},
|
||||
markAudioCard: () => {},
|
||||
openCharacterDictionary: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
}),
|
||||
@@ -82,6 +85,7 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active
|
||||
mineSentenceMultiple: () => {},
|
||||
toggleSecondarySub: () => {},
|
||||
markAudioCard: () => {},
|
||||
openCharacterDictionary: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
}),
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface OverlayShortcutHandlers {
|
||||
mineSentenceMultiple: (timeoutMs: number) => void;
|
||||
toggleSecondarySub: () => void;
|
||||
markAudioCard: () => void;
|
||||
openCharacterDictionary: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJimaku: () => void;
|
||||
}
|
||||
@@ -31,6 +32,7 @@ const OVERLAY_SHORTCUT_KEYS: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTim
|
||||
'mineSentenceMultiple',
|
||||
'toggleSecondarySub',
|
||||
'markAudioCard',
|
||||
'openCharacterDictionary',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
];
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface SessionActionExecutorDeps {
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openSessionHelp: () => void;
|
||||
openCharacterDictionary: () => void;
|
||||
openControllerSelect: () => void;
|
||||
openControllerDebug: () => void;
|
||||
openJimaku: () => void;
|
||||
@@ -85,6 +86,9 @@ export async function dispatchSessionAction(
|
||||
case 'openSessionHelp':
|
||||
deps.openSessionHelp();
|
||||
return;
|
||||
case 'openCharacterDictionary':
|
||||
deps.openCharacterDictionary();
|
||||
return;
|
||||
case 'openControllerSelect':
|
||||
deps.openControllerSelect();
|
||||
return;
|
||||
|
||||
@@ -18,6 +18,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
multiCopyTimeoutMs: 2500,
|
||||
toggleSecondarySub: null,
|
||||
markAudioCard: null,
|
||||
openCharacterDictionary: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openSessionHelp: null,
|
||||
|
||||
@@ -43,6 +43,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
|
||||
{ key: 'mineSentenceMultiple', actionId: 'mineSentenceMultiple' },
|
||||
{ key: 'toggleSecondarySub', actionId: 'toggleSecondarySub' },
|
||||
{ key: 'markAudioCard', actionId: 'markAudioCard' },
|
||||
{ key: 'openCharacterDictionary', actionId: 'openCharacterDictionary' },
|
||||
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
|
||||
{ key: 'openJimaku', actionId: 'openJimaku' },
|
||||
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
|
||||
|
||||
@@ -37,6 +37,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
openCharacterDictionary: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
@@ -48,6 +49,9 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: undefined,
|
||||
stats: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
|
||||
@@ -66,6 +66,7 @@ test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => {
|
||||
shortcuts: {
|
||||
mineSentence: 'KeyQ',
|
||||
openRuntimeOptions: 'Digit9',
|
||||
openCharacterDictionary: 'Ctrl+Shift+KeyA',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -73,4 +74,5 @@ test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => {
|
||||
|
||||
assert.equal(resolved.mineSentence, 'Q');
|
||||
assert.equal(resolved.openRuntimeOptions, '9');
|
||||
assert.equal(resolved.openCharacterDictionary, 'Ctrl+Shift+A');
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface ConfiguredShortcuts {
|
||||
multiCopyTimeoutMs: number;
|
||||
toggleSecondarySub: string | null | undefined;
|
||||
markAudioCard: string | null | undefined;
|
||||
openCharacterDictionary: string | null | undefined;
|
||||
openRuntimeOptions: string | null | undefined;
|
||||
openJimaku: string | null | undefined;
|
||||
openSessionHelp: string | null | undefined;
|
||||
@@ -76,6 +77,9 @@ export function resolveConfiguredShortcuts(
|
||||
? null
|
||||
: (config.shortcuts?.markAudioCard ?? defaultConfig.shortcuts?.markAudioCard),
|
||||
),
|
||||
openCharacterDictionary: normalizeShortcut(
|
||||
config.shortcuts?.openCharacterDictionary ?? defaultConfig.shortcuts?.openCharacterDictionary,
|
||||
),
|
||||
openRuntimeOptions: normalizeShortcut(
|
||||
config.shortcuts?.openRuntimeOptions ?? defaultConfig.shortcuts?.openRuntimeOptions,
|
||||
),
|
||||
|
||||
29
src/main.ts
29
src/main.ts
@@ -458,6 +458,7 @@ 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 { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
|
||||
import { openCharacterDictionaryModal as openCharacterDictionaryModalRuntime } from './main/runtime/character-dictionary-open';
|
||||
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
|
||||
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
|
||||
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
|
||||
@@ -1492,6 +1493,9 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
|
||||
openRuntimeOptionsPalette: () => {
|
||||
openRuntimeOptionsPalette();
|
||||
},
|
||||
openCharacterDictionary: () => {
|
||||
openCharacterDictionaryOverlay();
|
||||
},
|
||||
openJimaku: () => {
|
||||
openJimakuOverlay();
|
||||
},
|
||||
@@ -2290,6 +2294,14 @@ function openSessionHelpOverlay(): void {
|
||||
);
|
||||
}
|
||||
|
||||
function openCharacterDictionaryOverlay(): void {
|
||||
openOverlayHostedModalWithOsd(
|
||||
openCharacterDictionaryModalRuntime,
|
||||
'Character dictionary overlay unavailable.',
|
||||
'Failed to open character dictionary overlay.',
|
||||
);
|
||||
}
|
||||
|
||||
function openControllerSelectOverlay(): void {
|
||||
openOverlayHostedModalWithOsd(
|
||||
openControllerSelectModalRuntime,
|
||||
@@ -4622,6 +4634,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openJimaku: () => openJimakuOverlay(),
|
||||
openSessionHelp: () => openSessionHelpOverlay(),
|
||||
openCharacterDictionary: () => openCharacterDictionaryOverlay(),
|
||||
openControllerSelect: () => openControllerSelectOverlay(),
|
||||
openControllerDebug: () => openControllerDebugOverlay(),
|
||||
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
||||
@@ -4842,6 +4855,14 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
openAnilistSetup: () => openAnilistSetupWindow(),
|
||||
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
|
||||
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
|
||||
getCharacterDictionarySelection: () =>
|
||||
characterDictionaryRuntime.getManualSelectionSnapshot(),
|
||||
setCharacterDictionarySelection: async (mediaId: number) => {
|
||||
const result = await characterDictionaryRuntime.setManualSelection({ mediaId });
|
||||
resetAnilistMediaGuessState();
|
||||
await characterDictionaryAutoSyncRuntime.runSyncNow();
|
||||
return result;
|
||||
},
|
||||
appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(),
|
||||
...playlistBrowserMainDeps,
|
||||
getImmersionTracker: () => appState.immersionTracker,
|
||||
@@ -4923,6 +4944,14 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
}
|
||||
return await characterDictionaryRuntime.generateForCurrentMedia(targetPath);
|
||||
},
|
||||
getCharacterDictionarySelection: async (targetPath?: string) =>
|
||||
characterDictionaryRuntime.getManualSelectionSnapshot(targetPath),
|
||||
setCharacterDictionarySelection: async (request) => {
|
||||
const result = await characterDictionaryRuntime.setManualSelection(request);
|
||||
resetAnilistMediaGuessState();
|
||||
await characterDictionaryAutoSyncRuntime.runSyncNow();
|
||||
return result;
|
||||
},
|
||||
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
|
||||
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
|
||||
runStatsCliCommand(argsFromCommand, source),
|
||||
|
||||
@@ -25,12 +25,21 @@ import {
|
||||
} from './character-dictionary-runtime/constants';
|
||||
import {
|
||||
downloadCharacterImage,
|
||||
fetchAniListMediaCandidateById,
|
||||
fetchCharactersForMedia,
|
||||
resolveAniListMediaIdFromGuess,
|
||||
searchAniListMediaCandidates,
|
||||
} from './character-dictionary-runtime/fetch';
|
||||
import {
|
||||
buildCharacterDictionarySeriesKey,
|
||||
createCharacterDictionaryManualSelectionStore,
|
||||
} from './character-dictionary-runtime/manual-selection';
|
||||
import type {
|
||||
AniListMediaCandidate,
|
||||
CharacterDictionaryBuildResult,
|
||||
CharacterDictionaryGenerateOptions,
|
||||
CharacterDictionaryManualSelectionResult,
|
||||
CharacterDictionaryManualSelectionSnapshot,
|
||||
CharacterDictionaryRuntimeDeps,
|
||||
CharacterDictionarySnapshotImage,
|
||||
CharacterDictionarySnapshotProgress,
|
||||
@@ -136,6 +145,13 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
progress?: CharacterDictionarySnapshotProgressCallbacks,
|
||||
) => Promise<CharacterDictionarySnapshotResult>;
|
||||
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
|
||||
getManualSelectionSnapshot: (
|
||||
targetPath?: string,
|
||||
) => Promise<CharacterDictionaryManualSelectionSnapshot>;
|
||||
setManualSelection: (request: {
|
||||
targetPath?: string;
|
||||
mediaId: number;
|
||||
}) => Promise<CharacterDictionaryManualSelectionResult>;
|
||||
generateForCurrentMedia: (
|
||||
targetPath?: string,
|
||||
options?: CharacterDictionaryGenerateOptions,
|
||||
@@ -144,26 +160,54 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
const outputDir = path.join(deps.userDataPath, 'character-dictionaries');
|
||||
const sleepMs = deps.sleep ?? sleep;
|
||||
const getCollapsibleSectionOpenState = deps.getCollapsibleSectionOpenState ?? (() => false);
|
||||
const manualSelectionStore = createCharacterDictionaryManualSelectionStore({
|
||||
userDataPath: deps.userDataPath,
|
||||
});
|
||||
|
||||
const createAniListRequestSlot = (): (() => Promise<void>) => {
|
||||
let hasAniListRequest = false;
|
||||
return async (): Promise<void> => {
|
||||
if (!hasAniListRequest) {
|
||||
hasAniListRequest = true;
|
||||
return;
|
||||
}
|
||||
await sleepMs(ANILIST_REQUEST_DELAY_MS);
|
||||
};
|
||||
};
|
||||
|
||||
const resolveGuessInput = (targetPath?: string): { mediaPath: string | null; mediaTitle: string | null } => {
|
||||
const dictionaryTarget = targetPath?.trim() || '';
|
||||
return dictionaryTarget.length > 0
|
||||
? resolveDictionaryGuessInputs(dictionaryTarget)
|
||||
: {
|
||||
mediaPath: deps.getCurrentMediaPath(),
|
||||
mediaTitle: deps.getCurrentMediaTitle(),
|
||||
};
|
||||
};
|
||||
|
||||
const guessCurrentMedia = async (targetPath?: string) => {
|
||||
const guessInput = resolveGuessInput(targetPath);
|
||||
const mediaPathForGuess = deps.resolveMediaPathForJimaku(guessInput.mediaPath);
|
||||
const guessed = await deps.guessAnilistMediaInfo(mediaPathForGuess, guessInput.mediaTitle);
|
||||
if (!guessed || !guessed.title.trim()) {
|
||||
throw new Error('Unable to resolve current anime from media path/title.');
|
||||
}
|
||||
return {
|
||||
guessed,
|
||||
seriesKey: buildCharacterDictionarySeriesKey({
|
||||
mediaPath: mediaPathForGuess,
|
||||
mediaTitle: guessInput.mediaTitle,
|
||||
guess: guessed,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveCurrentMedia = async (
|
||||
targetPath?: string,
|
||||
beforeRequest?: () => Promise<void>,
|
||||
): Promise<ResolvedAniListMedia> => {
|
||||
deps.logInfo?.('[dictionary] resolving current anime for character dictionary generation');
|
||||
const dictionaryTarget = targetPath?.trim() || '';
|
||||
const guessInput =
|
||||
dictionaryTarget.length > 0
|
||||
? resolveDictionaryGuessInputs(dictionaryTarget)
|
||||
: {
|
||||
mediaPath: deps.getCurrentMediaPath(),
|
||||
mediaTitle: deps.getCurrentMediaTitle(),
|
||||
};
|
||||
const mediaPathForGuess = deps.resolveMediaPathForJimaku(guessInput.mediaPath);
|
||||
const mediaTitle = guessInput.mediaTitle;
|
||||
const guessed = await deps.guessAnilistMediaInfo(mediaPathForGuess, mediaTitle);
|
||||
if (!guessed || !guessed.title.trim()) {
|
||||
throw new Error('Unable to resolve current anime from media path/title.');
|
||||
}
|
||||
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
|
||||
deps.logInfo?.(
|
||||
`[dictionary] current anime guess: ${guessed.title.trim()}${
|
||||
typeof guessed.episode === 'number' && guessed.episode > 0
|
||||
@@ -171,6 +215,17 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
: ''
|
||||
}`,
|
||||
);
|
||||
const override = await manualSelectionStore.getOverride(seriesKey);
|
||||
if (override) {
|
||||
deps.logInfo?.(
|
||||
`[dictionary] manual AniList override: ${override.mediaTitle} -> AniList ${override.mediaId}`,
|
||||
);
|
||||
return {
|
||||
id: override.mediaId,
|
||||
title: override.mediaTitle,
|
||||
staleMediaIds: override.staleMediaIds,
|
||||
};
|
||||
}
|
||||
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest);
|
||||
deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`);
|
||||
return resolved;
|
||||
@@ -269,13 +324,13 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
`[dictionary] stored snapshot for AniList ${mediaId}: ${snapshot.entryCount} terms`,
|
||||
);
|
||||
|
||||
return {
|
||||
mediaId: snapshot.mediaId,
|
||||
mediaTitle: snapshot.mediaTitle,
|
||||
entryCount: snapshot.entryCount,
|
||||
fromCache: false,
|
||||
updatedAt: snapshot.updatedAt,
|
||||
};
|
||||
return {
|
||||
mediaId: snapshot.mediaId,
|
||||
mediaTitle: snapshot.mediaTitle,
|
||||
entryCount: snapshot.entryCount,
|
||||
fromCache: false,
|
||||
updatedAt: snapshot.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -283,25 +338,22 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
targetPath?: string,
|
||||
progress?: CharacterDictionarySnapshotProgressCallbacks,
|
||||
) => {
|
||||
let hasAniListRequest = false;
|
||||
const waitForAniListRequestSlot = async (): Promise<void> => {
|
||||
if (!hasAniListRequest) {
|
||||
hasAniListRequest = true;
|
||||
return;
|
||||
}
|
||||
await sleepMs(ANILIST_REQUEST_DELAY_MS);
|
||||
};
|
||||
const waitForAniListRequestSlot = createAniListRequestSlot();
|
||||
const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot);
|
||||
progress?.onChecking?.({
|
||||
mediaId: resolvedMedia.id,
|
||||
mediaTitle: resolvedMedia.title,
|
||||
});
|
||||
return getOrCreateSnapshot(
|
||||
const snapshot = await getOrCreateSnapshot(
|
||||
resolvedMedia.id,
|
||||
resolvedMedia.title,
|
||||
waitForAniListRequestSlot,
|
||||
progress,
|
||||
);
|
||||
return {
|
||||
...snapshot,
|
||||
staleMediaIds: resolvedMedia.staleMediaIds,
|
||||
};
|
||||
},
|
||||
buildMergedDictionary: async (mediaIds: number[]) => {
|
||||
const normalizedMediaIds = normalizeMergedMediaIds(mediaIds);
|
||||
@@ -341,18 +393,58 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
entryCount,
|
||||
};
|
||||
},
|
||||
getManualSelectionSnapshot: async (targetPath?: string) => {
|
||||
const waitForAniListRequestSlot = createAniListRequestSlot();
|
||||
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
|
||||
const [candidates, override] = await Promise.all([
|
||||
searchAniListMediaCandidates(guessed.title, waitForAniListRequestSlot),
|
||||
manualSelectionStore.getOverride(seriesKey),
|
||||
]);
|
||||
const current = await resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot)
|
||||
.then(
|
||||
(entry): AniListMediaCandidate => ({
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
episodes: candidates.find((candidate) => candidate.id === entry.id)?.episodes ?? null,
|
||||
}),
|
||||
)
|
||||
.catch(() => null);
|
||||
return {
|
||||
seriesKey,
|
||||
guessTitle: guessed.title,
|
||||
current,
|
||||
override: override
|
||||
? { id: override.mediaId, title: override.mediaTitle, episodes: null }
|
||||
: null,
|
||||
candidates,
|
||||
};
|
||||
},
|
||||
setManualSelection: async ({ targetPath, mediaId }) => {
|
||||
const waitForAniListRequestSlot = createAniListRequestSlot();
|
||||
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
|
||||
const [selected, current] = await Promise.all([
|
||||
fetchAniListMediaCandidateById(mediaId, waitForAniListRequestSlot),
|
||||
resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot).catch(() => null),
|
||||
]);
|
||||
const staleMediaIds = current && current.id !== selected.id ? [current.id] : [];
|
||||
await manualSelectionStore.setOverride({
|
||||
seriesKey,
|
||||
mediaId: selected.id,
|
||||
mediaTitle: selected.title,
|
||||
staleMediaIds,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
seriesKey,
|
||||
selected,
|
||||
staleMediaIds,
|
||||
};
|
||||
},
|
||||
generateForCurrentMedia: async (
|
||||
targetPath?: string,
|
||||
_options?: CharacterDictionaryGenerateOptions,
|
||||
) => {
|
||||
let hasAniListRequest = false;
|
||||
const waitForAniListRequestSlot = async (): Promise<void> => {
|
||||
if (!hasAniListRequest) {
|
||||
hasAniListRequest = true;
|
||||
return;
|
||||
}
|
||||
await sleepMs(ANILIST_REQUEST_DELAY_MS);
|
||||
};
|
||||
const waitForAniListRequestSlot = createAniListRequestSlot();
|
||||
const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot);
|
||||
const snapshot = await getOrCreateSnapshot(
|
||||
resolvedMedia.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
|
||||
import { ANILIST_GRAPHQL_URL } from './constants';
|
||||
import type {
|
||||
AniListMediaCandidate,
|
||||
CharacterDictionaryRole,
|
||||
CharacterRecord,
|
||||
ResolvedAniListMedia,
|
||||
@@ -123,6 +124,29 @@ function pickAniListSearchResult(
|
||||
};
|
||||
}
|
||||
|
||||
function toAniListMediaCandidate(
|
||||
entry: {
|
||||
id: number;
|
||||
episodes?: number | null;
|
||||
title?: {
|
||||
romaji?: string | null;
|
||||
english?: string | null;
|
||||
native?: string | null;
|
||||
};
|
||||
},
|
||||
fallbackTitle: string,
|
||||
): AniListMediaCandidate {
|
||||
return {
|
||||
id: entry.id,
|
||||
title:
|
||||
entry.title?.english?.trim() ||
|
||||
entry.title?.romaji?.trim() ||
|
||||
entry.title?.native?.trim() ||
|
||||
fallbackTitle,
|
||||
episodes: typeof entry.episodes === 'number' && entry.episodes > 0 ? entry.episodes : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAniList<T>(
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
@@ -208,6 +232,69 @@ export async function resolveAniListMediaIdFromGuess(
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function searchAniListMediaCandidates(
|
||||
title: string,
|
||||
beforeRequest?: () => Promise<void>,
|
||||
): Promise<AniListMediaCandidate[]> {
|
||||
const data = await fetchAniList<AniListSearchResponse>(
|
||||
`
|
||||
query($search: String!) {
|
||||
Page(perPage: 10) {
|
||||
media(search: $search, type: ANIME, sort: [SEARCH_MATCH, POPULARITY_DESC]) {
|
||||
id
|
||||
episodes
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ search: title },
|
||||
beforeRequest,
|
||||
);
|
||||
return (data.Page?.media ?? []).map((entry) => toAniListMediaCandidate(entry, title));
|
||||
}
|
||||
|
||||
export async function fetchAniListMediaCandidateById(
|
||||
mediaId: number,
|
||||
beforeRequest?: () => Promise<void>,
|
||||
): Promise<AniListMediaCandidate> {
|
||||
const data = await fetchAniList<{
|
||||
Media?: {
|
||||
id: number;
|
||||
episodes?: number | null;
|
||||
title?: {
|
||||
romaji?: string | null;
|
||||
english?: string | null;
|
||||
native?: string | null;
|
||||
};
|
||||
} | null;
|
||||
}>(
|
||||
`
|
||||
query($id: Int!) {
|
||||
Media(id: $id, type: ANIME) {
|
||||
id
|
||||
episodes
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id: mediaId },
|
||||
beforeRequest,
|
||||
);
|
||||
if (!data.Media) {
|
||||
throw new Error(`AniList media ${mediaId} not found.`);
|
||||
}
|
||||
return toAniListMediaCandidate(data.Media, `AniList ${mediaId}`);
|
||||
}
|
||||
|
||||
export async function fetchCharactersForMedia(
|
||||
mediaId: number,
|
||||
beforeRequest?: () => Promise<void>,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
buildCharacterDictionarySeriesKey,
|
||||
createCharacterDictionaryManualSelectionStore,
|
||||
} from './manual-selection';
|
||||
|
||||
const REZERO_EP1 =
|
||||
'/anime/Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv';
|
||||
const REZERO_EP2 =
|
||||
'/anime/Re - ZERO, Starting Life in Another World (2016) - S01E02 - Reunion with the Witch [Bluray-1080p][x265][JA]-SCY.mkv';
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-manual-selection-'));
|
||||
}
|
||||
|
||||
test('buildCharacterDictionarySeriesKey uses guessit title, alternative title, and year for Re ZERO series scope', () => {
|
||||
const key = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: REZERO_EP1,
|
||||
mediaTitle: null,
|
||||
guess: {
|
||||
title: 'Re ZERO, Starting Life in Another World',
|
||||
alternativeTitle: 'ZERO, Starting Life in Another World',
|
||||
year: 2016,
|
||||
season: 1,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(key, 're-zero-starting-life-in-another-world-2016');
|
||||
});
|
||||
|
||||
test('manual selection store persists overrides and matches later episodes in the same series', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||
const firstKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: REZERO_EP1,
|
||||
mediaTitle: null,
|
||||
guess: {
|
||||
title: 'Re ZERO, Starting Life in Another World',
|
||||
alternativeTitle: 'ZERO, Starting Life in Another World',
|
||||
year: 2016,
|
||||
season: 1,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
},
|
||||
});
|
||||
await store.setOverride({
|
||||
seriesKey: firstKey,
|
||||
mediaId: 21355,
|
||||
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||
staleMediaIds: [10607],
|
||||
});
|
||||
|
||||
const reloaded = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||
const secondKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: REZERO_EP2,
|
||||
mediaTitle: null,
|
||||
guess: {
|
||||
title: 'Re ZERO, Starting Life in Another World',
|
||||
alternativeTitle: 'ZERO, Starting Life in Another World',
|
||||
year: 2016,
|
||||
season: 1,
|
||||
episode: 2,
|
||||
source: 'guessit',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(secondKey, firstKey);
|
||||
assert.deepEqual(await reloaded.getOverride(secondKey), {
|
||||
seriesKey: firstKey,
|
||||
mediaId: 21355,
|
||||
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||
staleMediaIds: [10607],
|
||||
});
|
||||
});
|
||||
122
src/main/character-dictionary-runtime/manual-selection.ts
Normal file
122
src/main/character-dictionary-runtime/manual-selection.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
|
||||
import { ensureDir } from '../../shared/fs-utils';
|
||||
|
||||
export type CharacterDictionaryManualSelection = {
|
||||
seriesKey: string;
|
||||
mediaId: number;
|
||||
mediaTitle: string;
|
||||
staleMediaIds: number[];
|
||||
};
|
||||
|
||||
type ManualSelectionStoreFile = {
|
||||
overrides?: CharacterDictionaryManualSelection[];
|
||||
};
|
||||
|
||||
function normalizeManualMediaId(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||
const mediaId = Math.floor(value);
|
||||
return mediaId > 0 ? mediaId : null;
|
||||
}
|
||||
|
||||
function normalizeSeriesKeyPart(value: string): string {
|
||||
return value
|
||||
.normalize('NFKD')
|
||||
.replace(/[':]/g, '')
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function dedupeNumbers(values: number[]): number[] {
|
||||
const seen = new Set<number>();
|
||||
const result: number[] = [];
|
||||
for (const value of values) {
|
||||
const normalized = normalizeManualMediaId(value);
|
||||
if (normalized === null || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeOverride(value: unknown): CharacterDictionaryManualSelection | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const raw = value as Partial<CharacterDictionaryManualSelection>;
|
||||
const seriesKey = typeof raw.seriesKey === 'string' ? raw.seriesKey.trim() : '';
|
||||
const mediaId = normalizeManualMediaId(raw.mediaId);
|
||||
const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : '';
|
||||
if (!seriesKey || mediaId === null || !mediaTitle) return null;
|
||||
return {
|
||||
seriesKey,
|
||||
mediaId,
|
||||
mediaTitle,
|
||||
staleMediaIds: dedupeNumbers(Array.isArray(raw.staleMediaIds) ? raw.staleMediaIds : []),
|
||||
};
|
||||
}
|
||||
|
||||
function readOverrides(filePath: string): CharacterDictionaryManualSelection[] {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as ManualSelectionStoreFile;
|
||||
if (!Array.isArray(parsed.overrides)) return [];
|
||||
const byKey = new Map<string, CharacterDictionaryManualSelection>();
|
||||
for (const value of parsed.overrides) {
|
||||
const normalized = normalizeOverride(value);
|
||||
if (normalized) byKey.set(normalized.seriesKey, normalized);
|
||||
}
|
||||
return [...byKey.values()];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeOverrides(filePath: string, overrides: CharacterDictionaryManualSelection[]): void {
|
||||
ensureDir(path.dirname(filePath));
|
||||
fs.writeFileSync(filePath, JSON.stringify({ overrides }, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
export function buildCharacterDictionarySeriesKey(input: {
|
||||
mediaPath: string | null;
|
||||
mediaTitle: string | null;
|
||||
guess: AnilistMediaGuess | null;
|
||||
}): string {
|
||||
const guessedTitle = input.guess?.title.trim() || input.guess?.alternativeTitle?.trim() || '';
|
||||
const sourceTitle =
|
||||
guessedTitle ||
|
||||
(input.mediaTitle && input.mediaTitle.trim()) ||
|
||||
(input.mediaPath && path.basename(input.mediaPath).replace(/\.[^.]+$/, '')) ||
|
||||
'unknown';
|
||||
const withoutEpisode = sourceTitle
|
||||
.replace(/\bS\d{1,2}E\d{1,3}\b/gi, ' ')
|
||||
.replace(/\bepisode\s+\d+\b/gi, ' ')
|
||||
.trim();
|
||||
const base = normalizeSeriesKeyPart(withoutEpisode) || 'unknown';
|
||||
return input.guess?.year ? `${base}-${input.guess.year}` : base;
|
||||
}
|
||||
|
||||
export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) {
|
||||
const filePath = path.join(
|
||||
deps.userDataPath,
|
||||
'character-dictionaries',
|
||||
'anilist-overrides.json',
|
||||
);
|
||||
|
||||
return {
|
||||
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
|
||||
return readOverrides(filePath).find((entry) => entry.seriesKey === seriesKey) ?? null;
|
||||
},
|
||||
setOverride: async (selection: CharacterDictionaryManualSelection): Promise<void> => {
|
||||
const normalized = normalizeOverride(selection);
|
||||
if (!normalized) {
|
||||
throw new Error('Invalid character dictionary manual selection.');
|
||||
}
|
||||
const remaining = readOverrides(filePath).filter(
|
||||
(entry) => entry.seriesKey !== normalized.seriesKey,
|
||||
);
|
||||
writeOverrides(filePath, [...remaining, normalized]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -93,6 +93,7 @@ export type CharacterDictionarySnapshotResult = {
|
||||
entryCount: number;
|
||||
fromCache: boolean;
|
||||
updatedAt: number;
|
||||
staleMediaIds?: number[];
|
||||
};
|
||||
|
||||
export type CharacterDictionarySnapshotProgress = {
|
||||
@@ -112,6 +113,27 @@ export type MergedCharacterDictionaryBuildResult = {
|
||||
entryCount: number;
|
||||
};
|
||||
|
||||
export type AniListMediaCandidate = {
|
||||
id: number;
|
||||
title: string;
|
||||
episodes: number | null;
|
||||
};
|
||||
|
||||
export type CharacterDictionaryManualSelectionSnapshot = {
|
||||
seriesKey: string;
|
||||
guessTitle: string | null;
|
||||
current: AniListMediaCandidate | null;
|
||||
override: AniListMediaCandidate | null;
|
||||
candidates: AniListMediaCandidate[];
|
||||
};
|
||||
|
||||
export type CharacterDictionaryManualSelectionResult = {
|
||||
ok: boolean;
|
||||
seriesKey: string;
|
||||
selected: AniListMediaCandidate;
|
||||
staleMediaIds: number[];
|
||||
};
|
||||
|
||||
export interface CharacterDictionaryRuntimeDeps {
|
||||
userDataPath: string;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
@@ -133,4 +155,5 @@ export interface CharacterDictionaryRuntimeDeps {
|
||||
export type ResolvedAniListMedia = {
|
||||
id: number;
|
||||
title: string;
|
||||
staleMediaIds?: number[];
|
||||
};
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface CliCommandRuntimeServiceContext {
|
||||
getAnilistQueueStatus: CliCommandRuntimeServiceDepsParams['anilist']['getQueueStatus'];
|
||||
retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams['anilist']['retryQueueNow'];
|
||||
generateCharacterDictionary: CliCommandRuntimeServiceDepsParams['dictionary']['generate'];
|
||||
getCharacterDictionarySelection: CliCommandRuntimeServiceDepsParams['dictionary']['getSelection'];
|
||||
setCharacterDictionarySelection: CliCommandRuntimeServiceDepsParams['dictionary']['setSelection'];
|
||||
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
|
||||
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
|
||||
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
|
||||
@@ -103,6 +105,8 @@ function createCliCommandDepsFromContext(
|
||||
},
|
||||
dictionary: {
|
||||
generate: context.generateCharacterDictionary,
|
||||
getSelection: context.getCharacterDictionarySelection,
|
||||
setSelection: context.setCharacterDictionarySelection,
|
||||
},
|
||||
jellyfin: {
|
||||
openSetup: context.openJellyfinSetup,
|
||||
|
||||
@@ -94,6 +94,8 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup'];
|
||||
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
|
||||
getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection'];
|
||||
setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection'];
|
||||
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
|
||||
getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
|
||||
appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
|
||||
@@ -169,6 +171,8 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
};
|
||||
dictionary: {
|
||||
generate: CliCommandDepsRuntimeOptions['dictionary']['generate'];
|
||||
getSelection: CliCommandDepsRuntimeOptions['dictionary']['getSelection'];
|
||||
setSelection: CliCommandDepsRuntimeOptions['dictionary']['setSelection'];
|
||||
};
|
||||
jellyfin: {
|
||||
openSetup: CliCommandDepsRuntimeOptions['jellyfin']['openSetup'];
|
||||
@@ -258,6 +262,8 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
openAnilistSetup: params.openAnilistSetup,
|
||||
getAnilistQueueStatus: params.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: params.retryAnilistQueueNow,
|
||||
getCharacterDictionarySelection: params.getCharacterDictionarySelection,
|
||||
setCharacterDictionarySelection: params.setCharacterDictionarySelection,
|
||||
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
|
||||
getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
|
||||
appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
|
||||
@@ -341,6 +347,8 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
},
|
||||
dictionary: {
|
||||
generate: params.dictionary.generate,
|
||||
getSelection: params.dictionary.getSelection,
|
||||
setSelection: params.dictionary.setSelection,
|
||||
},
|
||||
jellyfin: {
|
||||
openSetup: params.jellyfin.openSetup,
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface OverlayShortcutRuntimeServiceInput {
|
||||
isOverlayShortcutContextActive?: () => boolean;
|
||||
showMpvOsd: (text: string) => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openCharacterDictionary: () => void;
|
||||
openJimaku: () => void;
|
||||
markAudioCard: () => Promise<void>;
|
||||
copySubtitleMultiple: (timeoutMs: number) => void;
|
||||
@@ -49,6 +50,9 @@ export function createOverlayShortcutsRuntimeService(
|
||||
openRuntimeOptions: () => {
|
||||
input.openRuntimeOptionsPalette();
|
||||
},
|
||||
openCharacterDictionary: () => {
|
||||
input.openCharacterDictionary();
|
||||
},
|
||||
openJimaku: () => {
|
||||
input.openJimaku();
|
||||
},
|
||||
|
||||
@@ -459,6 +459,69 @@ test('auto sync keeps revisited media retained when a new title is added afterwa
|
||||
assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '4 - Title 4', '3 - Title 3']);
|
||||
});
|
||||
|
||||
test('auto sync removes stale manual-selection media ids when applying corrected snapshot', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');
|
||||
fs.mkdirSync(dictionariesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dictionariesDir, 'auto-sync-state.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
activeMediaIds: [
|
||||
'10607 - Rerere no Tensai Bakabon',
|
||||
'130298 - The Eminence in Shadow',
|
||||
],
|
||||
mergedRevision: 'old',
|
||||
mergedDictionaryTitle: 'SubMiner Character Dictionary',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
const builtMediaIds: number[][] = [];
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
maxLoaded: 5,
|
||||
profileScope: 'all',
|
||||
}),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 21355,
|
||||
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||
entryCount: 120,
|
||||
fromCache: false,
|
||||
updatedAt: 99,
|
||||
staleMediaIds: [10607],
|
||||
}),
|
||||
buildMergedDictionary: async (mediaIds) => {
|
||||
builtMediaIds.push([...mediaIds]);
|
||||
return {
|
||||
zipPath: path.join(dictionariesDir, 'merged.zip'),
|
||||
revision: `rev-${mediaIds.join('-')}`,
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 200,
|
||||
};
|
||||
},
|
||||
getYomitanDictionaryInfo: async () => [],
|
||||
importYomitanDictionary: async () => true,
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => false,
|
||||
now: () => 1,
|
||||
});
|
||||
|
||||
await runtime.runSyncNow();
|
||||
|
||||
assert.deepEqual(builtMediaIds, [[21355, 130298]]);
|
||||
const state = JSON.parse(
|
||||
fs.readFileSync(path.join(dictionariesDir, 'auto-sync-state.json'), 'utf8'),
|
||||
) as { activeMediaIds: string[] };
|
||||
assert.deepEqual(state.activeMediaIds, [
|
||||
'21355 - Re:ZERO -Starting Life in Another World-',
|
||||
'130298 - The Eminence in Shadow',
|
||||
]);
|
||||
});
|
||||
|
||||
test('auto sync persists rebuilt MRU state even if Yomitan import fails afterward', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');
|
||||
|
||||
@@ -271,12 +271,19 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
currentMediaId = snapshot.mediaId;
|
||||
currentMediaTitle = snapshot.mediaTitle;
|
||||
const state = readAutoSyncState(statePath);
|
||||
const staleMediaIds = new Set(
|
||||
(snapshot.staleMediaIds ?? [])
|
||||
.map((mediaId) => normalizeMediaId(mediaId))
|
||||
.filter((mediaId): mediaId is number => mediaId !== null),
|
||||
);
|
||||
const nextActiveMediaIds = [
|
||||
{
|
||||
mediaId: snapshot.mediaId,
|
||||
label: buildActiveMediaLabel(snapshot.mediaId, snapshot.mediaTitle),
|
||||
},
|
||||
...state.activeMediaIds.filter((entry) => entry.mediaId !== snapshot.mediaId),
|
||||
...state.activeMediaIds.filter(
|
||||
(entry) => entry.mediaId !== snapshot.mediaId && !staleMediaIds.has(entry.mediaId),
|
||||
),
|
||||
].slice(0, Math.max(1, Math.floor(config.maxLoaded)));
|
||||
const nextActiveMediaIdValues = nextActiveMediaIds.map((entry) => entry.mediaId);
|
||||
deps.logInfo?.(
|
||||
|
||||
48
src/main/runtime/character-dictionary-open.ts
Normal file
48
src/main/runtime/character-dictionary-open.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const CHARACTER_DICTIONARY_MODAL: OverlayHostedModal = 'character-dictionary';
|
||||
const CHARACTER_DICTIONARY_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export async function openCharacterDictionaryModal(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: CHARACTER_DICTIONARY_MODAL,
|
||||
timeoutMs: CHARACTER_DICTIONARY_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Character dictionary modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.event.characterDictionaryOpen,
|
||||
modal: CHARACTER_DICTIONARY_MODAL,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,8 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
|
||||
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
|
||||
getCharacterDictionarySelection?: CliCommandContextFactoryDeps['getCharacterDictionarySelection'];
|
||||
setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection'];
|
||||
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
@@ -86,6 +88,8 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
getAnilistQueueStatus: deps.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: deps.retryAnilistQueueNow,
|
||||
generateCharacterDictionary: deps.generateCharacterDictionary,
|
||||
getCharacterDictionarySelection: deps.getCharacterDictionarySelection,
|
||||
setCharacterDictionarySelection: deps.setCharacterDictionarySelection,
|
||||
runStatsCommand: deps.runStatsCommand,
|
||||
runJellyfinCommand: deps.runJellyfinCommand,
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
|
||||
@@ -48,6 +48,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
|
||||
processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
|
||||
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
|
||||
getCharacterDictionarySelection?: CliCommandContextFactoryDeps['getCharacterDictionarySelection'];
|
||||
setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection'];
|
||||
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
@@ -113,6 +115,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(),
|
||||
generateCharacterDictionary: (targetPath?: string) =>
|
||||
deps.generateCharacterDictionary(targetPath),
|
||||
getCharacterDictionarySelection: deps.getCharacterDictionarySelection,
|
||||
setCharacterDictionarySelection: deps.setCharacterDictionarySelection,
|
||||
runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source),
|
||||
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
|
||||
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
|
||||
|
||||
@@ -41,6 +41,8 @@ export type CliCommandContextFactoryDeps = {
|
||||
getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
|
||||
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
|
||||
getCharacterDictionarySelection?: CliCommandRuntimeServiceContext['getCharacterDictionarySelection'];
|
||||
setCharacterDictionarySelection?: CliCommandRuntimeServiceContext['setCharacterDictionarySelection'];
|
||||
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
|
||||
@@ -98,6 +100,23 @@ export function createCliCommandContext(
|
||||
getAnilistQueueStatus: deps.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: deps.retryAnilistQueueNow,
|
||||
generateCharacterDictionary: deps.generateCharacterDictionary,
|
||||
getCharacterDictionarySelection:
|
||||
deps.getCharacterDictionarySelection ??
|
||||
(async () => ({
|
||||
seriesKey: '',
|
||||
guessTitle: null,
|
||||
current: null,
|
||||
override: null,
|
||||
candidates: [],
|
||||
})),
|
||||
setCharacterDictionarySelection:
|
||||
deps.setCharacterDictionarySelection ??
|
||||
(async () => ({
|
||||
ok: false,
|
||||
seriesKey: '',
|
||||
selected: { id: 0, title: '', episodes: null },
|
||||
staleMediaIds: [],
|
||||
})),
|
||||
runStatsCommand: deps.runStatsCommand,
|
||||
runJellyfinCommand: deps.runJellyfinCommand,
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
|
||||
@@ -51,6 +51,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
openCharacterDictionary: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
@@ -62,6 +63,9 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: undefined,
|
||||
stats: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
|
||||
@@ -16,6 +16,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
multiCopyTimeoutMs: 5000,
|
||||
toggleSecondarySub: null,
|
||||
markAudioCard: null,
|
||||
openCharacterDictionary: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openSessionHelp: null,
|
||||
|
||||
@@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
multiCopyTimeoutMs: 5000,
|
||||
toggleSecondarySub: null,
|
||||
markAudioCard: null,
|
||||
openCharacterDictionary: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openSessionHelp: null,
|
||||
|
||||
@@ -16,6 +16,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
|
||||
isOverlayShortcutContextActive: () => false,
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openCharacterDictionary: () => calls.push('character-dictionary'),
|
||||
openJimaku: () => calls.push('jimaku'),
|
||||
markAudioCard: async () => {
|
||||
calls.push('mark-audio');
|
||||
@@ -47,6 +48,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
|
||||
assert.equal(shortcutsRegistered, true);
|
||||
deps.showMpvOsd('x');
|
||||
deps.openRuntimeOptionsPalette();
|
||||
deps.openCharacterDictionary();
|
||||
deps.openJimaku();
|
||||
await deps.markAudioCard();
|
||||
deps.copySubtitleMultiple(5000);
|
||||
@@ -63,6 +65,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
|
||||
'registered:true',
|
||||
'osd:x',
|
||||
'runtime-options',
|
||||
'character-dictionary',
|
||||
'jimaku',
|
||||
'mark-audio',
|
||||
'copy-multi:5000',
|
||||
|
||||
@@ -11,6 +11,7 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler(
|
||||
isOverlayShortcutContextActive: () => deps.isOverlayShortcutContextActive?.() ?? true,
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
openCharacterDictionary: () => deps.openCharacterDictionary(),
|
||||
openJimaku: () => deps.openJimaku(),
|
||||
markAudioCard: () => deps.markAudioCard(),
|
||||
copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs),
|
||||
|
||||
@@ -124,6 +124,9 @@ function createQueuedIpcListenerWithPayload<T>(
|
||||
|
||||
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
||||
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
|
||||
const onOpenCharacterDictionaryEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.characterDictionaryOpen,
|
||||
);
|
||||
const onOpenControllerSelectEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.controllerSelectOpen,
|
||||
);
|
||||
@@ -340,6 +343,7 @@ const electronAPI: ElectronAPI = {
|
||||
onOpenJimaku: onOpenJimakuEvent,
|
||||
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
|
||||
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
|
||||
onOpenCharacterDictionary: onOpenCharacterDictionaryEvent,
|
||||
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
|
||||
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
|
||||
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
|
||||
@@ -363,6 +367,10 @@ const electronAPI: ElectronAPI = {
|
||||
request: YoutubePickerResolveRequest,
|
||||
): Promise<YoutubePickerResolveResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.youtubePickerResolve, request),
|
||||
getCharacterDictionarySelection: () =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getCharacterDictionarySelection),
|
||||
setCharacterDictionarySelection: (mediaId: number) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.setCharacterDictionarySelection, mediaId),
|
||||
notifyOverlayModalClosed: (modal) => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
|
||||
},
|
||||
|
||||
@@ -365,6 +365,7 @@ function createKeyboardHandlerHarness() {
|
||||
|
||||
const handlers = createKeyboardHandlers(ctx as never, {
|
||||
handleRuntimeOptionsKeydown: () => false,
|
||||
handleCharacterDictionaryKeydown: () => false,
|
||||
handleSubsyncKeydown: () => false,
|
||||
handleKikuKeydown: () => false,
|
||||
handleJimakuKeydown: () => false,
|
||||
|
||||
@@ -12,6 +12,7 @@ export function createKeyboardHandlers(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleCharacterDictionaryKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||
@@ -1004,6 +1005,10 @@ export function createKeyboardHandlers(
|
||||
options.handleRuntimeOptionsKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.characterDictionaryModalOpen) {
|
||||
options.handleCharacterDictionaryKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.subsyncModalOpen) {
|
||||
options.handleSubsyncKeydown(e);
|
||||
return;
|
||||
|
||||
@@ -197,6 +197,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="characterDictionaryModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content character-dictionary-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Character Dictionary Anime</div>
|
||||
<button id="characterDictionaryClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="characterDictionarySummary" class="runtime-options-hint"></div>
|
||||
<div id="characterDictionaryCurrent" class="character-dictionary-current"></div>
|
||||
<ul id="characterDictionaryCandidates" class="character-dictionary-candidates"></ul>
|
||||
<div id="characterDictionaryStatus" class="runtime-options-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content subsync-modal-content">
|
||||
<div class="modal-header">
|
||||
|
||||
144
src/renderer/modals/character-dictionary.test.ts
Normal file
144
src/renderer/modals/character-dictionary.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { CharacterDictionarySelectionSnapshot, ElectronAPI } from '../../types';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createCharacterDictionaryModal } from './character-dictionary.js';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
add: (...entries: string[]) => entries.forEach((entry) => tokens.add(entry)),
|
||||
remove: (...entries: string[]) => entries.forEach((entry) => tokens.delete(entry)),
|
||||
toggle: (entry: string, force?: boolean) => {
|
||||
if (force === undefined) {
|
||||
if (tokens.has(entry)) tokens.delete(entry);
|
||||
else tokens.add(entry);
|
||||
return;
|
||||
}
|
||||
if (force) tokens.add(entry);
|
||||
else tokens.delete(entry);
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
};
|
||||
}
|
||||
|
||||
function createElementStub() {
|
||||
return {
|
||||
className: '',
|
||||
textContent: '',
|
||||
type: '',
|
||||
children: [] as unknown[],
|
||||
classList: createClassList(),
|
||||
append(...children: unknown[]) {
|
||||
this.children.push(...children);
|
||||
},
|
||||
addEventListener: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function createNodeStub(hidden = false) {
|
||||
return {
|
||||
textContent: '',
|
||||
children: [] as unknown[],
|
||||
classList: createClassList(hidden ? ['hidden'] : []),
|
||||
setAttribute: () => {},
|
||||
addEventListener: () => {},
|
||||
replaceChildren(...children: unknown[]) {
|
||||
this.children = [...children];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function flushAsyncWork(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('character dictionary modal loads candidates and applies selected override', async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
const snapshot: CharacterDictionarySelectionSnapshot = {
|
||||
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||
guessTitle: 'Re ZERO, Starting Life in Another World',
|
||||
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
|
||||
override: null,
|
||||
candidates: [{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 }],
|
||||
};
|
||||
const calls: number[] = [];
|
||||
const overlay = createNodeStub();
|
||||
const modalNode = createNodeStub(true);
|
||||
const candidates = createNodeStub();
|
||||
const status = createNodeStub();
|
||||
const state = createRendererState();
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
getCharacterDictionarySelection: async () => snapshot,
|
||||
setCharacterDictionarySelection: async (mediaId: number) => {
|
||||
calls.push(mediaId);
|
||||
return {
|
||||
ok: true,
|
||||
seriesKey: snapshot.seriesKey,
|
||||
selected: snapshot.candidates[0]!,
|
||||
staleMediaIds: [10607],
|
||||
};
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
} satisfies Pick<
|
||||
ElectronAPI,
|
||||
| 'getCharacterDictionarySelection'
|
||||
| 'setCharacterDictionarySelection'
|
||||
| 'notifyOverlayModalClosed'
|
||||
>,
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => createElementStub(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const modal = createCharacterDictionaryModal(
|
||||
{
|
||||
state,
|
||||
dom: {
|
||||
overlay,
|
||||
characterDictionaryModal: modalNode,
|
||||
characterDictionaryClose: createNodeStub(),
|
||||
characterDictionarySummary: createNodeStub(),
|
||||
characterDictionaryCurrent: createNodeStub(),
|
||||
characterDictionaryCandidates: candidates,
|
||||
characterDictionaryStatus: status,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
await modal.openCharacterDictionaryModal();
|
||||
assert.equal(state.characterDictionaryModalOpen, true);
|
||||
assert.equal(overlay.classList.contains('interactive'), true);
|
||||
assert.equal(modalNode.classList.contains('hidden'), false);
|
||||
assert.equal(candidates.children.length, 1);
|
||||
|
||||
modal.handleCharacterDictionaryKeydown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.deepEqual(calls, [21355]);
|
||||
assert.match(status.textContent, /Override saved/);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
224
src/renderer/modals/character-dictionary.ts
Normal file
224
src/renderer/modals/character-dictionary.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import type {
|
||||
CharacterDictionaryCandidate,
|
||||
CharacterDictionarySelectionSnapshot,
|
||||
} from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
function clampIndex(index: number, length: number): number {
|
||||
if (length <= 0) return 0;
|
||||
return Math.min(Math.max(index, 0), length - 1);
|
||||
}
|
||||
|
||||
function formatCandidate(candidate: CharacterDictionaryCandidate | null): string {
|
||||
if (!candidate) return 'None';
|
||||
const episodes = candidate.episodes === null ? '?' : String(candidate.episodes);
|
||||
return `${candidate.id} - ${candidate.title} (${episodes} episodes)`;
|
||||
}
|
||||
|
||||
function buildSummary(snapshot: CharacterDictionarySelectionSnapshot): string {
|
||||
const guess = snapshot.guessTitle ?? 'No active title';
|
||||
return `Series key: ${snapshot.seriesKey} · Guess: ${guess}`;
|
||||
}
|
||||
|
||||
export function createCharacterDictionaryModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
function setStatus(message: string, isError = false): void {
|
||||
ctx.state.characterDictionaryStatus = message;
|
||||
ctx.dom.characterDictionaryStatus.textContent = message;
|
||||
ctx.dom.characterDictionaryStatus.classList.toggle('error', isError);
|
||||
}
|
||||
|
||||
function setSelection(snapshot: CharacterDictionarySelectionSnapshot): void {
|
||||
const previousId =
|
||||
ctx.state.characterDictionarySelection?.candidates[ctx.state.characterDictionarySelectedIndex]
|
||||
?.id;
|
||||
ctx.state.characterDictionarySelection = snapshot;
|
||||
const nextIndex = snapshot.candidates.findIndex((candidate) => candidate.id === previousId);
|
||||
ctx.state.characterDictionarySelectedIndex = clampIndex(
|
||||
nextIndex >= 0 ? nextIndex : 0,
|
||||
snapshot.candidates.length,
|
||||
);
|
||||
render();
|
||||
}
|
||||
|
||||
function renderCandidate(candidate: CharacterDictionaryCandidate, index: number): HTMLLIElement {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'character-dictionary-candidate';
|
||||
item.classList.toggle('active', index === ctx.state.characterDictionarySelectedIndex);
|
||||
|
||||
const main = document.createElement('div');
|
||||
main.className = 'runtime-options-label';
|
||||
main.textContent = candidate.title;
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'runtime-options-allowed';
|
||||
const episodeLabel = candidate.episodes === null ? '?' : String(candidate.episodes);
|
||||
meta.textContent = `AniList ${candidate.id} · ${episodeLabel} episodes`;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.className = 'character-dictionary-use';
|
||||
button.type = 'button';
|
||||
button.textContent = 'Use';
|
||||
button.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
ctx.state.characterDictionarySelectedIndex = index;
|
||||
void applySelectedCandidate();
|
||||
});
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'character-dictionary-candidate-body';
|
||||
body.append(main, meta);
|
||||
|
||||
item.append(body, button);
|
||||
item.addEventListener('click', () => {
|
||||
ctx.state.characterDictionarySelectedIndex = index;
|
||||
render();
|
||||
});
|
||||
item.addEventListener('dblclick', () => {
|
||||
ctx.state.characterDictionarySelectedIndex = index;
|
||||
void applySelectedCandidate();
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const snapshot = ctx.state.characterDictionarySelection;
|
||||
ctx.dom.characterDictionaryCandidates.replaceChildren();
|
||||
if (!snapshot) {
|
||||
ctx.dom.characterDictionarySummary.textContent = '';
|
||||
ctx.dom.characterDictionaryCurrent.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.characterDictionarySummary.textContent = buildSummary(snapshot);
|
||||
ctx.dom.characterDictionaryCurrent.textContent = `Current: ${formatCandidate(
|
||||
snapshot.current,
|
||||
)} · Override: ${formatCandidate(snapshot.override)}`;
|
||||
|
||||
if (snapshot.candidates.length === 0) {
|
||||
const empty = document.createElement('li');
|
||||
empty.className = 'character-dictionary-empty';
|
||||
empty.textContent = 'No AniList candidates found.';
|
||||
ctx.dom.characterDictionaryCandidates.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.characterDictionaryCandidates.replaceChildren(
|
||||
...snapshot.candidates.map((candidate, index) => renderCandidate(candidate, index)),
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshSelection(): Promise<void> {
|
||||
const snapshot = await window.electronAPI.getCharacterDictionarySelection();
|
||||
setSelection(snapshot);
|
||||
setStatus(
|
||||
snapshot.override
|
||||
? `Override active: ${formatCandidate(snapshot.override)}`
|
||||
: 'Select the correct AniList entry.',
|
||||
);
|
||||
}
|
||||
|
||||
async function applySelectedCandidate(): Promise<void> {
|
||||
const snapshot = ctx.state.characterDictionarySelection;
|
||||
const candidate = snapshot?.candidates[ctx.state.characterDictionarySelectedIndex];
|
||||
if (!candidate) return;
|
||||
|
||||
setStatus(`Saving override for ${candidate.title}...`);
|
||||
try {
|
||||
const result = await window.electronAPI.setCharacterDictionarySelection(candidate.id);
|
||||
if (!result.ok) {
|
||||
setStatus('Failed to save override', true);
|
||||
return;
|
||||
}
|
||||
await refreshSelection();
|
||||
const staleLabel =
|
||||
result.staleMediaIds.length > 0
|
||||
? ` Removed stale: ${result.staleMediaIds.join(', ')}.`
|
||||
: '';
|
||||
setStatus(`Override saved: ${formatCandidate(result.selected)}.${staleLabel}`);
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||
}
|
||||
}
|
||||
|
||||
function showShell(): void {
|
||||
ctx.state.characterDictionaryModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.characterDictionaryModal.classList.remove('hidden');
|
||||
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'false');
|
||||
setStatus('Loading AniList candidates...');
|
||||
}
|
||||
|
||||
async function openCharacterDictionaryModal(): Promise<void> {
|
||||
if (!ctx.state.characterDictionaryModalOpen) {
|
||||
showShell();
|
||||
} else {
|
||||
setStatus('Refreshing AniList candidates...');
|
||||
}
|
||||
await refreshSelection();
|
||||
}
|
||||
|
||||
function closeCharacterDictionaryModal(): void {
|
||||
if (!ctx.state.characterDictionaryModalOpen) return;
|
||||
ctx.state.characterDictionaryModalOpen = false;
|
||||
ctx.state.characterDictionarySelection = null;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.characterDictionaryModal.classList.add('hidden');
|
||||
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'true');
|
||||
ctx.dom.characterDictionaryCandidates.replaceChildren();
|
||||
window.electronAPI.notifyOverlayModalClosed('character-dictionary');
|
||||
setStatus('');
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
}
|
||||
|
||||
function moveSelection(delta: -1 | 1): void {
|
||||
const length = ctx.state.characterDictionarySelection?.candidates.length ?? 0;
|
||||
if (length <= 0) return;
|
||||
ctx.state.characterDictionarySelectedIndex = clampIndex(
|
||||
ctx.state.characterDictionarySelectedIndex + delta,
|
||||
length,
|
||||
);
|
||||
render();
|
||||
}
|
||||
|
||||
function handleCharacterDictionaryKeydown(e: KeyboardEvent): boolean {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeCharacterDictionaryModal();
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') {
|
||||
e.preventDefault();
|
||||
moveSelection(1);
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'ArrowUp' || e.key === 'k' || e.key === 'K') {
|
||||
e.preventDefault();
|
||||
moveSelection(-1);
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void applySelectedCandidate();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx.dom.characterDictionaryClose.addEventListener('click', closeCharacterDictionaryModal);
|
||||
|
||||
return {
|
||||
openCharacterDictionaryModal,
|
||||
closeCharacterDictionaryModal,
|
||||
handleCharacterDictionaryKeydown,
|
||||
};
|
||||
}
|
||||
@@ -94,6 +94,7 @@ const OVERLAY_SHORTCUTS: Array<{
|
||||
{ key: 'mineSentenceMultiple', label: 'Mine sentence (multi)' },
|
||||
{ key: 'toggleSecondarySub', label: 'Toggle secondary subtitle mode' },
|
||||
{ key: 'markAudioCard', label: 'Mark audio card' },
|
||||
{ key: 'openCharacterDictionary', label: 'Open character dictionary anime selector' },
|
||||
{ key: 'openRuntimeOptions', label: 'Open runtime options' },
|
||||
{ key: 'openJimaku', label: 'Open jimaku' },
|
||||
{ key: 'openSessionHelp', label: 'Open session help' },
|
||||
|
||||
@@ -38,6 +38,7 @@ import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
|
||||
import { createSessionHelpModal } from './modals/session-help.js';
|
||||
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
||||
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
||||
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
|
||||
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
||||
import { createSubsyncModal } from './modals/subsync.js';
|
||||
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
||||
@@ -71,6 +72,7 @@ function isAnySettingsModalOpen(): boolean {
|
||||
ctx.state.controllerSelectModalOpen ||
|
||||
ctx.state.controllerDebugModalOpen ||
|
||||
ctx.state.runtimeOptionsModalOpen ||
|
||||
ctx.state.characterDictionaryModalOpen ||
|
||||
ctx.state.subsyncModalOpen ||
|
||||
ctx.state.kikuModalOpen ||
|
||||
ctx.state.jimakuModalOpen ||
|
||||
@@ -87,6 +89,7 @@ function isAnyModalOpen(): boolean {
|
||||
ctx.state.jimakuModalOpen ||
|
||||
ctx.state.kikuModalOpen ||
|
||||
ctx.state.runtimeOptionsModalOpen ||
|
||||
ctx.state.characterDictionaryModalOpen ||
|
||||
ctx.state.subsyncModalOpen ||
|
||||
ctx.state.youtubePickerModalOpen ||
|
||||
ctx.state.sessionHelpModalOpen ||
|
||||
@@ -114,6 +117,10 @@ const runtimeOptionsModal = createRuntimeOptionsModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const characterDictionaryModal = createCharacterDictionaryModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const subsyncModal = createSubsyncModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
@@ -165,6 +172,7 @@ const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
|
||||
});
|
||||
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||
@@ -221,6 +229,7 @@ function getActiveModal(): string | null {
|
||||
if (ctx.state.playlistBrowserModalOpen) return 'playlist-browser';
|
||||
if (ctx.state.kikuModalOpen) return 'kiku';
|
||||
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
||||
if (ctx.state.characterDictionaryModalOpen) return 'character-dictionary';
|
||||
if (ctx.state.subsyncModalOpen) return 'subsync';
|
||||
if (ctx.state.sessionHelpModalOpen) return 'session-help';
|
||||
return null;
|
||||
@@ -248,6 +257,9 @@ function dismissActiveUiAfterError(): void {
|
||||
if (ctx.state.runtimeOptionsModalOpen) {
|
||||
runtimeOptionsModal.closeRuntimeOptionsModal();
|
||||
}
|
||||
if (ctx.state.characterDictionaryModalOpen) {
|
||||
characterDictionaryModal.closeCharacterDictionaryModal();
|
||||
}
|
||||
if (ctx.state.subsyncModalOpen) {
|
||||
subsyncModal.closeSubsyncModal();
|
||||
}
|
||||
@@ -435,6 +447,12 @@ function registerModalOpenHandlers(): void {
|
||||
window.electronAPI.notifyOverlayModalOpened('runtime-options');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenCharacterDictionary(() => {
|
||||
runGuardedAsync('character-dictionary:open', async () => {
|
||||
await characterDictionaryModal.openCharacterDictionaryModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('character-dictionary');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenSessionHelp(() => {
|
||||
runGuarded('session-help:open', () => {
|
||||
sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo());
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionState,
|
||||
RuntimeOptionValue,
|
||||
CharacterDictionarySelectionSnapshot,
|
||||
SubtitlePosition,
|
||||
SubtitleSidebarConfig,
|
||||
SubtitleCue,
|
||||
@@ -64,6 +65,11 @@ export type RendererState = {
|
||||
runtimeOptionSelectedIndex: number;
|
||||
runtimeOptionDraftValues: Map<RuntimeOptionId, RuntimeOptionValue>;
|
||||
|
||||
characterDictionaryModalOpen: boolean;
|
||||
characterDictionarySelection: CharacterDictionarySelectionSnapshot | null;
|
||||
characterDictionarySelectedIndex: number;
|
||||
characterDictionaryStatus: string;
|
||||
|
||||
subsyncModalOpen: boolean;
|
||||
subsyncSourceTracks: SubsyncSourceTrack[];
|
||||
subsyncSubmitting: boolean;
|
||||
@@ -169,6 +175,11 @@ export function createRendererState(): RendererState {
|
||||
runtimeOptionSelectedIndex: 0,
|
||||
runtimeOptionDraftValues: new Map(),
|
||||
|
||||
characterDictionaryModalOpen: false,
|
||||
characterDictionarySelection: null,
|
||||
characterDictionarySelectedIndex: 0,
|
||||
characterDictionaryStatus: '',
|
||||
|
||||
subsyncModalOpen: false,
|
||||
subsyncSourceTracks: [],
|
||||
subsyncSubmitting: false,
|
||||
|
||||
@@ -1463,6 +1463,71 @@ iframe[id^='yomitan-popup'],
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.character-dictionary-content {
|
||||
width: min(680px, 92%);
|
||||
}
|
||||
|
||||
.character-dictionary-current {
|
||||
font-size: 12px;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.character-dictionary-candidates {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(110, 115, 141, 0.2);
|
||||
border-radius: 8px;
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.character-dictionary-candidate,
|
||||
.character-dictionary-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(110, 115, 141, 0.1);
|
||||
}
|
||||
|
||||
.character-dictionary-candidate {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.character-dictionary-candidate:last-child,
|
||||
.character-dictionary-empty:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.character-dictionary-candidate.active {
|
||||
background: rgba(138, 173, 244, 0.15);
|
||||
}
|
||||
|
||||
.character-dictionary-candidate-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.character-dictionary-use {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(138, 173, 244, 0.38);
|
||||
border-radius: 6px;
|
||||
background: rgba(54, 58, 79, 0.8);
|
||||
color: var(--ctp-text);
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.character-dictionary-use:hover {
|
||||
background: rgba(91, 96, 120, 0.9);
|
||||
}
|
||||
|
||||
.character-dictionary-empty {
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.controller-select-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -57,6 +57,13 @@ export type RendererDom = {
|
||||
runtimeOptionsList: HTMLUListElement;
|
||||
runtimeOptionsStatus: HTMLDivElement;
|
||||
|
||||
characterDictionaryModal: HTMLDivElement;
|
||||
characterDictionaryClose: HTMLButtonElement;
|
||||
characterDictionarySummary: HTMLDivElement;
|
||||
characterDictionaryCurrent: HTMLDivElement;
|
||||
characterDictionaryCandidates: HTMLUListElement;
|
||||
characterDictionaryStatus: HTMLDivElement;
|
||||
|
||||
subsyncModal: HTMLDivElement;
|
||||
subsyncCloseButton: HTMLButtonElement;
|
||||
subsyncEngineAlass: HTMLInputElement;
|
||||
@@ -177,6 +184,15 @@ export function resolveRendererDom(): RendererDom {
|
||||
runtimeOptionsList: getRequiredElement<HTMLUListElement>('runtimeOptionsList'),
|
||||
runtimeOptionsStatus: getRequiredElement<HTMLDivElement>('runtimeOptionsStatus'),
|
||||
|
||||
characterDictionaryModal: getRequiredElement<HTMLDivElement>('characterDictionaryModal'),
|
||||
characterDictionaryClose: getRequiredElement<HTMLButtonElement>('characterDictionaryClose'),
|
||||
characterDictionarySummary: getRequiredElement<HTMLDivElement>('characterDictionarySummary'),
|
||||
characterDictionaryCurrent: getRequiredElement<HTMLDivElement>('characterDictionaryCurrent'),
|
||||
characterDictionaryCandidates: getRequiredElement<HTMLUListElement>(
|
||||
'characterDictionaryCandidates',
|
||||
),
|
||||
characterDictionaryStatus: getRequiredElement<HTMLDivElement>('characterDictionaryStatus'),
|
||||
|
||||
subsyncModal: getRequiredElement<HTMLDivElement>('subsyncModal'),
|
||||
subsyncCloseButton: getRequiredElement<HTMLButtonElement>('subsyncClose'),
|
||||
subsyncEngineAlass: getRequiredElement<HTMLInputElement>('subsyncEngineAlass'),
|
||||
|
||||
@@ -12,6 +12,7 @@ export const OVERLAY_HOSTED_MODALS = [
|
||||
'controller-debug',
|
||||
'subtitle-sidebar',
|
||||
'session-help',
|
||||
'character-dictionary',
|
||||
] as const;
|
||||
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
||||
|
||||
@@ -70,6 +71,8 @@ export const IPC_CHANNELS = {
|
||||
openAnilistSetup: 'anilist:open-setup',
|
||||
getAnilistQueueStatus: 'anilist:get-queue-status',
|
||||
retryAnilistNow: 'anilist:retry-now',
|
||||
getCharacterDictionarySelection: 'character-dictionary:get-selection',
|
||||
setCharacterDictionarySelection: 'character-dictionary:set-selection',
|
||||
appendClipboardVideoToQueue: 'clipboard:append-video-to-queue',
|
||||
getPlaylistBrowserSnapshot: 'playlist-browser:get-snapshot',
|
||||
appendPlaylistBrowserFile: 'playlist-browser:append-file',
|
||||
@@ -113,6 +116,7 @@ export const IPC_CHANNELS = {
|
||||
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
||||
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||
sessionHelpOpen: 'session-help:open',
|
||||
characterDictionaryOpen: 'character-dictionary:open',
|
||||
controllerSelectOpen: 'controller-select:open',
|
||||
controllerDebugOpen: 'controller-debug:open',
|
||||
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
|
||||
|
||||
@@ -31,6 +31,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
'toggleSubtitleSidebar',
|
||||
'openRuntimeOptions',
|
||||
'openSessionHelp',
|
||||
'openCharacterDictionary',
|
||||
'openControllerSelect',
|
||||
'openControllerDebug',
|
||||
'openJimaku',
|
||||
|
||||
@@ -87,6 +87,7 @@ export interface ShortcutsConfig {
|
||||
multiCopyTimeoutMs?: number;
|
||||
toggleSecondarySub?: string | null;
|
||||
markAudioCard?: string | null;
|
||||
openCharacterDictionary?: string | null;
|
||||
openRuntimeOptions?: string | null;
|
||||
openJimaku?: string | null;
|
||||
openSessionHelp?: string | null;
|
||||
|
||||
@@ -341,6 +341,27 @@ export interface SessionActionDispatchRequest {
|
||||
|
||||
export type ResolvedControllerConfig = ResolvedConfig['controller'];
|
||||
|
||||
export interface CharacterDictionaryCandidate {
|
||||
id: number;
|
||||
title: string;
|
||||
episodes: number | null;
|
||||
}
|
||||
|
||||
export interface CharacterDictionarySelectionSnapshot {
|
||||
seriesKey: string;
|
||||
guessTitle: string | null;
|
||||
current: CharacterDictionaryCandidate | null;
|
||||
override: CharacterDictionaryCandidate | null;
|
||||
candidates: CharacterDictionaryCandidate[];
|
||||
}
|
||||
|
||||
export interface CharacterDictionarySelectionResult {
|
||||
ok: boolean;
|
||||
seriesKey: string;
|
||||
selected: CharacterDictionaryCandidate;
|
||||
staleMediaIds: number[];
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
getOverlayLayer: () => 'visible' | 'modal' | null;
|
||||
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
||||
@@ -410,6 +431,7 @@ export interface ElectronAPI {
|
||||
onOpenJimaku: (callback: () => void) => void;
|
||||
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
|
||||
onOpenPlaylistBrowser: (callback: () => void) => void;
|
||||
onOpenCharacterDictionary: (callback: () => void) => void;
|
||||
onSubtitleSidebarToggle: (callback: () => void) => void;
|
||||
onCancelYoutubeTrackPicker: (callback: () => void) => void;
|
||||
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
||||
@@ -426,6 +448,8 @@ export interface ElectronAPI {
|
||||
youtubePickerResolve: (
|
||||
request: YoutubePickerResolveRequest,
|
||||
) => Promise<YoutubePickerResolveResult>;
|
||||
getCharacterDictionarySelection: () => Promise<CharacterDictionarySelectionSnapshot>;
|
||||
setCharacterDictionarySelection: (mediaId: number) => Promise<CharacterDictionarySelectionResult>;
|
||||
notifyOverlayModalClosed: (
|
||||
modal:
|
||||
| 'runtime-options'
|
||||
@@ -437,7 +461,8 @@ export interface ElectronAPI {
|
||||
| 'controller-select'
|
||||
| 'controller-debug'
|
||||
| 'subtitle-sidebar'
|
||||
| 'session-help',
|
||||
| 'session-help'
|
||||
| 'character-dictionary',
|
||||
) => void;
|
||||
notifyOverlayModalOpened: (
|
||||
modal:
|
||||
@@ -450,7 +475,8 @@ export interface ElectronAPI {
|
||||
| 'controller-select'
|
||||
| 'controller-debug'
|
||||
| 'subtitle-sidebar'
|
||||
| 'session-help',
|
||||
| 'session-help'
|
||||
| 'character-dictionary',
|
||||
) => void;
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||
|
||||
@@ -15,6 +15,7 @@ export type SessionActionId =
|
||||
| 'markAudioCard'
|
||||
| 'openRuntimeOptions'
|
||||
| 'openSessionHelp'
|
||||
| 'openCharacterDictionary'
|
||||
| 'openControllerSelect'
|
||||
| 'openControllerDebug'
|
||||
| 'openJimaku'
|
||||
|
||||
Reference in New Issue
Block a user