feat(config): add configuration window (#70)

This commit is contained in:
2026-05-21 04:16:21 -07:00
committed by GitHub
parent a54f03f0cd
commit dc52bc2fba
287 changed files with 14507 additions and 8134 deletions
+141
View File
@@ -48,3 +48,144 @@ test('AnkiConnectClient includes action name in retry logs', async () => {
console.info = originalInfo;
}
});
test('AnkiConnectClient lists decks and note type fields', async () => {
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
};
const calls: Array<{ action: string; params: unknown }> = [];
client.client = {
post: async (_url, body) => {
calls.push({ action: body.action, params: body.params });
if (body.action === 'deckNames') {
return { data: { result: ['Core', 'Mining'], error: null } };
}
if (body.action === 'modelNames') {
return { data: { result: ['Japanese sentences'], error: null } };
}
if (body.action === 'modelFieldNames') {
return { data: { result: ['Expression', 'Sentence'], error: null } };
}
return { data: { result: [], error: null } };
},
};
const typedClient = client as unknown as AnkiConnectClient;
assert.deepEqual(await typedClient.deckNames(), ['Core', 'Mining']);
assert.deepEqual(await typedClient.modelNames(), ['Japanese sentences']);
assert.deepEqual(await typedClient.modelFieldNames('Japanese sentences'), [
'Expression',
'Sentence',
]);
assert.deepEqual(
calls.map((call) => call.action),
['deckNames', 'modelNames', 'modelFieldNames'],
);
});
test('AnkiConnectClient derives field names from sampled notes in a deck', async () => {
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
};
const calls: Array<{ action: string; params: unknown }> = [];
client.client = {
post: async (_url, body) => {
calls.push({ action: body.action, params: body.params });
if (body.action === 'findNotes') {
return { data: { result: [3, 1, 2], error: null } };
}
if (body.action === 'notesInfo') {
return {
data: {
result: [
{ fields: { Sentence: { value: 'x' }, Expression: { value: 'y' } } },
{ fields: { Reading: { value: 'z' } } },
],
error: null,
},
};
}
return { data: { result: [], error: null } };
},
};
assert.deepEqual(
await (client as unknown as AnkiConnectClient).fieldNamesForDeck('Mining "Current"'),
['Expression', 'Reading', 'Sentence'],
);
assert.deepEqual(calls[0], {
action: 'findNotes',
params: { query: 'deck:"Mining \\"Current\\""' },
});
assert.deepEqual(calls[1], {
action: 'notesInfo',
params: { notes: [3, 1, 2] },
});
});
test('AnkiConnectClient treats negative deck note sample sizes as empty samples', async () => {
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
};
const calls: Array<{ action: string; params: unknown }> = [];
client.client = {
post: async (_url, body) => {
calls.push({ action: body.action, params: body.params });
if (body.action === 'findNotes') {
return { data: { result: [3, 1, 2], error: null } };
}
if (body.action === 'notesInfo') {
return {
data: {
result: [{ fields: { Sentence: { value: 'x' } } }],
error: null,
},
};
}
return { data: { result: [], error: null } };
},
};
assert.deepEqual(
await (client as unknown as AnkiConnectClient).fieldNamesForDeck('Mining', -1),
[],
);
assert.deepEqual(
calls.map((call) => call.action),
['findNotes'],
);
});
test('AnkiConnectClient derives model names from sampled notes in a deck', async () => {
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
};
const calls: Array<{ action: string; params: unknown }> = [];
client.client = {
post: async (_url, body) => {
calls.push({ action: body.action, params: body.params });
if (body.action === 'findNotes') {
return { data: { result: [5, 4], error: null } };
}
if (body.action === 'notesInfo') {
return {
data: {
result: [{ modelName: 'Lapis Morph' }, { modelName: 'Kiku' }],
error: null,
},
};
}
return { data: { result: [], error: null } };
},
};
assert.deepEqual(await (client as unknown as AnkiConnectClient).modelNamesForDeck('Mining'), [
'Kiku',
'Lapis Morph',
]);
assert.deepEqual(
calls.map((call) => call.action),
['findNotes', 'notesInfo'],
);
});
+70
View File
@@ -156,6 +156,76 @@ export class AnkiConnectClient {
return (result as number[]) || [];
}
async deckNames(): Promise<string[]> {
const result = await this.invoke('deckNames');
return Array.isArray(result)
? result.filter((value): value is string => typeof value === 'string').sort()
: [];
}
async modelNames(): Promise<string[]> {
const result = await this.invoke('modelNames');
return Array.isArray(result)
? result.filter((value): value is string => typeof value === 'string').sort()
: [];
}
async modelFieldNames(modelName: string): Promise<string[]> {
const result = await this.invoke('modelFieldNames', { modelName });
return Array.isArray(result)
? result.filter((value): value is string => typeof value === 'string').sort()
: [];
}
private async noteInfosForDeck(
deckName: string,
sampleSize = 100,
): Promise<Record<string, unknown>[]> {
const escapedDeckName = deckName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const noteIds = await this.findNotes(`deck:"${escapedDeckName}"`, { maxRetries: 0 });
if (noteIds.length === 0) {
return [];
}
const finiteSampleSize = Number.isFinite(sampleSize) ? sampleSize : 0;
const normalizedSampleSize = Math.min(
noteIds.length,
Math.max(0, Math.floor(finiteSampleSize)),
);
if (normalizedSampleSize === 0) {
return [];
}
return this.notesInfo(noteIds.slice(0, normalizedSampleSize));
}
async fieldNamesForDeck(deckName: string, sampleSize = 100): Promise<string[]> {
const noteInfos = await this.noteInfosForDeck(deckName, sampleSize);
const fields = new Set<string>();
for (const noteInfo of noteInfos) {
const noteFields = noteInfo.fields;
if (!noteFields || typeof noteFields !== 'object' || Array.isArray(noteFields)) {
continue;
}
for (const fieldName of Object.keys(noteFields)) {
fields.add(fieldName);
}
}
return [...fields].sort();
}
async modelNamesForDeck(deckName: string, sampleSize = 100): Promise<string[]> {
const noteInfos = await this.noteInfosForDeck(deckName, sampleSize);
const modelNames = new Set<string>();
for (const noteInfo of noteInfos) {
const modelName = noteInfo.modelName;
if (typeof modelName === 'string' && modelName.length > 0) {
modelNames.add(modelName);
}
}
return [...modelNames].sort();
}
async notesInfo(noteIds: number[]): Promise<Record<string, unknown>[]> {
const result = await this.invoke('notesInfo', { notes: noteIds });
return (result as Record<string, unknown>[]) || [];
+2 -1
View File
@@ -277,7 +277,8 @@ export class KnownWordCacheManager {
}
private isKnownWordCacheEnabled(): boolean {
return this.deps.getConfig().knownWords?.highlightEnabled === true;
const config = this.deps.getConfig();
return config.knownWords?.highlightEnabled === true || config.nPlusOne?.enabled === true;
}
private shouldAddMinedWordsImmediately(): boolean {
+4 -2
View File
@@ -157,7 +157,8 @@ export class AnkiIntegrationRuntime {
}
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
const wasKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
const wasKnownWordCacheEnabled =
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true;
const previousKnownWordCacheConfig = wasKnownWordCacheEnabled
? this.getKnownWordCacheLifecycleConfig(this.config)
: null;
@@ -207,7 +208,8 @@ export class AnkiIntegrationRuntime {
};
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
this.deps.onConfigChanged?.(this.config);
const nextKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
const nextKnownWordCacheEnabled =
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true;
if (wasKnownWordCacheEnabled && !nextKnownWordCacheEnabled) {
if (this.started) {
+31 -20
View File
@@ -7,7 +7,7 @@ import {
isHeadlessInitialCommand,
isStandaloneTexthookerCommand,
parseArgs,
shouldRunSettingsOnlyStartup,
shouldRunYomitanOnlyStartup,
shouldStartApp,
} from './args';
@@ -66,7 +66,7 @@ test('parseArgs captures update command and internal launcher paths', () => {
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
assert.equal(commandNeedsOverlayRuntime(args), false);
assert.equal(shouldRunSettingsOnlyStartup(args), false);
assert.equal(shouldRunYomitanOnlyStartup(args), false);
});
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
@@ -94,6 +94,7 @@ test('parseArgs captures youtube startup forwarding flags', () => {
test('parseArgs captures session action forwarding flags', () => {
const args = parseArgs([
'--toggle-stats-overlay',
'--mark-watched',
'--open-jimaku',
'--open-youtube-picker',
'--open-playlist-browser',
@@ -110,6 +111,7 @@ test('parseArgs captures session action forwarding flags', () => {
]);
assert.equal(args.toggleStatsOverlay, true);
assert.equal(args.markWatched, true);
assert.equal(args.openJimaku, true);
assert.equal(args.openYoutubePicker, true);
assert.equal(args.openPlaylistBrowser, true);
@@ -206,35 +208,38 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(shouldStartApp(update), true);
assert.equal(isHeadlessInitialCommand(update), true);
const yomitan = parseArgs(['--yomitan']);
assert.equal(yomitan.yomitan, true);
assert.equal(hasExplicitCommand(yomitan), true);
assert.equal(shouldStartApp(yomitan), true);
assert.equal(shouldRunYomitanOnlyStartup(yomitan), true);
const settings = parseArgs(['--settings']);
assert.equal(settings.settings, true);
assert.equal(hasExplicitCommand(settings), true);
assert.equal(shouldStartApp(settings), true);
assert.equal(shouldRunSettingsOnlyStartup(settings), true);
assert.equal(shouldRunYomitanOnlyStartup(settings), false);
assert.equal(commandNeedsOverlayRuntime(settings), false);
assert.equal(commandNeedsOverlayStartupPrereqs(settings), false);
const configSettings = parseArgs(['--config']);
assert.equal(configSettings.configSettings, true);
assert.equal(hasExplicitCommand(configSettings), true);
assert.equal(shouldStartApp(configSettings), true);
assert.equal(shouldRunSettingsOnlyStartup(configSettings), false);
assert.equal(commandNeedsOverlayRuntime(configSettings), false);
assert.equal(commandNeedsOverlayStartupPrereqs(configSettings), false);
const yomitanWithOverlay = parseArgs(['--yomitan', '--toggle-visible-overlay']);
assert.equal(yomitanWithOverlay.yomitan, true);
assert.equal(yomitanWithOverlay.toggleVisibleOverlay, true);
assert.equal(shouldRunYomitanOnlyStartup(yomitanWithOverlay), false);
const settingsWithOverlay = parseArgs(['--settings', '--toggle-visible-overlay']);
assert.equal(settingsWithOverlay.settings, true);
assert.equal(settingsWithOverlay.toggleVisibleOverlay, true);
assert.equal(shouldRunSettingsOnlyStartup(settingsWithOverlay), false);
const yomitanAlias = parseArgs(['--yomitan']);
assert.equal(yomitanAlias.settings, true);
assert.equal(hasExplicitCommand(yomitanAlias), true);
assert.equal(shouldStartApp(yomitanAlias), true);
const settingsDoesNotEnableYomitan = parseArgs(['--settings']);
assert.equal(settingsDoesNotEnableYomitan.yomitan, false);
const help = parseArgs(['--help']);
assert.equal(help.help, true);
assert.equal(hasExplicitCommand(help), true);
assert.equal(shouldStartApp(help), false);
assert.equal(shouldRunSettingsOnlyStartup(help), false);
assert.equal(shouldRunYomitanOnlyStartup(help), false);
const appPing = parseArgs(['--app-ping']);
assert.equal(appPing.appPing, true);
assert.equal(hasExplicitCommand(appPing), true);
assert.equal(shouldStartApp(appPing), false);
const youtubePlay = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
assert.equal(commandNeedsOverlayStartupPrereqs(youtubePlay), true);
@@ -280,6 +285,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
const markWatched = parseArgs(['--mark-watched']);
assert.equal(markWatched.markWatched, true);
assert.equal(hasExplicitCommand(markWatched), true);
assert.equal(shouldStartApp(markWatched), true);
assert.equal(commandNeedsOverlayRuntime(markWatched), true);
const dictionary = parseArgs(['--dictionary']);
assert.equal(dictionary.dictionary, true);
assert.equal(hasExplicitCommand(dictionary), true);
+24 -10
View File
@@ -10,8 +10,8 @@ export interface CliArgs {
toggle: boolean;
toggleVisibleOverlay: boolean;
togglePrimarySubtitleBar: boolean;
yomitan: boolean;
settings: boolean;
configSettings: boolean;
setup: boolean;
show: boolean;
hide: boolean;
@@ -28,6 +28,7 @@ export interface CliArgs {
triggerSubsync: boolean;
markAudioCard: boolean;
toggleStatsOverlay: boolean;
markWatched: boolean;
toggleSubtitleSidebar: boolean;
openRuntimeOptions: boolean;
openSessionHelp: boolean;
@@ -74,6 +75,7 @@ export interface CliArgs {
texthooker: boolean;
texthookerOpenBrowser: boolean;
help: boolean;
appPing?: boolean;
update?: boolean;
updateLauncherPath?: string;
updateResponsePath?: string;
@@ -115,8 +117,8 @@ export function parseArgs(argv: string[]): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -133,6 +135,7 @@ export function parseArgs(argv: string[]): CliArgs {
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
markWatched: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
@@ -172,6 +175,7 @@ export function parseArgs(argv: string[]): CliArgs {
texthooker: false,
texthookerOpenBrowser: false,
help: false,
appPing: false,
update: false,
updateLauncherPath: undefined,
updateResponsePath: undefined,
@@ -235,8 +239,8 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--toggle') args.toggle = true;
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
else if (arg === '--config') args.configSettings = true;
else if (arg === '--yomitan') args.yomitan = true;
else if (arg === '--settings') args.settings = true;
else if (arg === '--setup') args.setup = true;
else if (arg === '--show') args.show = true;
else if (arg === '--hide') args.hide = true;
@@ -253,6 +257,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--trigger-subsync') args.triggerSubsync = true;
else if (arg === '--mark-audio-card') args.markAudioCard = true;
else if (arg === '--toggle-stats-overlay') args.toggleStatsOverlay = true;
else if (arg === '--mark-watched') args.markWatched = true;
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;
@@ -339,6 +344,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true;
else if (arg === '--texthooker') args.texthooker = true;
else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
else if (arg === '--app-ping') args.appPing = true;
else if (arg === '--update') args.update = true;
else if (arg.startsWith('--update-launcher-path=')) {
const value = arg.split('=', 2)[1];
@@ -488,8 +494,8 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.toggle ||
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.settings ||
args.configSettings ||
args.setup ||
args.show ||
args.hide ||
@@ -506,6 +512,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.triggerSubsync ||
args.markAudioCard ||
args.toggleStatsOverlay ||
args.markWatched ||
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
@@ -540,6 +547,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.jellyfinRemoteAnnounce ||
args.jellyfinPreviewAuth ||
args.texthooker ||
args.appPing ||
args.update ||
args.generateConfig ||
args.help
@@ -561,8 +569,8 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.yomitan &&
!args.settings &&
!args.configSettings &&
!args.setup &&
!args.show &&
!args.hide &&
@@ -579,6 +587,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.triggerSubsync &&
!args.markAudioCard &&
!args.toggleStatsOverlay &&
!args.markWatched &&
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions &&
!args.openSessionHelp &&
@@ -612,6 +621,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.jellyfinPlay &&
!args.jellyfinRemoteAnnounce &&
!args.jellyfinPreviewAuth &&
!args.appPing &&
!args.update &&
!args.help &&
!args.autoStartOverlay &&
@@ -629,8 +639,8 @@ export function shouldStartApp(args: CliArgs): boolean {
args.toggle ||
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.settings ||
args.configSettings ||
args.setup ||
args.copySubtitle ||
args.copySubtitleMultiple ||
@@ -643,6 +653,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.triggerSubsync ||
args.markAudioCard ||
args.toggleStatsOverlay ||
args.markWatched ||
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
@@ -676,16 +687,16 @@ export function shouldStartApp(args: CliArgs): boolean {
return false;
}
export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
return (
args.settings &&
args.yomitan &&
!args.background &&
!args.start &&
!args.stop &&
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.configSettings &&
!args.settings &&
!args.show &&
!args.hide &&
!args.setup &&
@@ -702,6 +713,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.triggerSubsync &&
!args.markAudioCard &&
!args.toggleStatsOverlay &&
!args.markWatched &&
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions &&
!args.openSessionHelp &&
@@ -737,6 +749,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.jellyfinRemoteAnnounce &&
!args.jellyfinPreviewAuth &&
!args.texthooker &&
!args.appPing &&
!args.update &&
!args.help &&
!args.autoStartOverlay &&
@@ -762,6 +775,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.updateLastCardFromClipboard ||
args.toggleSecondarySub ||
args.toggleStatsOverlay ||
args.markWatched ||
args.toggleSubtitleSidebar ||
args.triggerFieldGrouping ||
args.triggerSubsync ||
+3 -1
View File
@@ -22,7 +22,9 @@ test('printHelp includes configured texthooker port', () => {
assert.match(output, /--open-browser\s+Open texthooker in your default browser/);
assert.doesNotMatch(output, /--refresh-known-words/);
assert.match(output, /--setup\s+Open first-run setup window/);
assert.match(output, /--config\s+Open configuration window/);
assert.match(output, /--settings\s+Open SubMiner settings window/);
assert.match(output, /--yomitan\s+Open Yomitan settings window/);
assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
assert.match(output, /--anilist-status/);
assert.match(output, /--anilist-retry-queue/);
assert.match(output, /--dictionary/);
+3 -2
View File
@@ -24,8 +24,8 @@ ${B}Overlay${R}
--toggle-primary-subtitle-bar Toggle primary subtitle bar
--show-visible-overlay Show subtitle overlay
--hide-visible-overlay Hide subtitle overlay
--settings Open Yomitan settings window
--config Open configuration window
--yomitan Open Yomitan settings window
--settings Open SubMiner settings window
--setup Open first-run setup window
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
@@ -39,6 +39,7 @@ ${B}Mining${R}
--trigger-field-grouping Run Kiku field grouping
--trigger-subsync Run subtitle sync
--toggle-secondary-sub Cycle secondary subtitle mode
--mark-watched Mark current video watched and advance playlist
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
--open-runtime-options Open runtime options palette
--open-session-help Open session help modal
@@ -0,0 +1,218 @@
import {
getNodeValue,
parseTree as parseJsoncTree,
type Node as JsoncNode,
type ParseError,
} from 'jsonc-parser';
import type { RawConfig } from '../types/config';
import type { ConfigSettingsPatchOperation } from '../types/settings';
import { DEFAULT_CONFIG } from './definitions';
import { applyConfigSettingsPatchToContent } from './settings/jsonc-edit';
export type LegacyAnkiConnectNPlusOneMigrationResult =
| {
migrated: true;
content: string;
rawConfig: RawConfig;
}
| {
migrated: false;
content: string;
rawConfig: RawConfig;
};
const LEGACY_N_PLUS_ONE_PATH_MAP = {
highlightEnabled: 'ankiConnect.knownWords.highlightEnabled',
refreshMinutes: 'ankiConnect.knownWords.refreshMinutes',
matchMode: 'ankiConnect.knownWords.matchMode',
decks: 'ankiConnect.knownWords.decks',
knownWord: 'subtitleStyle.knownWordColor',
nPlusOne: 'subtitleStyle.nPlusOneColor',
} as const;
const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
function propertyKey(propertyNode: JsoncNode): string | undefined {
return propertyNode.children?.[0]?.value;
}
function propertyValue(propertyNode: JsoncNode | undefined): JsoncNode | undefined {
return propertyNode?.children?.[1];
}
function objectProperties(node: JsoncNode | undefined): JsoncNode[] {
return node?.type === 'object' ? (node.children ?? []) : [];
}
function findLastProperty(node: JsoncNode | undefined, key: string): JsoncNode | undefined {
const matches = objectProperties(node).filter((property) => propertyKey(property) === key);
return matches.at(-1);
}
function findProperties(node: JsoncNode | undefined, key: string): JsoncNode[] {
return objectProperties(node).filter((property) => propertyKey(property) === key);
}
function findValueAtPath(root: JsoncNode | undefined, path: string): JsoncNode | undefined {
let node = root;
for (const segment of path.split('.')) {
node = propertyValue(findLastProperty(node, segment));
if (!node) return undefined;
}
return node;
}
function hasPath(root: JsoncNode | undefined, path: string): boolean {
return findValueAtPath(root, path) !== undefined;
}
function normalizeLegacyDecks(value: unknown): unknown {
if (!Array.isArray(value)) {
return value;
}
const defaultFields = [DEFAULT_CONFIG.ankiConnect.fields.word, 'Word', 'Reading', 'Word Reading'];
const decks = value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter(Boolean);
const normalized: Record<string, string[]> = {};
for (const deck of new Set(decks)) {
normalized[deck] = defaultFields;
}
return normalized;
}
function asLegacyColor(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const text = value.trim();
return hexColorPattern.test(text) ? text : undefined;
}
function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
operations: ConfigSettingsPatchOperation[];
hasLegacy: boolean;
} {
const operations: ConfigSettingsPatchOperation[] = [];
const ankiConnect = propertyValue(findLastProperty(root, 'ankiConnect'));
const nPlusOneProperties = findProperties(ankiConnect, 'nPlusOne');
const nPlusOneObjects = nPlusOneProperties.map(propertyValue).filter(Boolean) as JsoncNode[];
const knownWords = propertyValue(findLastProperty(ankiConnect, 'knownWords'));
const knownWordsColorNode = propertyValue(findLastProperty(knownWords, 'color'));
const knownWordsColor = knownWordsColorNode ? getNodeValue(knownWordsColorNode) : undefined;
const canonicalNPlusOneValues = new Map<string, unknown>();
const legacyValues = new Map<keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, unknown>();
let hasLegacy = false;
for (const nPlusOne of nPlusOneObjects) {
for (const property of objectProperties(nPlusOne)) {
const key = propertyKey(property);
if (!key) continue;
const valueNode = propertyValue(property);
const value = valueNode ? getNodeValue(valueNode) : undefined;
if (key === 'enabled' || key === 'minSentenceWords') {
canonicalNPlusOneValues.set(key, value);
continue;
}
if (key in LEGACY_N_PLUS_ONE_PATH_MAP) {
hasLegacy = true;
legacyValues.set(key as keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, value);
}
}
}
if (nPlusOneObjects.length > 1) {
for (const [key, value] of canonicalNPlusOneValues) {
operations.push({
op: 'set',
path: `ankiConnect.nPlusOne.${key}`,
value,
});
}
}
for (const [key, path] of Object.entries(LEGACY_N_PLUS_ONE_PATH_MAP) as Array<
[keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, string]
>) {
if (!legacyValues.has(key)) continue;
if (!hasPath(root, path)) {
const value =
key === 'decks' ? normalizeLegacyDecks(legacyValues.get(key)) : legacyValues.get(key);
operations.push({
op: 'set',
path,
value,
});
}
operations.push({
op: 'reset',
path: `ankiConnect.nPlusOne.${key}`,
});
}
const legacyKnownWordsColor = asLegacyColor(knownWordsColor);
if (legacyKnownWordsColor !== undefined) {
hasLegacy = true;
if (!hasPath(root, 'subtitleStyle.knownWordColor')) {
operations.push({
op: 'set',
path: 'subtitleStyle.knownWordColor',
value: legacyKnownWordsColor,
});
}
operations.push({
op: 'reset',
path: 'ankiConnect.knownWords.color',
});
}
return { operations, hasLegacy };
}
export function applyLegacyAnkiConnectNPlusOneMigrationToContent(options: {
content: string;
rawConfig: RawConfig;
}): LegacyAnkiConnectNPlusOneMigrationResult {
const errors: ParseError[] = [];
const root = parseJsoncTree(options.content || '{}', errors, {
allowTrailingComma: true,
disallowComments: false,
});
if (!root || errors.length > 0) {
return {
migrated: false,
content: options.content,
rawConfig: options.rawConfig,
};
}
const { operations, hasLegacy } = buildLegacyNPlusOneMigrationOperations(root);
if (operations.length === 0 && !hasLegacy) {
return {
migrated: false,
content: options.content,
rawConfig: options.rawConfig,
};
}
const result = applyConfigSettingsPatchToContent({
content: options.content,
operations,
previousWarnings: [],
});
if (!result.ok) {
return {
migrated: false,
content: options.content,
rawConfig: options.rawConfig,
};
}
return {
migrated: true,
content: result.content,
rawConfig: result.rawConfig,
};
}
+250 -36
View File
@@ -12,16 +12,44 @@ import {
} from './definitions';
import { parseConfigContent } from './parse';
import { generateConfigTemplate } from './template';
import {
buildSubtitleCssDeclarationObject,
getSubtitleCssManagedConfigPaths,
getSubtitleCssPath,
type SubtitleCssScope,
} from '../settings/subtitle-style-css';
const DEFAULT_SUBTITLE_FONT_FAMILY =
'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP';
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = 'Inter, Noto Sans, Helvetica Neue, sans-serif';
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = DEFAULT_SUBTITLE_FONT_FAMILY;
const DEFAULT_SUBTITLE_TEXT_SHADOW = '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)';
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-config-test-'));
}
function getValueAtPath(root: unknown, path: string): unknown {
let current = root;
for (const segment of path.split('.')) {
if (current === null || typeof current !== 'object' || Array.isArray(current)) {
return undefined;
}
current = (current as Record<string, unknown>)[segment];
}
return current;
}
function buildDefaultSubtitleCssDeclarations(scope: SubtitleCssScope): Record<string, string> {
const values: Record<string, unknown> = {
[getSubtitleCssPath(scope)]: getValueAtPath(DEFAULT_CONFIG, getSubtitleCssPath(scope)),
};
for (const path of getSubtitleCssManagedConfigPaths(scope)) {
values[path] = getValueAtPath(DEFAULT_CONFIG, path);
}
return buildSubtitleCssDeclarationObject(scope, values);
}
test('loads defaults when config is missing', () => {
const dir = makeTempDir();
const service = new ConfigService(dir);
@@ -61,7 +89,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.startupWarmups.mecab, true);
assert.equal(config.startupWarmups.yomitanExtension, true);
assert.equal(config.startupWarmups.subtitleDictionaries, true);
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
assert.equal(config.shortcuts.openCharacterDictionary, 'CommandOrControl+Alt+A');
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
@@ -73,8 +101,9 @@ test('loads defaults when config is missing', () => {
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
assert.equal(config.subtitleSidebar.enabled, true);
assert.equal(config.subtitleSidebar.pauseVideoOnHover, true);
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
assert.equal(config.subtitleStyle.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
assert.equal(config.subtitleStyle.fontWeight, '600');
assert.equal(config.subtitleStyle.lineHeight, 1.35);
@@ -83,13 +112,19 @@ test('loads defaults when config is missing', () => {
assert.equal(config.subtitleStyle.fontKerning, 'normal');
assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
assert.equal(config.subtitleStyle.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
assert.equal(config.subtitleStyle.paintOrder, '');
assert.equal(config.subtitleStyle.WebkitTextStroke, '');
assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)');
assert.equal(config.subtitleStyle.jlptColors.N4, '#8bd5ca');
assert.equal(config.subtitleStyle.secondary.fontFamily, DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY);
assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5');
assert.equal(config.subtitleStyle.secondary.fontWeight, '600');
assert.equal(config.subtitleStyle.secondary.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
assert.equal(config.subtitleStyle.secondary.paintOrder, '');
assert.equal(config.subtitleStyle.secondary.WebkitTextStroke, '');
assert.equal(config.subtitleStyle.secondary.backgroundColor, 'transparent');
assert.deepEqual(config.subtitleSidebar.css, {});
assert.equal(config.subtitleSidebar.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
assert.equal(config.immersionTracking.enabled, true);
assert.equal(config.immersionTracking.dbPath, '');
assert.equal(config.immersionTracking.batchSize, 25);
@@ -113,6 +148,13 @@ test('loads defaults when config is missing', () => {
assert.equal(config.updates.checkIntervalHours, 24);
assert.equal(config.updates.notificationType, 'system');
assert.equal(config.updates.channel, 'stable');
assert.equal(config.mpv.socketPath, '/tmp/subminer-socket');
assert.equal(config.mpv.backend, 'auto');
assert.equal(config.mpv.autoStartSubMiner, true);
assert.equal(config.mpv.pauseUntilOverlayReady, true);
assert.equal(config.mpv.subminerBinaryPath, '');
assert.equal(config.mpv.aniskipEnabled, true);
assert.equal(config.mpv.aniskipButtonKey, 'TAB');
});
test('parses updates config and warns on invalid values', () => {
@@ -181,6 +223,58 @@ test('throws actionable startup parse error for malformed config at construction
);
});
test('resolves legacy subtitle appearance options without rewriting config on load', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
const originalContent = `{
"subtitleStyle": {
"fontSize": 42,
"fontColor": "#ffffff",
"hoverTokenColor": "#abcdef",
"hoverTokenBackgroundColor": "transparent",
"css": {
"font-size": "44px",
"text-wrap": "balance"
},
"secondary": {
"fontSize": 28,
"fontColor": "#bbbbbb"
}
},
"subtitleSidebar": {
"fontFamily": "M PLUS 1, sans-serif",
"fontSize": 18,
"textColor": "#dddddd",
"timestampColor": "#aaaaaa",
"css": {
"font-size": "19px"
}
}
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
const service = new ConfigService(dir);
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
assert.deepEqual(service.getConfig().subtitleStyle.css, {
color: '#ffffff',
'font-size': '44px',
'--subtitle-hover-token-color': '#abcdef',
'--subtitle-hover-token-background-color': 'transparent',
'text-wrap': 'balance',
});
assert.deepEqual(service.getConfig().subtitleStyle.secondary.css, {
color: '#bbbbbb',
'font-size': '28px',
});
assert.deepEqual(service.getConfig().subtitleSidebar.css, {
'font-family': 'M PLUS 1, sans-serif',
color: '#dddddd',
'font-size': '19px',
'--subtitle-sidebar-timestamp-color': '#aaaaaa',
});
});
test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
@@ -255,6 +349,70 @@ test('parses texthooker.launchAtStartup and warns on invalid values', () => {
);
});
test('parses managed mpv plugin runtime settings from config', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"mpv": {
"socketPath": "/tmp/custom-subminer.sock",
"backend": "x11",
"autoStartSubMiner": false,
"pauseUntilOverlayReady": false,
"subminerBinaryPath": "/opt/SubMiner/SubMiner.AppImage",
"aniskipEnabled": false,
"aniskipButtonKey": "F8"
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
const config = validService.getConfig();
assert.equal(config.mpv.socketPath, '/tmp/custom-subminer.sock');
assert.equal(config.mpv.backend, 'x11');
assert.equal(config.mpv.autoStartSubMiner, false);
assert.equal(config.mpv.pauseUntilOverlayReady, false);
assert.equal(config.mpv.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
assert.equal(config.mpv.aniskipEnabled, false);
assert.equal(config.mpv.aniskipButtonKey, 'F8');
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"mpv": {
"socketPath": "",
"backend": "weston",
"autoStartSubMiner": "yes",
"pauseUntilOverlayReady": "no",
"subminerBinaryPath": 42,
"aniskipEnabled": "disabled",
"aniskipButtonKey": ""
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
const invalidConfig = invalidService.getConfig();
const warnings = invalidService.getWarnings();
assert.equal(invalidConfig.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
assert.equal(invalidConfig.mpv.backend, DEFAULT_CONFIG.mpv.backend);
assert.equal(invalidConfig.mpv.autoStartSubMiner, DEFAULT_CONFIG.mpv.autoStartSubMiner);
assert.equal(invalidConfig.mpv.pauseUntilOverlayReady, DEFAULT_CONFIG.mpv.pauseUntilOverlayReady);
assert.equal(invalidConfig.mpv.subminerBinaryPath, DEFAULT_CONFIG.mpv.subminerBinaryPath);
assert.equal(invalidConfig.mpv.aniskipEnabled, DEFAULT_CONFIG.mpv.aniskipEnabled);
assert.equal(invalidConfig.mpv.aniskipButtonKey, DEFAULT_CONFIG.mpv.aniskipButtonKey);
assert.ok(warnings.some((warning) => warning.path === 'mpv.socketPath'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.backend'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.autoStartSubMiner'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.pauseUntilOverlayReady'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.subminerBinaryPath'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.aniskipEnabled'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.aniskipButtonKey'));
});
test('parses annotationWebsocket settings and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
@@ -1685,6 +1843,7 @@ test('runtime options registry is centralized', () => {
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
assert.deepEqual(ids, [
'anki.autoUpdateNewCards',
'subtitle.annotation.knownWords.highlightEnabled',
'subtitle.annotation.nPlusOne',
'subtitle.annotation.jlpt',
'subtitle.annotation.frequency',
@@ -1846,7 +2005,7 @@ test('accepts valid ankiConnect knownWords match mode values', () => {
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
});
test('validates ankiConnect knownWords and n+1 color values', () => {
test('ignores invalid legacy ankiConnect n+1 color value after migration attempt', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
@@ -1867,17 +2026,16 @@ test('validates ankiConnect knownWords and n+1 color values', () => {
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne);
assert.equal(config.ankiConnect.knownWords.color, DEFAULT_CONFIG.ankiConnect.knownWords.color);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne'));
assert.equal(config.subtitleStyle.nPlusOneColor, DEFAULT_CONFIG.subtitleStyle.nPlusOneColor);
assert.equal(config.subtitleStyle.knownWordColor, DEFAULT_CONFIG.subtitleStyle.knownWordColor);
assert.ok(warnings.every((warning) => warning.path !== 'ankiConnect.nPlusOne.nPlusOne'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
});
test('accepts valid ankiConnect knownWords and n+1 color values', () => {
test('resolves legacy ankiConnect n+1 color value without rewriting config', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
const configPath = path.join(dir, 'config.jsonc');
const originalContent = `{
"ankiConnect": {
"nPlusOne": {
"nPlusOne": "#c6a0f6"
@@ -1886,22 +2044,31 @@ test('accepts valid ankiConnect knownWords and n+1 color values', () => {
"color": "#a6da95"
}
}
}`,
'utf-8',
);
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, '#c6a0f6');
assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
assert.equal(config.subtitleStyle.nPlusOneColor, '#c6a0f6');
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
});
test('supports legacy ankiConnect nPlusOne known-word settings as fallback', () => {
test('legacy migration failures are logged and rethrown', () => {
const source = fs.readFileSync(path.join(process.cwd(), 'src/config/service.ts'), 'utf-8');
const catchBlock = source.match(/catch\s*\(error\)\s*\{(?<body>[\s\S]*?)\n \}/)?.groups?.body;
assert.ok(catchBlock);
assert.match(catchBlock, /legacy config migration failed/);
assert.match(catchBlock, /console\.error/);
assert.match(catchBlock, /throw error;/);
});
test('resolves legacy ankiConnect nPlusOne known-word settings without rewriting config', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
const configPath = path.join(dir, 'config.jsonc');
const originalContent = `{
"ankiConnect": {
"nPlusOne": {
"highlightEnabled": true,
@@ -1911,32 +2078,50 @@ test('supports legacy ankiConnect nPlusOne known-word settings as fallback', ()
"knownWord": "#a6da95"
}
}
}`,
'utf-8',
);
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
assert.deepEqual(config.ankiConnect.knownWords.decks, {
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
});
assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
assert.ok(
warnings.some(
(warning) =>
warning.path === 'ankiConnect.nPlusOne.highlightEnabled' ||
warning.path === 'ankiConnect.nPlusOne.refreshMinutes' ||
warning.path === 'ankiConnect.nPlusOne.matchMode' ||
warning.path === 'ankiConnect.nPlusOne.decks' ||
warning.path === 'ankiConnect.nPlusOne.knownWord',
),
);
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
assert.ok(warnings.every((warning) => !warning.path.startsWith('ankiConnect.nPlusOne.')));
});
test('resolves duplicate ankiConnect nPlusOne objects without rewriting config', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
const originalContent = `{
"ankiConnect": {
"nPlusOne": {
"enabled": true,
"minSentenceWords": 3
},
"knownWords": {
"highlightEnabled": true
},
"nPlusOne": {
"minSentenceWords": "3"
}
}
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
});
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
@@ -1960,6 +2145,7 @@ test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
assert.ok(
@@ -2280,9 +2466,9 @@ test('template generator includes known keys', () => {
assert.match(output, /"characterDictionary":\s*\{/);
assert.match(output, /"preserveLineBreaks": false/);
assert.match(output, /"knownWords"\s*:\s*\{/);
assert.match(output, /"color": "#a6da95"/);
assert.match(output, /"knownWordColor": "#a6da95"/);
assert.match(output, /"nPlusOneColor": "#c6a0f6"/);
assert.match(output, /"nPlusOne"\s*:\s*\{/);
assert.match(output, /"nPlusOne": "#c6a0f6"/);
assert.match(output, /"minSentenceWords": 3/);
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
assert.match(
@@ -2385,6 +2571,34 @@ test('template generator includes known keys', () => {
);
});
test('template generator uses settings CSS declaration paths for appearance fields', () => {
const output = generateConfigTemplate(DEFAULT_CONFIG);
const parsed = parseConfigContent('config.example.jsonc', output);
assert.deepEqual(
getValueAtPath(parsed, 'subtitleStyle.css'),
buildDefaultSubtitleCssDeclarations('primary'),
);
assert.deepEqual(
getValueAtPath(parsed, 'subtitleStyle.secondary.css'),
buildDefaultSubtitleCssDeclarations('secondary'),
);
assert.deepEqual(
getValueAtPath(parsed, 'subtitleSidebar.css'),
buildDefaultSubtitleCssDeclarations('sidebar'),
);
for (const scope of SUBTITLE_CSS_SCOPES) {
for (const path of getSubtitleCssManagedConfigPaths(scope)) {
assert.equal(
getValueAtPath(parsed, path),
undefined,
`${path} should be represented by ${getSubtitleCssPath(scope)} in the generated template`,
);
}
}
});
test('template generator shows built-in default keybindings in the keybindings array', () => {
const output = generateConfigTemplate(DEFAULT_CONFIG);
const parsed = parseConfigContent('config.example.jsonc', output) as {
+2 -3
View File
@@ -105,7 +105,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
primarySubLanguages: ['ja', 'jpn'],
},
subsync: {
defaultMode: 'auto',
alass_path: '',
ffsubsync_path: '',
ffmpeg_path: '',
@@ -116,7 +115,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
mecab: true,
yomitanExtension: true,
subtitleDictionaries: true,
jellyfinRemoteSession: true,
jellyfinRemoteSession: false,
},
updates: {
enabled: true,
@@ -124,5 +123,5 @@ export const CORE_DEFAULT_CONFIG: Pick<
notificationType: 'system',
channel: 'stable',
},
auto_start_overlay: false,
auto_start_overlay: true,
};
@@ -1,4 +1,5 @@
import { ResolvedConfig } from '../../types/config';
import { getDefaultMpvSocketPath } from '../../shared/mpv-socket-path';
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
ResolvedConfig,
@@ -59,7 +60,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
addMinedWordsImmediately: true,
matchMode: 'headword',
decks: {},
color: '#a6da95',
},
behavior: {
overwriteAudio: true,
@@ -70,15 +70,15 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
autoUpdateNewCards: true,
},
nPlusOne: {
enabled: false,
minSentenceWords: 3,
nPlusOne: '#c6a0f6',
},
metadata: {
pattern: '[SubMiner] %f (%t)',
},
isLapis: {
enabled: false,
sentenceCardModel: 'Japanese sentences',
sentenceCardModel: 'Lapis',
},
isKiku: {
enabled: false,
@@ -94,6 +94,13 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
mpv: {
executablePath: '',
launchMode: 'normal',
socketPath: getDefaultMpvSocketPath(),
backend: 'auto',
autoStartSubMiner: true,
pauseUntilOverlayReady: true,
subminerBinaryPath: '',
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
anilist: {
enabled: false,
+12 -5
View File
@@ -3,13 +3,14 @@ import { ResolvedConfig } from '../../types/config';
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
subtitleStyle: {
primaryDefaultMode: 'visible',
css: {},
enableJlpt: false,
preserveLineBreaks: false,
autoPauseVideoOnHover: true,
autoPauseVideoOnYomitanPopup: true,
hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
nameMatchEnabled: true,
hoverTokenBackgroundColor: 'transparent',
nameMatchEnabled: false,
nameMatchColor: '#f5bde6',
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
fontSize: 35,
@@ -21,6 +22,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
fontKerning: 'normal',
textRendering: 'geometricPrecision',
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
paintOrder: '',
WebkitTextStroke: '',
fontStyle: 'normal',
backgroundColor: 'transparent',
backdropFilter: 'blur(6px)',
@@ -43,7 +46,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
},
secondary: {
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif',
css: {},
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
fontSize: 24,
fontColor: '#cad3f5',
lineHeight: 1.35,
@@ -52,6 +56,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
fontKerning: 'normal',
textRendering: 'geometricPrecision',
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
paintOrder: '',
WebkitTextStroke: '',
backgroundColor: 'transparent',
backdropFilter: 'blur(6px)',
fontWeight: '600',
@@ -63,13 +69,14 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
autoOpen: false,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
pauseVideoOnHover: true,
autoScroll: true,
css: {},
maxWidth: 420,
opacity: 0.95,
backgroundColor: 'rgba(73, 77, 100, 0.9)',
textColor: '#cad3f5',
fontFamily: '"M PLUS 1", "Noto Sans CJK JP", sans-serif',
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
fontSize: 16,
timestampColor: '#a5adcb',
activeLineColor: '#f5bde6',
+25 -2
View File
@@ -63,10 +63,9 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
'subtitleStyle.jlptColors.N3',
'subtitleStyle.jlptColors.N4',
'subtitleStyle.jlptColors.N5',
'subtitleStyle.knownWordColor',
'subtitleStyle.letterSpacing',
'subtitleStyle.lineHeight',
'subtitleStyle.nPlusOneColor',
'subtitleStyle.paintOrder',
'subtitleStyle.secondary.backdropFilter',
'subtitleStyle.secondary.backgroundColor',
'subtitleStyle.secondary.fontColor',
@@ -77,11 +76,14 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
'subtitleStyle.secondary.fontWeight',
'subtitleStyle.secondary.letterSpacing',
'subtitleStyle.secondary.lineHeight',
'subtitleStyle.secondary.paintOrder',
'subtitleStyle.secondary.textRendering',
'subtitleStyle.secondary.textShadow',
'subtitleStyle.secondary.WebkitTextStroke',
'subtitleStyle.secondary.wordSpacing',
'subtitleStyle.textRendering',
'subtitleStyle.textShadow',
'subtitleStyle.WebkitTextStroke',
'subtitleStyle.wordSpacing',
]);
@@ -103,6 +105,13 @@ test('config option registry includes critical paths and has unique entries', ()
'anilist.characterDictionary.collapsibleSections.description',
'mpv.executablePath',
'mpv.launchMode',
'mpv.socketPath',
'mpv.backend',
'mpv.autoStartSubMiner',
'mpv.pauseUntilOverlayReady',
'mpv.subminerBinaryPath',
'mpv.aniskipEnabled',
'mpv.aniskipButtonKey',
'yomitan.externalProfilePath',
'immersionTracking.enabled',
]) {
@@ -112,6 +121,20 @@ test('config option registry includes critical paths and has unique entries', ()
assert.equal(new Set(paths).size, paths.length);
});
test('known-word annotation color has one public config path', () => {
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
assert.ok(leaves.includes('subtitleStyle.knownWordColor'));
assert.ok(!leaves.includes('ankiConnect.knownWords.color'));
});
test('n+1 annotation color has one public config path', () => {
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
assert.ok(leaves.includes('subtitleStyle.nPlusOneColor'));
assert.ok(!leaves.includes('ankiConnect.nPlusOne.color'));
});
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
+2 -8
View File
@@ -339,7 +339,8 @@ export function buildCoreConfigOptionRegistry(
path: 'auto_start_overlay',
kind: 'boolean',
defaultValue: defaultConfig.auto_start_overlay,
description: 'Auto-start the subtitle overlay window when SubMiner launches.',
description:
'Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner.',
},
{
path: 'secondarySub.secondarySubLanguages',
@@ -387,13 +388,6 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.annotationWebsocket.port,
description: 'Annotated subtitle websocket server port.',
},
{
path: 'subsync.defaultMode',
kind: 'enum',
enumValues: ['auto', 'manual'],
defaultValue: defaultConfig.subsync.defaultMode,
description: 'Subsync default mode.',
},
{
path: 'subsync.replace',
kind: 'boolean',
+56 -13
View File
@@ -278,6 +278,13 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.knownWords.addMinedWordsImmediately,
description: 'Immediately append newly mined card words into the known-word cache.',
},
{
path: 'ankiConnect.nPlusOne.enabled',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.nPlusOne.enabled,
description:
'Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data.',
},
{
path: 'ankiConnect.nPlusOne.minSentenceWords',
kind: 'number',
@@ -291,18 +298,6 @@ export function buildIntegrationConfigOptionRegistry(
description:
'Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.',
},
{
path: 'ankiConnect.nPlusOne.nPlusOne',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.nPlusOne.nPlusOne,
description: 'Color used for the single N+1 target token highlight.',
},
{
path: 'ankiConnect.knownWords.color',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.knownWords.color,
description: 'Color used for known-word highlights.',
},
{
path: 'ankiConnect.isKiku.fieldGrouping',
kind: 'enum',
@@ -454,6 +449,53 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.mpv.launchMode,
description: 'Default window state for SubMiner-managed mpv launches.',
},
{
path: 'mpv.socketPath',
kind: 'string',
defaultValue: defaultConfig.mpv.socketPath,
description:
'mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.',
},
{
path: 'mpv.backend',
kind: 'enum',
enumValues: ['auto', 'hyprland', 'sway', 'x11', 'macos', 'windows'],
defaultValue: defaultConfig.mpv.backend,
description:
'Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform.',
},
{
path: 'mpv.autoStartSubMiner',
kind: 'boolean',
defaultValue: defaultConfig.mpv.autoStartSubMiner,
description: 'Start SubMiner in the background when SubMiner-managed mpv loads a file.',
},
{
path: 'mpv.pauseUntilOverlayReady',
kind: 'boolean',
defaultValue: defaultConfig.mpv.pauseUntilOverlayReady,
description:
'Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness.',
},
{
path: 'mpv.subminerBinaryPath',
kind: 'string',
defaultValue: defaultConfig.mpv.subminerBinaryPath,
description:
'Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.',
},
{
path: 'mpv.aniskipEnabled',
kind: 'boolean',
defaultValue: defaultConfig.mpv.aniskipEnabled,
description: 'Enable AniSkip intro detection and skip markers in the bundled mpv plugin.',
},
{
path: 'mpv.aniskipButtonKey',
kind: 'string',
defaultValue: defaultConfig.mpv.aniskipButtonKey,
description: 'mpv key used to trigger the AniSkip button while the skip marker is visible.',
},
{
path: 'jellyfin.enabled',
kind: 'boolean',
@@ -567,7 +609,8 @@ export function buildIntegrationConfigOptionRegistry(
},
{
path: 'discordPresence.presenceStyle',
kind: 'string',
kind: 'enum',
enumValues: ['default', 'meme', 'japanese', 'minimal'],
defaultValue: defaultConfig.discordPresence.presenceStyle,
description:
'Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".',
@@ -13,6 +13,20 @@ export function buildSubtitleConfigOptionRegistry(
description:
'Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover.',
},
{
path: 'subtitleStyle.css',
kind: 'object',
defaultValue: defaultConfig.subtitleStyle.css,
description:
'CSS declaration object applied to primary subtitles after normal subtitle style defaults.',
},
{
path: 'subtitleStyle.secondary.css',
kind: 'object',
defaultValue: defaultConfig.subtitleStyle.secondary.css,
description:
'CSS declaration object applied to secondary subtitles after normal subtitle style defaults.',
},
{
path: 'subtitleStyle.enableJlpt',
kind: 'boolean',
@@ -69,6 +83,18 @@ export function buildSubtitleConfigOptionRegistry(
description:
'Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.',
},
{
path: 'subtitleStyle.knownWordColor',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.knownWordColor,
description: 'Color used for known-word subtitle highlights.',
},
{
path: 'subtitleStyle.nPlusOneColor',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.nPlusOneColor,
description: 'Color used for the single N+1 target token subtitle highlight.',
},
{
path: 'subtitleStyle.frequencyDictionary.enabled',
kind: 'boolean',
@@ -155,6 +181,13 @@ export function buildSubtitleConfigOptionRegistry(
defaultValue: defaultConfig.subtitleSidebar.autoScroll,
description: 'Auto-scroll the active subtitle cue into view while playback advances.',
},
{
path: 'subtitleSidebar.css',
kind: 'object',
defaultValue: defaultConfig.subtitleSidebar.css,
description:
'CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.',
},
{
path: 'subtitleSidebar.maxWidth',
kind: 'number',
+18 -2
View File
@@ -20,9 +20,9 @@ export function buildRuntimeOptionRegistry(
}),
},
{
id: 'subtitle.annotation.nPlusOne',
id: 'subtitle.annotation.knownWords.highlightEnabled',
path: 'ankiConnect.knownWords.highlightEnabled',
label: 'N+1 Annotation',
label: 'Known Word Annotation',
scope: 'subtitle',
valueType: 'boolean',
allowedValues: [true, false],
@@ -35,6 +35,22 @@ export function buildRuntimeOptionRegistry(
},
}),
},
{
id: 'subtitle.annotation.nPlusOne',
path: 'ankiConnect.nPlusOne.enabled',
label: 'N+1 Annotation',
scope: 'subtitle',
valueType: 'boolean',
allowedValues: [true, false],
defaultValue: defaultConfig.ankiConnect.nPlusOne.enabled,
requiresRestart: false,
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
toAnkiPatch: (value) => ({
nPlusOne: {
enabled: value === true,
},
}),
},
{
id: 'subtitle.annotation.jlpt',
path: 'subtitleStyle.enableJlpt',
+12 -5
View File
@@ -2,9 +2,10 @@ import { ConfigTemplateSection } from './shared';
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Overlay Auto-Start',
title: 'Visible Overlay Auto-Start',
description: [
'When overlay connects to mpv, automatically show overlay and hide mpv subtitles.',
'Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.',
'SubMiner can still auto-start in the background when this is false.',
],
key: 'auto_start_overlay',
},
@@ -32,6 +33,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Logging',
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
notes: ['Hot-reload: logging.level applies live while SubMiner is running.'],
key: 'logging',
},
{
@@ -88,8 +90,9 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
key: 'secondarySub',
},
{
title: 'Auto Subtitle Sync',
title: 'Subtitle Sync',
description: ['Subsync engine and executable paths.'],
notes: ['Hot-reload: subsync changes apply to the next subtitle sync run.'],
key: 'subsync',
},
{
@@ -126,7 +129,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'],
notes: [
'Hot-reload: ankiConnect.ai.enabled updates live while SubMiner is running.',
'Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.',
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
'Most other AnkiConnect settings still require restart.',
],
@@ -135,6 +138,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Jimaku',
description: ['Jimaku API configuration and defaults.'],
notes: ['Hot-reload: Jimaku changes apply to the next Jimaku request.'],
key: 'jimaku',
},
{
@@ -142,6 +146,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
description: [
'Defaults for managed subtitle language preferences and YouTube subtitle loading.',
],
notes: ['Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.'],
key: 'youtube',
},
{
@@ -166,7 +171,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'MPV Launcher',
description: [
'Optional mpv.exe override for Windows playback entry points.',
'SubMiner-managed mpv launch and bundled plugin options.',
'Set mpv.socketPath to the IPC socket used by the launcher, Electron app, and bundled plugin.',
'autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.',
'Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.',
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
],
+23
View File
@@ -84,6 +84,29 @@ test('accepts knownWords.addMinedWordsImmediately boolean override', () => {
);
});
test('enables n+1 for existing configs with known-word highlighting enabled', () => {
const { context } = makeContext({
knownWords: { highlightEnabled: true },
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(context.resolved.ankiConnect.nPlusOne.enabled, true);
});
test('keeps explicit n+1 disabled when known-word highlighting is enabled', () => {
const { context } = makeContext({
knownWords: { highlightEnabled: true },
nPlusOne: { enabled: false },
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(context.resolved.ankiConnect.nPlusOne.enabled, false);
});
test('converts legacy knownWords.decks array to object with default fields', () => {
const { context, warnings } = makeContext({
knownWords: { decks: ['Core Deck'] },
+31 -115
View File
@@ -654,7 +654,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
const legacyNPlusOneHighlightEnabled = asBoolean(nPlusOneConfig.highlightEnabled);
if (knownWordsHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
} else if (knownWordsConfig.highlightEnabled !== undefined) {
@@ -666,23 +665,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
);
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
} else if (legacyNPlusOneHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled = legacyNPlusOneHighlightEnabled;
context.warn(
'ankiConnect.nPlusOne.highlightEnabled',
nPlusOneConfig.highlightEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
);
} else if (nPlusOneConfig.highlightEnabled !== undefined) {
context.warn(
'ankiConnect.nPlusOne.highlightEnabled',
nPlusOneConfig.highlightEnabled,
context.resolved.ankiConnect.knownWords.highlightEnabled,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
} else {
const legacyBehaviorNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
if (legacyBehaviorNPlusOneHighlightEnabled !== undefined) {
@@ -701,15 +683,10 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
}
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
const legacyNPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes);
const hasValidKnownWordsRefreshMinutes =
knownWordsRefreshMinutes !== undefined &&
Number.isInteger(knownWordsRefreshMinutes) &&
knownWordsRefreshMinutes > 0;
const hasValidLegacyNPlusOneRefreshMinutes =
legacyNPlusOneRefreshMinutes !== undefined &&
Number.isInteger(legacyNPlusOneRefreshMinutes) &&
legacyNPlusOneRefreshMinutes > 0;
if (knownWordsRefreshMinutes !== undefined) {
if (hasValidKnownWordsRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
@@ -723,25 +700,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else if (legacyNPlusOneRefreshMinutes !== undefined) {
if (hasValidLegacyNPlusOneRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = legacyNPlusOneRefreshMinutes;
context.warn(
'ankiConnect.nPlusOne.refreshMinutes',
nPlusOneConfig.refreshMinutes,
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes',
);
} else {
context.warn(
'ankiConnect.nPlusOne.refreshMinutes',
nPlusOneConfig.refreshMinutes,
context.resolved.ankiConnect.knownWords.refreshMinutes,
'Expected a positive integer.',
);
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) {
const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
const hasValidLegacyRefreshMinutes =
@@ -789,6 +747,23 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
}
const nPlusOneEnabled = asBoolean(nPlusOneConfig.enabled);
if (nPlusOneEnabled !== undefined) {
context.resolved.ankiConnect.nPlusOne.enabled = nPlusOneEnabled;
} else if (nPlusOneConfig.enabled !== undefined) {
context.warn(
'ankiConnect.nPlusOne.enabled',
nPlusOneConfig.enabled,
context.resolved.ankiConnect.nPlusOne.enabled,
'Expected boolean.',
);
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
} else if (context.resolved.ankiConnect.knownWords.highlightEnabled === true) {
context.resolved.ankiConnect.nPlusOne.enabled = true;
} else {
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
}
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
const hasValidNPlusOneMinSentenceWords =
nPlusOneMinSentenceWords !== undefined &&
@@ -813,12 +788,9 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
}
const knownWordsMatchMode = asString(knownWordsConfig.matchMode);
const legacyNPlusOneMatchMode = asString(nPlusOneConfig.matchMode);
const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
const hasValidKnownWordsMatchMode =
knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface';
const hasValidLegacyNPlusOneMatchMode =
legacyNPlusOneMatchMode === 'headword' || legacyNPlusOneMatchMode === 'surface';
const hasValidLegacyMatchMode =
legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
if (hasValidKnownWordsMatchMode) {
@@ -832,25 +804,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
);
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
} else if (legacyNPlusOneMatchMode !== undefined) {
if (hasValidLegacyNPlusOneMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = legacyNPlusOneMatchMode;
context.warn(
'ankiConnect.nPlusOne.matchMode',
nPlusOneConfig.matchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
'Legacy key is deprecated; use ankiConnect.knownWords.matchMode',
);
} else {
context.warn(
'ankiConnect.nPlusOne.matchMode',
nPlusOneConfig.matchMode,
context.resolved.ankiConnect.knownWords.matchMode,
"Expected 'headword' or 'surface'.",
);
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
}
} else if (legacyBehaviorNPlusOneMatchMode !== undefined) {
if (hasValidLegacyMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
@@ -882,7 +835,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
'Word Reading',
];
const knownWordsDecks = knownWordsConfig.decks;
const legacyNPlusOneDecks = nPlusOneConfig.decks;
if (isObject(knownWordsDecks)) {
const resolved: Record<string, string[]> = {};
for (const [deck, fields] of Object.entries(knownWordsDecks as Record<string, unknown>)) {
@@ -926,67 +878,31 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
context.resolved.ankiConnect.knownWords.decks,
'Expected an object mapping deck names to field arrays.',
);
} else if (Array.isArray(legacyNPlusOneDecks)) {
const normalized = legacyNPlusOneDecks
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const resolved: Record<string, string[]> = {};
for (const deck of new Set(normalized)) {
resolved[deck] = DEFAULT_FIELDS;
}
context.resolved.ankiConnect.knownWords.decks = resolved;
if (normalized.length > 0) {
context.warn(
'ankiConnect.nPlusOne.decks',
legacyNPlusOneDecks,
DEFAULT_CONFIG.ankiConnect.knownWords.decks,
'Legacy key is deprecated; use ankiConnect.knownWords.decks with object format',
);
}
}
const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne);
if (nPlusOneHighlightColor !== undefined) {
context.resolved.ankiConnect.nPlusOne.nPlusOne = nPlusOneHighlightColor;
} else if (nPlusOneConfig.nPlusOne !== undefined) {
context.warn(
'ankiConnect.nPlusOne.nPlusOne',
nPlusOneConfig.nPlusOne,
context.resolved.ankiConnect.nPlusOne.nPlusOne,
'Expected a hex color value.',
);
context.resolved.ankiConnect.nPlusOne.nPlusOne = DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne;
}
const rawSubtitleStyle = isObject(context.src.subtitleStyle)
? (context.src.subtitleStyle as Record<string, unknown>)
: {};
const hasCanonicalKnownWordColor = rawSubtitleStyle.knownWordColor !== undefined;
const knownWordsColor = asColor(knownWordsConfig.color);
const legacyNPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord);
if (knownWordsColor !== undefined) {
context.resolved.ankiConnect.knownWords.color = knownWordsColor;
if (!hasCanonicalKnownWordColor) {
context.resolved.subtitleStyle.knownWordColor = knownWordsColor;
}
context.warn(
'ankiConnect.knownWords.color',
knownWordsConfig.color,
context.resolved.subtitleStyle.knownWordColor,
'Legacy key is deprecated; use subtitleStyle.knownWordColor',
);
} else if (knownWordsConfig.color !== undefined) {
context.warn(
'ankiConnect.knownWords.color',
knownWordsConfig.color,
context.resolved.ankiConnect.knownWords.color,
context.resolved.subtitleStyle.knownWordColor,
'Expected a hex color value.',
);
context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color;
} else if (legacyNPlusOneKnownWordColor !== undefined) {
context.resolved.ankiConnect.knownWords.color = legacyNPlusOneKnownWordColor;
context.warn(
'ankiConnect.nPlusOne.knownWord',
nPlusOneConfig.knownWord,
DEFAULT_CONFIG.ankiConnect.knownWords.color,
'Legacy key is deprecated; use ankiConnect.knownWords.color',
);
} else if (nPlusOneConfig.knownWord !== undefined) {
context.warn(
'ankiConnect.nPlusOne.knownWord',
nPlusOneConfig.knownWord,
context.resolved.ankiConnect.knownWords.color,
'Expected a hex color value.',
);
context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color;
}
if (
-7
View File
@@ -273,13 +273,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
}
if (isObject(src.subsync)) {
const mode = src.subsync.defaultMode;
if (mode === 'auto' || mode === 'manual') {
resolved.subsync.defaultMode = mode;
} else if (mode !== undefined) {
warn('subsync.defaultMode', mode, resolved.subsync.defaultMode, 'Expected auto or manual.');
}
const alass = asString(src.subsync.alass_path);
if (alass !== undefined) resolved.subsync.alass_path = alass;
const ffsubsync = asString(src.subsync.ffsubsync_path);
+91
View File
@@ -253,6 +253,97 @@ export function applyIntegrationConfig(context: ResolveContext): void {
`Expected one of: ${MPV_LAUNCH_MODE_VALUES.map((value) => `'${value}'`).join(', ')}.`,
);
}
const socketPath = asString(src.mpv.socketPath);
if (socketPath !== undefined && socketPath.trim().length > 0) {
resolved.mpv.socketPath = socketPath.trim();
} else if (src.mpv.socketPath !== undefined) {
warn(
'mpv.socketPath',
src.mpv.socketPath,
resolved.mpv.socketPath,
'Expected non-empty string.',
);
}
const backend = asString(src.mpv.backend);
if (
backend === 'auto' ||
backend === 'hyprland' ||
backend === 'sway' ||
backend === 'x11' ||
backend === 'macos' ||
backend === 'windows'
) {
resolved.mpv.backend = backend;
} else if (src.mpv.backend !== undefined) {
warn(
'mpv.backend',
src.mpv.backend,
resolved.mpv.backend,
'Expected auto, hyprland, sway, x11, macos, or windows.',
);
}
const autoStartSubMiner = asBoolean(src.mpv.autoStartSubMiner);
if (autoStartSubMiner !== undefined) {
resolved.mpv.autoStartSubMiner = autoStartSubMiner;
} else if (src.mpv.autoStartSubMiner !== undefined) {
warn(
'mpv.autoStartSubMiner',
src.mpv.autoStartSubMiner,
resolved.mpv.autoStartSubMiner,
'Expected boolean.',
);
}
const pauseUntilOverlayReady = asBoolean(src.mpv.pauseUntilOverlayReady);
if (pauseUntilOverlayReady !== undefined) {
resolved.mpv.pauseUntilOverlayReady = pauseUntilOverlayReady;
} else if (src.mpv.pauseUntilOverlayReady !== undefined) {
warn(
'mpv.pauseUntilOverlayReady',
src.mpv.pauseUntilOverlayReady,
resolved.mpv.pauseUntilOverlayReady,
'Expected boolean.',
);
}
const subminerBinaryPath = asString(src.mpv.subminerBinaryPath);
if (subminerBinaryPath !== undefined) {
resolved.mpv.subminerBinaryPath = subminerBinaryPath.trim();
} else if (src.mpv.subminerBinaryPath !== undefined) {
warn(
'mpv.subminerBinaryPath',
src.mpv.subminerBinaryPath,
resolved.mpv.subminerBinaryPath,
'Expected string.',
);
}
const aniskipEnabled = asBoolean(src.mpv.aniskipEnabled);
if (aniskipEnabled !== undefined) {
resolved.mpv.aniskipEnabled = aniskipEnabled;
} else if (src.mpv.aniskipEnabled !== undefined) {
warn(
'mpv.aniskipEnabled',
src.mpv.aniskipEnabled,
resolved.mpv.aniskipEnabled,
'Expected boolean.',
);
}
const aniskipButtonKey = asString(src.mpv.aniskipButtonKey);
if (aniskipButtonKey !== undefined && aniskipButtonKey.trim().length > 0) {
resolved.mpv.aniskipButtonKey = aniskipButtonKey.trim();
} else if (src.mpv.aniskipButtonKey !== undefined) {
warn(
'mpv.aniskipButtonKey',
src.mpv.aniskipButtonKey,
resolved.mpv.aniskipButtonKey,
'Expected non-empty string.',
);
}
} else if (src.mpv !== undefined) {
warn('mpv', src.mpv, resolved.mpv, 'Expected object.');
}
+110
View File
@@ -10,6 +10,40 @@ import {
isObject,
} from './shared';
function asCssDeclarations(value: unknown): Record<string, string> | undefined {
if (!isObject(value)) return undefined;
const declarations: Record<string, string> = {};
for (const [property, declarationValue] of Object.entries(value)) {
if (typeof declarationValue !== 'string') {
return undefined;
}
if (declarationValue.trim().length > 0) {
declarations[property] = declarationValue.trim();
}
}
return declarations;
}
const SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY = '--subtitle-hover-token-color';
const SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY = '--subtitle-hover-token-background-color';
function applySubtitleHoverTokenCssCompatibility(
subtitleStyle: ResolvedConfig['subtitleStyle'],
): void {
const hoverTokenColor = asCssColor(subtitleStyle.css[SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY]);
if (hoverTokenColor !== undefined) {
subtitleStyle.hoverTokenColor = hoverTokenColor;
}
const hoverTokenBackgroundColor = asCssColor(
subtitleStyle.css[SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY],
);
if (hoverTokenBackgroundColor !== undefined) {
subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
}
}
export function applySubtitleDomainConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
@@ -157,6 +191,10 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
resolved.subtitleStyle.hoverTokenBackgroundColor;
const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled;
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor;
const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor;
const fallbackSubtitleStyleCss = { ...resolved.subtitleStyle.css };
const fallbackSubtitleStyleSecondaryCss = { ...resolved.subtitleStyle.secondary.css };
const fallbackFrequencyDictionary = {
...resolved.subtitleStyle.frequencyDictionary,
};
@@ -209,6 +247,35 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
}
const css = asCssDeclarations((src.subtitleStyle as { css?: unknown }).css);
if (css !== undefined) {
resolved.subtitleStyle.css = css;
} else if ((src.subtitleStyle as { css?: unknown }).css !== undefined) {
resolved.subtitleStyle.css = fallbackSubtitleStyleCss;
warn(
'subtitleStyle.css',
(src.subtitleStyle as { css?: unknown }).css,
resolved.subtitleStyle.css,
'Expected an object whose values are CSS declaration strings.',
);
}
const rawSecondary = isObject(src.subtitleStyle.secondary)
? (src.subtitleStyle.secondary as { css?: unknown })
: undefined;
const secondaryCss = asCssDeclarations(rawSecondary?.css);
if (secondaryCss !== undefined) {
resolved.subtitleStyle.secondary.css = secondaryCss;
} else if (rawSecondary?.css !== undefined) {
resolved.subtitleStyle.secondary.css = fallbackSubtitleStyleSecondaryCss;
warn(
'subtitleStyle.secondary.css',
rawSecondary.css,
resolved.subtitleStyle.secondary.css,
'Expected an object whose values are CSS declaration strings.',
);
}
const preserveLineBreaks = asBoolean(
(src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks,
);
@@ -301,6 +368,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
}
applySubtitleHoverTokenCssCompatibility(resolved.subtitleStyle);
const nameMatchColor = asColor(
(src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor,
);
@@ -333,6 +402,34 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
}
const knownWordColor = asColor(
(src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor,
);
if (knownWordColor !== undefined) {
resolved.subtitleStyle.knownWordColor = knownWordColor;
} else if ((src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor !== undefined) {
resolved.subtitleStyle.knownWordColor = fallbackSubtitleStyleKnownWordColor;
warn(
'subtitleStyle.knownWordColor',
(src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor,
resolved.subtitleStyle.knownWordColor,
'Expected hex color.',
);
}
const nPlusOneColor = asColor((src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor);
if (nPlusOneColor !== undefined) {
resolved.subtitleStyle.nPlusOneColor = nPlusOneColor;
} else if ((src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor !== undefined) {
resolved.subtitleStyle.nPlusOneColor = fallbackSubtitleStyleNPlusOneColor;
warn(
'subtitleStyle.nPlusOneColor',
(src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor,
resolved.subtitleStyle.nPlusOneColor,
'Expected hex color.',
);
}
const frequencyDictionary = isObject(
(src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary,
)
@@ -445,6 +542,19 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
...(src.subtitleSidebar as ResolvedConfig['subtitleSidebar']),
};
const css = asCssDeclarations((src.subtitleSidebar as { css?: unknown }).css);
if (css !== undefined) {
resolved.subtitleSidebar.css = css;
} else if ((src.subtitleSidebar as { css?: unknown }).css !== undefined) {
resolved.subtitleSidebar.css = fallback.css;
warn(
'subtitleSidebar.css',
(src.subtitleSidebar as { css?: unknown }).css,
resolved.subtitleSidebar.css,
'Expected an object whose values are CSS declaration strings.',
);
}
const enabled = asBoolean((src.subtitleSidebar as { enabled?: unknown }).enabled);
if (enabled !== undefined) {
resolved.subtitleSidebar.enabled = enabled;
@@ -55,6 +55,33 @@ test('subtitleSidebar accepts zero opacity', () => {
);
});
test('subtitleSidebar css declarations accept string declaration maps and warn on invalid values', () => {
const valid = createResolveContext({
subtitleSidebar: {
css: {
'font-size': '18px',
color: '#ffffff',
},
},
});
applySubtitleDomainConfig(valid.context);
assert.deepEqual(valid.context.resolved.subtitleSidebar.css, {
'font-size': '18px',
color: '#ffffff',
});
const invalid = createResolveContext({
subtitleSidebar: {
css: {
color: 42,
} as never,
},
});
applySubtitleDomainConfig(invalid.context);
assert.deepEqual(invalid.context.resolved.subtitleSidebar.css, {});
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleSidebar.css'));
});
test('subtitleSidebar falls back and warns on invalid values', () => {
const { context, warnings } = createResolveContext({
subtitleSidebar: {
+112 -1
View File
@@ -28,6 +28,68 @@ test('subtitleStyle preserveLineBreaks falls back while merge is preserved', ()
);
});
test('subtitleStyle css declarations accept string declaration maps and warn on invalid values', () => {
const valid = createResolveContext({
subtitleStyle: {
css: {
'font-size': '42px',
'text-wrap': 'balance',
'--subtitle-hover-token-color': '#c6a0f6',
'--subtitle-hover-token-background-color': 'transparent',
},
secondary: {
css: {
'text-transform': 'uppercase',
},
},
},
});
applySubtitleDomainConfig(valid.context);
assert.deepEqual(valid.context.resolved.subtitleStyle.css, {
'font-size': '42px',
'text-wrap': 'balance',
'--subtitle-hover-token-color': '#c6a0f6',
'--subtitle-hover-token-background-color': 'transparent',
});
assert.equal(valid.context.resolved.subtitleStyle.hoverTokenColor, '#c6a0f6');
assert.equal(valid.context.resolved.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
assert.deepEqual(valid.context.resolved.subtitleStyle.secondary.css, {
'text-transform': 'uppercase',
});
const invalid = createResolveContext({
subtitleStyle: {
css: {
'font-size': 42,
} as never,
secondary: {
css: 'font-size: 28px;' as never,
},
},
});
applySubtitleDomainConfig(invalid.context);
assert.deepEqual(invalid.context.resolved.subtitleStyle.css, {});
assert.deepEqual(invalid.context.resolved.subtitleStyle.secondary.css, {});
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.css'));
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.secondary.css'));
});
test('subtitleStyle hover css compatibility ignores invalid color declarations', () => {
const { context } = createResolveContext({
subtitleStyle: {
css: {
'--subtitle-hover-token-color': 'purple',
'--subtitle-hover-token-background-color': '#363a4fd6',
},
},
});
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleStyle.hoverTokenColor, '#f4dbd6');
assert.equal(context.resolved.subtitleStyle.hoverTokenBackgroundColor, '#363a4fd6');
});
test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
const { context, warnings } = createResolveContext({
subtitleStyle: {
@@ -100,7 +162,7 @@ test('subtitleStyle nameMatchEnabled falls back on invalid value', () => {
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, true);
assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, false);
assert.ok(
warnings.some(
(warning) =>
@@ -155,6 +217,55 @@ test('subtitleStyle nameMatchColor accepts valid values and warns on invalid', (
);
});
test('subtitleStyle knownWordColor accepts valid values and warns on invalid', () => {
const valid = createResolveContext({
subtitleStyle: {
knownWordColor: '#ed8796',
},
});
applySubtitleDomainConfig(valid.context);
assert.equal(valid.context.resolved.subtitleStyle.knownWordColor, '#ed8796');
const invalid = createResolveContext({
subtitleStyle: {
knownWordColor: 'pink',
},
});
applySubtitleDomainConfig(invalid.context);
assert.equal(invalid.context.resolved.subtitleStyle.knownWordColor, '#a6da95');
assert.ok(
invalid.warnings.some(
(warning) =>
warning.path === 'subtitleStyle.knownWordColor' &&
warning.message === 'Expected hex color.',
),
);
});
test('subtitleStyle nPlusOneColor accepts valid values and warns on invalid', () => {
const valid = createResolveContext({
subtitleStyle: {
nPlusOneColor: '#ed8796',
},
});
applySubtitleDomainConfig(valid.context);
assert.equal(valid.context.resolved.subtitleStyle.nPlusOneColor, '#ed8796');
const invalid = createResolveContext({
subtitleStyle: {
nPlusOneColor: 'pink',
},
});
applySubtitleDomainConfig(invalid.context);
assert.equal(invalid.context.resolved.subtitleStyle.nPlusOneColor, '#c6a0f6');
assert.ok(
invalid.warnings.some(
(warning) =>
warning.path === 'subtitleStyle.nPlusOneColor' && warning.message === 'Expected hex color.',
),
);
});
test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
const valid = createResolveContext({
subtitleStyle: {
+45 -3
View File
@@ -4,6 +4,8 @@ import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types/con
import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions';
import { ConfigPaths, loadRawConfig, loadRawConfigStrict } from './load';
import { resolveConfig } from './resolve';
import { applyLegacyAnkiConnectNPlusOneMigrationToContent } from './anki-connect-nplusone-migration';
import { applyLegacySubtitleStyleCssMigrationToContent } from './subtitle-style-css-migration';
export type ReloadConfigStrictResult =
| {
@@ -49,7 +51,10 @@ export class ConfigService {
if (!loadResult.ok) {
throw new ConfigStartupParseError(loadResult.path, loadResult.error);
}
this.applyResolvedConfig(loadResult.config, loadResult.path);
this.applyResolvedConfig(
this.migrateLegacyConfig(loadResult.config, loadResult.path),
loadResult.path,
);
}
getConfigPath(): string {
@@ -70,7 +75,7 @@ export class ConfigService {
reloadConfig(): ResolvedConfig {
const { config, path: configPath } = loadRawConfig(this.configPaths);
return this.applyResolvedConfig(config, configPath);
return this.applyResolvedConfig(this.migrateLegacyConfig(config, configPath), configPath);
}
reloadConfigStrict(): ReloadConfigStrictResult {
@@ -80,7 +85,10 @@ export class ConfigService {
}
const { config, path: configPath } = loadResult;
const resolvedConfig = this.applyResolvedConfig(config, configPath);
const resolvedConfig = this.applyResolvedConfig(
this.migrateLegacyConfig(config, configPath),
configPath,
);
return {
ok: true,
config: resolvedConfig,
@@ -113,4 +121,38 @@ export class ConfigService {
this.warnings = warnings;
return this.getConfig();
}
private migrateLegacyConfig(config: RawConfig, configPath: string): RawConfig {
if (!fs.existsSync(configPath)) {
return config;
}
try {
let content = fs.readFileSync(configPath, 'utf-8');
let rawConfig = config;
let migrated = false;
for (const applyMigration of [
applyLegacyAnkiConnectNPlusOneMigrationToContent,
applyLegacySubtitleStyleCssMigrationToContent,
]) {
const migration = applyMigration({
content,
rawConfig,
});
if (!migration.migrated) {
continue;
}
content = migration.content;
rawConfig = migration.rawConfig;
migrated = true;
}
if (!migrated) {
return rawConfig;
}
return rawConfig;
} catch (error) {
console.error(`[ConfigService] legacy config migration failed for ${configPath}:`, error);
throw error;
}
}
}
+64
View File
@@ -32,6 +32,70 @@ test('applyConfigSettingsPatchToContent preserves JSONC comments while setting n
assert.equal(parsed.subtitleStyle.fontSize, 35);
});
test('applyConfigSettingsPatchToContent updates effective duplicate object path', () => {
const input = `{
"ankiConnect": {
"nPlusOne": {
"enabled": true
},
"knownWords": {
"highlightEnabled": true
},
"nPlusOne": {
"minSentenceWords": 3
}
}
}`;
const result = applyConfigSettingsPatchToContent({
content: input,
operations: [
{
op: 'set',
path: 'ankiConnect.nPlusOne.enabled',
value: true,
},
],
previousWarnings: [],
});
assert.equal(result.ok, true);
const parsed = parse(result.content);
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
});
test('applyConfigSettingsPatchToContent removes duplicate properties across JSONC trivia', () => {
const input = `{
"ankiConnect": {
"nPlusOne": {
"enabled": false
} /* old value */ ,
// effective value follows
"nPlusOne": {
"minSentenceWords": 3
}
}
}`;
const result = applyConfigSettingsPatchToContent({
content: input,
operations: [
{
op: 'set',
path: 'ankiConnect.nPlusOne.enabled',
value: true,
},
],
previousWarnings: [],
});
assert.equal(result.ok, true);
const parsed = parse(result.content);
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
});
test('applyConfigSettingsPatchToContent reset removes explicit path', () => {
const input = `{
"subtitleStyle": {
+162 -2
View File
@@ -2,6 +2,9 @@ import {
applyEdits,
modify,
parse as parseJsonc,
parseTree as parseJsoncTree,
type Edit,
type Node as JsoncNode,
type FormattingOptions,
type ParseError,
} from 'jsonc-parser';
@@ -12,7 +15,7 @@ import type {
ConfigSettingsSnapshot,
} from '../../types/settings';
import { resolveConfig } from '../resolve';
import { getConfigValueAtPath } from './registry';
import { getConfigValueAtPath, SECRET_PATHS } from './registry';
const JSONC_FORMATTING_OPTIONS: FormattingOptions = {
insertSpaces: true,
@@ -91,6 +94,7 @@ function normalizeContent(content: string): string {
}
function applySingleOperation(content: string, operation: ConfigSettingsPatchOperation): string {
content = removeDuplicatePropertiesAlongPath(content, operation.path);
const edits = modify(
content,
pathToSegments(operation.path),
@@ -103,6 +107,148 @@ function applySingleOperation(content: string, operation: ConfigSettingsPatchOpe
return applyEdits(content, edits);
}
function propertyKey(propertyNode: JsoncNode): string | undefined {
return propertyNode.children?.[0]?.value;
}
function propertyValue(propertyNode: JsoncNode): JsoncNode | undefined {
return propertyNode.children?.[1];
}
function objectProperties(node: JsoncNode | undefined): JsoncNode[] {
return node?.type === 'object' ? (node.children ?? []) : [];
}
function isWhitespace(value: string | undefined): boolean {
return value === ' ' || value === '\t' || value === '\r' || value === '\n';
}
function nextNonWhitespaceOffset(content: string, offset: number): number {
let index = offset;
while (index < content.length) {
if (isWhitespace(content[index])) {
index += 1;
continue;
}
if (content[index] === '/' && content[index + 1] === '/') {
index += 2;
while (index < content.length && content[index] !== '\n') index += 1;
continue;
}
if (content[index] === '/' && content[index + 1] === '*') {
index += 2;
while (
index + 1 < content.length &&
!(content[index] === '*' && content[index + 1] === '/')
) {
index += 1;
}
index = Math.min(content.length, index + 2);
continue;
}
break;
}
return index;
}
function previousNonWhitespaceOffset(content: string, offset: number): number {
let index = offset;
while (index >= 0) {
if (isWhitespace(content[index])) {
index -= 1;
continue;
}
const lineStart = content.lastIndexOf('\n', index) + 1;
const linePrefix = content.slice(lineStart, index + 1);
const lineCommentStart = linePrefix.lastIndexOf('//');
if (lineCommentStart >= 0 && /^[ \t]*$/.test(linePrefix.slice(0, lineCommentStart))) {
index = lineStart - 1;
continue;
}
if (content[index] === '/' && content[index - 1] === '*') {
index -= 2;
while (index > 0 && !(content[index - 1] === '/' && content[index] === '*')) {
index -= 1;
}
index -= 2;
continue;
}
break;
}
return index;
}
function lineStartOffset(content: string, offset: number): number {
return content.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
}
function removalEditForProperty(content: string, propertyNode: JsoncNode): Edit {
let offset = propertyNode.offset;
let end = propertyNode.offset + propertyNode.length;
const next = nextNonWhitespaceOffset(content, end);
if (content[next] === ',') {
end = next + 1;
const lineStart = lineStartOffset(content, offset);
if (/^[ \t]*$/.test(content.slice(lineStart, offset))) {
offset = lineStart;
}
} else {
const previous = previousNonWhitespaceOffset(content, offset - 1);
if (content[previous] === ',') {
offset = previous;
}
}
return {
offset,
length: Math.max(0, end - offset),
content: '',
};
}
function collectDuplicatePropertyRemovalEdits(content: string, path: string): Edit[] {
const errors: ParseError[] = [];
let node = parseJsoncTree(content, errors, {
allowTrailingComma: true,
disallowComments: false,
});
if (!node || errors.length > 0) {
return [];
}
const edits: Edit[] = [];
for (const segment of pathToSegments(path)) {
const matches = objectProperties(node).filter((property) => propertyKey(property) === segment);
if (matches.length === 0) {
break;
}
for (const duplicate of matches.slice(0, -1)) {
edits.push(removalEditForProperty(content, duplicate));
}
node = propertyValue(matches[matches.length - 1]!);
}
return edits;
}
function applyRemovalEdits(content: string, edits: Edit[]): string {
return [...edits]
.sort((left, right) => right.offset - left.offset)
.reduce(
(current, edit) =>
`${current.slice(0, edit.offset)}${edit.content}${current.slice(edit.offset + edit.length)}`,
content,
);
}
function removeDuplicatePropertiesAlongPath(content: string, path: string): string {
const edits = collectDuplicatePropertyRemovalEdits(content, path);
return edits.length > 0 ? applyRemovalEdits(content, edits) : content;
}
function collectModifiedWarnings(
warnings: ConfigValidationWarning[],
operations: ConfigSettingsPatchOperation[],
@@ -188,7 +334,21 @@ export function buildConfigSettingsSnapshot(
continue;
}
values[field.configPath] = structuredClone(rawValue !== undefined ? rawValue : resolvedValue);
values[field.configPath] = structuredClone(rawValue != null ? rawValue : resolvedValue);
}
for (const secretPath of SECRET_PATHS) {
if (Object.hasOwn(values, secretPath)) {
continue;
}
const rawValue = getConfigValueAtPath(options.rawConfig, secretPath);
const resolvedValue = getConfigValueAtPath(options.resolvedConfig, secretPath);
if (
(typeof rawValue === 'string' && rawValue.length > 0) ||
(typeof resolvedValue === 'string' && resolvedValue.length > 0)
) {
values[secretPath] = { configured: true };
}
}
return {
+294 -28
View File
@@ -1,39 +1,305 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_CONFIG } from '../definitions';
import {
buildConfigSettingsRegistry,
getConfigSettingsCoverage,
LEGACY_HIDDEN_CONFIG_PATHS,
} from './registry';
import { buildConfigSettingsRegistry } from './registry';
test('config settings registry places hover pause under viewing playback behavior', () => {
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
const hoverPause = fields.find(
(field) => field.configPath === 'subtitleStyle.autoPauseVideoOnHover',
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
function field(path: string) {
const match = fields.find((candidate) => candidate.configPath === path);
assert.ok(match, `missing settings field: ${path}`);
return match;
}
test('settings registry splits viewing into appearance and behavior categories', () => {
assert.equal(field('subtitleStyle.fontSize').category, 'appearance');
assert.equal(field('subtitleStyle.primaryDefaultMode').category, 'behavior');
assert.equal(field('subtitleStyle.primaryDefaultMode').section, 'Subtitle Behavior');
assert.equal(field('secondarySub.defaultMode').category, 'behavior');
assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position');
assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
assert.equal(field('auto_start_overlay').category, 'behavior');
assert.equal(field('auto_start_overlay').section, 'Playback Behavior');
assert.equal(field('youtube.primarySubLanguages').category, 'behavior');
assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings');
assert.equal(field('mpv.launchMode').category, 'behavior');
assert.equal(field('mpv.launchMode').section, 'mpv Playback');
assert.ok(
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
);
assert.ok(hoverPause);
assert.equal(hoverPause.category, 'viewing');
assert.equal(hoverPause.section, 'Playback pause behavior');
assert.equal(hoverPause.control, 'boolean');
});
test('config settings registry hides legacy and ignored paths from normal fields', () => {
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
const visiblePaths = new Set(
fields.filter((field) => !field.legacyHidden).map((field) => field.configPath),
);
for (const path of LEGACY_HIDDEN_CONFIG_PATHS) {
assert.equal(visiblePaths.has(path), false, path);
test('settings registry groups playback startup controls under playback behavior', () => {
for (const path of [
'subtitleStyle.autoPauseVideoOnHover',
'subtitleStyle.autoPauseVideoOnYomitanPopup',
'subtitleSidebar.pauseVideoOnHover',
'mpv.autoStartSubMiner',
'auto_start_overlay',
'mpv.pauseUntilOverlayReady',
]) {
assert.equal(field(path).category, 'behavior', path);
assert.equal(field(path).section, 'Playback Behavior', path);
}
assert.equal(visiblePaths.has('controller.buttonIndices'), false);
});
test('config settings registry covers canonical defaults or marks explicit raw-only gaps', () => {
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
const coverage = getConfigSettingsCoverage(DEFAULT_CONFIG, fields);
assert.deepEqual(coverage.uncoveredDefaultPaths, []);
test('settings registry moves AniSkip button key into input shortcuts and hot reload', () => {
assert.equal(field('mpv.aniskipButtonKey').category, 'input');
assert.equal(field('mpv.aniskipButtonKey').section, 'Overlay Shortcuts');
assert.equal(field('mpv.aniskipButtonKey').subsection, 'Playback');
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
assert.equal(field('mpv.aniskipButtonKey').restartBehavior, 'hot-reload');
});
test('settings registry hides removed modal-only fields', () => {
for (const path of [
'shortcuts.multiCopyTimeoutMs',
'anilist.characterDictionary.profileScope',
'jellyfin.directPlayContainers',
'jellyfin.remoteControlDeviceName',
]) {
assert.equal(
fields.some((candidate) => candidate.configPath === path),
false,
path,
);
}
});
test('settings registry orders websocket server immediately after annotation websocket', () => {
const integrationSections = [
...new Set(
fields
.filter((candidate) => candidate.category === 'integrations')
.map((candidate) => candidate.section),
),
];
const annotationIndex = integrationSections.indexOf('Annotation WebSocket');
assert.equal(integrationSections[annotationIndex + 1], 'WebSocket server');
});
test('settings registry explains websocket auto mode and keeps it disabled by default', () => {
assert.equal(field('websocket.enabled').defaultValue, false);
assert.equal(
field('websocket.enabled').description,
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
);
});
test('settings registry places immersion tracking after other tracking and app sections', () => {
const trackingSections = [
...new Set(
fields
.filter((candidate) => candidate.category === 'tracking-app')
.map((candidate) => candidate.section),
),
];
assert.equal(trackingSections.at(-1), 'Immersion tracking');
});
test('settings registry groups annotation display fields by config group', () => {
assert.equal(field('ankiConnect.knownWords.highlightEnabled').section, 'Annotation Display');
assert.equal(field('ankiConnect.knownWords.highlightEnabled').subsection, 'Known Words');
assert.equal(field('subtitleStyle.knownWordColor').subsection, 'Known Words');
assert.equal(field('subtitleStyle.nPlusOneColor').subsection, 'N+1');
assert.equal(field('subtitleStyle.enableJlpt').subsection, 'JLPT');
assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color');
});
test('settings registry routes known words sync, n+1, and frequency config to behavior', () => {
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words');
assert.equal(field('ankiConnect.knownWords.decks').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.decks').section, 'Known Words');
assert.equal(field('ankiConnect.knownWords.matchMode').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.matchMode').section, 'Known Words');
assert.equal(field('ankiConnect.knownWords.refreshMinutes').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.refreshMinutes').section, 'Known Words');
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').category, 'behavior');
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').section, 'N+1');
assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').category, 'behavior');
assert.equal(
field('subtitleStyle.frequencyDictionary.sourcePath').section,
'Frequency Highlighting',
);
assert.equal(field('subtitleStyle.frequencyDictionary.mode').category, 'behavior');
assert.equal(field('subtitleStyle.frequencyDictionary.matchMode').category, 'behavior');
assert.equal(field('subtitleStyle.frequencyDictionary.topX').category, 'behavior');
});
test('settings registry exposes mpv aniskip button as an mpv key learn control', () => {
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
});
test('settings registry exposes specialized controls for config-assisted inputs', () => {
assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks');
assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
assert.equal(field('ankiConnect.fields.word').control, 'anki-field');
assert.equal(field('keybindings').control, 'mpv-keybindings');
assert.equal(field('subtitleStyle.css').control, 'css-declarations');
assert.equal(field('subtitleStyle.secondary.css').control, 'css-declarations');
assert.equal(field('shortcuts.copySubtitle').control, 'keyboard-shortcut');
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
assert.equal(field('subtitleSidebar.css').control, 'css-declarations');
assert.equal(field('stats.toggleKey').control, 'key-code');
assert.equal(field('discordPresence.presenceStyle').control, 'select');
});
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
const primaryVisible = fields
.filter(
(candidate) =>
candidate.section === 'Primary Subtitle Appearance' && !candidate.settingsHidden,
)
.map((candidate) => candidate.configPath);
const secondaryVisible = fields
.filter(
(candidate) =>
candidate.section === 'Secondary Subtitle Appearance' && !candidate.settingsHidden,
)
.map((candidate) => candidate.configPath);
assert.deepEqual(primaryVisible, ['subtitleStyle.css']);
assert.deepEqual(secondaryVisible, ['subtitleStyle.secondary.css']);
assert.equal(field('subtitleStyle.fontSize').settingsHidden, true);
assert.equal(field('subtitleStyle.secondary.fontSize').settingsHidden, true);
assert.equal(field('subtitleStyle.fontColor').settingsHidden, true);
assert.equal(field('subtitleStyle.backgroundColor').settingsHidden, true);
assert.equal(field('subtitleStyle.hoverTokenColor').settingsHidden, true);
assert.equal(field('subtitleStyle.hoverTokenBackgroundColor').settingsHidden, true);
assert.equal(field('subtitleStyle.paintOrder').settingsHidden, true);
assert.equal(field('subtitleStyle.WebkitTextStroke').settingsHidden, true);
assert.equal(field('subtitleStyle.knownWordColor').settingsHidden, false);
assert.equal(field('subtitleStyle.nPlusOneColor').settingsHidden, false);
assert.equal(field('subtitleStyle.nameMatchColor').settingsHidden, false);
assert.equal(field('subtitleStyle.jlptColors.N1').settingsHidden, false);
assert.equal(field('subtitleStyle.frequencyDictionary.singleColor').settingsHidden, false);
assert.equal(field('subtitleStyle.frequencyDictionary.bandedColors').settingsHidden, false);
});
test('settings registry exposes css declaration editor for subtitle sidebar appearance', () => {
const sidebarVisible = fields
.filter(
(candidate) =>
candidate.section === 'Subtitle Sidebar Appearance' && !candidate.settingsHidden,
)
.map((candidate) => candidate.configPath);
assert.deepEqual(sidebarVisible, ['subtitleSidebar.css']);
assert.equal(field('subtitleSidebar.fontFamily').settingsHidden, true);
assert.equal(field('subtitleSidebar.fontSize').settingsHidden, true);
assert.equal(field('subtitleSidebar.textColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.backgroundColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.timestampColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.activeLineColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.activeLineBackgroundColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.hoverLineBackgroundColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.enabled').settingsHidden, false);
assert.equal(field('subtitleSidebar.layout').settingsHidden, false);
});
test('settings registry routes playback-related integrations into integrations', () => {
assert.equal(field('jimaku.apiBaseUrl').category, 'integrations');
assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku');
assert.equal(field('subsync.replace').category, 'integrations');
assert.equal(field('subsync.replace').section, 'Subtitle Sync');
});
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled');
assert.ok(
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.enabled') <
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.pollingRate'),
);
assert.ok(
fields.findIndex((candidate) => candidate.section === 'AnkiConnect') <
fields.findIndex((candidate) => candidate.section === 'AnkiConnect Proxy'),
);
const kikuLapis = fields.filter((candidate) => candidate.section === 'Kiku/Lapis Features');
assert.deepEqual(
kikuLapis.slice(0, 2).map((candidate) => candidate.configPath),
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled'],
);
});
test('settings registry hides app-managed and inactive config surfaces', () => {
const paths = new Set(fields.map((candidate) => candidate.configPath));
for (const hiddenPath of [
'ai.enabled',
'ai.apiKey',
'ai.apiKeyCommand',
'ai.model',
'ai.baseUrl',
'ai.systemPrompt',
'ai.requestTimeoutMs',
'ankiConnect.ai.enabled',
'ankiConnect.ai.model',
'ankiConnect.ai.systemPrompt',
'ankiConnect.fields.translation',
'controller.bindings',
'controller.preferredGamepadId',
'controller.preferredGamepadLabel',
'controller.profiles',
'youtubeSubgen.whisperBin',
'jellyfin.clientVersion',
'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'jellyfin.clientName',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
]) {
assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`);
}
assert.equal(field('anilist.characterDictionary.enabled').section, 'Character Dictionary');
});
test('settings registry marks safe live config paths as hot-reloadable', () => {
for (const path of [
'mpv.aniskipButtonKey',
'stats.toggleKey',
'stats.markWatchedKey',
'logging.level',
'youtube.primarySubLanguages',
'jimaku.apiBaseUrl',
'jimaku.languagePreference',
'jimaku.maxEntryResults',
'subsync.replace',
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.knownWords.addMinedWordsImmediately',
'ankiConnect.knownWords.matchMode',
'ankiConnect.knownWords.decks',
'ankiConnect.nPlusOne.enabled',
'ankiConnect.nPlusOne.minSentenceWords',
'ankiConnect.fields.word',
'ankiConnect.fields.audio',
'ankiConnect.fields.image',
'ankiConnect.fields.sentence',
'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
]) {
assert.equal(field(path).restartBehavior, 'hot-reload', path);
}
});
test('settings registry does not expose removed subsync mode option', () => {
const paths = new Set(fields.map((candidate) => candidate.configPath));
assert.equal(paths.has('subsync.defaultMode'), false);
});
test('settings registry keeps unsafe config siblings restart-required', () => {
for (const path of [
'stats.serverPort',
'ankiConnect.url',
'ankiConnect.proxy.enabled',
'mpv.socketPath',
'websocket.port',
]) {
assert.equal(field(path).restartBehavior, 'restart', path);
}
});
+433 -43
View File
@@ -6,6 +6,10 @@ import type {
ConfigSettingsRestartBehavior,
} from '../../types/settings';
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
import {
getSubtitleCssManagedConfigPaths,
getSubtitleCssScopeForPath,
} from '../../settings/subtitle-style-css';
type Leaf = {
path: string;
@@ -46,41 +50,209 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'ankiConnect.nPlusOne.matchMode',
'ankiConnect.nPlusOne.decks',
'ankiConnect.nPlusOne.knownWord',
'ankiConnect.nPlusOne.nPlusOne',
'ankiConnect.knownWords.color',
'ankiConnect.behavior.nPlusOneHighlightEnabled',
'ankiConnect.behavior.nPlusOneRefreshMinutes',
'ankiConnect.behavior.nPlusOneMatchMode',
'ankiConnect.isLapis.sentenceCardSentenceField',
'ankiConnect.isLapis.sentenceCardAudioField',
'ankiConnect.fields.translation',
'controller.bindings',
'controller.preferredGamepadId',
'controller.preferredGamepadLabel',
'controller.profiles',
'youtubeSubgen.primarySubLanguages',
'anilist.characterDictionary.refreshTtlHours',
'anilist.characterDictionary.evictionPolicy',
'anilist.characterDictionary.profileScope',
'jellyfin.accessToken',
'jellyfin.userId',
'jellyfin.clientName',
'jellyfin.clientVersion',
'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'jellyfin.directPlayContainers',
'jellyfin.remoteControlDeviceName',
'controller.buttonIndices',
'shortcuts.multiCopyTimeoutMs',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
] as const;
const EXCLUDED_PREFIXES = ['controller.buttonIndices'] as const;
const EXCLUDED_PREFIXES = [
'ai',
'ankiConnect.ai',
'controller.buttonIndices',
'youtubeSubgen',
] as const;
const JSON_OBJECT_FIELDS = new Set([
'keybindings',
'controller.bindings',
'controller.profiles',
'ankiConnect.knownWords.decks',
'subtitleStyle.css',
'subtitleStyle.secondary.css',
'subtitleSidebar.css',
]);
const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
export const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
const COLOR_SUFFIXES = new Set([
'Color',
'color',
'backgroundColor',
'singleColor',
'knownWordColor',
'nPlusOne',
const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColor']);
const SUBTITLE_CSS_MANAGED_CONFIG_PATHS = new Set([
...getSubtitleCssManagedConfigPaths('primary'),
...getSubtitleCssManagedConfigPaths('secondary'),
...getSubtitleCssManagedConfigPaths('sidebar'),
]);
const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
const CATEGORY_ORDER: ConfigSettingsCategory[] = [
'appearance',
'behavior',
'mining-anki',
'input',
'integrations',
'tracking-app',
'advanced',
];
const SECTION_ORDER = new Map<string, number>(
[
'Annotation Display',
'Known Words',
'N+1',
'Frequency Highlighting',
'Primary Subtitle Appearance',
'Secondary Subtitle Appearance',
'Subtitle Sidebar Appearance',
'Playback Behavior',
'Subtitle Behavior',
'Subtitle Sidebar Behavior',
'YouTube Playback Settings',
'mpv Playback',
'Note Fields',
'Media Capture',
'Kiku/Lapis Features',
'Anki AI',
'AnkiConnect',
'AnkiConnect Proxy',
'Jimaku',
'Subtitle Sync',
'MPV Keybindings',
'Overlay Shortcuts',
'Controller',
'Annotation WebSocket',
'WebSocket server',
'AniList',
'Character Dictionary',
'Discord Rich Presence',
'Jellyfin',
'Texthooker',
'Yomitan',
'Stats dashboard',
'Startup warmups',
'Logging',
'Updates',
'Immersion tracking',
].map((section, index) => [section, index]),
);
const PATH_ORDER = new Map<string, number>(
[
'ankiConnect.enabled',
'ankiConnect.proxy.enabled',
'ankiConnect.isLapis.enabled',
'ankiConnect.isKiku.enabled',
'subtitleStyle.fontColor',
'subtitleStyle.backgroundColor',
'subtitleStyle.hoverTokenColor',
'subtitleStyle.hoverTokenBackgroundColor',
'subtitleStyle.css',
'subtitleStyle.primaryDefaultMode',
'subtitleStyle.secondary.fontColor',
'subtitleStyle.secondary.backgroundColor',
'subtitleStyle.secondary.css',
'subtitleSidebar.css',
'secondarySub.defaultMode',
'secondarySub.secondarySubLanguages',
'mpv.autoStartSubMiner',
'auto_start_overlay',
'mpv.pauseUntilOverlayReady',
'mpv.socketPath',
'mpv.backend',
'mpv.subminerBinaryPath',
'mpv.aniskipEnabled',
'mpv.launchMode',
'mpv.executablePath',
'mpv.aniskipButtonKey',
].map((path, index) => [path, index]),
);
const SUBSECTION_ORDER = new Map<string, number>(
[
'Known Words',
'N+1',
'JLPT',
'Frequency Highlighting',
'Character Names',
'Mining & Clipboard',
'Toggle & Visibility',
'Open Panels',
'Playback',
'Default Fold State',
].map((subsection, index) => [subsection, index]),
);
const LABEL_OVERRIDES: Record<string, string> = {
'ankiConnect.knownWords.highlightEnabled': 'Enabled',
'ankiConnect.nPlusOne.enabled': 'Enabled',
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
'stats.toggleKey': 'Toggle Stats Overlay',
'shortcuts.openCharacterDictionary': 'Open AniList Override',
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
'subtitleStyle.css': 'CSS Declarations',
'subtitleStyle.secondary.css': 'CSS Declarations',
'subtitleSidebar.css': 'CSS Declarations',
'secondarySub.defaultMode': 'Secondary Subtitle Visibility Mode',
'subtitlePosition.yPercent': 'Subtitle Position',
'mpv.executablePath': 'mpv Executable Path',
'mpv.subminerBinaryPath': 'SubMiner Binary Path',
'mpv.socketPath': 'mpv IPC Socket Path',
'mpv.autoStartSubMiner': 'Auto-start SubMiner',
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
'mpv.aniskipEnabled': 'Enable AniSkip',
'mpv.aniskipButtonKey': 'AniSkip Button Key',
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
};
const DESCRIPTION_OVERRIDES: Record<string, string> = {
'ankiConnect.pollingRate':
'Polling interval in milliseconds. Ignored while the local AnkiConnect proxy is enabled because push-based enrichment is used instead.',
'ankiConnect.isKiku.enabled':
'Enable Kiku-specific mining behavior. Kiku supersedes Lapis: Lapis features still work, and Kiku adds duplicate handling and field grouping.',
'ankiConnect.isLapis.enabled':
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
'ankiConnect.isLapis.sentenceCardModel':
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
'subtitleStyle.css':
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
'subtitleStyle.secondary.css':
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
'subtitleSidebar.css':
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
'websocket.enabled':
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
'discordPresence.updateIntervalMs':
'Minimum interval between presence payload updates, in milliseconds.',
};
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
@@ -119,6 +291,10 @@ function flattenConfigLeaves(value: unknown, prefix = ''): Leaf[] {
}
function humanizePath(path: string): string {
const override = LABEL_OVERRIDES[path];
if (override) {
return override;
}
const key = path.split('.').at(-1) ?? path;
const spaced = key
.replace(/_/g, ' ')
@@ -138,7 +314,29 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
path === 'subtitleSidebar.pauseVideoOnHover'
) {
return { category: 'viewing', section: 'Playback pause behavior' };
return { category: 'behavior', section: 'Playback Behavior' };
}
if (path === 'subtitleStyle.preserveLineBreaks') {
return { category: 'behavior', section: 'Subtitle Behavior' };
}
if (
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
path === 'ankiConnect.knownWords.decks' ||
path === 'ankiConnect.knownWords.matchMode' ||
path === 'ankiConnect.knownWords.refreshMinutes'
) {
return { category: 'behavior', section: 'Known Words' };
}
if (path === 'ankiConnect.nPlusOne.minSentenceWords') {
return { category: 'behavior', section: 'N+1' };
}
if (
path === 'subtitleStyle.frequencyDictionary.matchMode' ||
path === 'subtitleStyle.frequencyDictionary.mode' ||
path === 'subtitleStyle.frequencyDictionary.sourcePath' ||
path === 'subtitleStyle.frequencyDictionary.topX'
) {
return { category: 'behavior', section: 'Frequency Highlighting' };
}
if (
path.startsWith('ankiConnect.knownWords.') ||
@@ -146,62 +344,87 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
path.startsWith('subtitleStyle.frequencyDictionary.') ||
path.startsWith('subtitleStyle.jlptColors.') ||
path === 'subtitleStyle.enableJlpt' ||
path === 'subtitleStyle.knownWordColor' ||
path === 'subtitleStyle.nPlusOneColor' ||
path === 'subtitleStyle.nameMatchEnabled' ||
path === 'subtitleStyle.nameMatchColor'
) {
return { category: 'viewing', section: 'Annotation display' };
return { category: 'appearance', section: 'Annotation Display' };
}
if (path.startsWith('subtitleStyle.secondary.')) {
return { category: 'viewing', section: 'Secondary subtitle appearance' };
return { category: 'appearance', section: 'Secondary Subtitle Appearance' };
}
if (path === 'subtitleStyle.primaryDefaultMode') {
return { category: 'behavior', section: 'Subtitle Behavior' };
}
if (path.startsWith('subtitleStyle.')) {
return { category: 'viewing', section: 'Primary subtitle appearance' };
return { category: 'appearance', section: 'Primary Subtitle Appearance' };
}
if (path.startsWith('subtitleSidebar.')) {
return { category: 'viewing', section: 'Subtitle sidebar' };
const sidebarBehaviorPaths = new Set([
'subtitleSidebar.enabled',
'subtitleSidebar.autoOpen',
'subtitleSidebar.autoScroll',
'subtitleSidebar.layout',
]);
return sidebarBehaviorPaths.has(path)
? { category: 'behavior', section: 'Subtitle Sidebar Behavior' }
: { category: 'appearance', section: 'Subtitle Sidebar Appearance' };
}
if (path.startsWith('subtitlePosition.') || path.startsWith('secondarySub.')) {
return { category: 'viewing', section: 'Subtitle behavior' };
return { category: 'behavior', section: 'Subtitle Behavior' };
}
if (path.startsWith('ankiConnect.fields.')) {
return { category: 'mining-anki', section: 'Note fields' };
return { category: 'mining-anki', section: 'Note Fields' };
}
if (path.startsWith('ankiConnect.media.')) {
return { category: 'mining-anki', section: 'Media capture' };
return { category: 'mining-anki', section: 'Media Capture' };
}
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
return { category: 'mining-anki', section: 'Kiku and Lapis' };
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
}
if (path.startsWith('ankiConnect.ai.')) {
return { category: 'mining-anki', section: 'Anki AI' };
}
if (path.startsWith('ankiConnect.proxy.')) {
return { category: 'mining-anki', section: 'AnkiConnect proxy' };
return { category: 'mining-anki', section: 'AnkiConnect Proxy' };
}
if (path.startsWith('ankiConnect.')) {
return { category: 'mining-anki', section: 'AnkiConnect' };
}
if (
path.startsWith('mpv.') ||
path.startsWith('youtube.') ||
path.startsWith('youtubeSubgen.') ||
path.startsWith('jimaku.') ||
path.startsWith('subsync.')
path === 'auto_start_overlay' ||
path === 'mpv.autoStartSubMiner' ||
path === 'mpv.pauseUntilOverlayReady'
) {
return { category: 'playback-sources', section: topSection(path) };
return { category: 'behavior', section: 'Playback Behavior' };
}
if (path === 'mpv.aniskipButtonKey') {
return { category: 'input', section: 'Overlay Shortcuts' };
}
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
return { category: 'behavior', section: topSection(path) };
}
if (path.startsWith('jimaku.')) {
return { category: 'integrations', section: topSection(path) };
}
if (path.startsWith('subsync.')) {
return { category: 'integrations', section: topSection(path) };
}
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
return { category: 'input', section: 'Overlay Shortcuts' };
}
if (path.startsWith('shortcuts.')) {
return { category: 'input', section: 'Overlay shortcuts' };
return { category: 'input', section: 'Overlay Shortcuts' };
}
if (path === 'keybindings') {
return { category: 'input', section: 'MPV keybindings' };
return { category: 'input', section: 'MPV Keybindings' };
}
if (path.startsWith('controller.')) {
return { category: 'input', section: 'Controller' };
}
if (
path.startsWith('ai.') ||
path.startsWith('anilist.') ||
path.startsWith('yomitan.') ||
path.startsWith('jellyfin.') ||
path.startsWith('discordPresence.') ||
@@ -211,13 +434,18 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
) {
return { category: 'integrations', section: topSection(path) };
}
if (path.startsWith('anilist.characterDictionary.')) {
return { category: 'integrations', section: 'Character Dictionary' };
}
if (path.startsWith('anilist.')) {
return { category: 'integrations', section: 'AniList' };
}
if (
path.startsWith('immersionTracking.') ||
path.startsWith('stats.') ||
path.startsWith('updates.') ||
path.startsWith('startupWarmups.') ||
path.startsWith('logging.') ||
path === 'auto_start_overlay'
path.startsWith('logging.')
) {
return { category: 'tracking-app', section: topSection(path) };
}
@@ -235,23 +463,40 @@ function topSection(path: string): string {
jimaku: 'Jimaku',
jellyfin: 'Jellyfin',
logging: 'Logging',
mpv: 'mpv launcher',
mpv: 'mpv Playback',
stats: 'Stats dashboard',
startupWarmups: 'Startup warmups',
subsync: 'Auto subtitle sync',
subsync: 'Subtitle Sync',
texthooker: 'Texthooker',
updates: 'Updates',
websocket: 'WebSocket server',
yomitan: 'Yomitan',
youtube: 'YouTube playback',
youtube: 'YouTube Playback Settings',
youtubeSubgen: 'YouTube subtitle generation',
auto_start_overlay: 'Overlay startup',
auto_start_overlay: 'Playback Behavior',
};
return labels[top] ?? humanizePath(top);
}
function controlForPath(path: string, value: unknown): ConfigSettingsControl {
if (SECRET_PATHS.has(path)) return 'secret';
if (getSubtitleCssScopeForPath(path)) return 'css-declarations';
if (path === 'keybindings') return 'mpv-keybindings';
if (path === 'ankiConnect.knownWords.decks') return 'known-words-decks';
if (path === 'ankiConnect.isLapis.sentenceCardModel') return 'anki-note-type';
if (path.startsWith('ankiConnect.fields.')) return 'anki-field';
if (path.startsWith('shortcuts.'))
return path.endsWith('multiCopyTimeoutMs') ? 'number' : 'keyboard-shortcut';
if (path === 'mpv.aniskipButtonKey') return 'mpv-key';
if (
path === 'subtitleSidebar.toggleKey' ||
path === 'stats.toggleKey' ||
path === 'stats.markWatchedKey'
) {
return 'key-code';
}
if (path.startsWith('subtitleStyle.jlptColors.')) return 'color';
if (path === 'subtitleStyle.frequencyDictionary.bandedColors') return 'color-list';
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
if (Array.isArray(value)) return 'string-list';
@@ -266,6 +511,132 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
return 'json';
}
function subsectionForPath(path: string): string | undefined {
if (path === 'ankiConnect.knownWords.highlightEnabled') return 'Known Words';
if (path === 'ankiConnect.nPlusOne.enabled') return 'N+1';
if (path === 'subtitleStyle.knownWordColor') return 'Known Words';
if (path === 'subtitleStyle.nPlusOneColor') return 'N+1';
if (path === 'subtitleStyle.enableJlpt' || path.startsWith('subtitleStyle.jlptColors.')) {
return 'JLPT';
}
if (
path === 'subtitleStyle.frequencyDictionary.enabled' ||
path === 'subtitleStyle.frequencyDictionary.singleColor' ||
path === 'subtitleStyle.frequencyDictionary.bandedColors'
) {
return 'Frequency Highlighting';
}
if (path === 'subtitleStyle.nameMatchEnabled' || path === 'subtitleStyle.nameMatchColor') {
return 'Character Names';
}
if (path === 'anilist.characterDictionary.collapsibleSections.description') {
return 'Default Fold State';
}
if (path === 'anilist.characterDictionary.collapsibleSections.characterInformation') {
return 'Default Fold State';
}
if (path === 'anilist.characterDictionary.collapsibleSections.voicedBy') {
return 'Default Fold State';
}
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
return 'Toggle & Visibility';
}
if (path === 'mpv.aniskipButtonKey') {
return 'Playback';
}
if (path.startsWith('shortcuts.')) {
const leaf = path.split('.').at(-1) ?? '';
if (
leaf === 'copySubtitle' ||
leaf === 'copySubtitleMultiple' ||
leaf === 'mineSentence' ||
leaf === 'mineSentenceMultiple' ||
leaf === 'updateLastCardFromClipboard' ||
leaf === 'triggerFieldGrouping' ||
leaf === 'markAudioCard'
) {
return 'Mining & Clipboard';
}
if (
leaf === 'toggleVisibleOverlayGlobal' ||
leaf === 'toggleSubtitleSidebar' ||
leaf === 'toggleSecondarySub' ||
leaf === 'toggleStatsOverlay' ||
leaf === 'markWatched'
) {
return 'Toggle & Visibility';
}
if (
leaf === 'openCharacterDictionary' ||
leaf === 'openRuntimeOptions' ||
leaf === 'openJimaku' ||
leaf === 'openSessionHelp' ||
leaf === 'openControllerSelect' ||
leaf === 'openControllerDebug'
) {
return 'Open Panels';
}
if (leaf === 'triggerSubsync') return 'Playback';
return undefined;
}
return undefined;
}
function isFeatureToggle(field: ConfigSettingsField): boolean {
if (field.control !== 'boolean') return false;
const leaf = field.configPath.split('.').at(-1) ?? field.configPath;
return (
leaf === 'enabled' ||
leaf.startsWith('enable') ||
leaf.endsWith('Enabled') ||
field.label.startsWith('Enable ')
);
}
function fieldTypeRank(field: ConfigSettingsField): number {
if (field.control !== 'boolean') return 2;
return isFeatureToggle(field) ? 0 : 1;
}
function compareFields(a: ConfigSettingsField, b: ConfigSettingsField): number {
const category = CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category);
if (category !== 0) return category;
const section =
(SECTION_ORDER.get(a.section) ?? Number.MAX_SAFE_INTEGER) -
(SECTION_ORDER.get(b.section) ?? Number.MAX_SAFE_INTEGER);
if (section !== 0) return section;
const sectionName = a.section.localeCompare(b.section);
if (sectionName !== 0) return sectionName;
const aSubOrder =
a.subsection === undefined
? -1
: (SUBSECTION_ORDER.get(a.subsection) ?? Number.MAX_SAFE_INTEGER);
const bSubOrder =
b.subsection === undefined
? -1
: (SUBSECTION_ORDER.get(b.subsection) ?? Number.MAX_SAFE_INTEGER);
const subsection = aSubOrder - bSubOrder;
if (subsection !== 0) return subsection;
const subsectionName = (a.subsection ?? '').localeCompare(b.subsection ?? '');
if (subsectionName !== 0) return subsectionName;
const type = fieldTypeRank(a) - fieldTypeRank(b);
if (type !== 0) return type;
const pathOrder =
(PATH_ORDER.get(a.configPath) ?? Number.MAX_SAFE_INTEGER) -
(PATH_ORDER.get(b.configPath) ?? Number.MAX_SAFE_INTEGER);
if (pathOrder !== 0) return pathOrder;
const label = a.label.localeCompare(b.label);
if (label !== 0) return label;
return a.configPath.localeCompare(b.configPath);
}
function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
if (
path === 'keybindings' ||
@@ -273,7 +644,29 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
pathStartsWith(path, 'subtitleStyle') ||
pathStartsWith(path, 'subtitleSidebar') ||
path === 'secondarySub.defaultMode' ||
pathStartsWith(path, 'ankiConnect.ai')
path === 'ankiConnect.ai.enabled' ||
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
path === 'ankiConnect.knownWords.highlightEnabled' ||
path === 'ankiConnect.knownWords.refreshMinutes' ||
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
path === 'ankiConnect.knownWords.matchMode' ||
path === 'ankiConnect.knownWords.decks' ||
path === 'ankiConnect.nPlusOne.enabled' ||
path === 'ankiConnect.nPlusOne.minSentenceWords' ||
path === 'ankiConnect.fields.word' ||
path === 'ankiConnect.fields.audio' ||
path === 'ankiConnect.fields.image' ||
path === 'ankiConnect.fields.sentence' ||
path === 'ankiConnect.fields.miscInfo' ||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
path === 'ankiConnect.isKiku.fieldGrouping' ||
path === 'mpv.aniskipButtonKey' ||
path === 'stats.toggleKey' ||
path === 'stats.markWatchedKey' ||
path === 'logging.level' ||
path === 'youtube.primarySubLanguages' ||
pathStartsWith(path, 'jimaku') ||
pathStartsWith(path, 'subsync')
) {
return 'hot-reload';
}
@@ -283,13 +676,15 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
const option = OPTION_BY_PATH.get(leaf.path);
const { category, section } = categoryAndSection(leaf.path);
const description = DESCRIPTION_OVERRIDES[leaf.path] ?? option?.description;
return {
id: leaf.path,
label: option?.path === leaf.path ? humanizePath(leaf.path) : humanizePath(leaf.path),
description: option?.description ?? `${humanizePath(leaf.path)} setting.`,
description: description ?? `${humanizePath(leaf.path)} setting.`,
configPath: leaf.path,
category,
section,
...(subsectionForPath(leaf.path) ? { subsection: subsectionForPath(leaf.path) } : {}),
control: controlForPath(leaf.path, leaf.value),
defaultValue: leaf.value,
...(option?.enumValues ? { enumValues: option.enumValues } : {}),
@@ -299,6 +694,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
leaf.path.startsWith('immersionTracking.retention.') ||
leaf.path.startsWith('youtubeSubgen.'),
secret: SECRET_PATHS.has(leaf.path),
settingsHidden: SUBTITLE_CSS_MANAGED_CONFIG_PATHS.has(leaf.path),
};
}
@@ -306,13 +702,7 @@ export function buildConfigSettingsRegistry(
defaultConfig: ResolvedConfig = DEFAULT_CONFIG,
): ConfigSettingsField[] {
const leaves = flattenConfigLeaves(defaultConfig).filter((leaf) => !isLegacyHidden(leaf.path));
return leaves.map(fieldForLeaf).sort((a, b) => {
const category = a.category.localeCompare(b.category);
if (category !== 0) return category;
const section = a.section.localeCompare(b.section);
if (section !== 0) return section;
return a.configPath.localeCompare(b.configPath);
});
return leaves.map(fieldForLeaf).sort(compareFields);
}
export function getConfigSettingsCoverage(
+129
View File
@@ -0,0 +1,129 @@
import type { RawConfig } from '../types/config';
import type { ConfigSettingsPatchOperation } from '../types/settings';
import {
buildSubtitleCssDeclarationObject,
getSubtitleCssManagedConfigPaths,
getSubtitleCssPath,
type SubtitleCssScope,
} from '../settings/subtitle-style-css';
import { applyConfigSettingsPatchToContent } from './settings/jsonc-edit';
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
export type LegacySubtitleStyleCssMigrationResult =
| {
migrated: true;
content: string;
rawConfig: RawConfig;
}
| {
migrated: false;
content: string;
rawConfig: RawConfig;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function getValueAtPath(root: unknown, path: string): unknown {
let current = root;
for (const segment of path.split('.')) {
if (!isRecord(current)) return undefined;
current = current[segment];
}
return current;
}
function hasPath(root: unknown, path: string): boolean {
let current = root;
const segments = path.split('.');
for (const [index, segment] of segments.entries()) {
if (!isRecord(current) || !Object.hasOwn(current, segment)) {
return false;
}
if (index === segments.length - 1) {
return true;
}
current = current[segment];
}
return false;
}
function isMigratableLegacySubtitleCssValue(path: string, value: unknown): boolean {
if (path === 'subtitleStyle.hoverTokenColor') {
return typeof value === 'string' && HEX_COLOR_PATTERN.test(value.trim());
}
if (path === 'subtitleStyle.hoverTokenBackgroundColor') {
return typeof value === 'string';
}
return true;
}
export function buildLegacySubtitleStyleCssMigrationOperations(
rawConfig: RawConfig,
): ConfigSettingsPatchOperation[] {
const operations: ConfigSettingsPatchOperation[] = [];
for (const scope of SUBTITLE_CSS_SCOPES) {
const cssPath = getSubtitleCssPath(scope);
const values: Record<string, unknown> = {
[cssPath]: getValueAtPath(rawConfig, cssPath),
};
const legacyPaths = getSubtitleCssManagedConfigPaths(scope).filter(
(legacyPath) =>
hasPath(rawConfig, legacyPath) &&
isMigratableLegacySubtitleCssValue(legacyPath, getValueAtPath(rawConfig, legacyPath)),
);
if (legacyPaths.length === 0) continue;
for (const legacyPath of legacyPaths) {
values[legacyPath] = getValueAtPath(rawConfig, legacyPath);
}
operations.push({
op: 'set',
path: cssPath,
value: buildSubtitleCssDeclarationObject(scope, values),
});
for (const legacyPath of legacyPaths) {
operations.push({ op: 'reset', path: legacyPath });
}
}
return operations;
}
export function applyLegacySubtitleStyleCssMigrationToContent(options: {
content: string;
rawConfig: RawConfig;
}): LegacySubtitleStyleCssMigrationResult {
const operations = buildLegacySubtitleStyleCssMigrationOperations(options.rawConfig);
if (operations.length === 0) {
return {
migrated: false,
content: options.content,
rawConfig: options.rawConfig,
};
}
const result = applyConfigSettingsPatchToContent({
content: options.content,
operations,
previousWarnings: [],
});
if (!result.ok) {
return {
migrated: false,
content: options.content,
rawConfig: options.rawConfig,
};
}
return {
migrated: true,
content: result.content,
rawConfig: result.rawConfig,
};
}
+66
View File
@@ -6,11 +6,18 @@ import {
DEFAULT_KEYBINDINGS,
deepCloneConfig,
} from './definitions';
import {
buildSubtitleCssDeclarationObject,
getSubtitleCssManagedConfigPaths,
getSubtitleCssPath,
type SubtitleCssScope,
} from '../settings/subtitle-style-css';
const OPTION_REGISTRY_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
const TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY = new Map(
CONFIG_TEMPLATE_SECTIONS.map((section) => [String(section.key), section.description[0] ?? '']),
);
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
function normalizeCommentText(value: string): string {
return value.replace(/\s+/g, ' ').replace(/\*\//g, '*\\/').trim();
@@ -18,7 +25,9 @@ function normalizeCommentText(value: string): string {
function humanizeKey(key: string): string {
const spaced = key
.replace(/^--/, '')
.replace(/_/g, ' ')
.replace(/-/g, ' ')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.toLowerCase();
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
@@ -42,6 +51,62 @@ function buildInlineOptionComment(path: string, value: unknown): string {
return description;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function getValueAtPath(root: unknown, path: string): unknown {
let current = root;
for (const segment of path.split('.')) {
if (!isRecord(current)) return undefined;
current = current[segment];
}
return current;
}
function setValueAtPath(root: unknown, path: string, value: unknown): void {
const segments = path.split('.').filter(Boolean);
let current = root;
for (const [index, segment] of segments.entries()) {
if (!isRecord(current)) return;
if (index === segments.length - 1) {
current[segment] = value;
return;
}
current = current[segment];
}
}
function deleteValueAtPath(root: unknown, path: string): void {
const segments = path.split('.').filter(Boolean);
let current = root;
for (const [index, segment] of segments.entries()) {
if (!isRecord(current)) return;
if (index === segments.length - 1) {
delete current[segment];
return;
}
current = current[segment];
}
}
function foldSubtitleCssManagedDefaults(templateConfig: ResolvedConfig): void {
for (const scope of SUBTITLE_CSS_SCOPES) {
const cssPath = getSubtitleCssPath(scope);
const values: Record<string, unknown> = {
[cssPath]: getValueAtPath(templateConfig, cssPath),
};
const managedPaths = getSubtitleCssManagedConfigPaths(scope);
for (const managedPath of managedPaths) {
values[managedPath] = getValueAtPath(templateConfig, managedPath);
}
setValueAtPath(templateConfig, cssPath, buildSubtitleCssDeclarationObject(scope, values));
for (const managedPath of managedPaths) {
deleteValueAtPath(templateConfig, managedPath);
}
}
}
function renderValue(value: unknown, indent = 0, path = ''): string {
const pad = ' '.repeat(indent);
const nextPad = ' '.repeat(indent + 2);
@@ -106,6 +171,7 @@ function renderSection(
function createTemplateConfig(config: ResolvedConfig): ResolvedConfig {
const templateConfig = deepCloneConfig(config);
foldSubtitleCssManagedDefaults(templateConfig);
if (templateConfig.keybindings.length === 0) {
templateConfig.keybindings = DEFAULT_KEYBINDINGS.map((binding) => ({
key: binding.key,
+182 -1
View File
@@ -14,8 +14,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -32,6 +32,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
markWatched: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
@@ -69,6 +70,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
texthooker: false,
texthookerOpenBrowser: false,
help: false,
appPing: false,
autoStartOverlay: false,
generateConfig: false,
backupOverwrite: false,
@@ -91,6 +93,9 @@ function createDeps(overrides: Partial<AppLifecycleServiceDeps> = {}) {
quitApp: () => {
calls.push('quitApp');
},
exitApp: (code) => {
calls.push(`exit:${code}`);
},
onSecondInstance: () => {},
handleCliCommand: () => {},
printHelp: () => {
@@ -136,3 +141,179 @@ test('startAppLifecycle still acquires lock for startup commands', () => {
assert.equal(getLockCalls(), 1);
});
test('startAppLifecycle app ping exits non-zero immediately when no running instance owns the lock', () => {
const { deps, calls, getLockCalls } = createDeps({
shouldStartApp: () => false,
});
startAppLifecycle(makeArgs({ appPing: true }), deps);
assert.equal(getLockCalls(), 1);
assert.deepEqual(calls, ['exit:1']);
});
test('startAppLifecycle app ping exits zero immediately when another instance owns the lock', () => {
let lockCalls = 0;
const { deps, calls } = createDeps({
shouldStartApp: () => false,
requestSingleInstanceLock: () => {
lockCalls += 1;
return false;
},
});
startAppLifecycle(makeArgs({ appPing: true }), deps);
assert.equal(lockCalls, 1);
assert.deepEqual(calls, ['exit:0']);
});
test('startAppLifecycle queues second-instance commands until app ready runtime completes', async () => {
const handled: string[] = [];
let secondInstanceHandler: ((_event: unknown, argv: string[]) => void) | null = null;
let readyHandler: (() => Promise<void>) | null = null;
let releaseReady: (() => void) | null = null;
const readyFinished = new Promise<void>((resolve) => {
releaseReady = resolve;
});
const { deps } = createDeps({
shouldStartApp: () => true,
onSecondInstance: (handler) => {
secondInstanceHandler = handler;
},
parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }),
handleCliCommand: (args, source) => {
handled.push(`${source}:${args.start ? 'start' : 'other'}`);
},
whenReady: (handler) => {
readyHandler = handler;
},
onReady: async () => {
await readyFinished;
handled.push('ready');
},
});
startAppLifecycle(makeArgs({ background: true }), deps);
const runSecondInstance = (argv: string[]) => {
assert.ok(secondInstanceHandler);
(secondInstanceHandler as (_event: unknown, argv: string[]) => void)({}, argv);
};
const runReady = () => {
assert.ok(readyHandler);
return (readyHandler as () => Promise<void>)();
};
runSecondInstance(['SubMiner', '--start']);
assert.deepEqual(handled, []);
const readyRun = runReady();
await Promise.resolve();
assert.deepEqual(handled, []);
assert.ok(releaseReady);
(releaseReady as () => void)();
await readyRun;
assert.deepEqual(handled, ['ready', 'second-instance:start']);
runSecondInstance(['SubMiner', '--start']);
assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']);
});
test('startAppLifecycle routes control socket commands through the second-instance queue', async () => {
const handled: string[] = [];
let controlArgvHandler: ((argv: string[]) => void) | null = null;
let readyHandler: (() => Promise<void>) | null = null;
let releaseReady: (() => void) | null = null;
const readyFinished = new Promise<void>((resolve) => {
releaseReady = resolve;
});
const { deps } = createDeps({
shouldStartApp: () => true,
parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }),
handleCliCommand: (args, source) => {
handled.push(`${source}:${args.start ? 'start' : 'other'}`);
},
startControlServer: (handler) => {
controlArgvHandler = handler;
return () => {
handled.push('control-close');
};
},
whenReady: (handler) => {
readyHandler = handler;
},
onReady: async () => {
await readyFinished;
handled.push('ready');
},
});
let willQuitHandler: (() => void) | null = null;
deps.onWillQuit = (handler) => {
willQuitHandler = handler;
};
startAppLifecycle(makeArgs({ background: true }), deps);
assert.ok(controlArgvHandler);
(controlArgvHandler as (argv: string[]) => void)(['--start']);
assert.deepEqual(handled, []);
assert.ok(readyHandler);
const readyRun = (readyHandler as () => Promise<void>)();
await Promise.resolve();
assert.deepEqual(handled, []);
assert.ok(releaseReady);
(releaseReady as () => void)();
await readyRun;
assert.deepEqual(handled, ['ready', 'second-instance:start']);
assert.ok(willQuitHandler);
(willQuitHandler as () => void)();
assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']);
});
test('startAppLifecycle quits macOS config-only launch when all windows close', () => {
let windowAllClosedHandler: (() => void) | null = null;
const { deps, calls } = createDeps({
shouldStartApp: () => true,
isDarwinPlatform: () => true,
shouldQuitOnWindowAllClosed: () => true,
onWindowAllClosed: (handler) => {
windowAllClosedHandler = handler;
},
});
startAppLifecycle(makeArgs({ settings: true }), deps);
const handler = windowAllClosedHandler as (() => void) | null;
assert.ok(handler);
handler();
assert.deepEqual(calls, ['quitApp']);
});
test('startAppLifecycle quits macOS setup-only launch when all windows close', () => {
let windowAllClosedHandler: (() => void) | null = null;
const { deps, calls } = createDeps({
shouldStartApp: () => true,
isDarwinPlatform: () => true,
shouldQuitOnWindowAllClosed: () => true,
onWindowAllClosed: (handler) => {
windowAllClosedHandler = handler;
},
});
startAppLifecycle(makeArgs({ setup: true }), deps);
const handler = windowAllClosedHandler as (() => void) | null;
assert.ok(handler);
handler();
assert.deepEqual(calls, ['quitApp']);
});
+64 -3
View File
@@ -8,10 +8,12 @@ export interface AppLifecycleServiceDeps {
parseArgs: (argv: string[]) => CliArgs;
requestSingleInstanceLock: () => boolean;
quitApp: () => void;
exitApp: (code: number) => void;
onSecondInstance: (handler: (_event: unknown, argv: string[]) => void) => void;
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
whenReady: (handler: () => Promise<void>) => void;
onWindowAllClosed: (handler: () => void) => void;
onWillQuit: (handler: () => void) => void;
@@ -27,6 +29,7 @@ export interface AppLifecycleServiceDeps {
interface AppLike {
requestSingleInstanceLock: () => boolean;
quit: () => void;
exit?: (exitCode?: number) => void;
on: (...args: any[]) => unknown;
whenReady: () => Promise<void>;
}
@@ -39,6 +42,7 @@ export interface AppLifecycleDepsRuntimeOptions {
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
@@ -54,12 +58,21 @@ export function createAppLifecycleDepsRuntime(
parseArgs: options.parseArgs,
requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(),
quitApp: () => options.app.quit(),
exitApp: (code) => {
if (options.app.exit) {
options.app.exit(code);
return;
}
process.exitCode = code;
options.app.quit();
},
onSecondInstance: (handler) => {
options.app.on('second-instance', handler as (...args: unknown[]) => void);
},
handleCliCommand: options.handleCliCommand,
printHelp: options.printHelp,
logNoRunningInstance: options.logNoRunningInstance,
startControlServer: options.startControlServer,
whenReady: (handler) => {
options.app
.whenReady()
@@ -94,17 +107,52 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
}
const gotTheLock = deps.requestSingleInstanceLock();
if (initialArgs.appPing) {
deps.exitApp(gotTheLock ? 1 : 0);
return;
}
if (!gotTheLock) {
deps.quitApp();
return;
}
deps.onSecondInstance((_event, argv) => {
let appReadyRuntimeComplete = false;
const pendingSecondInstanceCommands: CliArgs[] = [];
let stopControlServer: (() => void) | null = null;
const handleSecondInstanceCommand = (args: CliArgs): void => {
try {
deps.handleCliCommand(deps.parseArgs(argv), 'second-instance');
deps.handleCliCommand(args, 'second-instance');
} catch (error) {
logger.error('Failed to handle second-instance CLI command:', error);
}
};
const flushPendingSecondInstanceCommands = (): void => {
while (pendingSecondInstanceCommands.length > 0) {
const nextArgs = pendingSecondInstanceCommands.shift();
if (nextArgs) {
handleSecondInstanceCommand(nextArgs);
}
}
};
const dispatchSecondInstanceArgv = (argv: string[]): void => {
try {
const nextArgs = deps.parseArgs(argv);
if (!appReadyRuntimeComplete) {
pendingSecondInstanceCommands.push(nextArgs);
return;
}
handleSecondInstanceCommand(nextArgs);
} catch (error) {
logger.error('Failed to handle second-instance CLI command:', error);
}
};
deps.onSecondInstance((_event, argv) => {
dispatchSecondInstanceArgv(argv);
});
if (!deps.shouldStartApp(initialArgs)) {
@@ -117,17 +165,30 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
return;
}
try {
stopControlServer = deps.startControlServer?.(dispatchSecondInstanceArgv) ?? null;
} catch (error) {
logger.error('Failed to start app control socket:', error);
}
deps.whenReady(async () => {
await deps.onReady();
appReadyRuntimeComplete = true;
flushPendingSecondInstanceCommands();
});
deps.onWindowAllClosed(() => {
if (!deps.isDarwinPlatform() && deps.shouldQuitOnWindowAllClosed()) {
if (
deps.shouldQuitOnWindowAllClosed() &&
(!deps.isDarwinPlatform() || initialArgs.settings || initialArgs.setup)
) {
deps.quitApp();
}
});
deps.onWillQuit(() => {
stopControlServer?.();
stopControlServer = null;
deps.onWillQuitCleanup();
});
+152 -4
View File
@@ -1,7 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CliArgs } from '../../cli/args';
import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
import {
CliCommandServiceDeps,
createCliCommandDepsRuntime,
handleCliCommand,
} from './cli-command';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
@@ -15,8 +19,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
stop: false,
toggle: false,
toggleVisibleOverlay: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -32,6 +36,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
markWatched: false,
toggleSubtitleSidebar: false,
refreshKnownWords: false,
openRuntimeOptions: false,
@@ -500,6 +505,132 @@ test('handleCliCommand applies socket path and connects on start', () => {
assert.ok(calls.includes('connectMpvClient'));
});
test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exists', () => {
const calls: string[] = [];
const client = {
setSocketPath: (socketPath: string) => {
calls.push(`setSocketPath:${socketPath}`);
},
connect: () => {
calls.push('connect');
},
reconnect: () => {
calls.push('reconnect');
},
};
const deps = createCliCommandDepsRuntime({
mpv: {
getSocketPath: () => '/tmp/runtime.sock',
setSocketPath: () => {},
getClient: () => client,
showOsd: () => {},
},
texthooker: {
service: { isRunning: () => false, start: () => {} },
getPort: () => 5174,
setPort: () => {},
getWebsocketUrl: () => undefined,
shouldOpenBrowser: () => false,
openInBrowser: () => {},
},
overlay: {
isInitialized: () => true,
initialize: () => {},
toggleVisible: () => {},
togglePrimarySubtitleBar: () => {},
setVisible: () => {},
},
mining: {
copyCurrentSubtitle: () => {},
startPendingMultiCopy: () => {},
mineSentenceCard: async () => {},
startPendingMineSentenceMultiple: () => {},
updateLastCardFromClipboard: async () => {},
refreshKnownWords: async () => {},
triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {},
},
anilist: {
getStatus: () => ({
tokenStatus: 'not_checked',
tokenSource: 'none',
tokenMessage: null,
tokenResolvedAt: null,
tokenErrorAt: null,
queuePending: 0,
queueReady: 0,
queueDeadLetter: 0,
queueLastAttemptAt: null,
queueLastError: null,
}),
clearToken: () => {},
openSetup: () => {},
getQueueStatus: () => ({
pending: 0,
ready: 0,
deadLetter: 0,
lastAttemptAt: null,
lastError: null,
}),
retryQueueNow: async () => ({ ok: true, message: 'ok' }),
},
dictionary: {
generate: async () => ({
zipPath: '/tmp/test.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 0,
}),
getSelection: async () => ({
seriesKey: 'test',
guessTitle: null,
current: null,
override: null,
candidates: [],
}),
setSelection: async () => ({
ok: true,
seriesKey: 'test',
selected: { id: 1, title: 'Test', episodes: null },
staleMediaIds: [],
}),
},
jellyfin: {
openSetup: () => {},
runStatsCommand: async () => {},
runCommand: async () => {},
},
ui: {
openFirstRunSetup: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
},
app: {
stop: () => {},
hasMainWindow: () => true,
runUpdateCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
},
dispatchSessionAction: async () => {},
getMultiCopyTimeoutMs: () => 2500,
schedule: () => undefined,
log: () => {},
logDebug: () => {},
warn: () => {},
error: () => {},
});
deps.setMpvClientSocketPath('/tmp/runtime.sock');
deps.connectMpvClient();
assert.deepEqual(calls, ['setSocketPath:/tmp/runtime.sock', 'reconnect']);
});
test('handleCliCommand warns when texthooker port override used while running', () => {
const { deps, calls } = createDeps({
isTexthookerRunning: () => true,
@@ -585,8 +716,8 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
args: Partial<CliArgs>;
expected: string;
}> = [
{ args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' },
{ args: { configSettings: true }, expected: 'openConfigSettingsWindow' },
{ args: { yomitan: true }, expected: 'openYomitanSettingsDelayed:1000' },
{ args: { settings: true }, expected: 'openConfigSettingsWindow' },
{
args: { showVisibleOverlay: true },
expected: 'setVisibleOverlayVisible:true',
@@ -607,6 +738,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
{ args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
{ args: { togglePrimarySubtitleBar: true }, expected: 'togglePrimarySubtitleBar' },
{ args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' },
{ args: { markWatched: true }, expected: 'dispatchSessionAction' },
{
args: { openRuntimeOptions: true },
expected: 'openRuntimeOptionsPalette',
@@ -653,6 +785,22 @@ test('handleCliCommand dispatches cycle-runtime-option session action', async ()
});
});
test('handleCliCommand dispatches mark-watched session action', async () => {
let request: unknown = null;
const { deps } = createDeps({
dispatchSessionAction: async (nextRequest) => {
request = nextRequest;
},
});
handleCliCommand(makeArgs({ markWatched: true }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(request, {
actionId: 'markWatched',
});
});
test('handleCliCommand logs AniList status details', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
+9 -2
View File
@@ -115,6 +115,7 @@ export interface CliCommandServiceDeps {
interface MpvClientLike {
setSocketPath: (socketPath: string) => void;
connect: () => void;
reconnect?: () => void;
}
interface TexthookerServiceLike {
@@ -235,6 +236,10 @@ export function createCliCommandDepsRuntime(
connectMpvClient: () => {
const client = options.mpv.getClient();
if (!client) return;
if (client.reconnect) {
client.reconnect();
return;
}
client.connect();
},
isTexthookerRunning: () => options.texthooker.service.isRunning(),
@@ -386,9 +391,9 @@ export function handleCliCommand(
} else if (args.setup) {
deps.openFirstRunSetup(true);
deps.logDebug('Opened first-run setup flow.');
} else if (args.settings) {
} else if (args.yomitan) {
deps.openYomitanSettingsDelayed(1000);
} else if (args.configSettings) {
} else if (args.settings) {
deps.openConfigSettingsWindow();
} else if (args.show || args.showVisibleOverlay) {
deps.setVisibleOverlayVisible(true);
@@ -469,6 +474,8 @@ export function handleCliCommand(
'toggleStatsOverlay',
'Stats toggle failed',
);
} else if (args.markWatched) {
dispatchCliSessionAction({ actionId: 'markWatched' }, 'markWatched', 'Mark watched failed');
} else if (args.toggleSubtitleSidebar) {
dispatchCliSessionAction(
{ actionId: 'toggleSubtitleSidebar' },
@@ -18,6 +18,80 @@ test('classifyConfigHotReloadDiff separates hot and restart-required fields', ()
assert.deepEqual(diff.restartRequiredFields, ['websocket']);
});
test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloadable', () => {
const prev = deepCloneConfig(DEFAULT_CONFIG);
const next = deepCloneConfig(DEFAULT_CONFIG);
next.mpv.aniskipButtonKey = 'F8';
next.stats.toggleKey = 'F8';
next.stats.markWatchedKey = 'F9';
next.logging.level = 'debug';
next.youtube.primarySubLanguages = ['ja', 'en'];
next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1;
next.subsync.replace = !prev.subsync.replace;
next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards;
next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5;
next.ankiConnect.knownWords.addMinedWordsImmediately =
!prev.ankiConnect.knownWords.addMinedWordsImmediately;
next.ankiConnect.knownWords.matchMode =
prev.ankiConnect.knownWords.matchMode === 'headword' ? 'surface' : 'headword';
next.ankiConnect.knownWords.decks = { Anime: ['Mining'] };
next.ankiConnect.nPlusOne.enabled = !prev.ankiConnect.nPlusOne.enabled;
next.ankiConnect.nPlusOne.minSentenceWords = prev.ankiConnect.nPlusOne.minSentenceWords + 1;
next.ankiConnect.fields.word = 'Vocabulary';
next.ankiConnect.fields.audio = 'SentenceAudioCustom';
next.ankiConnect.fields.image = 'ScreenshotCustom';
next.ankiConnect.fields.sentence = 'SentenceCustom';
next.ankiConnect.fields.miscInfo = 'MiscInfoCustom';
next.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom';
next.ankiConnect.isKiku.fieldGrouping =
prev.ankiConnect.isKiku.fieldGrouping === 'auto' ? 'manual' : 'auto';
const diff = classifyConfigHotReloadDiff(prev, next);
assert.deepEqual(
new Set(diff.hotReloadFields),
new Set([
'stats.toggleKey',
'mpv.aniskipButtonKey',
'stats.markWatchedKey',
'logging.level',
'youtube.primarySubLanguages',
'jimaku.maxEntryResults',
'subsync.replace',
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.knownWords.addMinedWordsImmediately',
'ankiConnect.knownWords.matchMode',
'ankiConnect.knownWords.decks',
'ankiConnect.nPlusOne.enabled',
'ankiConnect.nPlusOne.minSentenceWords',
'ankiConnect.fields.word',
'ankiConnect.fields.audio',
'ankiConnect.fields.image',
'ankiConnect.fields.sentence',
'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
]),
);
assert.deepEqual(diff.restartRequiredFields, []);
});
test('classifyConfigHotReloadDiff keeps unsafe nested siblings restart-required', () => {
const prev = deepCloneConfig(DEFAULT_CONFIG);
const next = deepCloneConfig(DEFAULT_CONFIG);
next.stats.serverPort = prev.stats.serverPort + 1;
next.ankiConnect.url = 'http://127.0.0.1:9999';
next.ankiConnect.ai.model = 'openrouter/new-model';
const diff = classifyConfigHotReloadDiff(prev, next);
assert.deepEqual(diff.hotReloadFields, []);
assert.deepEqual(diff.restartRequiredFields, ['ankiConnect', 'stats']);
});
test('config hot reload runtime debounces rapid watch events', () => {
let watchedChangeCallback: (() => void) | null = null;
const pendingTimers = new Map<number, () => void>();
+81 -44
View File
@@ -29,27 +29,85 @@ function isEqual(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function pathStartsWith(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}.`);
}
function collectChangedPaths(prev: unknown, next: unknown, prefix = ''): string[] {
if (isEqual(prev, next)) {
return [];
}
if (!isRecord(prev) || !isRecord(next)) {
return prefix ? [prefix] : [];
}
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
return [...keys].flatMap((key) =>
collectChangedPaths(prev[key], next[key], prefix ? `${prefix}.${key}` : key),
);
}
const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitleSidebar'] as const;
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'secondarySub.defaultMode',
'mpv.aniskipButtonKey',
'ankiConnect.ai.enabled',
'stats.toggleKey',
'stats.markWatchedKey',
'logging.level',
'youtube.primarySubLanguages',
'jimaku',
'subsync',
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.knownWords.addMinedWordsImmediately',
'ankiConnect.knownWords.matchMode',
'ankiConnect.knownWords.decks',
'ankiConnect.nPlusOne.enabled',
'ankiConnect.nPlusOne.minSentenceWords',
'ankiConnect.fields.word',
'ankiConnect.fields.audio',
'ankiConnect.fields.image',
'ankiConnect.fields.sentence',
'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
] as const;
function hotReloadFieldForChangedPath(path: string): string | null {
for (const root of HOT_RELOAD_ROOTS) {
if (pathStartsWith(path, root)) {
return root;
}
}
for (const hotPath of HOT_RELOAD_EXACT_OR_PREFIX_PATHS) {
if (pathStartsWith(path, hotPath)) {
return hotPath === 'jimaku' || hotPath === 'subsync' ? path : hotPath;
}
}
return null;
}
function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotReloadDiff {
const hotReloadFields: string[] = [];
const restartRequiredFields: string[] = [];
const hotReloadFieldSet = new Set<string>();
const changedPaths = collectChangedPaths(prev, next);
if (!isEqual(prev.subtitleStyle, next.subtitleStyle)) {
hotReloadFields.push('subtitleStyle');
}
if (!isEqual(prev.keybindings, next.keybindings)) {
hotReloadFields.push('keybindings');
}
if (!isEqual(prev.shortcuts, next.shortcuts)) {
hotReloadFields.push('shortcuts');
}
if (!isEqual(prev.subtitleSidebar, next.subtitleSidebar)) {
hotReloadFields.push('subtitleSidebar');
}
if (prev.secondarySub.defaultMode !== next.secondarySub.defaultMode) {
hotReloadFields.push('secondarySub.defaultMode');
}
if (!isEqual(prev.ankiConnect.ai, next.ankiConnect.ai)) {
hotReloadFields.push('ankiConnect.ai');
for (const path of changedPaths) {
const hotReloadField = hotReloadFieldForChangedPath(path);
if (hotReloadField) {
hotReloadFieldSet.add(hotReloadField);
}
}
const keys = new Set([
@@ -67,37 +125,16 @@ function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotRelo
continue;
}
if (key === 'secondarySub') {
const normalizedPrev = {
...prev.secondarySub,
defaultMode: next.secondarySub.defaultMode,
};
if (!isEqual(normalizedPrev, next.secondarySub)) {
restartRequiredFields.push('secondarySub');
}
continue;
}
if (key === 'ankiConnect') {
const normalizedPrev = {
...prev.ankiConnect,
ai: {
enabled: next.ankiConnect.ai.enabled,
model: prev.ankiConnect.ai.model,
systemPrompt: prev.ankiConnect.ai.systemPrompt,
},
};
if (!isEqual(normalizedPrev, next.ankiConnect)) {
restartRequiredFields.push('ankiConnect');
}
continue;
}
if (!isEqual(prev[key], next[key])) {
const changedPathsForKey = changedPaths.filter((path) => pathStartsWith(path, String(key)));
const hasRestartRequiredChange = changedPathsForKey.some(
(path) => !hotReloadFieldForChangedPath(path),
);
if (hasRestartRequiredChange) {
restartRequiredFields.push(String(key));
}
}
hotReloadFields.push(...hotReloadFieldSet);
return { hotReloadFields, restartRequiredFields };
}
@@ -907,64 +907,68 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
}
});
test('getTrendsDashboard supports 365d range and caps day buckets at 365', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
test(
'getTrendsDashboard supports 365d range and caps day buckets at 365',
{ timeout: 20_000 },
() => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/365d-trends.mkv', {
canonicalTitle: '365d Trends',
sourcePath: '/tmp/365d-trends.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: '365d Trends',
canonicalTitle: '365d Trends',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: '365d-trends.mkv',
parsedTitle: '365d Trends',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/365d-trends.mkv', {
canonicalTitle: '365d Trends',
sourcePath: '/tmp/365d-trends.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: '365d Trends',
canonicalTitle: '365d Trends',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: '365d-trends.mkv',
parsedTitle: '365d Trends',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
const insertDailyRollup = db.prepare(
`
const insertDailyRollup = db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
// Seed 400 distinct rollup days so we can prove the 365d range caps at 365.
const latestRollupDay = 20513;
const createdAtMs = '1772395200000';
for (let offset = 0; offset < 400; offset += 1) {
const rollupDay = latestRollupDay - offset;
insertDailyRollup.run(rollupDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
);
// Seed 400 distinct rollup days so we can prove the 365d range caps at 365.
const latestRollupDay = 20513;
const createdAtMs = '1772395200000';
for (let offset = 0; offset < 400; offset += 1) {
const rollupDay = latestRollupDay - offset;
insertDailyRollup.run(rollupDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
}
const dashboard = getTrendsDashboard(db, '365d', 'day');
assert.equal(dashboard.activity.watchTime.length, 365);
} finally {
db.close();
cleanupDbPath(dbPath);
}
const dashboard = getTrendsDashboard(db, '365d', 'day');
assert.equal(dashboard.activity.watchTime.length, 365);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
});
});
},
);
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
const dbPath = makeDbPath();
+51 -1
View File
@@ -32,6 +32,12 @@ class FakeSocket extends EventEmitter {
}
}
class ManualCloseSocket extends FakeSocket {
override destroy(): void {
this.destroyed = true;
}
}
const wait = () => new Promise((resolve) => setTimeout(resolve, 0));
test('getMpvReconnectDelay follows existing reconnect ramp', () => {
@@ -203,12 +209,15 @@ test('MpvSocketTransport ignores connect requests while already connecting or co
});
test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () => {
const events: string[] = [];
const transport = new MpvSocketTransport({
socketPath: '/tmp/mpv.sock',
onConnect: () => {},
onData: () => {},
onError: () => {},
onClose: () => {},
onClose: () => {
events.push('close');
},
socketFactory: () => new FakeSocket() as unknown as net.Socket,
});
@@ -220,4 +229,45 @@ test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () =
assert.equal(transport.isConnected, false);
assert.equal(transport.isConnecting, false);
assert.equal(transport.getSocket(), null);
assert.deepEqual(events, []);
});
test('MpvSocketTransport ignores stale socket events after shutdown and reconnect', async () => {
const events: string[] = [];
const sockets: ManualCloseSocket[] = [];
const transport = new MpvSocketTransport({
socketPath: '/tmp/mpv.sock',
onConnect: () => {
events.push('connect');
},
onData: () => {
events.push('data');
},
onError: () => {
events.push('error');
},
onClose: () => {
events.push('close');
},
socketFactory: () => {
const socket = new ManualCloseSocket();
sockets.push(socket);
return socket as unknown as net.Socket;
},
});
transport.connect();
await wait();
transport.shutdown();
transport.connect();
await wait();
const eventsBeforeStaleSocket = [...events];
sockets[0]!.emit('data', Buffer.from('{}'));
sockets[0]!.emit('error', new Error('stale'));
sockets[0]!.emit('close');
assert.deepEqual(events, eventsBeforeStaleSocket);
assert.equal(transport.isConnected, true);
assert.equal(transport.getSocket(), sockets[1]);
});
+16 -10
View File
@@ -105,32 +105,37 @@ export class MpvSocketTransport {
}
this.connecting = true;
this.socketRef = this.socketFactory();
this.socket = this.socketRef;
const socket = this.socketFactory();
this.socketRef = socket;
this.socket = socket;
this.socketRef.on('connect', () => {
socket.on('connect', () => {
if (this.socketRef !== socket) return;
this.connected = true;
this.connecting = false;
this.callbacks.onConnect();
});
this.socketRef.on('data', (data: Buffer) => {
socket.on('data', (data: Buffer) => {
if (this.socketRef !== socket) return;
this.callbacks.onData(data);
});
this.socketRef.on('error', (error: Error) => {
socket.on('error', (error: Error) => {
if (this.socketRef !== socket) return;
this.connected = false;
this.connecting = false;
this.callbacks.onError(error);
});
this.socketRef.on('close', () => {
socket.on('close', () => {
if (this.socketRef !== socket) return;
this.connected = false;
this.connecting = false;
this.callbacks.onClose();
});
this.socketRef.connect(this.socketPath);
socket.connect(this.socketPath);
}
send(payload: MpvSocketMessagePayload): boolean {
@@ -144,13 +149,14 @@ export class MpvSocketTransport {
}
shutdown(): void {
if (this.socketRef) {
this.socketRef.destroy();
}
const socket = this.socketRef;
this.socketRef = null;
this.socket = null;
this.connected = false;
this.connecting = false;
if (socket) {
socket.destroy();
}
}
getSocket(): net.Socket | null {
+66
View File
@@ -168,6 +168,37 @@ test('MpvIpcClient connect logs connect-request at debug level', () => {
assert.equal(requestLogs.length, 1);
});
test('MpvIpcClient reconnect clears stale connected state and starts a fresh transport connect', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const calls: string[] = [];
const connectionChanges: boolean[] = [];
const resolved: unknown[] = [];
client.on('connection-change', ({ connected }) => {
connectionChanges.push(connected);
});
(client as any).connected = true;
(client as any).connecting = false;
(client as any).socket = {};
(client as any).pendingRequests.set(10, (message: unknown) => {
resolved.push(message);
});
(client as any).transport.shutdown = () => {
calls.push('shutdown');
};
(client as any).transport.connect = () => {
calls.push('connect');
};
client.reconnect();
assert.deepEqual(calls, ['shutdown', 'connect']);
assert.equal(client.connected, false);
assert.equal((client as any).connecting, true);
assert.equal((client as any).socket, null);
assert.deepEqual(connectionChanges, [false]);
assert.deepEqual(resolved, [{ request_id: 10, error: 'disconnected' }]);
});
test('MpvIpcClient failPendingRequests resolves outstanding requests as disconnected', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const resolved: unknown[] = [];
@@ -385,6 +416,41 @@ test('MpvIpcClient connect does not force primary subtitle visibility from bindi
assert.equal(hasPrimaryVisibilityMutation, false);
});
test('MpvIpcClient snapshots current subtitles before connection side effects can hide them', () => {
const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
(client as any).send = (command: unknown) => {
commands.push(command);
return true;
};
client.on('connection-change', ({ connected }) => {
if (connected) {
client.setSubVisibility(false);
}
});
const callbacks = (client as any).transport.callbacks;
callbacks.onConnect();
const firstSubTextSnapshot = commands.findIndex((command) => {
const args = (command as { command?: unknown[] }).command;
return Array.isArray(args) && args[0] === 'get_property' && args[1] === 'sub-text';
});
const firstPrimaryHide = commands.findIndex((command) => {
const args = (command as { command?: unknown[] }).command;
return (
Array.isArray(args) &&
args[0] === 'set_property' &&
args[1] === 'sub-visibility' &&
(args[2] === false || args[2] === 'no')
);
});
assert.notEqual(firstSubTextSnapshot, -1);
assert.notEqual(firstPrimaryHide, -1);
assert.ok(firstSubTextSnapshot < firstPrimaryHide);
});
test('MpvIpcClient setSubVisibility writes compatibility commands for visibility toggle', () => {
const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
+16 -1
View File
@@ -186,12 +186,12 @@ export class MpvIpcClient implements MpvClient {
this.connected = true;
this.connecting = false;
this.socket = this.transport.getSocket();
this.emit('connection-change', { connected: true });
this.reconnectAttempt = 0;
this.hasConnectedOnce = true;
this.setSecondarySubVisibility(false);
subscribeToMpvProperties(this.send.bind(this));
requestMpvInitialState(this.send.bind(this));
this.emit('connection-change', { connected: true });
const shouldAutoStart =
this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true;
@@ -275,6 +275,21 @@ export class MpvIpcClient implements MpvClient {
this.transport.connect();
}
reconnect(): void {
logger.debug('MPV IPC reconnect requested.');
const wasConnected = this.connected;
this.transport.shutdown();
this.connected = false;
this.connecting = false;
this.socket = null;
this.playbackPaused = null;
if (wasConnected) {
this.emit('connection-change', { connected: false });
}
this.failPendingRequests();
this.connect();
}
private scheduleReconnect(): void {
this.reconnectAttempt = scheduleMpvReconnect({
attempt: this.reconnectAttempt,
+106 -3
View File
@@ -11,29 +11,49 @@ type WindowTrackerStub = {
isTargetWindowMinimized?: () => boolean;
};
function createMainWindowRecorder() {
function createMainWindowRecorder(options: { emitShowImmediately?: boolean } = {}) {
const emitShowImmediately = options.emitShowImmediately ?? true;
const calls: string[] = [];
const listeners = new Map<string, Array<() => void>>();
let visible = false;
let focused = false;
let opacity = 1;
let contentReady = true;
const emit = (event: string): void => {
const handlers = listeners.get(event) ?? [];
listeners.delete(event);
for (const handler of handlers) {
handler();
}
};
const emitShow = (): void => {
visible = true;
emit('show');
};
const window = {
webContents: {},
isDestroyed: () => false,
isVisible: () => visible,
isFocused: () => focused,
once: (event: string, handler: () => void) => {
listeners.set(event, [...(listeners.get(event) ?? []), handler]);
},
hide: () => {
visible = false;
focused = false;
calls.push('hide');
},
show: () => {
visible = true;
calls.push('show');
if (emitShowImmediately) {
emitShow();
}
},
showInactive: () => {
visible = true;
calls.push('show-inactive');
if (emitShowImmediately) {
emitShow();
}
},
focus: () => {
focused = true;
@@ -68,6 +88,7 @@ function createMainWindowRecorder() {
window,
calls,
getOpacity: () => opacity,
emitShow,
setContentReady: (nextContentReady: boolean) => {
contentReady = nextContentReady;
(
@@ -216,6 +237,88 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke
assert.ok(!calls.includes('osd'));
});
test('tracked non-macOS overlay reapplies bounds after first show', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
assert.deepEqual(
calls.filter((call) => call === 'update-bounds' || call === 'show'),
['update-bounds', 'show', 'update-bounds'],
);
});
test('tracked non-macOS overlay queues only one first-show bounds refresh', () => {
const { window, calls, emitShow } = createMainWindowRecorder({ emitShowImmediately: false });
let width = 1280;
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width, height: 720 }),
};
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: (geometry: { width: number }) => {
calls.push(`update-bounds:${geometry.width}`);
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
run();
width = 1440;
run();
emitShow();
assert.deepEqual(
calls.filter((call) => call.startsWith('update-bounds:')),
['update-bounds:1280', 'update-bounds:1440', 'update-bounds:1440'],
);
});
test('Windows visible overlay stays click-through and binds to mpv while tracked', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
+28
View File
@@ -8,6 +8,7 @@ const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap<
BrowserWindow,
ReturnType<typeof setTimeout>
>();
const pendingFirstShowBoundsRefreshGeometry = new WeakMap<BrowserWindow, WindowGeometry>();
function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
const opacityCapableWindow = window as BrowserWindow & {
setOpacity?: (opacity: number) => void;
@@ -270,6 +271,32 @@ export function updateVisibleOverlayVisibility(args: {
args.markOverlayLoadingOsdShown?.();
};
const refreshNonNativeOverlayBoundsAfterFirstShow = (geometry: WindowGeometry | null): void => {
if (
geometry === null ||
args.isMacOSPlatform ||
args.isWindowsPlatform ||
mainWindow.isVisible()
) {
return;
}
if (pendingFirstShowBoundsRefreshGeometry.has(mainWindow)) {
pendingFirstShowBoundsRefreshGeometry.set(mainWindow, geometry);
return;
}
pendingFirstShowBoundsRefreshGeometry.set(mainWindow, geometry);
mainWindow.once('show', () => {
const pendingGeometry = pendingFirstShowBoundsRefreshGeometry.get(mainWindow);
pendingFirstShowBoundsRefreshGeometry.delete(mainWindow);
if (mainWindow.isDestroyed() || !mainWindow.isVisible()) {
return;
}
if (pendingGeometry) {
args.updateVisibleOverlayBounds(pendingGeometry);
}
});
};
if (!args.visibleOverlayVisible) {
args.setTrackerNotReadyWarningShown(false);
args.resetOverlayLoadingOsdSuppression?.();
@@ -298,6 +325,7 @@ export function updateVisibleOverlayVisibility(args: {
const geometry = args.windowTracker.getGeometry();
if (geometry) {
args.updateVisibleOverlayBounds(geometry);
refreshNonNativeOverlayBoundsAfterFirstShow(geometry);
}
args.syncPrimaryOverlayWindowLayer('visible');
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
@@ -14,6 +14,33 @@ test('overlay window config explicitly disables renderer sandbox for preload com
assert.equal(options.webPreferences?.backgroundThrottling, false);
});
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
try {
const visibleOptions = buildOverlayWindowOptions('visible', {
isDev: false,
yomitanSession: null,
});
const modalOptions = buildOverlayWindowOptions('modal', {
isDev: false,
yomitanSession: null,
});
assert.equal(visibleOptions.resizable, true);
assert.equal(modalOptions.resizable, false);
} finally {
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
test('Windows visible overlay window config does not start as always-on-top', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
+2 -1
View File
@@ -16,6 +16,7 @@ export function buildOverlayWindowOptions(
): BrowserWindowConstructorOptions {
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible');
const shouldAllowCompositorResize = process.platform === 'linux' && kind === 'visible';
return {
show: false,
@@ -29,7 +30,7 @@ export function buildOverlayWindowOptions(
frame: false,
alwaysOnTop: shouldStartAlwaysOnTop,
skipTaskbar: true,
resizable: false,
resizable: shouldAllowCompositorResize,
hasShadow: false,
focusable: true,
acceptFirstMouse: true,
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { dispatchSessionAction, type SessionActionExecutorDeps } from './session-actions';
function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
const calls: string[] = [];
const deps: SessionActionExecutorDeps = {
toggleStatsOverlay: () => calls.push('stats'),
toggleVisibleOverlay: () => calls.push('visible'),
copyCurrentSubtitle: () => calls.push('copy'),
copySubtitleCount: (count) => calls.push(`copy:${count}`),
updateLastCardFromClipboard: async () => {
calls.push('update');
},
triggerFieldGrouping: async () => {
calls.push('field-grouping');
},
triggerSubsyncFromConfig: async () => {
calls.push('subsync');
},
mineSentenceCard: async () => {
calls.push('mine');
},
mineSentenceCount: (count) => calls.push(`mine:${count}`),
toggleSecondarySub: () => calls.push('secondary'),
toggleSubtitleSidebar: () => calls.push('sidebar'),
markLastCardAsAudioCard: async () => {
calls.push('audio');
},
markActiveVideoWatched: async () => {
calls.push('mark-watched');
return true;
},
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openSessionHelp: () => calls.push('session-help'),
openCharacterDictionary: () => calls.push('character-dictionary'),
openControllerSelect: () => calls.push('controller-select'),
openControllerDebug: () => calls.push('controller-debug'),
openJimaku: () => calls.push('jimaku'),
openYoutubeTrackPicker: () => {
calls.push('youtube');
},
openPlaylistBrowser: () => {
calls.push('playlist');
},
replayCurrentSubtitle: () => calls.push('replay'),
playNextSubtitle: () => calls.push('play-next'),
shiftSubDelayToAdjacentSubtitle: async (direction) => {
calls.push(`shift:${direction}`);
},
cycleRuntimeOption: () => ({ ok: true }),
playNextPlaylistItem: () => calls.push('playlist-next'),
showMpvOsd: (text) => calls.push(`osd:${text}`),
...overrides,
};
return { calls, deps };
}
test('dispatchSessionAction marks watched and advances playlist after success', async () => {
const { calls, deps } = createDeps();
await dispatchSessionAction({ actionId: 'markWatched' }, deps);
assert.deepEqual(calls, ['mark-watched', 'osd:Marked as watched', 'playlist-next']);
});
test('dispatchSessionAction does not advance playlist when mark watched no-ops', async () => {
const { calls, deps } = createDeps({
markActiveVideoWatched: async () => {
calls.push('mark-watched');
return false;
},
});
await dispatchSessionAction({ actionId: 'markWatched' }, deps);
assert.deepEqual(calls, ['mark-watched']);
});
+10
View File
@@ -15,6 +15,7 @@ export interface SessionActionExecutorDeps {
toggleSecondarySub: () => void;
toggleSubtitleSidebar: () => void;
markLastCardAsAudioCard: () => Promise<void>;
markActiveVideoWatched: () => Promise<boolean>;
openRuntimeOptionsPalette: () => void;
openSessionHelp: () => void;
openCharacterDictionary: () => void;
@@ -27,6 +28,7 @@ export interface SessionActionExecutorDeps {
playNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
playNextPlaylistItem: () => void;
showMpvOsd: (text: string) => void;
}
@@ -80,6 +82,14 @@ export async function dispatchSessionAction(
case 'markAudioCard':
await deps.markLastCardAsAudioCard();
return;
case 'markWatched': {
const marked = await deps.markActiveVideoWatched();
if (marked) {
deps.showMpvOsd('Marked as watched');
deps.playNextPlaylistItem();
}
return;
}
case 'openRuntimeOptions':
deps.openRuntimeOptionsPalette();
return;
@@ -375,3 +375,64 @@ test('compileSessionBindings includes stats toggle in the shared session binding
},
]);
});
test('compileSessionBindings includes mark-watched in the shared session binding artifact', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [],
statsMarkWatchedKey: 'Ctrl+Shift+KeyW',
platform: 'darwin',
});
assert.equal(result.warnings.length, 0);
assert.deepEqual(result.bindings, [
{
sourcePath: 'stats.markWatchedKey',
originalKey: 'Ctrl+Shift+KeyW',
key: {
code: 'KeyW',
modifiers: ['ctrl', 'shift'],
},
actionType: 'session-action',
actionId: 'markWatched',
},
]);
});
test('compileSessionBindings wires every configured shortcut key into the shared artifact', () => {
const shortcutKeys: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTimeoutMs'>> = [
'toggleVisibleOverlayGlobal',
'copySubtitle',
'copySubtitleMultiple',
'updateLastCardFromClipboard',
'triggerFieldGrouping',
'triggerSubsync',
'mineSentence',
'mineSentenceMultiple',
'toggleSecondarySub',
'markAudioCard',
'openCharacterDictionary',
'openRuntimeOptions',
'openJimaku',
'openSessionHelp',
'openControllerSelect',
'openControllerDebug',
'toggleSubtitleSidebar',
];
const shortcuts = createShortcuts();
shortcutKeys.forEach((key, index) => {
shortcuts[key] = `Ctrl+Alt+F${index + 1}`;
});
const result = compileSessionBindings({
shortcuts,
keybindings: [],
platform: 'linux',
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(
result.bindings.map((binding) => binding.sourcePath).sort(),
shortcutKeys.map((key) => `shortcuts.${key}`).sort(),
);
});
+30
View File
@@ -18,6 +18,7 @@ type CompileSessionBindingsInput = {
keybindings: Keybinding[];
shortcuts: ConfiguredShortcuts;
statsToggleKey?: string | null;
statsMarkWatchedKey?: string | null;
platform: PlatformKeyModel;
rawConfig?: ResolvedConfig | null;
};
@@ -353,6 +354,8 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
input.rawConfig?.shortcuts as Record<string, unknown> | undefined
)?.toggleVisibleOverlayGlobal;
const statsToggleKey = input.statsToggleKey ?? input.rawConfig?.stats?.toggleKey ?? null;
const statsMarkWatchedKey =
input.statsMarkWatchedKey ?? input.rawConfig?.stats?.markWatchedKey ?? null;
if (legacyToggleVisibleOverlayGlobal !== undefined) {
warnings.push({
@@ -419,6 +422,33 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
}
}
if (statsMarkWatchedKey) {
const parsed = parseDomKeyString(statsMarkWatchedKey, input.platform);
if (!parsed.key) {
warnings.push({
kind: 'unsupported',
path: 'stats.markWatchedKey',
value: statsMarkWatchedKey,
message: parsed.message ?? 'Unsupported stats mark-watched key syntax.',
});
} else {
const binding: CompiledSessionActionBinding = {
sourcePath: 'stats.markWatchedKey',
originalKey: statsMarkWatchedKey,
key: parsed.key,
actionType: 'session-action',
actionId: 'markWatched',
};
const signature = getSessionKeySpecSignature(parsed.key);
const draft = candidates.get(signature) ?? [];
draft.push({
binding,
actionFingerprint: getBindingFingerprint(binding),
});
candidates.set(signature, draft);
}
}
input.keybindings.forEach((binding, index) => {
if (!binding.command) return;
const parsed = parseDomKeyString(binding.key, input.platform);
+2 -1
View File
@@ -14,8 +14,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -32,6 +32,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
markWatched: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
+34 -14
View File
@@ -41,7 +41,6 @@ function makeDeps(
return {
getMpvClient: () => mpvClient,
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath: '/usr/bin/alass',
ffsubsyncPath: '/usr/bin/ffsubsync',
ffmpegPath: '/usr/bin/ffmpeg',
@@ -68,7 +67,7 @@ test('triggerSubsyncFromConfig returns early when already in progress', async ()
assert.deepEqual(osd, ['Subsync already running']);
});
test('triggerSubsyncFromConfig opens manual picker in manual mode', async () => {
test('triggerSubsyncFromConfig opens manual picker', async () => {
const osd: string[] = [];
let payloadTrackCount = 0;
let inProgressState: boolean | null = null;
@@ -92,6 +91,31 @@ test('triggerSubsyncFromConfig opens manual picker in manual mode', async () =>
assert.equal(inProgressState, false);
});
test('triggerSubsyncFromConfig does not run automatic sync', async () => {
const osd: string[] = [];
let payloadTrackCount = 0;
let spinnerRan = false;
await triggerSubsyncFromConfig(
makeDeps({
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
},
showMpvOsd: (text) => {
osd.push(text);
},
runWithSubsyncSpinner: async <T>(task: () => Promise<T>) => {
spinnerRan = true;
return task();
},
}),
);
assert.equal(payloadTrackCount, 1);
assert.equal(spinnerRan, false);
assert.deepEqual(osd, ['Subsync: choose engine and source']);
});
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
let payloadTrackCount = 0;
@@ -161,14 +185,14 @@ test('runSubsyncManual requires a source track for alass', async () => {
});
});
test('triggerSubsyncFromConfig reports path validation failures', async () => {
test('triggerSubsyncFromConfig does not validate sync tool paths before manual selection', async () => {
const osd: string[] = [];
const inProgress: boolean[] = [];
let payloadTrackCount = 0;
await triggerSubsyncFromConfig(
makeDeps({
getResolvedConfig: () => ({
defaultMode: 'auto',
alassPath: '/missing/alass',
ffsubsyncPath: '/missing/ffsubsync',
ffmpegPath: '/missing/ffmpeg',
@@ -176,16 +200,18 @@ test('triggerSubsyncFromConfig reports path validation failures', async () => {
setSubsyncInProgress: (value) => {
inProgress.push(value);
},
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
},
showMpvOsd: (text) => {
osd.push(text);
},
}),
);
assert.deepEqual(inProgress, [true, false]);
assert.ok(
osd.some((line) => line.startsWith('Subsync failed: Configured ffmpeg executable not found')),
);
assert.deepEqual(inProgress, [false]);
assert.equal(payloadTrackCount, 1);
assert.deepEqual(osd, ['Subsync: choose engine and source']);
});
function writeExecutableScript(filePath: string, content: string): void {
@@ -260,7 +286,6 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
@@ -326,7 +351,6 @@ test('runSubsyncManual writes deterministic _retimed filename when replace is fa
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
@@ -382,7 +406,6 @@ test('runSubsyncManual reports ffsubsync command failures with details', async (
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
@@ -448,7 +471,6 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
@@ -520,7 +542,6 @@ test('runSubsyncManual keeps internal alass source file alive until sync finishe
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
@@ -577,7 +598,6 @@ test('runSubsyncManual resolves string sid values from mpv stream properties', a
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
+2 -64
View File
@@ -15,9 +15,6 @@ import {
SubsyncResolvedConfig,
} from '../../subsync/utils';
import { isRemoteMediaPath } from '../../jimaku/utils';
import { createLogger } from '../../logger';
const logger = createLogger('main:subsync');
interface FileExtractionResult {
path: string;
@@ -340,57 +337,6 @@ function validateFfsubsyncReference(videoPath: string): void {
}
}
async function runSubsyncAutoInternal(deps: SubsyncCoreDeps): Promise<SubsyncResult> {
const client = getMpvClientForSubsync(deps);
const context = await gatherSubsyncContext(client);
const resolved = deps.getResolvedConfig();
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
if (context.secondaryTrack) {
let secondaryExtraction: FileExtractionResult | null = null;
try {
secondaryExtraction = await extractSubtitleTrackToFile(
ffmpegPath,
context.videoPath,
context.secondaryTrack,
);
const alassResult = await subsyncToReference(
'alass',
secondaryExtraction.path,
context,
resolved,
client,
);
if (alassResult.ok) {
return alassResult;
}
} catch (error) {
logger.warn('Auto alass sync failed, trying ffsubsync fallback:', error);
} finally {
if (secondaryExtraction) {
cleanupTemporaryFile(secondaryExtraction);
}
}
}
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
if (!ffsubsyncPath) {
return {
ok: false,
message: 'No secondary subtitle for alass and ffsubsync not configured',
};
}
try {
validateFfsubsyncReference(context.videoPath);
} catch (error) {
return {
ok: false,
message: `ffsubsync synchronization failed: ${(error as Error).message}`,
};
}
return subsyncToReference('ffsubsync', context.videoPath, context, resolved, client);
}
export async function runSubsyncManual(
request: SubsyncManualRunRequest,
deps: SubsyncCoreDeps,
@@ -448,17 +394,9 @@ export async function triggerSubsyncFromConfig(deps: TriggerSubsyncFromConfigDep
return;
}
const resolved = deps.getResolvedConfig();
try {
if (resolved.defaultMode === 'manual') {
await openSubsyncManualPicker(deps);
deps.showMpvOsd('Subsync: choose engine and source');
return;
}
deps.setSubsyncInProgress(true);
const result = await deps.runWithSubsyncSpinner(() => runSubsyncAutoInternal(deps));
deps.showMpvOsd(result.message);
await openSubsyncManualPicker(deps);
deps.showMpvOsd('Subsync: choose engine and source');
} catch (error) {
deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`);
} finally {
+32
View File
@@ -217,6 +217,38 @@ test('serializeSubtitleWebsocketMessage emits structured token api payload', ()
});
});
test('serializeSubtitleWebsocketMessage can force plain subtitle payloads', () => {
const payload: SubtitleData = {
text: '無事',
tokens: [
{
surface: '無事',
reading: 'ぶじ',
headword: '無事',
startPos: 0,
endPos: 2,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: true,
isNPlusOneTarget: false,
jlptLevel: 'N2',
frequencyRank: 745,
},
],
};
const raw = serializeSubtitleWebsocketMessage(payload, frequencyOptions, {
payloadMode: 'plain',
});
assert.deepEqual(JSON.parse(raw), {
version: 1,
text: '無事',
sentence: '無事',
tokens: [],
});
});
test('serializeInitialSubtitleWebsocketMessage keeps annotated current subtitle content', () => {
const payload: SubtitleData = {
text: 'ignored fallback',
+24 -2
View File
@@ -18,6 +18,12 @@ export type SubtitleWebsocketFrequencyOptions = {
mode: 'single' | 'banded';
};
export type SubtitleWebsocketPayloadMode = 'plain' | 'annotated';
type SubtitleWebsocketMessageOptions = {
payloadMode?: SubtitleWebsocketPayloadMode;
};
type SerializedSubtitleToken = Pick<
MergedToken,
| 'surface'
@@ -198,7 +204,17 @@ export function serializeSubtitleMarkup(
export function serializeSubtitleWebsocketMessage(
payload: SubtitleData,
options: SubtitleWebsocketFrequencyOptions,
messageOptions: SubtitleWebsocketMessageOptions = {},
): string {
if (messageOptions.payloadMode === 'plain') {
return JSON.stringify({
version: 1,
text: payload.text,
sentence: escapeHtml(payload.text).replaceAll('\n', '<br>'),
tokens: [],
});
}
return JSON.stringify({
version: 1,
text: payload.text,
@@ -210,18 +226,21 @@ export function serializeSubtitleWebsocketMessage(
export function serializeInitialSubtitleWebsocketMessage(
payload: SubtitleData | null,
options: SubtitleWebsocketFrequencyOptions,
messageOptions: SubtitleWebsocketMessageOptions = {},
): string | null {
if (!payload || !payload.text.trim()) {
return null;
}
return serializeSubtitleWebsocketMessage(payload, options);
return serializeSubtitleWebsocketMessage(payload, options, messageOptions);
}
export class SubtitleWebSocket {
private server: WebSocket.Server | null = null;
private latestMessage = '';
public constructor(private readonly payloadMode: SubtitleWebsocketPayloadMode = 'annotated') {}
public isRunning(): boolean {
return this.server !== null;
}
@@ -247,6 +266,7 @@ export class SubtitleWebSocket {
const currentMessage = serializeInitialSubtitleWebsocketMessage(
getCurrentSubtitleData(),
getFrequencyOptions(),
{ payloadMode: this.payloadMode },
);
if (currentMessage) {
ws.send(currentMessage);
@@ -262,7 +282,9 @@ export class SubtitleWebSocket {
public broadcast(payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions): void {
if (!this.server) return;
const message = serializeSubtitleWebsocketMessage(payload, options);
const message = serializeSubtitleWebsocketMessage(payload, options, {
payloadMode: this.payloadMode,
});
this.latestMessage = message;
for (const client of this.server.clients) {
if (client.readyState === WebSocket.OPEN) {
+22 -4
View File
@@ -43,6 +43,7 @@ export interface TokenizerServiceDeps {
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string) => boolean;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getKnownWordsEnabled?: () => boolean;
getJlptLevel: (text: string) => JlptLevel | null;
getNPlusOneEnabled?: () => boolean;
getJlptEnabled?: () => boolean;
@@ -74,6 +75,7 @@ export interface TokenizerDepsRuntimeOptions {
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string) => boolean;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getKnownWordsEnabled?: () => boolean;
getJlptLevel: (text: string) => JlptLevel | null;
getNPlusOneEnabled?: () => boolean;
getJlptEnabled?: () => boolean;
@@ -88,6 +90,7 @@ export interface TokenizerDepsRuntimeOptions {
}
interface TokenizerAnnotationOptions {
knownWordsEnabled: boolean;
nPlusOneEnabled: boolean;
jlptEnabled: boolean;
nameMatchEnabled: boolean;
@@ -119,18 +122,28 @@ function getKnownWordLookup(
deps: TokenizerServiceDeps,
options: TokenizerAnnotationOptions,
): (text: string) => boolean {
if (!options.nPlusOneEnabled) {
if (!options.knownWordsEnabled && !options.nPlusOneEnabled) {
return () => false;
}
return deps.isKnownWord;
}
function needsMecabPosEnrichment(options: TokenizerAnnotationOptions): boolean {
return options.nPlusOneEnabled || options.jlptEnabled || options.frequencyEnabled;
return (
options.knownWordsEnabled ||
options.nPlusOneEnabled ||
options.jlptEnabled ||
options.frequencyEnabled
);
}
function hasAnyAnnotationEnabled(options: TokenizerAnnotationOptions): boolean {
return options.nPlusOneEnabled || options.jlptEnabled || options.frequencyEnabled;
return (
options.knownWordsEnabled ||
options.nPlusOneEnabled ||
options.jlptEnabled ||
options.frequencyEnabled
);
}
async function enrichTokensWithMecabAsync(
@@ -211,6 +224,7 @@ export function createTokenizerDepsRuntime(
setYomitanParserInitPromise: options.setYomitanParserInitPromise,
isKnownWord: options.isKnownWord,
getKnownWordMatchMode: options.getKnownWordMatchMode,
getKnownWordsEnabled: options.getKnownWordsEnabled,
getJlptLevel: options.getJlptLevel,
getNPlusOneEnabled: options.getNPlusOneEnabled,
getJlptEnabled: options.getJlptEnabled,
@@ -662,8 +676,12 @@ function applyFrequencyRanks(
}
function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOptions {
const nPlusOneEnabled = deps.getNPlusOneEnabled?.() !== false;
return {
nPlusOneEnabled: deps.getNPlusOneEnabled?.() !== false,
knownWordsEnabled: deps.getKnownWordsEnabled
? deps.getKnownWordsEnabled() !== false
: nPlusOneEnabled,
nPlusOneEnabled,
jlptEnabled: deps.getJlptEnabled?.() !== false,
nameMatchEnabled: deps.getNameMatchEnabled?.() !== false,
frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false,
@@ -56,6 +56,50 @@ test('annotateTokens known-word match mode uses headword vs surface', () => {
assert.equal(surfaceResult[0]?.isKnown, false);
});
test('annotateTokens marks known words when N+1 is disabled', () => {
const tokens = [
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
makeToken({ surface: '猫', headword: '猫', startPos: 1, endPos: 2 }),
makeToken({ surface: '犬', headword: '犬', startPos: 2, endPos: 3 }),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '私' || text === '猫',
}),
{ nPlusOneEnabled: false, knownWordsEnabled: true },
);
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[1]?.isKnown, true);
assert.equal(result[1]?.isNPlusOneTarget, false);
assert.equal(result[2]?.isKnown, false);
assert.equal(result[2]?.isNPlusOneTarget, false);
});
test('annotateTokens hides known-word marks while still using known words for N+1', () => {
const tokens = [
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
makeToken({ surface: '猫', headword: '猫', startPos: 1, endPos: 2 }),
makeToken({ surface: '犬', headword: '犬', startPos: 2, endPos: 3 }),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '私' || text === '猫',
}),
{ nPlusOneEnabled: true, knownWordsEnabled: false, minSentenceWordsForNPlusOne: 3 },
);
assert.equal(result[0]?.isKnown, false);
assert.equal(result[1]?.isKnown, false);
assert.equal(result[2]?.isKnown, false);
assert.equal(result[2]?.isNPlusOneTarget, true);
});
test('annotateTokens falls back to reading for known-word matches when headword lookup misses', () => {
const tokens = [
makeToken({
@@ -122,6 +166,35 @@ test('annotateTokens excludes frequency for particle/bound_auxiliary and pos1 ex
assert.equal(result[3]?.frequencyRank, 11);
});
test('annotateTokens keeps frequency for determiner-led content noun compounds', () => {
const tokens = [
makeToken({
surface: 'その場',
headword: 'その場',
reading: 'そのば',
partOfSpeech: PartOfSpeech.noun,
pos1: '連体詞|名詞',
pos2: '*|一般',
startPos: 0,
endPos: 3,
frequencyRank: 879,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === 'その場',
getJlptLevel: (text) => (text === 'その場' ? 'N4' : null),
}),
{ minSentenceWordsForNPlusOne: 1 },
);
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.frequencyRank, 879);
assert.equal(result[0]?.jlptLevel, 'N4');
});
test('annotateTokens preserves existing frequency rank when frequency is enabled', () => {
const tokens = [makeToken({ surface: '猫', headword: '猫', frequencyRank: 42 })];
+61 -10
View File
@@ -31,6 +31,7 @@ export interface AnnotationStageDeps {
}
export interface AnnotationStageOptions {
knownWordsEnabled?: boolean;
nPlusOneEnabled?: boolean;
nameMatchEnabled?: boolean;
jlptEnabled?: boolean;
@@ -188,6 +189,35 @@ function shouldAllowHonorificPrefixNounFrequency(token: MergedToken): boolean {
);
}
function shouldAllowDeterminerLedNounFrequency(
normalizedPos1: string,
normalizedPos2: string,
pos1Exclusions: ReadonlySet<string>,
pos2Exclusions: ReadonlySet<string>,
): boolean {
const pos1Parts = splitNormalizedTagParts(normalizedPos1);
if (pos1Parts.length < 2 || pos1Parts[0] !== '連体詞') {
return false;
}
const pos2Parts = splitNormalizedTagParts(normalizedPos2);
if (!isExcludedComponent(pos1Parts[0], pos2Parts[0], pos1Exclusions, pos2Exclusions)) {
return false;
}
const componentCount = Math.max(pos1Parts.length, pos2Parts.length);
for (let index = 1; index < componentCount; index += 1) {
if (
pos1Parts[index] === '名詞' &&
!isExcludedComponent(pos1Parts[index], pos2Parts[index], pos1Exclusions, pos2Exclusions)
) {
return true;
}
}
return false;
}
function isFrequencyExcludedByPos(
token: MergedToken,
pos1Exclusions: ReadonlySet<string>,
@@ -207,12 +237,19 @@ function isFrequencyExcludedByPos(
pos1Exclusions,
pos2Exclusions,
);
const allowDeterminerLedNounToken = shouldAllowDeterminerLedNounFrequency(
normalizedPos1,
normalizedPos2,
pos1Exclusions,
pos2Exclusions,
);
const allowOrdinalPrefixNounToken = shouldAllowOrdinalPrefixNounFrequency(token);
const allowHonorificPrefixNounToken = shouldAllowHonorificPrefixNounFrequency(token);
if (
isExcludedByTagSet(normalizedPos1, pos1Exclusions) &&
!allowContentLedMergedToken &&
!allowDeterminerLedNounToken &&
!allowOrdinalPrefixNounToken &&
!allowHonorificPrefixNounToken
) {
@@ -222,6 +259,7 @@ function isFrequencyExcludedByPos(
if (
isExcludedByTagSet(normalizedPos2, pos2Exclusions) &&
!allowContentLedMergedToken &&
!allowDeterminerLedNounToken &&
!allowOrdinalPrefixNounToken &&
!allowHonorificPrefixNounToken
) {
@@ -632,13 +670,16 @@ export function annotateTokens(
): MergedToken[] {
const pos1Exclusions = resolvePos1Exclusions(options);
const pos2Exclusions = resolvePos2Exclusions(options);
const knownWordsEnabled = options.knownWordsEnabled !== false;
const nPlusOneEnabled = options.nPlusOneEnabled !== false;
const nameMatchEnabled = options.nameMatchEnabled !== false;
const frequencyEnabled = options.frequencyEnabled !== false;
const jlptEnabled = options.jlptEnabled !== false;
const shouldComputeKnownStatus = knownWordsEnabled || nPlusOneEnabled;
const nPlusOneKnownStatuses: boolean[] = [];
// Single pass: compute known word status, frequency filtering, and JLPT level together
const annotated = tokens.map((token) => {
const annotated = tokens.map((token, index) => {
if (
sharedShouldExcludeTokenFromSubtitleAnnotations(token, {
pos1Exclusions,
@@ -649,6 +690,7 @@ export function annotateTokens(
pos1Exclusions,
pos2Exclusions,
});
nPlusOneKnownStatuses[index] = false;
return {
...strippedToken,
isKnown: false,
@@ -656,9 +698,10 @@ export function annotateTokens(
}
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
const isKnown = nPlusOneEnabled
const isKnownForMatching = shouldComputeKnownStatus
? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode)
: false;
nPlusOneKnownStatuses[index] = isKnownForMatching;
const frequencyRank =
frequencyEnabled && !prioritizedNameMatch
@@ -672,7 +715,7 @@ export function annotateTokens(
return {
...token,
isKnown,
isKnown: knownWordsEnabled ? isKnownForMatching : false,
isNPlusOneTarget: nPlusOneEnabled && !prioritizedNameMatch ? token.isNPlusOneTarget : false,
frequencyRank,
jlptLevel,
@@ -691,13 +734,21 @@ export function annotateTokens(
? minSentenceWordsForNPlusOne
: 3;
const nPlusOneMarked = markNPlusOneTargets(
annotated,
sanitizedMinSentenceWordsForNPlusOne,
pos1Exclusions,
pos2Exclusions,
options.sourceText,
);
const nPlusOneMarked = nPlusOneEnabled
? markNPlusOneTargets(
annotated.map((token, index) => ({
...token,
isKnown: nPlusOneKnownStatuses[index] ?? false,
})),
sanitizedMinSentenceWordsForNPlusOne,
pos1Exclusions,
pos2Exclusions,
options.sourceText,
).map((token, index) => ({
...annotated[index]!,
isNPlusOneTarget: token.isNPlusOneTarget,
}))
: annotated;
if (!nameMatchEnabled) {
return nPlusOneMarked;
+25
View File
@@ -76,3 +76,28 @@ test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => {
assert.equal(resolved.openRuntimeOptions, '9');
assert.equal(resolved.openCharacterDictionary, 'Ctrl+Shift+A');
});
test('preserves null shortcut overrides so defaults can be disabled', () => {
const config: Config = {
shortcuts: {
copySubtitle: null,
openJimaku: null,
toggleSubtitleSidebar: null,
},
};
const defaults: Config = {
shortcuts: {
copySubtitle: 'Ctrl+KeyC',
openJimaku: 'Ctrl+Shift+KeyJ',
toggleSubtitleSidebar: 'Backslash',
openRuntimeOptions: 'Digit9',
},
};
const resolved = resolveConfiguredShortcuts(config, defaults);
assert.equal(resolved.copySubtitle, null);
assert.equal(resolved.openJimaku, null);
assert.equal(resolved.toggleSubtitleSidebar, null);
assert.equal(resolved.openRuntimeOptions, '9');
});
+24 -57
View File
@@ -26,77 +26,44 @@ export function resolveConfiguredShortcuts(
defaultConfig: Config,
): ConfiguredShortcuts {
const isAnkiConnectDisabled = config.ankiConnect?.enabled === false;
type ShortcutKey = keyof Omit<ConfiguredShortcuts, 'multiCopyTimeoutMs'> &
keyof NonNullable<Config['shortcuts']>;
const normalizeShortcut = (value: string | null | undefined): string | null | undefined => {
if (typeof value !== 'string') return value;
return value.replace(/\bKey([A-Z])\b/g, '$1').replace(/\bDigit([0-9])\b/g, '$1');
};
const shortcutValue = (key: ShortcutKey): string | null | undefined =>
Object.prototype.hasOwnProperty.call(config.shortcuts ?? {}, key)
? config.shortcuts?.[key]
: defaultConfig.shortcuts?.[key];
return {
toggleVisibleOverlayGlobal: normalizeShortcut(
config.shortcuts?.toggleVisibleOverlayGlobal ??
defaultConfig.shortcuts?.toggleVisibleOverlayGlobal,
),
copySubtitle: normalizeShortcut(
config.shortcuts?.copySubtitle ?? defaultConfig.shortcuts?.copySubtitle,
),
copySubtitleMultiple: normalizeShortcut(
config.shortcuts?.copySubtitleMultiple ?? defaultConfig.shortcuts?.copySubtitleMultiple,
),
toggleVisibleOverlayGlobal: normalizeShortcut(shortcutValue('toggleVisibleOverlayGlobal')),
copySubtitle: normalizeShortcut(shortcutValue('copySubtitle')),
copySubtitleMultiple: normalizeShortcut(shortcutValue('copySubtitleMultiple')),
updateLastCardFromClipboard: normalizeShortcut(
isAnkiConnectDisabled
? null
: (config.shortcuts?.updateLastCardFromClipboard ??
defaultConfig.shortcuts?.updateLastCardFromClipboard),
isAnkiConnectDisabled ? null : shortcutValue('updateLastCardFromClipboard'),
),
triggerFieldGrouping: normalizeShortcut(
isAnkiConnectDisabled
? null
: (config.shortcuts?.triggerFieldGrouping ?? defaultConfig.shortcuts?.triggerFieldGrouping),
),
triggerSubsync: normalizeShortcut(
config.shortcuts?.triggerSubsync ?? defaultConfig.shortcuts?.triggerSubsync,
),
mineSentence: normalizeShortcut(
isAnkiConnectDisabled
? null
: (config.shortcuts?.mineSentence ?? defaultConfig.shortcuts?.mineSentence),
isAnkiConnectDisabled ? null : shortcutValue('triggerFieldGrouping'),
),
triggerSubsync: normalizeShortcut(shortcutValue('triggerSubsync')),
mineSentence: normalizeShortcut(isAnkiConnectDisabled ? null : shortcutValue('mineSentence')),
mineSentenceMultiple: normalizeShortcut(
isAnkiConnectDisabled
? null
: (config.shortcuts?.mineSentenceMultiple ?? defaultConfig.shortcuts?.mineSentenceMultiple),
isAnkiConnectDisabled ? null : shortcutValue('mineSentenceMultiple'),
),
multiCopyTimeoutMs:
config.shortcuts?.multiCopyTimeoutMs ?? defaultConfig.shortcuts?.multiCopyTimeoutMs ?? 5000,
toggleSecondarySub: normalizeShortcut(
config.shortcuts?.toggleSecondarySub ?? defaultConfig.shortcuts?.toggleSecondarySub,
),
markAudioCard: normalizeShortcut(
isAnkiConnectDisabled
? null
: (config.shortcuts?.markAudioCard ?? defaultConfig.shortcuts?.markAudioCard),
),
openCharacterDictionary: normalizeShortcut(
config.shortcuts?.openCharacterDictionary ?? defaultConfig.shortcuts?.openCharacterDictionary,
),
openRuntimeOptions: normalizeShortcut(
config.shortcuts?.openRuntimeOptions ?? defaultConfig.shortcuts?.openRuntimeOptions,
),
openJimaku: normalizeShortcut(
config.shortcuts?.openJimaku ?? defaultConfig.shortcuts?.openJimaku,
),
openSessionHelp: normalizeShortcut(
config.shortcuts?.openSessionHelp ?? defaultConfig.shortcuts?.openSessionHelp,
),
openControllerSelect: normalizeShortcut(
config.shortcuts?.openControllerSelect ?? defaultConfig.shortcuts?.openControllerSelect,
),
openControllerDebug: normalizeShortcut(
config.shortcuts?.openControllerDebug ?? defaultConfig.shortcuts?.openControllerDebug,
),
toggleSubtitleSidebar: normalizeShortcut(
config.shortcuts?.toggleSubtitleSidebar ?? defaultConfig.shortcuts?.toggleSubtitleSidebar,
),
toggleSecondarySub: normalizeShortcut(shortcutValue('toggleSecondarySub')),
markAudioCard: normalizeShortcut(isAnkiConnectDisabled ? null : shortcutValue('markAudioCard')),
openCharacterDictionary: normalizeShortcut(shortcutValue('openCharacterDictionary')),
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')),
toggleSubtitleSidebar: normalizeShortcut(shortcutValue('toggleSubtitleSidebar')),
};
}
+51
View File
@@ -14,6 +14,7 @@ import {
shouldHandleHelpOnlyAtEntry,
shouldHandleLaunchMpvAtEntry,
shouldHandleStatsDaemonCommandAtEntry,
hasTransportedStartupArgs,
} from './main-entry-runtime';
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
@@ -55,6 +56,56 @@ test('normalizeStartupArgv defaults no-arg Windows startup to --start only', ()
}
});
test('normalizeStartupArgv uses transported AppImage args instead of raw Electron args', () => {
assert.deepEqual(
normalizeStartupArgv(['SubMiner.AppImage', '--background'], {
SUBMINER_APP_ARGC: '2',
SUBMINER_APP_ARG_0: '--stop',
SUBMINER_APP_ARG_1: '--socket',
}),
['SubMiner.AppImage', '--stop', '--socket'],
);
});
test('normalizeStartupArgv defaults empty transported AppImage args to background startup', () => {
const originalPlatform = process.platform;
try {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
assert.deepEqual(
normalizeStartupArgv(['SubMiner.AppImage', '--background'], {
SUBMINER_APP_ARGC: '0',
}),
['SubMiner.AppImage', '--start', '--background'],
);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
test('normalizeStartupArgv defaults passive-only transported AppImage args to background startup', () => {
const originalPlatform = process.platform;
try {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
assert.deepEqual(
normalizeStartupArgv(['SubMiner.AppImage'], {
SUBMINER_APP_ARGC: '2',
SUBMINER_APP_ARG_0: '--password-store',
SUBMINER_APP_ARG_1: 'gnome-libsecret',
}),
['SubMiner.AppImage', '--password-store', 'gnome-libsecret', '--start', '--background'],
);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
test('hasTransportedStartupArgs detects env-carried app args', () => {
assert.equal(hasTransportedStartupArgs({ SUBMINER_APP_ARGC: '1' }), true);
assert.equal(hasTransportedStartupArgs({}), false);
});
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
+40
View File
@@ -7,6 +7,9 @@ const BACKGROUND_ARG = '--background';
const START_ARG = '--start';
const PASSWORD_STORE_ARG = '--password-store';
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
const MAX_TRANSPORTED_APP_ARGS = 256;
const APP_NAME = 'SubMiner';
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
'--alang',
@@ -83,9 +86,46 @@ function parseCliArgs(argv: string[]): CliArgs {
return parseArgs(argv);
}
export function hasTransportedStartupArgs(env: NodeJS.ProcessEnv): boolean {
return typeof env[TRANSPORTED_APP_ARGC_ENV] === 'string';
}
function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null {
const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
if (rawCount === undefined) {
return null;
}
const count = Number(rawCount);
if (!Number.isInteger(count) || count < 0 || count > MAX_TRANSPORTED_APP_ARGS) {
return null;
}
const args: string[] = [];
for (let index = 0; index < count; index += 1) {
const value = env[`${TRANSPORTED_APP_ARG_PREFIX}${index}`];
if (typeof value !== 'string') {
return null;
}
args.push(value);
}
return args;
}
export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): string[] {
if (env.ELECTRON_RUN_AS_NODE === '1') return argv;
const transportedArgs = readTransportedStartupArgs(env);
if (transportedArgs) {
if (removePassiveStartupArgs(transportedArgs).length === 0) {
if (process.platform === 'win32') {
return [argv[0] ?? APP_NAME, ...transportedArgs, START_ARG];
}
return [argv[0] ?? APP_NAME, ...transportedArgs, START_ARG, BACKGROUND_ARG];
}
return [argv[0] ?? APP_NAME, ...transportedArgs];
}
const effectiveArgs = removePassiveStartupArgs(argv.slice(1));
if (effectiveArgs.length === 0) {
if (process.platform === 'win32') {
+3 -1
View File
@@ -13,6 +13,7 @@ import {
sanitizeBackgroundEnv,
sanitizeHelpEnv,
sanitizeLaunchMpvEnv,
hasTransportedStartupArgs,
shouldDetachBackgroundLaunch,
shouldHandleHelpOnlyAtEntry,
shouldHandleLaunchMpvAtEntry,
@@ -184,7 +185,8 @@ registerFatalErrorHandlers({
});
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
const child = spawn(process.execPath, process.argv.slice(1), {
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
const child = spawn(process.execPath, childArgs, {
detached: true,
stdio: 'ignore',
env: sanitizeBackgroundEnv(process.env),
+339 -62
View File
@@ -21,7 +21,6 @@ import {
clipboard,
globalShortcut,
ipcMain,
net,
shell,
protocol,
Extension,
@@ -35,6 +34,8 @@ import {
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
import { startAppControlServer } from './main/runtime/app-control-server';
import { getAppControlSocketPath } from './shared/app-control';
import {
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
@@ -91,7 +92,7 @@ protocol.registerSchemesAsPrivileged([
]);
import * as fs from 'fs';
import { spawn } from 'node:child_process';
import { execFile, spawn } from 'node:child_process';
import * as os from 'os';
import * as path from 'path';
import { MecabTokenizer } from './mecab-tokenizer';
@@ -104,6 +105,7 @@ import type {
RuntimeOptionState,
SessionActionDispatchRequest,
SecondarySubMode,
SubtitleCue,
SubtitleData,
SubtitlePosition,
UpdateChannel,
@@ -140,6 +142,7 @@ import {
} from './cli/args';
import { printHelp } from './cli/help';
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
import { AnkiConnectClient } from './anki-connect';
import {
getStartupModeFlags,
shouldRefreshAnilistOnConfigReload,
@@ -165,6 +168,7 @@ import {
rememberAnilistAttemptedUpdateKey,
} from './main/runtime/domains/anilist';
import { DEFAULT_MIN_WATCH_RATIO } from './shared/watch-threshold';
import { shouldShowTexthookerTrayEntry } from './main/runtime/tray-main-actions';
import {
createApplyJellyfinMpvDefaultsHandler,
createBuildApplyJellyfinMpvDefaultsMainDepsHandler,
@@ -310,6 +314,7 @@ import {
importYomitanDictionaryFromZip,
initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore,
initializeOverlayRuntime as initializeOverlayRuntimeCore,
isOverlayWindowContentReady,
jellyfinTicksToSecondsRuntime,
listJellyfinItemsRuntime,
listJellyfinLibrariesRuntime,
@@ -361,6 +366,8 @@ import {
createYoutubePrimarySubtitleNotificationRuntime,
} from './main/runtime/youtube-primary-subtitle-notification';
import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate';
import { selectAutoplayStartupCue } from './main/runtime/autoplay-subtitle-primer';
import { createAutoplayTokenizationWarmRelease } from './main/runtime/autoplay-tokenization-warm-release';
import { createManagedLocalSubtitleSelectionRuntime } from './main/runtime/local-subtitle-selection';
import {
buildFirstRunSetupHtml,
@@ -375,7 +382,6 @@ import {
detectInstalledMpvPlugin,
removeLegacyMpvPluginCandidates,
resolvePackagedRuntimePluginPath,
syncInstalledFirstRunPluginBinaryPath,
} from './main/runtime/first-run-setup-plugin';
import {
applyWindowsMpvShortcuts,
@@ -401,6 +407,7 @@ import {
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
import {
shouldEnsureTrayOnStartupForInitialArgs,
shouldQuitOnMpvShutdownForTrayState,
shouldQuitOnWindowAllClosedForTrayState,
} from './main/runtime/startup-tray-policy';
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
@@ -494,12 +501,13 @@ import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/
import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/character-dictionary-auto-sync-completion';
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import {
createElectronAppUpdater,
isNativeUpdaterSupported,
} from './main/runtime/update/app-updater';
import { createElectronNetFetch, createGlobalFetch } from './main/runtime/update/fetch-adapter';
import { createCurlFetch, createGlobalFetch } from './main/runtime/update/fetch-adapter';
import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor';
import { createFetchHttpExecutor } from './main/runtime/update/fetch-http-executor';
import {
@@ -536,6 +544,7 @@ import {
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
import { isYoutubePlaybackActive } from './main/runtime/youtube-playback';
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
import { reloadOverlayWindowsForYomitanContentScripts } from './main/runtime/yomitan-extension-overlay-reload';
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
import {
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
@@ -607,6 +616,7 @@ const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60;
const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
const TRAY_TOOLTIP = 'SubMiner';
const SUBMINER_BUNDLE_ID = 'com.sudacode.SubMiner';
const JELLYFIN_SETUP_PRELOAD_PATH = path.join(__dirname, 'preload-jellyfin-setup.js');
let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState =
@@ -664,14 +674,18 @@ const texthookerService = new Texthooker(() => {
const characterDictionaryEnabled =
config.anilist.characterDictionary.enabled &&
yomitanProfilePolicy.isCharacterDictionaryEnabled();
const knownAndNPlusOneEnabled = getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
const knownWordColoringEnabled = getRuntimeBooleanOption(
'subtitle.annotation.knownWords.highlightEnabled',
config.ankiConnect.knownWords.highlightEnabled,
);
const nPlusOneColoringEnabled = getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
config.ankiConnect.nPlusOne.enabled,
);
return {
enableKnownWordColoring: knownAndNPlusOneEnabled,
enableNPlusOneColoring: knownAndNPlusOneEnabled,
enableKnownWordColoring: knownWordColoringEnabled,
enableNPlusOneColoring: nPlusOneColoringEnabled,
enableNameMatchColoring: config.subtitleStyle.nameMatchEnabled && characterDictionaryEnabled,
enableFrequencyColoring: getRuntimeBooleanOption(
'subtitle.annotation.frequency',
@@ -682,8 +696,8 @@ const texthookerService = new Texthooker(() => {
config.subtitleStyle.enableJlpt,
),
characterDictionaryEnabled,
knownWordColor: config.ankiConnect.knownWords.color,
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
knownWordColor: config.subtitleStyle.knownWordColor,
nPlusOneColor: config.subtitleStyle.nPlusOneColor,
nameMatchColor: config.subtitleStyle.nameMatchColor,
hoverTokenColor: config.subtitleStyle.hoverTokenColor,
hoverTokenBackgroundColor: config.subtitleStyle.hoverTokenBackgroundColor,
@@ -722,6 +736,7 @@ type BootServices = MainBootServicesResult<
{
requestSingleInstanceLock: () => boolean;
quit: () => void;
exit: (code?: number) => void;
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
whenReady: () => Promise<void>;
}
@@ -778,7 +793,7 @@ const bootServices = createMainBootServices({
warn: (message: string, details?: unknown) => console.warn(message, details),
error: (message: string, details?: unknown) => console.error(message, details),
}),
createSubtitleWebSocket: () => new SubtitleWebSocket(),
createSubtitleWebSocket: (payloadMode) => new SubtitleWebSocket(payloadMode),
createLogger,
createMainRuntimeRegistry,
createOverlayManager,
@@ -1091,6 +1106,13 @@ const autoplayReadyGate = createAutoplayReadyGate({
signalPluginAutoplayReady: () => {
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']);
},
isSignalTargetReady: () => {
if (!overlayManager.getVisibleOverlayVisible()) {
return true;
}
const overlayWindow = overlayManager.getMainWindow();
return Boolean(overlayWindow && isOverlayWindowContentReady(overlayWindow));
},
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
logDebug: (message) => logger.debug(message),
});
@@ -1222,6 +1244,7 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) =>
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(mpvPath, detection),
},
getMpvPluginRuntimeConfig(),
),
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
@@ -1232,6 +1255,21 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
clearScheduled: (timer) => clearTimeout(timer),
});
function getMpvPluginRuntimeConfig() {
const config = getResolvedConfig();
return {
socketPath: appState.mpvSocketPath,
binaryPath: config.mpv.subminerBinaryPath,
backend: config.mpv.backend,
autoStart: config.mpv.autoStartSubMiner,
autoStartVisibleOverlay: config.auto_start_overlay,
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
texthookerEnabled: config.texthooker.launchAtStartup,
aniskipEnabled: config.mpv.aniskipEnabled,
aniskipButtonKey: config.mpv.aniskipButtonKey,
};
}
let firstRunSetupMessage: string | null = null;
const resolveWindowsMpvShortcutRuntimePaths = () =>
resolveWindowsMpvShortcutPaths({
@@ -1248,12 +1286,6 @@ const createCommandLineLauncherRuntimeOptions = () => ({
resourcesPath: process.resourcesPath,
appExePath: process.execPath,
});
syncInstalledFirstRunPluginBinaryPath({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
binaryPath: process.execPath,
});
const firstRunSetupService = createFirstRunSetupService({
platform: process.platform,
configDir: CONFIG_DIR,
@@ -1570,6 +1602,7 @@ function emitSubtitlePayload(payload: SubtitleData): void {
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
});
autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true });
subtitlePrefetchService?.resume();
}
const buildSubtitleProcessingControllerMainDepsHandler =
@@ -1593,6 +1626,88 @@ let lastObservedTimePos = 0;
let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null =
null;
const SEEK_THRESHOLD_SECONDS = 3;
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
let autoplaySubtitlePrimedMediaPath: string | null = null;
function getCurrentAutoplayMediaPath(): string | null {
return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null;
}
function isCurrentAutoplayMediaPath(mediaPath: string): boolean {
return getCurrentAutoplayMediaPath() === mediaPath;
}
function markAutoplaySubtitlePrimeConsumed(mediaPath: string): boolean {
if (autoplaySubtitlePrimedMediaPath === mediaPath) {
return false;
}
autoplaySubtitlePrimedMediaPath = mediaPath;
return true;
}
function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
return false;
}
if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
return false;
}
appState.currentSubText = text;
const rawPayload = withCurrentSubtitleTiming({ text, tokens: null });
appState.currentSubtitleData = rawPayload;
broadcastToOverlayWindows('subtitle:set', rawPayload);
subtitlePrefetchService?.pause();
subtitleProcessingController.onSubtitleChange(text);
return true;
}
async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise<void> {
const client = appState.mpvClient;
if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) {
return;
}
const subTextRaw = await client.requestProperty('sub-text').catch((error) => {
logger.debug(
`[autoplay-subtitle-prime] failed to read sub-text: ${
error instanceof Error ? error.message : String(error)
}`,
);
return null;
});
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
emitAutoplayPrimedSubtitle(mediaPath, text);
}
async function primeAutoplaySubtitleFromParsedCues(
mediaPath: string,
cues: SubtitleCue[],
): Promise<void> {
if (
cues.length === 0 ||
autoplaySubtitlePrimedMediaPath === mediaPath ||
!isCurrentAutoplayMediaPath(mediaPath)
) {
return;
}
const client = appState.mpvClient;
const timePosRaw = await client?.requestProperty('time-pos').catch(() => null);
const currentTimeSeconds = Number(
timePosRaw ?? client?.currentTimePos ?? lastObservedTimePos ?? 0,
);
const cue = selectAutoplayStartupCue(
cues,
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS,
);
if (!cue) {
return;
}
emitAutoplayPrimedSubtitle(mediaPath, cue.text);
}
function clearScheduledSubtitlePrefetchRefresh(): void {
if (subtitlePrefetchRefreshTimer) {
@@ -1625,6 +1740,16 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
appState.activeParsedSubtitleCues = cues ?? [];
appState.activeParsedSubtitleSource = sourceKey;
const mediaPath = getCurrentAutoplayMediaPath();
if (mediaPath && cues?.length) {
void primeAutoplaySubtitleFromParsedCues(mediaPath, cues).catch((error) => {
logger.debug(
`[autoplay-subtitle-prime] failed to prime from parsed cues: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
}
},
});
const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler({
@@ -1772,6 +1897,18 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
},
invalidateTokenizationCache: () => {
subtitleProcessingController.invalidateTokenizationCache();
},
refreshSubtitlePrefetch: () => {
subtitlePrefetchService?.onSeek(lastObservedTimePos);
},
refreshCurrentSubtitle: () => {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
},
setLogLevel: (level) => {
setLogLevel(level, 'config');
},
},
);
const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler(
@@ -1813,7 +1950,9 @@ const configSettingsRuntime = createConfigSettingsRuntime({
getConfig: () => configService.getConfig(),
getWarnings: () => configService.getWarnings(),
reloadConfigStrict: () => configService.reloadConfigStrict(),
applyHotReload: (diff, config) => applyConfigHotReloadDiff(diff, config),
onHotReloadApplied: applyConfigHotReloadDiff,
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
createAnkiClient: (url) => new AnkiConnectClient(url),
getSettingsWindow: () => appState.configSettingsWindow,
setSettingsWindow: (window) => {
appState.configSettingsWindow = window as BrowserWindow | null;
@@ -2570,7 +2709,11 @@ function getResolvedConfig() {
}
function getRuntimeBooleanOption(
id: 'subtitle.annotation.nPlusOne' | 'subtitle.annotation.jlpt' | 'subtitle.annotation.frequency',
id:
| 'subtitle.annotation.knownWords.highlightEnabled'
| 'subtitle.annotation.nPlusOne'
| 'subtitle.annotation.jlpt'
| 'subtitle.annotation.frequency',
fallback: boolean,
): boolean {
const value = appState.runtimeOptionsManager?.getOptionValue(id);
@@ -2579,9 +2722,13 @@ function getRuntimeBooleanOption(
function shouldInitializeMecabForAnnotations(): boolean {
const config = getResolvedConfig();
const knownWordsEnabled = getRuntimeBooleanOption(
'subtitle.annotation.knownWords.highlightEnabled',
config.ankiConnect.knownWords.highlightEnabled,
);
const nPlusOneEnabled = getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
config.ankiConnect.knownWords.highlightEnabled,
config.ankiConnect.nPlusOne.enabled,
);
const jlptEnabled = getRuntimeBooleanOption(
'subtitle.annotation.jlpt',
@@ -2591,7 +2738,7 @@ function shouldInitializeMecabForAnnotations(): boolean {
'subtitle.annotation.frequency',
config.subtitleStyle.frequencyDictionary.enabled,
);
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
return knownWordsEnabled || nPlusOneEnabled || jlptEnabled || frequencyEnabled;
}
const {
@@ -2623,6 +2770,7 @@ const {
getLaunchMode: () => getResolvedConfig().mpv.launchMode,
platform: process.platform,
execPath: process.execPath,
getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(),
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS,
removeSocketPath: (socketPath) => {
@@ -2928,6 +3076,16 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
return;
}
if (submission.action === 'open-config-settings') {
const opened = openConfigSettingsWindow();
firstRunSetupMessage = opened
? 'Opened SubMiner settings.'
: 'SubMiner settings are unavailable.';
if (opened) {
return { skipRender: true };
}
return;
}
if (submission.action === 'refresh') {
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
firstRunSetupMessage = snapshot.message;
@@ -3399,8 +3557,9 @@ const {
stopConfigHotReload: () => configHotReloadRuntime.stop(),
restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(),
restoreMpvSubVisibility: () => {
restoreOverlayMpvSubtitles();
restoreOverlayMpvSubtitles({ force: true });
},
isAppReady: () => app.isReady(),
unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(),
stopSubtitleWebsocket: () => {
subtitleWsService.stop();
@@ -4030,6 +4189,9 @@ async function ensureYoutubePlaybackRuntimeReady(): Promise<void> {
ensureOverlayWindowsReadyForVisibilityActions();
}
let signalAutoplayReadyFromWarmTokenization: ((path: string | null | undefined) => void) | null =
null;
const {
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
@@ -4055,6 +4217,17 @@ const {
reportJellyfinRemoteStopped: () => {
void reportJellyfinRemoteStopped();
},
onMpvConnected: () => {
if (appState.sessionBindingsInitialized) {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
'subminer-reload-session-bindings',
]);
}
if (appState.currentSubText.trim()) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
},
maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options),
recordAnilistMediaDuration: (durationSec) => {
recordAnilistMediaDuration(durationSec);
@@ -4080,6 +4253,27 @@ const {
tokenizeSubtitleForImmersion: async (text): Promise<SubtitleData | null> =>
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
updateCurrentMediaPath: (path) => {
const normalizedPath = path.trim();
const previousPath = appState.currentMediaPath?.trim() || null;
if ((normalizedPath || null) !== previousPath) {
const resetSubtitlePayload = { text: '', tokens: null };
const frequencyDictionary = getResolvedConfig().subtitleStyle.frequencyDictionary;
const frequencyOptions = {
enabled: frequencyDictionary.enabled,
topX: frequencyDictionary.topX,
mode: frequencyDictionary.mode,
};
autoplaySubtitlePrimedMediaPath = null;
lastObservedTimePos = 0;
appState.currentSubText = '';
appState.currentSubAssText = '';
appState.currentSubtitleData = null;
appState.activeParsedSubtitleCues = [];
appState.activeParsedSubtitleSource = null;
broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
}
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
currentMediaTokenizationGate.updateCurrentMediaPath(path);
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
@@ -4089,7 +4283,8 @@ const {
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
if (path) {
ensureImmersionTrackerStarted();
// Delay slightly to allow MPV's track-list to be populated.
void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack();
// Retry after a short delay because MPV can populate track-list after path.
subtitlePrefetchRuntime.scheduleSubtitlePrefetchRefresh(500);
}
mediaRuntime.updateCurrentMediaPath(path);
@@ -4113,15 +4308,7 @@ const {
syncImmersionMediaState: () => {
immersionMediaRuntime.syncFromCurrentMediaState();
},
signalAutoplayReadyIfWarm: () => {
if (!isTokenizationWarmupReady()) {
return;
}
autoplayReadyGate.maybeSignalPluginAutoplayReady(
{ text: '__warm__', tokens: null },
{ forceWhilePaused: true },
);
},
signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path),
scheduleCharacterDictionarySync: () => {
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) {
return;
@@ -4185,7 +4372,12 @@ const {
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
appState.reconnectTimer = timer;
},
shouldQuitOnMpvShutdown: () => appState.initialArgs?.managedPlayback === true,
shouldQuitOnMpvShutdown: () =>
shouldQuitOnMpvShutdownForTrayState({
managedPlayback: appState.initialArgs?.managedPlayback === true,
backgroundMode: appState.backgroundMode,
hasTray: Boolean(appTray),
}),
requestAppQuit: () => requestAppQuit(),
},
updateMpvSubtitleRenderMetricsMainDeps: {
@@ -4222,10 +4414,15 @@ const {
getKnownWordMatchMode: () =>
appState.ankiIntegration?.getKnownWordMatchMode() ??
getResolvedConfig().ankiConnect.knownWords.matchMode,
getKnownWordsEnabled: () =>
getRuntimeBooleanOption(
'subtitle.annotation.knownWords.highlightEnabled',
getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
),
getNPlusOneEnabled: () =>
getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
getResolvedConfig().ankiConnect.nPlusOne.enabled,
),
getMinSentenceWordsForNPlusOne: () =>
getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
@@ -4250,15 +4447,11 @@ const {
getFrequencyRank: (text) => appState.frequencyRankLookup(text),
getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled,
getMecabTokenizer: () => appState.mecabTokenizer,
onTokenizationReady: (text) => {
onTokenizationReady: () => {
currentMediaTokenizationGate.markReady(
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
);
startupOsdSequencer.markTokenizationReady();
autoplayReadyGate.maybeSignalPluginAutoplayReady(
{ text, tokens: null },
{ forceWhilePaused: true },
);
},
},
createTokenizerRuntimeDeps: (deps) =>
@@ -4335,6 +4528,22 @@ const {
},
},
});
signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease({
isTokenizationWarmupReady: () => isTokenizationWarmupReady(),
startTokenizationWarmups: async () => {
await startTokenizationWarmups();
},
getCurrentMediaPath: () =>
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
primeCurrentSubtitle: (mediaPath) => primeCurrentSubtitleForAutoplay(mediaPath),
signalAutoplayReady: () => {
autoplayReadyGate.maybeSignalPluginAutoplayReady(
{ text: '__warm__', tokens: null },
{ forceWhilePaused: true },
);
},
warn: (message, error) => logger.warn(message, error),
});
tokenizeSubtitleDeferred = tokenizeSubtitle;
function createMpvClientRuntimeService(): MpvIpcClient {
@@ -4634,6 +4843,7 @@ function compileCurrentSessionBindings(): {
keybindings: appState.keybindings,
shortcuts: getConfiguredShortcuts(),
statsToggleKey: getResolvedConfig().stats.toggleKey,
statsMarkWatchedKey: getResolvedConfig().stats.markWatchedKey,
platform: resolveSessionBindingPlatform(),
rawConfig: getResolvedConfig(),
});
@@ -4693,26 +4903,19 @@ flushPendingMpvLogWrites = () => {
const updateStateStore = createFileUpdateStateStore(path.join(USER_DATA_PATH, 'update-state.json'));
let updateService: ReturnType<typeof createUpdateService> | null = null;
const electronNetFetch = createElectronNetFetch({
fetch: (url, init) => net.fetch(url, init as RequestInit),
});
const globalFetchForUpdater = createGlobalFetch();
const curlFetch = createCurlFetch();
function createNativeUpdaterHttpExecutor() {
if (process.platform === 'darwin') {
return createCurlHttpExecutor();
}
if (process.platform === 'win32') {
return createFetchHttpExecutor();
}
return undefined;
return createCurlHttpExecutor();
}
function getFetchForUpdater() {
if (process.platform === 'win32') {
return globalFetchForUpdater;
}
return electronNetFetch;
if (process.platform === 'win32') return globalFetchForUpdater;
return curlFetch;
}
async function updateLauncherFromSelectedRelease(
@@ -4759,11 +4962,8 @@ function getUpdateService() {
isPackaged: app.isPackaged,
log: (message) => logger.info(message),
getChannel: () => getResolvedConfig().updates.channel,
configureHttpExecutor:
process.platform === 'darwin' || process.platform === 'win32'
? createNativeUpdaterHttpExecutor
: undefined,
disableDifferentialDownload: process.platform === 'darwin' || process.platform === 'win32',
configureHttpExecutor: createNativeUpdaterHttpExecutor,
disableDifferentialDownload: true,
isNativeUpdaterSupported: () =>
isNativeUpdaterSupported({
platform: process.platform,
@@ -4775,7 +4975,37 @@ function getUpdateService() {
});
const updateDialogPresenter = createUpdateDialogPresenter({
platform: process.platform,
focusApp: () => app.focus({ steal: true }),
focusApp: async () => {
if (process.platform !== 'darwin') {
app.focus({ steal: true });
return;
}
try {
await app.dock?.show();
} catch (error) {
logger.warn('Failed to show macOS dock before update dialog', error);
}
// app.focus({ steal: true }) alone does not reliably activate the process
// when SubMiner was reached via `subminer -u` (single-instance forwarding
// from a CLI-spawned child). osascript's `activate` uses LaunchServices,
// which is the only path that reliably brings the running app forward.
await new Promise<void>((resolve) => {
execFile(
'/usr/bin/osascript',
['-e', `tell application id "${SUBMINER_BUNDLE_ID}" to activate`],
{ timeout: 2000 },
(error) => {
if (error) {
logger.warn(
`Failed to activate SubMiner via osascript: ${error instanceof Error ? error.message : String(error)}`,
);
}
resolve();
},
);
});
app.focus({ steal: true });
},
showMessageBox: (options) => dialog.showMessageBox(options),
});
updateService = createUpdateService({
@@ -5134,6 +5364,18 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
toggleSecondarySub: () => handleCycleSecondarySubMode(),
toggleSubtitleSidebar: () => toggleSubtitleSidebar(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
markActiveVideoWatched: async () => {
ensureImmersionTrackerStarted();
const marked = (await appState.immersionTracker?.markActiveVideoWatched()) ?? false;
if (marked) {
try {
await maybeRunAnilistPostWatchUpdate({ force: true });
} catch (error) {
logger.warn('Failed to run AniList post-watch update after manual watched mark:', error);
}
}
return marked;
},
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => openJimakuOverlay(),
openSessionHelp: () => openSessionHelpOverlay(),
@@ -5155,6 +5397,8 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
(text) => showMpvOsd(text),
);
},
playNextPlaylistItem: () =>
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
showMpvOsd: (text) => showMpvOsd(text),
});
}
@@ -5242,8 +5486,17 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
openYomitanSettings: () => openYomitanSettings(),
quitApp: () => requestAppQuit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: async () =>
withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)),
tokenizeCurrentSubtitle: async () => {
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
return resolveCurrentSubtitleForRenderer({
currentSubText: appState.currentSubText,
currentSubtitleData: appState.currentSubtitleData,
withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload),
tokenizeSubtitle: tokenizeSubtitleForCurrent
? (text) => tokenizeSubtitleForCurrent(text)
: undefined,
});
},
getCurrentSubtitleRaw: () => appState.currentSubText,
getCurrentSubtitleAss: () => appState.currentSubAssText,
getSubtitleSidebarSnapshot: async () => {
@@ -5556,6 +5809,16 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
startControlServer: (handleArgv: (argv: string[]) => void) => {
const server = startAppControlServer({
socketPath: getAppControlSocketPath({ configDir: CONFIG_DIR }),
platform: process.platform,
handleArgv,
logDebug: (message) => logger.debug(message),
logWarn: (message, error) => logger.warn(message, error),
});
return () => server.close();
},
onReady: runAppReadyRuntimeWithFatalReporting,
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
@@ -5580,7 +5843,7 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
enforceUnsupportedWaylandMode(args);
},
shouldStartApp: (args: CliArgs) => shouldStartApp(args),
getDefaultSocketPath: () => getDefaultSocketPath(),
getDefaultSocketPath: () => getResolvedConfig().mpv.socketPath || getDefaultSocketPath(),
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
configDir: CONFIG_DIR,
defaultConfig: DEFAULT_CONFIG,
@@ -5646,7 +5909,13 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
onWindowContentReady: () => {
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
if (appState.currentSubText.trim()) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
autoplayReadyGate.flushPendingAutoplayReadySignal();
},
onWindowClosed: (windowKind) => {
if (windowKind === 'visible') {
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
@@ -5697,12 +5966,11 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
openSessionHelpModal: () => openSessionHelpOverlay(),
openTexthookerInBrowser: () =>
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
showTexthookerPage: () => getResolvedConfig().texthooker.launchAtStartup !== false,
showTexthookerPage: () => shouldShowTexthookerTrayEntry(getResolvedConfig()),
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
openYomitanSettings: () => openYomitanSettings(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openConfigSettingsWindow: () => openConfigSettingsWindow(),
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
isJellyfinConfigured: () =>
@@ -5769,6 +6037,15 @@ const yomitanExtensionRuntime = createYomitanExtensionRuntime({
setLoadInFlight: (promise) => {
yomitanLoadInFlight = promise;
},
onYomitanExtensionLoaded: () => {
const reloaded = reloadOverlayWindowsForYomitanContentScripts(
getOverlayWindows(),
(message, error) => logger.warn(message, error),
);
if (reloaded > 0) {
logger.debug(`Reloaded ${reloaded} overlay window(s) after Yomitan extension load.`);
}
},
});
const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
runtimeRegistry.overlay.createOverlayRuntimeBootstrapHandlers({
+2
View File
@@ -11,6 +11,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
@@ -73,6 +74,7 @@ export function createAppLifecycleRuntimeDeps(
handleCliCommand: params.handleCliCommand,
printHelp: params.printHelp,
logNoRunningInstance: params.logNoRunningInstance,
startControlServer: params.startControlServer,
onReady: params.onReady,
onWillQuitCleanup: params.onWillQuitCleanup,
shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate,
+13 -3
View File
@@ -6,6 +6,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
type MockAppLifecycleApp = {
requestSingleInstanceLock: () => boolean;
quit: () => void;
exit: (code?: number) => void;
on: (event: string, listener: (...args: unknown[]) => void) => MockAppLifecycleApp;
whenReady: () => Promise<void>;
};
@@ -20,7 +21,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
{ targetPath: string },
{ targetPath: string },
{ targetPath: string },
{ kind: string },
{ kind: string; payloadMode: 'plain' | 'annotated' },
{ scope: string; warn: () => void; info: () => void; error: () => void },
{ registry: boolean },
{ getMainWindow: () => null; getModalWindow: () => null },
@@ -54,6 +55,9 @@ test('createMainBootServices builds boot-phase service bundle', () => {
setPathValue = value;
},
quit: () => {},
exit: (code?: number) => {
calls.push(`exit:${code ?? 0}`);
},
on: (event: string) => {
appOnCalls.push(event);
return {};
@@ -72,7 +76,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
createAnilistTokenStore: (targetPath) => ({ targetPath }),
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
createSubtitleWebSocket: () => ({ kind: 'ws' }),
createSubtitleWebSocket: (payloadMode) => ({ kind: 'ws', payloadMode }),
createLogger: (scope) =>
({
scope,
@@ -111,6 +115,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
assert.deepEqual(services.anilistUpdateQueue, {
targetPath: '/tmp/subminer-config/anilist-retry-queue.json',
});
assert.deepEqual(services.subtitleWsService, { kind: 'ws', payloadMode: 'plain' });
assert.deepEqual(services.annotationSubtitleWsService, {
kind: 'ws',
payloadMode: 'annotated',
});
assert.deepEqual(services.appState, {
mpvSocketPath: '/tmp/subminer.sock',
texthookerPort: 5174,
@@ -123,8 +132,9 @@ test('createMainBootServices builds boot-phase service bundle', () => {
services.appLifecycleApp.on('second-instance', () => {}),
services.appLifecycleApp,
);
services.appLifecycleApp.exit(7);
assert.deepEqual(appOnCalls, ['ready']);
assert.equal(secondInstanceHandlerRegistered, true);
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']);
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']);
assert.equal(setPathValue, '/tmp/subminer-config');
});
+6 -3
View File
@@ -4,6 +4,7 @@ import { ConfigStartupParseError } from '../../config';
export interface AppLifecycleShape {
requestSingleInstanceLock: () => boolean;
quit: () => void;
exit: (code?: number) => void;
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
whenReady: () => Promise<void>;
}
@@ -50,6 +51,7 @@ export interface MainBootServicesParams<
app: {
setPath: (name: string, value: string) => void;
quit: () => void;
exit: (code?: number) => void;
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures
on: Function;
whenReady: () => Promise<void>;
@@ -62,7 +64,7 @@ export interface MainBootServicesParams<
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore;
createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue;
createSubtitleWebSocket: () => TSubtitleWebSocket;
createSubtitleWebSocket: (payloadMode: 'plain' | 'annotated') => TSubtitleWebSocket;
createLogger: (scope: string) => TLogger & {
warn: (message: string) => void;
info: (message: string) => void;
@@ -203,8 +205,8 @@ export function createMainBootServices<
const anilistUpdateQueue = params.createAnilistUpdateQueue(
params.joinPath(userDataPath, 'anilist-retry-queue.json'),
);
const subtitleWsService = params.createSubtitleWebSocket();
const annotationSubtitleWsService = params.createSubtitleWebSocket();
const subtitleWsService = params.createSubtitleWebSocket('plain');
const annotationSubtitleWsService = params.createSubtitleWebSocket('annotated');
const logger = params.createLogger('main');
const runtimeRegistry = params.createMainRuntimeRegistry();
const overlayManager = params.createOverlayManager();
@@ -260,6 +262,7 @@ export function createMainBootServices<
requestSingleInstanceLock: () =>
params.shouldBypassSingleInstanceLock() ? true : params.requestSingleInstanceLockEarly(),
quit: () => params.app.quit(),
exit: (code?: number) => params.app.exit(code),
on: (event: string, listener: (...args: unknown[]) => void) => {
if (event === 'second-instance') {
params.registerSecondInstanceHandlerEarly(
+40 -5
View File
@@ -9,22 +9,40 @@ import * as earlySingleInstance from './early-single-instance';
function createFakeApp(lockValue = true) {
let requestCalls = 0;
let secondInstanceListener: ((_event: unknown, argv: string[]) => void) | null = null;
let requestData: unknown = null;
let secondInstanceListener:
| ((
_event: unknown,
argv: string[],
workingDirectory?: string,
additionalData?: unknown,
) => void)
| null = null;
return {
app: {
requestSingleInstanceLock: () => {
requestSingleInstanceLock: (additionalData?: unknown) => {
requestCalls += 1;
requestData = additionalData ?? null;
return lockValue;
},
on: (_event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => {
on: (
_event: 'second-instance',
listener: (
_event: unknown,
argv: string[],
workingDirectory?: string,
additionalData?: unknown,
) => void,
) => {
secondInstanceListener = listener;
},
},
emitSecondInstance: (argv: string[]) => {
secondInstanceListener?.({}, argv);
emitSecondInstance: (argv: string[], additionalData?: unknown) => {
secondInstanceListener?.({}, argv, '/tmp', additionalData);
},
getRequestCalls: () => requestCalls,
getRequestData: () => requestData,
};
}
@@ -56,6 +74,23 @@ test('registerSecondInstanceHandlerEarly replays queued argv and forwards new ev
]);
});
test('requestSingleInstanceLockEarly sends normalized argv through second-instance data', () => {
resetEarlySingleInstanceStateForTests();
const fake = createFakeApp(true);
const primaryArgv = ['SubMiner.AppImage', '--start'];
const transportedArgv = ['SubMiner.AppImage', '--stop'];
const calls: string[][] = [];
assert.equal(requestSingleInstanceLockEarly(fake.app, primaryArgv), true);
registerSecondInstanceHandlerEarly(fake.app, (_event, argv) => {
calls.push(argv);
});
fake.emitSecondInstance(['SubMiner.AppImage'], { subminerArgv: transportedArgv });
assert.deepEqual(fake.getRequestData(), { subminerArgv: primaryArgv });
assert.deepEqual(calls, [transportedArgv]);
});
test('stats daemon args bypass the normal single-instance lock path', () => {
const shouldBypass = (
earlySingleInstance as typeof earlySingleInstance & {
+35 -6
View File
@@ -1,8 +1,18 @@
interface ElectronSecondInstanceAppLike {
requestSingleInstanceLock: () => boolean;
on: (event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => unknown;
requestSingleInstanceLock: (additionalData?: Record<string, unknown>) => boolean;
on: (
event: 'second-instance',
listener: (
_event: unknown,
argv: string[],
workingDirectory?: string,
additionalData?: unknown,
) => void,
) => unknown;
}
const SECOND_INSTANCE_ARGV_KEY = 'subminerArgv';
export function shouldBypassSingleInstanceLockForArgv(argv: readonly string[]): boolean {
return argv.includes('--stats-background') || argv.includes('--stats-stop');
}
@@ -12,10 +22,24 @@ let secondInstanceListenerAttached = false;
const secondInstanceArgvHistory: string[][] = [];
const secondInstanceHandlers = new Set<(_event: unknown, argv: string[]) => void>();
function normalizeSecondInstanceArgv(argv: string[], additionalData: unknown): string[] {
if (
additionalData &&
typeof additionalData === 'object' &&
Array.isArray((additionalData as { subminerArgv?: unknown }).subminerArgv) &&
(additionalData as { subminerArgv: unknown[] }).subminerArgv.every(
(value) => typeof value === 'string',
)
) {
return [...(additionalData as { subminerArgv: string[] }).subminerArgv];
}
return [...argv];
}
function attachSecondInstanceListener(app: ElectronSecondInstanceAppLike): void {
if (secondInstanceListenerAttached) return;
app.on('second-instance', (event, argv) => {
const clonedArgv = [...argv];
app.on('second-instance', (event, argv, _workingDirectory, additionalData) => {
const clonedArgv = normalizeSecondInstanceArgv(argv, additionalData);
secondInstanceArgvHistory.push(clonedArgv);
for (const handler of secondInstanceHandlers) {
handler(event, [...clonedArgv]);
@@ -24,12 +48,17 @@ function attachSecondInstanceListener(app: ElectronSecondInstanceAppLike): void
secondInstanceListenerAttached = true;
}
export function requestSingleInstanceLockEarly(app: ElectronSecondInstanceAppLike): boolean {
export function requestSingleInstanceLockEarly(
app: ElectronSecondInstanceAppLike,
argv: readonly string[] = process.argv,
): boolean {
attachSecondInstanceListener(app);
if (cachedSingleInstanceLock !== null) {
return cachedSingleInstanceLock;
}
cachedSingleInstanceLock = app.requestSingleInstanceLock();
cachedSingleInstanceLock = app.requestSingleInstanceLock({
[SECOND_INSTANCE_ARGV_KEY]: [...argv],
});
return cachedSingleInstanceLock;
}
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
function readMainSource(): string {
return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8');
}
test('manual watched session action starts immersion tracker before marking watched', () => {
const source = readMainSource();
const actionBlock = source.match(
/markActiveVideoWatched:\s*async\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\}\s*,/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /ensureImmersionTrackerStarted\(\);/);
assert.ok(
actionBlock.indexOf('ensureImmersionTrackerStarted();') <
actionBlock.indexOf('markActiveVideoWatched()'),
);
});
test('media path changes clear rendered subtitle state', () => {
const source = readMainSource();
const actionBlock = source.match(
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /appState\.currentSubText = '';/);
assert.match(actionBlock, /appState\.currentSubAssText = '';/);
assert.match(actionBlock, /appState\.currentSubtitleData = null;/);
assert.match(actionBlock, /appState\.activeParsedSubtitleCues = \[\];/);
assert.match(actionBlock, /appState\.activeParsedSubtitleSource = null;/);
assert.match(actionBlock, /lastObservedTimePos = 0;/);
assert.match(actionBlock, /broadcastToOverlayWindows\('subtitle:set',/);
assert.match(actionBlock, /subtitleWsService\.broadcast\(/);
assert.match(actionBlock, /annotationSubtitleWsService\.broadcast\(/);
assert.ok(
actionBlock.indexOf('appState.currentSubtitleData = null;') <
actionBlock.indexOf("broadcastToOverlayWindows('subtitle:set'"),
);
});
test('main process uses one shared mpv plugin runtime config helper', () => {
const source = readMainSource();
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
assert.equal((source.match(/socketPath: appState\.mpvSocketPath/g) ?? []).length, 1);
assert.equal(
(source.match(/binaryPath: getResolvedConfig\(\)\.mpv\.subminerBinaryPath/g) ?? []).length,
0,
);
});
+131
View File
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { sendAppControlCommand } from '../../shared/app-control-client';
import { startAppControlServer } from './app-control-server';
async function waitForSocketPath(socketPath: string): Promise<void> {
const timeoutMs = 1000;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (fs.existsSync(socketPath)) return;
await new Promise<void>((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Timed out waiting for control socket ${socketPath} after ${timeoutMs}ms`);
}
test('app control server dispatches argv requests and replies ok', async () => {
if (process.platform === 'win32') return;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-control-test-'));
const socketPath = path.join(dir, 'control.sock');
const received: string[][] = [];
const server = startAppControlServer({
socketPath,
platform: 'linux',
handleArgv: (argv) => {
received.push(argv);
},
});
try {
await waitForSocketPath(socketPath);
const result = await sendAppControlCommand(['--start', '--socket', '/tmp/mpv.sock'], {
socketPath,
});
assert.deepEqual(result, { ok: true });
assert.deepEqual(received, [['--start', '--socket', '/tmp/mpv.sock']]);
} finally {
server.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('app control server rejects requests larger than 64KB by UTF-8 byte length', async () => {
if (process.platform === 'win32') return;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-control-test-'));
const socketPath = path.join(dir, 'control.sock');
const received: string[][] = [];
const server = startAppControlServer({
socketPath,
platform: 'linux',
handleArgv: (argv) => {
received.push(argv);
},
});
try {
await waitForSocketPath(socketPath);
const result = await sendAppControlCommand(
Array.from({ length: 4 }, () => 'あ'.repeat(6000)),
{
socketPath,
},
);
assert.deepEqual(result, { ok: false, error: 'App control request too large' });
assert.deepEqual(received, []);
} finally {
server.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('app control server logs and closes errored client sockets', () => {
const originalCreateServer = net.createServer;
let socketHandler: ((socket: net.Socket) => void) | null = null;
const fakeServer = new EventEmitter() as net.Server;
fakeServer.listen = (() => fakeServer) as net.Server['listen'];
fakeServer.close = ((callback?: (err?: Error) => void) => {
callback?.();
return fakeServer;
}) as net.Server['close'];
const received: string[][] = [];
const warnings: Array<{ message: string; error?: unknown }> = [];
try {
net.createServer = ((handler?: (socket: net.Socket) => void) => {
socketHandler = handler ?? null;
return fakeServer;
}) as typeof net.createServer;
const server = startAppControlServer({
socketPath: '\\\\.\\pipe\\subminer-test-control',
platform: 'win32',
handleArgv: (argv) => {
received.push(argv);
},
logWarn: (message, error) => {
warnings.push({ message, error });
},
});
const error = new Error('client reset');
let destroyed = false;
const socket = new EventEmitter() as net.Socket;
socket.destroy = (() => {
destroyed = true;
return socket;
}) as net.Socket['destroy'];
const handler = socketHandler as ((socket: net.Socket) => void) | null;
assert.ok(handler);
handler(socket);
socket.emit('error', error);
socket.emit('data', Buffer.from('{"argv":["--start"]}\n'));
assert.equal(destroyed, true);
assert.deepEqual(received, []);
assert.deepEqual(warnings, [{ message: 'App control client socket error.', error }]);
server.close();
} finally {
net.createServer = originalCreateServer;
}
});
+105
View File
@@ -0,0 +1,105 @@
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import {
encodeAppControlResponse,
parseAppControlRequestLine,
type AppControlResponse,
} from '../../shared/app-control';
export interface AppControlServerOptions {
socketPath: string;
platform?: NodeJS.Platform;
handleArgv: (argv: string[]) => void;
logDebug?: (message: string) => void;
logWarn?: (message: string, error?: unknown) => void;
}
export interface AppControlServerHandle {
close: () => void;
}
function prepareSocketPath(socketPath: string, platform: NodeJS.Platform): void {
if (platform === 'win32') return;
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
fs.rmSync(socketPath, { force: true });
}
function cleanupSocketPath(socketPath: string, platform: NodeJS.Platform): void {
if (platform === 'win32') return;
try {
fs.rmSync(socketPath, { force: true });
} catch {
// ignore
}
}
function writeResponse(socket: net.Socket, response: AppControlResponse): void {
socket.end(encodeAppControlResponse(response));
}
export function startAppControlServer(options: AppControlServerOptions): AppControlServerHandle {
const platform = options.platform ?? process.platform;
prepareSocketPath(options.socketPath, platform);
const server = net.createServer((socket) => {
let buffer = '';
let byteCount = 0;
let handled = false;
socket.on('error', (error) => {
if (handled) return;
handled = true;
options.logWarn?.('App control client socket error.', error);
socket.destroy();
});
socket.on('data', (chunk) => {
if (handled) return;
byteCount += chunk.length;
buffer += chunk.toString('utf8');
if (byteCount > 65536) {
handled = true;
writeResponse(socket, { ok: false, error: 'App control request too large' });
return;
}
const newlineIndex = buffer.indexOf('\n');
if (newlineIndex < 0) return;
handled = true;
try {
const request = parseAppControlRequestLine(buffer.slice(0, newlineIndex));
options.handleArgv(request.argv);
writeResponse(socket, { ok: true });
} catch (error) {
options.logWarn?.('Failed to handle app control command.', error);
writeResponse(socket, {
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
});
});
server.on('error', (error) => {
options.logWarn?.(`App control socket failed: ${options.socketPath}`, error);
});
server.listen(options.socketPath, () => {
options.logDebug?.(`App control socket listening: ${options.socketPath}`);
});
let closed = false;
return {
close: () => {
if (closed) return;
closed = true;
try {
server.close();
} catch {
// ignore
}
cleanupSocketPath(options.socketPath, platform);
},
};
}
@@ -15,6 +15,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
stopConfigHotReload: () => calls.push('stop-config'),
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
isAppReady: () => true,
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
@@ -102,6 +103,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
isAppReady: () => true,
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
@@ -148,3 +150,51 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
assert.deepEqual(calls, []);
});
test('cleanup deps builder skips global shortcut cleanup before app ready', () => {
const calls: string[] = [];
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
destroyTray: () => calls.push('destroy-tray'),
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
isAppReady: () => false,
unregisterAllGlobalShortcuts: () => {
throw new Error('globalShortcut cannot be used before the app is ready');
},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => null,
clearMainOverlayWindow: () => {},
getModalOverlayWindow: () => null,
clearModalOverlayWindow: () => {},
getYomitanParserWindow: () => null,
clearYomitanParserState: () => {},
getWindowTracker: () => null,
flushMpvLog: () => {},
getMpvSocket: () => null,
getReconnectTimer: () => null,
clearReconnectTimerRef: () => {},
getSubtitleTimingTracker: () => null,
getImmersionTracker: () => null,
clearImmersionTracker: () => {},
getAnkiIntegration: () => null,
getAnilistSetupWindow: () => null,
clearAnilistSetupWindow: () => {},
getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {},
getFirstRunSetupWindow: () => null,
clearFirstRunSetupWindow: () => {},
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {},
stopDiscordPresenceService: () => {},
});
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
cleanup();
assert.deepEqual(calls, ['destroy-tray']);
});
@@ -22,6 +22,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
stopConfigHotReload: () => void;
restorePreviousSecondarySubVisibility: () => void;
restoreMpvSubVisibility: () => void;
isAppReady: () => boolean;
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
@@ -63,7 +64,10 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
stopConfigHotReload: () => deps.stopConfigHotReload(),
restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(),
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
unregisterAllGlobalShortcuts: () => {
if (!deps.isAppReady()) return;
deps.unregisterAllGlobalShortcuts();
},
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
stopTexthookerService: () => deps.stopTexthookerService(),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
@@ -143,3 +143,92 @@ test('autoplay ready gate does not unpause again after a later manual pause on t
1,
);
});
test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => {
const commands: Array<Array<string | boolean>> = [];
let targetReady = false;
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
isSignalTargetReady: () => targetReady,
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands, []);
targetReady = true;
gate.flushPendingAutoplayReadySignal();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
assert.equal(
commands.some(
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
),
true,
);
});
test('autoplay ready gate drops deferred readiness after media changes before flush', async () => {
const commands: Array<Array<string | boolean>> = [];
let targetReady = false;
let currentMediaPath = '/media/video-1.mkv';
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => currentMediaPath,
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
isSignalTargetReady: () => targetReady,
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
currentMediaPath = '/media/video-2.mkv';
targetReady = true;
gate.flushPendingAutoplayReadySignal();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands, []);
});
+39 -2
View File
@@ -14,6 +14,7 @@ export type AutoplayReadyGateDeps = {
getPlaybackPaused: () => boolean | null;
getMpvClient: () => MpvClientLike | null;
signalPluginAutoplayReady: () => void;
isSignalTargetReady?: () => boolean;
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logDebug: (message: string) => void;
};
@@ -21,12 +22,23 @@ export type AutoplayReadyGateDeps = {
export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
let autoPlayReadySignalMediaPath: string | null = null;
let autoPlayReadySignalGeneration = 0;
let pendingAutoplayReadySignal: {
mediaPath: string;
payload: SubtitleData;
options?: { forceWhilePaused?: boolean };
} | null = null;
const invalidatePendingAutoplayReadyFallbacks = (): void => {
autoPlayReadySignalMediaPath = null;
pendingAutoplayReadySignal = null;
autoPlayReadySignalGeneration += 1;
};
const isSignalTargetReady = (): boolean => deps.isSignalTargetReady?.() ?? true;
const getSignalMediaPath = (): string =>
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
const maybeSignalPluginAutoplayReady = (
payload: SubtitleData,
options?: { forceWhilePaused?: boolean },
@@ -39,8 +51,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
return;
}
const mediaPath =
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
const mediaPath = getSignalMediaPath();
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
const releaseRetryDelayMs = 200;
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
@@ -104,16 +115,42 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
};
if (duplicateMediaSignal) {
pendingAutoplayReadySignal = null;
return;
}
if (!isSignalTargetReady()) {
pendingAutoplayReadySignal = { mediaPath, payload, options };
deps.logDebug(
`[autoplay-ready] deferred until signal target is ready for media ${mediaPath}`,
);
return;
}
pendingAutoplayReadySignal = null;
autoPlayReadySignalMediaPath = mediaPath;
const playbackGeneration = ++autoPlayReadySignalGeneration;
deps.signalPluginAutoplayReady();
attemptRelease(playbackGeneration, 0);
};
const flushPendingAutoplayReadySignal = (): void => {
if (!pendingAutoplayReadySignal || !isSignalTargetReady()) {
return;
}
const pendingSignal = pendingAutoplayReadySignal;
pendingAutoplayReadySignal = null;
if (getSignalMediaPath() !== pendingSignal.mediaPath) {
deps.logDebug(
`[autoplay-ready] dropped deferred signal for stale media ${pendingSignal.mediaPath}`,
);
return;
}
maybeSignalPluginAutoplayReady(pendingSignal.payload, pendingSignal.options);
};
return {
flushPendingAutoplayReadySignal,
getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath,
invalidatePendingAutoplayReadyFallbacks,
maybeSignalPluginAutoplayReady,
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
test('selectAutoplayStartupCue returns the active cue at the current time', () => {
assert.deepEqual(
selectAutoplayStartupCue(
[
{ startTime: 1, endTime: 3, text: 'first' },
{ startTime: 4, endTime: 5, text: 'second' },
],
2,
1,
),
{ startTime: 1, endTime: 3, text: 'first' },
);
});
test('selectAutoplayStartupCue returns the next imminent cue before playback starts', () => {
assert.deepEqual(
selectAutoplayStartupCue(
[
{ startTime: 1.2, endTime: 3, text: 'first' },
{ startTime: 4, endTime: 5, text: 'second' },
],
0,
2,
),
{ startTime: 1.2, endTime: 3, text: 'first' },
);
});
test('selectAutoplayStartupCue clamps negative current time to startup', () => {
assert.deepEqual(
selectAutoplayStartupCue([{ startTime: 0, endTime: 1, text: 'startup' }], -0.5, 0),
{ startTime: 0, endTime: 1, text: 'startup' },
);
});
test('selectAutoplayStartupCue does not reveal far future subtitle text', () => {
assert.equal(
selectAutoplayStartupCue([{ startTime: 12, endTime: 15, text: 'later' }], 0, 2),
null,
);
});
test('selectAutoplayStartupCue skips blank cues', () => {
assert.deepEqual(
selectAutoplayStartupCue(
[
{ startTime: 0, endTime: 1, text: ' ' },
{ startTime: 0.5, endTime: 2, text: 'visible' },
],
0.75,
1,
),
{ startTime: 0.5, endTime: 2, text: 'visible' },
);
});
@@ -0,0 +1,31 @@
import type { SubtitleCue } from '../../types';
export function selectAutoplayStartupCue(
cues: SubtitleCue[],
currentTimeSeconds: number,
lookaheadSeconds: number,
): SubtitleCue | null {
const currentTime = Math.max(0, Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0);
const lookahead = Math.max(0, Number.isFinite(lookaheadSeconds) ? lookaheadSeconds : 0);
const latestStartTime = currentTime + lookahead;
for (const cue of cues) {
if (!cue.text.trim()) {
continue;
}
if (cue.startTime <= currentTime && cue.endTime > currentTime) {
return cue;
}
}
for (const cue of cues) {
if (!cue.text.trim()) {
continue;
}
if (cue.startTime >= currentTime && cue.startTime <= latestStartTime) {
return cue;
}
}
return null;
}
@@ -0,0 +1,153 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAutoplayTokenizationWarmRelease } from './autoplay-tokenization-warm-release';
function flushMicrotasks(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
test('autoplay tokenization warm release signals immediately when warmups are ready', () => {
const calls: string[] = [];
const release = createAutoplayTokenizationWarmRelease({
isTokenizationWarmupReady: () => true,
startTokenizationWarmups: async () => {
calls.push('warmup');
},
getCurrentMediaPath: () => '/tmp/video.mkv',
signalAutoplayReady: () => calls.push('signal'),
warn: () => {},
});
release('/tmp/video.mkv');
assert.deepEqual(calls, ['signal']);
});
test('autoplay tokenization warm release primes subtitles before waiting for warmups', async () => {
const calls: string[] = [];
let resolveWarmup!: () => void;
const warmup = new Promise<void>((resolve) => {
resolveWarmup = resolve;
});
const release = createAutoplayTokenizationWarmRelease({
isTokenizationWarmupReady: () => false,
startTokenizationWarmups: async () => {
calls.push('warmup');
await warmup;
},
getCurrentMediaPath: () => '/tmp/video.mkv',
primeCurrentSubtitle: () => {
calls.push('prime');
},
signalAutoplayReady: () => calls.push('signal'),
warn: () => {},
});
release('/tmp/video.mkv');
await Promise.resolve();
assert.deepEqual(calls, ['prime', 'warmup']);
resolveWarmup();
await warmup;
await flushMicrotasks();
assert.deepEqual(calls, ['prime', 'warmup', 'signal']);
});
test('autoplay tokenization warm release waits for subtitle priming before signaling ready media', async () => {
const calls: string[] = [];
let resolvePrime!: () => void;
const prime = new Promise<void>((resolve) => {
resolvePrime = resolve;
});
const release = createAutoplayTokenizationWarmRelease({
isTokenizationWarmupReady: () => true,
startTokenizationWarmups: async () => {
calls.push('warmup');
},
getCurrentMediaPath: () => '/tmp/video.mkv',
primeCurrentSubtitle: () => {
calls.push('prime');
return prime;
},
signalAutoplayReady: () => calls.push('signal'),
warn: () => {},
});
release('/tmp/video.mkv');
await Promise.resolve();
assert.deepEqual(calls, ['prime']);
resolvePrime();
await prime;
await Promise.resolve();
assert.deepEqual(calls, ['prime', 'signal']);
});
test('autoplay tokenization warm release waits for warmups before signaling current media', async () => {
const calls: string[] = [];
let resolveWarmup!: () => void;
const warmup = new Promise<void>((resolve) => {
resolveWarmup = resolve;
});
const release = createAutoplayTokenizationWarmRelease({
isTokenizationWarmupReady: () => false,
startTokenizationWarmups: async () => {
calls.push('warmup');
await warmup;
},
getCurrentMediaPath: () => '/tmp/video.mkv',
signalAutoplayReady: () => calls.push('signal'),
warn: () => {},
});
release('/tmp/video.mkv');
await Promise.resolve();
assert.deepEqual(calls, ['warmup']);
resolveWarmup();
await warmup;
await Promise.resolve();
assert.deepEqual(calls, ['warmup', 'signal']);
});
test('autoplay tokenization warm release skips stale media after warmup resolves', async () => {
const calls: string[] = [];
let currentMediaPath = '/tmp/video-2.mkv';
const release = createAutoplayTokenizationWarmRelease({
isTokenizationWarmupReady: () => false,
startTokenizationWarmups: async () => {
calls.push('warmup');
},
getCurrentMediaPath: () => currentMediaPath,
signalAutoplayReady: () => calls.push('signal'),
warn: () => {},
});
release('/tmp/video-1.mkv');
await Promise.resolve();
currentMediaPath = '/tmp/video-3.mkv';
await Promise.resolve();
assert.deepEqual(calls, ['warmup']);
});
test('autoplay tokenization warm release skips signaling when current media is cleared', () => {
const calls: string[] = [];
const release = createAutoplayTokenizationWarmRelease({
isTokenizationWarmupReady: () => true,
startTokenizationWarmups: async () => {
calls.push('warmup');
},
getCurrentMediaPath: () => null,
signalAutoplayReady: () => calls.push('signal'),
warn: () => {},
});
release('/tmp/video.mkv');
assert.deepEqual(calls, []);
});
@@ -0,0 +1,67 @@
function normalizeMediaPath(mediaPath: string | null | undefined): string | null {
if (typeof mediaPath !== 'string') {
return null;
}
const trimmed = mediaPath.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function createAutoplayTokenizationWarmRelease(deps: {
isTokenizationWarmupReady: () => boolean;
startTokenizationWarmups: () => Promise<void>;
getCurrentMediaPath: () => string | null | undefined;
primeCurrentSubtitle?: (mediaPath: string) => void | Promise<void>;
signalAutoplayReady: () => void;
warn: (message: string, error: unknown) => void;
}): (mediaPath: string | null | undefined) => void {
const signalIfCurrent = (mediaPath: string): void => {
const currentMediaPath = normalizeMediaPath(deps.getCurrentMediaPath());
if (!currentMediaPath || currentMediaPath !== mediaPath) {
return;
}
deps.signalAutoplayReady();
};
const primeSubtitleForRelease = (mediaPath: string): Promise<void> | null => {
if (!deps.primeCurrentSubtitle) {
return null;
}
try {
return Promise.resolve(deps.primeCurrentSubtitle(mediaPath)).catch((error) => {
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
});
} catch (error) {
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
return null;
}
};
return (mediaPath) => {
const normalizedPath = normalizeMediaPath(mediaPath);
if (!normalizedPath) {
return;
}
const primePromise = primeSubtitleForRelease(normalizedPath);
if (deps.isTokenizationWarmupReady()) {
if (!primePromise) {
signalIfCurrent(normalizedPath);
return;
}
void primePromise.then(() => {
signalIfCurrent(normalizedPath);
});
return;
}
const warmupPromise = deps.startTokenizationWarmups();
const readinessPromise = primePromise
? Promise.all([primePromise, warmupPromise]).then(() => {})
: warmupPromise;
void readinessPromise
.then(() => {
signalIfCurrent(normalizedPath);
})
.catch((error) => {
deps.warn('Startup tokenization warmup failed before autoplay readiness release:', error);
});
};
}
@@ -137,10 +137,13 @@ export function composeMpvRuntimeHandlers<
const shouldInitializeMecabForAnnotations = (): boolean => {
const nPlusOneEnabled =
options.tokenizer.buildTokenizerDepsMainDeps.getNPlusOneEnabled?.() !== false;
const knownWordsEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getKnownWordsEnabled
? options.tokenizer.buildTokenizerDepsMainDeps.getKnownWordsEnabled() !== false
: nPlusOneEnabled;
const jlptEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getJlptEnabled() !== false;
const frequencyEnabled =
options.tokenizer.buildTokenizerDepsMainDeps.getFrequencyDictionaryEnabled() !== false;
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
return knownWordsEnabled || nPlusOneEnabled || jlptEnabled || frequencyEnabled;
};
const shouldWarmupAnnotationDictionaries = (): boolean => {
const jlptEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getJlptEnabled() !== false;
@@ -18,6 +18,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
isAppReady: () => true,
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
@@ -11,7 +11,7 @@ import {
test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
const config = deepCloneConfig(DEFAULT_CONFIG);
const calls: string[] = [];
const ankiPatches: Array<{ enabled: boolean }> = [];
const ankiPatches: unknown[] = [];
const sessionBindingWarnings: string[][] = [];
const applyHotReload = createConfigHotReloadAppliedHandler({
@@ -25,7 +25,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${typeof payload === 'string' ? payload : 'object'}`),
applyAnkiRuntimeConfigPatch: (patch) => {
ankiPatches.push({ enabled: patch.ai });
ankiPatches.push(patch);
},
});
@@ -48,7 +48,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`));
assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:')));
assert.ok(calls.includes('broadcast:config:hot-reload:object'));
assert.deepEqual(ankiPatches, [{ enabled: config.ankiConnect.ai.enabled }]);
assert.deepEqual(ankiPatches, [{ ai: config.ankiConnect.ai.enabled }]);
assert.equal(sessionBindingWarnings.length, 1);
assert.ok(
sessionBindingWarnings[0]?.some((message) =>
@@ -57,6 +57,87 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
);
});
test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and logging changes', () => {
const config = deepCloneConfig(DEFAULT_CONFIG);
config.ankiConnect.behavior.autoUpdateNewCards = false;
config.ankiConnect.knownWords.highlightEnabled = true;
config.ankiConnect.knownWords.refreshMinutes = 90;
config.ankiConnect.knownWords.decks = { Anime: ['Mining'] };
config.ankiConnect.nPlusOne.enabled = true;
config.ankiConnect.nPlusOne.minSentenceWords = 4;
config.ankiConnect.fields.word = 'Expression';
config.ankiConnect.fields.audio = 'SentenceAudioCustom';
config.ankiConnect.fields.image = 'ScreenshotCustom';
config.ankiConnect.fields.sentence = 'SentenceCustom';
config.ankiConnect.fields.miscInfo = 'MiscInfoCustom';
config.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom';
config.ankiConnect.isKiku.fieldGrouping = 'manual';
config.logging.level = 'debug';
const calls: string[] = [];
const ankiPatches: unknown[] = [];
const applyHotReload = createConfigHotReloadAppliedHandler({
setKeybindings: () => calls.push('set:keybindings'),
setSessionBindings: () => calls.push('set:session-bindings'),
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
setSecondarySubMode: () => calls.push('set:secondary'),
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
applyAnkiRuntimeConfigPatch: (patch) => {
calls.push('anki:patch');
ankiPatches.push(patch);
},
invalidateTokenizationCache: () => calls.push('invalidate:tokens'),
refreshSubtitlePrefetch: () => calls.push('refresh:prefetch'),
refreshCurrentSubtitle: () => calls.push('refresh:subtitle'),
setLogLevel: (level) => calls.push(`log:${level}`),
});
applyHotReload(
{
hotReloadFields: [
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.knownWords.decks',
'ankiConnect.nPlusOne.enabled',
'ankiConnect.nPlusOne.minSentenceWords',
'ankiConnect.fields.word',
'ankiConnect.fields.audio',
'ankiConnect.fields.image',
'ankiConnect.fields.sentence',
'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
'logging.level',
],
restartRequiredFields: [],
},
config,
);
assert.deepEqual(ankiPatches, [
{
behavior: { autoUpdateNewCards: false },
knownWords: config.ankiConnect.knownWords,
nPlusOne: config.ankiConnect.nPlusOne,
fields: {
word: 'Expression',
audio: 'SentenceAudioCustom',
image: 'ScreenshotCustom',
sentence: 'SentenceCustom',
miscInfo: 'MiscInfoCustom',
},
isLapis: { sentenceCardModel: 'Sentence Card Custom' },
isKiku: { fieldGrouping: 'manual' },
},
]);
assert.ok(calls.includes('invalidate:tokens'));
assert.ok(calls.includes('refresh:prefetch'));
assert.ok(calls.includes('refresh:subtitle'));
assert.ok(calls.includes('log:debug'));
assert.ok(calls.includes('broadcast:config:hot-reload'));
});
test('buildConfigHotReloadPayload includes independent primary subtitle mode', () => {
const config = deepCloneConfig(DEFAULT_CONFIG);
config.subtitleStyle.primaryDefaultMode = 'hover';
@@ -68,6 +149,48 @@ test('buildConfigHotReloadPayload includes independent primary subtitle mode', (
assert.equal(payload.secondarySubMode, 'hidden');
});
test('buildConfigHotReloadPayload reflects added, removed, and remapped session bindings', () => {
const config = deepCloneConfig(DEFAULT_CONFIG);
config.stats.markWatchedKey = 'Ctrl+Shift+KeyW';
config.shortcuts.openJimaku = null;
config.keybindings = [
{ key: 'KeyF', command: null },
{ key: 'Ctrl+Alt+KeyM', command: ['show-text', 'custom'] },
];
const payload = buildConfigHotReloadPayload(config);
assert.equal(
payload.sessionBindings.some(
(binding) =>
binding.sourcePath === 'stats.markWatchedKey' &&
binding.originalKey === 'Ctrl+Shift+KeyW' &&
binding.actionType === 'session-action' &&
binding.actionId === 'markWatched',
),
true,
);
assert.equal(
payload.sessionBindings.some(
(binding) =>
binding.originalKey === 'Ctrl+Alt+KeyM' &&
binding.actionType === 'mpv-command' &&
binding.command.join(' ') === 'show-text custom',
),
true,
);
assert.equal(
payload.sessionBindings.some((binding) => binding.originalKey === 'KeyF'),
false,
);
assert.equal(
payload.sessionBindings.some(
(binding) => binding.actionType === 'session-action' && binding.actionId === 'openJimaku',
),
false,
);
});
test('createConfigHotReloadAppliedHandler skips optional effects when no hot fields', () => {
const config = deepCloneConfig(DEFAULT_CONFIG);
const calls: string[] = [];
+86 -7
View File
@@ -3,6 +3,7 @@ import { compileSessionBindings } from '../../core/services/session-bindings';
import { resolveKeybindings } from '../../core/utils/keybindings';
import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config';
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config';
import type { AnkiConnectConfig } from '../../types/anki';
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
type ConfigHotReloadAppliedDeps = {
@@ -14,9 +15,11 @@ type ConfigHotReloadAppliedDeps = {
refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
applyAnkiRuntimeConfigPatch: (patch: {
ai: ResolvedConfig['ankiConnect']['ai']['enabled'];
}) => void;
applyAnkiRuntimeConfigPatch: (patch: Partial<AnkiConnectConfig>) => void;
invalidateTokenizationCache?: () => void;
refreshSubtitlePrefetch?: () => void;
refreshCurrentSubtitle?: () => void;
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
};
type ConfigHotReloadMessageDeps = {
@@ -30,8 +33,8 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
}
return {
...config.subtitleStyle,
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
knownWordColor: config.ankiConnect.knownWords.color,
nPlusOneColor: config.subtitleStyle.nPlusOneColor,
knownWordColor: config.subtitleStyle.knownWordColor,
nameMatchColor: config.subtitleStyle.nameMatchColor,
enableJlpt: config.subtitleStyle.enableJlpt,
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
@@ -44,6 +47,7 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe
keybindings,
shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG),
statsToggleKey: config.stats.toggleKey,
statsMarkWatchedKey: config.stats.markWatchedKey,
platform:
process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win32' : 'linux',
rawConfig: config,
@@ -59,6 +63,70 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe
};
}
function hasAnyHotReloadField(diff: ConfigHotReloadDiff, prefixes: string[]): boolean {
return diff.hotReloadFields.some((field) =>
prefixes.some((prefix) => field === prefix || field.startsWith(`${prefix}.`)),
);
}
function buildAnkiRuntimeConfigPatch(
diff: ConfigHotReloadDiff,
config: ResolvedConfig,
): Partial<AnkiConnectConfig> | null {
const patch: Partial<AnkiConnectConfig> = {};
if (diff.hotReloadFields.includes('ankiConnect.ai')) {
patch.ai = config.ankiConnect.ai.enabled;
}
if (diff.hotReloadFields.includes('ankiConnect.ai.enabled')) {
patch.ai = config.ankiConnect.ai.enabled;
}
if (diff.hotReloadFields.includes('ankiConnect.behavior.autoUpdateNewCards')) {
patch.behavior = { autoUpdateNewCards: config.ankiConnect.behavior.autoUpdateNewCards };
}
if (hasAnyHotReloadField(diff, ['ankiConnect.knownWords'])) {
patch.knownWords = config.ankiConnect.knownWords;
}
if (hasAnyHotReloadField(diff, ['ankiConnect.nPlusOne'])) {
patch.nPlusOne = config.ankiConnect.nPlusOne;
}
const fieldPatch: NonNullable<AnkiConnectConfig['fields']> = {};
if (diff.hotReloadFields.includes('ankiConnect.fields.word')) {
fieldPatch.word = config.ankiConnect.fields.word;
}
if (diff.hotReloadFields.includes('ankiConnect.fields.audio')) {
fieldPatch.audio = config.ankiConnect.fields.audio;
}
if (diff.hotReloadFields.includes('ankiConnect.fields.image')) {
fieldPatch.image = config.ankiConnect.fields.image;
}
if (diff.hotReloadFields.includes('ankiConnect.fields.sentence')) {
fieldPatch.sentence = config.ankiConnect.fields.sentence;
}
if (diff.hotReloadFields.includes('ankiConnect.fields.miscInfo')) {
fieldPatch.miscInfo = config.ankiConnect.fields.miscInfo;
}
if (Object.keys(fieldPatch).length > 0) {
patch.fields = fieldPatch;
}
if (diff.hotReloadFields.includes('ankiConnect.isLapis.sentenceCardModel')) {
patch.isLapis = { sentenceCardModel: config.ankiConnect.isLapis.sentenceCardModel };
}
if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) {
patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping };
}
return Object.keys(patch).length > 0 ? patch : null;
}
function hasAnnotationRuntimeHotReload(diff: ConfigHotReloadDiff): boolean {
return hasAnyHotReloadField(diff, [
'ankiConnect.knownWords',
'ankiConnect.nPlusOne',
'ankiConnect.fields.word',
]);
}
export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadAppliedDeps) {
return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => {
const payload = buildConfigHotReloadPayload(config);
@@ -74,8 +142,19 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
deps.broadcastToOverlayWindows('secondary-subtitle:mode', payload.secondarySubMode);
}
if (diff.hotReloadFields.includes('ankiConnect.ai')) {
deps.applyAnkiRuntimeConfigPatch({ ai: config.ankiConnect.ai.enabled });
const ankiPatch = buildAnkiRuntimeConfigPatch(diff, config);
if (ankiPatch) {
deps.applyAnkiRuntimeConfigPatch(ankiPatch);
}
if (hasAnnotationRuntimeHotReload(diff)) {
deps.invalidateTokenizationCache?.();
deps.refreshSubtitlePrefetch?.();
deps.refreshCurrentSubtitle?.();
}
if (diff.hotReloadFields.includes('logging.level')) {
deps.setLogLevel?.(config.logging.level);
}
if (diff.hotReloadFields.length > 0) {
@@ -3,6 +3,7 @@ import type {
ConfigHotReloadRuntimeDeps,
} from '../../core/services/config-hot-reload';
import type { ReloadConfigStrictResult } from '../../config';
import type { AnkiConnectConfig } from '../../types/anki';
import type {
ConfigHotReloadPayload,
ConfigValidationWarning,
@@ -69,9 +70,11 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
applyAnkiRuntimeConfigPatch: (patch: {
ai: ResolvedConfig['ankiConnect']['ai']['enabled'];
}) => void;
applyAnkiRuntimeConfigPatch: (patch: Partial<AnkiConnectConfig>) => void;
invalidateTokenizationCache?: () => void;
refreshSubtitlePrefetch?: () => void;
refreshCurrentSubtitle?: () => void;
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
}) {
return () => ({
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
@@ -84,8 +87,12 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
broadcastToOverlayWindows: (channel: string, payload: unknown) =>
deps.broadcastToOverlayWindows(channel, payload),
applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai']['enabled'] }) =>
applyAnkiRuntimeConfigPatch: (patch: Partial<AnkiConnectConfig>) =>
deps.applyAnkiRuntimeConfigPatch(patch),
invalidateTokenizationCache: () => deps.invalidateTokenizationCache?.(),
refreshSubtitlePrefetch: () => deps.refreshSubtitlePrefetch?.(),
refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(),
setLogLevel: (level: ResolvedConfig['logging']['level']) => deps.setLogLevel?.(level),
});
}
+2 -2
View File
@@ -9,8 +9,8 @@ const fields: ConfigSettingsField[] = [
label: 'Launch mode',
description: 'Launch mode setting.',
configPath: 'mpv.launchMode',
category: 'playback-sources',
section: 'mpv launcher',
category: 'behavior',
section: 'mpv Playback',
control: 'select',
defaultValue: 'windowed',
restartBehavior: 'restart',
+85 -6
View File
@@ -3,16 +3,17 @@ import path from 'node:path';
import { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit';
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
import type {
ConfigSettingsAnkiListResult,
ConfigSettingsField,
ConfigSettingsSaveResult,
ConfigSettingsSnapshot,
} from '../../types/settings';
import type { ReloadConfigStrictResult } from '../../config';
import { classifyConfigHotReloadDiff } from '../../core/services/config-hot-reload';
import {
classifyConfigHotReloadDiff,
type ConfigHotReloadDiff,
} from '../../core/services/config-hot-reload';
import { createSaveConfigSettingsPatchHandler } from './config-settings-save';
createSaveConfigSettingsPatchHandler,
type ConfigSettingsHotReloadDiff,
} from './config-settings-save';
import {
createOpenConfigSettingsWindowHandler,
type ConfigSettingsWindowLike,
@@ -28,6 +29,19 @@ export interface ConfigSettingsIpcChannels {
saveConfigSettingsPatch: string;
openConfigSettingsFile: string;
openConfigSettingsWindow: string;
getConfigSettingsAnkiDeckNames: string;
getConfigSettingsAnkiDeckFieldNames: string;
getConfigSettingsAnkiDeckModelNames: string;
getConfigSettingsAnkiModelNames: string;
getConfigSettingsAnkiModelFieldNames: string;
}
export interface ConfigSettingsAnkiClient {
deckNames(): Promise<string[]>;
fieldNamesForDeck(deckName: string): Promise<string[]>;
modelNamesForDeck(deckName: string): Promise<string[]>;
modelNames(): Promise<string[]>;
modelFieldNames(modelName: string): Promise<string[]>;
}
export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowLike> {
@@ -37,12 +51,14 @@ export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowL
getConfig(): ResolvedConfig;
getWarnings(): ConfigValidationWarning[];
reloadConfigStrict(): ReloadConfigStrictResult;
applyHotReload(diff: ConfigHotReloadDiff, config: ResolvedConfig): void;
onHotReloadApplied?: (diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig) => void;
getSettingsWindow(): TWindow | null;
setSettingsWindow(window: TWindow | null): void;
createSettingsWindow(): TWindow;
settingsHtmlPath: string;
openPath(path: string): Promise<string>;
defaultAnkiConnectUrl: string;
createAnkiClient(url: string): ConfigSettingsAnkiClient;
ipcMain: ConfigSettingsIpcMainLike;
ipcChannels: ConfigSettingsIpcChannels;
log?: (message: string) => void;
@@ -111,8 +127,8 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
deleteFile: (targetPath) => fs.rmSync(targetPath, { force: true }),
reloadConfigStrict: () => deps.reloadConfigStrict(),
classifyDiff: (previous, next) => classifyConfigHotReloadDiff(previous, next),
applyHotReload: (diff, config) => deps.applyHotReload(diff, config),
getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(deps.fields, fields),
onHotReloadApplied: deps.onHotReloadApplied,
});
function ensureConfigFileExists(): string {
@@ -142,6 +158,36 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
};
}
function getAnkiConnectUrl(draftUrl: unknown): string {
return typeof draftUrl === 'string' && draftUrl.trim().length > 0
? draftUrl.trim()
: deps.getConfig().ankiConnect.url || deps.defaultAnkiConnectUrl;
}
async function getAnkiList(
draftUrl: unknown,
lookup: (client: ConfigSettingsAnkiClient) => Promise<string[]>,
): Promise<ConfigSettingsAnkiListResult> {
try {
const client = deps.createAnkiClient(getAnkiConnectUrl(draftUrl));
return { ok: true, values: await lookup(client) };
} catch (error) {
return {
ok: false,
values: [],
error: error instanceof Error ? error.message : 'Failed to query AnkiConnect.',
};
}
}
function invalidAnkiListResult(error: string): ConfigSettingsAnkiListResult {
return {
ok: false,
values: [],
error,
};
}
function registerHandlers(): void {
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot());
deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => {
@@ -155,6 +201,39 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
return openError.length === 0;
});
deps.ipcMain.handle(deps.ipcChannels.openConfigSettingsWindow, () => openWindow());
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsAnkiDeckNames, (_event, draftUrl) =>
getAnkiList(draftUrl, (client) => client.deckNames()),
);
deps.ipcMain.handle(
deps.ipcChannels.getConfigSettingsAnkiDeckFieldNames,
(_event, deckName, draftUrl) => {
const normalizedDeckName = typeof deckName === 'string' ? deckName.trim() : '';
return normalizedDeckName
? getAnkiList(draftUrl, (client) => client.fieldNamesForDeck(normalizedDeckName))
: invalidAnkiListResult('Deck name is required.');
},
);
deps.ipcMain.handle(
deps.ipcChannels.getConfigSettingsAnkiDeckModelNames,
(_event, deckName, draftUrl) => {
const normalizedDeckName = typeof deckName === 'string' ? deckName.trim() : '';
return normalizedDeckName
? getAnkiList(draftUrl, (client) => client.modelNamesForDeck(normalizedDeckName))
: invalidAnkiListResult('Deck name is required.');
},
);
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsAnkiModelNames, (_event, draftUrl) =>
getAnkiList(draftUrl, (client) => client.modelNames()),
);
deps.ipcMain.handle(
deps.ipcChannels.getConfigSettingsAnkiModelFieldNames,
(_event, modelName, draftUrl) => {
const normalizedModelName = typeof modelName === 'string' ? modelName.trim() : '';
return normalizedModelName
? getAnkiList(draftUrl, (client) => client.modelFieldNames(normalizedModelName))
: invalidAnkiListResult('Note type is required.');
},
);
}
return {
+72 -7
View File
@@ -14,7 +14,7 @@ function snapshot(): ConfigSettingsSnapshot {
};
}
test('config settings save applies hot-reloadable diff live', () => {
test('config settings save returns hot-reloadable diff for watcher path', () => {
const calls: string[] = [];
const previous = DEFAULT_CONFIG;
const next: ResolvedConfig = {
@@ -46,7 +46,6 @@ test('config settings save applies hot-reloadable diff live', () => {
hotReloadFields: ['subtitleStyle'],
restartRequiredFields: [],
}),
applyHotReload: (diff) => calls.push(`hot:${diff.hotReloadFields.join(',')}`),
getRestartRequiredSections: () => [],
});
@@ -62,11 +61,81 @@ test('config settings save applies hot-reloadable diff live', () => {
assert.equal(result.ok, true);
assert.match(written, /autoPauseVideoOnHover/);
assert.deepEqual(calls, ['write', 'hot:subtitleStyle']);
assert.deepEqual(calls, ['write']);
assert.deepEqual(result.hotReloadFields, ['subtitleStyle']);
assert.deepEqual(result.restartRequiredFields, []);
});
test('config settings save immediately applies hot-reloadable subtitle CSS changes', () => {
const previous = DEFAULT_CONFIG;
const next: ResolvedConfig = {
...DEFAULT_CONFIG,
subtitleStyle: {
...DEFAULT_CONFIG.subtitleStyle,
css: {
'font-size': '50px',
},
secondary: {
...DEFAULT_CONFIG.subtitleStyle.secondary,
css: {
'font-size': '28px',
},
},
},
};
const applied: Array<{
hotReloadFields: string[];
config: ResolvedConfig;
}> = [];
const save = createSaveConfigSettingsPatchHandler({
getConfigPath: () => '/tmp/config.jsonc',
getCurrentConfig: () => previous,
getWarnings: () => [],
getSnapshot: () => snapshot(),
fileExists: () => true,
readText: () => '{}',
writeTextAtomically: () => {},
reloadConfigStrict: (): ReloadConfigStrictResult => ({
ok: true,
config: next,
warnings: [],
path: '/tmp/config.jsonc',
}),
classifyDiff: () => ({
hotReloadFields: ['subtitleStyle'],
restartRequiredFields: [],
}),
getRestartRequiredSections: () => [],
onHotReloadApplied: (diff, config) => {
applied.push({
hotReloadFields: diff.hotReloadFields,
config,
});
},
});
const result = save({
operations: [
{
op: 'set',
path: 'subtitleStyle.css',
value: { 'font-size': '50px' },
},
{
op: 'set',
path: 'subtitleStyle.secondary.css',
value: { 'font-size': '28px' },
},
],
});
assert.equal(result.ok, true);
assert.equal(applied.length, 1);
assert.deepEqual(applied[0]?.hotReloadFields, ['subtitleStyle']);
assert.equal(applied[0]?.config.subtitleStyle.css['font-size'], '50px');
assert.equal(applied[0]?.config.subtitleStyle.secondary.css['font-size'], '28px');
});
test('config settings save returns restart-required sections without applying hot reload', () => {
const calls: string[] = [];
const previous = DEFAULT_CONFIG;
@@ -95,7 +164,6 @@ test('config settings save returns restart-required sections without applying ho
hotReloadFields: [],
restartRequiredFields: ['mpv'],
}),
applyHotReload: () => calls.push('hot'),
getRestartRequiredSections: () => ['mpv launcher'],
});
@@ -130,9 +198,6 @@ test('config settings save restores previous file content when strict reload fai
classifyDiff: () => {
throw new Error('Should not classify invalid config.');
},
applyHotReload: () => {
throw new Error('Should not hot reload invalid config.');
},
getRestartRequiredSections: () => [],
});
+13 -8
View File
@@ -23,8 +23,8 @@ export interface ConfigSettingsSaveDeps {
deleteFile?(path: string): void;
reloadConfigStrict(): ReloadConfigStrictResult;
classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigSettingsHotReloadDiff;
applyHotReload(diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig): void;
getRestartRequiredSections(restartRequiredFields: string[]): string[];
onHotReloadApplied?: (diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig) => void;
}
export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDeps) {
@@ -64,12 +64,17 @@ export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDep
deps.writeTextAtomically(configPath, candidate.content);
const reloadResult = deps.reloadConfigStrict();
if (!reloadResult.ok) {
if (hadExistingConfig) {
deps.writeTextAtomically(configPath, content);
} else if (deps.deleteFile) {
deps.deleteFile(configPath);
} else {
deps.writeTextAtomically(configPath, content);
try {
if (hadExistingConfig) {
deps.writeTextAtomically(configPath, content);
} else if (deps.deleteFile) {
deps.deleteFile(configPath);
} else {
deps.writeTextAtomically(configPath, content);
}
deps.reloadConfigStrict();
} catch {
// Best-effort rollback; preserve original reload error for caller.
}
return {
ok: false,
@@ -83,7 +88,7 @@ export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDep
const diff = deps.classifyDiff(previousConfig, reloadResult.config);
if (diff.hotReloadFields.length > 0) {
deps.applyHotReload(diff, reloadResult.config);
deps.onHotReloadApplied?.(diff, reloadResult.config);
}
return {
+1 -1
View File
@@ -27,7 +27,7 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
const window = deps.createSettingsWindow();
void Promise.resolve(window.loadFile(deps.settingsHtmlPath)).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
deps.log?.(`Failed to load configuration settings window: ${message}`);
deps.log?.(`Failed to load settings window: ${message}`);
deps.setSettingsWindow(null);
window.destroy?.();
});
@@ -41,18 +41,16 @@ test('current media tokenization gate returns immediately for ready media', asyn
await gate.waitUntilReady('/tmp/video-1.mkv');
});
test('current media tokenization gate stays ready for later media after first warmup', async () => {
test('current media tokenization gate treats later media as ready after warmup completes', async () => {
const gate = createCurrentMediaTokenizationGate();
gate.updateCurrentMediaPath('/tmp/video-1.mkv');
gate.markReady('/tmp/video-1.mkv');
gate.updateCurrentMediaPath('/tmp/video-2.mkv');
let resolved = false;
const waitPromise = gate.waitUntilReady('/tmp/video-2.mkv').then(() => {
await gate.waitUntilReady('/tmp/video-2.mkv').then(() => {
resolved = true;
});
await Promise.resolve();
assert.equal(resolved, true);
await waitPromise;
});
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { SubtitleData } from '../../types';
import { resolveCurrentSubtitleForRenderer } from './current-subtitle-snapshot';
function withTiming(payload: SubtitleData): SubtitleData {
return {
...payload,
startTime: 1,
endTime: 2,
};
}
test('renderer current subtitle snapshot reuses cached payload for first paint', async () => {
const payload = await resolveCurrentSubtitleForRenderer({
currentSubText: '字幕',
currentSubtitleData: { text: '字幕', tokens: [{ text: '字' } as never] },
withCurrentSubtitleTiming: withTiming,
});
assert.equal(payload.text, '字幕');
assert.equal(payload.startTime, 1);
assert.deepEqual(payload.tokens, [{ text: '字' }]);
});
test('renderer current subtitle snapshot does not block on tokenizer for empty text', async () => {
const payload = await resolveCurrentSubtitleForRenderer({
currentSubText: '',
currentSubtitleData: null,
withCurrentSubtitleTiming: withTiming,
});
assert.equal(payload.text, '');
assert.equal(payload.tokens, null);
});
test('renderer current subtitle snapshot falls back to raw text for uncached subtitles', async () => {
const payload = await resolveCurrentSubtitleForRenderer({
currentSubText: 'まだキャッシュされていない字幕',
currentSubtitleData: null,
withCurrentSubtitleTiming: withTiming,
});
assert.equal(payload.text, 'まだキャッシュされていない字幕');
assert.equal(payload.startTime, 1);
assert.equal(payload.tokens, null);
});
test('renderer current subtitle snapshot tokenizes uncached subtitles when tokenizer is available', async () => {
const payload = await resolveCurrentSubtitleForRenderer({
currentSubText: '新しい字幕',
currentSubtitleData: null,
withCurrentSubtitleTiming: withTiming,
tokenizeSubtitle: async (text) => ({ text, tokens: [{ text: '新' } as never] }),
});
assert.equal(payload.text, '新しい字幕');
assert.equal(payload.startTime, 1);
assert.deepEqual(payload.tokens, [{ text: '新' }]);
});
@@ -0,0 +1,29 @@
import type { SubtitleData } from '../../types';
export async function resolveCurrentSubtitleForRenderer(deps: {
currentSubText: string;
currentSubtitleData: SubtitleData | null;
withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData;
tokenizeSubtitle?: (text: string) => Promise<SubtitleData | null>;
}): Promise<SubtitleData> {
if (deps.currentSubtitleData?.text === deps.currentSubText) {
return deps.withCurrentSubtitleTiming(deps.currentSubtitleData);
}
if (!deps.currentSubText.trim()) {
return deps.withCurrentSubtitleTiming({
text: deps.currentSubText,
tokens: null,
});
}
const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText);
if (tokenized) {
return deps.withCurrentSubtitleTiming(tokenized);
}
return deps.withCurrentSubtitleTiming({
text: deps.currentSubText,
tokens: null,
});
}
@@ -10,7 +10,6 @@ import {
removeLegacyMpvPluginCandidates,
resolvePackagedFirstRunPluginAssets,
resolvePackagedRuntimePluginPath,
syncInstalledFirstRunPluginBinaryPath,
} from './first-run-setup-plugin';
import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state';
@@ -66,66 +65,6 @@ test('resolvePackagedRuntimePluginPath returns packaged plugin entrypoint', () =
});
});
test('syncInstalledFirstRunPluginBinaryPath fills blank binary_path for existing installs', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(
installPaths.pluginConfigPath,
'binary_path=\nsocket_path=/tmp/subminer-socket\n',
);
const result = syncInstalledFirstRunPluginBinaryPath({
platform: 'linux',
homeDir,
xdgConfigHome,
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
});
assert.deepEqual(result, {
updated: true,
configPath: installPaths.pluginConfigPath,
});
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'binary_path=/Applications/SubMiner.app/Contents/MacOS/SubMiner\nsocket_path=/tmp/subminer-socket\n',
);
});
});
test('syncInstalledFirstRunPluginBinaryPath preserves explicit binary_path overrides', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(
installPaths.pluginConfigPath,
'binary_path=/tmp/SubMiner/scripts/subminer-dev.sh\nsocket_path=/tmp/subminer-socket\n',
);
const result = syncInstalledFirstRunPluginBinaryPath({
platform: 'linux',
homeDir,
xdgConfigHome,
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
});
assert.deepEqual(result, {
updated: false,
configPath: installPaths.pluginConfigPath,
});
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'binary_path=/tmp/SubMiner/scripts/subminer-dev.sh\nsocket_path=/tmp/subminer-socket\n',
);
});
});
test('detectInstalledFirstRunPlugin detects plugin installed in canonical mpv config location on macOS', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
+1 -79
View File
@@ -1,6 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { resolveDefaultMpvInstallPaths, type MpvInstallPaths } from '../../shared/setup-state';
import type { MpvInstallPaths } from '../../shared/setup-state';
export interface InstalledFirstRunPluginCandidate {
path: string;
@@ -27,51 +27,6 @@ export interface LegacyMpvPluginRemovalResult {
failedPaths: Array<{ path: string; message: string }>;
}
function rewriteInstalledWindowsPluginConfig(configPath: string): void {
const content = fs.readFileSync(configPath, 'utf8');
const updated = content.replace(/^socket_path=.*$/m, 'socket_path=\\\\.\\pipe\\subminer-socket');
if (updated !== content) {
fs.writeFileSync(configPath, updated, 'utf8');
}
}
function sanitizePluginConfigValue(value: string): string {
return value.replace(/[\r\n]/g, '').trim();
}
function upsertPluginConfigLine(content: string, key: string, value: string): string {
const normalizedValue = sanitizePluginConfigValue(value);
const line = `${key}=${normalizedValue}`;
const pattern = new RegExp(`^${key}=.*$`, 'm');
if (pattern.test(content)) {
return content.replace(pattern, line);
}
const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n';
return `${content}${suffix}${line}\n`;
}
function rewriteInstalledPluginBinaryPath(configPath: string, binaryPath: string): boolean {
const content = fs.readFileSync(configPath, 'utf8');
const updated = upsertPluginConfigLine(content, 'binary_path', binaryPath);
if (updated === content) {
return false;
}
fs.writeFileSync(configPath, updated, 'utf8');
return true;
}
function readInstalledPluginBinaryPath(configPath: string): string | null {
const content = fs.readFileSync(configPath, 'utf8');
const match = content.match(/^binary_path=(.*)$/m);
if (!match) {
return null;
}
const rawValue = match[1] ?? '';
const value = sanitizePluginConfigValue(rawValue);
return value.length > 0 ? value : null;
}
export function resolvePackagedFirstRunPluginAssets(deps: {
dirname: string;
appPath: string;
@@ -338,36 +293,3 @@ export async function removeLegacyMpvPluginCandidates(options: {
failedPaths,
};
}
export function syncInstalledFirstRunPluginBinaryPath(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgConfigHome?: string;
binaryPath: string;
}): { updated: boolean; configPath: string | null } {
const installPaths = resolveDefaultMpvInstallPaths(
options.platform,
options.homeDir,
options.xdgConfigHome,
);
if (!installPaths.supported || !fs.existsSync(installPaths.pluginConfigPath)) {
return { updated: false, configPath: null };
}
const configuredBinaryPath = readInstalledPluginBinaryPath(installPaths.pluginConfigPath);
if (configuredBinaryPath) {
return { updated: false, configPath: installPaths.pluginConfigPath };
}
const updated = rewriteInstalledPluginBinaryPath(
installPaths.pluginConfigPath,
options.binaryPath,
);
if (options.platform === 'win32') {
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
}
return {
updated,
configPath: installPaths.pluginConfigPath,
};
}
@@ -29,8 +29,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -47,6 +47,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
markWatched: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
@@ -121,12 +122,12 @@ function createCommandLineLauncherSnapshot(
test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: true })), true);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, configSettings: true })), false);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, settings: true })), false);
assert.equal(
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinRemoteAnnounce: true })),
false,
);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ yomitan: true })), false);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, update: true })), false);
});
@@ -155,6 +156,7 @@ test('shouldAutoOpenFirstRunSetup treats session and stats startup commands as e
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, openControllerDebug: true })),
false,
);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, markWatched: true })), false);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, stats: true })), false);
assert.equal(
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinSubtitleUrlsOnly: true })),
+2 -1
View File
@@ -71,8 +71,8 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.launchMpv ||
args.yomitan ||
args.settings ||
args.configSettings ||
args.show ||
args.hide ||
args.showVisibleOverlay ||
@@ -90,6 +90,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.triggerSubsync ||
args.markAudioCard ||
args.toggleStatsOverlay ||
args.markWatched ||
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
+101 -5
View File
@@ -59,10 +59,15 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
assert.match(html, /SubMiner setup/);
assert.doesNotMatch(html, /Install legacy mpv plugin/);
assert.doesNotMatch(html, /action=install-plugin/);
assert.match(html, /Ready/);
assert.doesNotMatch(html, /mpv runtime plugin/);
assert.doesNotMatch(html, /Bundled ready/);
assert.match(html, /Managed mpv launches use the bundled runtime plugin\./);
assert.doesNotMatch(html, /Managed mpv launches use the bundled runtime plugin\./);
assert.match(html, /Open Yomitan Settings/);
assert.match(html, /Open SubMiner Settings/);
assert.match(
html,
/action=open-yomitan-settings'">Open Yomitan Settings<\/button>\s*<button class="ghost" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=refresh'">Refresh status<\/button>\s*<button onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=open-config-settings'">Open SubMiner Settings<\/button>\s*<button class="primary" disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/,
);
assert.match(html, /Finish setup/);
assert.match(html, /disabled/);
assert.match(html, /html,\s*body\s*{\s*min-height:\s*100%;/);
@@ -70,7 +75,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
assert.match(html, /box-sizing:\s*border-box;/);
});
test('buildFirstRunSetupHtml switches plugin action to reinstall when already installed', () => {
test('buildFirstRunSetupHtml omits bundled mpv plugin readiness when already installed', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
@@ -94,10 +99,11 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
assert.doesNotMatch(html, /Reinstall mpv plugin/);
assert.doesNotMatch(html, /action=install-plugin/);
assert.doesNotMatch(html, /mpv runtime plugin/);
assert.match(html, /mpv executable path/);
assert.match(html, /Leave blank to auto-discover mpv\.exe from PATH\./);
assert.match(html, /aria-label="Path to mpv\.exe"/);
assert.match(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./);
assert.doesNotMatch(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./);
});
test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirmation', () => {
@@ -124,7 +130,8 @@ test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirm
});
assert.match(html, /Legacy mpv plugin/);
assert.match(html, /Legacy detected/);
assert.doesNotMatch(html, /mpv runtime plugin/);
assert.match(html, /Found/);
assert.match(html, /\/tmp\/mpv\/scripts\/subminer/);
assert.match(html, /\/tmp\/mpv\/scripts\/subminer\.lua/);
assert.match(html, /Remove legacy mpv plugin/);
@@ -251,6 +258,12 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
action: 'remove-legacy-plugin',
},
);
assert.deepEqual(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=open-config-settings'),
{
action: 'open-config-settings',
},
);
assert.equal(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
null,
@@ -542,6 +555,89 @@ test('opening first-run setup skips rendering if window is destroyed after snaps
assert.deepEqual(calls, ['set', 'show', 'focus', 'in-progress', 'snapshot']);
});
test('first-run setup action can skip rerender after launching another window', async () => {
const calls: string[] = [];
let navigateHandler: ((event: unknown, url: string) => void) | undefined;
const handler = createOpenFirstRunSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () =>
({
webContents: {
on: (_event: 'will-navigate', callback: (event: unknown, url: string) => void) => {
navigateHandler = callback;
},
},
loadURL: async () => {
calls.push('load');
},
on: () => {},
isDestroyed: () => false,
close: () => {},
show: () => calls.push('show'),
focus: () => calls.push('focus'),
}) as never,
getSetupSnapshot: async () => ({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
}),
buildSetupHtml: () => '<html></html>',
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
handleAction: async () => {
calls.push('action');
return { skipRender: true };
},
markSetupInProgress: async () => {
calls.push('in-progress');
},
markSetupCancelled: async () => undefined,
isSetupCompleted: () => true,
shouldQuitWhenClosedIncomplete: () => false,
quitApp: () => {},
clearSetupWindow: () => {},
setSetupWindow: () => {
calls.push('set');
},
encodeURIComponent: (value) => value,
logError: () => {},
});
handler();
await new Promise((resolve) => setTimeout(resolve, 0));
navigateHandler?.(
{ preventDefault: () => calls.push('preventDefault') },
'subminer://first-run-setup?action=open-config-settings',
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [
'set',
'show',
'focus',
'in-progress',
'load',
'show',
'focus',
'preventDefault',
'action',
]);
});
test('closing incomplete first-run setup quits app outside background mode', async () => {
const calls: string[] = [];
let closedHandler: (() => void) | undefined;

Some files were not shown because too many files have changed in this diff Show More