mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-29 04:19:26 -07:00
Restore multi-copy digit capture and add AniList selection (#56)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -6,12 +6,14 @@ import { AppLifecycleServiceDeps, startAppLifecycle } from './app-lifecycle';
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
managedPlayback: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
settings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
@@ -37,6 +39,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
openCharacterDictionary: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
@@ -48,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,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
managedPlayback: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
@@ -34,11 +35,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
refreshKnownWords: false,
|
||||
openRuntimeOptions: false,
|
||||
openSessionHelp: false,
|
||||
openCharacterDictionary: false,
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
@@ -50,6 +53,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,
|
||||
@@ -115,6 +121,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
toggleVisibleOverlay: () => {
|
||||
calls.push('toggleVisibleOverlay');
|
||||
},
|
||||
togglePrimarySubtitleBar: () => {
|
||||
calls.push('togglePrimarySubtitleBar');
|
||||
},
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
@@ -199,6 +208,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');
|
||||
},
|
||||
@@ -516,6 +538,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
|
||||
expected: 'startPendingMineSentenceMultiple:2500',
|
||||
},
|
||||
{ args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
|
||||
{ args: { togglePrimarySubtitleBar: true }, expected: 'togglePrimarySubtitleBar' },
|
||||
{ args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' },
|
||||
{
|
||||
args: { openRuntimeOptions: true },
|
||||
@@ -624,6 +647,105 @@ 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 log character dictionary selection success when result is not ok', async () => {
|
||||
const { calls, deps } = createDeps({
|
||||
setCharacterDictionarySelection: async () => ({
|
||||
ok: false,
|
||||
seriesKey: 'test',
|
||||
selected: { id: 0, title: '', episodes: null },
|
||||
staleMediaIds: [],
|
||||
}),
|
||||
});
|
||||
|
||||
handleCliCommand(
|
||||
makeArgs({
|
||||
dictionarySelect: true,
|
||||
dictionaryAnilistId: 21355,
|
||||
dictionaryTarget: '/tmp/re-zero.mkv',
|
||||
}),
|
||||
'initial',
|
||||
deps,
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.ok(calls.includes('warn:Character dictionary override was not saved.'));
|
||||
assert.equal(
|
||||
calls.some((call) => call.startsWith('log:Character dictionary override saved:')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -19,6 +40,7 @@ export interface CliCommandServiceDeps {
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
@@ -64,6 +86,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: {
|
||||
@@ -110,6 +139,7 @@ interface OverlayCliRuntime {
|
||||
isInitialized: () => boolean;
|
||||
initialize: () => void;
|
||||
toggleVisible: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
setVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -162,6 +192,11 @@ export interface CliCommandDepsRuntimeOptions {
|
||||
mediaTitle: string;
|
||||
entryCount: number;
|
||||
}>;
|
||||
getSelection: (targetPath?: string) => Promise<CharacterDictionarySelectionSnapshot>;
|
||||
setSelection: (request: {
|
||||
targetPath?: string;
|
||||
mediaId: number;
|
||||
}) => Promise<CharacterDictionarySelectionResult>;
|
||||
};
|
||||
jellyfin: {
|
||||
openSetup: () => void;
|
||||
@@ -211,6 +246,7 @@ export function createCliCommandDepsRuntime(
|
||||
isOverlayRuntimeInitialized: options.overlay.isInitialized,
|
||||
initializeOverlayRuntime: options.overlay.initialize,
|
||||
toggleVisibleOverlay: options.overlay.toggleVisible,
|
||||
togglePrimarySubtitleBar: options.overlay.togglePrimarySubtitleBar,
|
||||
openFirstRunSetup: options.ui.openFirstRunSetup,
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
options.schedule(() => {
|
||||
@@ -237,6 +273,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 +305,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',
|
||||
@@ -326,6 +372,8 @@ export function handleCliCommand(
|
||||
|
||||
if (args.toggle || args.toggleVisibleOverlay) {
|
||||
deps.toggleVisibleOverlay();
|
||||
} else if (args.togglePrimarySubtitleBar) {
|
||||
deps.togglePrimarySubtitleBar();
|
||||
} else if (args.setup) {
|
||||
deps.openFirstRunSetup();
|
||||
deps.log('Opened first-run setup flow.');
|
||||
@@ -411,6 +459,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 +600,75 @@ 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) => {
|
||||
if (!result.ok) {
|
||||
deps.warn('Character dictionary override was not saved.');
|
||||
return;
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
commands: unknown[];
|
||||
mediaPath: string;
|
||||
restored: number;
|
||||
quitRequested: number;
|
||||
};
|
||||
} {
|
||||
const state = {
|
||||
@@ -28,6 +29,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
commands: [] as unknown[],
|
||||
mediaPath: '',
|
||||
restored: 0,
|
||||
quitRequested: 0,
|
||||
};
|
||||
const metrics: MpvSubtitleRenderMetrics = {
|
||||
subPos: 100,
|
||||
@@ -102,6 +104,10 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
restorePreviousSecondarySubVisibility: () => {
|
||||
state.restored += 1;
|
||||
},
|
||||
shouldQuitOnMpvShutdown: () => false,
|
||||
requestAppQuit: () => {
|
||||
state.quitRequested += 1;
|
||||
},
|
||||
setPreviousSecondarySubVisibility: () => {
|
||||
// intentionally not tracked in this unit test
|
||||
},
|
||||
@@ -223,6 +229,18 @@ test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', asy
|
||||
await dispatchMpvProtocolMessage({ event: 'shutdown' }, deps);
|
||||
|
||||
assert.equal(state.restored, 1);
|
||||
assert.equal(state.quitRequested, 0);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage quits app on managed playback shutdown', async () => {
|
||||
const { deps, state } = createDeps({
|
||||
shouldQuitOnMpvShutdown: () => true,
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage({ event: 'shutdown' }, deps);
|
||||
|
||||
assert.equal(state.restored, 1);
|
||||
assert.equal(state.quitRequested, 1);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is set', async () => {
|
||||
|
||||
@@ -91,6 +91,8 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
) => void;
|
||||
sendCommand: (payload: { command: unknown[]; request_id?: number }) => boolean;
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
shouldQuitOnMpvShutdown: () => boolean;
|
||||
requestAppQuit: () => void;
|
||||
}
|
||||
|
||||
type SubtitleTrackCandidate = {
|
||||
@@ -360,6 +362,9 @@ export async function dispatchMpvProtocolMessage(
|
||||
}
|
||||
} else if (msg.event === 'shutdown') {
|
||||
deps.restorePreviousSecondarySubVisibility();
|
||||
if (deps.shouldQuitOnMpvShutdown()) {
|
||||
deps.requestAppQuit();
|
||||
}
|
||||
} else if (msg.request_id) {
|
||||
if (deps.resolvePendingRequest(msg.request_id, msg)) {
|
||||
return;
|
||||
|
||||
@@ -285,6 +285,25 @@ test('MpvIpcClient onClose resolves outstanding requests and schedules reconnect
|
||||
assert.equal(timers.length, 1);
|
||||
});
|
||||
|
||||
test('MpvIpcClient onClose requests app quit for managed playback', () => {
|
||||
let quitRequests = 0;
|
||||
const client = new MpvIpcClient(
|
||||
'/tmp/mpv.sock',
|
||||
makeDeps({
|
||||
shouldQuitOnMpvShutdown: () => true,
|
||||
requestAppQuit: () => {
|
||||
quitRequests += 1;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
(client as any).scheduleReconnect = () => {};
|
||||
|
||||
(client as any).transport.callbacks.onClose();
|
||||
|
||||
assert.equal(quitRequests, 1);
|
||||
});
|
||||
|
||||
test('MpvIpcClient reconnect replays property subscriptions and initial state requests', () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
|
||||
@@ -105,6 +105,8 @@ export interface MpvIpcClientProtocolDeps {
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||
shouldQuitOnMpvShutdown?: () => boolean;
|
||||
requestAppQuit?: () => void;
|
||||
}
|
||||
|
||||
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
|
||||
@@ -217,6 +219,10 @@ export class MpvIpcClient implements MpvClient {
|
||||
this.playbackPaused = null;
|
||||
this.emit('connection-change', { connected: false });
|
||||
this.failPendingRequests();
|
||||
if (this.deps.shouldQuitOnMpvShutdown?.() === true) {
|
||||
this.deps.requestAppQuit?.();
|
||||
return;
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
},
|
||||
});
|
||||
@@ -399,6 +405,8 @@ export class MpvIpcClient implements MpvClient {
|
||||
restorePreviousSecondarySubVisibility: () => {
|
||||
this.restorePreviousSecondarySubVisibility();
|
||||
},
|
||||
shouldQuitOnMpvShutdown: () => this.deps.shouldQuitOnMpvShutdown?.() ?? false,
|
||||
requestAppQuit: () => this.deps.requestAppQuit?.(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
@@ -135,12 +139,11 @@ test('createOverlayShortcutRuntimeHandlers reports async failures via OSD', asyn
|
||||
}
|
||||
});
|
||||
|
||||
test('runOverlayShortcutLocalFallback dispatches matching actions with timeout', () => {
|
||||
test('runOverlayShortcutLocalFallback dispatches matching single-step actions', () => {
|
||||
const handled: string[] = [];
|
||||
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
|
||||
const shortcuts = makeShortcuts({
|
||||
copySubtitleMultiple: 'Ctrl+M',
|
||||
multiCopyTimeoutMs: 4321,
|
||||
copySubtitle: 'Ctrl+M',
|
||||
});
|
||||
|
||||
const result = runOverlayShortcutLocalFallback(
|
||||
@@ -155,6 +158,7 @@ test('runOverlayShortcutLocalFallback dispatches matching actions with timeout',
|
||||
},
|
||||
{
|
||||
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
|
||||
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
|
||||
openJimaku: () => handled.push('openJimaku'),
|
||||
markAudioCard: () => handled.push('markAudioCard'),
|
||||
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||
@@ -169,10 +173,63 @@ test('runOverlayShortcutLocalFallback dispatches matching actions with timeout',
|
||||
);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(handled, ['copySubtitleMultiple:4321']);
|
||||
assert.deepEqual(handled, ['copySubtitle']);
|
||||
assert.deepEqual(matched, [{ accelerator: 'Ctrl+M', allowWhenRegistered: false }]);
|
||||
});
|
||||
|
||||
test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for renderer handling', () => {
|
||||
const handled: string[] = [];
|
||||
const shortcuts = makeShortcuts({
|
||||
copySubtitleMultiple: 'Ctrl+M',
|
||||
mineSentenceMultiple: 'Ctrl+N',
|
||||
multiCopyTimeoutMs: 4321,
|
||||
});
|
||||
|
||||
const copyResult = runOverlayShortcutLocalFallback(
|
||||
{} as Electron.Input,
|
||||
shortcuts,
|
||||
(_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}`),
|
||||
copySubtitle: () => handled.push('copySubtitle'),
|
||||
toggleSecondarySub: () => handled.push('toggleSecondarySub'),
|
||||
updateLastCardFromClipboard: () => handled.push('updateLastCardFromClipboard'),
|
||||
triggerFieldGrouping: () => handled.push('triggerFieldGrouping'),
|
||||
triggerSubsync: () => handled.push('triggerSubsync'),
|
||||
mineSentence: () => handled.push('mineSentence'),
|
||||
mineSentenceMultiple: (timeoutMs) => handled.push(`mineSentenceMultiple:${timeoutMs}`),
|
||||
},
|
||||
);
|
||||
|
||||
const mineResult = runOverlayShortcutLocalFallback(
|
||||
{} as Electron.Input,
|
||||
shortcuts,
|
||||
(_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}`),
|
||||
copySubtitle: () => handled.push('copySubtitle'),
|
||||
toggleSecondarySub: () => handled.push('toggleSecondarySub'),
|
||||
updateLastCardFromClipboard: () => handled.push('updateLastCardFromClipboard'),
|
||||
triggerFieldGrouping: () => handled.push('triggerFieldGrouping'),
|
||||
triggerSubsync: () => handled.push('triggerSubsync'),
|
||||
mineSentence: () => handled.push('mineSentence'),
|
||||
mineSentenceMultiple: (timeoutMs) => handled.push(`mineSentenceMultiple:${timeoutMs}`),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(copyResult, false);
|
||||
assert.equal(mineResult, false);
|
||||
assert.deepEqual(handled, []);
|
||||
});
|
||||
|
||||
test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-sub toggle', () => {
|
||||
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
|
||||
const shortcuts = makeShortcuts({
|
||||
@@ -191,6 +248,7 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
|
||||
},
|
||||
{
|
||||
openRuntimeOptions: () => {},
|
||||
openCharacterDictionary: () => {},
|
||||
openJimaku: () => {},
|
||||
markAudioCard: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
@@ -226,6 +284,7 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut',
|
||||
},
|
||||
{
|
||||
openRuntimeOptions: () => {},
|
||||
openCharacterDictionary: () => {},
|
||||
openJimaku: () => {},
|
||||
markAudioCard: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
@@ -253,6 +312,9 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', ()
|
||||
openRuntimeOptions: () => {
|
||||
called = true;
|
||||
},
|
||||
openCharacterDictionary: () => {
|
||||
called = true;
|
||||
},
|
||||
openJimaku: () => {
|
||||
called = true;
|
||||
},
|
||||
@@ -335,6 +397,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
|
||||
mineSentenceMultiple: () => {},
|
||||
toggleSecondarySub: () => {},
|
||||
markAudioCard: () => {},
|
||||
openCharacterDictionary: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
}),
|
||||
@@ -361,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: () => {
|
||||
@@ -147,12 +159,6 @@ export function runOverlayShortcutLocalFallback(
|
||||
handlers.markAudioCard();
|
||||
},
|
||||
},
|
||||
{
|
||||
accelerator: shortcuts.copySubtitleMultiple,
|
||||
run: () => {
|
||||
handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs);
|
||||
},
|
||||
},
|
||||
{
|
||||
accelerator: shortcuts.copySubtitle,
|
||||
run: () => {
|
||||
@@ -188,12 +194,6 @@ export function runOverlayShortcutLocalFallback(
|
||||
handlers.mineSentence();
|
||||
},
|
||||
},
|
||||
{
|
||||
accelerator: shortcuts.mineSentenceMultiple,
|
||||
run: () => {
|
||||
handlers.mineSentenceMultiple(shortcuts.multiCopyTimeoutMs);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const action of actions) {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -6,12 +6,14 @@ import { CliArgs } from '../../cli/args';
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
managedPlayback: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
settings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
@@ -37,6 +39,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
openCharacterDictionary: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
@@ -48,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,
|
||||
|
||||
@@ -4069,6 +4069,225 @@ test('tokenizeSubtitle clears all annotations for explanatory contrast endings',
|
||||
);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle clears annotations for ことに while preserving lexical N+1 target', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'さっきの俺と違うことに気付かないのかい?',
|
||||
makeDepsFromYomitanTokens(
|
||||
[
|
||||
{ surface: 'さっき', reading: 'さっき', headword: 'さっき' },
|
||||
{ surface: 'の', reading: 'の', headword: 'の' },
|
||||
{ surface: '俺', reading: 'おれ', headword: '俺' },
|
||||
{ surface: 'と', reading: 'と', headword: 'と' },
|
||||
{ surface: '違う', reading: 'ちがう', headword: '違う' },
|
||||
{ surface: 'ことに', reading: 'ことに', headword: '事' },
|
||||
{ surface: '気付かない', reading: 'きづかない', headword: '気付く' },
|
||||
{ surface: 'の', reading: 'の', headword: 'の' },
|
||||
{ surface: 'かい', reading: 'かい', headword: 'かい' },
|
||||
{ surface: '?', reading: '', headword: '?' },
|
||||
],
|
||||
{
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getFrequencyRank: (text) =>
|
||||
text === '違う' ? 900 : text === '事' ? 81 : text === '気付く' ? 1500 : null,
|
||||
getJlptLevel: (text) =>
|
||||
text === '違う' ? 'N4' : text === '事' ? 'N4' : text === '気付く' ? 'N3' : null,
|
||||
isKnownWord: (text) => ['さっき', 'の', '俺', 'と', '気付く', 'かい', '?'].includes(text),
|
||||
getMinSentenceWordsForNPlusOne: () => 1,
|
||||
tokenizeWithMecab: async () => [
|
||||
{
|
||||
headword: 'さっき',
|
||||
surface: 'さっき',
|
||||
reading: 'サッキ',
|
||||
startPos: 0,
|
||||
endPos: 3,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞',
|
||||
pos2: '副詞可能',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'の',
|
||||
surface: 'の',
|
||||
reading: 'ノ',
|
||||
startPos: 3,
|
||||
endPos: 4,
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
pos2: '連体化',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: '俺',
|
||||
surface: '俺',
|
||||
reading: 'オレ',
|
||||
startPos: 4,
|
||||
endPos: 5,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞',
|
||||
pos2: '代名詞',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'と',
|
||||
surface: 'と',
|
||||
reading: 'ト',
|
||||
startPos: 5,
|
||||
endPos: 6,
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
pos2: '格助詞',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: '違う',
|
||||
surface: '違う',
|
||||
reading: 'チガウ',
|
||||
startPos: 6,
|
||||
endPos: 8,
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
pos1: '動詞',
|
||||
pos2: '自立',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: '事',
|
||||
surface: 'こと',
|
||||
reading: 'コト',
|
||||
startPos: 8,
|
||||
endPos: 10,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞',
|
||||
pos2: '非自立',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'に',
|
||||
surface: 'に',
|
||||
reading: 'ニ',
|
||||
startPos: 10,
|
||||
endPos: 11,
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
pos2: '格助詞',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: '気付く',
|
||||
surface: '気付か',
|
||||
reading: 'キヅカ',
|
||||
startPos: 11,
|
||||
endPos: 14,
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
pos1: '動詞',
|
||||
pos2: '自立',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'ない',
|
||||
surface: 'ない',
|
||||
reading: 'ナイ',
|
||||
startPos: 14,
|
||||
endPos: 16,
|
||||
partOfSpeech: PartOfSpeech.bound_auxiliary,
|
||||
pos1: '助動詞',
|
||||
pos2: '*',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'の',
|
||||
surface: 'の',
|
||||
reading: 'ノ',
|
||||
startPos: 16,
|
||||
endPos: 17,
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
pos2: '終助詞',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'かい',
|
||||
surface: 'かい',
|
||||
reading: 'カイ',
|
||||
startPos: 17,
|
||||
endPos: 19,
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
pos2: '終助詞',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: '?',
|
||||
surface: '?',
|
||||
reading: '',
|
||||
startPos: 19,
|
||||
endPos: 20,
|
||||
partOfSpeech: PartOfSpeech.symbol,
|
||||
pos1: '記号',
|
||||
pos2: '一般',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const tokenSummary = result.tokens?.map((token) => ({
|
||||
surface: token.surface,
|
||||
headword: token.headword,
|
||||
isKnown: token.isKnown,
|
||||
isNPlusOneTarget: token.isNPlusOneTarget,
|
||||
frequencyRank: token.frequencyRank,
|
||||
jlptLevel: token.jlptLevel,
|
||||
}));
|
||||
|
||||
assert.deepEqual(
|
||||
tokenSummary?.find((token) => token.surface === 'ことに'),
|
||||
{
|
||||
surface: 'ことに',
|
||||
headword: '事',
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
frequencyRank: undefined,
|
||||
jlptLevel: undefined,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
tokenSummary?.find((token) => token.surface === '違う'),
|
||||
{
|
||||
surface: '違う',
|
||||
headword: '違う',
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: true,
|
||||
frequencyRank: 900,
|
||||
jlptLevel: 'N4',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle excludes default non-independent pos2 from N+1 when JLPT/frequency are disabled', async () => {
|
||||
let mecabCalls = 0;
|
||||
const result = await tokenizeSubtitle(
|
||||
|
||||
@@ -353,6 +353,19 @@ test('shouldExcludeTokenFromSubtitleAnnotations excludes kana-only demonstrative
|
||||
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
|
||||
});
|
||||
|
||||
test('shouldExcludeTokenFromSubtitleAnnotations excludes kana-only non-independent noun helper merges', () => {
|
||||
const token = makeToken({
|
||||
surface: 'ことに',
|
||||
headword: '事',
|
||||
reading: 'コトニ',
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞|助詞',
|
||||
pos2: '非自立|格助詞',
|
||||
});
|
||||
|
||||
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
|
||||
});
|
||||
|
||||
test('stripSubtitleAnnotationMetadata keeps token hover data while clearing annotation fields', () => {
|
||||
const token = makeToken({
|
||||
surface: 'は',
|
||||
@@ -812,3 +825,69 @@ test('annotateTokens applies one shared exclusion gate across known N+1 frequenc
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears all annotations for kana-only non-independent noun helper merges', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'ことに',
|
||||
headword: '事',
|
||||
reading: 'コトニ',
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞|助詞',
|
||||
pos2: '非自立|格助詞',
|
||||
startPos: 0,
|
||||
endPos: 3,
|
||||
frequencyRank: 81,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === '事',
|
||||
getJlptLevel: (text) => (text === '事' ? 'N4' : null),
|
||||
}),
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears all annotations from standalone あ interjections without POS tags', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'あ',
|
||||
headword: 'あ',
|
||||
reading: 'あ',
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
pos1: '',
|
||||
pos2: '',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: true,
|
||||
frequencyRank: 522,
|
||||
jlptLevel: 'N5',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === 'あ',
|
||||
getJlptLevel: (text) => (text === 'あ' ? 'N5' : null),
|
||||
}),
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.surface, 'あ');
|
||||
assert.equal(result[0]?.headword, 'あ');
|
||||
assert.equal(result[0]?.reading, 'あ');
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ const KATAKANA_CODEPOINT_START = 0x30a1;
|
||||
const KATAKANA_CODEPOINT_END = 0x30f6;
|
||||
|
||||
const SUBTITLE_ANNOTATION_EXCLUDED_TERMS = new Set([
|
||||
'あ',
|
||||
'ああ',
|
||||
'ええ',
|
||||
'うう',
|
||||
@@ -70,6 +71,7 @@ const SUBTITLE_ANNOTATION_EXCLUDED_TRAILING_PARTICLE_SUFFIXES = new Set([
|
||||
'ってば',
|
||||
]);
|
||||
const AUXILIARY_STEM_GRAMMAR_TAIL_POS1 = new Set(['名詞', '助動詞', '助詞']);
|
||||
const NON_INDEPENDENT_NOUN_HELPER_TAIL_POS1 = new Set(['助詞', '助動詞']);
|
||||
|
||||
export interface SubtitleAnnotationFilterOptions {
|
||||
pos1Exclusions?: ReadonlySet<string>;
|
||||
@@ -251,6 +253,31 @@ function isAuxiliaryStemGrammarTailToken(token: MergedToken): boolean {
|
||||
return pos3Parts.includes('助動詞語幹');
|
||||
}
|
||||
|
||||
function isKanaOnlyNonIndependentNounHelperMerge(token: MergedToken): boolean {
|
||||
const normalizedSurface = normalizeKana(token.surface);
|
||||
const normalizedHeadword = normalizeKana(token.headword);
|
||||
if (
|
||||
!normalizedSurface ||
|
||||
!normalizedHeadword ||
|
||||
normalizedSurface === normalizedHeadword ||
|
||||
![...normalizedSurface].every(isKanaChar)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pos1Parts = splitNormalizedTagParts(normalizePosTag(token.pos1));
|
||||
if (pos1Parts.length < 2 || pos1Parts[0] !== '名詞') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pos2Parts = splitNormalizedTagParts(normalizePosTag(token.pos2));
|
||||
if (pos2Parts[0] !== '非自立') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pos1Parts.slice(1).every((part) => NON_INDEPENDENT_NOUN_HELPER_TAIL_POS1.has(part));
|
||||
}
|
||||
|
||||
function isExcludedByTerm(token: MergedToken): boolean {
|
||||
const candidates = [token.surface, token.reading, token.headword].filter(
|
||||
(candidate): candidate is string => typeof candidate === 'string' && candidate.length > 0,
|
||||
@@ -334,6 +361,10 @@ export function shouldExcludeTokenFromSubtitleAnnotations(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isKanaOnlyNonIndependentNounHelperMerge(token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isExcludedTrailingParticleMergedToken(token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user