Improve startup dictionary sync UX and default playback keybindings

- Add default `f` fullscreen overlay binding and switch default AniSkip skip key to `Tab`
- Make character-dictionary auto-sync non-blocking at startup with tokenization gating for Yomitan mutations
- Add ordered startup OSD progress (checking/generating/updating/importing), refresh current subtitle on sync completion, and extend regression tests
This commit is contained in:
2026-03-09 00:50:32 -07:00
parent a0521aeeaf
commit e0f82d28f0
36 changed files with 2691 additions and 148 deletions

View File

@@ -73,3 +73,10 @@ test('default keybindings include primary and secondary subtitle track cycling o
assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']);
assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']);
});
test('default keybindings include fullscreen on F', () => {
const keybindingMap = new Map(
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
);
assert.deepEqual(keybindingMap.get('KeyF'), ['cycle', 'fullscreen']);
});

View File

@@ -50,6 +50,7 @@ export const SPECIAL_COMMANDS = {
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
{ key: 'Space', command: ['cycle', 'pause'] },
{ key: 'KeyF', command: ['cycle', 'fullscreen'] },
{ key: 'KeyJ', command: ['cycle', 'sid'] },
{ key: 'Shift+KeyJ', command: ['cycle', 'secondary-sid'] },
{ key: 'ArrowRight', command: ['seek', 5] },

View File

@@ -77,7 +77,7 @@ test('macOS keeps visible overlay hidden while tracker is not ready and emits on
assert.ok(!calls.includes('show'));
});
test('non-macOS keeps fallback visible overlay behavior when tracker is not ready', () => {
test('tracked non-macOS overlay stays hidden while tracker is not ready', () => {
const { window, calls } = createMainWindowRecorder();
let trackerWarning = false;
const tracker: WindowTrackerStub = {
@@ -116,7 +116,48 @@ test('non-macOS keeps fallback visible overlay behavior when tracker is not read
} as never);
assert.equal(trackerWarning, true);
assert.ok(calls.includes('update-bounds'));
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('update-bounds'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
assert.ok(!calls.includes('osd'));
});
test('untracked non-macOS overlay keeps fallback visible behavior when no tracker exists', () => {
const { window, calls } = createMainWindowRecorder();
let trackerWarning = false;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: null,
trackerNotReadyWarningShown: trackerWarning,
setTrackerNotReadyWarningShown: (shown: boolean) => {
trackerWarning = shown;
},
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,
showOverlayLoadingOsd: () => {
calls.push('osd');
},
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
} as never);
assert.equal(trackerWarning, false);
assert.ok(calls.includes('show'));
assert.ok(calls.includes('focus'));
assert.ok(!calls.includes('osd'));

View File

@@ -84,19 +84,7 @@ export function updateVisibleOverlayVisibility(args: {
}
}
if (args.isMacOSPlatform || args.isWindowsPlatform) {
mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
const fallbackBounds = args.resolveFallbackBounds?.();
if (!fallbackBounds) return;
args.updateVisibleOverlayBounds(fallbackBounds);
args.syncPrimaryOverlayWindowLayer('visible');
showPassiveVisibleOverlay();
args.enforceOverlayLayerOrder();
mainWindow.hide();
args.syncOverlayShortcuts();
}

View File

@@ -372,6 +372,9 @@ import { createMediaRuntimeService } from './main/media-runtime';
import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime';
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import {
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
shouldForceOverrideYomitanAnkiServer,
@@ -913,6 +916,10 @@ const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntim
const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler());
let autoPlayReadySignalMediaPath: string | null = null;
let autoPlayReadySignalGeneration = 0;
const currentMediaTokenizationGate = createCurrentMediaTokenizationGate();
const startupOsdSequencer = createStartupOsdSequencer({
showOsd: (message) => showMpvOsd(message),
});
function maybeSignalPluginAutoplayReady(
payload: SubtitleData,
@@ -1324,8 +1331,13 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
profileScope: config.profileScope,
};
},
getOrCreateCurrentSnapshot: () => characterDictionaryRuntime.getOrCreateCurrentSnapshot(),
getOrCreateCurrentSnapshot: (targetPath, progress) =>
characterDictionaryRuntime.getOrCreateCurrentSnapshot(targetPath, progress),
buildMergedDictionary: (mediaIds) => characterDictionaryRuntime.buildMergedDictionary(mediaIds),
waitForYomitanMutationReady: () =>
currentMediaTokenizationGate.waitUntilReady(
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
),
getYomitanDictionaryInfo: async () => {
await ensureYomitanExtensionLoaded();
return await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
@@ -1364,6 +1376,24 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
clearSchedule: (timer) => clearTimeout(timer),
logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message),
onSyncStatus: (event) => {
notifyCharacterDictionaryAutoSyncStatus(event, {
getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
showOsd: (message) => showMpvOsd(message),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
startupOsdSequencer,
});
},
onSyncComplete: ({ mediaId, mediaTitle, changed }) => {
if (appState.yomitanParserWindow) {
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
}
subtitleProcessingController.invalidateTokenizationCache();
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
logger.info(
`[dictionary:auto-sync] refreshed current subtitle after sync (AniList ${mediaId}, changed=${changed ? 'yes' : 'no'}, title=${mediaTitle})`,
);
},
});
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
@@ -2673,6 +2703,8 @@ const {
},
updateCurrentMediaPath: (path) => {
autoPlayReadySignalMediaPath = null;
currentMediaTokenizationGate.updateCurrentMediaPath(path);
startupOsdSequencer.reset();
if (path) {
ensureImmersionTrackerStarted();
}
@@ -2793,6 +2825,10 @@ const {
getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled,
getMecabTokenizer: () => appState.mecabTokenizer,
onTokenizationReady: (text) => {
currentMediaTokenizationGate.markReady(
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
);
startupOsdSequencer.markTokenizationReady();
maybeSignalPluginAutoplayReady({ text, tokens: null }, { forceWhilePaused: true });
},
},
@@ -2812,6 +2848,9 @@ const {
ensureFrequencyDictionaryLookup: () =>
frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(),
showMpvOsd: (message: string) => showMpvOsd(message),
showLoadingOsd: (message: string) => startupOsdSequencer.showAnnotationLoading(message),
showLoadedOsd: (message: string) =>
startupOsdSequencer.markAnnotationLoadingComplete(message),
shouldShowOsdNotification: () => {
const type = getResolvedConfig().ankiConnect.behavior.notificationType;
return type === 'osd' || type === 'both';

View File

@@ -213,7 +213,7 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w
assert.equal(roleBadgeDiv.tag, 'div');
const badge = roleBadgeDiv.content as { tag: string; content: string };
assert.equal(badge.tag, 'span');
assert.equal(badge.content, 'Side Character');
assert.equal(badge.content, 'Main Character');
const descSection = children.find(
(c) =>
@@ -695,6 +695,128 @@ test('generateForCurrentMedia adds kana aliases for romanized names when native
}
});
test('generateForCurrentMedia indexes kanji family and given names using AniList first and last hints', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url === GRAPHQL_URL) {
const body = JSON.parse(String(init?.body ?? '{}')) as {
query?: string;
};
if (body.query?.includes('Page(perPage: 10)')) {
return new Response(
JSON.stringify({
data: {
Page: {
media: [
{
id: 37450,
episodes: 13,
title: {
romaji: 'Seishun Buta Yarou wa Bunny Girl Senpai no Yume wo Minai',
english: 'Rascal Does Not Dream of Bunny Girl Senpai',
native: '青春ブタ野郎はバニーガール先輩の夢を見ない',
},
},
],
},
},
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
);
}
if (body.query?.includes('characters(page: $page')) {
return new Response(
JSON.stringify({
data: {
Media: {
title: {
romaji: 'Seishun Buta Yarou wa Bunny Girl Senpai no Yume wo Minai',
english: 'Rascal Does Not Dream of Bunny Girl Senpai',
native: '青春ブタ野郎はバニーガール先輩の夢を見ない',
},
characters: {
pageInfo: { hasNextPage: false },
edges: [
{
role: 'SUPPORTING',
node: {
id: 77,
description: 'Classmate.',
image: null,
name: {
first: 'Yuuma',
full: 'Yuuma Kunimi',
last: 'Kunimi',
native: '国見佑真',
},
},
},
],
},
},
},
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
);
}
}
throw new Error(`Unexpected fetch URL: ${url}`);
}) as typeof globalThis.fetch;
try {
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => '/tmp/bunny-girl-senpai-s01e01.mkv',
getCurrentMediaTitle: () => 'Rascal Does Not Dream of Bunny Girl Senpai - S01E01',
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({
title: 'Rascal Does Not Dream of Bunny Girl Senpai',
episode: 1,
source: 'fallback',
}),
now: () => 1_700_000_000_000,
});
const result = await runtime.generateForCurrentMedia();
const termBank = JSON.parse(
readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'),
) as Array<
[
string,
string,
string,
string,
number,
Array<string | Record<string, unknown>>,
number,
string,
]
>;
const familyName = termBank.find(([term]) => term === '国見');
assert.ok(familyName, 'expected kanji family-name term from AniList hints');
assert.equal(familyName[1], 'くにみ');
const givenName = termBank.find(([term]) => term === '佑真');
assert.ok(givenName, 'expected kanji given-name term from AniList hints');
assert.equal(givenName[1], 'ゆうま');
} finally {
globalThis.fetch = originalFetch;
}
});
test('generateForCurrentMedia indexes AniList alternative character names for alias lookups', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
@@ -812,6 +934,520 @@ test('generateForCurrentMedia indexes AniList alternative character names for al
}
});
test('generateForCurrentMedia skips AniList characters without a native name when other valid characters exist', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url === GRAPHQL_URL) {
const body = JSON.parse(String(init?.body ?? '{}')) as {
query?: string;
};
if (body.query?.includes('Page(perPage: 10)')) {
return new Response(
JSON.stringify({
data: {
Page: {
media: [
{
id: 130298,
episodes: 20,
title: {
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
english: 'The Eminence in Shadow',
native: '陰の実力者になりたくて!',
},
},
],
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (body.query?.includes('characters(page: $page')) {
return new Response(
JSON.stringify({
data: {
Media: {
title: {
english: 'The Eminence in Shadow',
},
characters: {
pageInfo: { hasNextPage: false },
edges: [
{
role: 'MAIN',
node: {
id: 111,
description: 'Valid native name.',
image: null,
name: {
full: 'Alpha',
native: 'アルファ',
first: 'Alpha',
last: null,
},
},
},
{
role: 'SUPPORTING',
node: {
id: 222,
description: 'Missing native name.',
image: null,
name: {
full: 'John Smith',
native: '',
first: 'John',
last: 'Smith',
},
},
},
],
},
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
}
throw new Error(`Unexpected fetch URL: ${url}`);
}) as typeof globalThis.fetch;
try {
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow',
episode: 5,
source: 'fallback',
}),
now: () => 1_700_000_000_000,
});
const result = await runtime.generateForCurrentMedia();
const termBank = JSON.parse(
readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'),
) as Array<
[
string,
string,
string,
string,
number,
Array<string | Record<string, unknown>>,
number,
string,
]
>;
assert.ok(termBank.find(([term]) => term === 'アルファ'));
assert.equal(
termBank.some(([term]) => term === 'John Smith'),
false,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test('generateForCurrentMedia uses AniList first and last name hints to build kanji readings', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url === GRAPHQL_URL) {
const body = JSON.parse(String(init?.body ?? '{}')) as {
query?: string;
};
if (body.query?.includes('Page(perPage: 10)')) {
return new Response(
JSON.stringify({
data: {
Page: {
media: [
{
id: 20594,
episodes: 10,
title: {
romaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
english: 'KONOSUBA -Gods blessing on this wonderful world!',
native: 'この素晴らしい世界に祝福を!',
},
},
],
},
},
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
);
}
if (body.query?.includes('characters(page: $page')) {
return new Response(
JSON.stringify({
data: {
Media: {
title: {
romaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
english: 'KONOSUBA -Gods blessing on this wonderful world!',
native: 'この素晴らしい世界に祝福を!',
},
characters: {
pageInfo: { hasNextPage: false },
edges: [
{
role: 'MAIN',
node: {
id: 1,
description: 'The protagonist.',
image: null,
name: {
full: 'Satou Kazuma',
native: '佐藤和真',
first: '和真',
last: '佐藤',
},
},
},
],
},
},
},
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
);
}
}
throw new Error(`Unexpected fetch URL: ${url}`);
}) as typeof globalThis.fetch;
try {
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => '/tmp/konosuba-s02e05.mkv',
getCurrentMediaTitle: () => 'Konosuba S02E05',
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({
title: 'Konosuba',
episode: 5,
source: 'fallback',
}),
now: () => 1_700_000_000_000,
});
const result = await runtime.generateForCurrentMedia();
const termBank = JSON.parse(
readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'),
) as Array<
[
string,
string,
string,
string,
number,
Array<string | Record<string, unknown>>,
number,
string,
]
>;
assert.equal(termBank.find(([term]) => term === '佐藤和真')?.[1], 'さとうかずま');
assert.equal(termBank.find(([term]) => term === '佐藤')?.[1], 'さとう');
assert.equal(termBank.find(([term]) => term === '和真')?.[1], 'かずま');
} finally {
globalThis.fetch = originalFetch;
}
});
test('generateForCurrentMedia includes AniList gender age birthday and blood type in character information', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url === GRAPHQL_URL) {
const body = JSON.parse(String(init?.body ?? '{}')) as {
query?: string;
};
if (body.query?.includes('Page(perPage: 10)')) {
return new Response(
JSON.stringify({
data: {
Page: {
media: [
{
id: 130298,
episodes: 20,
title: {
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
english: 'The Eminence in Shadow',
native: '陰の実力者になりたくて!',
},
},
],
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (body.query?.includes('characters(page: $page')) {
return new Response(
JSON.stringify({
data: {
Media: {
title: {
english: 'The Eminence in Shadow',
},
characters: {
pageInfo: { hasNextPage: false },
edges: [
{
role: 'SUPPORTING',
node: {
id: 123,
description: 'Second princess of Midgar.',
image: null,
gender: 'Female',
age: '15',
dateOfBirth: {
month: 9,
day: 1,
},
bloodType: 'A',
name: {
full: 'Alexia Midgar',
native: 'アレクシア・ミドガル',
first: 'Alexia',
last: 'Midgar',
},
},
},
],
},
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
}
throw new Error(`Unexpected fetch URL: ${url}`);
}) as typeof globalThis.fetch;
try {
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow',
episode: 5,
source: 'fallback',
}),
now: () => 1_700_000_000_000,
});
const result = await runtime.generateForCurrentMedia();
const termBank = JSON.parse(
readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'),
) as Array<
[
string,
string,
string,
string,
number,
Array<string | Record<string, unknown>>,
number,
string,
]
>;
const alexia = termBank.find(([term]) => term === 'アレクシア');
assert.ok(alexia);
const children = (
alexia[5][0] as {
content: { content: Array<Record<string, unknown>> };
}
).content.content;
const infoSection = children.find(
(c) =>
(c as { tag?: string }).tag === 'details' &&
Array.isArray((c as { content?: unknown[] }).content) &&
(c as { content: Array<{ content?: string }> }).content[0]?.content ===
'Character Information',
) as { content: Array<Record<string, unknown>> } | undefined;
assert.ok(infoSection);
const body = infoSection.content[1] as { content: Array<{ content?: string }> };
const flattened = JSON.stringify(body.content);
assert.match(flattened, /Female|♂ Male|♀ Female/);
assert.match(flattened, /15 years/);
assert.match(flattened, /Blood Type A/);
assert.match(flattened, /Birthday: September 1/);
} finally {
globalThis.fetch = originalFetch;
}
});
test('generateForCurrentMedia preserves duplicate surface forms across different characters', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url === GRAPHQL_URL) {
const body = JSON.parse(String(init?.body ?? '{}')) as {
query?: string;
};
if (body.query?.includes('Page(perPage: 10)')) {
return new Response(
JSON.stringify({
data: {
Page: {
media: [
{
id: 130298,
episodes: 20,
title: {
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
english: 'The Eminence in Shadow',
native: '陰の実力者になりたくて!',
},
},
],
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (body.query?.includes('characters(page: $page')) {
return new Response(
JSON.stringify({
data: {
Media: {
title: {
english: 'The Eminence in Shadow',
},
characters: {
pageInfo: { hasNextPage: false },
edges: [
{
role: 'MAIN',
node: {
id: 111,
description: 'First Alpha.',
image: null,
name: {
full: 'Alpha One',
native: 'アルファ',
first: 'Alpha',
last: 'One',
},
},
},
{
role: 'MAIN',
node: {
id: 222,
description: 'Second Alpha.',
image: null,
name: {
full: 'Alpha Two',
native: 'アルファ',
first: 'Alpha',
last: 'Two',
},
},
},
],
},
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
}
throw new Error(`Unexpected fetch URL: ${url}`);
}) as typeof globalThis.fetch;
try {
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow',
episode: 5,
source: 'fallback',
}),
now: () => 1_700_000_000_000,
});
const result = await runtime.generateForCurrentMedia();
const termBank = JSON.parse(
readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'),
) as Array<
[
string,
string,
string,
string,
number,
Array<string | Record<string, unknown>>,
number,
string,
]
>;
const alphaEntries = termBank.filter(([term]) => term === 'アルファ');
assert.equal(alphaEntries.length, 2);
const glossaries = alphaEntries.map((entry) =>
JSON.stringify(
(
entry[5][0] as {
content: { content: Array<Record<string, unknown>> };
}
).content.content,
),
);
assert.ok(glossaries.some((value) => value.includes('First Alpha.')));
assert.ok(glossaries.some((value) => value.includes('Second Alpha.')));
} finally {
globalThis.fetch = originalFetch;
}
});
test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;

View File

@@ -10,21 +10,21 @@ const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
const ANILIST_REQUEST_DELAY_MS = 2000;
const CHARACTER_IMAGE_DOWNLOAD_DELAY_MS = 250;
const HONORIFIC_SUFFIXES = [
'さん',
'様',
'先生',
'先輩',
'後輩',
'氏',
'君',
'くん',
'ちゃん',
'たん',
'坊',
'殿',
'博士',
'社長',
'部長',
{ term: 'さん', reading: 'さん' },
{ term: '様', reading: 'さま' },
{ term: '先生', reading: 'せんせい' },
{ term: '先輩', reading: 'せんぱい' },
{ term: '後輩', reading: 'こうはい' },
{ term: '氏', reading: 'し' },
{ term: '君', reading: 'くん' },
{ term: 'くん', reading: 'くん' },
{ term: 'ちゃん', reading: 'ちゃん' },
{ term: 'たん', reading: 'たん' },
{ term: '坊', reading: 'ぼう' },
{ term: '殿', reading: 'どの' },
{ term: '博士', reading: 'はかせ' },
{ term: '社長', reading: 'しゃちょう' },
{ term: '部長', reading: 'ぶちょう' },
] as const;
type CharacterDictionaryRole = 'main' | 'primary' | 'side' | 'appears';
@@ -45,6 +45,24 @@ type CharacterDictionarySnapshotImage = {
dataBase64: string;
};
type CharacterBirthday = [number, number];
type JapaneseNameParts = {
hasSpace: boolean;
original: string;
combined: string;
family: string | null;
given: string | null;
};
type NameReadings = {
hasSpace: boolean;
original: string;
full: string;
family: string;
given: string;
};
export type CharacterDictionarySnapshot = {
formatVersion: number;
mediaId: number;
@@ -55,7 +73,7 @@ export type CharacterDictionarySnapshot = {
images: CharacterDictionarySnapshotImage[];
};
const CHARACTER_DICTIONARY_FORMAT_VERSION = 14;
const CHARACTER_DICTIONARY_FORMAT_VERSION = 15;
const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
type AniListSearchResponse = {
@@ -103,8 +121,17 @@ type AniListCharacterPageResponse = {
large?: string | null;
medium?: string | null;
} | null;
gender?: string | null;
age?: string | number | null;
dateOfBirth?: {
month?: number | null;
day?: number | null;
} | null;
bloodType?: string | null;
name?: {
first?: string | null;
full?: string | null;
last?: string | null;
native?: string | null;
alternative?: Array<string | null> | null;
} | null;
@@ -124,11 +151,17 @@ type VoiceActorRecord = {
type CharacterRecord = {
id: number;
role: CharacterDictionaryRole;
firstNameHint: string;
fullName: string;
lastNameHint: string;
nativeName: string;
alternativeNames: string[];
bloodType: string;
birthday: CharacterBirthday | null;
description: string;
imageUrl: string | null;
age: string;
sex: string;
voiceActors: VoiceActorRecord[];
};
@@ -161,6 +194,16 @@ export type CharacterDictionarySnapshotResult = {
updatedAt: number;
};
export type CharacterDictionarySnapshotProgress = {
mediaId: number;
mediaTitle: string;
};
export type CharacterDictionarySnapshotProgressCallbacks = {
onChecking?: (progress: CharacterDictionarySnapshotProgress) => void;
onGenerating?: (progress: CharacterDictionarySnapshotProgress) => void;
};
export type MergedCharacterDictionaryBuildResult = {
zipPath: string;
revision: string;
@@ -263,6 +306,16 @@ function buildReading(term: string): string {
return katakanaToHiragana(compact);
}
function containsKanji(value: string): boolean {
for (const char of value) {
const code = char.charCodeAt(0);
if ((code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3400 && code <= 0x4dbf)) {
return true;
}
}
return false;
}
function isRomanizedName(value: string): boolean {
return /^[A-Za-zĀĪŪĒŌÂÊÎÔÛāīūēōâêîôû'.\-\s]+$/.test(value);
}
@@ -484,6 +537,67 @@ function romanizedTokenToKatakana(token: string): string | null {
return output.length > 0 ? output : null;
}
function buildReadingFromRomanized(value: string): string {
const katakana = romanizedTokenToKatakana(value);
return katakana ? katakanaToHiragana(katakana) : '';
}
function buildReadingFromHint(value: string): string {
return buildReading(value) || buildReadingFromRomanized(value);
}
function scoreJapaneseNamePartLength(length: number): number {
if (length === 2) return 3;
if (length === 1 || length === 3) return 2;
if (length === 4) return 1;
return 0;
}
function inferJapaneseNameSplitIndex(
nameOriginal: string,
firstNameHint: string,
lastNameHint: string,
): number | null {
const chars = [...nameOriginal];
if (chars.length < 2) return null;
const familyHintLength = [...buildReadingFromHint(lastNameHint)].length;
const givenHintLength = [...buildReadingFromHint(firstNameHint)].length;
const totalHintLength = familyHintLength + givenHintLength;
const defaultBoundary = Math.round(chars.length / 2);
let bestIndex: number | null = null;
let bestScore = Number.NEGATIVE_INFINITY;
for (let index = 1; index < chars.length; index += 1) {
const familyLength = index;
const givenLength = chars.length - index;
let score =
scoreJapaneseNamePartLength(familyLength) + scoreJapaneseNamePartLength(givenLength);
if (chars.length >= 4 && familyLength >= 2 && givenLength >= 2) {
score += 1;
}
if (totalHintLength > 0) {
const expectedFamilyLength = (chars.length * familyHintLength) / totalHintLength;
score -= Math.abs(familyLength - expectedFamilyLength) * 1.5;
} else {
score -= Math.abs(familyLength - defaultBoundary) * 0.5;
}
if (familyLength === givenLength) {
score += 0.25;
}
if (score > bestScore) {
bestScore = score;
bestIndex = index;
}
}
return bestIndex;
}
function addRomanizedKanaAliases(values: Iterable<string>): string[] {
const aliases = new Set<string>();
for (const value of values) {
@@ -497,6 +611,166 @@ function addRomanizedKanaAliases(values: Iterable<string>): string[] {
return [...aliases];
}
function splitJapaneseName(
nameOriginal: string,
firstNameHint?: string,
lastNameHint?: string,
): JapaneseNameParts {
const trimmed = nameOriginal.trim();
if (!trimmed) {
return {
hasSpace: false,
original: '',
combined: '',
family: null,
given: null,
};
}
const normalizedSpace = trimmed.replace(/[\s\u3000]+/g, ' ').trim();
const spaceParts = normalizedSpace.split(' ').filter((part) => part.length > 0);
if (spaceParts.length === 2) {
const family = spaceParts[0]!;
const given = spaceParts[1]!;
return {
hasSpace: true,
original: normalizedSpace,
combined: `${family}${given}`,
family,
given,
};
}
const middleDotParts = trimmed
.split(/[・・·•]/)
.map((part) => part.trim())
.filter((part) => part.length > 0);
if (middleDotParts.length === 2) {
const family = middleDotParts[0]!;
const given = middleDotParts[1]!;
return {
hasSpace: true,
original: trimmed,
combined: `${family}${given}`,
family,
given,
};
}
const hintedFirst = firstNameHint?.trim() || '';
const hintedLast = lastNameHint?.trim() || '';
if (hintedFirst && hintedLast) {
const familyGiven = `${hintedLast}${hintedFirst}`;
if (trimmed === familyGiven) {
return {
hasSpace: true,
original: trimmed,
combined: familyGiven,
family: hintedLast,
given: hintedFirst,
};
}
const givenFamily = `${hintedFirst}${hintedLast}`;
if (trimmed === givenFamily) {
return {
hasSpace: true,
original: trimmed,
combined: givenFamily,
family: hintedFirst,
given: hintedLast,
};
}
}
if (hintedFirst && hintedLast && containsKanji(trimmed)) {
const splitIndex = inferJapaneseNameSplitIndex(trimmed, hintedFirst, hintedLast);
if (splitIndex != null) {
const chars = [...trimmed];
const family = chars.slice(0, splitIndex).join('');
const given = chars.slice(splitIndex).join('');
if (family && given) {
return {
hasSpace: true,
original: trimmed,
combined: trimmed,
family,
given,
};
}
}
}
return {
hasSpace: false,
original: trimmed,
combined: trimmed,
family: null,
given: null,
};
}
function generateNameReadings(
nameOriginal: string,
romanizedName: string,
firstNameHint?: string,
lastNameHint?: string,
): NameReadings {
const trimmed = nameOriginal.trim();
if (!trimmed) {
return {
hasSpace: false,
original: '',
full: '',
family: '',
given: '',
};
}
const nameParts = splitJapaneseName(trimmed, firstNameHint, lastNameHint);
if (!nameParts.hasSpace || !nameParts.family || !nameParts.given) {
const full = containsKanji(trimmed)
? buildReadingFromRomanized(romanizedName)
: buildReading(trimmed);
return {
hasSpace: false,
original: trimmed,
full,
family: full,
given: full,
};
}
const romanizedParts = romanizedName
.trim()
.split(/\s+/)
.filter((part) => part.length > 0);
const familyFromHints = buildReadingFromHint(lastNameHint || '');
const givenFromHints = buildReadingFromHint(firstNameHint || '');
const familyRomajiFallback = romanizedParts[0] || '';
const givenRomajiFallback = romanizedParts.slice(1).join(' ');
const family =
familyFromHints ||
(containsKanji(nameParts.family)
? buildReadingFromRomanized(familyRomajiFallback)
: buildReading(nameParts.family));
const given =
givenFromHints ||
(containsKanji(nameParts.given)
? buildReadingFromRomanized(givenRomajiFallback)
: buildReading(nameParts.given));
const full =
`${family}${given}` || buildReading(trimmed) || buildReadingFromRomanized(romanizedName);
return {
hasSpace: true,
original: nameParts.original,
full,
family,
given,
};
}
function expandRawNameVariants(rawName: string): string[] {
const trimmed = rawName.trim();
if (!trimmed) return [];
@@ -555,24 +829,125 @@ function buildNameTerms(character: CharacterRecord): string[] {
}
}
const nativeParts = splitJapaneseName(
character.nativeName,
character.firstNameHint,
character.lastNameHint,
);
if (nativeParts.family) {
base.add(nativeParts.family);
}
if (nativeParts.given) {
base.add(nativeParts.given);
}
const withHonorifics = new Set<string>();
for (const entry of base) {
withHonorifics.add(entry);
for (const suffix of HONORIFIC_SUFFIXES) {
withHonorifics.add(`${entry}${suffix}`);
withHonorifics.add(`${entry}${suffix.term}`);
}
}
for (const alias of addRomanizedKanaAliases(withHonorifics)) {
withHonorifics.add(alias);
for (const suffix of HONORIFIC_SUFFIXES) {
withHonorifics.add(`${alias}${suffix}`);
withHonorifics.add(`${alias}${suffix.term}`);
}
}
return [...withHonorifics].filter((entry) => entry.trim().length > 0);
}
const MONTH_NAMES: ReadonlyArray<[number, string]> = [
[1, 'January'],
[2, 'February'],
[3, 'March'],
[4, 'April'],
[5, 'May'],
[6, 'June'],
[7, 'July'],
[8, 'August'],
[9, 'September'],
[10, 'October'],
[11, 'November'],
[12, 'December'],
];
const SEX_DISPLAY: ReadonlyArray<[string, string]> = [
['m', '♂ Male'],
['f', '♀ Female'],
['male', '♂ Male'],
['female', '♀ Female'],
];
function formatBirthday(birthday: CharacterBirthday | null): string {
if (!birthday) return '';
const [month, day] = birthday;
const monthName = MONTH_NAMES.find(([m]) => m === month)?.[1] || 'Unknown';
return `${monthName} ${day}`;
}
function formatCharacterStats(character: CharacterRecord): string {
const parts: string[] = [];
const normalizedSex = character.sex.trim().toLowerCase();
const sexDisplay = SEX_DISPLAY.find(([key]) => key === normalizedSex)?.[1];
if (sexDisplay) parts.push(sexDisplay);
if (character.age.trim()) parts.push(`${character.age.trim()} years`);
if (character.bloodType.trim()) parts.push(`Blood Type ${character.bloodType.trim()}`);
const birthday = formatBirthday(character.birthday);
if (birthday) parts.push(`Birthday: ${birthday}`);
return parts.join(' • ');
}
function buildReadingForTerm(
term: string,
character: CharacterRecord,
readings: NameReadings,
nameParts: JapaneseNameParts,
): string {
for (const suffix of HONORIFIC_SUFFIXES) {
if (term.endsWith(suffix.term) && term.length > suffix.term.length) {
const baseTerm = term.slice(0, -suffix.term.length);
const baseReading = buildReadingForTerm(baseTerm, character, readings, nameParts);
return baseReading ? `${baseReading}${suffix.reading}` : '';
}
}
const compactNative = character.nativeName.replace(/[\s\u3000]+/g, '');
const noMiddleDotsNative = compactNative.replace(/[・・·•]/g, '');
if (
term === character.nativeName ||
term === compactNative ||
term === noMiddleDotsNative ||
term === nameParts.original ||
term === nameParts.combined
) {
return readings.full;
}
const familyCompact = nameParts.family?.replace(/[・・·•]/g, '') || '';
if (nameParts.family && (term === nameParts.family || term === familyCompact)) {
return readings.family;
}
const givenCompact = nameParts.given?.replace(/[・・·•]/g, '') || '';
if (nameParts.given && (term === nameParts.given || term === givenCompact)) {
return readings.given;
}
const compact = term.replace(/[\s\u3000]+/g, '');
if (hasKanaOnly(compact)) {
return buildReading(compact);
}
if (isRomanizedName(term)) {
return buildReadingFromRomanized(term) || readings.full;
}
return '';
}
function parseCharacterDescription(raw: string): {
fields: Array<{ key: string; value: string }>;
text: string;
@@ -623,16 +998,16 @@ function roleInfo(role: CharacterDictionaryRole): { tag: string; score: number }
function mapRole(input: string | null | undefined): CharacterDictionaryRole {
const value = (input || '').trim().toUpperCase();
if (value === 'MAIN') return 'main';
if (value === 'BACKGROUND') return 'appears';
if (value === 'SUPPORTING') return 'side';
return 'primary';
if (value === 'SUPPORTING') return 'primary';
if (value === 'BACKGROUND') return 'side';
return 'side';
}
function roleLabel(role: CharacterDictionaryRole): string {
if (role === 'main') return 'Main';
if (role === 'primary') return 'Primary';
if (role === 'side') return 'Side';
return 'Appears';
if (role === 'main') return 'Protagonist';
if (role === 'primary') return 'Main Character';
if (role === 'side') return 'Side Character';
return 'Minor Role';
}
function inferImageExt(contentType: string | null): string {
@@ -780,10 +1155,10 @@ function roleBadgeStyle(role: CharacterDictionaryRole): Record<string, string> {
fontWeight: 'bold',
color: '#fff',
};
if (role === 'main') return { ...base, backgroundColor: '#4a8c3f' };
if (role === 'primary') return { ...base, backgroundColor: '#5c82b0' };
if (role === 'side') return { ...base, backgroundColor: '#7889a0' };
return { ...base, backgroundColor: '#777' };
if (role === 'main') return { ...base, backgroundColor: '#4CAF50' };
if (role === 'primary') return { ...base, backgroundColor: '#2196F3' };
if (role === 'side') return { ...base, backgroundColor: '#FF9800' };
return { ...base, backgroundColor: '#9E9E9E' };
}
function buildCollapsibleSection(
@@ -939,10 +1314,11 @@ function createDefinitionGlossary(
content: {
tag: 'span',
style: roleBadgeStyle(character.role),
content: `${roleLabel(character.role)} Character`,
content: roleLabel(character.role),
},
});
const statsLine = formatCharacterStats(character);
if (descriptionText) {
content.push(
buildCollapsibleSection(
@@ -953,11 +1329,21 @@ function createDefinitionGlossary(
);
}
if (fields.length > 0) {
const fieldItems: Array<Record<string, unknown>> = fields.map((f) => ({
const fieldItems: Array<Record<string, unknown>> = [];
if (statsLine) {
fieldItems.push({
tag: 'li',
style: { fontWeight: 'bold' },
content: statsLine,
});
}
fieldItems.push(
...fields.map((f) => ({
tag: 'li',
content: `${f.key}: ${f.value}`,
}));
})),
);
if (fieldItems.length > 0) {
content.push(
buildCollapsibleSection(
'Character Information',
@@ -1248,12 +1634,21 @@ async function fetchCharactersForMedia(
node {
id
description(asHtml: false)
gender
age
dateOfBirth {
month
day
}
bloodType
image {
large
medium
}
name {
first
full
last
native
alternative
}
@@ -1287,7 +1682,9 @@ async function fetchCharactersForMedia(
for (const edge of edges) {
const node = edge?.node;
if (!node || typeof node.id !== 'number') continue;
const firstNameHint = node.name?.first?.trim() || '';
const fullName = node.name?.full?.trim() || '';
const lastNameHint = node.name?.last?.trim() || '';
const nativeName = node.name?.native?.trim() || '';
const alternativeNames = [
...new Set(
@@ -1297,7 +1694,7 @@ async function fetchCharactersForMedia(
.filter((value) => value.length > 0),
),
];
if (!fullName && !nativeName && alternativeNames.length === 0) continue;
if (!nativeName) continue;
const voiceActors: VoiceActorRecord[] = [];
for (const va of edge?.voiceActors ?? []) {
if (!va || typeof va.id !== 'number') continue;
@@ -1314,11 +1711,25 @@ async function fetchCharactersForMedia(
characters.push({
id: node.id,
role: mapRole(edge?.role),
firstNameHint,
fullName,
lastNameHint,
nativeName,
alternativeNames,
bloodType: node.bloodType?.trim() || '',
birthday:
typeof node.dateOfBirth?.month === 'number' && typeof node.dateOfBirth?.day === 'number'
? [node.dateOfBirth.month, node.dateOfBirth.day]
: null,
description: node.description || '',
imageUrl: node.image?.large || node.image?.medium || null,
age:
typeof node.age === 'string'
? node.age.trim()
: typeof node.age === 'number'
? String(node.age)
: '',
sex: node.gender?.trim() || '',
voiceActors,
});
}
@@ -1400,9 +1811,9 @@ function buildSnapshotFromCharacters(
) => boolean,
): CharacterDictionarySnapshot {
const termEntries: CharacterDictionaryTermEntry[] = [];
const seen = new Set<string>();
for (const character of characters) {
const seenTerms = new Set<string>();
const imagePath = imagesByCharacterId.get(character.id)?.path ?? null;
const vaImagePaths = new Map<number, string>();
for (const va of character.voiceActors) {
@@ -1417,11 +1828,21 @@ function buildSnapshotFromCharacters(
getCollapsibleSectionOpenState,
);
const candidateTerms = buildNameTerms(character);
const nameParts = splitJapaneseName(
character.nativeName,
character.firstNameHint,
character.lastNameHint,
);
const readings = generateNameReadings(
character.nativeName,
character.fullName,
character.firstNameHint,
character.lastNameHint,
);
for (const term of candidateTerms) {
const reading = buildReading(term);
const dedupeKey = `${term}|${reading}|${character.role}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
if (seenTerms.has(term)) continue;
seenTerms.add(term);
const reading = buildReadingForTerm(term, character, readings, nameParts);
termEntries.push(buildTermEntry(term, reading, character.role, glossary));
}
}
@@ -1560,7 +1981,10 @@ function buildMergedRevision(mediaIds: number[], snapshots: CharacterDictionaryS
}
export function createCharacterDictionaryRuntimeService(deps: CharacterDictionaryRuntimeDeps): {
getOrCreateCurrentSnapshot: (targetPath?: string) => Promise<CharacterDictionarySnapshotResult>;
getOrCreateCurrentSnapshot: (
targetPath?: string,
progress?: CharacterDictionarySnapshotProgressCallbacks,
) => Promise<CharacterDictionarySnapshotResult>;
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
generateForCurrentMedia: (
targetPath?: string,
@@ -1606,6 +2030,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
mediaId: number,
mediaTitleHint?: string,
beforeRequest?: () => Promise<void>,
progress?: CharacterDictionarySnapshotProgressCallbacks,
): Promise<CharacterDictionarySnapshotResult> => {
const snapshotPath = getSnapshotPath(outputDir, mediaId);
const cachedSnapshot = readSnapshot(snapshotPath);
@@ -1620,6 +2045,10 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
};
}
progress?.onGenerating?.({
mediaId,
mediaTitle: mediaTitleHint || `AniList ${mediaId}`,
});
deps.logInfo?.(`[dictionary] snapshot miss for AniList ${mediaId}, fetching characters`);
const { mediaTitle: fetchedMediaTitle, characters } = await fetchCharactersForMedia(
@@ -1700,7 +2129,10 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
};
return {
getOrCreateCurrentSnapshot: async (targetPath?: string) => {
getOrCreateCurrentSnapshot: async (
targetPath?: string,
progress?: CharacterDictionarySnapshotProgressCallbacks,
) => {
let hasAniListRequest = false;
const waitForAniListRequestSlot = async (): Promise<void> => {
if (!hasAniListRequest) {
@@ -1710,7 +2142,16 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
await sleepMs(ANILIST_REQUEST_DELAY_MS);
};
const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot);
return getOrCreateSnapshot(resolvedMedia.id, resolvedMedia.title, waitForAniListRequestSlot);
progress?.onChecking?.({
mediaId: resolvedMedia.id,
mediaTitle: resolvedMedia.title,
});
return getOrCreateSnapshot(
resolvedMedia.id,
resolvedMedia.title,
waitForAniListRequestSlot,
progress,
);
},
buildMergedDictionary: async (mediaIds: number[]) => {
const normalizedMediaIds = mediaIds

View File

@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
notifyCharacterDictionaryAutoSyncStatus,
type CharacterDictionaryAutoSyncNotificationEvent,
} from './character-dictionary-auto-sync-notifications';
function makeEvent(
phase: CharacterDictionaryAutoSyncNotificationEvent['phase'],
message: string,
): CharacterDictionaryAutoSyncNotificationEvent {
return {
phase,
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
message,
};
}
test('auto sync notifications send osd updates for progress phases', () => {
const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('checking', 'checking'), {
getNotificationType: () => 'osd',
showOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating'), {
getNotificationType: () => 'osd',
showOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
getNotificationType: () => 'osd',
showOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
getNotificationType: () => 'osd',
showOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
getNotificationType: () => 'osd',
showOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, [
'osd:checking',
'osd:generating',
'osd:syncing',
'osd:importing',
'osd:ready',
]);
});
test('auto sync notifications never send desktop notifications', () => {
const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
getNotificationType: () => 'both',
showOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
getNotificationType: () => 'both',
showOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
getNotificationType: () => 'both',
showOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
notifyCharacterDictionaryAutoSyncStatus(makeEvent('failed', 'failed'), {
getNotificationType: () => 'both',
showOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, ['osd:syncing', 'osd:importing', 'osd:ready', 'osd:failed']);
});

View File

@@ -0,0 +1,34 @@
import type { CharacterDictionaryAutoSyncStatusEvent } from './character-dictionary-auto-sync';
import type { StartupOsdSequencerCharacterDictionaryEvent } from './startup-osd-sequencer';
export type CharacterDictionaryAutoSyncNotificationEvent = CharacterDictionaryAutoSyncStatusEvent;
export interface CharacterDictionaryAutoSyncNotificationDeps {
getNotificationType: () => 'osd' | 'system' | 'both' | 'none' | undefined;
showOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body?: string }) => void;
startupOsdSequencer?: {
notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => void;
};
}
function shouldShowOsd(type: 'osd' | 'system' | 'both' | 'none' | undefined): boolean {
return type !== 'none';
}
export function notifyCharacterDictionaryAutoSyncStatus(
event: CharacterDictionaryAutoSyncNotificationEvent,
deps: CharacterDictionaryAutoSyncNotificationDeps,
): void {
const type = deps.getNotificationType();
if (shouldShowOsd(type)) {
if (deps.startupOsdSequencer) {
deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
phase: event.phase,
message: event.message,
});
return;
}
deps.showOsd(event.message);
}
}

View File

@@ -9,6 +9,14 @@ function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-char-dict-auto-sync-'));
}
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
test('auto sync imports merged dictionary and persists MRU state', async () => {
const userDataPath = makeTempDir();
const imported: string[] = [];
@@ -267,3 +275,296 @@ test('auto sync evicts least recently used media from merged set', async () => {
};
assert.deepEqual(state.activeMediaIds, [4, 3, 2]);
});
test('auto sync invokes completion callback after successful sync', async () => {
const userDataPath = makeTempDir();
const completions: Array<{ mediaId: number; mediaTitle: string; changed: boolean }> = [];
let importedRevision: string | null = null;
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath,
getConfig: () => ({
enabled: true,
maxLoaded: 3,
profileScope: 'all',
}),
getOrCreateCurrentSnapshot: async () => ({
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
entryCount: 2560,
fromCache: false,
updatedAt: 1000,
}),
buildMergedDictionary: async () => ({
zipPath: '/tmp/merged.zip',
revision: 'rev-101291',
dictionaryTitle: 'SubMiner Character Dictionary',
entryCount: 2560,
}),
getYomitanDictionaryInfo: async () =>
importedRevision
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
: [],
importYomitanDictionary: async () => {
importedRevision = 'rev-101291';
return true;
},
deleteYomitanDictionary: async () => true,
upsertYomitanDictionarySettings: async () => true,
now: () => 1000,
onSyncComplete: (completion) => {
completions.push(completion);
},
});
await runtime.runSyncNow();
assert.deepEqual(completions, [
{
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
changed: true,
},
]);
});
test('auto sync emits progress events for start import and completion', async () => {
const userDataPath = makeTempDir();
const events: Array<{
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
mediaId?: number;
mediaTitle?: string;
message: string;
changed?: boolean;
}> = [];
let importedRevision: string | null = null;
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath,
getConfig: () => ({
enabled: true,
maxLoaded: 3,
profileScope: 'all',
}),
getOrCreateCurrentSnapshot: async (_targetPath, progress) => {
progress?.onChecking?.({
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
});
progress?.onGenerating?.({
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
});
return {
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
entryCount: 2560,
fromCache: false,
updatedAt: 1000,
};
},
buildMergedDictionary: async () => ({
zipPath: '/tmp/merged.zip',
revision: 'rev-101291',
dictionaryTitle: 'SubMiner Character Dictionary',
entryCount: 2560,
}),
getYomitanDictionaryInfo: async () =>
importedRevision
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
: [],
importYomitanDictionary: async () => {
importedRevision = 'rev-101291';
return true;
},
deleteYomitanDictionary: async () => true,
upsertYomitanDictionarySettings: async () => true,
now: () => 1000,
onSyncStatus: (event) => {
events.push(event);
},
});
await runtime.runSyncNow();
assert.deepEqual(events, [
{
phase: 'checking',
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
message: 'Checking character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
},
{
phase: 'generating',
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
message: 'Generating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
},
{
phase: 'syncing',
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
message: 'Updating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
},
{
phase: 'importing',
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
message: 'Importing character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
},
{
phase: 'ready',
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
message: 'Character dictionary ready for Rascal Does Not Dream of Bunny Girl Senpai',
changed: true,
},
]);
});
test('auto sync emits checking before snapshot resolves and skips generating on cache hit', async () => {
const userDataPath = makeTempDir();
const events: Array<{
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
mediaId?: number;
mediaTitle?: string;
message: string;
changed?: boolean;
}> = [];
const snapshotDeferred = createDeferred<{
mediaId: number;
mediaTitle: string;
entryCount: number;
fromCache: boolean;
updatedAt: number;
}>();
let importedRevision: string | null = null;
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath,
getConfig: () => ({
enabled: true,
maxLoaded: 3,
profileScope: 'all',
}),
getOrCreateCurrentSnapshot: async (_targetPath, progress) => {
progress?.onChecking?.({
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
});
return await snapshotDeferred.promise;
},
buildMergedDictionary: async () => ({
zipPath: '/tmp/merged.zip',
revision: 'rev-101291',
dictionaryTitle: 'SubMiner Character Dictionary',
entryCount: 2560,
}),
getYomitanDictionaryInfo: async () =>
importedRevision
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
: [],
importYomitanDictionary: async () => {
importedRevision = 'rev-101291';
return true;
},
deleteYomitanDictionary: async () => true,
upsertYomitanDictionarySettings: async () => true,
now: () => 1000,
onSyncStatus: (event) => {
events.push(event);
},
});
const syncPromise = runtime.runSyncNow();
await Promise.resolve();
assert.deepEqual(events, [
{
phase: 'checking',
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
message: 'Checking character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
},
]);
snapshotDeferred.resolve({
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
entryCount: 2560,
fromCache: true,
updatedAt: 1000,
});
await syncPromise;
assert.equal(
events.some((event) => event.phase === 'generating'),
false,
);
});
test('auto sync waits for tokenization-ready gate before Yomitan mutations', async () => {
const userDataPath = makeTempDir();
const gate = (() => {
let resolve!: () => void;
const promise = new Promise<void>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
})();
const calls: string[] = [];
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath,
getConfig: () => ({
enabled: true,
maxLoaded: 3,
profileScope: 'all',
}),
getOrCreateCurrentSnapshot: async () => ({
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
entryCount: 2560,
fromCache: false,
updatedAt: 1000,
}),
buildMergedDictionary: async () => {
calls.push('build');
return {
zipPath: '/tmp/merged.zip',
revision: 'rev-101291',
dictionaryTitle: 'SubMiner Character Dictionary',
entryCount: 2560,
};
},
waitForYomitanMutationReady: async () => {
calls.push('wait');
await gate.promise;
},
getYomitanDictionaryInfo: async () => {
calls.push('info');
return [];
},
importYomitanDictionary: async () => {
calls.push('import');
return true;
},
deleteYomitanDictionary: async () => true,
upsertYomitanDictionarySettings: async () => {
calls.push('settings');
return true;
},
now: () => 1000,
});
const syncPromise = runtime.runSyncNow();
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(calls, ['build', 'wait']);
gate.resolve();
await syncPromise;
assert.deepEqual(calls, ['build', 'wait', 'info', 'import', 'settings']);
});

View File

@@ -2,6 +2,7 @@ import * as fs from 'fs';
import * as path from 'path';
import type { AnilistCharacterDictionaryProfileScope } from '../../types';
import type {
CharacterDictionarySnapshotProgressCallbacks,
CharacterDictionarySnapshotResult,
MergedCharacterDictionaryBuildResult,
} from '../character-dictionary-runtime';
@@ -23,11 +24,23 @@ export interface CharacterDictionaryAutoSyncConfig {
profileScope: AnilistCharacterDictionaryProfileScope;
}
export interface CharacterDictionaryAutoSyncStatusEvent {
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
mediaId?: number;
mediaTitle?: string;
message: string;
changed?: boolean;
}
export interface CharacterDictionaryAutoSyncRuntimeDeps {
userDataPath: string;
getConfig: () => CharacterDictionaryAutoSyncConfig;
getOrCreateCurrentSnapshot: (targetPath?: string) => Promise<CharacterDictionarySnapshotResult>;
getOrCreateCurrentSnapshot: (
targetPath?: string,
progress?: CharacterDictionarySnapshotProgressCallbacks,
) => Promise<CharacterDictionarySnapshotResult>;
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
waitForYomitanMutationReady?: () => Promise<void>;
getYomitanDictionaryInfo: () => Promise<AutoSyncDictionaryInfo[]>;
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
@@ -41,6 +54,8 @@ export interface CharacterDictionaryAutoSyncRuntimeDeps {
operationTimeoutMs?: number;
logInfo?: (message: string) => void;
logWarn?: (message: string) => void;
onSyncStatus?: (event: CharacterDictionaryAutoSyncStatusEvent) => void;
onSyncComplete?: (result: { mediaId: number; mediaTitle: string; changed: boolean }) => void;
}
function ensureDir(dirPath: string): void {
@@ -92,6 +107,33 @@ function arraysEqual(left: number[], right: number[]): boolean {
return true;
}
function buildSyncingMessage(mediaTitle: string): string {
return `Updating character dictionary for ${mediaTitle}...`;
}
function buildCheckingMessage(mediaTitle: string): string {
return `Checking character dictionary for ${mediaTitle}...`;
}
function buildGeneratingMessage(mediaTitle: string): string {
return `Generating character dictionary for ${mediaTitle}...`;
}
function buildImportingMessage(mediaTitle: string): string {
return `Importing character dictionary for ${mediaTitle}...`;
}
function buildReadyMessage(mediaTitle: string): string {
return `Character dictionary ready for ${mediaTitle}`;
}
function buildFailedMessage(mediaTitle: string | null, errorMessage: string): string {
if (mediaTitle) {
return `Character dictionary sync failed for ${mediaTitle}: ${errorMessage}`;
}
return `Character dictionary sync failed: ${errorMessage}`;
}
export function createCharacterDictionaryAutoSyncRuntimeService(
deps: CharacterDictionaryAutoSyncRuntimeDeps,
): {
@@ -133,84 +175,150 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
return;
}
deps.logInfo?.('[dictionary:auto-sync] syncing current anime snapshot');
const snapshot = await deps.getOrCreateCurrentSnapshot();
const state = readAutoSyncState(statePath);
const nextActiveMediaIds = [
snapshot.mediaId,
...state.activeMediaIds.filter((mediaId) => mediaId !== snapshot.mediaId),
].slice(0, Math.max(1, Math.floor(config.maxLoaded)));
deps.logInfo?.(
`[dictionary:auto-sync] active AniList media set: ${nextActiveMediaIds.join(', ')}`,
);
let currentMediaId: number | undefined;
let currentMediaTitle: string | null = null;
const retainedChanged = !arraysEqual(nextActiveMediaIds, state.activeMediaIds);
let merged: MergedCharacterDictionaryBuildResult | null = null;
if (
retainedChanged ||
!state.mergedRevision ||
!state.mergedDictionaryTitle ||
!snapshot.fromCache
) {
deps.logInfo?.('[dictionary:auto-sync] rebuilding merged dictionary for active anime set');
merged = await deps.buildMergedDictionary(nextActiveMediaIds);
}
try {
deps.logInfo?.('[dictionary:auto-sync] syncing current anime snapshot');
const snapshot = await deps.getOrCreateCurrentSnapshot(undefined, {
onChecking: ({ mediaId, mediaTitle }) => {
currentMediaId = mediaId;
currentMediaTitle = mediaTitle;
deps.onSyncStatus?.({
phase: 'checking',
mediaId,
mediaTitle,
message: buildCheckingMessage(mediaTitle),
});
},
onGenerating: ({ mediaId, mediaTitle }) => {
currentMediaId = mediaId;
currentMediaTitle = mediaTitle;
deps.onSyncStatus?.({
phase: 'generating',
mediaId,
mediaTitle,
message: buildGeneratingMessage(mediaTitle),
});
},
});
currentMediaId = snapshot.mediaId;
currentMediaTitle = snapshot.mediaTitle;
deps.onSyncStatus?.({
phase: 'syncing',
mediaId: snapshot.mediaId,
mediaTitle: snapshot.mediaTitle,
message: buildSyncingMessage(snapshot.mediaTitle),
});
const state = readAutoSyncState(statePath);
const nextActiveMediaIds = [
snapshot.mediaId,
...state.activeMediaIds.filter((mediaId) => mediaId !== snapshot.mediaId),
].slice(0, Math.max(1, Math.floor(config.maxLoaded)));
deps.logInfo?.(
`[dictionary:auto-sync] active AniList media set: ${nextActiveMediaIds.join(', ')}`,
);
const dictionaryTitle = merged?.dictionaryTitle ?? state.mergedDictionaryTitle;
const revision = merged?.revision ?? state.mergedRevision;
if (!dictionaryTitle || !revision) {
throw new Error('Merged character dictionary state is incomplete.');
}
const dictionaryInfo = await withOperationTimeout(
'getYomitanDictionaryInfo',
deps.getYomitanDictionaryInfo(),
);
const existing = dictionaryInfo.find((entry) => entry.title === dictionaryTitle) ?? null;
const existingRevision =
existing && (typeof existing.revision === 'string' || typeof existing.revision === 'number')
? String(existing.revision)
: null;
const shouldImport =
merged !== null ||
existing === null ||
existingRevision === null ||
existingRevision !== revision;
if (shouldImport) {
if (existing !== null) {
await withOperationTimeout(
`deleteYomitanDictionary(${dictionaryTitle})`,
deps.deleteYomitanDictionary(dictionaryTitle),
);
}
if (merged === null) {
const retainedChanged = !arraysEqual(nextActiveMediaIds, state.activeMediaIds);
let merged: MergedCharacterDictionaryBuildResult | null = null;
if (
retainedChanged ||
!state.mergedRevision ||
!state.mergedDictionaryTitle ||
!snapshot.fromCache
) {
deps.logInfo?.('[dictionary:auto-sync] rebuilding merged dictionary for active anime set');
merged = await deps.buildMergedDictionary(nextActiveMediaIds);
}
deps.logInfo?.(`[dictionary:auto-sync] importing merged dictionary: ${merged.zipPath}`);
const imported = await withOperationTimeout(
`importYomitanDictionary(${path.basename(merged.zipPath)})`,
deps.importYomitanDictionary(merged.zipPath),
);
if (!imported) {
throw new Error(`Failed to import dictionary ZIP: ${merged.zipPath}`);
const dictionaryTitle = merged?.dictionaryTitle ?? state.mergedDictionaryTitle;
const revision = merged?.revision ?? state.mergedRevision;
if (!dictionaryTitle || !revision) {
throw new Error('Merged character dictionary state is incomplete.');
}
await deps.waitForYomitanMutationReady?.();
const dictionaryInfo = await withOperationTimeout(
'getYomitanDictionaryInfo',
deps.getYomitanDictionaryInfo(),
);
const existing = dictionaryInfo.find((entry) => entry.title === dictionaryTitle) ?? null;
const existingRevision =
existing && (typeof existing.revision === 'string' || typeof existing.revision === 'number')
? String(existing.revision)
: null;
const shouldImport =
merged !== null ||
existing === null ||
existingRevision === null ||
existingRevision !== revision;
let changed = merged !== null;
if (shouldImport) {
deps.onSyncStatus?.({
phase: 'importing',
mediaId: snapshot.mediaId,
mediaTitle: snapshot.mediaTitle,
message: buildImportingMessage(snapshot.mediaTitle),
});
if (existing !== null) {
await withOperationTimeout(
`deleteYomitanDictionary(${dictionaryTitle})`,
deps.deleteYomitanDictionary(dictionaryTitle),
);
}
if (merged === null) {
merged = await deps.buildMergedDictionary(nextActiveMediaIds);
}
deps.logInfo?.(`[dictionary:auto-sync] importing merged dictionary: ${merged.zipPath}`);
const imported = await withOperationTimeout(
`importYomitanDictionary(${path.basename(merged.zipPath)})`,
deps.importYomitanDictionary(merged.zipPath),
);
if (!imported) {
throw new Error(`Failed to import dictionary ZIP: ${merged.zipPath}`);
}
changed = true;
}
deps.logInfo?.(`[dictionary:auto-sync] applying Yomitan settings for ${dictionaryTitle}`);
const settingsUpdated = await withOperationTimeout(
`upsertYomitanDictionarySettings(${dictionaryTitle})`,
deps.upsertYomitanDictionarySettings(dictionaryTitle, config.profileScope),
);
changed = changed || settingsUpdated === true;
writeAutoSyncState(statePath, {
activeMediaIds: nextActiveMediaIds,
mergedRevision: merged?.revision ?? revision,
mergedDictionaryTitle: merged?.dictionaryTitle ?? dictionaryTitle,
});
deps.logInfo?.(
`[dictionary:auto-sync] synced AniList ${snapshot.mediaId}: ${dictionaryTitle} (${snapshot.entryCount} entries)`,
);
deps.onSyncStatus?.({
phase: 'ready',
mediaId: snapshot.mediaId,
mediaTitle: snapshot.mediaTitle,
message: buildReadyMessage(snapshot.mediaTitle),
changed,
});
deps.onSyncComplete?.({
mediaId: snapshot.mediaId,
mediaTitle: snapshot.mediaTitle,
changed,
});
} catch (error) {
const errorMessage = (error as Error)?.message ?? String(error);
deps.onSyncStatus?.({
phase: 'failed',
mediaId: currentMediaId,
mediaTitle: currentMediaTitle ?? undefined,
message: buildFailedMessage(currentMediaTitle, errorMessage),
});
throw error;
}
deps.logInfo?.(`[dictionary:auto-sync] applying Yomitan settings for ${dictionaryTitle}`);
await withOperationTimeout(
`upsertYomitanDictionarySettings(${dictionaryTitle})`,
deps.upsertYomitanDictionarySettings(dictionaryTitle, config.profileScope),
);
writeAutoSyncState(statePath, {
activeMediaIds: nextActiveMediaIds,
mergedRevision: merged?.revision ?? revision,
mergedDictionaryTitle: merged?.dictionaryTitle ?? dictionaryTitle,
});
deps.logInfo?.(
`[dictionary:auto-sync] synced AniList ${snapshot.mediaId}: ${dictionaryTitle} (${snapshot.entryCount} entries)`,
);
};
const enqueueSync = (): void => {

View File

@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createCurrentMediaTokenizationGate } from './current-media-tokenization-gate';
test('current media tokenization gate waits until current path is marked ready', async () => {
const gate = createCurrentMediaTokenizationGate();
gate.updateCurrentMediaPath('/tmp/video-1.mkv');
let resolved = false;
const waitPromise = gate.waitUntilReady('/tmp/video-1.mkv').then(() => {
resolved = true;
});
await Promise.resolve();
assert.equal(resolved, false);
gate.markReady('/tmp/video-1.mkv');
await waitPromise;
assert.equal(resolved, true);
});
test('current media tokenization gate resolves old waiters when media changes', async () => {
const gate = createCurrentMediaTokenizationGate();
gate.updateCurrentMediaPath('/tmp/video-1.mkv');
let resolved = false;
const waitPromise = gate.waitUntilReady('/tmp/video-1.mkv').then(() => {
resolved = true;
});
gate.updateCurrentMediaPath('/tmp/video-2.mkv');
await waitPromise;
assert.equal(resolved, true);
});
test('current media tokenization gate returns immediately for ready media', async () => {
const gate = createCurrentMediaTokenizationGate();
gate.updateCurrentMediaPath('/tmp/video-1.mkv');
gate.markReady('/tmp/video-1.mkv');
await gate.waitUntilReady('/tmp/video-1.mkv');
});

View File

@@ -0,0 +1,70 @@
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 createCurrentMediaTokenizationGate(): {
updateCurrentMediaPath: (mediaPath: string | null | undefined) => void;
markReady: (mediaPath: string | null | undefined) => void;
waitUntilReady: (mediaPath: string | null | undefined) => Promise<void>;
} {
let currentMediaPath: string | null = null;
let readyMediaPath: string | null = null;
let pendingMediaPath: string | null = null;
let pendingPromise: Promise<void> | null = null;
let resolvePending: (() => void) | null = null;
const resolvePendingWaiter = (): void => {
resolvePending?.();
resolvePending = null;
pendingPromise = null;
pendingMediaPath = null;
};
const ensurePendingPromise = (mediaPath: string): Promise<void> => {
if (pendingMediaPath === mediaPath && pendingPromise) {
return pendingPromise;
}
resolvePendingWaiter();
pendingMediaPath = mediaPath;
pendingPromise = new Promise<void>((resolve) => {
resolvePending = resolve;
});
return pendingPromise;
};
return {
updateCurrentMediaPath: (mediaPath) => {
const normalizedPath = normalizeMediaPath(mediaPath);
if (normalizedPath === currentMediaPath) {
return;
}
currentMediaPath = normalizedPath;
readyMediaPath = null;
resolvePendingWaiter();
if (normalizedPath) {
ensurePendingPromise(normalizedPath);
}
},
markReady: (mediaPath) => {
const normalizedPath = normalizeMediaPath(mediaPath);
if (!normalizedPath) {
return;
}
readyMediaPath = normalizedPath;
if (pendingMediaPath === normalizedPath) {
resolvePendingWaiter();
}
},
waitUntilReady: async (mediaPath) => {
const normalizedPath = normalizeMediaPath(mediaPath) ?? currentMediaPath;
if (!normalizedPath || readyMediaPath === normalizedPath) {
return;
}
await ensurePendingPromise(normalizedPath);
},
};
}

View File

@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createStartupOsdSequencer,
type StartupOsdSequencerCharacterDictionaryEvent,
} from './startup-osd-sequencer';
function makeDictionaryEvent(
phase: StartupOsdSequencerCharacterDictionaryEvent['phase'],
message: string,
): StartupOsdSequencerCharacterDictionaryEvent {
return {
phase,
message,
};
}
test('startup OSD keeps dictionary progress hidden until tokenization and annotation loading finish', () => {
const osdMessages: string[] = [];
const sequencer = createStartupOsdSequencer({
showOsd: (message) => {
osdMessages.push(message);
},
});
sequencer.notifyCharacterDictionaryStatus(
makeDictionaryEvent('syncing', 'Updating character dictionary for Frieren...'),
);
sequencer.showAnnotationLoading('Loading subtitle annotations |');
sequencer.markTokenizationReady();
assert.deepEqual(osdMessages, ['Loading subtitle annotations |']);
sequencer.showAnnotationLoading('Loading subtitle annotations /');
assert.deepEqual(osdMessages, [
'Loading subtitle annotations |',
'Loading subtitle annotations /',
]);
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
assert.deepEqual(osdMessages, [
'Loading subtitle annotations |',
'Loading subtitle annotations /',
'Updating character dictionary for Frieren...',
]);
});
test('startup OSD buffers checking behind annotations and replaces it with later generating progress', () => {
const osdMessages: string[] = [];
const sequencer = createStartupOsdSequencer({
showOsd: (message) => {
osdMessages.push(message);
},
});
sequencer.notifyCharacterDictionaryStatus(
makeDictionaryEvent('checking', 'Checking character dictionary for Frieren...'),
);
sequencer.showAnnotationLoading('Loading subtitle annotations |');
sequencer.markTokenizationReady();
sequencer.notifyCharacterDictionaryStatus(
makeDictionaryEvent('generating', 'Generating character dictionary for Frieren...'),
);
assert.deepEqual(osdMessages, ['Loading subtitle annotations |']);
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
assert.deepEqual(osdMessages, [
'Loading subtitle annotations |',
'Generating character dictionary for Frieren...',
]);
});
test('startup OSD skips buffered dictionary ready messages when progress completed before it became visible', () => {
const osdMessages: string[] = [];
const sequencer = createStartupOsdSequencer({
showOsd: (message) => {
osdMessages.push(message);
},
});
sequencer.notifyCharacterDictionaryStatus(
makeDictionaryEvent('syncing', 'Updating character dictionary for Frieren...'),
);
sequencer.notifyCharacterDictionaryStatus(
makeDictionaryEvent('ready', 'Character dictionary ready for Frieren'),
);
sequencer.markTokenizationReady();
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
assert.deepEqual(osdMessages, ['Subtitle annotations loaded']);
});
test('startup OSD shows dictionary failure after annotation loading completes', () => {
const osdMessages: string[] = [];
const sequencer = createStartupOsdSequencer({
showOsd: (message) => {
osdMessages.push(message);
},
});
sequencer.showAnnotationLoading('Loading subtitle annotations |');
sequencer.notifyCharacterDictionaryStatus(
makeDictionaryEvent('failed', 'Character dictionary sync failed for Frieren: boom'),
);
sequencer.markTokenizationReady();
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
assert.deepEqual(osdMessages, [
'Loading subtitle annotations |',
'Character dictionary sync failed for Frieren: boom',
]);
});
test('startup OSD reset requires the next media to wait for tokenization again', () => {
const osdMessages: string[] = [];
const sequencer = createStartupOsdSequencer({
showOsd: (message) => {
osdMessages.push(message);
},
});
sequencer.markTokenizationReady();
sequencer.reset();
sequencer.notifyCharacterDictionaryStatus(
makeDictionaryEvent('syncing', 'Updating character dictionary for Frieren...'),
);
assert.deepEqual(osdMessages, []);
sequencer.markTokenizationReady();
assert.deepEqual(osdMessages, ['Updating character dictionary for Frieren...']);
});

View File

@@ -0,0 +1,106 @@
export interface StartupOsdSequencerCharacterDictionaryEvent {
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
message: string;
}
export function createStartupOsdSequencer(deps: { showOsd: (message: string) => void }): {
reset: () => void;
markTokenizationReady: () => void;
showAnnotationLoading: (message: string) => void;
markAnnotationLoadingComplete: (message: string) => void;
notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => void;
} {
let tokenizationReady = false;
let annotationLoadingMessage: string | null = null;
let pendingDictionaryProgress: StartupOsdSequencerCharacterDictionaryEvent | null = null;
let pendingDictionaryFailure: StartupOsdSequencerCharacterDictionaryEvent | null = null;
let dictionaryProgressShown = false;
const canShowDictionaryStatus = (): boolean =>
tokenizationReady && annotationLoadingMessage === null;
const flushBufferedDictionaryStatus = (): boolean => {
if (!canShowDictionaryStatus()) {
return false;
}
if (pendingDictionaryProgress) {
deps.showOsd(pendingDictionaryProgress.message);
dictionaryProgressShown = true;
return true;
}
if (pendingDictionaryFailure) {
deps.showOsd(pendingDictionaryFailure.message);
pendingDictionaryFailure = null;
dictionaryProgressShown = false;
return true;
}
return false;
};
return {
reset: () => {
tokenizationReady = false;
annotationLoadingMessage = null;
pendingDictionaryProgress = null;
pendingDictionaryFailure = null;
dictionaryProgressShown = false;
},
markTokenizationReady: () => {
tokenizationReady = true;
if (annotationLoadingMessage !== null) {
deps.showOsd(annotationLoadingMessage);
return;
}
flushBufferedDictionaryStatus();
},
showAnnotationLoading: (message) => {
annotationLoadingMessage = message;
if (tokenizationReady) {
deps.showOsd(message);
}
},
markAnnotationLoadingComplete: (message) => {
annotationLoadingMessage = null;
if (!tokenizationReady) {
return;
}
if (flushBufferedDictionaryStatus()) {
return;
}
deps.showOsd(message);
},
notifyCharacterDictionaryStatus: (event) => {
if (
event.phase === 'checking' ||
event.phase === 'generating' ||
event.phase === 'syncing' ||
event.phase === 'importing'
) {
pendingDictionaryProgress = event;
pendingDictionaryFailure = null;
if (canShowDictionaryStatus()) {
deps.showOsd(event.message);
dictionaryProgressShown = true;
}
return;
}
pendingDictionaryProgress = null;
if (event.phase === 'failed') {
if (canShowDictionaryStatus()) {
deps.showOsd(event.message);
} else {
pendingDictionaryFailure = event;
}
dictionaryProgressShown = false;
return;
}
pendingDictionaryFailure = null;
if (canShowDictionaryStatus() && dictionaryProgressShown) {
deps.showOsd(event.message);
}
dictionaryProgressShown = false;
},
};
}

View File

@@ -80,6 +80,8 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
ensureJlptDictionaryLookup: () => Promise<void>;
ensureFrequencyDictionaryLookup: () => Promise<void>;
showMpvOsd?: (message: string) => void;
showLoadingOsd?: (message: string) => void;
showLoadedOsd?: (message: string) => void;
shouldShowOsdNotification?: () => boolean;
setInterval?: (callback: () => void, delayMs: number) => unknown;
clearInterval?: (timer: unknown) => void;
@@ -90,6 +92,8 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
let loadingOsdFrame = 0;
let loadingOsdTimer: unknown = null;
const showMpvOsd = deps.showMpvOsd;
const showLoadingOsd = deps.showLoadingOsd ?? showMpvOsd;
const showLoadedOsd = deps.showLoadedOsd ?? showMpvOsd;
const setIntervalHandler =
deps.setInterval ??
((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs));
@@ -99,7 +103,7 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
const spinnerFrames = ['|', '/', '-', '\\'];
const beginLoadingOsd = (): boolean => {
if (!showMpvOsd) {
if (!showLoadingOsd) {
return false;
}
loadingOsdDepth += 1;
@@ -108,13 +112,13 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
}
loadingOsdFrame = 0;
showMpvOsd(`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame]}`);
showLoadingOsd(`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame]}`);
loadingOsdFrame += 1;
loadingOsdTimer = setIntervalHandler(() => {
if (!showMpvOsd) {
if (!showLoadingOsd) {
return;
}
showMpvOsd(
showLoadingOsd(
`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame % spinnerFrames.length]}`,
);
loadingOsdFrame += 1;
@@ -123,7 +127,7 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
};
const endLoadingOsd = (): void => {
if (!showMpvOsd) {
if (!showLoadedOsd) {
return;
}
@@ -136,7 +140,7 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
clearIntervalHandler(loadingOsdTimer);
loadingOsdTimer = null;
}
showMpvOsd('Subtitle annotations loaded');
showLoadedOsd('Subtitle annotations loaded');
};
return async (options?: { showLoadingOsd?: boolean }): Promise<void> => {

View File

@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
parseHyprctlClients,
selectHyprlandMpvWindow,
type HyprlandClient,
} from './hyprland-tracker';
@@ -9,6 +10,7 @@ function makeClient(overrides: Partial<HyprlandClient> = {}): HyprlandClient {
return {
address: '0x1',
class: 'mpv',
initialClass: 'mpv',
at: [0, 0],
size: [1280, 720],
mapped: true,
@@ -70,3 +72,37 @@ test('selectHyprlandMpvWindow prefers active visible window among socket matches
assert.equal(selected?.address, '0xsecond');
});
test('selectHyprlandMpvWindow matches mpv by initialClass when class is blank', () => {
const selected = selectHyprlandMpvWindow(
[
makeClient({
address: '0xinitial',
class: '',
initialClass: 'mpv',
}),
],
{
targetMpvSocketPath: null,
activeWindowAddress: null,
getWindowCommandLine: () => null,
},
);
assert.equal(selected?.address, '0xinitial');
});
test('parseHyprctlClients tolerates non-json prefix output', () => {
const clients = parseHyprctlClients(`ok
[{"address":"0x1","class":"mpv","initialClass":"mpv","at":[1,2],"size":[3,4]}]`);
assert.deepEqual(clients, [
{
address: '0x1',
class: 'mpv',
initialClass: 'mpv',
at: [1, 2],
size: [3, 4],
},
]);
});

View File

@@ -26,6 +26,7 @@ const log = createLogger('tracker').child('hyprland');
export interface HyprlandClient {
address?: string;
class: string;
initialClass?: string;
at: [number, number];
size: [number, number];
pid?: number;
@@ -39,6 +40,23 @@ interface SelectHyprlandMpvWindowOptions {
getWindowCommandLine: (pid: number) => string | null;
}
function extractHyprctlJsonPayload(output: string): string | null {
const trimmed = output.trim();
if (!trimmed) {
return null;
}
const arrayStart = trimmed.indexOf('[');
const objectStart = trimmed.indexOf('{');
const startCandidates = [arrayStart, objectStart].filter((index) => index >= 0);
if (startCandidates.length === 0) {
return null;
}
const startIndex = Math.min(...startCandidates);
return trimmed.slice(startIndex);
}
function matchesTargetSocket(commandLine: string, targetMpvSocketPath: string): boolean {
return (
commandLine.includes(`--input-ipc-server=${targetMpvSocketPath}`) ||
@@ -60,12 +78,23 @@ function preferActiveHyprlandWindow(
return clients[0] ?? null;
}
function isMpvClassName(value: string | undefined): boolean {
if (!value) {
return false;
}
return value.trim().toLowerCase().includes('mpv');
}
export function selectHyprlandMpvWindow(
clients: HyprlandClient[],
options: SelectHyprlandMpvWindowOptions,
): HyprlandClient | null {
const visibleMpvWindows = clients.filter(
(client) => client.class === 'mpv' && client.mapped !== false && client.hidden !== true,
(client) =>
(isMpvClassName(client.class) || isMpvClassName(client.initialClass)) &&
client.mapped !== false &&
client.hidden !== true,
);
if (!options.targetMpvSocketPath) {
@@ -89,6 +118,20 @@ export function selectHyprlandMpvWindow(
return preferActiveHyprlandWindow(matchingWindows, options.activeWindowAddress);
}
export function parseHyprctlClients(output: string): HyprlandClient[] | null {
const jsonPayload = extractHyprctlJsonPayload(output);
if (!jsonPayload) {
return null;
}
const parsed = JSON.parse(jsonPayload) as unknown;
if (!Array.isArray(parsed)) {
return null;
}
return parsed as HyprlandClient[];
}
export class HyprlandWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private eventSocket: net.Socket | null = null;
@@ -185,8 +228,12 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
private pollGeometry(): void {
try {
const output = execSync('hyprctl clients -j', { encoding: 'utf-8' });
const clients: HyprlandClient[] = JSON.parse(output);
const output = execSync('hyprctl -j clients', { encoding: 'utf-8' });
const clients = parseHyprctlClients(output);
if (!clients) {
this.updateGeometry(null);
return;
}
const mpvWindow = this.findTargetWindow(clients);
if (mpvWindow) {