mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
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:
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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] },
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
41
src/main.ts
41
src/main.ts
@@ -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';
|
||||
|
||||
@@ -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 -God’s 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 -God’s 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
42
src/main/runtime/current-media-tokenization-gate.test.ts
Normal file
42
src/main/runtime/current-media-tokenization-gate.test.ts
Normal 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');
|
||||
});
|
||||
70
src/main/runtime/current-media-tokenization-gate.ts
Normal file
70
src/main/runtime/current-media-tokenization-gate.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
134
src/main/runtime/startup-osd-sequencer.test.ts
Normal file
134
src/main/runtime/startup-osd-sequencer.test.ts
Normal 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...']);
|
||||
});
|
||||
106
src/main/runtime/startup-osd-sequencer.ts
Normal file
106
src/main/runtime/startup-osd-sequencer.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user