chore: merge main and update Electron to 43.7.2

This commit is contained in:
2026-09-25 10:10:04 -07:00
584 changed files with 48824 additions and 10097 deletions
+3 -3
View File
@@ -450,7 +450,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
}
const nameSplitTokenizerAvailable = isNameSplitTokenizerAvailable();
const resolvedNameSplits = nameSplitTokenizerAvailable
const nameSplitResolution = nameSplitTokenizerAvailable
? await resolveJapaneseNameSplits(
characters,
deps.tokenizeJapaneseName!,
@@ -466,8 +466,8 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
},
)
: undefined;
const nameSplitSource =
resolvedNameSplits && resolvedNameSplits.size > 0 ? 'mecab' : 'heuristic';
const resolvedNameSplits = nameSplitResolution?.splits;
const nameSplitSource = nameSplitResolution?.kind === 'complete' ? 'mecab' : 'heuristic';
progress?.onGenerateProgress?.({
mediaId,
@@ -198,6 +198,66 @@ test('createCharacterDictionaryImageLookup can scope duplicate names to the curr
assert.equal(scoped.alt, 'Kazuma');
});
test('createCharacterDictionaryImageLookup reports and retries a failed index-ready callback', async () => {
const outputDir = makeTempDir();
const snapshot: CharacterDictionarySnapshot = {
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
mediaId: 21858,
mediaTitle: 'Little Witch Academia',
entryCount: 1,
updatedAt: 1_700_000_000_000,
termEntries: [
[
'ダイアナ',
'だいあな',
'name primary',
'',
75,
[
{
type: 'structured-content',
content: {
tag: 'img',
path: 'img/m21858-c81709.png',
alt: 'ダイアナ・キャベンディッシュ',
},
},
],
0,
'',
],
],
images: [{ path: 'img/m21858-c81709.png', dataBase64: PNG_1X1_BASE64 }],
};
await writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
const callbackError = new Error('annotation refresh failed');
const reportingError = new Error('error reporter failed');
let readyCount = 0;
const reportedErrors: unknown[] = [];
const lookup = createCharacterDictionaryImageLookup({
outputDir,
onIndexReady: () => {
readyCount += 1;
if (readyCount === 1) {
throw callbackError;
}
},
onIndexReadyError: (error) => {
reportedErrors.push(error);
throw reportingError;
},
});
assert.equal(lookup.get('ダイアナ', snapshot.mediaId), null);
await waitForRefresh(() => (reportedErrors.length === 1 ? true : null));
assert.ok(lookup.get('ダイアナ', snapshot.mediaId));
assert.equal(readyCount, 2);
assert.deepEqual(reportedErrors, [callbackError]);
lookup.get('ダイアナ', snapshot.mediaId);
assert.equal(readyCount, 2);
});
test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', async () => {
const outputDir = makeTempDir();
const snapshot: CharacterDictionarySnapshot = {
@@ -218,6 +218,8 @@ export function createCharacterDictionaryImageLookup(deps: {
userDataPath?: string;
outputDir?: string;
getCurrentMediaId?: () => number | null | undefined;
onIndexReady?: () => void;
onIndexReadyError?: (error: unknown) => void;
}): {
get: (term: string, mediaId?: number | null) => CharacterNameImage | null;
invalidate: () => void;
@@ -229,6 +231,24 @@ export function createCharacterDictionaryImageLookup(deps: {
let index = new Map<string, CharacterNameImage>();
let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
let refreshInFlight = false;
let indexReadyDeliveryPending = false;
function deliverIndexReadyIfPending(): void {
if (!indexReadyDeliveryPending || !deps.onIndexReady) {
return;
}
indexReadyDeliveryPending = false;
try {
deps.onIndexReady();
} catch (error) {
indexReadyDeliveryPending = true;
try {
deps.onIndexReadyError?.(error);
} catch {
// Error reporting must not reject the detached index refresh task.
}
}
}
// Rebuilding means re-reading every cached snapshot (potentially GBs of JSON), which used to run
// synchronously inside a lookup and froze the whole app right after a snapshot changed. Lookups
@@ -241,6 +261,7 @@ export function createCharacterDictionaryImageLookup(deps: {
signature = '';
return;
}
deliverIndexReadyIfPending();
const nextSignature = getSnapshotDirectorySignature(outputDir);
if (nextSignature === signature || refreshInFlight) {
return;
@@ -262,6 +283,8 @@ export function createCharacterDictionaryImageLookup(deps: {
index = nextIndex;
indexByMediaId = nextIndexByMediaId;
signature = nextSignature;
indexReadyDeliveryPending = deps.onIndexReady !== undefined;
deliverIndexReadyIfPending();
} finally {
refreshInFlight = false;
}
@@ -43,7 +43,8 @@ test('resolveJapaneseNameSplits splits a single-kanji surname via person-name PO
}),
);
assert.deepEqual(splits.get('東紫乃'), { family: '東', given: '紫乃' });
assert.equal(splits.kind, 'complete');
assert.deepEqual(splits.splits.get('東紫乃'), { family: '東', given: '紫乃' });
});
test('resolveJapaneseNameSplits corrects a hint-length-misleading surname boundary', async () => {
@@ -64,7 +65,8 @@ test('resolveJapaneseNameSplits corrects a hint-length-misleading surname bounda
}),
);
assert.deepEqual(splits.get('渡辺真奈美'), { family: '渡辺', given: '真奈美' });
assert.equal(splits.kind, 'complete');
assert.deepEqual(splits.splits.get('渡辺真奈美'), { family: '渡辺', given: '真奈美' });
});
test('resolveJapaneseNameSplits falls back to hint readings when POS tags are generic', async () => {
@@ -85,7 +87,8 @@ test('resolveJapaneseNameSplits falls back to hint readings when POS tags are ge
}),
);
assert.deepEqual(splits.get('鈴木みゆ'), { family: '鈴木', given: 'みゆ' });
assert.equal(splits.kind, 'complete');
assert.deepEqual(splits.splits.get('鈴木みゆ'), { family: '鈴木', given: 'みゆ' });
});
test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the name', async () => {
@@ -96,7 +99,8 @@ test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the
}),
);
assert.equal(splits.size, 0);
assert.equal(splits.kind, 'complete');
assert.equal(splits.splits.size, 0);
});
test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', async () => {
@@ -117,7 +121,8 @@ test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', asyn
}),
);
assert.equal(splits.size, 0);
assert.equal(splits.kind, 'complete');
assert.equal(splits.splits.size, 0);
});
test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
@@ -130,7 +135,8 @@ test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
(message) => warnings.push(message),
);
assert.equal(splits.size, 0);
assert.equal(splits.kind, 'incomplete');
assert.equal(splits.splits.size, 0);
assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /mecab unavailable/);
});
@@ -7,6 +7,10 @@ import type {
ResolvedNameSplit,
} from './types';
export type JapaneseNameSplitResolution =
| { kind: 'complete'; splits: Map<string, ResolvedNameSplit> }
| { kind: 'incomplete'; splits: Map<string, ResolvedNameSplit> };
const NAME_SEPARATOR_PATTERN = /[\s ・・·•]/;
function joinSurfaces(tokens: NameSplitToken[]): string {
@@ -87,8 +91,9 @@ export async function resolveJapaneseNameSplits(
tokenize: NameSplitTokenizer,
logWarn?: (message: string) => void,
onCharacterResolved?: (completed: number, total: number) => void,
): Promise<Map<string, ResolvedNameSplit>> {
): Promise<JapaneseNameSplitResolution> {
const splits = new Map<string, ResolvedNameSplit>();
let tokenizerFailed = false;
let resolvedCharacters = 0;
for (const character of characters) {
const familyHintReading = buildReadingFromHint(character.lastNameHint?.trim() || '');
@@ -99,12 +104,17 @@ export async function resolveJapaneseNameSplits(
try {
tokens = await tokenize(name);
} catch (err) {
tokenizerFailed = true;
logWarn?.(
`[dictionary] name split tokenization failed for "${name}": ${(err as Error).message}`,
);
continue;
}
if (!tokens || tokens.length < 2 || joinSurfaces(tokens) !== name) continue;
if (!tokens) {
tokenizerFailed = true;
continue;
}
if (tokens.length < 2 || joinSurfaces(tokens) !== name) continue;
const splitIndex =
splitIndexFromPersonNamePos(tokens) ??
splitIndexFromHintReadings(tokens, familyHintReading, givenHintReading);
@@ -118,5 +128,5 @@ export async function resolveJapaneseNameSplits(
resolvedCharacters += 1;
onCharacterResolved?.(resolvedCharacters, characters.length);
}
return splits;
return tokenizerFailed ? { kind: 'incomplete', splits } : { kind: 'complete', splits };
}
@@ -7,7 +7,7 @@ import test from 'node:test';
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
import { getSnapshotPath, writeSnapshot } from './cache';
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
import type { CharacterDictionarySnapshot } from './types';
import type { CharacterDictionarySnapshot, NameSplitTokenizer } from './types';
const GRAPHQL_URL = 'https://graphql.anilist.co';
const PNG_1X1 = Buffer.from(
@@ -121,7 +121,12 @@ test('generateForCurrentMedia refreshes same-version snapshots missing images wh
}
});
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => {
async function runNameSplitRefreshScenario(tokenizeJapaneseName: NameSplitTokenizer): Promise<{
characterPageRequests: number;
firstResultFromCache: boolean;
refreshedNameSplitSource: CharacterDictionarySnapshot['nameSplitSource'];
secondResultFromCache: boolean;
}> {
const userDataPath = makeTempDir();
const outputDir = path.join(userDataPath, 'character-dictionaries');
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
@@ -172,7 +177,6 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable'
}) as typeof globalThis.fetch;
try {
let tokenizerCalls = 0;
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
@@ -185,29 +189,54 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable'
source: 'fallback',
}),
getNameMatchImagesEnabled: () => false,
tokenizeJapaneseName: async () => {
tokenizerCalls += 1;
return null;
},
tokenizeJapaneseName,
getJapaneseNameTokenizerAvailable: () => true,
now: () => 1_700_000_000_500,
});
const result = await runtime.generateForCurrentMedia();
const firstResult = await runtime.generateForCurrentMedia();
const refreshedSnapshot = JSON.parse(
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
) as CharacterDictionarySnapshot;
const secondResult = await runtime.generateForCurrentMedia();
assert.equal(result.fromCache, false);
assert.equal(refreshedSnapshot.nameSplitSource, 'heuristic');
const retriedResult = await runtime.generateForCurrentMedia();
assert.equal(retriedResult.fromCache, false);
assert.equal(characterPageRequests, 2);
assert.equal(tokenizerCalls, 2);
return {
characterPageRequests,
firstResultFromCache: firstResult.fromCache,
refreshedNameSplitSource: refreshedSnapshot.nameSplitSource,
secondResultFromCache: secondResult.fromCache,
};
} finally {
globalThis.fetch = originalFetch;
}
}
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => {
let tokenizerCalls = 0;
const result = await runNameSplitRefreshScenario(async () => {
tokenizerCalls += 1;
return null;
});
assert.equal(result.firstResultFromCache, false);
assert.equal(result.refreshedNameSplitSource, 'heuristic');
assert.equal(result.secondResultFromCache, false);
assert.equal(result.characterPageRequests, 2);
assert.equal(tokenizerCalls, 2);
});
test('generateForCurrentMedia caches completed MeCab refreshes with no resolved splits', async () => {
let tokenizerCalls = 0;
const result = await runNameSplitRefreshScenario(async () => {
tokenizerCalls += 1;
return [];
});
assert.equal(result.firstResultFromCache, false);
assert.equal(result.refreshedNameSplitSource, 'mecab');
assert.equal(result.secondResultFromCache, true);
assert.equal(result.characterPageRequests, 1);
assert.equal(tokenizerCalls, 1);
});
test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => {
+14
View File
@@ -62,6 +62,11 @@ export interface MainIpcRuntimeServiceDepsParams {
onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint'];
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
previewMediaTimingReview?: IpcDepsRuntimeOptions['previewMediaTimingReview'];
getMediaTimingReviewFrame?: IpcDepsRuntimeOptions['getMediaTimingReviewFrame'];
getMediaTimingReviewWaveform?: IpcDepsRuntimeOptions['getMediaTimingReviewWaveform'];
stopMediaTimingReviewPreview?: IpcDepsRuntimeOptions['stopMediaTimingReviewPreview'];
resolveMediaTimingReview?: IpcDepsRuntimeOptions['resolveMediaTimingReview'];
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
quitApp: IpcDepsRuntimeOptions['quitApp'];
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
@@ -79,6 +84,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer'];
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
getMpvInputBindings?: IpcDepsRuntimeOptions['getMpvInputBindings'];
getSessionBindings: IpcDepsRuntimeOptions['getSessionBindings'];
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction'];
@@ -132,6 +138,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
getYoutubeMediaSourceUrl?: AnkiJimakuIpcRuntimeOptions['getYoutubeMediaSourceUrl'];
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged'];
getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver'];
@@ -256,6 +263,11 @@ export function createMainIpcRuntimeServiceDeps(
onOverlayInteractiveHint: params.onOverlayInteractiveHint,
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
onYoutubePickerResolve: params.onYoutubePickerResolve,
previewMediaTimingReview: params.previewMediaTimingReview,
getMediaTimingReviewFrame: params.getMediaTimingReviewFrame,
getMediaTimingReviewWaveform: params.getMediaTimingReviewWaveform,
stopMediaTimingReviewPreview: params.stopMediaTimingReviewPreview,
resolveMediaTimingReview: params.resolveMediaTimingReview,
openYomitanSettings: params.openYomitanSettings,
quitApp: params.quitApp,
toggleVisibleOverlay: params.toggleVisibleOverlay,
@@ -271,6 +283,7 @@ export function createMainIpcRuntimeServiceDeps(
getMecabTokenizer: params.getMecabTokenizer,
handleMpvCommand: params.handleMpvCommand,
getKeybindings: params.getKeybindings,
getMpvInputBindings: params.getMpvInputBindings,
getSessionBindings: params.getSessionBindings,
getConfiguredShortcuts: params.getConfiguredShortcuts,
dispatchSessionAction: params.dispatchSessionAction,
@@ -334,6 +347,7 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
: {}),
showDesktopNotification: params.showDesktopNotification,
showOverlayNotification: params.showOverlayNotification,
dismissOverlayNotification: params.dismissOverlayNotification,
createFieldGroupingCallback: params.createFieldGroupingCallback,
broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged,
getFieldGroupingResolver: params.getFieldGroupingResolver,
+46 -10
View File
@@ -183,7 +183,10 @@ test('remote media keeps parsed cues when the active subtitle source cannot be r
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /isYoutubeMediaPath\(videoPath\) \|\| isRemoteMediaPath\(videoPath\)/);
assert.match(
actionBlock,
/isYoutubeMediaPath\(videoPath\) \|\| \(await detectRemoteMediaPath\(videoPath\)\)/,
);
});
test('jellyfin subtitle preload seeds the tokenization prefetch directly', () => {
@@ -430,11 +433,11 @@ test('warm tokenization release can signal readiness before the first subtitle a
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
const source = readSource('src/main/runtime/stats-server-runtime.ts');
const startStatsServerBlock = source.match(
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
const statsServerConfigBlock = source.match(
/const buildStatsServerConfig[\s\S]*?return \{(?<body>[\s\S]*?)\n \};\n \};/,
)?.groups?.body;
const addYomitanNoteBlock = startStatsServerBlock?.match(
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
const addYomitanNoteBlock = statsServerConfigBlock?.match(
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(addYomitanNoteBlock);
@@ -450,7 +453,7 @@ test('Linux visible overlay recreation clears stale input state before creating
const source = readMainSource();
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
const actionBlock = source.match(
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
/const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime\(\{[\s\S]*?createWindow: \(\) => \{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
const resetBlock = runtimeSource.match(
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
@@ -469,7 +472,7 @@ test('Linux visible overlay recreation clears stale input state before creating
test('Linux visible overlay recreation avoids display fallback before tracked geometry exists', () => {
const source = readMainSource();
const actionBlock = source.match(
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
/const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime\(\{[\s\S]*?refreshWindow: \(\) => \{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(actionBlock);
@@ -477,15 +480,18 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge
actionBlock,
/const trackedGeometry = overlayGeometryRuntime\.getCurrentTrackedOverlayGeometry\(\);/,
);
assert.match(actionBlock, /if \(trackedGeometry\) \{/);
assert.match(
actionBlock,
/if \(trackedGeometry\) overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/,
);
assert.match(actionBlock, /overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/);
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
});
test('known-word updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
test('subtitle annotation updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
const source = readMainSource();
const actionBlock = source.match(
/const refreshCurrentSubtitleAfterKnownWordUpdate = \(\): void => \{(?<body>[\s\S]*?)\n\};/,
/function refreshCurrentSubtitleAnnotations\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(actionBlock);
@@ -503,6 +509,20 @@ test('known-word updates invalidate prefetched tokenizations before refreshing c
);
});
test('character portrait index readiness refreshes cached subtitle annotations', () => {
const source = readMainSource();
const lookupDeps = source.match(
/const characterDictionaryImageLookup = createCharacterDictionaryImageLookup\(\{(?<body>[\s\S]*?)\n\}\);/,
)?.groups?.body;
assert.ok(lookupDeps);
assert.match(lookupDeps, /onIndexReady: \(\) => refreshCurrentSubtitleAnnotations\(\),/);
assert.match(
lookupDeps,
/onIndexReadyError: \(error\) =>[\s\S]*?logger\.warn\([\s\S]*?character portrait index became ready\.[\s\S]*?error,/,
);
});
test('subtitle processing controller resumes prefetch on settle, not on its emits', () => {
const source = readMainSource();
const depsBlock = source.match(
@@ -846,3 +866,19 @@ test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active
snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'),
);
});
test('main process extracts internal subtitle tracks without a network-mount guard', () => {
const source = readMainSource();
const resolverWiring = source.match(
/const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler\(\{(?<body>[\s\S]*?)\n\}\);/,
)?.groups?.body;
assert.ok(resolverWiring);
// Network-mounted files are extracted like local ones; only remote URLs skip
// extraction, handled inside the resolver itself.
assert.doesNotMatch(resolverWiring, /isRemoteMediaPath/);
assert.match(
resolverWiring,
/extractInternalSubtitleTrack:[\s\S]*cachedInternalSubtitleTrackExtractor\.extract/,
);
});
+7 -5
View File
@@ -1,4 +1,5 @@
import { updateCurrentMediaPath } from '../core/services';
import { sanitizeMediaTitle } from '../shared/media-identity';
import type { SubtitlePosition } from '../types';
@@ -52,17 +53,18 @@ export function createMediaRuntimeService(deps: MediaRuntimeDeps): MediaRuntimeS
updateCurrentMediaTitle(mediaTitle: unknown): void {
if (typeof mediaTitle === 'string') {
const sanitized = mediaTitle.trim();
deps.setCurrentMediaTitle(sanitized.length > 0 ? sanitized : null);
const sanitized = sanitizeMediaTitle(mediaTitle);
if (mediaTitle.trim() && !sanitized) return;
deps.setCurrentMediaTitle(sanitized);
return;
}
deps.setCurrentMediaTitle(null);
},
resolveMediaPathForJimaku(mediaPath: string | null): string | null {
return mediaPath && deps.isRemoteMediaPath(mediaPath) && deps.getCurrentMediaTitle()
? deps.getCurrentMediaTitle()
: mediaPath;
return mediaPath && deps.isRemoteMediaPath(mediaPath)
? sanitizeMediaTitle(deps.getCurrentMediaTitle())
: sanitizeMediaTitle(mediaPath);
},
};
}
+60
View File
@@ -828,6 +828,7 @@ test('modal fallback reveal skips showing window when content is not ready', asy
setModalWindowBounds: () => {},
},
{
platform: 'darwin',
scheduleRevealFallback: (callback) => {
scheduledReveal = callback;
return { scheduled: true } as never;
@@ -1363,3 +1364,62 @@ test('modal placement reconcile cancels stale retry ladder after a newer visible
globalThis.clearTimeout = originalClearTimeout;
}
});
test('Linux keeps the dedicated modal window unmapped until the renderer opens the modal, then hides the overlay before revealing it', () => {
const mainWindow = createMockWindow();
mainWindow.visible = true;
const modalWindow = createMockWindow();
const order: string[] = [];
const hideMain = mainWindow.hide;
mainWindow.hide = () => {
order.push('main:hide');
hideMain();
};
const showModal = modalWindow.show;
modalWindow.show = () => {
order.push('modal:show');
showModal();
};
let revealScheduled = false;
const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => mainWindow as never,
getModalWindow: () => modalWindow as never,
createModalWindow: () => modalWindow as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
},
{
platform: 'linux',
scheduleRevealFallback: () => {
revealScheduled = true;
return { scheduled: true } as never;
},
clearRevealFallback: () => {},
},
);
const open = () =>
runtime.sendToActiveOverlayWindow(
'media-timing-review:open',
{ reviewId: 'review' },
{ restoreOnModalClose: 'media-timing-review', preferModalWindow: true },
);
assert.equal(open(), true);
assert.deepEqual(modalWindow.sent, [['media-timing-review:open', { reviewId: 'review' }]]);
assert.equal(revealScheduled, false);
assert.equal(modalWindow.getShowCount(), 0);
assert.equal(mainWindow.getHideCount(), 0);
// The open retry must not map the window before the renderer answers either.
assert.equal(open(), true);
assert.equal(modalWindow.getShowCount(), 0);
runtime.notifyOverlayModalOpened('media-timing-review');
assert.deepEqual(order, ['main:hide', 'modal:show']);
assert.equal(mainWindow.isVisible(), false);
assert.equal(modalWindow.isVisible(), true);
assert.equal(modalWindow.ignoreMouseEvents, false);
});
+21 -5
View File
@@ -90,6 +90,12 @@ export function createOverlayModalRuntimeService(
const platform = options.platform ?? process.platform;
const shouldPrimeModalWindow = platform === 'darwin' || platform === 'win32';
const reuseModalWindowAfterClose = platform === 'darwin';
// On Linux (Hyprland) every placement dispatch on a mapped window (resize, move, set_prop)
// blanks the still-visible overlay for a few frames while mpv is fullscreen. Revealing the
// dedicated modal window before its renderer has the modal open runs the placement ladder,
// and the open retry, against a visible overlay, which the user sees as flicker. Keep the
// window unmapped until the renderer acknowledges the open, then hide the overlay first.
const deferModalRevealUntilOpened = platform === 'linux';
const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus;
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
@@ -457,7 +463,9 @@ export function createOverlayModalRuntimeService(
deps.setModalWindowBounds(deps.getModalGeometry());
const wasVisible = modalWindow.isVisible();
if (!wasVisible) {
if (modalWindowPrimedForImmediateShow && isWindowReadyForIpc(modalWindow)) {
if (deferModalRevealUntilOpened) {
// notifyOverlayModalOpened reveals the window once the renderer has the modal open.
} else if (modalWindowPrimedForImmediateShow && isWindowReadyForIpc(modalWindow)) {
showModalWindow(modalWindow);
} else {
scheduleModalWindowReveal(modalWindow);
@@ -560,15 +568,23 @@ export function createOverlayModalRuntimeService(
}
const modalWindow = deps.getModalWindow();
const targetIsModalWindow =
modalWindow !== null && !modalWindow.isDestroyed() && targetWindow === modalWindow;
const handOffMainWindowToModal = (): void => {
setMainWindowMousePassthroughForModal(true);
setMainWindowVisibilityForModal(true);
};
if (targetIsModalWindow && deferModalRevealUntilOpened) {
handOffMainWindowToModal();
}
if (targetWindow.isVisible()) {
ensureModalWindowInteractive(targetWindow);
} else {
showModalWindow(targetWindow);
}
if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) {
setMainWindowMousePassthroughForModal(true);
setMainWindowVisibilityForModal(true);
if (targetIsModalWindow && !deferModalRevealUntilOpened) {
handOffMainWindowToModal();
}
};
@@ -9,6 +9,10 @@ import {
test('buildAnilistAttemptKey formats media and episode', () => {
assert.equal(buildAnilistAttemptKey('/tmp/video.mkv', 3), '/tmp/video.mkv::3');
assert.equal(
buildAnilistAttemptKey('https://example.com/Videos/item/stream?api_key=test-secret', 3),
'jellyfin://example.com/item/item::3',
);
});
test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
@@ -17,6 +21,49 @@ test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
assert.deepEqual(Array.from(set), ['b', 'c']);
});
test('post-watch rejects empty media identities before attempted keys or update side effects', async () => {
for (const mediaKey of [
' ',
'stream?api_key=secret',
'stream%3Fapi_key%3Dsecret',
'https://[invalid',
]) {
assert.equal(buildAnilistAttemptKey(mediaKey, 3), null);
const calls: string[] = [];
const unexpected = () => assert.fail('invalid identity reached update side effects');
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => mediaKey,
hasMpvClient: () => true,
getTrackedMediaKey: () => mediaKey,
resetTrackedMedia: unexpected,
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }),
hasAttemptedUpdateKey: unexpected,
processNextAnilistRetryUpdate: unexpected,
refreshAnilistClientSecretState: unexpected,
enqueueRetry: unexpected,
markRetryFailure: unexpected,
markRetrySuccess: unexpected,
refreshRetryQueueState: unexpected,
updateAnilistPostWatchProgress: unexpected,
rememberAttemptedUpdateKey: unexpected,
showMpvOsd: unexpected,
logInfo: unexpected,
logWarn: unexpected,
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
await handler({ force: true });
assert.deepEqual(calls, ['inflight:true', 'inflight:false', 'inflight:true', 'inflight:false']);
}
});
test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => {
const calls: string[] = [];
const handler = createProcessNextAnilistRetryUpdateHandler({
@@ -335,6 +382,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already ha
const attemptedKeys = new Set<string>();
const mediaKey = '/tmp/video.mkv';
const attemptKey = buildAnilistAttemptKey(mediaKey, 1);
assert.ok(attemptKey);
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
+5 -2
View File
@@ -1,4 +1,5 @@
import { isYoutubeMediaPath } from './youtube-playback';
import { toMediaIdentityPath } from '../../shared/media-identity';
type AnilistGuess = {
title: string;
@@ -31,8 +32,9 @@ type AnilistDurationProbeOptions = {
force?: boolean;
};
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
return `${mediaKey}::${episode}`;
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string | null {
const identity = toMediaIdentityPath(mediaKey);
return identity ? `${identity}::${episode}` : null;
}
export function rememberAnilistAttemptedUpdateKey(
@@ -214,6 +216,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
}
const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode);
if (!attemptKey) return;
if (deps.hasAttemptedUpdateKey(attemptKey)) {
return;
}
+74 -7
View File
@@ -1,12 +1,32 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createForceQuitHandler,
createOnWillQuitCleanupHandler,
createRestoreWindowsOnActivateHandler,
createShouldRestoreWindowsOnActivateHandler,
} from './app-lifecycle-actions';
test('on will quit cleanup handler runs all cleanup steps', () => {
test('forced quit finalizes stats before exiting, even when finalization throws', async () => {
for (const fails of [false, true]) {
const calls: string[] = [];
await createForceQuitHandler({
destroyImmersionTracker: () => {
calls.push('finalize');
if (fails) throw new Error('flush failed');
},
logError: () => {
calls.push('error');
},
exit: () => {
calls.push('exit');
},
})();
assert.deepEqual(calls, fails ? ['finalize', 'error', 'exit'] : ['finalize', 'exit']);
}
});
test('on will quit cleanup handler runs all cleanup steps', async () => {
const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler({
destroyTray: () => calls.push('destroy-tray'),
@@ -32,7 +52,15 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
destroyMpvSocket: () => calls.push('destroy-socket'),
clearReconnectTimer: () => calls.push('clear-reconnect'),
destroySubtitleTimingTracker: () => calls.push('destroy-subtitle-tracker'),
destroyImmersionTracker: () => calls.push('destroy-immersion'),
stopStatsServer: async () => {
calls.push('stop-stats-server-start');
await Promise.resolve();
calls.push('stop-stats-server-complete');
},
destroyImmersionTracker: async () => {
await Promise.resolve();
calls.push('destroy-immersion');
},
destroyAnkiIntegration: () => calls.push('destroy-anki'),
destroyAnilistSetupWindow: () => calls.push('destroy-anilist-window'),
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
@@ -43,25 +71,57 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
cleanup();
assert.equal(calls.length, 34);
await cleanup();
assert.equal(calls.length, 38);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
assert.ok(calls.includes('cleanup-internal-subtitles'));
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
assert.ok(calls.includes('cleanup-youtube-subtitles'));
assert.ok(calls.includes('cleanup-youtube-media'));
assert.ok(calls.includes('cleanup-remote-media-windows'));
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
assert.ok(calls.indexOf('stop-stats-server-complete') < calls.indexOf('destroy-immersion'));
assert.ok(calls.indexOf('destroy-immersion') < calls.indexOf('destroy-anki'));
});
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', () => {
test('forced quit waits for asynchronous stats finalization', async () => {
const calls: string[] = [];
await createForceQuitHandler({
destroyImmersionTracker: async () => {
await Promise.resolve();
calls.push('finalized');
},
logError: () => calls.push('error'),
exit: () => calls.push('exit'),
})();
assert.deepEqual(calls, ['finalized', 'exit']);
});
test('forced quit exits when asynchronous stats finalization never settles', async () => {
const calls: string[] = [];
await createForceQuitHandler({
destroyImmersionTracker: () => new Promise<void>(() => {}),
logError: (error) => {
assert.match(String(error), /Stats finalization timed out/);
calls.push('timeout');
},
exit: () => calls.push('exit'),
})();
assert.deepEqual(calls, ['timeout', 'exit']);
});
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', async () => {
const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler({
destroyTray: () => {},
@@ -83,6 +143,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
destroyMpvSocket: () => {},
clearReconnectTimer: () => {},
destroySubtitleTimingTracker: () => {},
stopStatsServer: () => {},
destroyImmersionTracker: () => {},
destroyAnkiIntegration: () => {},
destroyAnilistSetupWindow: () => {},
@@ -97,14 +158,20 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
calls.push('stop-jellyfin-remote');
throw new Error('stop failed');
},
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
assert.throws(() => cleanup(), /stop failed/);
assert.deepEqual(calls, ['stop-jellyfin-remote', 'cleanup-jellyfin-subtitles']);
await assert.rejects(cleanup(), /stop failed/);
assert.deepEqual(calls, [
'stop-jellyfin-remote',
'cleanup-jellyfin-subtitles',
'cleanup-internal-subtitles',
]);
});
test('should restore windows on activate requires initialized runtime and no windows', () => {
+54 -6
View File
@@ -1,3 +1,26 @@
export function createForceQuitHandler(deps: {
destroyImmersionTracker: () => void | Promise<void>;
logError: (error: unknown) => void;
exit: () => void;
}) {
return async () => {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
Promise.resolve().then(() => deps.destroyImmersionTracker()),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error('Stats finalization timed out.')), 1_000);
}),
]);
} catch (error) {
deps.logError(error);
} finally {
clearTimeout(timeout);
deps.exit();
}
};
}
export function createOnWillQuitCleanupHandler(deps: {
destroyTray: () => void;
stopConfigHotReload: () => void;
@@ -18,7 +41,8 @@ export function createOnWillQuitCleanupHandler(deps: {
destroyMpvSocket: () => void;
clearReconnectTimer: () => void;
destroySubtitleTimingTracker: () => void;
destroyImmersionTracker: () => void;
stopStatsServer: () => Promise<void> | void;
destroyImmersionTracker: () => void | Promise<void>;
destroyAnkiIntegration: () => void;
destroyAnilistSetupWindow: () => void;
clearAnilistSetupWindow: () => void;
@@ -29,12 +53,14 @@ export function createOnWillQuitCleanupHandler(deps: {
destroyYomitanSettingsWindow: () => void;
clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void;
cleanupInternalSubtitleTrackCache: () => void;
cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void;
cleanupRemoteMediaWindows: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
return (): Promise<void> => {
return async (): Promise<void> => {
deps.destroyTray();
deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility();
@@ -42,7 +68,12 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
const stopSyncAutoScheduler = deps.stopSyncAutoScheduler();
const cleanupErrors: unknown[] = [];
const stopSyncAutoScheduler = Promise.resolve(deps.stopSyncAutoScheduler()).catch(
(error: unknown) => {
cleanupErrors.push(error);
},
);
deps.clearWindowsVisibleOverlayForegroundPollLoop();
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
deps.destroyMainOverlayWindow();
@@ -54,7 +85,16 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.destroyMpvSocket();
deps.clearReconnectTimer();
deps.destroySubtitleTimingTracker();
deps.destroyImmersionTracker();
try {
await deps.stopStatsServer();
} catch (error) {
cleanupErrors.push(error);
}
try {
await deps.destroyImmersionTracker();
} catch (error) {
cleanupErrors.push(error);
}
deps.destroyAnkiIntegration();
deps.destroyAnilistSetupWindow();
deps.clearAnilistSetupWindow();
@@ -67,12 +107,20 @@ export function createOnWillQuitCleanupHandler(deps: {
try {
deps.stopJellyfinRemoteSession();
} finally {
deps.cleanupJellyfinSubtitleCache();
try {
deps.cleanupJellyfinSubtitleCache();
} finally {
deps.cleanupInternalSubtitleTrackCache();
}
}
deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupYoutubeMediaCache();
deps.cleanupRemoteMediaWindows();
deps.stopDiscordPresenceService();
return Promise.resolve(stopSyncAutoScheduler);
await stopSyncAutoScheduler;
if (cleanupErrors.length > 0) {
throw cleanupErrors[0];
}
};
}
@@ -3,11 +3,14 @@ import test from 'node:test';
import { createBuildOnWillQuitCleanupDepsHandler } from './app-lifecycle-main-cleanup';
import { createOnWillQuitCleanupHandler } from './app-lifecycle-actions';
test('cleanup deps builder returns handlers that guard optional runtime objects', () => {
test('cleanup deps builder returns handlers that guard optional runtime objects', async () => {
const calls: string[] = [];
let reconnectTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {}, 60_000);
let immersionTracker: { destroy: () => void } | null = {
destroy: () => calls.push('destroy-immersion'),
let immersionTracker: { destroy: () => Promise<void> } | null = {
destroy: async () => {
await Promise.resolve();
calls.push('destroy-immersion');
},
};
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
@@ -54,6 +57,9 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
getSubtitleTimingTracker: () => ({ destroy: () => calls.push('destroy-subtitle-tracker') }),
getImmersionTracker: () => immersionTracker,
stopStatsServer: () => {
calls.push('stop-stats-server');
},
clearImmersionTracker: () => {
immersionTracker = null;
calls.push('clear-immersion-ref');
@@ -72,14 +78,16 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
cleanup();
await cleanup();
assert.ok(calls.includes('destroy-tray'));
assert.ok(calls.includes('destroy-main-overlay-window'));
@@ -92,9 +100,11 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('clear-reconnect-ref'));
assert.ok(calls.includes('destroy-immersion'));
assert.ok(calls.includes('clear-immersion-ref'));
assert.ok(calls.indexOf('destroy-immersion') < calls.indexOf('clear-immersion-ref'));
assert.ok(calls.includes('destroy-first-run-window'));
assert.ok(calls.includes('destroy-yomitan-settings-window'));
assert.ok(calls.includes('stop-jellyfin-remote'));
assert.ok(calls.includes('cleanup-internal-subtitles'));
assert.ok(calls.includes('cleanup-youtube-subtitles'));
assert.ok(calls.includes('cleanup-youtube-media'));
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
@@ -141,6 +151,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
clearReconnectTimerRef: () => {},
getSubtitleTimingTracker: () => null,
getImmersionTracker: () => null,
stopStatsServer: () => {},
clearImmersionTracker: () => {},
getAnkiIntegration: () => null,
getAnilistSetupWindow: () => null,
@@ -152,8 +163,10 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {},
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupRemoteMediaWindows: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
});
@@ -193,6 +206,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
clearReconnectTimerRef: () => {},
getSubtitleTimingTracker: () => null,
getImmersionTracker: () => null,
stopStatsServer: () => {},
clearImmersionTracker: () => {},
getAnkiIntegration: () => null,
getAnilistSetupWindow: () => null,
@@ -204,8 +218,10 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {},
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupRemoteMediaWindows: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
});
@@ -44,7 +44,8 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
clearReconnectTimerRef: () => void;
getSubtitleTimingTracker: () => Destroyable | null;
getImmersionTracker: () => Destroyable | null;
getImmersionTracker: () => { destroy: () => void | Promise<void> } | null;
stopStatsServer: () => Promise<void> | void;
clearImmersionTracker: () => void;
getAnkiIntegration: () => Destroyable | null;
@@ -58,8 +59,10 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void;
cleanupInternalSubtitleTrackCache: () => void;
cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void;
cleanupRemoteMediaWindows: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
@@ -118,10 +121,11 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
destroySubtitleTimingTracker: () => {
deps.getSubtitleTimingTracker()?.destroy();
},
destroyImmersionTracker: () => {
stopStatsServer: () => deps.stopStatsServer(),
destroyImmersionTracker: async () => {
const tracker = deps.getImmersionTracker();
if (!tracker) return;
tracker.destroy();
await tracker.destroy();
deps.clearImmersionTracker();
},
destroyAnkiIntegration: () => {
@@ -144,8 +148,10 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
},
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
cleanupInternalSubtitleTrackCache: () => deps.cleanupInternalSubtitleTrackCache(),
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(),
cleanupRemoteMediaWindows: () => deps.cleanupRemoteMediaWindows(),
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
});
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import { createSubtitleProcessingController } from '../../core/services/subtitle-processing-controller';
import type { SubtitleData } from '../../types';
import {
@@ -211,6 +212,69 @@ test('primeCurrentSubtitleForAutoplay emits raw first paint on cache miss before
]);
});
test('parsed cues replace a duplicate raw autoplay subtitle that was already primed', async () => {
const rawText = 'ジグザグな道を抜け\nジグザグな道を抜け';
const correctedText = 'ジグザグな道を抜け';
const mediaPath = '/media/video.mkv';
let currentSubText = '';
const emitted: string[] = [];
const client = {
connected: true,
currentVideoPath: mediaPath,
currentTimePos: 90,
currentSubText: rawText,
requestProperty: async (name: string) => {
if (name === 'sub-text') return rawText;
if (name === 'time-pos') return 90;
return null;
},
};
const cues = parseSubtitleCues(
[
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
`Dialogue: 1,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,${correctedText}`,
`Dialogue: 0,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,${correctedText}`,
].join('\n'),
'startup-ending.ass',
);
let activeCues = cues.slice(0, 0);
const runtime = createAutoplaySubtitlePrimingRuntime({
getCurrentMediaPath: () => mediaPath,
getMpvClient: () => client,
setCurrentSubText: (text) => {
currentSubText = text;
},
getCurrentSubText: () => currentSubText,
getCurrentSubtitleData: () => null,
getActiveParsedSubtitleCues: () => activeCues,
setActiveParsedSubtitleMediaPath: () => {},
subtitleProcessingController: {
consumeCachedSubtitle: () => null,
onSubtitleChange: () => true,
refreshCurrentSubtitle: () => true,
notePlainSubtitleEmitted: () => {},
},
emitSubtitlePayload: (payload) => emitted.push(payload.text),
getSubtitlePrefetchService: () => null,
getLastObservedTimePos: () => 90,
getVisibleOverlayVisible: () => true,
emitSecondarySubtitle: () => {},
initSubtitlePrefetch: async () => {},
refreshSubtitlePrefetchFromActiveTrack: async () => {},
logDebug: () => {},
});
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
assert.equal(currentSubText, rawText);
activeCues = cues;
await runtime.primeAutoplaySubtitleFromParsedCues(mediaPath, cues);
assert.equal(currentSubText, correctedText);
assert.deepEqual(emitted, [rawText, correctedText]);
});
// Driven by the real processing controller rather than a stub: the failure this
// covers is a disagreement between the priming path and the controller's own
// staleness rules, which a hand-written stub cannot reproduce.
@@ -12,6 +12,7 @@ type AutoplaySubtitlePrimingMpvClient = {
requestProperty: (name: string) => Promise<unknown>;
currentVideoPath?: string;
currentTimePos?: number;
currentSubText?: string;
currentSecondarySubText?: string;
setCurrentSecondarySubText?: (text: string) => void;
};
@@ -107,11 +108,19 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
autoplaySubtitlePrimedMediaPath = null;
}
function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
function emitAutoplayPrimedSubtitle(
mediaPath: string,
text: string,
options: { replaceExisting?: boolean } = {},
): boolean {
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
return false;
}
if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
if (autoplaySubtitlePrimedMediaPath === mediaPath) {
if (!options.replaceExisting || deps.getCurrentSubText() === text) {
return false;
}
} else if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
return false;
}
@@ -252,11 +261,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
mediaPath: string,
cues: SubtitleCue[],
): Promise<void> {
if (
cues.length === 0 ||
autoplaySubtitlePrimedMediaPath === mediaPath ||
!isCurrentAutoplayMediaPath(mediaPath)
) {
if (cues.length === 0 || !isCurrentAutoplayMediaPath(mediaPath)) {
return;
}
@@ -265,16 +270,21 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
const currentTimeSeconds = Number(
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
);
const resolvedTimeSeconds = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0;
const cue = selectAutoplayStartupCue(
cues,
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
resolvedTimeSeconds,
AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS,
);
if (!cue) {
const liveText = client?.currentSubText ?? '';
const text = liveText.trim()
? resolvePrimarySubtitleText({ liveText, currentTimeSec: resolvedTimeSeconds, cues })
: (cue?.text ?? '');
if (!text) {
return;
}
emitAutoplayPrimedSubtitle(mediaPath, cue.text);
emitAutoplayPrimedSubtitle(mediaPath, text, { replaceExisting: true });
}
function clearScheduledSubtitlePrefetchRefresh(): void {
@@ -24,10 +24,10 @@ function createDeps(
return { deps, calls };
}
test('ensures background stats server and logs local startup', () => {
test('ensures background stats server and logs local startup', async () => {
const { deps, calls } = createDeps();
createEnsureBackgroundStatsServerHandler(deps)();
await createEnsureBackgroundStatsServerHandler(deps)();
assert.ok(calls.includes('ensureBackgroundStatsServerStarted'));
assert.ok(
@@ -35,7 +35,7 @@ test('ensures background stats server and logs local startup', () => {
);
});
test('logs reuse when a background stats server is already running', () => {
test('logs reuse when a background stats server is already running', async () => {
const { deps, calls } = createDeps({
ensureBackgroundStatsServerStarted: () => ({
url: 'http://127.0.0.1:3888',
@@ -43,36 +43,53 @@ test('logs reuse when a background stats server is already running', () => {
}),
});
createEnsureBackgroundStatsServerHandler(deps)();
await createEnsureBackgroundStatsServerHandler(deps)();
assert.ok(
calls.some((value) => value.startsWith('info:') && /already running|reusing/i.test(value)),
);
});
test('skips when stats.autoStartServer is disabled', () => {
test('skips when stats.autoStartServer is disabled', async () => {
const { deps, calls } = createDeps({ isStatsAutoStartEnabled: () => false });
createEnsureBackgroundStatsServerHandler(deps)();
await createEnsureBackgroundStatsServerHandler(deps)();
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
});
test('skips when immersion tracking is disabled', () => {
test('skips when immersion tracking is disabled', async () => {
const { deps, calls } = createDeps({ isImmersionTrackingEnabled: () => false });
createEnsureBackgroundStatsServerHandler(deps)();
await createEnsureBackgroundStatsServerHandler(deps)();
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
});
test('logs a warning instead of throwing when startup fails', () => {
test('logs a warning instead of throwing when startup fails', async () => {
const { deps, calls } = createDeps({
ensureBackgroundStatsServerStarted: () => {
throw new Error('port in use');
},
});
assert.doesNotThrow(() => createEnsureBackgroundStatsServerHandler(deps)());
await assert.doesNotReject(createEnsureBackgroundStatsServerHandler(deps)());
assert.ok(calls.some((value) => value.startsWith('warn:')));
});
test('logs an asynchronously reported startup failure', async () => {
const { deps, calls } = createDeps({
ensureBackgroundStatsServerStarted: async () => {
await Promise.resolve();
throw new Error('address in use');
},
});
await createEnsureBackgroundStatsServerHandler(deps)();
assert.ok(calls.some((value) => value.startsWith('warn:')));
assert.equal(
calls.some((value) => value.startsWith('info:')),
false,
);
});
+12 -7
View File
@@ -1,18 +1,23 @@
export interface EnsureBackgroundStatsServerDeps {
isStatsAutoStartEnabled: () => boolean;
isImmersionTrackingEnabled: () => boolean;
ensureBackgroundStatsServerStarted: () => {
url: string;
runningInCurrentProcess: boolean;
};
ensureBackgroundStatsServerStarted: () =>
| Promise<{
url: string;
runningInCurrentProcess: boolean;
}>
| {
url: string;
runningInCurrentProcess: boolean;
};
logInfo: (message: string) => void;
logWarn: (message: string, error?: unknown) => void;
}
export function createEnsureBackgroundStatsServerHandler(
deps: EnsureBackgroundStatsServerDeps,
): () => void {
return () => {
): () => Promise<void> {
return async () => {
if (!deps.isStatsAutoStartEnabled()) {
deps.logInfo('Background start: stats.autoStartServer is disabled; skipping stats server.');
return;
@@ -22,7 +27,7 @@ export function createEnsureBackgroundStatsServerHandler(
return;
}
try {
const result = deps.ensureBackgroundStatsServerStarted();
const result = await deps.ensureBackgroundStatsServerStarted();
deps.logInfo(
result.runningInCurrentProcess
? `Background start: stats server started at ${result.url}.`
+54 -12
View File
@@ -32,6 +32,8 @@ export type CommonOptions = FsDeps & {
resourcesPath?: string;
appExePath?: string;
launcherResourcePath?: string;
bundledBunPath?: string;
appVersion?: string;
runCommand?: RunCommand;
};
@@ -143,8 +145,40 @@ function needsWindowsShell(command: string): boolean {
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
}
function quoteForWindowsShell(value: string): string {
return `"${value.replace(/([&|<>^%!])/g, '^$1').replace(/"/g, '""')}"`;
/*!
* Windows command escaping adapted from cross-spawn 7.0.6.
*
* The MIT License (MIT)
*
* Copyright (c) 2018 Made With MOXY Lda <hello@moxy.studio>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
const WINDOWS_SHELL_META_CHARACTERS = /([()\][%!^"`<>&|;, *?])/g;
// Quote for both cmd.exe and the Windows argv parser. The outer caret escapes
// are consumed by cmd, leaving the quoted argument unchanged for the command.
function escapeWindowsShellArgument(value: string): string {
const quotesEscaped = value
.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"')
.replace(/(?=(\\+?)?)\1$/, '$1$1');
return `"${quotesEscaped}"`.replace(WINDOWS_SHELL_META_CHARACTERS, '^$1');
}
function createDefaultRunCommand(): RunCommand {
@@ -153,16 +187,24 @@ function createDefaultRunCommand(): RunCommand {
const useShell = needsWindowsShell(command);
let child: ReturnType<typeof spawn>;
try {
child = useShell
? spawn(quoteForWindowsShell(command), args.map(quoteForWindowsShell), {
env: options.env ?? process.env,
windowsHide: false,
shell: true,
})
: spawn(command, args, {
env: options.env ?? process.env,
windowsHide: false,
});
const env = options.env ?? process.env;
if (useShell) {
const shellCommand = [
escapeWindowsShellArgument(command),
...args.map(escapeWindowsShellArgument),
].join(' ');
const commandProcessor = env.ComSpec ?? env.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe';
child = spawn(commandProcessor, ['/d', '/s', '/v:off', '/c', `"${shellCommand}"`], {
env,
windowsHide: false,
windowsVerbatimArguments: true,
});
} else {
child = spawn(command, args, {
env,
windowsHide: false,
});
}
} catch (error) {
resolve({
exitCode: 1,
+35 -23
View File
@@ -91,43 +91,54 @@ test('resolveBunInstallCommand prefers winget on Windows', () => {
test('default runCommand preserves Windows cmd metacharacter args', async (t) => {
if (process.platform !== 'win32') return;
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-cmd-args-'));
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer cmd & 100% ! '));
const scriptPath = path.join(tempDir, 'argv.cmd');
const outputPath = path.join(tempDir, 'argv.txt');
const argvScriptPath = path.join(tempDir, 'argv.js');
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
fs.writeFileSync(
argvScriptPath,
'process.stdout.write(JSON.stringify(process.argv.slice(2)));',
'utf8',
);
fs.writeFileSync(
scriptPath,
[
'@echo off',
'setlocal DisableDelayedExpansion',
'> "%SUBMINER_ARGV_OUT%" (',
' echo 1=%~1',
' echo 2=%~2',
' echo 3=%~3',
' echo 4=%~4',
' echo 5=%~5',
' echo 6=%~6',
')',
'"%SUBMINER_TEST_RUNTIME%" "%SUBMINER_ARGV_SCRIPT%" %*',
'exit /b %errorlevel%',
'',
].join('\r\n'),
'utf8',
);
const result = await getRunCommand({})(
scriptPath,
['plain', 'has space', 'a&b', 'x|y', 'p%PATH%q', 'bang!z'],
{
env: { ...process.env, SUBMINER_ARGV_OUT: outputPath },
const args = [
'plain',
'has space',
'a&b',
'x|y',
'p%TEMP%q',
'bang!z',
'caret^z',
'<left>',
'say "hi"',
'slash\\"quote',
'trailing\\',
'',
'日本語',
];
const result = await getRunCommand({})(scriptPath, args, {
env: {
...process.env,
SUBMINER_ARGV_SCRIPT: argvScriptPath,
SUBMINER_TEST_RUNTIME: process.execPath,
},
);
});
assert.equal(result.exitCode, 0, result.stderr);
assert.equal(
fs.readFileSync(outputPath, 'utf8'),
['1=plain', '2=has space', '3=a&b', '4=x|y', '5=p%PATH%q', '6=bang!z', ''].join('\r\n'),
);
assert.deepEqual(JSON.parse(result.stdout), args);
});
test('resolveBunInstallCommand falls back to scoop on Windows before official installer', () => {
@@ -189,7 +200,7 @@ test('resolveLauncherInstallTarget prefers writable user bin on Linux', async ()
assert.equal(target.installPath, '/home/tester/.local/bin/subminer');
});
test('resolveLauncherInstallTarget returns not_installable without writable PATH dirs', async () => {
test('resolveLauncherInstallTarget offers a user bin without writable PATH dirs', async () => {
const target = await resolveLauncherInstallTarget({
platform: 'linux',
homeDir: '/home/tester',
@@ -200,8 +211,9 @@ test('resolveLauncherInstallTarget returns not_installable without writable PATH
},
});
assert.equal(target.status, 'not_installable');
assert.equal(target.installPath, null);
assert.equal(target.status, 'not_installed');
assert.equal(target.installPath, '/home/tester/.local/bin/subminer');
assert.match(target.message ?? '', /export PATH=/);
});
test('resolveLauncherInstallTarget skips Homebrew bin for empty macOS manual installs', async () => {
+205 -36
View File
@@ -1,6 +1,13 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
cleanupOldWindowsManagedRuntimes,
isManagedLauncher,
managedLauncherContent,
shellQuote,
stageManagedLauncher,
} from './managed-launcher';
import {
accessSyncOf,
envOf,
@@ -115,8 +122,13 @@ export function resolveBunInstallCommand(
}
export async function detectBun(options: CommonOptions = {}): Promise<BunSnapshot> {
const bunPath = findCommand('bun', options);
const installCommand = resolveBunInstallCommand(options);
const bundled = options.bundledBunPath;
const bunPath = bundled
? existsSyncOf(options)(bundled)
? bundled
: null
: findCommand('bun', options);
const installCommand = bundled ? null : resolveBunInstallCommand(options);
if (!bunPath) {
return {
status: 'missing',
@@ -124,7 +136,9 @@ export async function detectBun(options: CommonOptions = {}): Promise<BunSnapsho
version: null,
installMethod: installMethodForCommand(installCommand),
installCommand,
message: null,
message: bundled
? 'The included launcher runtime is missing. Reinstall SubMiner to repair it.'
: null,
};
}
@@ -139,7 +153,7 @@ export async function detectBun(options: CommonOptions = {}): Promise<BunSnapsho
version: result.stdout.trim() || null,
installMethod: null,
installCommand: null,
message: null,
message: bundled ? 'Included with SubMiner. No separate Bun installation is needed.' : null,
};
}
@@ -158,9 +172,11 @@ export function resolveLauncherResourcePath(options: CommonOptions): string {
if (options.launcherResourcePath) return options.launcherResourcePath;
const resourcesPath =
options.resourcesPath ?? (process as typeof process & { resourcesPath?: string }).resourcesPath;
const packaged = resourcesPath ? platformPath.join(resourcesPath, 'launcher', 'subminer') : null;
const packaged = resourcesPath
? platformPath.join(resourcesPath, 'launcher', 'subminer.js')
: null;
if (packaged && existsSyncOf(options)(packaged)) return packaged;
return platformPath.join(options.cwd ?? process.cwd(), 'dist', 'launcher', 'subminer');
return platformPath.join(options.cwd ?? process.cwd(), 'dist', 'launcher', 'subminer.js');
}
function isWritableDir(candidate: string, options: CommonOptions): boolean {
@@ -183,6 +199,21 @@ function collectPathDirs(options: CommonOptions): string[] {
return dirs;
}
function preferredLauncherDirs(platform: NodeJS.Platform, homeDir: string): string[] {
return platform === 'darwin'
? [
'/opt/homebrew/bin',
'/usr/local/bin',
path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'),
]
: [
path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'),
'/usr/local/bin',
];
}
export async function resolveLauncherInstallTarget(
options: CommonOptions & WindowsPathOptions = {},
): Promise<LauncherSnapshot> {
@@ -201,19 +232,7 @@ export async function resolveLauncherInstallTarget(
const homeDir = options.homeDir ?? os.homedir();
const pathDirs = collectPathDirs(options);
const preferred =
platform === 'darwin'
? [
'/opt/homebrew/bin',
'/usr/local/bin',
path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'),
]
: [
path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'),
'/usr/local/bin',
];
const preferred = preferredLauncherDirs(platform, homeDir);
const manualPreferred =
platform === 'darwin'
? [
@@ -251,13 +270,15 @@ export async function resolveLauncherInstallTarget(
isWritableDir(dir, options),
);
if (!selected) {
const pathDir = path.posix.join(homeDir, '.local', 'bin');
const installPath = path.posix.join(pathDir, 'subminer');
return {
status: 'not_installable',
commandPath: null,
installPath: null,
pathDir: null,
status: existsSyncOf(options)(installPath) ? 'not_on_path' : 'not_installed',
commandPath: existsSyncOf(options)(installPath) ? installPath : null,
installPath,
pathDir,
shadowedBy: null,
message: 'No writable directory was found on your command-line PATH.',
message: `Add ${pathDir} to your terminal PATH: export PATH=${shellQuote(pathDir)}:"$PATH". Save this in your shell configuration for future terminals.`,
};
}
const installPath = path.posix.join(selected, 'subminer');
@@ -283,7 +304,29 @@ export async function detectLauncher(
const launcherResourcePath = resolveLauncherResourcePath(options);
const appExePath = options.appExePath ?? process.execPath;
if (platform === 'win32' && existsSyncOf(options)(expectedPath)) {
if (options.bundledBunPath && existsSyncOf(options)(expectedPath)) {
const content = String((options.readFileSync ?? fs.readFileSync)(expectedPath, 'utf8'));
if (!isManagedLauncher(content)) {
return {
...target,
status: 'not_installed',
message: 'Reinstall the launcher to use the runtime included with SubMiner.',
};
}
if (
content !==
managedLauncherContent({
platform,
appPath: envOf(options).APPIMAGE ?? appExePath,
})
) {
return {
...target,
status: 'not_installed',
message: 'Reinstall the launcher to refresh its SubMiner location.',
};
}
} else if (platform === 'win32' && existsSyncOf(options)(expectedPath)) {
const content = String((options.readFileSync ?? fs.readFileSync)(expectedPath, 'utf8'));
if (!shimMatchesCurrentInstall(content, appExePath, launcherResourcePath)) {
return {
@@ -305,26 +348,19 @@ export async function detectLauncher(
}
if (!existsSyncOf(options)(expectedPath))
return { ...target, status: 'not_installed', commandPath: null };
if (!commandPath) {
return {
...target,
status: 'not_on_path',
commandPath: expectedPath,
message: 'Launcher exists but its directory is not on PATH.',
};
}
const bunSnapshot = options.bunSnapshot ?? (await detectBun(options));
if (bunSnapshot.status !== 'ready') {
return {
...target,
status: 'installed_bun_missing',
commandPath,
message: 'Launcher is installed, but Bun is missing. Install Bun, then open a new terminal.',
message: options.bundledBunPath
? bunSnapshot.message
: 'Launcher is installed, but Bun is missing. Install Bun, then open a new terminal.',
};
}
const result = await getRunCommand(options)(commandPath, ['--help'], {
const result = await getRunCommand(options)(expectedPath, ['--help'], {
timeoutMs: COMMAND_TIMEOUT_MS,
env: envOf(options) as NodeJS.ProcessEnv,
});
@@ -336,6 +372,16 @@ export async function detectLauncher(
message: failureMessage(result, 'subminer --help failed'),
};
}
if (!commandPath) {
return {
...target,
status: 'not_on_path',
commandPath: expectedPath,
message:
target.message ??
`Launcher installed. Add ${target.pathDir} to your terminal PATH: export PATH=${shellQuote(target.pathDir ?? '')}:"$PATH". Save this in your shell configuration for future terminals.`,
};
}
return { ...target, status: 'ready', commandPath, message: null };
}
@@ -354,6 +400,48 @@ export async function installLauncher(
};
}
if (options.bundledBunPath) {
const bun = await detectBun(options);
if (bun.status !== 'ready')
return {
...target,
status: 'failed',
message: bun.message ?? 'The included launcher runtime failed to start.',
};
try {
stageManagedLauncher({
...options,
bundledBunPath: options.bundledBunPath,
launcherResourcePath,
force: true,
});
(options.mkdirSync ?? fs.mkdirSync)(target.pathDir, { recursive: true });
(options.writeFileSync ?? fs.writeFileSync)(
target.installPath,
managedLauncherContent({
platform,
appPath: envOf(options).APPIMAGE ?? options.appExePath ?? process.execPath,
}),
);
(options.chmodSync ?? fs.chmodSync)(target.installPath, 0o755);
if (platform === 'win32') {
cleanupOldWindowsManagedRuntimes(options);
const nextPath = await appendWindowsUserPathDir(target.pathDir, options);
if (nextPath && options.env) {
options.env.PATH = nextPath;
options.env.Path = nextPath;
}
}
return await detectLauncher({ ...options, bunSnapshot: bun });
} catch (error) {
return {
...target,
status: 'failed',
message: error instanceof Error ? error.message : String(error),
};
}
}
if (platform === 'win32') {
(options.mkdirSync ?? fs.mkdirSync)(target.pathDir, { recursive: true });
(options.writeFileSync ?? fs.writeFileSync)(
@@ -375,6 +463,8 @@ export async function installLauncher(
};
}
} else {
if (!existsSyncOf(options)(target.pathDir))
(options.mkdirSync ?? fs.mkdirSync)(target.pathDir, { recursive: true });
(options.copyFileSync ?? fs.copyFileSync)(launcherResourcePath, target.installPath);
(options.chmodSync ?? fs.chmodSync)(target.installPath, 0o755);
}
@@ -384,6 +474,7 @@ export async function installLauncher(
export async function installBun(
options: CommonOptions & WindowsPathOptions = {},
): Promise<BunSnapshot> {
if (options.bundledBunPath) return detectBun(options);
const platform = platformOf(options);
if (platform === 'win32') {
const bunDir = defaultBunRepairPath(options);
@@ -455,6 +546,84 @@ export async function installBun(
};
}
// Runs at app startup. Migrates recognized launchers in the standard bin dirs,
// the setup install target, and any paths a deferred update handed over.
// Returns paths that were refreshed or are no longer eligible for migration.
export async function refreshManagedCommandLineLauncher(
options: CommonOptions & WindowsPathOptions & { additionalLauncherPaths?: string[] },
): Promise<string[]> {
if (!options.bundledBunPath) return [];
const target = await resolveLauncherInstallTarget(options);
const platform = platformOf(options);
const platformPath = pathModuleFor(platform);
// cmd.exe reads a batch file incrementally while it runs, so the launcher that
// started this app is left alone until a later app start rewrites it.
const runningLauncherPath =
platform === 'win32' ? envOf(options).SUBMINER_LAUNCHER_PATH : undefined;
const isRunningLauncher = (candidate: string) =>
runningLauncherPath !== undefined &&
platformPath.normalize(candidate).toLowerCase() ===
platformPath.normalize(runningLauncherPath).toLowerCase();
const candidates = new Set([
...(target.installPath ? [target.installPath] : []),
...(options.additionalLauncherPaths ?? []),
...(platform === 'win32'
? []
: preferredLauncherDirs(platform, options.homeDir ?? os.homedir()).map((directory) =>
path.posix.join(directory, 'subminer'),
)),
]);
const readFile = options.readFileSync ?? fs.readFileSync;
const acknowledgedPaths: string[] = [];
let payload: ReturnType<typeof stageManagedLauncher> | undefined;
for (const candidate of candidates) {
if (isRunningLauncher(candidate)) continue;
if (!existsSyncOf(options)(candidate)) {
acknowledgedPaths.push(candidate);
continue;
}
let existing: string;
try {
existing = String(readFile(candidate, 'utf8'));
} catch {
continue;
}
const legacy =
(existing.startsWith('#!/usr/bin/env bun\n') &&
(existing.includes('SubMiner launcher') ||
existing.includes('Launch MPV with SubMiner'))) ||
(platform === 'win32' &&
existing ===
windowsShimContent(
options.appExePath ?? process.execPath,
resolveLauncherResourcePath(options).replace(/subminer\.js$/, 'subminer'),
));
if (!isManagedLauncher(existing) && !legacy) {
acknowledgedPaths.push(candidate);
continue;
}
if (!isWritableDir(pathModuleFor(platform).dirname(candidate), options)) continue;
try {
accessSyncOf(options)(candidate, fs.constants.W_OK);
} catch {
continue;
}
payload ??= stageManagedLauncher({
...options,
bundledBunPath: options.bundledBunPath,
launcherResourcePath: resolveLauncherResourcePath(options),
});
const content = managedLauncherContent({
platform,
appPath: envOf(options).APPIMAGE ?? options.appExePath ?? process.execPath,
});
if (existing !== content) (options.writeFileSync ?? fs.writeFileSync)(candidate, content);
acknowledgedPaths.push(candidate);
}
if (platform === 'win32' && payload) cleanupOldWindowsManagedRuntimes(options);
return acknowledgedPaths;
}
export async function detectCommandLineLauncher(
options: CommonOptions & WindowsPathOptions = {},
): Promise<CommandLineLauncherSnapshot> {
@@ -7,6 +7,7 @@ import {
createHandleJellyfinRemoteGeneralCommand,
createHandleJellyfinRemotePlay,
createHandleJellyfinRemotePlaystate,
createJellyfinRemoteReportTracker,
createReportJellyfinRemoteProgressHandler,
createReportJellyfinRemoteStoppedHandler,
} from '../domains/jellyfin';
@@ -91,13 +92,17 @@ export function composeJellyfinRemoteHandlers(
getNow: options.getNow,
ticksPerSecond: options.ticksPerSecond,
logDebug: options.logDebug,
logWarn: options.logWarn,
});
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
buildReportJellyfinRemoteProgressMainDepsHandler(),
);
const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler(
buildReportJellyfinRemoteStoppedMainDepsHandler(),
);
const reportTracker = createJellyfinRemoteReportTracker();
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler({
...buildReportJellyfinRemoteProgressMainDepsHandler(),
reportTracker,
});
const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler({
...buildReportJellyfinRemoteStoppedMainDepsHandler(),
reportTracker,
});
const buildHandleJellyfinRemotePlayMainDepsHandler =
createBuildHandleJellyfinRemotePlayMainDepsHandler({
@@ -54,6 +54,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
sleep: async () => {},
},
launchMpvIdleForJellyfinPlaybackMainDeps: {
getMpvExecutablePath: () => 'mpv',
getSocketPath: () => '/tmp/test-mpv.sock',
getLaunchMode: () => 'normal',
platform: 'linux',
@@ -38,6 +38,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
clearReconnectTimerRef: () => {},
getSubtitleTimingTracker: () => null,
getImmersionTracker: () => null,
stopStatsServer: () => {},
clearImmersionTracker: () => {},
getAnkiIntegration: () => null,
getAnilistSetupWindow: () => null,
@@ -49,8 +50,10 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: async () => {},
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupRemoteMediaWindows: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
},
@@ -8,13 +8,10 @@ import {
createBuildRestoreWindowsOnActivateMainDepsHandler,
createBuildShouldRestoreWindowsOnActivateMainDepsHandler,
} from '../app-lifecycle-main-activate';
import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from '../protocol-url-handlers-main-deps';
import { registerProtocolUrlHandlers } from '../protocol-url-handlers';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type RegisterProtocolUrlHandlersMainDeps = Parameters<
typeof createBuildRegisterProtocolUrlHandlersMainDepsHandler
>[0];
type RegisterProtocolUrlHandlersMainDeps = Parameters<typeof registerProtocolUrlHandlers>[0];
type OnWillQuitCleanupDeps = Parameters<typeof createBuildOnWillQuitCleanupDepsHandler>[0];
type ShouldRestoreWindowsOnActivateMainDeps = Parameters<
typeof createBuildShouldRestoreWindowsOnActivateMainDepsHandler
@@ -32,7 +29,7 @@ export type StartupLifecycleComposerOptions = ComposerInputs<{
export type StartupLifecycleComposerResult = ComposerOutputs<{
registerProtocolUrlHandlers: () => void;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
}>;
@@ -40,10 +37,6 @@ export type StartupLifecycleComposerResult = ComposerOutputs<{
export function composeStartupLifecycleHandlers(
options: StartupLifecycleComposerOptions,
): StartupLifecycleComposerResult {
const registerProtocolUrlHandlersMainDeps = createBuildRegisterProtocolUrlHandlersMainDepsHandler(
options.registerProtocolUrlHandlersMainDeps,
)();
const onWillQuitCleanupHandler = createOnWillQuitCleanupHandler(
createBuildOnWillQuitCleanupDepsHandler(options.onWillQuitCleanupMainDeps)(),
);
@@ -58,9 +51,9 @@ export function composeStartupLifecycleHandlers(
return {
registerProtocolUrlHandlers: () =>
registerProtocolUrlHandlers(registerProtocolUrlHandlersMainDeps),
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(),
registerProtocolUrlHandlers(options.registerProtocolUrlHandlersMainDeps),
onWillQuitCleanup: onWillQuitCleanupHandler,
shouldRestoreWindowsOnActivate: shouldRestoreWindowsOnActivateHandler,
restoreWindowsOnActivate: restoreWindowsOnActivateHandler,
};
}
@@ -156,6 +156,7 @@ test('createConfigHotReloadAppliedHandler applies only changed Anki media option
const config = deepCloneConfig(DEFAULT_CONFIG);
config.ankiConnect.media.normalizeAudio = false;
config.ankiConnect.media.mirrorMpvVolume = false;
config.ankiConnect.media.reviewTiming = true;
const ankiPatches: unknown[] = [];
const applyHotReload = createConfigHotReloadAppliedHandler({
@@ -181,10 +182,18 @@ test('createConfigHotReloadAppliedHandler applies only changed Anki media option
},
config,
);
applyHotReload(
{
hotReloadFields: ['ankiConnect.media.reviewTiming'],
restartRequiredFields: [],
},
config,
);
assert.deepEqual(ankiPatches, [
{ media: { normalizeAudio: false } },
{ media: { mirrorMpvVolume: false } },
{ media: { reviewTiming: true } },
]);
});
+10 -1
View File
@@ -100,6 +100,9 @@ function buildAnkiRuntimeConfigPatch(
if (diff.hotReloadFields.includes('ankiConnect.media.mirrorMpvVolume')) {
mediaPatch.mirrorMpvVolume = config.ankiConnect.media.mirrorMpvVolume;
}
if (diff.hotReloadFields.includes('ankiConnect.media.reviewTiming')) {
mediaPatch.reviewTiming = config.ankiConnect.media.reviewTiming;
}
if (Object.keys(mediaPatch).length > 0) {
patch.media = mediaPatch;
}
@@ -134,6 +137,9 @@ function buildAnkiRuntimeConfigPatch(
if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) {
patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping };
}
if (diff.hotReloadFields.includes('ankiConnect.isSenren.fieldGrouping')) {
patch.isSenren = { fieldGrouping: config.ankiConnect.isSenren.fieldGrouping };
}
if (diff.hotReloadFields.includes('ankiConnect.lapisKiku.wordCardKind')) {
patch.lapisKiku = { wordCardKind: config.ankiConnect.lapisKiku.wordCardKind };
}
@@ -157,7 +163,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
deps.setKeybindings(payload.keybindings);
deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings);
if (diff.hotReloadFields.includes('shortcuts')) {
if (
diff.hotReloadFields.includes('shortcuts') ||
diff.hotReloadFields.includes('subtitleSelection')
) {
deps.refreshGlobalAndOverlayShortcuts();
}
@@ -5,9 +5,91 @@ import os from 'node:os';
import path from 'node:path';
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
import { resolveConfig } from '../../config/resolve';
import { buildConfigSettingsRegistry } from '../../config/settings/registry';
import type { RawConfig } from '../../types/config';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { createConfigSettingsRuntime } from './config-settings-runtime';
test('settings saves report live changes and only the sections that actually need restart', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-settings-live-'));
const configPath = path.join(dir, 'config.jsonc');
let rawConfig: RawConfig = {};
let resolvedConfig = resolveConfig(rawConfig).resolved;
const applied: string[][] = [];
const runtime = createConfigSettingsRuntime({
fields: buildConfigSettingsRegistry(DEFAULT_CONFIG),
getConfigPath: () => configPath,
getRawConfig: () => rawConfig,
getConfig: () => resolvedConfig,
getWarnings: () => [],
reloadConfigStrict: () => {
rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const result = resolveConfig(rawConfig);
resolvedConfig = result.resolved;
return { ok: true, config: resolvedConfig, warnings: result.warnings, path: configPath };
},
onHotReloadApplied: (diff) => {
applied.push(diff.hotReloadFields);
},
getSettingsWindow: () => null,
setSettingsWindow: () => {},
createSettingsWindow: () => {
throw new Error('Save must not open a window');
},
settingsHtmlPath: '/tmp/settings.html',
openPath: async () => '',
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
createAnkiClient: () => {
throw new Error('Save must not query Anki');
},
ipcMain: { handle: () => {} },
ipcChannels: IPC_CHANNELS.request,
});
try {
const live = runtime.savePatch({
operations: [
{ op: 'set', path: 'notifications.overlayPosition', value: 'top' },
{
op: 'set',
path: 'subtitleGeneration.threads',
value: DEFAULT_CONFIG.subtitleGeneration.threads + 1,
},
],
});
assert.equal(live.ok, true);
assert.deepEqual(live.restartRequiredFields, []);
assert.deepEqual(live.restartRequiredSections, []);
assert.deepEqual(
new Set(live.hotReloadFields),
new Set(['notifications.overlayPosition', 'subtitleGeneration.threads']),
);
assert.deepEqual(applied, [live.hotReloadFields]);
const mixed = runtime.savePatch({
operations: [
{ op: 'set', path: 'ankiConnect.deck', value: 'Mining' },
{ op: 'set', path: 'ankiConnect.url', value: 'http://127.0.0.1:9999' },
],
});
assert.equal(mixed.ok, true);
assert.deepEqual(mixed.hotReloadFields, ['ankiConnect.deck']);
assert.deepEqual(mixed.restartRequiredSections, ['AnkiConnect']);
const reset = runtime.savePatch({
operations: [
{ op: 'reset', path: 'notifications.overlayPosition' },
{ op: 'reset', path: 'subtitleGeneration.threads' },
],
});
assert.equal(reset.ok, true);
assert.deepEqual(reset.restartRequiredSections, []);
assert.deepEqual(new Set(reset.hotReloadFields), new Set(live.hotReloadFields));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('config settings runtime exposes inferred Yomitan Anki deck lookup', async () => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
const runtime = createConfigSettingsRuntime({
-1
View File
@@ -13,4 +13,3 @@ export * from '../anilist-state';
export * from '../anilist-token-refresh';
export * from '../anilist-token-refresh-main-deps';
export * from '../protocol-url-handlers';
export * from '../protocol-url-handlers-main-deps';
@@ -271,7 +271,7 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null);
});
test('buildFirstRunSetupHtml renders command-line launcher section and actions', () => {
test('buildFirstRunSetupHtml reports a broken included runtime in the optional launcher controls', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
@@ -294,9 +294,9 @@ test('buildFirstRunSetupHtml renders command-line launcher section and actions',
status: 'failed',
commandPath: null,
version: null,
installMethod: 'official-script',
installCommand: ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'],
message: 'network failed',
installMethod: null,
installCommand: null,
message: 'Included Bun runtime is missing.',
},
launcher: {
status: 'installed_bun_missing',
@@ -311,14 +311,11 @@ test('buildFirstRunSetupHtml renders command-line launcher section and actions',
});
assert.match(html, /Command line launcher/);
assert.match(html, /Optional\. Setup can finish without Bun or the launcher\./);
assert.match(html, /Bun runtime/);
assert.match(html, /Optional\. Install the launcher to use SubMiner from your terminal\./);
assert.match(html, /Failed/);
assert.match(html, /bash -lc curl -fsSL https:\/\/bun\.com\/install \| bash/);
assert.match(html, /Install Bun/);
assert.match(html, /action=install-bun/);
assert.match(html, /SubMiner launcher/);
assert.match(html, /Installed, Bun missing/);
assert.match(html, /Reinstall SubMiner to repair it/);
assert.match(html, /<button disabled[^>]+action=install-command-line-launcher/);
assert.match(html, /\/home\/tester\/\.local\/bin\/subminer/);
assert.match(html, /action=install-command-line-launcher/);
assert.match(
@@ -360,7 +357,7 @@ test('buildFirstRunSetupHtml disables launcher install when no target is install
assert.match(
html,
/<button disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=install-command-line-launcher'">Install launcher<\/button>/,
/<button disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=install-command-line-launcher'">Install command-line launcher<\/button>/,
);
});
+13 -64
View File
@@ -1,9 +1,5 @@
import { getFirstRunSetupCompletionMessage } from './first-run-setup-service';
import type {
BunSnapshot,
CommandLineLauncherSnapshot,
LauncherSnapshot,
} from './command-line-launcher';
import type { CommandLineLauncherSnapshot, LauncherSnapshot } from './command-line-launcher';
type FocusableWindowLike = {
focus: () => void;
@@ -74,29 +70,12 @@ function renderStatusBadge(value: string, tone: 'ready' | 'warn' | 'muted' | 'da
return `<span class="badge ${tone}">${escapeHtml(value)}</span>`;
}
function formatCommand(command: string[] | null): string {
return command?.join(' ') ?? 'No install command detected';
}
function getBunStatusLabel(status: BunSnapshot['status']): string {
switch (status) {
case 'ready':
return 'Ready';
case 'installing':
return 'Installing';
case 'failed':
return 'Failed';
case 'missing':
return 'Missing';
}
}
function getLauncherStatusLabel(status: LauncherSnapshot['status']): string {
switch (status) {
case 'ready':
return 'Ready';
case 'installed_bun_missing':
return 'Installed, Bun missing';
return 'Unavailable';
case 'not_installed':
return 'Not installed';
case 'not_on_path':
@@ -110,13 +89,6 @@ function getLauncherStatusLabel(status: LauncherSnapshot['status']): string {
}
}
function getToolTone(status: BunSnapshot['status']): 'ready' | 'warn' | 'muted' | 'danger' {
if (status === 'ready') return 'ready';
if (status === 'failed') return 'danger';
if (status === 'installing') return 'muted';
return 'warn';
}
function getLauncherTone(
status: LauncherSnapshot['status'],
): 'ready' | 'warn' | 'muted' | 'danger' {
@@ -135,49 +107,26 @@ function renderCommandLineLauncherSection(
const bun = commandLineLauncher.bun;
const launcher = commandLineLauncher.launcher;
const bunMeta =
bun.status === 'ready'
? [
bun.commandPath ? `Path: ${bun.commandPath}` : null,
bun.version ? `Version: ${bun.version}` : null,
].filter(Boolean)
: [
bun.installMethod ? `Method: ${bun.installMethod}` : null,
`Command: ${formatCommand(bun.installCommand)}`,
bun.message,
].filter(Boolean);
const runtimeUnavailable = bun.status !== 'ready';
const launcherStatus = runtimeUnavailable ? 'failed' : launcher.status;
const runtimeError = bun.installCommand
? "The launcher runtime is unavailable. Install the project's Bun dependency, then refresh."
: 'The launcher runtime is unavailable. Reinstall SubMiner to repair it.';
const launcherMeta = [
launcher.commandPath ? `Command: ${launcher.commandPath}` : null,
launcher.installPath ? `Install target: ${launcher.installPath}` : null,
launcher.pathDir ? `PATH dir: ${launcher.pathDir}` : null,
launcher.shadowedBy ? `Shadowed by: ${launcher.shadowedBy}` : null,
launcher.message,
bun.status !== 'ready' ? 'Warning: subminer will not run until Bun is available.' : null,
runtimeUnavailable ? runtimeError : launcher.message,
].filter(Boolean);
const bunInstallButton =
bun.status === 'missing' || bun.status === 'failed'
? `<button onclick="window.location.href='subminer://first-run-setup?action=install-bun'">Install Bun</button>`
: '';
const launcherButtonDisabled = launcher.status === 'not_installable' ? 'disabled' : '';
const launcherButtonDisabled =
launcher.status === 'not_installable' || bun.status !== 'ready' ? 'disabled' : '';
return `
<section class="setup-section">
<div class="section-head">
<h2>Command line launcher</h2>
<div class="meta">Optional. Setup can finish without Bun or the launcher.</div>
</div>
<div class="card block">
<div class="card-head">
<div>
<strong>Bun runtime</strong>
${bunMeta.map((line) => `<div class="meta">${escapeHtml(String(line))}</div>`).join('')}
</div>
${renderStatusBadge(getBunStatusLabel(bun.status), getToolTone(bun.status))}
</div>
<div class="inline-actions">
${bunInstallButton}
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh</button>
</div>
<div class="meta">Optional. Install the launcher to use SubMiner from your terminal.</div>
</div>
<div class="card block">
<div class="card-head">
@@ -185,10 +134,10 @@ function renderCommandLineLauncherSection(
<strong>SubMiner launcher</strong>
${launcherMeta.map((line) => `<div class="meta">${escapeHtml(String(line))}</div>`).join('')}
</div>
${renderStatusBadge(getLauncherStatusLabel(launcher.status), getLauncherTone(launcher.status))}
${renderStatusBadge(getLauncherStatusLabel(launcherStatus), getLauncherTone(launcherStatus))}
</div>
<div class="inline-actions">
<button ${launcherButtonDisabled} onclick="window.location.href='subminer://first-run-setup?action=install-command-line-launcher'">Install launcher</button>
<button ${launcherButtonDisabled} onclick="window.location.href='subminer://first-run-setup?action=install-command-line-launcher'">Install command-line launcher</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh</button>
</div>
</div>
@@ -20,6 +20,8 @@ function createShortcuts(): ConfiguredShortcuts {
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
@@ -24,6 +24,8 @@ function createShortcuts(): ConfiguredShortcuts {
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
+3 -1
View File
@@ -1,3 +1,5 @@
import { toMediaIdentityPath } from '../../shared/media-identity';
type ResolvedConfigLike = {
immersionTracking?: {
dbPath?: string | null;
@@ -119,7 +121,7 @@ export function createImmersionMediaRuntime(deps: ImmersionMediaRuntimeDeps): {
const mediaState = await getCurrentMpvMediaStateForTracker();
if (mediaState.path) {
deps.logInfo(
`Seeded immersion tracker media state at attempt ${attempt + 1}/${attempts}: ${mediaState.path}`,
`Seeded immersion tracker media state at attempt ${attempt + 1}/${attempts}: ${toMediaIdentityPath(mediaState.path)}`,
);
tracker.handleMediaChange(mediaState.path, mediaState.title);
return;
@@ -6,6 +6,7 @@ import process from 'node:process';
import test from 'node:test';
import {
buildFfmpegSubtitleExtractionArgs,
createCachedInternalSubtitleTrackExtractor,
extractInternalSubtitleTrackToTempFile,
parseTrackId,
} from './internal-subtitle-extraction';
@@ -22,6 +23,65 @@ test('parseTrackId rejects negative track ids', () => {
assert.equal(parseTrackId(' -2 '), null);
});
test('cached internal subtitle extraction shares concurrent and repeated track requests', async () => {
let extractionCalls = 0;
let cleanupCalls = 0;
let resolveExtraction:
| ((result: { path: string; cleanup: () => Promise<void> }) => void)
| undefined;
const firstExtraction = new Promise<{ path: string; cleanup: () => Promise<void> }>((resolve) => {
resolveExtraction = resolve;
});
const extractor = createCachedInternalSubtitleTrackExtractor({
extract: async () => {
extractionCalls += 1;
if (extractionCalls === 1) {
return firstExtraction;
}
return {
path: `/tmp/subtitle-${extractionCalls}.ass`,
cleanup: async () => {
cleanupCalls += 1;
},
};
},
});
const request = () =>
extractor.extract('ffmpeg', '/Volumes/media/episode.mkv', {
'ff-index': 3,
codec: 'ass',
});
const concurrent = Array.from({ length: 6 }, request);
assert.equal(extractionCalls, 1);
if (!resolveExtraction) {
throw new Error('extraction did not start');
}
resolveExtraction({
path: '/tmp/subtitle-1.ass',
cleanup: async () => {
cleanupCalls += 1;
},
});
const results = await Promise.all(concurrent);
assert.deepEqual(
results.map((result) => result?.path),
Array.from({ length: 6 }, () => '/tmp/subtitle-1.ass'),
);
await Promise.all(results.map((result) => result?.cleanup()));
assert.equal(cleanupCalls, 0);
assert.equal((await request())?.path, '/tmp/subtitle-1.ass');
assert.equal(extractionCalls, 1);
extractor.clear();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(cleanupCalls, 1);
assert.equal((await request())?.path, '/tmp/subtitle-2.ass');
assert.equal(extractionCalls, 2);
});
test('extractInternalSubtitleTrackToTempFile times out stalled ffmpeg process', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-ffmpeg-timeout-'));
const videoPath = path.join(root, 'video.mkv');
@@ -35,7 +35,21 @@ export type MpvSubtitleTrackLike = {
'external-filename'?: unknown;
};
const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000;
export type ExtractedInternalSubtitleTrack = {
path: string;
cleanup: () => Promise<void>;
};
export type InternalSubtitleTrackExtractor = (
ffmpegPath: string,
videoPath: string,
track: MpvSubtitleTrackLike,
) => Promise<ExtractedInternalSubtitleTrack | null>;
// Subtitle packets are interleaved through the container, so extraction reads the
// entire file. Network mounts move ~100 MB/s on gigabit, so large Bluray remuxes
// need well over 30 seconds.
const DEFAULT_EXTRACTION_TIMEOUT_MS = 120_000;
export function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
@@ -80,7 +94,7 @@ export async function extractInternalSubtitleTrackToTempFile(
videoPath: string,
track: MpvSubtitleTrackLike,
options: { extractionTimeoutMs?: number; spawnArgsOverride?: string[] } = {},
): Promise<{ path: string; cleanup: () => Promise<void> } | null> {
): Promise<ExtractedInternalSubtitleTrack | null> {
const ffIndex = parseTrackId(track['ff-index']);
const codec = typeof track.codec === 'string' ? track.codec : null;
const extension = codecToExtension(codec ?? undefined);
@@ -145,3 +159,69 @@ export async function extractInternalSubtitleTrackToTempFile(
},
};
}
type CachedExtraction = {
promise: Promise<ExtractedInternalSubtitleTrack | null>;
};
function buildCachedExtractionKey(
ffmpegPath: string,
videoPath: string,
track: MpvSubtitleTrackLike,
): string {
const codec = typeof track.codec === 'string' ? track.codec : null;
return JSON.stringify([ffmpegPath, videoPath, parseTrackId(track['ff-index']), codec]);
}
const releaseCachedExtraction = async (): Promise<void> => {};
/**
* Owns extracted subtitle files for the active media and shares one extraction between callers.
* Caller cleanup releases only its view; clear removes the owned files on media changes or quit.
*/
export function createCachedInternalSubtitleTrackExtractor(
deps: { extract?: InternalSubtitleTrackExtractor } = {},
): {
extract: InternalSubtitleTrackExtractor;
clear: () => void;
} {
const extractTrack = deps.extract ?? extractInternalSubtitleTrackToTempFile;
const extractions = new Map<string, CachedExtraction>();
const extract: InternalSubtitleTrackExtractor = async (ffmpegPath, videoPath, track) => {
const key = buildCachedExtractionKey(ffmpegPath, videoPath, track);
let cached = extractions.get(key);
if (!cached) {
const next: CachedExtraction = {
promise: extractTrack(ffmpegPath, videoPath, track),
};
cached = next;
extractions.set(key, next);
void next.promise.catch(() => {
if (extractions.get(key) === next) {
extractions.delete(key);
}
});
}
const result = await cached.promise;
if (extractions.get(key) !== cached || !result) {
return null;
}
return {
path: result.path,
cleanup: releaseCachedExtraction,
};
};
const clear = (): void => {
const staleExtractions = [...extractions.values()];
extractions.clear();
for (const extraction of staleExtractions) {
void extraction.promise.then((result) => result?.cleanup()).catch(() => undefined);
}
};
return { extract, clear };
}
@@ -103,6 +103,7 @@ test('playback handler drives mpv commands and playback state', async () => {
['set_property', 'sub-visibility', 'no'],
['set_property', 'secondary-sub-visibility', 'no'],
['script-message', 'subminer-managed-subtitles-loading'],
['set_property', 'force-media-title', 'Episode 1'],
[
'loadfile',
'https://stream.example/video.m3u8',
@@ -110,7 +111,6 @@ test('playback handler drives mpv commands and playback state', async () => {
-1,
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no,start=1.2',
],
['set_property', 'force-media-title', 'Episode 1'],
]);
assert.equal(scheduled.length, 0);
assert.equal(
@@ -437,6 +437,8 @@ test('playback handler publishes Jellyfin title before loading tokenized stream
assert.ok(titleIndex >= 0);
assert.ok(loadIndex >= 0);
assert.ok(titleIndex < loadIndex);
const mpvTitleIndex = timeline.indexOf('cmd:set_property:force-media-title');
assert.ok(mpvTitleIndex >= 0 && mpvTitleIndex < loadIndex);
assert.equal(timeline[titleIndex]?.includes('api_key'), false);
});
+3 -2
View File
@@ -165,7 +165,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
const mpvClient = deps.getMpvClient();
if (!connected || !mpvClient) {
throw new Error(
'MPV not connected and auto-launch failed. Ensure mpv is installed and available in PATH.',
'MPV not connected and auto-launch failed. Check mpv.executablePath or ensure mpv is available in PATH.',
);
}
@@ -221,11 +221,12 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
});
deps.setLastProgressAtMs(0);
deps.sendMpvCommand(['script-message', 'subminer-managed-subtitles-loading']);
// Set mpv's title before loadfile can emit a URL-derived media-title event.
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace', -1, loadfileOptions]);
if (params.setQuitOnDisconnectArm !== false) {
deps.armQuitOnDisconnect();
}
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
await awaitBestEffortPlaybackHook(() =>
deps.preloadExternalSubtitles({
@@ -32,6 +32,7 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
},
};
const deps = createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler({
getMpvExecutablePath: () => '/usr/local/bin/mpv',
getSocketPath: () => '/tmp/mpv.sock',
getLaunchMode: () => 'fullscreen',
platform: 'darwin',
@@ -47,8 +48,8 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
getDefaultMpvLogPath: () => '/tmp/mpv.log',
defaultMpvArgs: ['--no-config'],
removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`),
spawnMpv: (args) => {
calls.push(`spawn:${args.join(' ')}`);
spawnMpv: (executablePath, args) => {
calls.push(`spawn:${executablePath} ${args.join(' ')}`);
return proc;
},
logWarn: (message) => calls.push(`warn:${message}`),
@@ -60,14 +61,20 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
assert.equal(deps.platform, 'darwin');
assert.equal(deps.execPath, '/tmp/subminer');
assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua');
assert.equal(deps.getInstalledPluginDetection?.().installed, false);
assert.equal(deps.getMpvExecutablePath(), '/usr/local/bin/mpv');
assert.equal(deps.getInstalledPluginDetection?.('/usr/local/bin/mpv').installed, false);
assert.equal(deps.getDefaultMpvLogPath(), '/tmp/mpv.log');
assert.deepEqual(deps.defaultMpvArgs, ['--no-config']);
deps.removeSocketPath('/tmp/mpv.sock');
deps.spawnMpv(['--idle=yes']);
deps.spawnMpv('/usr/local/bin/mpv', ['--idle=yes']);
deps.logInfo('launched');
deps.logWarn('bad', null);
assert.deepEqual(calls, ['rm:/tmp/mpv.sock', 'spawn:--idle=yes', 'info:launched', 'warn:bad']);
assert.deepEqual(calls, [
'rm:/tmp/mpv.sock',
'spawn:/usr/local/bin/mpv --idle=yes',
'info:launched',
'warn:bad',
]);
});
test('ensure mpv connected for jellyfin main deps builder maps callbacks', async () => {
@@ -16,6 +16,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
deps: LaunchMpvForJellyfinDeps,
) {
return (): LaunchMpvForJellyfinDeps => ({
getMpvExecutablePath: () => deps.getMpvExecutablePath(),
getSocketPath: () => deps.getSocketPath(),
getLaunchMode: () => deps.getLaunchMode(),
platform: deps.platform,
@@ -26,7 +27,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
getDefaultMpvLogPath: () => deps.getDefaultMpvLogPath(),
defaultMpvArgs: deps.defaultMpvArgs,
removeSocketPath: (socketPath: string) => deps.removeSocketPath(socketPath),
spawnMpv: (args: string[]) => deps.spawnMpv(args),
spawnMpv: (executablePath, args) => deps.spawnMpv(executablePath, args),
logWarn: (message: string, error: unknown) => deps.logWarn(message, error),
logInfo: (message: string) => deps.logInfo(message),
});
@@ -1,5 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { detectInstalledMpvPlugin } from './first-run-setup-plugin';
import { resolveWindowsMpvPath } from './mpv-process';
import {
createEnsureMpvConnectedForJellyfinPlaybackHandler,
createLaunchMpvIdleForJellyfinPlaybackHandler,
@@ -30,6 +32,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
const spawnedArgs: string[][] = [];
const logs: string[] = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getMpvExecutablePath: () => 'mpv',
getSocketPath: () => '/tmp/subminer.sock',
getLaunchMode: () => 'maximized',
platform: 'darwin',
@@ -39,7 +42,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
getDefaultMpvLogPath: () => ' /tmp/mp.log ',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
spawnMpv: (_executable, args) => {
spawnedArgs.push(args);
return {
on: () => {},
@@ -67,6 +70,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin config', () => {
const spawnedArgs: string[][] = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getMpvExecutablePath: () => 'mpv',
getSocketPath: () => '/tmp/subminer.sock',
getLaunchMode: () => 'normal',
platform: 'linux',
@@ -84,7 +88,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
getDefaultMpvLogPath: () => '/tmp/mp.log',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
spawnMpv: (_executable, args) => {
spawnedArgs.push(args);
return {
on: () => {},
@@ -108,41 +112,53 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
assert.doesNotMatch(scriptOpts ?? '', /subminer-aniskip_button_key=/);
});
test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => {
const spawnedArgs: string[][] = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getSocketPath: () => '/tmp/subminer.sock',
getLaunchMode: () => 'normal',
platform: 'linux',
execPath: '/opt/SubMiner/SubMiner.AppImage',
getRuntimePluginEntrypoint: () => '/opt/SubMiner/plugin/subminer/main.lua',
getInstalledPluginDetection: () => ({
installed: true,
path: '/home/tester/.config/mpv/scripts/subminer/main.lua',
version: '0.1.0',
source: 'default-config',
message: null,
}),
getDefaultMpvLogPath: () => '/tmp/mp.log',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
spawnedArgs.push(args);
return {
on: () => {},
unref: () => {},
};
},
logWarn: () => {},
logInfo: () => {},
});
test('Jellyfin detects portable plugins beside the executable selected for launch', () => {
const mpvPath = 'C:\\portable player\\mpv.exe';
const pluginPath = 'C:\\portable player\\portable_config\\scripts\\subminer\\main.lua';
for (const source of ['environment', 'PATH']) {
let resolutions = 0;
const spawned: Array<{ executable: string; args: string[] }> = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getMpvExecutablePath: () => {
resolutions += 1;
return resolveWindowsMpvPath({
getEnv: () => (source === 'environment' ? mpvPath : undefined),
runWhere: () => ({ status: 0, stdout: mpvPath }),
fileExists: (candidate) => candidate === mpvPath,
});
},
getSocketPath: () => '\\\\.\\pipe\\subminer-test',
getLaunchMode: () => 'normal',
platform: 'win32',
execPath: 'C:\\SubMiner\\SubMiner.exe',
getRuntimePluginEntrypoint: () => 'C:\\SubMiner\\plugin\\subminer\\main.lua',
getInstalledPluginDetection: (mpvExecutablePath) =>
detectInstalledMpvPlugin({
platform: 'win32',
homeDir: 'C:\\Users\\test',
mpvExecutablePath,
existsSync: (candidate) => candidate === pluginPath,
}),
getDefaultMpvLogPath: () => '',
defaultMpvArgs: [],
removeSocketPath: () => {},
spawnMpv: (executable, args) => {
spawned.push({ executable, args });
return { on: () => {}, unref: () => {} };
},
logWarn: () => {},
logInfo: () => {},
});
launch();
assert.equal(
spawnedArgs[0]?.some((arg) => arg.startsWith('--script=/opt/SubMiner/plugin/subminer')),
false,
);
assert.ok(spawnedArgs[0]?.some((arg) => arg.startsWith('--script-opts=')));
launch();
assert.equal(resolutions, 1, source);
assert.equal(spawned.length, 1);
assert.equal(spawned[0]!.executable, mpvPath);
assert.equal(
spawned[0]!.args.some((arg) => arg.startsWith('--script=')),
false,
);
}
});
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
@@ -41,23 +41,25 @@ export function createWaitForMpvConnectedHandler(deps: WaitForMpvConnectedDeps)
}
export type LaunchMpvForJellyfinDeps = {
getMpvExecutablePath: () => string;
getSocketPath: () => string;
getLaunchMode: () => MpvLaunchMode;
platform: NodeJS.Platform;
execPath: string;
getRuntimePluginEntrypoint?: () => string | null | undefined;
getInstalledPluginDetection?: () => InstalledMpvPluginDetection;
getInstalledPluginDetection?: (mpvExecutablePath: string) => InstalledMpvPluginDetection;
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
getDefaultMpvLogPath: () => string;
defaultMpvArgs: readonly string[];
removeSocketPath: (socketPath: string) => void;
spawnMpv: (args: string[]) => SpawnedProcessLike;
spawnMpv: (executablePath: string, args: string[]) => SpawnedProcessLike;
logWarn: (message: string, error: unknown) => void;
logInfo: (message: string) => void;
};
export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvForJellyfinDeps) {
return (): void => {
const executablePath = deps.getMpvExecutablePath();
const socketPath = deps.getSocketPath();
if (deps.platform !== 'win32') {
try {
@@ -78,7 +80,7 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
)
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`;
const installedPlugin = deps.getInstalledPluginDetection?.();
const installedPlugin = deps.getInstalledPluginDetection?.(executablePath);
const runtimePluginEntrypoint = installedPlugin?.installed
? ''
: (deps.getRuntimePluginEntrypoint?.()?.trim() ?? '');
@@ -95,7 +97,7 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
...(defaultMpvLogPath ? [`--log-file=${defaultMpvLogPath}`] : []),
`--input-ipc-server=${socketPath}`,
];
const proc = deps.spawnMpv(mpvArgs);
const proc = deps.spawnMpv(executablePath, mpvArgs);
proc.on('error', (error) => {
deps.logWarn('Failed to launch mpv for Jellyfin remote playback', error);
});
@@ -75,5 +75,6 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
ticksPerSecond: deps.ticksPerSecond,
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
...(deps.logWarn ? { logWarn: (message: string) => deps.logWarn?.(message) } : {}),
});
}
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
markJellyfinRemotePlaybackLoaded,
createJellyfinRemoteReportTracker,
createReportJellyfinRemoteProgressHandler,
createReportJellyfinRemoteStoppedHandler,
secondsToJellyfinTicks,
@@ -528,3 +529,70 @@ test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before
assert.equal(stopped, false);
assert.equal(cleared, false);
});
test('createReportJellyfinRemoteStoppedHandler clears playback before reporting and waits for in-flight progress', async () => {
const tracker = createJellyfinRemoteReportTracker();
let playback: { itemId: string; playMethod: 'DirectPlay'; loadedMediaPath: string } | null = {
itemId: 'item-1',
playMethod: 'DirectPlay',
loadedMediaPath: 'http://pve-main:8096/Videos/item-1/stream',
};
const calls: string[] = [];
let releaseProgress: () => void = () => undefined;
const progressGate = new Promise<void>((resolve) => {
releaseProgress = resolve;
});
const session = {
isConnected: () => true,
reportProgress: async ({ eventName }: { eventName: string }) => {
calls.push(`progress:${eventName}:${playback ? 'active' : 'cleared'}`);
if (calls.length === 1) await progressGate;
return true;
},
reportStopped: async () => {
calls.push(`stopped:${playback ? 'active' : 'cleared'}`);
return true;
},
};
const shared = {
getActivePlayback: () => playback,
clearActivePlayback: () => {
playback = null;
},
getSession: () => session,
getMpvClient: () => ({ currentTimePos: 42 }),
ticksPerSecond: 10_000_000,
logDebug: () => undefined,
reportTracker: tracker,
};
const reportProgress = createReportJellyfinRemoteProgressHandler({
...shared,
getNow: () => 10_000,
getLastProgressAtMs: () => 0,
setLastProgressAtMs: () => undefined,
progressIntervalMs: 3000,
});
const reportStopped = createReportJellyfinRemoteStoppedHandler(shared);
// A periodic tick is mid-request when the stop starts.
const tick = reportProgress(true);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
const stop = reportStopped();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(playback, null);
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
// A tick fired after the stop began must not report anything.
await reportProgress(true);
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
releaseProgress();
await tick;
await stop;
assert.deepEqual(calls, [
'progress:TimeUpdate:active',
'progress:TimeUpdate:cleared',
'stopped:cleared',
]);
});
+43 -4
View File
@@ -134,6 +134,29 @@ function isSeekLikePositionJump(
return Math.abs(nextPositionSeconds - previousPositionSeconds) >= thresholdSeconds;
}
// Jellyfin re-creates a session's NowPlayingItem from any progress report, so a progress
// tick that lands after the stop report leaves the server showing playback forever. The
// tracker lets the stop handler wait for reports that are already in flight.
export type JellyfinRemoteReportTracker = {
track: (report: Promise<void>) => void;
settled: () => Promise<void>;
};
export function createJellyfinRemoteReportTracker(): JellyfinRemoteReportTracker {
const active = new Set<Promise<void>>();
return {
track: (report) => {
active.add(report);
void report.finally(() => active.delete(report));
},
settled: async () => {
while (active.size > 0) {
await Promise.allSettled([...active]);
}
},
};
}
export type JellyfinRemoteProgressReporterDeps = {
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
clearActivePlayback: () => void;
@@ -145,6 +168,7 @@ export type JellyfinRemoteProgressReporterDeps = {
progressIntervalMs: number;
ticksPerSecond: number;
logDebug: (message: string, error: unknown) => void;
reportTracker?: JellyfinRemoteReportTracker;
};
export function createReportJellyfinRemoteProgressHandler(
@@ -152,7 +176,7 @@ export function createReportJellyfinRemoteProgressHandler(
) {
let lastReportedPositionSeconds: number | null = null;
return async (force = false): Promise<void> => {
const report = async (force: boolean): Promise<void> => {
const playback = deps.getActivePlayback();
if (!playback) return;
const session = deps.getSession();
@@ -193,6 +217,12 @@ export function createReportJellyfinRemoteProgressHandler(
deps.logDebug('Failed to report Jellyfin remote progress', error);
}
};
return async (force = false): Promise<void> => {
const pending = report(force);
deps.reportTracker?.track(pending);
await pending;
};
}
export type JellyfinRemoteStoppedReporterDeps = {
@@ -203,6 +233,8 @@ export type JellyfinRemoteStoppedReporterDeps = {
getNow?: () => number;
ticksPerSecond: number;
logDebug: (message: string, error: unknown) => void;
logWarn?: (message: string) => void;
reportTracker?: JellyfinRemoteReportTracker;
};
export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteStoppedReporterDeps) {
@@ -226,6 +258,10 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
deps.clearActivePlayback();
return;
}
// Clear before any network call so progress ticks fired during the stop find nothing to
// report, then let reports already in flight finish so none can arrive after the stop.
deps.clearActivePlayback();
await deps.reportTracker?.settled();
try {
const observedPositionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient());
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
@@ -244,7 +280,7 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
} catch (error) {
deps.logDebug('Failed to report Jellyfin remote final progress', error);
}
await session.reportStopped({
const reported = await session.reportStopped({
itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId,
positionTicks,
@@ -254,10 +290,13 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
subtitleStreamIndex: playback.subtitleStreamIndex,
eventName: 'stop',
});
if (reported === false) {
deps.logWarn?.(
`Jellyfin did not accept the playback stop report for item ${playback.itemId}; the server may keep showing it as playing.`,
);
}
} catch (error) {
deps.logDebug('Failed to report Jellyfin remote stop', error);
} finally {
deps.clearActivePlayback();
}
};
}
@@ -38,6 +38,7 @@ type JellyfinRemoteServiceOptions = {
};
onConnected: () => void;
onDisconnected: () => void;
logWarn?: (message: string, details?: unknown) => void;
onPlay: (payload: JellyfinRemoteEventPayload) => void;
onPlaystate: (payload: JellyfinRemoteEventPayload) => void;
onGeneralCommand: (payload: JellyfinRemoteEventPayload) => void;
@@ -110,6 +111,7 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
onDisconnected: () => {
deps.logWarn('Jellyfin remote websocket disconnected; retrying.');
},
logWarn: (message, details) => deps.logWarn(message, details),
onPlay: (payload) => {
void deps.handlePlay(payload).catch((error) => {
deps.logWarn('Failed handling Jellyfin remote Play event', error);
@@ -19,19 +19,6 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
return { path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' };
},
cleanupCachedSubtitles: () => calls.push('cleanup'),
getSavedSubtitleDelay: (_itemId, streamIndex) => {
calls.push(`load-delay:${streamIndex}`);
return 1.25;
},
setActiveSubtitleDelayKey: (key) => calls.push(`active-delay:${key?.streamIndex ?? 'none'}`),
loadSubtitleSourceText: async (source) => {
calls.push(`load-source:${source}`);
return 'subtitle';
},
saveSubtitleDelay: (_itemId, streamIndex, delaySeconds) => {
calls.push(`save-delay:${streamIndex}:${delaySeconds}`);
return true;
},
logDebug: (message) => calls.push(`debug:${message}`),
})();
@@ -41,21 +28,6 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
await deps.wait(1);
await deps.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' });
deps.cleanupCachedSubtitles(['/tmp/subs']);
assert.equal(deps.getSavedSubtitleDelay?.('item', 3), 1.25);
deps.setActiveSubtitleDelayKey?.({ itemId: 'item', streamIndex: 3 });
assert.equal(await deps.loadSubtitleSourceText?.('/tmp/sub.srt'), 'subtitle');
assert.equal(deps.saveSubtitleDelay?.('item', 3, -31.5), true);
deps.logDebug('oops', null);
assert.deepEqual(calls, [
'list',
'send',
'wait',
'cache',
'cleanup',
'load-delay:3',
'active-delay:3',
'load-source:/tmp/sub.srt',
'save-delay:3:-31.5',
'debug:oops',
]);
assert.deepEqual(calls, ['list', 'send', 'wait', 'cache', 'cleanup', 'debug:oops']);
});
@@ -15,19 +15,6 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
wait: (ms: number) => deps.wait(ms),
cacheSubtitleTrack: (track) => deps.cacheSubtitleTrack(track),
cleanupCachedSubtitles: (dirs) => deps.cleanupCachedSubtitles(dirs),
getSavedSubtitleDelay: deps.getSavedSubtitleDelay
? (itemId, streamIndex) => deps.getSavedSubtitleDelay!(itemId, streamIndex)
: undefined,
setActiveSubtitleDelayKey: deps.setActiveSubtitleDelayKey
? (key) => deps.setActiveSubtitleDelayKey!(key)
: undefined,
loadSubtitleSourceText: deps.loadSubtitleSourceText
? (source) => deps.loadSubtitleSourceText!(source)
: undefined,
saveSubtitleDelay: deps.saveSubtitleDelay
? (itemId, streamIndex, delaySeconds) =>
deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds)
: undefined,
initSubtitlePrefetch: deps.initSubtitlePrefetch
? (sourcePath) => deps.initSubtitlePrefetch!(sourcePath)
: undefined,
+160 -190
View File
@@ -32,14 +32,6 @@ function makeDeps(overrides: {
cleanupCachedSubtitles?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['cleanupCachedSubtitles'];
getSavedSubtitleDelay?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['getSavedSubtitleDelay'];
setActiveSubtitleDelayKey?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['setActiveSubtitleDelayKey'];
loadSubtitleSourceText?: (source: string) => Promise<string>;
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void;
initSubtitlePrefetch?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['initSubtitlePrefetch'];
@@ -57,10 +49,6 @@ function makeDeps(overrides: {
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
})),
cleanupCachedSubtitles: overrides.cleanupCachedSubtitles ?? (() => {}),
getSavedSubtitleDelay: overrides.getSavedSubtitleDelay,
setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey,
loadSubtitleSourceText: overrides.loadSubtitleSourceText,
saveSubtitleDelay: overrides.saveSubtitleDelay,
initSubtitlePrefetch: overrides.initSubtitlePrefetch,
logDebug: overrides.logDebug ?? (() => {}),
};
@@ -375,22 +363,177 @@ test('preload jellyfin subtitles waits for delayed external japanese track inste
]);
});
test('preload jellyfin subtitles selects japanese before slower tracks finish downloading', async () => {
const commands: Array<Array<string | number>> = [];
let releaseSlowTrack!: () => void;
const slowTrackBlocked = new Promise<void>((resolve) => {
releaseSlowTrack = resolve;
});
const mpvTracks: Array<Record<string, unknown>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'eng', title: 'English', deliveryUrl: 'https://sub/eng.ass' },
{ index: 1, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
],
getMpvClient: () => ({ requestProperty: async () => mpvTracks }),
cacheSubtitleTrack: async (track) => {
if (track.index === 0) {
await slowTrackBlocked;
}
return {
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
};
},
sendMpvCommand: (command) => {
commands.push(command);
if (command[0] === 'sub-add') {
mpvTracks.push({
type: 'sub',
id: mpvTracks.length + 1,
lang: command[4],
title: command[3],
external: true,
'external-filename': command[1],
});
}
},
}),
);
const done = preload({ session, clientInfo, itemId: 'item-1' });
const hasJapanesePrimary = () =>
commands.some(
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 1,
);
for (let tick = 0; tick < 1000 && !hasJapanesePrimary(); tick += 1) {
await new Promise((resolve) => setImmediate(resolve));
}
const selectedBeforeSlowTrack = setPropertyCommandsExceptTrackAutoSelection(commands);
// Release before asserting so a regression fails here instead of leaving the preload hanging.
releaseSlowTrack();
await done;
assert.deepEqual(selectedBeforeSlowTrack, [['set_property', 'sid', 1]]);
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
['set_property', 'sid', 1],
['set_property', 'secondary-sid', 2],
]);
});
test('preload jellyfin subtitles does not lock in a fallback japanese track during early selection', async () => {
const commands: Array<Array<string | number>> = [];
let requestCount = 0;
const fallbackJapanese = {
type: 'sub',
id: 5,
lang: 'jpn',
title: 'Japanese SDH',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
};
const preferredJapanese = {
type: 'sub',
id: 6,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
};
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese SDH', deliveryUrl: 'https://sub/sdh.srt' },
{
index: 1,
language: 'jpn',
title: 'Japanese',
isDefault: true,
deliveryUrl: 'https://sub/jpn.srt',
},
],
getMpvClient: () => ({
requestProperty: async () => {
requestCount += 1;
// mpv lists the preferred track only after the early selection poll gives up.
return requestCount <= 10 ? [fallbackJapanese] : [fallbackJapanese, preferredJapanese];
},
}),
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
['set_property', 'sid', 6],
]);
});
test('preload jellyfin subtitles still selects remaining tracks when one download fails', async () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'eng', title: 'English', deliveryUrl: 'https://sub/eng.srt' },
{ index: 1, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
{ index: 5, language: 'eng', title: 'English PGS', deliveryUrl: 'https://sub/pgs.srt' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 1,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 2,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
],
}),
cacheSubtitleTrack: async (track) => {
if (track.index === 5) {
throw new Error('Failed to download Jellyfin subtitle (HTTP 400)');
}
return {
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
};
},
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
['set_property', 'sid', 2],
['set_property', 'secondary-sid', 1],
]);
});
test('preload jellyfin subtitles clears managed delay when no external tracks are available', async () => {
const commands: Array<Array<string | number>> = [];
const activeDelayKeys: Array<unknown> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Embedded Japanese' },
],
sendMpvCommand: (command) => commands.push(command),
setActiveSubtitleDelayKey: (key) => activeDelayKeys.push(key),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(activeDelayKeys, [null]);
assert.deepEqual(commands, [['set_property', 'sub-delay', 0]]);
});
@@ -461,42 +604,7 @@ test('preload jellyfin subtitles prefers Jellyfin default and embedded japanese
]);
});
test('preload jellyfin subtitles applies saved delay for selected japanese stream', async () => {
const commands: Array<Array<string | number>> = [];
const activeKeys: Array<{ itemId: string; streamIndex: number } | null> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 3, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 11,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/3.srt',
},
],
}),
sendMpvCommand: (command) => commands.push(command),
getSavedSubtitleDelay: (_itemId, streamIndex) => (streamIndex === 3 ? 1.25 : null),
setActiveSubtitleDelayKey: (key) => activeKeys.push(key),
}),
);
await preload({ session, clientInfo, itemId: 'item-9' });
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
['set_property', 'sub-delay', 1.25],
['set_property', 'sid', 11],
]);
assert.deepEqual(activeKeys, [{ itemId: 'item-9', streamIndex: 3 }]);
});
test('preload jellyfin subtitles applies saved delay before selecting japanese stream', async () => {
test('preload jellyfin subtitles resets delay before selecting japanese stream', async () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
@@ -516,14 +624,13 @@ test('preload jellyfin subtitles applies saved delay before selecting japanese s
],
}),
sendMpvCommand: (command) => commands.push(command),
getSavedSubtitleDelay: () => 1.25,
}),
);
await preload({ session, clientInfo, itemId: 'item-9' });
const delayIndex = commands.findIndex(
(command) => command[0] === 'set_property' && command[1] === 'sub-delay' && command[2] === 1.25,
(command) => command[0] === 'set_property' && command[1] === 'sub-delay' && command[2] === 0,
);
const selectedSidIndex = commands.findIndex(
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 11,
@@ -533,143 +640,6 @@ test('preload jellyfin subtitles applies saved delay before selecting japanese s
assert.ok(delayIndex < selectedSidIndex);
});
test('preload jellyfin subtitles auto-aligns late japanese track from english reference', async () => {
const commands: Array<Array<string | number>> = [];
const savedDelays: Array<{ itemId: string; streamIndex: number; delaySeconds: number }> = [];
const primarySrt = `1
00:00:34,935 --> 00:00:36,937
Japanese 1
2
00:00:36,937 --> 00:00:41,441
Japanese 2
3
00:00:41,441 --> 00:00:45,279
Japanese 3
4
00:00:45,279 --> 00:00:48,115
Japanese 4
5
00:00:48,115 --> 00:00:52,286
Japanese 5
6
00:00:52,286 --> 00:00:54,955
Japanese 6
7
00:00:54,955 --> 00:00:59,793
Japanese 7
8
00:00:59,793 --> 00:01:03,630
Japanese 8
9
00:01:03,630 --> 00:01:07,634
Japanese 9
10
00:01:07,634 --> 00:01:13,040
Japanese 10
11
00:01:16,643 --> 00:01:20,814
Japanese 11
12
00:01:20,814 --> 00:01:23,116
Japanese 12
13
00:01:27,988 --> 00:01:30,991
Japanese 13
14
00:01:30,991 --> 00:01:34,094
Japanese 14
15
00:01:34,094 --> 00:01:37,097
Japanese 15
16
00:01:37,097 --> 00:01:39,100
Japanese 16
`;
const referenceAss = `[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:03.46,0:00:08.73,Default,,0,0,0,,English 1
Dialogue: 0,0:00:09.48,0:00:13.61,Default,,0,0,0,,English 2
Dialogue: 0,0:00:13.61,0:00:19.64,Default,,0,0,0,,English 3
Dialogue: 0,0:00:21.40,0:00:27.32,Default,,0,0,0,,English 4
Dialogue: 0,0:00:28.16,0:00:31.75,Default,,0,0,0,,English 5
Dialogue: 0,0:00:32.06,0:00:34.52,Default,,0,0,0,,English 6
Dialogue: 0,0:00:35.93,0:00:40.57,Default,,0,0,0,,English 7
Dialogue: 0,0:00:45.10,0:00:51.01,Default,,0,0,0,,English 8
Dialogue: 0,0:00:56.57,0:00:59.12,Default,,0,0,0,,English 9
Dialogue: 0,0:00:59.68,0:01:02.44,Default,,0,0,0,,English 10
Dialogue: 0,0:01:02.44,0:01:05.56,Default,,0,0,0,,English 11
Dialogue: 0,0:01:05.56,0:01:06.87,Default,,0,0,0,,English 12
`;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
{ index: 4, language: 'eng', title: 'English', deliveryUrl: 'https://sub/eng.ass' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 10,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 12,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/4.ass',
},
],
}),
sendMpvCommand: (command) => commands.push(command),
cacheSubtitleTrack: async (track) => ({
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.${track.index === 4 ? 'ass' : 'srt'}`,
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
}),
getSavedSubtitleDelay: () => null,
loadSubtitleSourceText: async (source) =>
source.endsWith('.ass') ? referenceAss : primarySrt,
saveSubtitleDelay: (itemId, streamIndex, delaySeconds) => {
savedDelays.push({ itemId, streamIndex, delaySeconds });
},
}),
);
await preload({ session, clientInfo, itemId: 'item-9' });
const delayCommand = commands.find(
(command) => command[0] === 'set_property' && command[1] === 'sub-delay',
);
assert.ok(delayCommand);
const delaySeconds = delayCommand[2];
if (typeof delaySeconds !== 'number') {
assert.fail('Expected numeric subtitle delay.');
}
assert.ok(delaySeconds > -32);
assert.ok(delaySeconds < -31);
assert.deepEqual(savedDelays, [{ itemId: 'item-9', streamIndex: 0, delaySeconds }]);
});
test('preload jellyfin subtitles accepts numeric string mpv track ids', async () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
+138 -157
View File
@@ -1,6 +1,3 @@
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import { estimateSubtitleTimingOffset } from '../../core/services/subtitle-timing-offset';
type JellyfinSession = {
serverUrl: string;
accessToken: string;
@@ -35,11 +32,6 @@ type CachedExternalSubtitleTrack = CachedSubtitleTrack & {
source: JellyfinSubtitleTrack;
};
type JellyfinSubtitleDelayKey = {
itemId: string;
streamIndex: number;
};
type MpvSubtitleTrack = {
id: number;
lang: string;
@@ -137,24 +129,48 @@ function pickBestCachedTrackId(
: false,
)
.filter(({ track }) => track.id !== excludeId)
.map(({ track, cached }) => {
const title = cached?.source.title || track.title;
return {
track,
score:
(track.external ? 100 : 0) +
(cached?.source.isDefault ? 35 : 0) +
(cached?.source.isExternal === false ? 25 : 0) +
(cached?.source.isExternal === true ? -10 : 0) +
(cached?.source.isForced ? -25 : 0) +
(isLikelyHearingImpaired(title) ? -10 : 10) +
(/\bdefault\b/i.test(title) ? 3 : 0),
};
})
.flatMap(({ track, cached }) =>
cached
? [
{
track,
score:
(track.external ? 100 : 0) +
scoreJellyfinSource(cached.source, cached.source.title || track.title),
},
]
: [],
)
.sort((a, b) => b.score - a.score);
return ranked[0]?.track.id ?? null;
}
// Ranks Jellyfin subtitle sources by metadata alone, so the preferred track is known before download.
function scoreJellyfinSource(source: JellyfinSubtitleTrack, title: string): number {
return (
(source.isDefault ? 35 : 0) +
(source.isExternal === false ? 25 : 0) +
(source.isExternal === true ? -10 : 0) +
(source.isForced ? -25 : 0) +
(isLikelyHearingImpaired(title) ? -10 : 10) +
(/\bdefault\b/i.test(title) ? 3 : 0)
);
}
function pickPreferredJapaneseSource(
sources: JellyfinSubtitleTrack[],
): JellyfinSubtitleTrack | null {
const ranked = sources
.filter((source) => isJapanese(source.language || '') || isJapanese(source.title || ''))
.map((source) => ({ source, score: scoreJellyfinSource(source, source.title || '') }))
.sort((a, b) => b.score - a.score);
return ranked[0]?.source ?? null;
}
function findMpvTrackIdByPath(tracks: MpvSubtitleTrack[], filePath: string): number | null {
return tracks.find((track) => track.externalFilename === filePath)?.id ?? null;
}
function findCachedTrackForMpvTrackId(
tracks: MpvSubtitleTrack[],
cachedTracks: CachedExternalSubtitleTrack[],
@@ -257,54 +273,6 @@ async function waitForPreferredSubtitleTracks(
return subtitleTracks;
}
async function estimateSubtitleDelayFromReference(
deps: {
loadSubtitleSourceText?: (source: string) => Promise<string>;
logDebug: (message: string, error: unknown) => void;
},
primaryTrack: CachedExternalSubtitleTrack | null,
referenceTrack: CachedExternalSubtitleTrack | null,
): Promise<number | null> {
if (!deps.loadSubtitleSourceText || !primaryTrack || !referenceTrack) {
return null;
}
try {
const [primaryContent, referenceContent] = await Promise.all([
deps.loadSubtitleSourceText(primaryTrack.path),
deps.loadSubtitleSourceText(referenceTrack.path),
]);
const primaryCues = parseSubtitleCues(primaryContent, primaryTrack.path);
const referenceCues = parseSubtitleCues(referenceContent, referenceTrack.path);
return estimateSubtitleTimingOffset(primaryCues, referenceCues)?.offsetSeconds ?? null;
} catch (error) {
deps.logDebug('Failed to auto-align Jellyfin subtitle timing', error);
return null;
}
}
function saveEstimatedSubtitleDelay(
deps: {
saveSubtitleDelay?: (
itemId: string,
streamIndex: number,
delaySeconds: number,
) => boolean | void;
logDebug: (message: string, error: unknown) => void;
},
key: JellyfinSubtitleDelayKey,
delaySeconds: number,
): void {
try {
const saved = deps.saveSubtitleDelay?.(key.itemId, key.streamIndex, delaySeconds);
if (saved === false) {
deps.logDebug('Failed to save Jellyfin auto subtitle delay', key);
}
} catch (error) {
deps.logDebug('Failed to save Jellyfin auto subtitle delay', error);
}
}
export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
listJellyfinSubtitleTracks: (
session: JellyfinSession,
@@ -316,10 +284,6 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
wait: (ms: number) => Promise<void>;
cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise<CachedSubtitleTrack>;
cleanupCachedSubtitles: (dirs: string[]) => void;
getSavedSubtitleDelay?: (itemId: string, streamIndex: number) => number | null;
setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void;
loadSubtitleSourceText?: (source: string) => Promise<string>;
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void;
initSubtitlePrefetch?: (sourcePath: string) => void | Promise<void>;
logDebug: (message: string, error: unknown) => void;
}): PreloadJellyfinExternalSubtitlesHandler {
@@ -342,6 +306,22 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
});
}
function selectJapanesePrimary(
subtitleTracks: MpvSubtitleTrack[],
cachedTracks: CachedExternalSubtitleTrack[],
trackId: number | null,
): void {
if (trackId === null) {
deps.sendMpvCommand(['set_property', 'sid', 'no']);
return;
}
deps.sendMpvCommand(['set_property', 'sid', trackId]);
const selectedCachedTrack = findCachedTrackForMpvTrackId(subtitleTracks, cachedTracks, trackId);
if (selectedCachedTrack) {
startSubtitlePrefetchForCachedTrack(selectedCachedTrack.path);
}
}
function cleanupActiveCache(): void {
const dirs = [...activeCacheDirs];
if (dirs.length === 0) return;
@@ -357,6 +337,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
itemId: string;
}): Promise<void> => {
try {
resetManagedSubtitleDelay();
try {
cleanupActiveCache();
} catch (error) {
@@ -369,8 +350,6 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
);
const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl));
if (externalTracks.length === 0) {
deps.setActiveSubtitleDelayKey?.(null);
resetManagedSubtitleDelay();
return;
}
@@ -380,91 +359,93 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'no']);
await deps.wait(300);
const seenUrls = new Set<string>();
const cachedTracks: CachedExternalSubtitleTrack[] = [];
for (const track of externalTracks) {
if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) {
continue;
}
const uniqueTracks = externalTracks.filter((track) => {
if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) return false;
seenUrls.add(track.deliveryUrl);
const labelBase = (track.title || track.language || '').trim();
const label = labelBase || `Jellyfin Subtitle ${track.index}`;
const cached = await deps.cacheSubtitleTrack(track);
activeCacheDirs.add(cached.cleanupDir);
cachedTracks.push({ ...cached, source: track });
deps.sendMpvCommand(['sub-add', cached.path, 'auto', label, track.language || '']);
}
return true;
});
await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS);
const shouldWaitForExternalJapanese = externalTracks.some(
(track) => isJapanese(track.language || '') || isJapanese(track.title || ''),
);
const subtitleTracks = await waitForPreferredSubtitleTracks(
deps,
shouldWaitForExternalJapanese,
cachedTracks.map((track) => track.path),
);
if (
shouldWaitForExternalJapanese &&
(!subtitleTracks || !hasExternalJapaneseTrack(subtitleTracks))
) {
deps.logDebug('Timed out waiting for Jellyfin Japanese subtitle track', {
itemId: params.itemId,
});
return;
}
const resolvedSubtitleTracks = subtitleTracks ?? [];
const japanesePrimaryId =
pickBestCachedTrackId(resolvedSubtitleTracks, cachedTracks, isJapanese) ??
pickBestTrackId(resolvedSubtitleTracks, isJapanese);
const englishSecondaryId =
pickBestCachedTrackId(resolvedSubtitleTracks, cachedTracks, isEnglish, japanesePrimaryId) ??
pickBestTrackId(resolvedSubtitleTracks, isEnglish, japanesePrimaryId);
if (japanesePrimaryId !== null) {
const selectedCachedTrack = findCachedTrackForMpvTrackId(
resolvedSubtitleTracks,
cachedTracks,
japanesePrimaryId,
);
if (selectedCachedTrack) {
const delayKey = { itemId: params.itemId, streamIndex: selectedCachedTrack.source.index };
deps.setActiveSubtitleDelayKey?.(delayKey);
const savedDelay = deps.getSavedSubtitleDelay?.(delayKey.itemId, delayKey.streamIndex);
if (typeof savedDelay === 'number' && Number.isFinite(savedDelay)) {
deps.sendMpvCommand(['set_property', 'sub-delay', savedDelay]);
} else {
const referenceCachedTrack = findCachedTrackForMpvTrackId(
resolvedSubtitleTracks,
cachedTracks,
englishSecondaryId,
);
const estimatedDelay = await estimateSubtitleDelayFromReference(
deps,
selectedCachedTrack,
referenceCachedTrack,
);
if (estimatedDelay !== null) {
deps.sendMpvCommand(['set_property', 'sub-delay', estimatedDelay]);
saveEstimatedSubtitleDelay(deps, delayKey, estimatedDelay);
} else {
resetManagedSubtitleDelay();
// Download every track at once and add each to mpv as soon as it lands. Jellyfin has
// to extract embedded tracks from the container, so one slow track must not hold up
// the Japanese primary that annotations depend on. A failed track is skipped rather
// than failing the whole preload, so the remaining tracks still get selected.
const cachedTracks: CachedExternalSubtitleTrack[] = [];
const downloads = new Map(
uniqueTracks.map((track) => [
track,
(async (): Promise<CachedExternalSubtitleTrack | null> => {
const labelBase = (track.title || track.language || '').trim();
const label = labelBase || `Jellyfin Subtitle ${track.index}`;
let cached: CachedExternalSubtitleTrack;
try {
cached = { ...(await deps.cacheSubtitleTrack(track)), source: track };
} catch (error) {
deps.logDebug(`Failed to download Jellyfin subtitle track ${track.index}`, error);
return null;
}
}
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
startSubtitlePrefetchForCachedTrack(selectedCachedTrack.path);
} else {
deps.setActiveSubtitleDelayKey?.(null);
resetManagedSubtitleDelay();
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
}
} else {
deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.setActiveSubtitleDelayKey?.(null);
resetManagedSubtitleDelay();
}
activeCacheDirs.add(cached.cleanupDir);
cachedTracks.push(cached);
deps.sendMpvCommand(['sub-add', cached.path, 'auto', label, track.language || '']);
return cached;
})(),
]),
);
const allDownloads = Promise.all(downloads.values());
if (englishSecondaryId !== null) {
deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]);
try {
let subtitleTracks: MpvSubtitleTrack[] = [];
let japanesePrimaryId: number | null | undefined;
const preferredJapaneseSource = pickPreferredJapaneseSource(uniqueTracks);
const preferredJapanese = preferredJapaneseSource
? await downloads.get(preferredJapaneseSource)
: null;
if (preferredJapanese) {
await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS);
subtitleTracks =
(await waitForPreferredSubtitleTracks(deps, true, [preferredJapanese.path])) ?? [];
if (!hasExternalJapaneseTrack(subtitleTracks)) {
deps.logDebug('Timed out waiting for Jellyfin Japanese subtitle track', {
itemId: params.itemId,
});
return;
}
// Only commit early to the preferred track. If mpv has not listed it yet, leave the
// choice to the full ranking below instead of locking in a lower-ranked fallback.
const preferredJapaneseTrackId = findMpvTrackIdByPath(
subtitleTracks,
preferredJapanese.path,
);
if (preferredJapaneseTrackId !== null) {
japanesePrimaryId = preferredJapaneseTrackId;
selectJapanesePrimary(subtitleTracks, cachedTracks, japanesePrimaryId);
}
}
await allDownloads;
const cachedPaths = cachedTracks.map((track) => track.path);
if (!hasExpectedExternalSubtitleTracks(subtitleTracks, cachedPaths)) {
await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS);
subtitleTracks = (await waitForPreferredSubtitleTracks(deps, false, cachedPaths)) ?? [];
}
if (japanesePrimaryId === undefined) {
japanesePrimaryId =
pickBestCachedTrackId(subtitleTracks, cachedTracks, isJapanese) ??
pickBestTrackId(subtitleTracks, isJapanese);
selectJapanesePrimary(subtitleTracks, cachedTracks, japanesePrimaryId);
}
const englishSecondaryId =
pickBestCachedTrackId(subtitleTracks, cachedTracks, isEnglish, japanesePrimaryId) ??
pickBestTrackId(subtitleTracks, isEnglish, japanesePrimaryId);
if (englishSecondaryId !== null) {
deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]);
}
} finally {
// Keep this run in the queue until every download has registered its cache dir, so
// the next run's cleanup sees them all.
await allDownloads;
}
} catch (error) {
deps.logDebug('Failed to preload Jellyfin external subtitles', error);
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { test } from 'node:test';
import { createLinuxOverlayModeRuntime } from './linux-overlay-mode-runtime';
class TestWindow extends EventEmitter {
destroyed = false;
hidden = false;
isDestroyed() {
return this.destroyed;
}
hide() {
this.hidden = true;
}
destroy() {
this.destroyed = true;
}
finishClose() {
this.emit('closed');
}
}
function fixture() {
const initial = new TestWindow();
const state: { window: TestWindow | null; visible: boolean; creates: number; refreshes: number } =
{
window: initial,
visible: true,
creates: 0,
refreshes: 0,
};
const runtime = createLinuxOverlayModeRuntime({
isEnabled: () => true,
isVisible: () => state.visible,
getWindow: () => state.window,
clearWindow: () => {
state.window = null;
},
createWindow: () => {
state.creates += 1;
state.window = new TestWindow();
},
refreshWindow: () => {
state.refreshes += 1;
},
now: () => 42,
logDebug: () => {},
});
return { initial, state, runtime };
}
test('Linux mode transition waits for close before replacing and refreshing the window', () => {
const { initial, state, runtime } = fixture();
runtime.ownerBindingKey = 'old-owner';
runtime.sync(true);
assert.equal(runtime.mode, 'fullscreen-override');
assert.equal(runtime.fullscreenChangedAtMs, 42);
assert.equal(runtime.ownerBindingKey, null);
assert.equal(initial.hidden, true);
assert.equal(state.creates, 0);
initial.finishClose();
assert.equal(state.creates, 1);
assert.equal(state.refreshes, 1);
runtime.sync(true);
assert.equal(state.creates, 1);
});
test('an older close callback cannot clear or replace a newer overlay', () => {
const { initial, state, runtime } = fixture();
runtime.sync(true);
runtime.sync(false);
const replacement = state.window;
assert.equal(state.creates, 1);
initial.finishClose();
assert.equal(state.window, replacement);
assert.equal(state.creates, 1);
assert.equal(runtime.mode, 'managed');
});
test('hiding or cancelling a transition prevents delayed window creation', () => {
for (const cancel of [false, true]) {
const { initial, state, runtime } = fixture();
runtime.sync(true);
if (cancel) runtime.cancelPendingTransition();
else state.visible = false;
initial.finishClose();
assert.equal(state.creates, 0);
assert.equal(state.window, null);
state.visible = true;
runtime.sync(true);
assert.equal(state.creates, 1);
}
});
@@ -0,0 +1,94 @@
import type { BrowserWindow } from 'electron';
import {
resolveLinuxVisibleOverlayWindowModeAction,
type LinuxVisibleOverlayWindowMode,
} from './linux-visible-overlay-window-mode';
type OverlayWindow = Pick<BrowserWindow, 'isDestroyed' | 'hide' | 'destroy'> & {
once: (event: 'closed', listener: () => void) => unknown;
};
export function createLinuxOverlayModeRuntime<Window extends OverlayWindow>(deps: {
isEnabled: () => boolean;
isVisible: () => boolean;
getWindow: () => Window | null;
clearWindow: () => void;
createWindow: () => void;
refreshWindow: () => void;
now: () => number;
logDebug: (message: string) => void;
}) {
let mode: LinuxVisibleOverlayWindowMode = 'managed';
let fullscreen = false;
let fullscreenChangedAtMs = 0;
let ownerBindingKey: string | null = null;
let generation = 0;
function createWindowForMode(token: number, nextFullscreen: boolean): void {
if (token !== generation || !deps.isVisible()) return;
const existing = deps.getWindow();
if (existing && !existing.isDestroyed()) return;
deps.createWindow();
deps.refreshWindow();
deps.logDebug(
`Switched Linux visible overlay window mode to ${mode} for mpv fullscreen=${nextFullscreen}`,
);
}
function sync(nextFullscreen: boolean): void {
if (!deps.isEnabled()) return;
if (fullscreen !== nextFullscreen) fullscreenChangedAtMs = deps.now();
fullscreen = nextFullscreen;
const current = deps.getWindow();
const action = resolveLinuxVisibleOverlayWindowModeAction({
currentMode: mode,
fullscreen,
hasLiveWindow: Boolean(current && !current.isDestroyed()),
visibleOverlayVisible: deps.isVisible(),
});
mode = action.nextMode;
ownerBindingKey = null;
const token = ++generation;
if (!action.shouldCreateWindow && !action.shouldDestroyCurrentWindow) return;
if (action.shouldDestroyCurrentWindow && current && !current.isDestroyed()) {
current.once('closed', () => {
if (deps.getWindow() === current) deps.clearWindow();
if (action.createWindowTiming === 'after-current-destroyed') {
createWindowForMode(token, nextFullscreen);
}
});
current.hide();
current.destroy();
}
if (!action.shouldCreateWindow) {
deps.logDebug(
`Recorded Linux visible overlay window mode ${action.nextMode} for hidden mpv fullscreen=${fullscreen}`,
);
return;
}
if (action.createWindowTiming === 'now') createWindowForMode(token, nextFullscreen);
}
return {
get mode() {
return mode;
},
get fullscreen() {
return fullscreen;
},
get fullscreenChangedAtMs() {
return fullscreenChangedAtMs;
},
get ownerBindingKey() {
return ownerBindingKey;
},
set ownerBindingKey(key: string | null) {
ownerBindingKey = key;
},
sync,
cancelPendingTransition: () => {
generation += 1;
},
};
}
@@ -8,6 +8,18 @@ import {
resolveManagedLinuxRuntimePluginPaths,
} from './linux-runtime-plugin-assets';
const THUMBNAILER_RELATIVE_PATH = path.join(
'thumbnailers',
'subminer-ffmpegthumbnailer.thumbnailer',
);
function writeThumbnailer(rootDir: string, content = '[Thumbnailer Entry]\n'): string {
const thumbnailerPath = path.join(rootDir, THUMBNAILER_RELATIVE_PATH);
fs.mkdirSync(path.dirname(thumbnailerPath), { recursive: true });
fs.writeFileSync(thumbnailerPath, content);
return thumbnailerPath;
}
async function withTempDir<T>(fn: (dir: string) => Promise<T> | T): Promise<T> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-linux-plugin-assets-test-'));
try {
@@ -48,6 +60,7 @@ test('resolveManagedLinuxRuntimePluginPaths resolves XDG data target paths', ()
pluginEntrypointPath: '/tmp/xdg-data/SubMiner/plugin/subminer/main.lua',
pluginConfigPath: '/tmp/xdg-data/SubMiner/plugin/subminer.conf',
themePath: '/tmp/xdg-data/SubMiner/themes/subminer.rasi',
thumbnailerPath: '/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
});
});
@@ -79,6 +92,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and ro
await withTempDir(async (tempDir) => {
const sourceRoot = path.join(tempDir, 'source', 'plugin');
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
const targetRoot = path.join(tempDir, 'xdg-data', 'SubMiner', 'plugin');
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
@@ -94,6 +108,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and ro
pluginDirSource: path.join(sourceRoot, 'subminer'),
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
themeSourcePath,
thumbnailerSourcePath,
}),
});
@@ -117,6 +132,19 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and ro
),
'/* theme */\n',
);
assert.equal(
fs.readFileSync(
path.join(
tempDir,
'xdg-data',
'SubMiner',
'thumbnailers',
'subminer-ffmpegthumbnailer.thumbnailer',
),
'utf8',
),
'[Thumbnailer Entry]\n',
);
});
});
@@ -124,6 +152,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets a
await withTempDir(async (tempDir) => {
const sourceRoot = path.join(tempDir, 'source', 'plugin');
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
const xdgDataHome = path.join(tempDir, 'xdg-data');
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
@@ -143,6 +172,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets a
pluginDirSource: path.join(sourceRoot, 'subminer'),
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
themeSourcePath,
thumbnailerSourcePath,
}),
});
@@ -169,6 +199,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets a
test('ensureLinuxRuntimePluginAssets installs managed theme without resolving plugin sources when plugin assets already exist', async () => {
await withTempDir(async (tempDir) => {
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
const xdgDataHome = path.join(tempDir, 'xdg-data');
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
@@ -183,6 +214,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme without resolving pl
xdgDataHome,
resolveBundledAssets: () => ({
themeSourcePath,
thumbnailerSourcePath,
}),
});
@@ -250,6 +282,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin assets without reso
path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'),
'/* existing theme */\n',
);
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
const result = await ensureLinuxRuntimePluginAssets({
platform: 'linux',
@@ -258,6 +291,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin assets without reso
resolveBundledAssets: () => ({
pluginDirSource: path.join(sourceRoot, 'subminer'),
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
thumbnailerSourcePath,
}),
});
@@ -332,6 +366,7 @@ test('ensureLinuxRuntimePluginAssets returns already-present when managed assets
path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'),
'/* theme */\n',
);
writeThumbnailer(path.join(xdgDataHome, 'SubMiner'));
const result = await ensureLinuxRuntimePluginAssets({
platform: 'linux',
@@ -369,6 +404,7 @@ test('ensureLinuxRuntimePluginAssets leaves no final target tree on failed insta
await withTempDir(async (tempDir) => {
const sourceRoot = path.join(tempDir, 'source', 'plugin');
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
const xdgDataHome = path.join(tempDir, 'xdg-data');
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
@@ -385,6 +421,7 @@ test('ensureLinuxRuntimePluginAssets leaves no final target tree on failed insta
pluginDirSource: path.join(sourceRoot, 'subminer'),
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
themeSourcePath,
thumbnailerSourcePath,
}),
copyFile: async () => {
throw new Error('copy failed');
@@ -10,6 +10,7 @@ export interface ManagedLinuxRuntimePluginPaths {
pluginEntrypointPath: string;
pluginConfigPath: string;
themePath: string;
thumbnailerPath: string;
}
export interface EnsureLinuxRuntimePluginAssetsResult {
@@ -23,6 +24,7 @@ interface RuntimePluginAssetSources {
pluginDirSource?: string;
pluginConfigSource?: string;
themeSourcePath?: string;
thumbnailerSourcePath?: string;
}
interface RuntimePluginDirentLike {
@@ -72,6 +74,11 @@ export function resolveManagedLinuxRuntimePluginPaths(options: {
pluginEntrypointPath: pathModule.join(pluginDir, 'main.lua'),
pluginConfigPath: pathModule.join(rootDir, 'subminer.conf'),
themePath: pathModule.join(dataDir, 'themes', 'subminer.rasi'),
thumbnailerPath: pathModule.join(
dataDir,
'thumbnailers',
'subminer-ffmpegthumbnailer.thumbnailer',
),
};
}
@@ -95,11 +102,12 @@ async function copyDirectoryRecursive(
}
}
function resolveBundledThemePath(options: {
function resolveBundledAssetPath(options: {
dirname: string;
appPath: string;
resourcesPath: string;
existsSync: (candidate: string) => boolean;
relativePath: string;
}): string | null {
const roots = [
path.join(options.resourcesPath, 'assets'),
@@ -111,7 +119,7 @@ function resolveBundledThemePath(options: {
];
for (const root of roots) {
const candidate = path.join(root, 'themes', 'subminer.rasi');
const candidate = path.join(root, options.relativePath);
if (options.existsSync(candidate)) return candidate;
}
@@ -129,16 +137,25 @@ function resolveBundledAssetsDefault(
existsSync,
});
const themeSourcePath = resolveBundledThemePath({
const themeSourcePath = resolveBundledAssetPath({
dirname: __dirname,
appPath: process.execPath,
resourcesPath,
existsSync,
relativePath: path.join('themes', 'subminer.rasi'),
});
const thumbnailerSourcePath = resolveBundledAssetPath({
dirname: __dirname,
appPath: process.execPath,
resourcesPath,
existsSync,
relativePath: path.join('thumbnailers', 'subminer-ffmpegthumbnailer.thumbnailer'),
});
return {
...(pluginAssets ?? {}),
...(themeSourcePath ? { themeSourcePath } : {}),
...(thumbnailerSourcePath ? { thumbnailerSourcePath } : {}),
};
}
@@ -178,7 +195,8 @@ export async function ensureLinuxRuntimePluginAssets(
const pluginAssetsExist =
existsSync(managedPaths.pluginEntrypointPath) && existsSync(managedPaths.pluginConfigPath);
const themeExists = existsSync(managedPaths.themePath);
if (pluginAssetsExist && themeExists) {
const thumbnailerExists = existsSync(managedPaths.thumbnailerPath);
if (pluginAssetsExist && themeExists && thumbnailerExists) {
return {
ok: true,
status: 'already-present',
@@ -193,6 +211,7 @@ export async function ensureLinuxRuntimePluginAssets(
const shouldInstallPluginAssets = !pluginAssetsExist;
const shouldInstallTheme = !themeExists;
const shouldInstallThumbnailer = !thumbnailerExists;
if (
shouldInstallPluginAssets &&
(!bundledAssets.pluginDirSource || !bundledAssets.pluginConfigSource)
@@ -210,6 +229,13 @@ export async function ensureLinuxRuntimePluginAssets(
error: 'Bundled Linux runtime theme asset was not found.',
};
}
if (shouldInstallThumbnailer && !bundledAssets.thumbnailerSourcePath) {
return {
ok: false,
status: 'failed',
error: 'Bundled Linux rofi thumbnailer asset was not found.',
};
}
const stagingSuffix = `${process.pid}-${Date.now()}`;
const stagedPluginDir = pathModule.join(managedPaths.rootDir, `.subminer-stage-${stagingSuffix}`);
@@ -221,9 +247,14 @@ export async function ensureLinuxRuntimePluginAssets(
pathModule.dirname(managedPaths.themePath),
`.subminer.rasi-stage-${stagingSuffix}`,
);
const stagedThumbnailerPath = pathModule.join(
pathModule.dirname(managedPaths.thumbnailerPath),
`.subminer-ffmpegthumbnailer.thumbnailer-stage-${stagingSuffix}`,
);
let pluginDirInstalled = false;
let pluginConfigInstalled = false;
let themeInstalled = false;
let thumbnailerInstalled = false;
try {
if (shouldInstallPluginAssets) {
@@ -249,6 +280,14 @@ export async function ensureLinuxRuntimePluginAssets(
await mkdir(pathModule.dirname(managedPaths.themePath), { recursive: true });
await copyFile(themeSourcePath, stagedThemePath);
}
if (shouldInstallThumbnailer) {
const thumbnailerSourcePath = bundledAssets.thumbnailerSourcePath;
if (!thumbnailerSourcePath) {
throw new Error('Bundled Linux rofi thumbnailer asset was not found.');
}
await mkdir(pathModule.dirname(managedPaths.thumbnailerPath), { recursive: true });
await copyFile(thumbnailerSourcePath, stagedThumbnailerPath);
}
if (shouldInstallPluginAssets) {
await rm(managedPaths.pluginDir, { recursive: true, force: true });
await rm(managedPaths.pluginConfigPath, { force: true });
@@ -262,6 +301,11 @@ export async function ensureLinuxRuntimePluginAssets(
await rename(stagedThemePath, managedPaths.themePath);
themeInstalled = true;
}
if (shouldInstallThumbnailer) {
await rm(managedPaths.thumbnailerPath, { force: true });
await rename(stagedThumbnailerPath, managedPaths.thumbnailerPath);
thumbnailerInstalled = true;
}
return {
ok: true,
@@ -278,9 +322,13 @@ export async function ensureLinuxRuntimePluginAssets(
if (themeInstalled) {
await rm(managedPaths.themePath, { force: true }).catch(() => {});
}
if (thumbnailerInstalled) {
await rm(managedPaths.thumbnailerPath, { force: true }).catch(() => {});
}
await rm(stagedPluginDir, { recursive: true, force: true }).catch(() => {});
await rm(stagedPluginConfigPath, { force: true }).catch(() => {});
await rm(stagedThemePath, { force: true }).catch(() => {});
await rm(stagedThumbnailerPath, { force: true }).catch(() => {});
return {
ok: false,
status: 'failed',
+412
View File
@@ -0,0 +1,412 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import test from 'node:test';
import {
createUpdateStateStore,
takePendingLauncherMigrationPath,
type UpdateState,
} from './update/update-service';
import {
detectBun,
installBun,
installLauncher,
refreshManagedCommandLineLauncher,
} from './command-line-launcher';
import {
cleanupOldWindowsManagedRuntimes,
MANAGED_LAUNCHER_MARKER,
managedLauncherContent,
managedLauncherPaths,
stageManagedLauncher,
windowsManagedRuntimePaths,
} from './managed-launcher';
function workspace(t: test.TestContext) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "subminer bundled bun's "));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
return root;
}
test('a missing packaged Bun never falls back to system Bun or runs an installer', async () => {
let calls = 0;
const options = {
bundledBunPath: '/missing/private/bun',
env: { PATH: path.dirname(process.execPath) },
existsSync: (candidate: string) => candidate === process.execPath,
runCommand: async () => {
calls += 1;
return { exitCode: 0, stdout: '1.3.5', stderr: '' };
},
};
for (const snapshot of [await detectBun(options), await installBun(options)]) {
assert.equal(snapshot.status, 'missing');
assert.equal(snapshot.installCommand, null);
assert.match(snapshot.message ?? '', /Reinstall SubMiner/);
}
assert.equal(calls, 0);
});
test('packaged POSIX launcher works without Bun on PATH and survives AppImage unmount', async (t) => {
if (process.platform !== 'linux') return;
const root = workspace(t);
const resources = path.join(root, 'mounted resources');
const bin = path.join(root, 'home', '.local', 'bin');
fs.mkdirSync(resources, { recursive: true });
fs.mkdirSync(bin, { recursive: true });
const launcherResourcePath = path.join(resources, 'subminer');
const bundledBunPath = path.join(resources, 'bun');
fs.symlinkSync(process.execPath, bundledBunPath);
fs.writeFileSync(
launcherResourcePath,
'console.log(JSON.stringify({args:process.argv.slice(2),app:process.env.SUBMINER_BINARY_PATH,managed:process.env.SUBMINER_MANAGED_LAUNCHER}));',
);
const appPath = path.join(root, 'SubMiner.AppImage');
fs.writeFileSync(appPath, '#!/bin/sh\nexit 73\n', { mode: 0o755 });
const options = {
platform: process.platform,
homeDir: path.join(root, 'home'),
env: {
HOME: path.join(root, 'home'),
PATH: bin,
XDG_DATA_HOME: path.join(root, 'data'),
APPIMAGE: appPath,
},
appExePath: path.join(resources, 'SubMiner'),
appVersion: '1.0.0',
bundledBunPath,
launcherResourcePath,
};
const installed = await installLauncher(options);
assert.equal(installed.status, 'ready', installed.message ?? 'install failed');
assert.match(fs.readFileSync(path.join(bin, 'subminer'), 'utf8'), /SubMiner managed launcher/);
fs.renameSync(resources, path.join(root, 'unmounted'));
const args = ['file with spaces.mkv', "single'quote", '$HOME', '$(touch nope)', '日本語'];
const result = spawnSync(path.join(bin, 'subminer'), args, {
env: options.env,
encoding: 'utf8',
timeout: 15000,
});
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), { args, app: appPath, managed: '1' });
});
test('setup can install into a new user bin despite an empty GUI PATH', async (t) => {
if (process.platform === 'win32') return;
const root = workspace(t);
const appPath = path.join(root, 'SubMiner.app', 'Contents', 'MacOS', 'SubMiner');
const resources = path.join(root, 'SubMiner.app', 'Contents', 'Resources');
fs.mkdirSync(path.dirname(appPath), { recursive: true });
fs.mkdirSync(path.join(resources, 'bun'), { recursive: true });
fs.mkdirSync(path.join(resources, 'launcher'), { recursive: true });
fs.writeFileSync(appPath, '#!/bin/sh\nexit 73\n', { mode: 0o755 });
fs.symlinkSync(process.execPath, path.join(resources, 'bun', 'bun'));
const script = path.join(resources, 'launcher', 'subminer.js');
fs.writeFileSync(script, 'console.log("help");');
const snapshot = await installLauncher({
platform: 'darwin',
homeDir: root,
env: { PATH: '' },
appExePath: appPath,
bundledBunPath: process.execPath,
launcherResourcePath: script,
});
assert.equal(snapshot.status, 'not_on_path', snapshot.message ?? 'install failed');
assert.equal(snapshot.installPath, path.join(root, '.local', 'bin', 'subminer'));
assert.match(snapshot.message ?? '', /export PATH=/);
const result = spawnSync(snapshot.installPath!, ['--help'], {
env: { PATH: '' },
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr);
});
test('app upgrades refresh payloads, migrate legacy Bun launchers, and preserve custom scripts', async (t) => {
if (process.platform !== 'linux') return;
const root = workspace(t);
const bin = path.join(root, 'bin');
fs.mkdirSync(bin, { recursive: true });
const script = path.join(root, 'resource');
fs.writeFileSync(script, 'console.log("old");');
const appPath = path.join(root, 'SubMiner.AppImage');
fs.writeFileSync(appPath, '#!/bin/sh\nexit 73\n', { mode: 0o755 });
const options = {
platform: process.platform,
homeDir: root,
env: { HOME: root, PATH: bin },
appVersion: '1',
appExePath: appPath,
bundledBunPath: process.execPath,
launcherResourcePath: script,
};
assert.equal((await installLauncher(options)).status, 'ready');
fs.writeFileSync(script, 'console.log("new");');
await refreshManagedCommandLineLauncher({
...options,
env: { HOME: root, PATH: '' },
appVersion: '2',
});
const payload = managedLauncherPaths(options);
assert.equal(fs.readFileSync(payload.scriptPath, 'utf8'), 'console.log("new");');
fs.writeFileSync(path.join(bin, 'subminer'), '#!/usr/bin/env bun\n// SubMiner launcher\n');
await refreshManagedCommandLineLauncher({ ...options, appVersion: '3' });
assert.match(fs.readFileSync(path.join(bin, 'subminer'), 'utf8'), /SubMiner managed launcher/);
const deferred = path.join(root, 'custom', 'subminer');
fs.mkdirSync(path.dirname(deferred));
fs.writeFileSync(deferred, '#!/usr/bin/env bun\n// SubMiner launcher\n');
const unreadable = path.join(root, 'unreadable');
fs.mkdirSync(unreadable);
const acknowledged = await refreshManagedCommandLineLauncher({
...options,
appVersion: '3',
additionalLauncherPaths: [unreadable, deferred],
});
assert.ok(!acknowledged.includes(unreadable));
assert.ok(acknowledged.includes(deferred));
assert.match(fs.readFileSync(deferred, 'utf8'), /SubMiner managed launcher/);
fs.writeFileSync(path.join(bin, 'subminer'), '#!/bin/sh\necho standalone\n');
const customAcknowledged = await refreshManagedCommandLineLauncher({
...options,
appVersion: '4',
});
assert.ok(customAcknowledged.includes(path.join(bin, 'subminer')));
assert.equal(fs.readFileSync(payload.versionPath, 'utf8'), '3');
});
test('Windows startup never rewrites the batch launcher that started the app', async () => {
const programs = 'C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner';
const installPath = 'C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin\\subminer.cmd';
const writes: string[] = [];
let state: UpdateState = { pendingLauncherMigrationPath: installPath };
const store = createUpdateStateStore({
readState: async () => state,
writeState: async (nextState) => {
state = nextState;
},
});
const options = {
platform: 'win32' as const,
localAppData: 'C:\\Users\\tester\\AppData\\Local',
appVersion: '1.2.3',
appExePath: `${programs}\\SubMiner.exe`,
bundledBunPath: `${programs}\\resources\\bun\\bun.exe`,
launcherResourcePath: `${programs}\\resources\\launcher\\subminer.js`,
existsSync: (candidate: string) => !candidate.includes('licenses'),
accessSync: () => {},
mkdirSync: () => undefined,
copyFileSync: () => {},
readFileSync: () =>
managedLauncherContent({ platform: 'win32', appPath: 'D:\\Old\\SubMiner.exe' }),
writeFileSync: (candidate: string) => {
writes.push(candidate);
},
};
await takePendingLauncherMigrationPath(store, async (pendingPath) => {
const acknowledged = await refreshManagedCommandLineLauncher({
...options,
additionalLauncherPaths: pendingPath ? [pendingPath] : [],
env: { SUBMINER_LAUNCHER_PATH: installPath.toUpperCase() },
});
return pendingPath !== undefined && acknowledged.includes(pendingPath);
});
assert.deepEqual(writes, []);
assert.equal(state.pendingLauncherMigrationPath, installPath);
await takePendingLauncherMigrationPath(store, async (pendingPath) => {
const acknowledged = await refreshManagedCommandLineLauncher({
...options,
additionalLauncherPaths: pendingPath ? [pendingPath] : [],
env: {},
});
assert.equal(state.pendingLauncherMigrationPath, installPath);
return pendingPath !== undefined && acknowledged.includes(pendingPath);
});
assert.deepEqual(writes, [installPath]);
assert.equal(state.pendingLauncherMigrationPath, undefined);
});
test('Windows wrapper discovers the configured app and its versioned private runtime', () => {
const content = managedLauncherContent({
platform: 'win32',
appPath: 'C:\\Apps 100% !\\SubMiner.exe',
});
assert.ok(content.includes(MANAGED_LAUNCHER_MARKER));
assert.ok(content.includes('setlocal DisableDelayedExpansion'));
assert.ok(content.includes('set "SUBMINER_BINARY_PATH=C:\\Apps 100%% !\\SubMiner.exe"'));
assert.ok(content.includes('%SUBMINER_RESOURCES_PATH%\\launcher\\version'));
assert.ok(
content.includes(
'set "SUBMINER_BUN_PATH=%LOCALAPPDATA%\\SubMiner\\launcher-runtime\\%SUBMINER_APP_VERSION%\\bun.exe"',
),
);
assert.ok(
content.includes('"%SUBMINER_BUN_PATH%" "%SUBMINER_RESOURCES_PATH%\\launcher\\subminer.js" %*'),
);
assert.ok(content.includes('exit /b %errorlevel%'));
});
test('Windows managed runtime path is absolute, versioned, and injectable', () => {
const paths = windowsManagedRuntimePaths({
platform: 'win32',
localAppData: 'D:\\Profiles\\テスト User\\AppData\\Local',
appVersion: '1.2.3-beta.4',
});
assert.equal(
paths.bunPath,
'D:\\Profiles\\テスト User\\AppData\\Local\\SubMiner\\launcher-runtime\\1.2.3-beta.4\\bun.exe',
);
assert.ok(path.win32.isAbsolute(paths.bunPath));
assert.throws(
() =>
windowsManagedRuntimePaths({
platform: 'win32',
localAppData: 'relative',
appVersion: '1.2.3',
}),
/must be an absolute Windows path/,
);
assert.throws(
() =>
windowsManagedRuntimePaths({
platform: 'win32',
localAppData: 'C:\\Users\\tester\\AppData\\Local',
appVersion: '1:2',
}),
/not a valid directory name/,
);
});
test('Windows managed launcher forwards arguments without a system Bun', async (t) => {
if (process.platform !== 'win32') return;
const root = workspace(t);
const appDirectory = path.join(root, 'Installed App');
const appPath = path.join(appDirectory, 'SubMiner.exe');
const launcherDirectory = path.join(appDirectory, 'resources', 'launcher');
const script = path.join(launcherDirectory, 'subminer.js');
fs.mkdirSync(launcherDirectory, { recursive: true });
fs.copyFileSync(process.execPath, appPath);
fs.writeFileSync(script, 'console.log(JSON.stringify(process.argv.slice(2)));');
fs.writeFileSync(path.join(launcherDirectory, 'version'), '1.0.0');
const options = {
platform: process.platform,
env: { ...process.env, PATH: '', LOCALAPPDATA: root },
bundledBunPath: process.execPath,
launcherResourcePath: script,
appExePath: appPath,
appVersion: '1.0.0',
localAppData: root,
getUserPath: () => '',
setUserPath: () => {},
broadcastEnvironmentChange: () => {},
};
const snapshot = await installLauncher(options);
assert.equal(snapshot.status, 'ready', snapshot.message ?? 'install failed');
const { getRunCommand } = await import('./command-line-launcher-deps');
const args = ['spaces here', 'a&b', 'p%TEMP%q', 'bang!z', 'say "hi"', '日本語'];
const result = await getRunCommand({})(snapshot.installPath!, args, { env: options.env });
assert.equal(result.exitCode, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), args);
});
test('Windows stages a new runtime version while the prior Bun executable is running', async (t) => {
if (process.platform !== 'win32') return;
const root = workspace(t);
const bundledDirectory = path.join(root, 'packaged', 'bun');
const bundledBunPath = path.join(bundledDirectory, 'bun.exe');
const launcherResourcePath = path.join(root, 'packaged', 'launcher', 'subminer');
fs.mkdirSync(path.join(bundledDirectory, 'licenses'), { recursive: true });
fs.mkdirSync(path.dirname(launcherResourcePath), { recursive: true });
fs.copyFileSync(process.execPath, bundledBunPath);
fs.writeFileSync(path.join(bundledDirectory, 'licenses', 'Bun-LICENSE.md'), 'license');
fs.writeFileSync(launcherResourcePath, 'console.log("launcher");');
const first = stageManagedLauncher({
platform: 'win32',
localAppData: root,
appVersion: '1.0.0',
bundledBunPath,
launcherResourcePath,
});
const running = spawn(first.bunPath, ['-e', 'setInterval(() => {}, 1000)']);
await new Promise<void>((resolve, reject) => {
running.once('spawn', resolve);
running.once('error', reject);
});
const expectedSecond = windowsManagedRuntimePaths({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
try {
const second = stageManagedLauncher({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
bundledBunPath,
launcherResourcePath,
});
assert.notEqual(second.bunPath, first.bunPath);
assert.ok(fs.existsSync(second.bunPath));
cleanupOldWindowsManagedRuntimes({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
assert.ok(fs.existsSync(first.bunPath));
assert.ok(fs.existsSync(path.join(path.dirname(first.bunPath), 'licenses', 'Bun-LICENSE.md')));
assert.equal(
fs.readFileSync(
path.join(path.dirname(second.bunPath), 'licenses', 'Bun-LICENSE.md'),
'utf8',
),
'license',
);
} finally {
if (running.exitCode === null) {
const exited = new Promise<void>((resolve) => running.once('exit', () => resolve()));
running.kill();
await exited;
}
}
cleanupOldWindowsManagedRuntimes({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
assert.equal(fs.existsSync(path.dirname(first.bunPath)), false);
assert.ok(fs.existsSync(expectedSecond.bunPath));
});
test('Windows cleanup removes an obsolete runtime with no Bun executable', (t) => {
if (process.platform !== 'win32') return;
const root = workspace(t);
const current = windowsManagedRuntimePaths({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
const obsolete = windowsManagedRuntimePaths({
platform: 'win32',
localAppData: root,
appVersion: '1.0.0',
});
fs.mkdirSync(path.join(path.dirname(obsolete.bunPath), 'licenses'), { recursive: true });
fs.writeFileSync(
path.join(path.dirname(obsolete.bunPath), 'licenses', 'Bun-LICENSE.md'),
'license',
);
fs.mkdirSync(path.dirname(current.bunPath), { recursive: true });
cleanupOldWindowsManagedRuntimes({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
assert.equal(fs.existsSync(path.dirname(obsolete.bunPath)), false);
assert.ok(fs.existsSync(path.dirname(current.bunPath)));
});
+219
View File
@@ -0,0 +1,219 @@
import { randomUUID } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { windowsLauncherBootstrapContent } from './windows-launcher-bootstrap';
import { MANAGED_LAUNCHER_MARKER, posixLauncherBootstrapContent } from './posix-launcher-bootstrap';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
envOf,
existsSyncOf,
pathModuleFor,
platformOf,
type CommonOptions,
type WindowsPathOptions,
} from './command-line-launcher-deps';
export { MANAGED_LAUNCHER_MARKER, shellQuote } from './posix-launcher-bootstrap';
export function isManagedLauncher(content: string): boolean {
const lines = content.split(/\r?\n/, 3);
return (
(lines[0] === '#!/bin/sh' && lines[1] === `# ${MANAGED_LAUNCHER_MARKER}`) ||
(lines[0] === '@echo off' && lines[1] === `rem ${MANAGED_LAUNCHER_MARKER}`)
);
}
export function managedLauncherContent(options: {
platform: NodeJS.Platform;
appPath: string;
}): string {
if (options.platform === 'win32') return windowsLauncherBootstrapContent(options.appPath);
return posixLauncherBootstrapContent(options.appPath);
}
export function managedLauncherPaths(options: CommonOptions) {
const platform = platformOf(options);
const platformPath = pathModuleFor(platform);
const env = envOf(options);
const home = options.homeDir ?? os.homedir();
const dataHome = env.XDG_DATA_HOME;
const directory = platformPath.join(
dataHome && platformPath.isAbsolute(dataHome)
? dataHome
: platformPath.join(home, '.local', 'share'),
'SubMiner',
'launcher',
);
return {
directory,
bunPath: platformPath.join(directory, 'bun'),
scriptPath: platformPath.join(directory, 'subminer'),
versionPath: platformPath.join(directory, 'version'),
fingerprintPath: platformPath.join(directory, 'fingerprint'),
appPathFile: platformPath.join(directory, 'app-path'),
};
}
function absoluteWindowsPath(candidate: string, label: string): string {
const normalized = path.win32.normalize(candidate);
if (!path.win32.isAbsolute(normalized)) {
throw new Error(`${label} must be an absolute Windows path: ${candidate}`);
}
return normalized;
}
export function windowsManagedRuntimePaths(options: CommonOptions & WindowsPathOptions) {
const env = envOf(options);
const userProfile = options.userProfile?.trim() || env.USERPROFILE?.trim() || os.homedir();
const localAppData =
options.localAppData?.trim() ||
env.LOCALAPPDATA?.trim() ||
path.win32.join(userProfile, 'AppData', 'Local');
const rootDirectory = path.win32.join(
absoluteWindowsPath(localAppData, 'Windows local app data directory'),
'SubMiner',
'launcher-runtime',
);
const version = options.appVersion?.trim() || 'development';
if (
version === '.' ||
version === '..' ||
/[<>:"/\\|?*\u0000-\u001f]/.test(version) ||
/[ .]$/.test(version)
) {
throw new Error(`SubMiner version is not a valid directory name: ${version}`);
}
const directory = path.win32.join(rootDirectory, version);
return {
rootDirectory,
directory,
bunPath: path.win32.join(directory, 'bun.exe'),
};
}
export function cleanupOldWindowsManagedRuntimes(
options: CommonOptions & WindowsPathOptions,
): void {
const paths = windowsManagedRuntimePaths(options);
try {
for (const entry of fs.readdirSync(paths.rootDirectory, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === path.win32.basename(paths.directory)) continue;
const oldDirectory = path.win32.join(paths.rootDirectory, entry.name);
try {
fs.rmSync(path.win32.join(oldDirectory, 'bun.exe'), { force: true });
} catch {
continue;
}
try {
fs.rmSync(oldDirectory, { recursive: true, force: true });
} catch {
// Cleanup is best-effort after proving the old runtime is not locked.
}
}
} catch {
// The runtime root can be absent before the first managed launcher install.
}
}
function stageWindowsManagedRuntime(
options: CommonOptions &
WindowsPathOptions & {
bundledBunPath: string;
force?: boolean;
},
): string {
const paths = windowsManagedRuntimePaths(options);
const exists = existsSyncOf(options);
const mkdir = options.mkdirSync ?? fs.mkdirSync;
const copy = options.copyFileSync ?? fs.copyFileSync;
mkdir(paths.directory, { recursive: true });
if (options.force || !exists(paths.bunPath)) {
const stagingPath = `${paths.bunPath}.${randomUUID()}.tmp`;
try {
copy(options.bundledBunPath, stagingPath);
if (exists(paths.bunPath)) fs.rmSync(paths.bunPath, { force: true });
fs.renameSync(stagingPath, paths.bunPath);
} finally {
fs.rmSync(stagingPath, { force: true });
}
}
const packagedLicenses = path.join(path.dirname(options.bundledBunPath), 'licenses');
const cachedLicenses = path.win32.join(paths.directory, 'licenses');
if (exists(packagedLicenses) && (options.force || !exists(cachedLicenses))) {
fs.cpSync(packagedLicenses, cachedLicenses, { recursive: true, force: true });
}
return paths.bunPath;
}
// Keep ephemeral or replaceable executables outside the installed app. Linux
// also copies the script because AppImage resources disappear when the app exits.
export function stageManagedLauncher(
options: CommonOptions &
WindowsPathOptions & {
bundledBunPath: string;
launcherResourcePath: string;
force?: boolean;
},
) {
const platform = platformOf(options);
if (platform === 'win32') {
return {
bunPath: stageWindowsManagedRuntime(options),
scriptPath: options.launcherResourcePath,
};
}
if (platform !== 'linux') {
return { bunPath: options.bundledBunPath, scriptPath: options.launcherResourcePath };
}
const paths = managedLauncherPaths(options);
const exists = existsSyncOf(options);
const read = options.readFileSync ?? fs.readFileSync;
const write = options.writeFileSync ?? fs.writeFileSync;
const copy = options.copyFileSync ?? fs.copyFileSync;
const mkdir = options.mkdirSync ?? fs.mkdirSync;
const chmod = options.chmodSync ?? fs.chmodSync;
const version = options.appVersion ?? 'development';
const appPath = envOf(options).APPIMAGE ?? options.appExePath;
const fingerprint = appPath
? execFileSync('stat', ['-Lc', '%d:%i:%s:%y:%z', '--', appPath], {
encoding: 'utf8',
env: { ...envOf(options), PATH: `/usr/bin:/bin:${envOf(options).PATH ?? ''}` },
}).trim()
: '';
if (
!options.force &&
exists(paths.versionPath) &&
read(paths.versionPath, 'utf8') === version &&
exists(paths.fingerprintPath) &&
read(paths.fingerprintPath, 'utf8') === `${fingerprint}\n` &&
exists(paths.appPathFile) &&
read(paths.appPathFile, 'utf8') === `${appPath ?? ''}\n` &&
exists(paths.bunPath) &&
exists(paths.scriptPath)
)
return paths;
mkdir(paths.directory, { recursive: true });
const staging = fs.mkdtempSync(path.join(paths.directory, '.stage-'));
try {
copy(options.bundledBunPath, path.join(staging, 'bun'));
chmod(path.join(staging, 'bun'), 0o755);
copy(options.launcherResourcePath, path.join(staging, 'subminer'));
const notices = path.join(path.dirname(options.bundledBunPath), 'licenses');
if (exists(notices))
fs.cpSync(notices, path.join(paths.directory, 'licenses'), { recursive: true });
write(path.join(staging, 'version'), version);
write(path.join(staging, 'app-path'), `${appPath ?? ''}\n`);
write(path.join(staging, 'fingerprint'), `${fingerprint}\n`);
for (const name of ['bun', 'subminer', 'version', 'app-path', 'fingerprint']) {
fs.renameSync(path.join(staging, name), path.join(paths.directory, name));
}
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
return paths;
}
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
import type { MediaTimingFrameOptions } from '../../core/services/media-timing-frame';
import {
createMediaTimingReviewRuntime,
type MediaTimingReviewRuntimeDeps,
} from './media-timing-review';
async function start(
options: Partial<MediaTimingReviewRuntimeDeps> = {},
screenshotEnabled = true,
) {
let opened!: (payload: MediaTimingReviewOpenPayload) => void;
const payloadPromise = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
opened = resolve;
});
const frames: MediaTimingFrameOptions[] = [];
const runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video.mkv',
send: () => {},
requestProperty: async (name) => (name === 'duration' ? 100 : true),
}),
getCurrentMediaPath: () => '/video.mkv',
getMpvExecutablePath: () => '',
createPreviewSession: () => ({
start: async () => {},
play: async () => {},
stop: async () => {},
onPlaybackEnded: () => {},
dispose: () => {},
}),
generateWaveform: async () => [0, 1],
resolveVideoSource: async () => ({ path: '/video.mkv' }),
generateFrame: async (options) => {
frames.push(options);
return { dataUrl: 'data:image/jpeg;base64,AA==', timestamp: options.timestamp };
},
openModal: async (payload) => {
opened(payload);
return true;
},
showStatus: () => {},
...options,
});
const decision = runtime.requestReview({
kind: 'word',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: 30,
screenshotEnabled,
});
return { runtime, decision, payload: await payloadPromise, frames };
}
test('review accepts a screenshot outside the audio range and validates media bounds', async () => {
const { runtime, decision, payload, frames } = await start();
for (const timestamp of [NaN, Infinity, -1, 100]) {
assert.equal((await runtime.getFrame({ reviewId: payload.reviewId, timestamp })).ok, false);
assert.equal(
runtime.resolveReview({
reviewId: payload.reviewId,
decision: { action: 'confirm', startTime: 10, endTime: 11, screenshotTime: timestamp },
}).ok,
false,
);
}
assert.equal((await runtime.getFrame({ reviewId: payload.reviewId, timestamp: 13 })).ok, true);
assert.equal(frames[0]?.timestamp, 13);
assert.equal(
runtime.resolveReview({
reviewId: payload.reviewId,
decision: { action: 'confirm', startTime: 10, endTime: 11, screenshotTime: 13 },
}).ok,
true,
);
assert.deepEqual(await decision, {
action: 'confirm',
startTime: 10,
endTime: 11,
screenshotTime: 13,
});
});
test('screenshot preview reuses the remote audio window and its absolute timestamps', async () => {
let downloads = 0;
const remote = 'https://example.test/video';
const { runtime, payload, decision, frames } = await start({
resolveMediaSource: async () => ({ path: remote }),
resolveVideoSource: async () => ({ path: remote }),
acquireMediaWindow: async () => {
downloads += 1;
return {
path: '/window.mkv',
sourcePath: remote,
startTime: 7,
endTime: 15,
audioStreamIndex: null,
media: { path: '/window.mkv', absoluteTimestamps: true },
};
},
});
await runtime.getWaveform({ reviewId: payload.reviewId, startTime: 8, endTime: 14 });
assert.equal((await runtime.getFrame({ reviewId: payload.reviewId, timestamp: 11 })).ok, true);
assert.equal(downloads, 1);
assert.deepEqual(frames[0]?.media, { path: '/window.mkv', absoluteTimestamps: true });
await runtime.dispose();
await decision;
});
test('split video streams never read an audio-only cache as a video source', async () => {
const video = {
path: 'https://example.test/video',
inputOptions: { headers: { 'X-Test': 'value' } },
};
const { runtime, payload, decision, frames } = await start({
resolveMediaSource: async () => ({ path: 'https://example.test/audio' }),
resolveVideoSource: async () => video,
});
await runtime.getFrame({ reviewId: payload.reviewId, timestamp: 11 });
assert.deepEqual(frames[0]?.media, video);
await runtime.dispose();
await decision;
});
test('disabled screenshots and missing video inputs do not trigger extraction', async () => {
for (const [enabled, options] of [
[false, {}],
[true, { resolveVideoSource: async () => null }],
] as const) {
const { runtime, payload, decision, frames } = await start(options, enabled);
assert.equal((await runtime.getFrame({ reviewId: payload.reviewId, timestamp: 11 })).ok, false);
assert.equal(frames.length, 0);
await runtime.dispose();
await decision;
}
});
test('closing a review invalidates in-flight frame results and clears the frame index', async () => {
let finish!: (value: { dataUrl: string; timestamp: number }) => void;
let clearCount = 0;
const { runtime, payload, decision } = await start({
generateFrame: () =>
new Promise((resolve) => {
finish = resolve;
}),
clearFrameCache: () => {
clearCount += 1;
},
});
const frame = runtime.getFrame({ reviewId: payload.reviewId, timestamp: 11 });
await runtime.dispose();
finish({ dataUrl: 'data:image/jpeg;base64,AA==', timestamp: 11 });
assert.equal((await frame).stale, true);
await decision;
assert.equal(clearCount, 1);
});
@@ -0,0 +1,44 @@
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
const MODAL: OverlayHostedModal = 'media-timing-review';
export async function openMediaTimingReviewModal(
deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
},
payload: MediaTimingReviewOpenPayload,
signal?: AbortSignal,
): Promise<boolean> {
return await retryOverlayModalOpen(
{ waitForModalOpen: deps.waitForModalOpen, logWarn: deps.logWarn },
{
modal: MODAL,
signal,
// The review renderer regularly needs more than the 1.5 s the other modals allow; a
// premature retry re-sends the payload and reloads the waveform for nothing.
timeoutMs: 4_000,
retryWarning:
'Media timing review did not acknowledge modal open; retrying the dedicated modal window.',
sendOpen: () =>
openOverlayHostedModal(deps, {
channel: IPC_CHANNELS.event.mediaTimingReviewOpen,
modal: MODAL,
payload,
preferModalWindow: true,
}),
},
);
}
File diff suppressed because it is too large Load Diff
+763
View File
@@ -0,0 +1,763 @@
import { randomUUID } from 'crypto';
import type {
MediaTimingReviewActionResult,
MediaTimingReviewContextLine,
MediaTimingReviewDecision,
MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewRequest,
MediaTimingReviewRequest,
MediaTimingReviewResolveRequest,
MediaTimingReviewFrameRequest,
MediaTimingReviewFrameResult,
MediaTimingReviewWaveformRequest,
MediaTimingReviewWaveformResult,
} from '../../types/anki';
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
import type { MediaTimingFrameOptions } from '../../core/services/media-timing-frame';
import {
isRemoteMediaWindowSourcePath,
type RemoteMediaWindow,
type RemoteMediaWindowRange,
type RemoteMediaWindowSource,
} from '../../core/services/remote-media-window-cache';
import type { MediaInput, MediaInputOptions } from '../../media-input';
const INITIAL_TIMELINE_MARGIN_SECONDS = 2;
const REVIEW_DECISION_TIMEOUT_MS = 5 * 60_000;
const CONTEXT_LINE_LIMIT = 12;
const CONTEXT_LINE_EPSILON_SECONDS = 0.05;
interface ReviewMpvClient {
connected: boolean;
currentVideoPath: string;
currentAudioStreamIndex?: number | null;
requestProperty?: (name: string) => Promise<unknown>;
send: (payload: { command: Array<string | number> }) => void;
}
interface PreviewSession {
start(options: {
mediaPath: string;
executablePath?: string;
audioTrackId?: number;
volume?: number;
absoluteTimestamps?: boolean;
}): Promise<void>;
play(startTime: number, endTime: number): Promise<void>;
stop(): Promise<void>;
/** Fires when the player reaches the end of the clip started by play(). */
onPlaybackEnded(listener: () => void): void;
dispose(): void;
}
interface ReviewMediaSource {
path: string;
inputOptions?: MediaInputOptions;
singleResolvedStream?: boolean;
}
interface ActiveReview {
payload: MediaTimingReviewOpenPayload;
/** What the hidden mpv preview plays when no cached window is available. */
mediaPath: string;
/** What the waveform reads when no cached window is available. */
waveformMedia: MediaInput;
videoSource: ReviewMediaSource | null;
frameInFlight: boolean;
audioStreamIndex?: number;
/** Remote source to download windows of; null for local media or without a cache. */
windowSource: RemoteMediaWindowSource | null;
/** Latest window returned for this review; reused while it still covers the request. */
window: RemoteMediaWindow | null;
windowRequest: (RemoteMediaWindowRange & { promise: Promise<RemoteMediaWindow | null> }) | null;
windowFailed: boolean;
previewOptions: { executablePath?: string; audioTrackId?: number; volume?: number };
preview: { path: string; session: Promise<PreviewSession> } | null;
mpvClient: ReviewMpvClient;
restorePlayback: boolean;
resolve: (decision: MediaTimingReviewDecision) => void;
}
interface ReviewRequestLifecycle {
signal: AbortSignal;
cancelled: Promise<void>;
settled: Promise<void>;
isCancelled(): boolean;
cancel(): void;
markSettled(): void;
}
export interface MediaTimingReviewRuntimeDeps {
getMpvClient: () => ReviewMpvClient | null;
getCurrentMediaPath: () => string | null;
getMpvExecutablePath: () => string;
createPreviewSession: () => PreviewSession;
generateWaveform: (options: SpeechWaveformOptions) => Promise<number[]>;
/** Resolves the FFmpeg-readable stream URL and headers behind the current media path. */
resolveMediaSource?: () => Promise<ReviewMediaSource | null>;
resolveVideoSource?: () => Promise<ReviewMediaSource | null>;
generateFrame?: (
options: MediaTimingFrameOptions,
) => Promise<{ dataUrl: string; timestamp: number }>;
clearFrameCache?: () => void;
/** Downloads (or reuses) a local window of a remote source covering the range. */
acquireMediaWindow?: (
source: RemoteMediaWindowSource,
range: RemoteMediaWindowRange,
) => Promise<RemoteMediaWindow>;
getSubtitleContextLines?: (range: { startTime: number; endTime: number }) => {
previous: MediaTimingReviewContextLine[];
next: MediaTimingReviewContextLine[];
};
decisionTimeoutMs?: number;
openModal: (payload: MediaTimingReviewOpenPayload, signal: AbortSignal) => Promise<boolean>;
/** Tells the modal that the hidden player finished the previewed clip. */
onPreviewEnded?: (reviewId: string) => void;
showStatus: (message: string) => void;
}
function finiteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function booleanProperty(value: unknown): boolean | null {
if (typeof value === 'boolean') return value;
if (value === 'yes' || value === 1) return true;
if (value === 'no' || value === 0) return false;
return null;
}
function createReviewRequestLifecycle(): ReviewRequestLifecycle {
const controller = new AbortController();
let resolveCancellation: (() => void) | null = null;
let resolveSettled: (() => void) | null = null;
const cancellation = new Promise<void>((resolve) => {
resolveCancellation = resolve;
});
const settled = new Promise<void>((resolve) => {
resolveSettled = resolve;
});
return {
signal: controller.signal,
cancelled: cancellation,
settled,
isCancelled: () => controller.signal.aborted,
cancel: () => {
if (controller.signal.aborted) return;
controller.abort();
resolveCancellation?.();
},
markSettled: () => {
resolveSettled?.();
resolveSettled = null;
},
};
}
/**
* Picks the subtitle lines adjacent to the mined range that the review modal can pull
* onto the card. Parsed cues cover both directions; when none are loaded (e.g. the
* active track was never parsed) the timing tracker's history still provides the
* lines that already played, so only "next" is unavailable.
*/
export function collectMediaTimingContextLines(options: {
cues: readonly { text: string; startTime: number; endTime: number }[];
fallbackPrevious?: readonly { displayText: string; startTime: number; endTime: number }[];
startTime: number;
endTime: number;
}): { previous: MediaTimingReviewContextLine[]; next: MediaTimingReviewContextLine[] } {
const usable = options.cues
.filter(
(cue) =>
cue.text.trim().length > 0 &&
Number.isFinite(cue.startTime) &&
Number.isFinite(cue.endTime) &&
cue.endTime > cue.startTime,
)
.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime);
let previous = usable
.filter((cue) => cue.endTime <= options.startTime + CONTEXT_LINE_EPSILON_SECONDS)
.slice(-CONTEXT_LINE_LIMIT)
.map(({ text, startTime, endTime }) => ({ text: text.trim(), startTime, endTime }));
const next = usable
.filter((cue) => cue.startTime >= options.endTime - CONTEXT_LINE_EPSILON_SECONDS)
.slice(0, CONTEXT_LINE_LIMIT)
.map(({ text, startTime, endTime }) => ({ text: text.trim(), startTime, endTime }));
if (previous.length === 0 && options.fallbackPrevious) {
previous = options.fallbackPrevious
.filter(
(entry) =>
entry.displayText.trim().length > 0 &&
Number.isFinite(entry.startTime) &&
Number.isFinite(entry.endTime) &&
entry.endTime > entry.startTime &&
entry.endTime <= options.startTime + CONTEXT_LINE_EPSILON_SECONDS,
)
.slice(-CONTEXT_LINE_LIMIT)
.map((entry) => ({
text: entry.displayText.trim(),
startTime: entry.startTime,
endTime: entry.endTime,
}));
}
return { previous, next };
}
/**
* Result for requests that name a review main has already resolved or disposed (decision
* watchdog, overlay teardown, duplicate modal). The renderer closes on it instead of
* leaving the user with controls that can never succeed.
*/
function staleReviewResult(): MediaTimingReviewActionResult {
return { ok: false, stale: true, message: 'This timing review is no longer active.' };
}
function isValidMediaTimingRange(
payload: MediaTimingReviewOpenPayload,
startTime: number,
endTime: number,
): boolean {
return (
Number.isFinite(startTime) &&
Number.isFinite(endTime) &&
startTime >= 0 &&
endTime > startTime &&
(payload.maxMediaDuration <= 0 || endTime - startTime <= payload.maxMediaDuration + 0.001) &&
(payload.mediaDuration === undefined || endTime <= payload.mediaDuration + 0.001)
);
}
export function buildMediaTimingReviewPayload(
request: MediaTimingReviewRequest,
options: {
reviewId: string;
mediaDuration?: number;
contextLines?: {
previous: MediaTimingReviewContextLine[];
next: MediaTimingReviewContextLine[];
};
},
): MediaTimingReviewOpenPayload {
const duration = finiteNumber(options.mediaDuration);
const maxTime = duration !== null && duration > 0 ? duration : Number.POSITIVE_INFINITY;
const paddedStart = Math.max(0, request.startTime - request.audioPadding);
let paddedEnd = Math.min(maxTime, request.endTime + request.audioPadding);
const maxMediaDuration = Math.max(0, request.maxMediaDuration);
if (maxMediaDuration > 0 && paddedEnd - paddedStart > maxMediaDuration) {
paddedEnd = paddedStart + maxMediaDuration;
}
if (paddedEnd <= paddedStart) {
paddedEnd = Math.min(maxTime, paddedStart + 0.1);
}
const timelineStartTime = Math.max(0, paddedStart - INITIAL_TIMELINE_MARGIN_SECONDS);
const timelineEndTime = Math.max(
paddedEnd,
Math.min(maxTime, paddedEnd + INITIAL_TIMELINE_MARGIN_SECONDS),
);
return {
reviewId: options.reviewId,
kind: request.kind,
text: request.text,
previousLines: options.contextLines?.previous ?? [],
nextLines: options.contextLines?.next ?? [],
...(request.noteId !== undefined ? { noteId: request.noteId } : {}),
originalStartTime: request.startTime,
originalEndTime: request.endTime,
selectionStartTime: paddedStart,
selectionEndTime: paddedEnd,
timelineStartTime,
timelineEndTime,
...(duration !== null && duration > 0 ? { mediaDuration: duration } : {}),
maxMediaDuration,
...(request.screenshotEnabled !== undefined
? { screenshotEnabled: request.screenshotEnabled }
: {}),
};
}
export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDeps) {
let active: ActiveReview | null = null;
let currentRequest: ReviewRequestLifecycle | null = null;
let pendingPauseRestore: ReviewMpvClient | null = null;
function restorePendingPlayback(): void {
const mpvClient = pendingPauseRestore;
pendingPauseRestore = null;
if (mpvClient?.connected) {
mpvClient.send({ command: ['set_property', 'pause', 'no'] });
}
}
function ensureWindow(
review: ActiveReview,
range: RemoteMediaWindowRange,
): Promise<RemoteMediaWindow | null> {
const { windowSource } = review;
if (!windowSource || review.windowFailed || !deps.acquireMediaWindow) {
return Promise.resolve(null);
}
const coversRange = (candidate: RemoteMediaWindowRange): boolean =>
candidate.startTime <= range.startTime && candidate.endTime >= range.endTime;
if (review.window && coversRange(review.window)) return Promise.resolve(review.window);
const inFlight = review.windowRequest;
if (inFlight && coversRange(inFlight)) return inFlight.promise;
const request = {
startTime: range.startTime,
endTime: range.endTime,
promise: Promise.resolve<RemoteMediaWindow | null>(null),
};
request.promise = deps
.acquireMediaWindow(windowSource, { startTime: range.startTime, endTime: range.endTime })
.then((window) => {
review.window = window;
return window;
})
.catch(() => {
// Fall back to the remote source for the rest of this review instead of retrying.
review.windowFailed = true;
return null;
})
.finally(() => {
if (review.windowRequest === request) review.windowRequest = null;
});
review.windowRequest = request;
return request.promise;
}
/**
* Returns the preview player for the range, restarting it when the range needs a
* different file (the first cached window, or a wider one after the timeline grew).
*/
async function previewFor(
review: ActiveReview,
range: RemoteMediaWindowRange,
): Promise<PreviewSession> {
const window = await ensureWindow(review, range);
if (active !== review) {
// The review ended during the download; do not start a player nobody will dispose.
throw new Error('This timing review is no longer active.');
}
const mediaPath = window?.path ?? review.mediaPath;
if (review.preview?.path === mediaPath) return review.preview.session;
const previous = review.preview;
const session = deps.createPreviewSession();
const { audioTrackId, ...previewOptions } = review.previewOptions;
const startSession = async (): Promise<PreviewSession> => {
if (active !== review) {
throw new Error('This timing review is no longer active.');
}
await session.start({
mediaPath,
...previewOptions,
// A cached window keeps one audio stream, so mpv's track id from the source no longer applies.
...(window
? { absoluteTimestamps: true }
: audioTrackId !== undefined
? { audioTrackId }
: {}),
});
return session;
};
const started = Promise.resolve()
.then(startSession)
.catch((error: unknown) => {
session.dispose();
throw error;
});
review.preview = { path: mediaPath, session: started };
session.onPlaybackEnded(() => {
if (active === review && review.preview?.session === started) {
deps.onPreviewEnded?.(review.payload.reviewId);
}
});
void started.catch(() => {});
if (previous) void previous.session.then((old) => old.dispose()).catch(() => {});
return started;
}
async function runReview(
request: MediaTimingReviewRequest,
lifecycle: ReviewRequestLifecycle,
): Promise<MediaTimingReviewDecision> {
const mpvClient = deps.getMpvClient();
const mediaPath =
deps.getCurrentMediaPath()?.trim() || mpvClient?.currentVideoPath?.trim() || '';
if (!mpvClient?.connected || !mediaPath) {
deps.showStatus('Timing review unavailable. Using the original subtitle timing.');
return { action: 'use-original' };
}
const setupPromise = Promise.all([
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
deps.resolveMediaSource?.().catch(() => null) ?? null,
request.screenshotEnabled ? (deps.resolveVideoSource?.().catch(() => null) ?? null) : null,
]);
const setup = await Promise.race([
setupPromise.then((values) => ({ kind: 'ready' as const, values })),
lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })),
]);
if (setup.kind === 'cancelled' || lifecycle.isCancelled()) {
return { action: 'use-original' };
}
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource, videoSource] =
setup.values;
const pauseState = booleanProperty(pauseRaw);
pendingPauseRestore = pauseState === false ? mpvClient : null;
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
if (lifecycle.isCancelled()) {
restorePendingPlayback();
return { action: 'use-original' };
}
let contextLines: ReturnType<NonNullable<typeof deps.getSubtitleContextLines>> | undefined;
try {
contextLines = deps.getSubtitleContextLines?.({
startTime: request.startTime,
endTime: request.endTime,
});
} catch {
contextLines = undefined;
}
const payload = buildMediaTimingReviewPayload(request, {
reviewId: randomUUID(),
mediaDuration: finiteNumber(durationRaw) ?? undefined,
...(contextLines ? { contextLines } : {}),
});
const sourcePath = resolvedSource?.path.trim() || mediaPath;
const inputOptions = resolvedSource?.inputOptions;
const audioStreamIndex =
resolvedSource?.singleResolvedStream || mpvClient.currentAudioStreamIndex == null
? undefined
: mpvClient.currentAudioStreamIndex;
const windowSource: RemoteMediaWindowSource | null =
deps.acquireMediaWindow && isRemoteMediaWindowSourcePath(sourcePath)
? {
path: sourcePath,
...(inputOptions ? { inputOptions } : {}),
audioStreamIndex: audioStreamIndex ?? null,
}
: null;
let resolveDecision!: (decision: MediaTimingReviewDecision) => void;
const decisionPromise = new Promise<MediaTimingReviewDecision>((resolve) => {
resolveDecision = resolve;
});
const review: ActiveReview = {
payload,
mediaPath,
waveformMedia: inputOptions ? { path: sourcePath, inputOptions } : sourcePath,
videoSource,
frameInFlight: false,
...(audioStreamIndex !== undefined ? { audioStreamIndex } : {}),
windowSource,
window: null,
windowRequest: null,
windowFailed: false,
previewOptions: {
executablePath: deps.getMpvExecutablePath(),
audioTrackId: finiteNumber(audioTrackRaw) ?? undefined,
volume: finiteNumber(volumeRaw) ?? undefined,
},
preview: null,
mpvClient,
restorePlayback: pendingPauseRestore === mpvClient,
resolve: resolveDecision,
};
active = review;
pendingPauseRestore = null;
// Download the visible timeline once now; the waveform and preview both wait on it.
void previewFor(review, {
startTime: payload.timelineStartTime,
endTime: payload.timelineEndTime,
}).catch(() => {});
if (lifecycle.isCancelled() || active !== review) {
await cleanupActiveReview(review);
return { action: 'use-original' };
}
const openModal = deps.openModal(payload, lifecycle.signal).catch(() => false);
const openResult = await Promise.race([
openModal.then((opened) => ({ kind: 'opened' as const, opened })),
lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })),
]);
if (openResult.kind === 'cancelled' || lifecycle.isCancelled() || active !== review) {
await cleanupActiveReview(review);
return { action: 'use-original' };
}
const { opened } = openResult;
if (!opened) {
await cleanupActiveReview(review);
deps.showStatus('Timing review could not open. Using the original subtitle timing.');
return { action: 'use-original' };
}
const decisionWatchdog = setTimeout(
() => resolveDecision({ action: 'use-original' }),
Math.max(0, deps.decisionTimeoutMs ?? REVIEW_DECISION_TIMEOUT_MS),
);
let decision: MediaTimingReviewDecision = { action: 'use-original' };
try {
const decisionResult = await Promise.race([
decisionPromise.then((value) => ({ kind: 'decided' as const, value })),
lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })),
]);
if (decisionResult.kind === 'decided') {
decision = decisionResult.value;
}
} finally {
clearTimeout(decisionWatchdog);
}
await cleanupActiveReview(review);
return decision;
}
async function requestReview(
request: MediaTimingReviewRequest,
): Promise<MediaTimingReviewDecision> {
if (active || currentRequest) {
deps.showStatus('Finish the current timing review before mining another card.');
return { action: 'use-original' };
}
const lifecycle = createReviewRequestLifecycle();
currentRequest = lifecycle;
try {
return await runReview(request, lifecycle);
} catch {
await cleanupActiveReview();
restorePendingPlayback();
deps.showStatus('Timing review failed. Using the original subtitle timing.');
return { action: 'use-original' };
} finally {
if (currentRequest === lifecycle) {
currentRequest = null;
}
lifecycle.markSettled();
}
}
async function previewRange(
request: MediaTimingReviewPreviewRequest,
): Promise<MediaTimingReviewActionResult> {
const current = active;
if (!current || request.reviewId !== current.payload.reviewId) {
return staleReviewResult();
}
if (!isValidMediaTimingRange(current.payload, request.startTime, request.endTime)) {
return { ok: false, message: 'The selected preview range is invalid.' };
}
try {
const previewSession = await previewFor(current, request);
if (active !== current) {
return staleReviewResult();
}
await previewSession.play(request.startTime, request.endTime);
// Playback spans the whole clip, so the review can end (watchdog, teardown) while
// it runs; reporting success would leave the modal open on a dead review.
if (active !== current) {
return staleReviewResult();
}
return { ok: true };
} catch (error) {
if (active !== current) {
return staleReviewResult();
}
return {
ok: false,
message: `Audio preview unavailable: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
async function getWaveform(
request: MediaTimingReviewWaveformRequest,
): Promise<MediaTimingReviewWaveformResult> {
const current = active;
if (!current || request.reviewId !== current.payload.reviewId) {
return staleReviewResult();
}
if (
!Number.isFinite(request.startTime) ||
!Number.isFinite(request.endTime) ||
request.startTime < 0 ||
request.endTime <= request.startTime ||
(current.payload.mediaDuration !== undefined &&
request.endTime > current.payload.mediaDuration + 0.001)
) {
return { ok: false, message: 'The waveform range is invalid.' };
}
try {
const window = await ensureWindow(current, request);
if (active !== current) {
return staleReviewResult();
}
const peaks = await deps.generateWaveform({
mediaPath: window?.media ?? current.waveformMedia,
startTime: request.startTime,
endTime: request.endTime,
...(!window && current.audioStreamIndex !== undefined
? { audioStreamIndex: current.audioStreamIndex }
: {}),
});
// ffmpeg decoding runs long enough for the review to end underneath it.
if (active !== current) {
return staleReviewResult();
}
if (peaks.length < 2 || peaks.some((peak) => !Number.isFinite(peak))) {
return { ok: false, message: 'Timing waveform is unavailable.' };
}
return { ok: true, peaks };
} catch {
if (active !== current) {
return staleReviewResult();
}
return { ok: false, message: 'Timing waveform is unavailable.' };
}
}
async function getFrame(
request: MediaTimingReviewFrameRequest,
): Promise<MediaTimingReviewFrameResult> {
const current = active;
if (!current || request.reviewId !== current.payload.reviewId) return staleReviewResult();
if (!current.payload.screenshotEnabled || !deps.generateFrame || !current.videoSource) {
return { ok: false, message: 'Screenshot preview is unavailable for this media.' };
}
if (
!Number.isFinite(request.timestamp) ||
request.timestamp < 0 ||
(current.payload.mediaDuration !== undefined &&
request.timestamp >= current.payload.mediaDuration) ||
(request.direction !== undefined && request.direction !== -1 && request.direction !== 1)
) {
return { ok: false, message: 'The screenshot time is invalid.' };
}
if (current.frameInFlight)
return { ok: false, message: 'A screenshot preview is already loading.' };
current.frameInFlight = true;
try {
// Reuse the audio window only when it contains this same video source (not split streams).
const range = {
startTime: Math.max(0, Math.min(current.payload.timelineStartTime, request.timestamp - 2)),
endTime: Math.min(
current.payload.mediaDuration ?? Infinity,
Math.max(current.payload.timelineEndTime, request.timestamp + 2),
),
};
const window =
current.windowSource?.path === current.videoSource.path
? await ensureWindow(current, range)
: null;
if (active !== current) return staleReviewResult();
const frame = await deps.generateFrame({
media: window?.media ?? current.videoSource,
timestamp: request.timestamp,
...(request.direction !== undefined ? { direction: request.direction } : {}),
});
if (active !== current) return staleReviewResult();
return { ok: true, ...frame };
} catch {
if (active !== current) return staleReviewResult();
return {
ok: false,
message: 'Screenshot preview unavailable. Try another time or reset to the midpoint.',
};
} finally {
current.frameInFlight = false;
}
}
async function stopPreview(reviewId: string): Promise<MediaTimingReviewActionResult> {
const current = active;
if (!current || reviewId !== current.payload.reviewId) {
return staleReviewResult();
}
try {
const previewSession = current.preview ? await current.preview.session : null;
if (active !== current) {
return staleReviewResult();
}
await previewSession?.stop();
if (active !== current) {
return staleReviewResult();
}
return { ok: true };
} catch (error) {
if (active !== current) {
return staleReviewResult();
}
return {
ok: false,
message: `Could not stop preview: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
function resolveReview(request: MediaTimingReviewResolveRequest): MediaTimingReviewActionResult {
const current = active;
if (!current || request.reviewId !== current.payload.reviewId) {
return staleReviewResult();
}
if (request.decision.action === 'confirm') {
const { startTime, endTime, text, screenshotTime } = request.decision;
if (!isValidMediaTimingRange(current.payload, startTime, endTime)) {
return { ok: false, message: 'The selected timing range is invalid.' };
}
if (text !== undefined && (typeof text !== 'string' || text.trim().length === 0)) {
return { ok: false, message: 'The combined sentence text is invalid.' };
}
if (
screenshotTime !== undefined &&
(!current.payload.screenshotEnabled ||
!Number.isFinite(screenshotTime) ||
screenshotTime < 0 ||
(current.payload.mediaDuration !== undefined &&
screenshotTime >= current.payload.mediaDuration))
) {
return { ok: false, message: 'The screenshot time is invalid.' };
}
}
current.resolve(request.decision);
return { ok: true };
}
async function cleanupActiveReview(expected?: ActiveReview): Promise<void> {
const current = active;
if (expected && current !== expected) return;
active = null;
if (!current) return;
deps.clearFrameCache?.();
void current.preview?.session.then((session) => session.dispose()).catch(() => {});
if (current.restorePlayback && current.mpvClient.connected) {
current.mpvClient.send({ command: ['set_property', 'pause', 'no'] });
}
}
async function dispose(): Promise<void> {
const request = currentRequest;
request?.cancel();
active?.resolve({ action: 'use-original' });
await cleanupActiveReview();
restorePendingPlayback();
await request?.settled;
}
return {
requestReview,
previewRange,
getWaveform,
getFrame,
stopPreview,
resolveReview,
dispose,
};
}
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readMpvInputBindings } from './mpv-input-bindings';
test('discovery reads the connected player and preserves configured keys including disabled bindings', async () => {
const client = {
connected: true,
requestProperty: async (name: string) => {
assert.equal(name, 'input-bindings');
return [{ key: 'r', cmd: 'script-binding replay/run', priority: 1 }];
},
};
assert.deepEqual(
await readMpvInputBindings({
getMpvClient: () => client,
getConfiguredKeybindings: () => [{ key: 'Ctrl+KeyR', command: null }],
platform: 'linux',
}),
{ keys: ['r'], blockedKeys: [{ code: 'KeyR', modifiers: ['ctrl'] }] },
);
});
test('discovery safely handles unsupported properties and disconnects during a request', async () => {
const client = {
connected: true,
requestProperty: async (): Promise<unknown> => {
throw new Error('property unavailable');
},
};
const deps = {
getMpvClient: () => client,
getConfiguredKeybindings: () => [],
platform: 'linux',
} satisfies Parameters<typeof readMpvInputBindings>[0];
assert.deepEqual((await readMpvInputBindings(deps)).keys, []);
client.requestProperty = async () => {
client.connected = false;
return [{ key: 'r', cmd: 'seek 5', priority: 1 }];
};
assert.deepEqual((await readMpvInputBindings(deps)).keys, []);
});
+31
View File
@@ -0,0 +1,31 @@
import type { Keybinding } from '../../types';
import { parseSessionBindingKey } from '../../core/services/session-bindings';
import { parseMpvInputBindingKeys } from '../../shared/mpv-input-bindings';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
export async function readMpvInputBindings(deps: {
getMpvClient: () => {
connected: boolean;
requestProperty: (name: string) => Promise<unknown>;
} | null;
getConfiguredKeybindings: () => Keybinding[];
platform: 'darwin' | 'win32' | 'linux';
}): Promise<MpvInputBindingsSnapshot> {
const blockedKeys = deps.getConfiguredKeybindings().flatMap((binding) => {
const { key } = parseSessionBindingKey(binding.key, deps.platform);
return key ? [key] : [];
});
const client = deps.getMpvClient();
if (!client?.connected) return { keys: [], blockedKeys };
try {
const value = await client.requestProperty('input-bindings');
return {
keys:
client === deps.getMpvClient() && client.connected ? parseMpvInputBindingKeys(value) : [],
blockedKeys,
};
} catch {
// Older mpv versions and disconnected sessions retain SubMiner's controls.
return { keys: [], blockedKeys };
}
}
@@ -358,6 +358,30 @@ test('time-pos handler forces Jellyfin progress when mpv position jumps', () =>
]);
});
test('time-pos handler treats an explicit short jump as a seek', () => {
const updateKinds: string[] = [];
let explicitSeekPending = false;
const timeHandler = createHandleMpvTimePosChangeHandler({
recordPlaybackPosition: () => {},
reportJellyfinRemoteProgress: () => {},
refreshDiscordPresence: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
consumeExplicitSeek: () => {
const pending = explicitSeekPending;
explicitSeekPending = false;
return pending;
},
onTimePosUpdate: (_time, kind) => updateKinds.push(kind),
});
timeHandler({ time: 10 });
explicitSeekPending = true;
timeHandler({ time: 11.5 });
timeHandler({ time: 11.6 });
assert.deepEqual(updateKinds, ['initial', 'seek', 'playback']);
});
test('time-pos handler passes fresh playback time to AniList post-watch', async () => {
const watchedSeconds: unknown[] = [];
const timeHandler = createHandleMpvTimePosChangeHandler({
+13 -3
View File
@@ -4,6 +4,8 @@ type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
};
type TimePosUpdateKind = 'initial' | 'playback' | 'seek';
/** Jump size that marks a time-pos change as a seek rather than normal playback. */
export const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
@@ -138,12 +140,20 @@ export function createHandleMpvTimePosChangeHandler(deps: {
refreshDiscordPresence: () => void;
maybeRunAnilistPostWatchUpdate?: (options?: AnilistPostWatchRunOptions) => Promise<void>;
logError?: (message: string, error: unknown) => void;
onTimePosUpdate?: (time: number) => void;
onTimePosUpdate?: (time: number, kind: TimePosUpdateKind) => void;
consumeExplicitSeek?: () => boolean;
}) {
let lastObservedTime: number | null = null;
return ({ time }: { time: number }): void => {
const forceImmediate = isSeekLikeTimeChange(lastObservedTime, time);
const explicitSeek = deps.consumeExplicitSeek?.() ?? false;
const updateKind: TimePosUpdateKind =
lastObservedTime === null
? 'initial'
: explicitSeek || isSeekLikeTimeChange(lastObservedTime, time)
? 'seek'
: 'playback';
const forceImmediate = updateKind === 'seek';
if (Number.isFinite(time)) {
lastObservedTime = time;
}
@@ -153,7 +163,7 @@ export function createHandleMpvTimePosChangeHandler(deps: {
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
deps.logError?.('AniList post-watch update failed unexpectedly', error);
});
deps.onTimePosUpdate?.(time);
deps.onTimePosUpdate?.(time, updateKind);
};
}
@@ -1,10 +1,23 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import { createBindMpvMainEventHandlersHandler } from './mpv-main-event-bindings';
import { resolvePrimarySubtitleText } from './primary-subtitle-text';
test('main mpv event binder wires callbacks through to runtime deps', () => {
const handlers = new Map<string, (payload: unknown) => void>();
const calls: string[] = [];
let currentTime = 0;
const seekLiveText = '少しだけ好きになる\n少しだけ好きになる';
const seekCues = parseSubtitleCues(
[
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,少しだけ好きになる',
'Dialogue: 0,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,少しだけ好きになる',
].join('\n'),
'seek-ending.ass',
);
const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
@@ -27,6 +40,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
calls.push(`post-watch:${options?.watchedSeconds ?? 'none'}`);
},
logSubtitleTimingError: () => calls.push('subtitle-error'),
resolveSubtitleText: (liveText) =>
resolvePrimarySubtitleText({ liveText, currentTimeSec: currentTime, cues: seekCues }),
getCurrentLiveSubtitleText: () => seekLiveText,
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
getImmediateSubtitlePayload: (text) => ({ text, tokens: [] }),
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
@@ -60,6 +76,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
recordMediaDuration: (duration) => calls.push(`duration:${duration}`),
reportJellyfinRemoteProgress: (forceImmediate) =>
calls.push(`progress:${forceImmediate ? 'force' : 'normal'}`),
onTimePosUpdate: (time) => {
currentTime = time;
},
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
updateSubtitleRenderMetrics: () => calls.push('subtitle-metrics'),
@@ -83,7 +102,13 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
handlers.get('media-path-change')?.({ path: '' });
handlers.get('media-title-change')?.({ title: 'Episode 1' });
handlers.get('subtitle-timing')?.({ text: 'timed line', start: 899, end: 901 });
handlers.get('subtitle-change')?.({ text: seekLiveText });
handlers.get('time-pos-change')?.({ time: 90 });
assert.ok(calls.includes('set-sub:少しだけ好きになる'));
handlers.get('time-pos-change')?.({ time: 2.5 });
handlers.get('subtitle-change')?.({ text: seekLiveText });
handlers.get('time-pos-change')?.({ time: 90 });
handlers.get('pause-change')?.({ paused: true });
assert.ok(calls.includes('set-sub:line'));
+11 -1
View File
@@ -44,6 +44,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
setCurrentSubText: (text: string) => void;
resolveSubtitleText?: (text: string) => string;
getCurrentLiveSubtitleText?: () => string;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
emitImmediateSubtitle?: (payload: SubtitleData) => void;
broadcastSubtitle: (payload: SubtitleData) => void;
@@ -78,6 +79,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
recordMediaDuration: (durationSec: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
onTimePosUpdate?: (time: number) => void;
consumeExplicitSeek?: () => boolean;
onFullscreenChange?: (fullscreen: boolean) => void;
recordPauseState: (paused: boolean) => void;
@@ -171,7 +173,15 @@ export function createBindMpvMainEventHandlersHandler(deps: {
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
logError: (message, error) => deps.logSubtitleTimingError(message, error),
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
consumeExplicitSeek: deps.consumeExplicitSeek,
onTimePosUpdate: (time, updateKind) => {
deps.onTimePosUpdate?.(time);
if (updateKind === 'playback') return;
const liveText = deps.getCurrentLiveSubtitleText?.();
if (liveText !== undefined) {
handleMpvSubtitleChange({ text: liveText });
}
},
});
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
recordPauseState: (paused) => deps.recordPauseState(paused),
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseAssCues } from '../../core/services/subtitle-cue-parser';
import { createBuildBindMpvMainEventHandlersMainDepsHandler } from './mpv-main-event-main-deps';
test('mpv main event main deps map app state updates and delegate callbacks', async () => {
@@ -426,6 +427,13 @@ test('canonical ASS cues replace live glyph spam for display, history, and immer
text: '飛び越えてみたくて',
source: 'canonical-ass',
},
{
startTime: 10,
endTime: 12,
text: 'MaidCafeMaidCafe',
source: 'reconstructed-ass',
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
},
],
currentMediaPath: '/video.mkv',
currentSubText: '',
@@ -496,13 +504,25 @@ test('canonical ASS cues replace live glyph spam for display, history, and immer
assert.deepEqual(timing.slice(3), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
assert.equal(immersion.length, 3);
// A jump of exactly the seek threshold counts as a seek, matching the time-pos
// handler's own `>=` boundary.
handlers.onTimePosUpdate?.(4.5);
handlers.onTimePosUpdate?.(2);
// Jumping back to a brief previous line moves time-pos by less than the general
// seek threshold. It is still a backward seek, so the revisited line records
// again; otherwise multi-line copy would keep treating the later line as current.
handlers.onTimePosUpdate?.(3.9);
handlers.onTimePosUpdate?.(2.9);
handlers.recordSubtitleTiming('今', 0.8, 1.5);
assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
// Tiny time-pos jitter is not a seek and must not re-record the line.
handlers.onTimePosUpdate?.(3.0);
handlers.onTimePosUpdate?.(2.9);
handlers.recordSubtitleTiming('今', 0.8, 1.5);
assert.equal(timing.length, 5);
handlers.recordImmersionSubtitleLine('Maid\nCafe', 10, 12);
handlers.recordSubtitleTiming('Maid\nCafe', 10, 12);
assert.equal(immersion.length, 3);
assert.equal(timing.length, 5);
});
test('subtitle-track changes stop stale canonical cues from substituting immediately', () => {
@@ -563,3 +583,156 @@ test('subtitle-track changes stop stale canonical cues from substituting immedia
assert.equal(appState.activeParsedSubtitleSource, null);
assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今\n手にある');
});
test('subtitle recorders drop ASS furigana events the same way the display does', () => {
// Broadcast-caption ASS (Caption2Ass style): furigana are separate half-scale events
// positioned above their base line, and mpv lists them as their own live lines.
const cues = parseAssCues(
[
'[Script Info]',
'PlayResX: 960',
'PlayResY: 540',
'',
'[V4+ Styles]',
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding',
'Style: Default,Yu Gothic,46,&H00FFFFFF,&H000000FF,&H00000000,&H7F000000,1,0,0,0,100,100,4,0,1,2,2,1,0,0,0,1',
'',
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:04:56.26,0:04:59.63,Default,,0000,0000,0000,,{\\pos(472,443)\\fscx50\\fscy50}あくむ',
'Dialogue: 0,0:04:56.26,0:04:59.63,Default,,0000,0000,0000,,{\\pos(172,497)}こんな短時間で{\\fscx50} {\\fscx100}悪夢{\\fscx50} {\\fscx100}見んなよ{\\fscx50}。',
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(172,407)\\fscx50}({\\fscx100}平{\\fscx50}){\\fscx100}暗記教科は{\\fscx50} {\\fscx100}もう',
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(332,443)\\fscx50\\fscy50}かた',
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(412,443)\\fscx50\\fscy50}ぱし',
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(172,497)}とにかく片っ端から覚えるんだよ{\\fscx50}。',
].join('\n'),
);
assert.deepEqual(
cues.map((cue) => cue.text),
[
'こんな短時間で 悪夢 見んなよ。',
'(平)暗記教科は もう',
'とにかく片っ端から覚えるんだよ。',
],
);
const immersion: string[] = [];
const timing: string[] = [];
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: { currentTimePos: 299.7 },
immersionTracker: { recordSubtitleLine: (text: string) => immersion.push(text) },
subtitleTimingTracker: { recordSubtitle: (text: string) => timing.push(text) },
activeParsedSubtitleCues: cues,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
const liveText = '(平)暗記教科は もう\nかた\nぱし\nとにかく片っ端から覚えるんだよ。';
const expected = '(平)暗記教科は もう\n\nとにかく片っ端から覚えるんだよ。';
assert.equal(handlers.resolveSubtitleText?.(liveText), expected);
handlers.recordImmersionSubtitleLine(liveText, 299.63, 303.54);
handlers.recordSubtitleTiming(liveText, 299.63, 303.54);
assert.deepEqual(immersion, [expected]);
assert.deepEqual(timing, [expected]);
});
test('a resolved line survives recording while a fragment grid is on screen', () => {
// Fragment stripping drops every line it can trace back to a cue, and returns nothing
// at all when a fragment grid is nearby. Text the parsed cues already resolved is a
// complete line, not raw mpv output, so it must not be fed through that path.
const immersion: string[] = [];
const timing: string[] = [];
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: { currentTimePos: 3.2 },
immersionTracker: { recordSubtitleLine: (text: string) => immersion.push(text) },
subtitleTimingTracker: { recordSubtitle: (text: string) => timing.push(text) },
activeParsedSubtitleCues: [
{ startTime: 3, endTime: 6, text: '飛び越えてみたくて', source: 'canonical-ass' },
{
startTime: 3,
endTime: 6,
text: 'MaidCafeMaidCafe',
source: 'reconstructed-ass',
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
},
],
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
// The grid fragment beside the lyric keeps canonical substitution from applying, so
// recording falls to the parsed view -- which is where the whole line is recovered.
const liveText = '飛び越え\nMaid';
assert.equal(handlers.resolveSubtitleText?.(liveText), '飛び越えてみたくて');
handlers.recordImmersionSubtitleLine(liveText, 3, 6);
handlers.recordSubtitleTiming(liveText, 3, 6);
assert.deepEqual(immersion, ['飛び越えてみたくて']);
assert.deepEqual(timing, ['飛び越えてみたくて']);
// A spacer event left as literal control debris is not a subtitle line. The display
// drops it, so no recorder may keep it either.
assert.equal(handlers.resolveSubtitleText?.('\\'), '');
handlers.recordImmersionSubtitleLine('\\', 3, 6);
handlers.recordSubtitleTiming('\\', 3, 6);
assert.equal(immersion.length, 1);
assert.equal(timing.length, 1);
});
+28 -12
View File
@@ -1,10 +1,9 @@
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
import { SEEK_LIKE_TIME_DELTA_SECONDS } from './mpv-main-event-actions';
import {
resolveCanonicalPrimarySubtitle,
resolvePrimarySubtitleText,
stripCanonicalFragmentLines,
resolveRecordedPrimarySubtitleText,
} from './primary-subtitle-text';
type AnilistPostWatchRunOptions = {
@@ -21,6 +20,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
overlayRuntimeInitialized: boolean;
mpvClient: {
connected?: boolean;
currentSubText?: string;
currentSecondarySubText?: string;
currentTimePos?: number;
requestProperty?: (name: string) => Promise<unknown>;
@@ -85,6 +85,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
resetAnilistMediaGuessState: () => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
onTimePosUpdate?: (time: number) => void;
consumeExplicitSeek?: () => boolean;
onFullscreenChange?: (fullscreen: boolean) => void;
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
refreshDiscordPresence: () => void;
@@ -113,6 +114,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
// after the change is dropped instead of landing in the next session.
let subtitleSessionEpoch = 0;
let lastTimePosForTimingReset: number | null = null;
// Small margin so time-pos jitter is not mistaken for a backward seek.
const BACKWARD_SEEK_TIMING_RESET_SECONDS = 0.25;
const canonicalCueKey = (cue: SubtitleCue): string =>
`${cue.startTime}|${cue.endTime}|${cue.text}`;
const resetSubtitleDeduplication = (): void => {
@@ -128,10 +131,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
currentTimeSec: startSec,
cues: deps.appState.activeParsedSubtitleCues,
});
// When substitution declined because dialogue shares the screen with a song, record
// the dialogue alone rather than the combined dialogue-plus-fragments stack.
const stripFragmentsForRecording = (liveText: string, startSec: number) =>
stripCanonicalFragmentLines({
// Recorders see the same text the overlay displays: mpv's live `sub-text` lists every
// simultaneously active ASS event, including furigana events the parser folded into
// their base line. Cues the caller already resolved canonically are recorded one by
// one above; everything else goes through the shared recording resolution.
const resolveTextForRecording = (liveText: string, startSec: number): string =>
resolveRecordedPrimarySubtitleText({
liveText,
currentTimeSec: startSec,
cues: deps.appState.activeParsedSubtitleCues,
@@ -160,6 +165,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
cues: deps.appState.activeParsedSubtitleCues,
}),
getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '',
recordImmersionSubtitleLine: (text: string, start: number, end: number) => {
deps.ensureImmersionTrackerInitialized();
const tracker = deps.appState.immersionTracker;
@@ -214,7 +220,10 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
}
return;
}
text = stripFragmentsForRecording(text, start);
text = resolveTextForRecording(text, start);
if (!text.trim()) {
return;
}
if (!immersionLineDedupGate.shouldRecord({ text, startSec: start, endSec: end })) {
return;
}
@@ -225,8 +234,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
const canonical = resolveCanonicalSample(text, start);
if (!canonical) {
const recordableText = resolveTextForRecording(text, start);
if (!recordableText.trim()) {
return;
}
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
stripFragmentsForRecording(text, start),
recordableText,
start,
end,
secondaryText,
@@ -332,14 +345,17 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
},
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
consumeExplicitSeek: deps.consumeExplicitSeek,
onTimePosUpdate: (time: number) => {
// Timing history is a viewing log: after a real backward seek, a rewatched
// canonical line should enter it again. Immersion stats keep their
// once-per-media deduplication and are not reset here.
// Timing history is a viewing log: after any backward seek, a rewatched canonical
// line should enter it again so multi-line copy treats it as the current line.
// Playback never moves time-pos backward on its own, so even a short jump to a
// brief previous line counts. Immersion stats keep their once-per-media
// deduplication and are not reset here.
if (
Number.isFinite(time) &&
lastTimePosForTimingReset !== null &&
time <= lastTimePosForTimingReset - SEEK_LIKE_TIME_DELTA_SECONDS
time <= lastTimePosForTimingReset - BACKWARD_SEEK_TIMING_RESET_SECONDS
) {
recordedTimingCanonicalKeys.clear();
}
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import test from 'node:test';
import { resolveMpvExecutablePath, spawnMpvProcess } from './mpv-process';
const testWindows = process.platform === 'win32' ? test : test.skip;
test('mpv process launcher forwards arguments and environment to the child', async () => {
const child = spawnMpvProcess(
process.execPath,
['-e', 'process.exit(Number(process.env.SUBMINER_TEST_EXIT))'],
{ ...process.env, DISPLAY: '', SUBMINER_TEST_EXIT: '17' },
);
try {
const [code] = await once(child, 'exit');
assert.equal(code, 17);
} finally {
if (child.exitCode === null) child.kill();
}
});
testWindows('managed Windows playback launches the configured executable', async () => {
const executable = resolveMpvExecutablePath(` ${process.execPath} `);
const child = spawnMpvProcess(executable, ['-e', 'process.exit(17)']);
try {
assert.equal(child.spawnfile, process.execPath);
const [code] = await once(child, 'exit');
assert.equal(code, 17);
} finally {
if (child.exitCode === null) child.kill();
}
});
testWindows('managed Windows playback rejects an invalid configured executable', () => {
assert.throws(
() => resolveMpvExecutablePath(`${process.execPath}/missing-mpv.exe`),
/Could not find mpv.exe/,
);
});
+112
View File
@@ -0,0 +1,112 @@
import fs from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import {
MPV_X11_BACKEND_ARGS,
applyX11EnvOverrides,
shouldForceX11WaylandSession,
} from '../../shared/mpv-x11-backend';
export interface WindowsMpvPathDeps {
getEnv: (name: string) => string | undefined;
runWhere: () => { status: number | null; stdout: string; error?: Error };
fileExists: (candidate: string) => boolean;
}
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
function fileExists(candidate: string): boolean {
try {
return fs.statSync(candidate).isFile();
} catch {
return false;
}
}
export function getConfiguredWindowsMpvPathStatus(
configuredMpvPath = '',
exists: (candidate: string) => boolean = fileExists,
): ConfiguredWindowsMpvPathStatus {
const configPath = configuredMpvPath.trim();
if (!configPath) {
return 'blank';
}
return exists(configPath) ? 'configured' : 'invalid';
}
export function createWindowsMpvPathDeps(
overrides: Partial<WindowsMpvPathDeps> = {},
): WindowsMpvPathDeps {
return {
getEnv: overrides.getEnv ?? ((name) => process.env[name]),
fileExists: overrides.fileExists ?? fileExists,
runWhere:
overrides.runWhere ??
(() => {
const result = spawnSync('where.exe', ['mpv.exe'], {
encoding: 'utf8',
windowsHide: true,
});
return {
status: result.status,
stdout: result.stdout ?? '',
error: result.error ?? undefined,
};
}),
};
}
export function resolveWindowsMpvPath(deps: WindowsMpvPathDeps, configuredMpvPath = ''): string {
const configPath = configuredMpvPath.trim();
const configuredPathStatus = getConfiguredWindowsMpvPathStatus(configPath, deps.fileExists);
if (configuredPathStatus === 'configured') {
return configPath;
}
if (configuredPathStatus === 'invalid') {
return '';
}
const envPath = deps.getEnv('SUBMINER_MPV_PATH')?.trim();
if (envPath && deps.fileExists(envPath)) {
return envPath;
}
const whereResult = deps.runWhere();
if (whereResult.status === 0) {
const firstPath = whereResult.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0 && deps.fileExists(line));
if (firstPath) {
return firstPath;
}
}
return '';
}
export function spawnMpvProcess(
executablePath: string,
args: string[],
env: NodeJS.ProcessEnv = process.env,
): ReturnType<typeof spawn> {
const forceX11 = shouldForceX11WaylandSession(env);
return spawn(executablePath, forceX11 ? [...args, ...MPV_X11_BACKEND_ARGS] : args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
env: forceX11 ? applyX11EnvOverrides({ ...env }) : env,
});
}
export function resolveMpvExecutablePath(configuredMpvPath = ''): string {
const executablePath =
process.platform === 'win32'
? resolveWindowsMpvPath(createWindowsMpvPathDeps(), configuredMpvPath)
: 'mpv';
if (!executablePath) {
throw new Error(
'Could not find mpv.exe. Check mpv.executablePath, SUBMINER_MPV_PATH, or PATH.',
);
}
return executablePath;
}
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createRemoteMediaPathDetector } from './network-media-path';
test('remote media detector recognizes mounted network filesystems', async () => {
const detectRemoteMedia = createRemoteMediaPathDetector({
platform: 'darwin',
readMountOutput: async () =>
[
'/dev/disk3s5 on /System/Volumes/Data (apfs, local, journaled)',
'//viewer@media/jellyfin on /Volumes/jellyfin (smbfs, nodev, nosuid)',
].join('\n'),
});
assert.equal(await detectRemoteMedia('/Volumes/jellyfin/movie.mkv'), true);
assert.equal(await detectRemoteMedia('/Volumes/jellyfin-another/movie.mkv'), false);
assert.equal(await detectRemoteMedia('/Users/viewer/movie.mkv'), false);
});
test('remote media detector recognizes Linux network mount output', async () => {
const detectRemoteMedia = createRemoteMediaPathDetector({
platform: 'linux',
readMountOutput: async () =>
'//media/jellyfin on /mnt/Jellyfin\\040Media type cifs (rw,relatime)',
});
assert.equal(await detectRemoteMedia('/mnt/Jellyfin Media/movie.mkv'), true);
});
test('remote media detector shares its mount lookup between concurrent callers', async () => {
let mountReads = 0;
const detectRemoteMedia = createRemoteMediaPathDetector({
platform: 'darwin',
readMountOutput: async () => {
mountReads += 1;
return '//viewer@media/jellyfin on /Volumes/jellyfin (smbfs, nodev, nosuid)';
},
});
const results = await Promise.all(
Array.from({ length: 6 }, () => detectRemoteMedia('/Volumes/jellyfin/movie.mkv')),
);
assert.deepEqual(
results,
Array.from({ length: 6 }, () => true),
);
assert.equal(mountReads, 1);
});
test('remote media detector recognizes URLs and Windows UNC paths without reading mounts', async () => {
let mountReads = 0;
const detectRemoteMedia = createRemoteMediaPathDetector({
platform: 'win32',
readMountOutput: async () => {
mountReads += 1;
return '';
},
});
assert.equal(await detectRemoteMedia('https://media.example/movie.mkv'), true);
assert.equal(await detectRemoteMedia('\\\\media-server\\jellyfin\\movie.mkv'), true);
assert.equal(mountReads, 0);
});
+142
View File
@@ -0,0 +1,142 @@
import { execFile } from 'node:child_process';
import path from 'node:path';
import process from 'node:process';
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
const DEFAULT_MOUNT_CACHE_TTL_MS = 5_000;
const NETWORK_FILESYSTEM_TYPES = new Set([
'9p',
'afpfs',
'cifs',
'davfs',
'davfs2',
'fuse.sshfs',
'nfs',
'nfs4',
'smbfs',
'sshfs',
'webdav',
]);
function isRemoteUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
function decodeMountPath(value: string): string {
return value.replace(/\\([0-7]{3})/g, (_match, digits: string) =>
String.fromCharCode(Number.parseInt(digits, 8)),
);
}
function parseNetworkMountPaths(output: string): string[] {
const networkMountPaths: string[] = [];
for (const line of output.split('\n')) {
const optionsStart = line.lastIndexOf(' (');
if (optionsStart < 0) continue;
let mountDescription = line.slice(0, optionsStart);
const options = line.slice(optionsStart + 2, line.indexOf(')', optionsStart));
const linuxTypeSeparator = mountDescription.lastIndexOf(' type ');
const filesystemType = (
linuxTypeSeparator >= 0
? mountDescription.slice(linuxTypeSeparator + ' type '.length)
: (options.split(',').at(0) ?? '')
)
.trim()
.toLowerCase();
if (!NETWORK_FILESYSTEM_TYPES.has(filesystemType)) continue;
if (linuxTypeSeparator >= 0) {
mountDescription = mountDescription.slice(0, linuxTypeSeparator);
}
const mountSeparator = mountDescription.indexOf(' on ');
if (mountSeparator < 0) continue;
networkMountPaths.push(
path.posix.normalize(decodeMountPath(mountDescription.slice(mountSeparator + 4).trim())),
);
}
return networkMountPaths;
}
function readMountOutput(platform: NodeJS.Platform): Promise<string> {
if (platform === 'win32') return Promise.resolve('');
const command = platform === 'darwin' ? '/sbin/mount' : 'mount';
return new Promise((resolve, reject) => {
execFile(
command,
[],
{ encoding: 'utf8', timeout: 1_000, maxBuffer: 1024 * 1024 },
(error, stdout) => {
if (error) {
reject(error);
return;
}
resolve(stdout);
},
);
});
}
function isPathWithinMount(filePath: string, mountPath: string): boolean {
const relativePath = path.posix.relative(mountPath, filePath);
return (
relativePath === '' ||
(relativePath !== '..' &&
!relativePath.startsWith(`..${path.posix.sep}`) &&
!path.posix.isAbsolute(relativePath))
);
}
export type RemoteMediaPathDetector = (mediaPath: string) => Promise<boolean>;
export function createRemoteMediaPathDetector(
deps: {
platform?: NodeJS.Platform;
readMountOutput?: () => Promise<string>;
now?: () => number;
mountCacheTtlMs?: number;
} = {},
): RemoteMediaPathDetector {
const platform = deps.platform ?? process.platform;
const getMountOutput = deps.readMountOutput ?? (() => readMountOutput(platform));
const now = deps.now ?? Date.now;
const mountCacheTtlMs = deps.mountCacheTtlMs ?? DEFAULT_MOUNT_CACHE_TTL_MS;
let mountCache: { expiresAt: number; networkMountPaths: Promise<readonly string[]> } | undefined;
const getNetworkMountPaths = (): Promise<readonly string[]> => {
const currentTime = now();
if (mountCache && currentTime < mountCache.expiresAt) {
return mountCache.networkMountPaths;
}
const networkMountPaths = getMountOutput()
.then(parseNetworkMountPaths)
.catch(() => []);
mountCache = {
expiresAt: currentTime + mountCacheTtlMs,
networkMountPaths,
};
return networkMountPaths;
};
return async (mediaPath): Promise<boolean> => {
const source = mediaPath.trim();
if (!source) return false;
if (isRemoteUrl(source)) return true;
const filePath = resolveSubtitleSourcePath(source);
if (platform === 'win32') {
return filePath.startsWith('\\\\');
}
if (!path.posix.isAbsolute(filePath)) return false;
const networkMountPaths = await getNetworkMountPaths();
const normalizedPath = path.posix.normalize(filePath);
return networkMountPaths.some((mountPath) => isPathWithinMount(normalizedPath, mountPath));
};
}
@@ -1,6 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { openOverlayHostedModal } from './overlay-hosted-modal-open';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
test('retryOverlayModalOpen skips the first send when already aborted', async () => {
const controller = new AbortController();
controller.abort();
const unexpectedCall = () => assert.fail('aborted open must not send or wait');
assert.equal(
await retryOverlayModalOpen(
{ waitForModalOpen: unexpectedCall, logWarn: unexpectedCall },
{
modal: 'media-timing-review',
timeoutMs: 4_000,
retryWarning: 'retry',
sendOpen: unexpectedCall,
signal: controller.signal,
},
),
false,
);
});
for (const abortOnWait of [1, 2]) {
test(`retryOverlayModalOpen rejects an acknowledgement aborted during wait ${abortOnWait}`, async () => {
const controller = new AbortController();
let waitCalls = 0;
let sendCalls = 0;
const opened = await retryOverlayModalOpen(
{
waitForModalOpen: async () => {
waitCalls += 1;
if (waitCalls === abortOnWait) {
controller.abort();
return true;
}
return false;
},
logWarn: () => {},
},
{
modal: 'media-timing-review',
timeoutMs: 4_000,
retryWarning: 'retry',
sendOpen: () => {
sendCalls += 1;
return true;
},
signal: controller.signal,
},
);
assert.equal(opened, false);
assert.equal(sendCalls, abortOnWait);
assert.equal(waitCalls, abortOnWait);
});
}
test('retryOverlayModalOpen still retries other modals without a signal', async () => {
let sendCalls = 0;
const opened = await retryOverlayModalOpen(
{ waitForModalOpen: async () => sendCalls === 2, logWarn: () => {} },
{
modal: 'runtime-options',
timeoutMs: 1_500,
retryWarning: 'retry',
sendOpen: () => {
sendCalls += 1;
return true;
},
},
);
assert.equal(opened, true);
assert.equal(sendCalls, 2);
});
test('openOverlayHostedModal ensures overlay readiness before sending the open event', () => {
const calls: string[] = [];
@@ -38,20 +38,24 @@ export async function retryOverlayModalOpen(
timeoutMs: number;
retryWarning: string;
sendOpen: () => boolean;
signal?: AbortSignal;
},
): Promise<boolean> {
if (!input.sendOpen()) {
if (input.signal?.aborted || !input.sendOpen()) {
return false;
}
if (await deps.waitForModalOpen(input.modal, input.timeoutMs)) {
const opened = await deps.waitForModalOpen(input.modal, input.timeoutMs);
if (input.signal?.aborted) return false;
if (opened) {
return true;
}
deps.logWarn(input.retryWarning);
if (!input.sendOpen()) {
if (input.signal?.aborted || !input.sendOpen()) {
return false;
}
return await deps.waitForModalOpen(input.modal, input.timeoutMs);
const retryOpened = await deps.waitForModalOpen(input.modal, input.timeoutMs);
return !input.signal?.aborted && retryOpened;
}
@@ -26,6 +26,7 @@ type InitializeOverlayRuntimeCore = (options: {
} | null;
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -33,6 +33,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
getOverlayWindows: () => [],
getResolvedConfig: () => ({}),
showDesktopNotification: () => calls.push('notify'),
showOverlayNotification: () => calls.push('show-overlay'),
dismissOverlayNotification: () => calls.push('dismiss-overlay'),
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
@@ -57,6 +59,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
deps.refreshCurrentSubtitle?.();
deps.syncOverlayShortcuts();
deps.showDesktopNotification('title', {});
deps.showOverlayNotification?.({ title: 'title' });
deps.dismissOverlayNotification?.('notification-id');
const tracker = {
close: () => {},
@@ -73,6 +77,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
'refresh-subtitle',
'sync-shortcuts',
'notify',
'show-overlay',
'dismiss-overlay',
]);
assert.equal(appState.windowTracker, tracker);
assert.deepEqual(appState.ankiIntegration, { id: 'anki' });
@@ -39,6 +39,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath'];
@@ -78,6 +79,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
},
showDesktopNotification: deps.showDesktopNotification,
showOverlayNotification: deps.showOverlayNotification,
dismissOverlayNotification: deps.dismissOverlayNotification,
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
@@ -22,6 +22,8 @@ test('build initialize overlay runtime options maps dependencies', () => {
getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => calls.push('set-anki'),
showDesktopNotification: () => calls.push('notify'),
showOverlayNotification: () => calls.push('show-overlay'),
dismissOverlayNotification: () => calls.push('dismiss-overlay'),
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
@@ -47,6 +49,8 @@ test('build initialize overlay runtime options maps dependencies', () => {
options.setWindowTracker(null);
options.setAnkiIntegration(null);
options.showDesktopNotification('title', {});
options.showOverlayNotification?.({ title: 'title' });
options.dismissOverlayNotification?.('notification-id');
assert.deepEqual(calls, [
'create-main',
@@ -58,5 +62,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
'set-tracker',
'set-anki',
'notify',
'show-overlay',
'dismiss-overlay',
]);
});
@@ -33,6 +33,7 @@ type OverlayRuntimeOptions = {
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -73,6 +74,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -107,6 +109,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
setAnkiIntegration: deps.setAnkiIntegration,
showDesktopNotification: deps.showDesktopNotification,
showOverlayNotification: deps.showOverlayNotification,
dismissOverlayNotification: deps.dismissOverlayNotification,
createFieldGroupingCallback: deps.createFieldGroupingCallback,
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
import { posixLauncherBootstrapContent, shellQuote } from './posix-launcher-bootstrap';
test('downloaded launcher prepares once, survives unmount, and refreshes after app replacement', (t) => {
if (process.platform !== 'linux') return;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "subminer bootstrap's "));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const appPath = path.join(root, 'SubMiner.AppImage');
const resources = path.join(root, 'mount', 'resources');
fs.mkdirSync(path.join(resources, 'launcher'), { recursive: true });
fs.mkdirSync(path.join(resources, 'bun', 'licenses'), { recursive: true });
fs.symlinkSync(process.execPath, path.join(resources, 'bun', 'bun'));
fs.writeFileSync(path.join(resources, 'bun', 'licenses', 'notice'), 'Bun notice');
fs.writeFileSync(path.join(resources, 'launcher', 'version'), '1.0.0\n');
fs.writeFileSync(
path.join(resources, 'launcher', 'prepare.cjs'),
`exports.prepareLauncherRuntime = require(${JSON.stringify(path.join(__dirname, 'prepare-launcher-runtime.ts'))}).prepareLauncherRuntime;`,
);
const script = (version: number) =>
`console.log(JSON.stringify({version:${version},args:process.argv.slice(2)}));`;
fs.writeFileSync(path.join(resources, 'launcher', 'subminer.js'), script(1));
const prepares = path.join(root, 'preparations');
const appContent = `#!/bin/sh\necho prepared >> ${shellQuote(prepares)}\nexport APPDIR=${shellQuote(path.dirname(resources))}\nexec ${shellQuote(process.execPath)} "$@"\n`;
fs.writeFileSync(appPath, appContent, { mode: 0o755 });
const wrapper = path.join(root, 'subminer');
fs.writeFileSync(wrapper, posixLauncherBootstrapContent(), { mode: 0o755 });
const env = { HOME: root, PATH: '', SUBMINER_BINARY_PATH: appPath };
const args = ['space here', "a'b", 'a&b', '$(touch nope)', '日本語'];
const run = (extraEnv = {}) =>
spawnSync(wrapper, args, { env: { ...env, ...extraEnv }, encoding: 'utf8' });
const first = run();
assert.equal(first.status, 0, first.stderr);
assert.deepEqual(JSON.parse(first.stdout), { version: 1, args });
const cache = path.join(root, '.local', 'share', 'SubMiner', 'launcher');
assert.equal(fs.readFileSync(path.join(cache, 'licenses', 'notice'), 'utf8'), 'Bun notice');
fs.renameSync(resources, `${resources}.unmounted`);
const warm = run({ SUBMINER_BINARY_PATH: '' }); // Finds the app recorded by preparation.
assert.equal(warm.status, 0, warm.stderr);
assert.deepEqual(JSON.parse(warm.stdout), { version: 1, args });
assert.equal(fs.readFileSync(prepares, 'utf8'), 'prepared\n');
fs.renameSync(`${resources}.unmounted`, resources);
// Same version and path, new inode: manual replacement must still refresh.
fs.writeFileSync(`${appPath}.new`, appContent, { mode: 0o755 });
fs.renameSync(`${appPath}.new`, appPath);
fs.writeFileSync(path.join(resources, 'launcher', 'subminer.js'), script(2));
const updated = run();
assert.equal(updated.status, 0, updated.stderr);
assert.deepEqual(JSON.parse(updated.stdout), { version: 2, args });
assert.equal(fs.readFileSync(prepares, 'utf8'), 'prepared\nprepared\n');
fs.unlinkSync(path.join(cache, 'bun'));
assert.equal(run().status, 0);
assert.equal(fs.readFileSync(prepares, 'utf8'), 'prepared\nprepared\nprepared\n');
fs.writeFileSync(appPath, '#!/bin/sh\nexit 42\n', { mode: 0o755 });
const failed = run();
assert.equal(failed.status, 42);
assert.equal(failed.stdout, ''); // Never silently execute stale CLI after failed preparation.
});
@@ -0,0 +1,56 @@
export const MANAGED_LAUNCHER_MARKER = 'SubMiner managed launcher (bundled runtime)';
export function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
// Only a missing or stale Linux cache starts Electron in Node mode. Normal
// launches do one stat and execute the cached Bun and matching CLI directly.
export function posixLauncherBootstrapContent(appPath = ''): string {
return `#!/bin/sh
# ${MANAGED_LAUNCHER_MARKER}
export SUBMINER_MANAGED_LAUNCHER=1
export SUBMINER_LAUNCHER_PATH="$0"
subminer_default_app=${shellQuote(appPath)}
case "\${XDG_DATA_HOME:-}" in
/*) subminer_data="$XDG_DATA_HOME" ;;
*) subminer_data="$HOME/.local/share" ;;
esac
subminer_cache="$subminer_data/SubMiner/launcher"
subminer_saved_app=
if [ -f "$subminer_cache/app-path" ]; then
IFS= read -r subminer_saved_app < "$subminer_cache/app-path" || :
fi
subminer_app=
for subminer_candidate in "\${SUBMINER_APPIMAGE_PATH:-}" "\${SUBMINER_BINARY_PATH:-}" "$subminer_default_app" "$subminer_saved_app" "$HOME/.local/bin/SubMiner.AppImage" /opt/SubMiner/SubMiner.AppImage /Applications/SubMiner.app/Contents/MacOS/SubMiner "$HOME/Applications/SubMiner.app/Contents/MacOS/SubMiner"; do
if [ -n "$subminer_candidate" ] && [ -x "$subminer_candidate" ]; then
subminer_app="$subminer_candidate"
break
fi
done
if [ -z "$subminer_app" ]; then
echo 'SubMiner app not found. Install the app or set SUBMINER_BINARY_PATH to its executable.' >&2
exit 1
fi
export SUBMINER_BINARY_PATH="$subminer_app"
case "$subminer_app" in
*/Contents/MacOS/*)
subminer_resources="\${subminer_app%/MacOS/*}/Resources"
if [ ! -x "$subminer_resources/bun/bun" ] || [ ! -f "$subminer_resources/launcher/subminer.js" ]; then
echo 'This launcher requires a SubMiner app with the included Bun runtime. Update SubMiner.' >&2
exit 1
fi
exec "$subminer_resources/bun/bun" "$subminer_resources/launcher/subminer.js" "$@"
;;
esac
subminer_fingerprint=$(PATH="/usr/bin:/bin:$PATH" stat -Lc '%d:%i:%s:%y:%z' -- "$subminer_app") || exit 1
subminer_cached_fingerprint=
if [ -f "$subminer_cache/fingerprint" ]; then
IFS= read -r subminer_cached_fingerprint < "$subminer_cache/fingerprint" || :
fi
if [ "$subminer_app" != "$subminer_saved_app" ] || [ "$subminer_fingerprint" != "$subminer_cached_fingerprint" ] || [ ! -x "$subminer_cache/bun" ] || [ ! -f "$subminer_cache/subminer" ]; then
PATH="/usr/bin:/bin:$PATH" ELECTRON_RUN_AS_NODE=1 "$subminer_app" -e 'const p=require("node:path"); const r=process.env.APPDIR ? p.join(process.env.APPDIR,"resources") : p.join(p.dirname(process.execPath),"resources"); try { require(p.join(r,"launcher/prepare.cjs")).prepareLauncherRuntime({appPath:process.env.SUBMINER_BINARY_PATH,resourcesPath:r}); } catch(e) { console.error("Cannot prepare SubMiner launcher. Update or reinstall the SubMiner app.",e.message); process.exit(1); }' || exit $?
fi
exec "$subminer_cache/bun" "$subminer_cache/subminer" "$@"
`;
}
@@ -0,0 +1,24 @@
import fs from 'node:fs';
import path from 'node:path';
import { cleanupOldWindowsManagedRuntimes, stageManagedLauncher } from './managed-launcher';
// Bundled separately as Node-compatible code so AppImages can prepare their
// runtime without starting Electron's GUI, single-instance lock, or settings.
export function prepareLauncherRuntime(options: { appPath: string; resourcesPath: string }) {
const appVersion = fs
.readFileSync(path.join(options.resourcesPath, 'launcher', 'version'), 'utf8')
.trim();
const payload = stageManagedLauncher({
appExePath: options.appPath,
appVersion,
bundledBunPath: path.join(
options.resourcesPath,
'bun',
process.platform === 'win32' ? 'bun.exe' : 'bun',
),
launcherResourcePath: path.join(options.resourcesPath, 'launcher', 'subminer.js'),
force: process.platform === 'linux',
});
if (process.platform === 'win32') cleanupOldWindowsManagedRuntimes({ appVersion });
return payload;
}
+395 -2
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import {
resolveCanonicalPrimarySubtitle,
resolvePrimarySubtitle,
resolvePrimarySubtitleText,
stripCanonicalFragmentLines,
} from './primary-subtitle-text';
@@ -60,7 +61,85 @@ test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () =
{ startTime: 1, endTime: 3, text: '二行目' },
],
}),
'一行目\n二行目',
'一行目\n\n二行目',
);
});
test('resolvePrimarySubtitleText accounts for live ASS furigana after canonical recovery', () => {
const ass = [
'[Script Info]',
'PlayResY: 540',
'',
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(192,77)}ごめん 結局 ぬれたな。',
'Comment: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,大丈夫。',
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,113)\\fscx50\\fscy50}だいじょうぶ',
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)\\clip(m 1 1)}大丈夫。',
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)\\clip(m 2 2)}大丈夫。',
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)\\clip(m 3 3)}大丈夫。',
].join('\n');
const cues = parseSubtitleCues(ass, 'polar-opposites-s02e08.ass');
assert.equal(
resolvePrimarySubtitleText({
liveText: 'ごめん 結局 ぬれたな。\nだいじょうぶ\n大丈夫。',
currentTimeSec: 159,
cues,
}),
'ごめん 結局 ぬれたな。\n\n大丈夫。',
);
});
test('resolvePrimarySubtitleText removes duplicate lines across multiline parsed cues', () => {
assert.equal(
resolvePrimarySubtitleText({
liveText: 'First line\nSecond line\nFirst line',
currentTimeSec: 2,
cues: [
{ startTime: 1, endTime: 3, text: 'First line\nSecond line' },
{ startTime: 1, endTime: 3, text: 'First line' },
],
}),
'First line\nSecond line',
);
});
test('resolvePrimarySubtitleText removes equivalent full-width duplicate lines', () => {
assert.equal(
resolvePrimarySubtitleText({
liveText: '20分53秒\n20分53秒',
currentTimeSec: 2,
cues: [
{ startTime: 1, endTime: 3, text: '20分53秒' },
{ startTime: 1, endTime: 3, text: '20分53秒' },
],
}),
'20分53秒',
);
});
test('resolvePrimarySubtitleText collapses whitespace variants of one ASS lyric', () => {
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 2,0:00:01.00,0:00:03.00,EDJP,,0,0,0,,少しだけ好きになる',
'Dialogue: 1,0:00:01.00,0:00:03.00,EDJP,,0,0,0,,少しだけ\\h好きになる',
'Dialogue: 0,0:00:01.00,0:00:03.00,EDJP,,0,0,0,,少しだけ 好きになる',
].join('\n');
const cues = parseSubtitleCues(ass, 'polar-opposites-s01e10.ass');
assert.deepEqual(
cues.map((cue) => cue.text),
['少しだけ好きになる', '少しだけ 好きになる', '少しだけ 好きになる'],
);
assert.equal(
resolvePrimarySubtitleText({
liveText: ['少しだけ好きになる', '少しだけ 好きになる', '少しだけ 好きになる'].join('\n'),
currentTimeSec: 2,
cues,
}),
'少しだけ好きになる',
);
});
@@ -128,6 +207,73 @@ test('resolvePrimarySubtitleText keeps concurrent dialogue that is not part of t
assert.equal(text, '普通のセリフ\n今\n手にある');
});
test('resolvePrimarySubtitleText combines parsed dialogue with a reconstructed lyric', () => {
const text = resolvePrimarySubtitleText({
liveText: '普通のセリフ\n今\n今\n手\n手\nにある\nにある',
currentTimeSec: 2,
cues: [
{ startTime: 1, endTime: 3, text: '普通のセリフ' },
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'reconstructed-ass',
},
],
});
assert.equal(text, '普通のセリフ\n\n今 手にある');
});
test('resolvePrimarySubtitleText uses fragment grids only to account for live sign pieces', () => {
const text = resolvePrimarySubtitleText({
liveText: 'Ordinary dialogue\nMaid\nCafe',
currentTimeSec: 2,
cues: [
{ startTime: 1, endTime: 3, text: 'Ordinary dialogue' },
{
startTime: 1,
endTime: 3,
text: 'MaidCafeMaidCafe',
source: 'reconstructed-ass',
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
},
],
});
assert.equal(text, 'Ordinary dialogue');
});
test('resolvePrimarySubtitleText drops malformed ASS control debris from live text', () => {
const cues = parseSubtitleCues(
[
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Visible line',
].join('\n'),
'test.ass',
);
assert.equal(
resolvePrimarySubtitleText({
liveText: 'Visible line\n\\\n{\\fr0',
currentTimeSec: 2,
cues,
}),
'Visible line',
);
});
test('resolvePrimarySubtitleText preserves SRT text that resembles ASS control debris', () => {
const liveText = 'Visible line\n\\\n{\\fr0';
const cues = parseSubtitleCues(
['1', '00:00:01,000 --> 00:00:03,000', liveText].join('\n'),
'test.srt',
);
assert.equal(resolvePrimarySubtitleText({ liveText, currentTimeSec: 2, cues }), liveText);
});
test('resolvePrimarySubtitleText keeps a fresh line starting just after the animation ended', () => {
const text = resolvePrimarySubtitleText({
liveText: '次のセリフ',
@@ -181,7 +327,46 @@ test('resolvePrimarySubtitleText combines simultaneous canonical cues in source
],
});
assert.equal(text, 'first\nsecond');
assert.equal(text, 'first\n\nsecond');
});
test('resolveCanonicalPrimarySubtitle orders active cues from top to bottom', () => {
const resolved = resolveCanonicalPrimarySubtitle({
liveText: 'bottom\ntop',
currentTimeSec: 2,
cues: [
{
startTime: 1,
endTime: 3,
text: 'bottom',
source: 'canonical-ass',
assLayout: { kind: 'source-order', sourceOrder: 1, verticalBand: 'bottom' },
},
{
startTime: 1,
endTime: 3,
text: 'top',
source: 'canonical-ass',
assLayout: { kind: 'source-order', sourceOrder: 0, verticalBand: 'top' },
},
],
});
assert.equal(resolved?.text, 'top\n\nbottom');
});
test('resolvePrimarySubtitleText collapses whitespace variants of a canonical lyric', () => {
assert.equal(
resolvePrimarySubtitleText({
liveText: '少しだけ好きになる\n少しだけ 好きになる',
currentTimeSec: 2,
cues: [
{ startTime: 1, endTime: 3, text: '少しだけ好きになる', source: 'canonical-ass' },
{ startTime: 1, endTime: 3, text: '少しだけ 好きになる', source: 'canonical-ass' },
],
}),
'少しだけ好きになる',
);
});
test('resolveCanonicalPrimarySubtitle covers a nearby generated animation edge', () => {
@@ -344,3 +529,211 @@ test('resolveCanonicalPrimarySubtitle picks the cue its fragments spell, not the
'今 手にある',
);
});
test('resolvePrimarySubtitleText suppresses a live glyph wall when no cues are available', () => {
const wall = [...'wansdumretoikhI'].join('\n');
assert.equal(
resolvePrimarySubtitleText({ liveText: `${wall}\ntai`, currentTimeSec: 1355, cues: null }),
'',
);
});
test('stripCanonicalFragmentLines drops a live glyph wall with no nearby canonical cues', () => {
const wall = [...'wansdumretoikhI'].join('\n');
assert.equal(
stripCanonicalFragmentLines({
liveText: `${wall}\nそれよりも ノート…`,
currentTimeSec: 1355,
cues: [],
}),
'それよりも ノート…',
);
});
test('resolvePrimarySubtitleText keeps a line joining an active cue despite stale time-pos', () => {
// Issue #220: mpv publishes the combined sub-text the moment a joining line's first
// frame renders, while the observed time-pos still sits just before that line's
// start. The joining cue must not be filtered out as inactive.
assert.equal(
resolvePrimarySubtitleText({
liveText: 'Балда! Балда, балда, балда!\nСестренка не может остановиться',
currentTimeSec: 767.78,
cues: [
{ startTime: 767.19, endTime: 772.78, text: 'Балда! Балда, балда, балда!' },
{ startTime: 767.79, endTime: 771.15, text: 'Сестренка не может остановиться' },
],
}),
'Балда! Балда, балда, балда!\n\nСестренка не может остановиться',
);
});
test('resolvePrimarySubtitleText drops a finished lyric whose exit ghosts outlive it beside a raw line', () => {
// The reconstructed lyric ended at 6.0 but its exit ghost glyphs stay in the live
// text until 7.0, while the next authored line is a plain raw event. The retired cue
// must explain the ghost fragments without re-surfacing next to the active line.
const cues = [
{
startTime: 1.0,
endTime: 6.0,
text: 'エネルギーはサイクル',
source: 'reconstructed-ass' as const,
animationStartTime: 0.5,
animationEndTime: 7.0,
assStyle: 'OP - JP',
},
{ startTime: 6.0, endTime: 12.0, text: '象徴的なパレード' },
];
assert.equal(
resolvePrimarySubtitleText({
liveText: 'エ\nネ\nル\nギ\nー\n象徴的なパレード',
currentTimeSec: 6.5,
cues,
}),
'象徴的なパレード',
);
});
test('resolvePrimarySubtitleText stacks simultaneous cues by screen position, not start order', () => {
// A top-anchored lyric and bottom dialogue: mpv draws the lyric above the dialogue for
// the whole overlap. Whichever event started first must not decide the row, or the
// pair swaps every time one side is replaced mid-overlap.
const lyricLayout = { kind: 'source-order', sourceOrder: 0, verticalBand: 'top' } as const;
const dialogueLayout = { kind: 'source-order', sourceOrder: 1, verticalBand: 'bottom' } as const;
const dialogue = {
startTime: 632.2,
endTime: 634.8,
text: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
assLayout: dialogueLayout,
};
// Lyric started before the dialogue...
assert.equal(
resolvePrimarySubtitleText({
liveText: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b\n\u6b4c\u8a5e\uff21',
currentTimeSec: 632.5,
cues: [
{ startTime: 629.5, endTime: 633.5, text: '\u6b4c\u8a5e\uff21', assLayout: lyricLayout },
dialogue,
],
}),
'\u6b4c\u8a5e\uff21\n\n\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
);
// ...and the next lyric starts after it: the rows must not swap.
assert.equal(
resolvePrimarySubtitleText({
liveText: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b\n\u6b4c\u8a5e\uff22',
currentTimeSec: 633.8,
cues: [
dialogue,
{ startTime: 633.5, endTime: 637.0, text: '\u6b4c\u8a5e\uff22', assLayout: lyricLayout },
],
}),
'\u6b4c\u8a5e\uff22\n\n\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
);
});
test('resolvePrimarySubtitleText puts an unreadable placement above bottom dialogue', () => {
// Dialogue is the case that reliably declares a bottom alignment, so a cue whose
// placement could not be read is more often a sign or song line. Keeping dialogue on
// the bottom row means the line worth reading stays where the eye already is.
assert.equal(
resolvePrimarySubtitleText({
liveText: '\u4e0b\u306e\u30bb\u30ea\u30d5\n\u4e0d\u660e\u306a\u884c',
currentTimeSec: 2,
cues: [
{
startTime: 1,
endTime: 3,
text: '\u4e0b\u306e\u30bb\u30ea\u30d5',
assLayout: { kind: 'source-order', sourceOrder: 0, verticalBand: 'bottom' },
},
{
startTime: 1.5,
endTime: 3,
text: '\u4e0d\u660e\u306a\u884c',
assLayout: { kind: 'source-order', sourceOrder: 1 },
},
],
}),
'\u4e0d\u660e\u306a\u884c\n\n\u4e0b\u306e\u30bb\u30ea\u30d5',
);
});
test('resolvePrimarySubtitleText keeps source order when no cue declares a placement', () => {
// SRT and websocket cues carry no layout at all: every cue ties, so the stable sort
// must leave them exactly as the cue list had them.
assert.equal(
resolvePrimarySubtitleText({
liveText: 'First line\nSecond line',
currentTimeSec: 2,
cues: [
{ startTime: 1, endTime: 3, text: 'First line' },
{ startTime: 1.5, endTime: 3, text: 'Second line' },
],
}),
'First line\n\nSecond line',
);
});
test('resolvePrimarySubtitleText publishes a wrapped caption sentence as one cue', () => {
// mpv still reports the two source rows (plus the ruby row) as separate live lines, so
// the merged cue must explain all of them and come back as a single-break line the
// display layer may flatten, not as a two-cue boundary.
const ass = [
'[Script Info]',
'PlayResY: 540',
'',
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:02:42.33,0:02:44.43,Default,,0,0,0,,{\\pos(232,437)\\fscx50}({\\fscx100}東{\\fscx50}){\\fscx100}≪好きだと',
'Dialogue: 0,0:02:42.33,0:02:44.43,Default,,0,0,0,,{\\pos(292,443)\\fscx50\\fscy50}じかく',
'Dialogue: 0,0:02:42.33,0:02:44.43,Default,,0,0,0,,{\\pos(232,497)}自覚してしまったものの➡',
// A second labeled turn, so the script reads as broadcast captions.
'Dialogue: 0,0:02:44.43,0:02:47.37,Default,,0,0,0,,{\\pos(212,497)\\fscx50}({\\fscx100}平{\\fscx50}){\\fscx100}どうした?',
].join('\n');
const cues = parseSubtitleCues(ass, 'polar-opposites-s02e09.ass');
assert.equal(
resolvePrimarySubtitleText({
liveText: '(東)≪好きだと\nじかく\n自覚してしまったものの➡',
currentTimeSec: 163,
cues,
}),
'(東)≪好きだと\n自覚してしまったものの➡',
);
});
test('resolvePrimarySubtitle drops a finished caption row lingering beside a fresh line', () => {
// Broadcast captions give each row its own event, and a row of the previous line can
// outlive its siblings by a frame. mpv's sub-text still lists it, so the mined line
// must come from the parsed cue that is actually running, with that cue's timings.
const ass = [
'[Script Info]',
'PlayResY: 540',
'',
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:14:30.00,0:14:33.00,Default,,0,0,0,,{\\pos(232,437)\\fscx50}({\\fscx100}東{\\fscx50}){\\fscx100}ずっと 言えなかっ',
'Dialogue: 0,0:14:30.00,0:14:33.02,Default,,0,0,0,,{\\pos(232,497)}たが',
'Dialogue: 0,0:14:33.00,0:14:36.00,Default,,0,0,0,,{\\pos(232,437)}⸨もし お互い',
'Dialogue: 0,0:14:33.00,0:14:36.00,Default,,0,0,0,,{\\pos(232,497)}本命 受かったら 大学 近いし➡',
].join('\n');
const cues = parseSubtitleCues(ass, 'polar-opposites-s02e09.ass');
const resolved = resolvePrimarySubtitle({
liveText: 'たが\n⸨もし お互い\n本命 受かったら 大学 近いし➡',
currentTimeSec: 14 * 60 + 33.05,
cues,
});
assert.deepEqual(
{ ...resolved, cues: resolved?.cues.map((cue) => cue.text) },
{
text: '⸨もし お互い\n本命 受かったら 大学 近いし➡',
startTime: 14 * 60 + 33,
endTime: 14 * 60 + 36,
cues: ['⸨もし お互い\n本命 受かったら 大学 近いし➡'],
},
);
});
+175 -44
View File
@@ -1,4 +1,8 @@
import type { SubtitleCue } from '../../types';
import type { AssVerticalBand, SubtitleCue } from '../../types';
import {
removeAssControlDebrisLines,
removeLiveGlyphFragmentLines,
} from '../../core/services/ass-text';
// Slack on top of each cue's recorded animation envelope, for time-pos observation
// staleness and small user sub-delay offsets. The envelope itself covers how far
@@ -13,6 +17,22 @@ export interface ResolvedPrimarySubtitle {
cues: SubtitleCue[];
}
function cuesUseAssSyntax(cues: readonly SubtitleCue[] | null | undefined): boolean {
return (cues ?? []).some(
(cue) =>
cue.source === 'canonical-ass' ||
cue.source === 'reconstructed-ass' ||
cue.assLayout !== undefined,
);
}
function decodedLiveText(
liveText: string,
cues: readonly SubtitleCue[] | null | undefined,
): string {
return cuesUseAssSyntax(cues) ? removeAssControlDebrisLines(liveText) : liveText;
}
function animationSpan(cue: SubtitleCue): { start: number; end: number } {
return {
start: cue.animationStartTime ?? cue.startTime,
@@ -23,9 +43,13 @@ function animationSpan(cue: SubtitleCue): { start: number; end: number } {
function nearbyCanonicalCues(
cues: readonly SubtitleCue[] | null | undefined,
currentTimeSec: number,
includeFragmentGrids = false,
): SubtitleCue[] {
return (cues ?? []).filter((cue) => {
if (cue.source !== 'canonical-ass') {
if (
(cue.source !== 'canonical-ass' && cue.source !== 'reconstructed-ass') ||
(!includeFragmentGrids && cue.assLayout?.kind === 'fragment-grid')
) {
return false;
}
const span = animationSpan(cue);
@@ -37,7 +61,55 @@ function nearbyCanonicalCues(
}
function compactWhitespace(text: string): string {
return text.replace(/\s+/gu, '');
return text.normalize('NFKC').replace(/\s+/gu, '');
}
/**
* Distinct simultaneous cues are separated by a blank line so the display layer can tell
* a wrap inside one utterance from the boundary between two of them. Consumers that read
* the text rather than display it fold these back to single breaks.
*/
const CUE_BOUNDARY = '\n\n';
const VERTICAL_BAND_RANK: Record<AssVerticalBand, number> = { top: 0, middle: 1, bottom: 2 };
/**
* Stack simultaneous cues the way they sit on screen: mpv keeps a top-anchored lyric or
* sign above bottom dialogue for its whole run, while cue-list order follows start time
* and would swap the pair whenever one side is replaced mid-overlap. The band is
* constant per event, so a line never changes rows while it is displayed.
*
* A cue whose placement could not be read -- an unknown style, a script with no styles
* section -- sorts to the top. Dialogue is the case that reliably declares a bottom
* alignment, so what is left unresolved is more often a sign or a song line, and keeping
* the dialogue on the bottom row means the line worth reading stays where the eye
* already is. Sort is stable, so cues sharing a rank keep their existing order.
*/
function orderCuesForDisplay(cues: readonly SubtitleCue[]): SubtitleCue[] {
const rank = (cue: SubtitleCue): number =>
VERTICAL_BAND_RANK[cue.assLayout?.verticalBand ?? 'top'];
return [...cues].sort((a, b) => rank(a) - rank(b));
}
// ASS layers can encode the same visible spacing with ordinary, hard, or
// ideographic spaces. Matching and emission must use the same identity or each
// layer reappears as a copy.
function uniqueCueTextGroups(cues: readonly SubtitleCue[]): string[] {
const groups: string[] = [];
const seen = new Set<string>();
for (const cue of cues) {
const lines: string[] = [];
for (const line of cue.text.split('\n')) {
const compactText = compactWhitespace(line);
if (!compactText || seen.has(compactText)) continue;
seen.add(compactText);
lines.push(line);
}
if (lines.length > 0) {
groups.push(lines.join('\n'));
}
}
return groups;
}
function compactLineSegments(text: string): string[] {
@@ -72,30 +144,58 @@ function resolveActiveParsedPrimarySubtitle(options: {
return false;
}
const cueSegments = compactLineSegments(cue.text);
return cueSegments.length > 0 && cueSegments.every((segment) => liveSegmentSet.has(segment));
if (cueSegments.length === 0) return false;
if (cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass') {
return liveSegments.some((segment) =>
cueSegments.some((cueSegment) => cueSegment.includes(segment)),
);
}
return cueSegments.every((segment) => liveSegmentSet.has(segment));
});
if (selected.length === 0) {
return null;
}
const parsedSegmentSet = new Set(selected.flatMap((cue) => compactLineSegments(cue.text)));
if (!liveSegments.every((segment) => parsedSegmentSet.has(segment))) {
const parsedSegments = selected.flatMap((cue) => {
const recovered = cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass';
return [
...compactLineSegments(cue.text).map((segment) => ({ segment, recovered })),
...(cue.assFurigana ?? []).flatMap((text) =>
compactLineSegments(text).map((segment) => ({ segment, recovered: false })),
),
];
});
if (
!liveSegments.every((liveSegment) =>
parsedSegments.some(({ segment, recovered }) =>
recovered ? segment.includes(liveSegment) : segment === liveSegment,
),
)
) {
return null;
}
const texts: string[] = [];
const seen = new Set<string>();
for (const cue of selected) {
if (!seen.has(cue.text)) {
seen.add(cue.text);
texts.push(cue.text);
}
}
// A cue selected only through the edge tolerance on its end has already finished by
// its published timing: a lyric whose exit ghosts linger into the next line. It still
// explains those live fragments above, but must not re-surface beside cues that are
// still running. The start side keeps the tolerance: mpv publishes the combined
// sub-text the moment a joining line's first frame renders, while the observed
// time-pos still sits just before that line's start, and the selection above already
// required the cue's text to be on screen (#220). With every selected cue finished,
// the edge cues remain the display fallback for stale time-pos readings.
const unfinished = selected.filter((cue) => cue.endTime > options.currentTimeSec);
const displayCues = unfinished.length > 0 ? unfinished : selected;
// Dense sign grids still explain their raw mpv fragments, but are visual
// typesetting rather than a publishable subtitle line.
const groups = uniqueCueTextGroups(
orderCuesForDisplay(displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid')),
);
return {
text: texts.join('\n'),
startTime: Math.min(...selected.map((cue) => cue.startTime)),
endTime: Math.max(...selected.map((cue) => cue.endTime)),
cues: selected,
text: groups.join(CUE_BOUNDARY),
startTime: Math.min(...displayCues.map((cue) => cue.startTime)),
endTime: Math.max(...displayCues.map((cue) => cue.endTime)),
cues: displayCues,
};
}
@@ -159,16 +259,9 @@ export function resolveCanonicalPrimarySubtitle(options: {
return null;
}
const texts: string[] = [];
const seen = new Set<string>();
for (const cue of selected) {
if (!seen.has(cue.text)) {
seen.add(cue.text);
texts.push(cue.text);
}
}
const groups = uniqueCueTextGroups(orderCuesForDisplay(selected));
return {
text: texts.join('\n'),
text: groups.join(CUE_BOUNDARY),
startTime: Math.min(...selected.map((cue) => cue.startTime)),
endTime: Math.max(...selected.map((cue) => cue.endTime)),
cues: selected,
@@ -178,8 +271,9 @@ export function resolveCanonicalPrimarySubtitle(options: {
/**
* Live text with generated-animation fragment lines removed. Recording paths use this
* when full canonical substitution declined -- concurrent dialogue during an insert
* song: the dialogue is worth recording, the glyph fragments beside it are not. Returns
* the input unchanged when no canonical cue is near or nothing non-fragment remains.
* song: the dialogue is worth recording, the glyph fragments beside it are not. An
* all-fragment visual grid becomes empty; other all-matched input remains unchanged as a
* defensive fallback.
*/
export function stripCanonicalFragmentLines(options: {
liveText: string;
@@ -187,18 +281,62 @@ export function stripCanonicalFragmentLines(options: {
cues: readonly SubtitleCue[] | null | undefined;
}): string {
if (!Number.isFinite(options.currentTimeSec)) {
return options.liveText;
return removeLiveGlyphFragmentLines(options.liveText);
}
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec);
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec, true);
if (nearby.length === 0) {
return options.liveText;
return removeLiveGlyphFragmentLines(options.liveText);
}
const compactCues = nearby.map((cue) => compactWhitespace(cue.text));
const kept = options.liveText.split('\n').filter((line) => {
const compact = compactWhitespace(line);
return compact && !compactCues.some((cueText) => cueText.includes(compact));
});
return kept.length > 0 ? kept.join('\n') : options.liveText;
if (kept.length > 0) return removeLiveGlyphFragmentLines(kept.join('\n'));
if (nearby.some((cue) => cue.assLayout?.kind === 'fragment-grid')) return '';
return removeLiveGlyphFragmentLines(options.liveText);
}
/**
* Recording text for a live sample. Callers substitute canonical cues themselves and
* record those cue by cue, so what is resolved here is the parsed view -- the one that
* folds ASS furigana events back into their base line. Its text is already a complete
* line, while fragment stripping takes raw mpv text and would discard a resolved line
* whole while a fragment grid is on screen, so only one of the two ever runs.
*/
export function resolveRecordedPrimarySubtitleText(options: {
liveText: string;
currentTimeSec: number;
cues: readonly SubtitleCue[] | null | undefined;
}): string {
const liveText = decodedLiveText(options.liveText, options.cues);
if (!liveText.trim()) {
return liveText;
}
return (
resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ??
stripCanonicalFragmentLines({ ...options, liveText })
);
}
/**
* The parsed view of the live text with its cue timings: a canonical animation when one
* explains the live lines, otherwise the active parsed cues. Null when the parsed cues
* cannot account for every live line, in which case callers keep the raw mpv text.
*/
export function resolvePrimarySubtitle(options: {
liveText: string;
currentTimeSec: number;
cues: readonly SubtitleCue[] | null | undefined;
}): ResolvedPrimarySubtitle | null {
const liveText = decodedLiveText(options.liveText, options.cues);
if (!liveText.trim()) {
return null;
}
return (
resolveCanonicalPrimarySubtitle({ ...options, liveText }) ??
resolveActiveParsedPrimarySubtitle({ ...options, liveText })
);
}
export function resolvePrimarySubtitleText(options: {
@@ -206,16 +344,9 @@ export function resolvePrimarySubtitleText(options: {
currentTimeSec: number;
cues: readonly SubtitleCue[] | null | undefined;
}): string {
if (!options.liveText.trim()) {
return options.liveText;
const liveText = decodedLiveText(options.liveText, options.cues);
if (!liveText.trim()) {
return liveText;
}
return (
resolveCanonicalPrimarySubtitle({
liveText: options.liveText,
currentTimeSec: options.currentTimeSec,
cues: options.cues,
})?.text ??
resolveActiveParsedPrimarySubtitle(options)?.text ??
options.liveText
);
return resolvePrimarySubtitle(options)?.text ?? removeLiveGlyphFragmentLines(liveText);
}
@@ -1,29 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from './protocol-url-handlers-main-deps';
test('protocol url handlers main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildRegisterProtocolUrlHandlersMainDepsHandler({
registerOpenUrl: () => calls.push('open-register'),
registerSecondInstance: () => calls.push('second-register'),
handleAnilistSetupProtocolUrl: () => true,
findAnilistSetupDeepLinkArgvUrl: () => 'subminer://anilist-setup',
logUnhandledOpenUrl: (rawUrl) => calls.push(`open:${rawUrl}`),
logUnhandledSecondInstanceUrl: (rawUrl) => calls.push(`second:${rawUrl}`),
})();
deps.registerOpenUrl(() => {});
deps.registerSecondInstance(() => {});
assert.equal(deps.handleAnilistSetupProtocolUrl('subminer://anilist-setup'), true);
assert.equal(deps.findAnilistSetupDeepLinkArgvUrl(['x']), 'subminer://anilist-setup');
deps.logUnhandledOpenUrl('subminer://noop');
deps.logUnhandledSecondInstanceUrl('subminer://noop');
assert.deepEqual(calls, [
'open-register',
'second-register',
'open:subminer://noop',
'second:subminer://noop',
]);
});
@@ -1,16 +0,0 @@
import type { registerProtocolUrlHandlers } from './protocol-url-handlers';
type RegisterProtocolUrlHandlersMainDeps = Parameters<typeof registerProtocolUrlHandlers>[0];
export function createBuildRegisterProtocolUrlHandlersMainDepsHandler(
deps: RegisterProtocolUrlHandlersMainDeps,
) {
return (): RegisterProtocolUrlHandlersMainDeps => ({
registerOpenUrl: (listener) => deps.registerOpenUrl(listener),
registerSecondInstance: (listener) => deps.registerSecondInstance(listener),
handleAnilistSetupProtocolUrl: (rawUrl: string) => deps.handleAnilistSetupProtocolUrl(rawUrl),
findAnilistSetupDeepLinkArgvUrl: (argv: string[]) => deps.findAnilistSetupDeepLinkArgvUrl(argv),
logUnhandledOpenUrl: (rawUrl: string) => deps.logUnhandledOpenUrl(rawUrl),
logUnhandledSecondInstanceUrl: (rawUrl: string) => deps.logUnhandledSecondInstanceUrl(rawUrl),
});
}
@@ -20,6 +20,295 @@ test('findActiveSubtitleText combines unique simultaneous parsed cues', () => {
);
});
test('findActiveSubtitleText removes duplicate lines across multiline cues', () => {
assert.equal(
findActiveSubtitleText(
[
{ startTime: 1, endTime: 3, text: 'First line\nSecond line' },
{ startTime: 1, endTime: 3, text: 'First line' },
],
2,
),
'First line\nSecond line',
);
});
test('findActiveSubtitleText removes equivalent full-width duplicate lines', () => {
assert.equal(
findActiveSubtitleText(
[
{ startTime: 1, endTime: 3, text: '真白~' },
{ startTime: 1, endTime: 3, text: '真白~' },
],
2,
),
'真白~',
);
});
test('findActiveSubtitleText collapses whitespace variants of one ASS lyric', () => {
assert.equal(
findActiveSubtitleText(
[
{ startTime: 1, endTime: 3, text: '少しだけ好きになる' },
{ startTime: 1, endTime: 3, text: '少しだけ 好きになる' },
{ startTime: 1, endTime: 3, text: '少しだけ 好きになる' },
],
2,
),
'少しだけ好きになる',
);
});
test('parsed secondary text collapses a positioned sign that repeats dialogue without punctuation', () => {
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 10,0:03:58.49,0:04:00.34,GJM_Main_1080p,Nar,0,0,0,,{\\i1}A question veiled as an insult!',
'Dialogue: 1,0:03:58.59,0:04:00.34,iFanzSigns,,0,0,0,,{\\pos(960,75)}A question veiled as an insult',
].join('\n');
const cues = parseSubtitleCues(ass, 'kaguya-s02e10.ass');
assert.equal(findActiveSubtitleText(cues, 238.48), '');
assert.equal(findActiveSubtitleText(cues, 238.5), 'A question veiled as an insult!');
assert.equal(findActiveSubtitleText(cues, 239), 'A question veiled as an insult!');
assert.equal(findActiveSubtitleText(cues, 240.34), '');
});
test('parsed secondary text drops a reconstructed grid of positioned sign fragments', () => {
const signFragment = (text: string, x: number, y: number) =>
`Dialogue: 1,0:00:01.00,0:00:03.00,Signs,,0,0,0,,{\\pos(${x},${y})\\t(0,100,\\fscx101)}${text}`;
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 10,0:00:01.00,0:00:03.00,Default,Speaker,0,0,0,,Come on, wake up!',
signFragment('Timetable', 1700, 150),
signFragment('Mon', 1750, 230),
signFragment('Tue', 1850, 230),
signFragment('1', 1650, 320),
signFragment('2', 1650, 390),
signFragment('Civics', 1750, 320),
signFragment('Math', 1850, 390),
signFragment('PE', 1850, 460),
].join('\n');
assert.equal(
findActiveSubtitleText(parseSubtitleCues(ass, 'kaguya-s02e11.ass'), 2),
'Come on, wake up!',
);
});
test('parsed secondary text keeps phone translations while dropping texture payloads', () => {
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 2,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain Medium\\clip(500,40,660,150)}LLLLLLLLLLLL',
'Dialogue: 90,0:00:01.00,0:00:03.00,Default,,0,0,0,,Why did you choose Hanajo instead?',
"Dialogue: 1,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain\\fs10\\alpha&H70&}q26D'vrA;\\NE? GS\\NESLhlawEv",
"Dialogue: 3,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(582,180)\\fnSF Pro Display\\fs66}We're {\\2a0}running {\\2a1}out {\\2a0}of {\\2a1}time!\\N{\\2a0}Where {\\2a1}are {\\2a0}you {\\2a1}right {\\2a0}now?!",
].join('\n');
assert.equal(
findActiveSubtitleText(parseSubtitleCues(ass, 'phone.ass'), 2),
"Why did you choose Hanajo instead?\nWe're running out of time!\nWhere are you right now?!",
);
});
test('parsed secondary lyrics keep explicit ASS vertical order when durations alternate', () => {
const lyric = (options: { start: string; end: string; style: string; y: number; text: string }) =>
`Dialogue: 0,0:00:${options.start},0:00:${options.end},${options.style},,0,0,0,fx,{\\move(100,${options.y},120,${options.y})\\t(0,200,\\fscx110)}${options.text}\\N{\\p1}m 0 0 l 0 5`;
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
lyric({
start: '01.00',
end: '02.20',
style: 'ed_romaji',
y: 66,
text: 'ima wo kakusarechau mae ni',
}),
lyric({
start: '01.00',
end: '02.00',
style: 'ed_english',
y: 1020,
text: 'Before the present moment gets hidden away.',
}),
lyric({
start: '03.00',
end: '04.00',
style: 'ed_romaji',
y: 66,
text: 'ame mitai ni hikatteru',
}),
lyric({
start: '03.00',
end: '04.20',
style: 'ed_english',
y: 1020,
text: 'Is shining like rain.',
}),
].join('\n');
const cues = parseSubtitleCues(ass, 'polar-opposites-s01e08.ass');
assert.equal(
findActiveSubtitleText(cues, 1.5),
'ima wo kakusarechau mae ni\nBefore the present moment gets hidden away.',
);
assert.equal(findActiveSubtitleText(cues, 3.5), 'ame mitai ni hikatteru\nIs shining like rain.');
});
test('unpositioned secondary lyrics fall back to ASS source order', () => {
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:02.20,ED Romaji,,0,0,0,,ima wo kakusarechau mae ni',
'Dialogue: 0,0:00:01.00,0:00:02.00,ED English,,0,0,0,,Before the present moment gets hidden away.',
].join('\n');
assert.equal(
findActiveSubtitleText(parseSubtitleCues(ass, 'ending.ass'), 1.5),
'ima wo kakusarechau mae ni\nBefore the present moment gets hidden away.',
);
});
test('findActiveSubtitleText keeps a canonical ASS cue for its generated animation span', () => {
const poof = {
startTime: 1110.67,
endTime: 1110.71,
text: 'POOF',
source: 'canonical-ass' as const,
animationStartTime: 1110.67,
animationEndTime: 1111.59,
};
assert.equal(findActiveSubtitleText([poof], 1111.58), 'POOF');
assert.equal(findActiveSubtitleText([poof], 1111.59), '');
});
test('findActiveSubtitleText advances when the next canonical lyric animation starts', () => {
const cues = [
{
startTime: 121.73,
endTime: 124.1,
text: 'Torn at the seams, a sound pours out',
source: 'canonical-ass' as const,
animationStartTime: 121.4,
animationEndTime: 124.1,
},
{
startTime: 124.13,
endTime: 126.38,
text: 'It’s silent, yet spreads all around',
source: 'canonical-ass' as const,
animationStartTime: 123.8,
animationEndTime: 126.38,
},
];
assert.equal(findActiveSubtitleText(cues, 123.79), cues[0]!.text);
assert.equal(findActiveSubtitleText(cues, 123.8), cues[1]!.text);
});
test('ASS fragment karaoke stays separated by style with authored word spacing', () => {
const lineEvents = (
style: string,
fragments: readonly string[],
y: number,
baseTime = 1,
): string[] => {
const events: string[] = [];
for (const layer of [0, 1]) {
fragments.forEach((fragment, index) => {
const x = 100 + index * 40;
const start = (baseTime + index * 0.25).toFixed(2).padStart(5, '0');
const end = (baseTime + 3 + index * 0.2).toFixed(2).padStart(5, '0');
events.push(
`Dialogue: ${layer},0:00:${start},0:00:${end},${style},,0,0,0,,{\\pos(${x},${y})\\t(0,200,\\fscx110)}${fragment}\\N{\\p1}m 0 0 l 0 10`,
);
});
}
return events;
};
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...lineEvents('ed_romaji', ['ji', 'gu', 'za', 'gu ', 'na', 'mi'], 70),
...lineEvents('ed_english', ['Pas', 'si', 'ng ', 'thro', 'u', 'gh '], 110),
...lineEvents('op_english', ['I', 'want', 'to', 'go'], 110, 7),
].join('\n');
assert.equal(
findActiveSubtitleText(parseSubtitleCues(ass, 'ending.ass'), 2.5),
'jiguzagu nami\nPassing through',
);
// Some generated scripts discard spaces and retain only positioned chunks. Joining
// without invented separators avoids turning one word into spaced syllables.
assert.equal(findActiveSubtitleText(parseSubtitleCues(ass, 'ending.ass'), 8.5), 'Iwanttogo');
});
test('ASS fragment karaoke preserves word spaces authored at event boundaries', () => {
const fragments = [
'The ',
'shoot',
'ing ',
'stars ',
'arc',
'ing ',
'across ',
'the ',
'sky ',
'I ',
'wish ',
'upon,',
];
const events: string[] = [];
for (const layer of [0, 1]) {
fragments.forEach((fragment, index) => {
events.push(
`Dialogue: ${layer},0:00:01.00,0:00:04.00,op_english,,0,0,0,,{\\pos(${100 + index * 40},110)\\t(0,200,\\fscx110)}${fragment}`,
);
});
}
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...events,
].join('\n');
assert.equal(
findActiveSubtitleText(parseSubtitleCues(ass, 'bravern-s01e10.ass'), 2),
'The shooting stars arcing across the sky I wish upon,',
);
});
test('findActiveSubtitleText keeps a complete reconstructed line over entrance fragments', () => {
const current = {
startTime: 1,
endTime: 4,
text: 'Complete current line',
source: 'reconstructed-ass' as const,
assStyle: 'op_english',
};
const nextEntrance = {
startTime: 3.8,
endTime: 4.2,
text: 'Ne',
source: 'reconstructed-ass' as const,
assStyle: 'op_english',
};
const nextLine = {
startTime: 4,
endTime: 7,
text: 'Next complete line',
source: 'reconstructed-ass' as const,
assStyle: 'op_english',
};
assert.equal(findActiveSubtitleText([current, nextEntrance], 3.9), current.text);
assert.equal(findActiveSubtitleText([current, nextEntrance, nextLine], 4.1), nextLine.text);
});
test('secondary track controller parses the selected ASS file before publishing', async () => {
const broadcasts: string[] = [];
let currentText = '';
@@ -157,6 +446,125 @@ test('secondary track controller falls back to live mpv text without a readable
assert.deepEqual(broadcasts, ['live fallback']);
});
test('secondary ASS live fallback drops malformed control debris', async () => {
const broadcasts: string[] = [];
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
if (name === 'path') return '/media/video.mkv';
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async () => ({ path: '/subs/english.ass', sourceKey: 'english' }),
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => [],
setCurrentSecondaryText: () => {},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
await controller.refresh();
broadcasts.length = 0;
controller.handleLiveText('Visible line\n\\\n{\\fr0');
assert.deepEqual(broadcasts, ['Visible line']);
});
test('secondary SRT live fallback preserves text that resembles ASS control debris', async () => {
const broadcasts: string[] = [];
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
if (name === 'path') return '/media/video.mkv';
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async () => ({ path: '/subs/english.srt', sourceKey: 'english' }),
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => [],
setCurrentSecondaryText: () => {},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
await controller.refresh();
broadcasts.length = 0;
controller.handleLiveText('Visible line\n\\\n{\\fr0');
assert.deepEqual(broadcasts, ['Visible line\n\\\n{\\fr0']);
});
test('secondary disconnect clears stale ASS fallback sanitization state', async () => {
let connected = true;
const broadcasts: string[] = [];
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
if (name === 'path') return '/media/video.mkv';
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async () => ({ path: '/subs/english.ass', sourceKey: 'english' }),
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => [],
setCurrentSecondaryText: () => {},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
await controller.refresh();
connected = false;
await controller.refresh();
broadcasts.length = 0;
controller.handleLiveText('Visible line\n\\\n{\\fr0');
assert.deepEqual(broadcasts, ['Visible line\n\\\n{\\fr0']);
});
test('secondary source refresh failure clears stale ASS fallback sanitization state', async () => {
let resolveCalls = 0;
const broadcasts: string[] = [];
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
if (name === 'path') return '/media/video.mkv';
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async () => {
resolveCalls += 1;
if (resolveCalls === 1) {
return { path: '/subs/english.ass', sourceKey: 'english' };
}
throw new Error('source refresh failed');
},
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => [],
setCurrentSecondaryText: () => {},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
await controller.refresh();
await controller.refresh();
broadcasts.length = 0;
controller.handleLiveText('Visible line\n\\\n{\\fr0');
assert.deepEqual(broadcasts, ['Visible line\n\\\n{\\fr0']);
});
test('secondary track controller reuses parsed cues for an unchanged embedded track', async () => {
let resolveCalls = 0;
let parseCalls = 0;
@@ -248,3 +656,36 @@ test('secondary track controller ignores and cleans up a refresh invalidated by
assert.equal(parseCalls, 0);
assert.equal(cleanupCalls, 1);
});
test('secondary live fallback suppresses a per-glyph typesetting wall', async () => {
let currentText = '';
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
if (name === 'path') return '/mnt/nas/video.mkv';
if (name === 'secondary-sub-delay') return 0;
return null;
},
}),
getCurrentTimePos: () => 1355,
// Network-mounted media: embedded extraction is skipped, so no parsed cues exist.
resolveSubtitleSource: async () => null,
loadSubtitleSourceText: async () => '',
parseSubtitleCues,
setCurrentSecondaryText: (text) => {
currentText = text;
},
broadcastSecondaryText: () => {},
});
await controller.refresh();
const wall = [...'wansdumretoikhI'].join('\n');
controller.handleLiveText(`${wall}\ntai`);
assert.equal(currentText, '');
controller.handleLiveText(`${wall}\nそれよりも ノート…`);
assert.equal(currentText, 'それよりも ノート…');
});
+141 -8
View File
@@ -1,4 +1,9 @@
import type { SubtitleCue } from '../../types/subtitle';
import { flattenedSecondarySubtitleLineIdentity } from '../../core/services/secondary-subtitle-line-identity';
import {
removeAssControlDebrisLines,
removeLiveGlyphFragmentLines,
} from '../../core/services/ass-text';
type SecondarySubtitleMpvClient = {
connected?: boolean;
@@ -22,6 +27,11 @@ type SecondarySubtitleSourceInput = {
const DEFAULT_REFRESH_DELAY_MS = 500;
function sourceUsesAssSyntax(source: string): boolean {
const sourceWithoutQuery = source.split(/[?#]/u, 1)[0] ?? '';
return /\.(?:ass|ssa)$/iu.test(sourceWithoutQuery);
}
function finiteNumber(value: unknown, fallback = 0): number {
const number = typeof value === 'number' ? value : Number(value);
return Number.isFinite(number) ? number : fallback;
@@ -58,17 +68,130 @@ function buildSelectedTrackIdentity(
]);
}
type IndexedSubtitleCue = { cue: SubtitleCue; index: number };
function compareAuthoredSubtitleOrder(left: IndexedSubtitleCue, right: IndexedSubtitleCue): number {
const leftLayout = left.cue.assLayout;
const rightLayout = right.cue.assLayout;
if (leftLayout?.kind === 'positioned' && rightLayout?.kind === 'positioned') {
const verticalOrder = leftLayout.y - rightLayout.y;
if (verticalOrder !== 0) return verticalOrder;
}
if (leftLayout && rightLayout) {
const sourceOrder = leftLayout.sourceOrder - rightLayout.sourceOrder;
if (sourceOrder !== 0) return sourceOrder;
}
return left.index - right.index;
}
export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds: number): string {
if (!Number.isFinite(timeSeconds)) return '';
const seen = new Set<string>();
const authoredCanonical = cues.filter(
(cue) =>
cue.source === 'canonical-ass' && cue.startTime <= timeSeconds && cue.endTime > timeSeconds,
);
const enteringCanonical = cues.filter(
(cue) =>
cue.source === 'canonical-ass' &&
(cue.animationStartTime ?? cue.startTime) <= timeSeconds &&
cue.startTime > timeSeconds &&
(cue.animationEndTime ?? cue.endTime) > timeSeconds,
);
const nextAuthoredStart = enteringCanonical.reduce(
(earliest, cue) => Math.min(earliest, cue.startTime),
Infinity,
);
// Generated lyrics can begin drawing before their canonical Comment timing. Once that
// entrance starts, replace a preceding lyric that ends before the new authored span;
// genuinely concurrent subtitles that continue through the new span stay selected.
const selectedCanonical = new Set<SubtitleCue>([
...authoredCanonical.filter(
(cue) => enteringCanonical.length === 0 || cue.endTime > nextAuthoredStart,
),
...enteringCanonical,
]);
if (selectedCanonical.size === 0) {
const animatedCanonical = cues.filter(
(cue) =>
cue.source === 'canonical-ass' &&
(cue.animationStartTime ?? cue.startTime) <= timeSeconds &&
(cue.animationEndTime ?? cue.endTime) > timeSeconds,
);
const nearestDistance = animatedCanonical.reduce((nearest, cue) => {
const distance =
timeSeconds < cue.startTime
? cue.startTime - timeSeconds
: Math.max(0, timeSeconds - cue.endTime);
return Math.min(nearest, distance);
}, Infinity);
for (const cue of animatedCanonical) {
const distance =
timeSeconds < cue.startTime
? cue.startTime - timeSeconds
: Math.max(0, timeSeconds - cue.endTime);
if (distance === nearestDistance) {
selectedCanonical.add(cue);
}
}
}
const activeReconstructed = cues.filter(
(cue) =>
cue.source === 'reconstructed-ass' &&
cue.assLayout?.kind !== 'fragment-grid' &&
cue.startTime <= timeSeconds &&
cue.endTime > timeSeconds,
);
const reconstructedByStyle = new Map<string, SubtitleCue>();
for (const cue of activeReconstructed) {
const style = cue.assStyle ?? '';
const existing = reconstructedByStyle.get(style);
if (!existing) {
reconstructedByStyle.set(style, cue);
continue;
}
const duration = cue.endTime - cue.startTime;
const existingDuration = existing.endTime - existing.startTime;
if (
duration > existingDuration ||
(duration === existingDuration && cue.text.length > existing.text.length) ||
(duration === existingDuration &&
cue.text.length === existing.text.length &&
cue.startTime > existing.startTime)
) {
reconstructedByStyle.set(style, cue);
}
}
const selectedReconstructed = new Set(reconstructedByStyle.values());
const seenExact = new Set<string>();
const seenFlattened = new Set<string>();
const activeText: string[] = [];
for (const cue of cues) {
if (cue.startTime > timeSeconds || cue.endTime <= timeSeconds) continue;
const text = cue.text.trim();
if (!text || seen.has(text)) continue;
seen.add(text);
activeText.push(text);
const activeCues: IndexedSubtitleCue[] = [];
cues.forEach((cue, index) => {
const active =
cue.source === 'canonical-ass'
? selectedCanonical.has(cue)
: cue.source === 'reconstructed-ass'
? selectedReconstructed.has(cue)
: cue.startTime <= timeSeconds && cue.endTime > timeSeconds;
if (active) activeCues.push({ cue, index });
});
activeCues.sort(compareAuthoredSubtitleOrder);
for (const { cue } of activeCues) {
for (const line of cue.text.split('\n')) {
const text = line.trim();
const compactText = text.normalize('NFKC').replace(/\s+/gu, '');
if (!compactText || seenExact.has(compactText)) continue;
seenExact.add(compactText);
const flattenedIdentity = flattenedSecondarySubtitleLineIdentity(text);
if (flattenedIdentity && seenFlattened.has(flattenedIdentity)) continue;
if (flattenedIdentity) seenFlattened.add(flattenedIdentity);
activeText.push(text);
}
}
return activeText.join('\n');
}
@@ -89,6 +212,7 @@ export function createSecondarySubtitleTrackController(deps: {
let parsedCues: SubtitleCue[] | null = null;
let parsedSourceKey: string | null = null;
let parsedTrackIdentity: string | null = null;
let activeSourceUsesAssSyntax = false;
let secondaryDelaySeconds = 0;
let lastLiveText = '';
let lastBroadcastText: string | null = null;
@@ -118,6 +242,7 @@ export function createSecondarySubtitleTrackController(deps: {
const generation = ++refreshGeneration;
const client = deps.getMpvClient();
if (!client?.connected) {
activeSourceUsesAssSyntax = false;
useLiveFallback();
return;
}
@@ -134,6 +259,7 @@ export function createSecondarySubtitleTrackController(deps: {
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw.trim() : '';
if (!videoPath || secondarySid === null || secondarySid === 'no') {
activeSourceUsesAssSyntax = false;
useLiveFallback();
return;
}
@@ -155,11 +281,14 @@ export function createSecondarySubtitleTrackController(deps: {
});
if (generation !== refreshGeneration) return;
if (!resolvedSource) {
activeSourceUsesAssSyntax = false;
deps.logDebug?.('[secondary-subtitle-track] selected source is not readable');
useLiveFallback();
return;
}
activeSourceUsesAssSyntax = sourceUsesAssSyntax(resolvedSource.path);
if (resolvedSource.sourceKey === parsedSourceKey && parsedCues) {
parsedTrackIdentity = selectedTrackIdentity;
publish(resolveAtTime(deps.getCurrentTimePos()));
@@ -181,6 +310,7 @@ export function createSecondarySubtitleTrackController(deps: {
publish(resolveAtTime(deps.getCurrentTimePos()));
} catch (error) {
if (generation !== refreshGeneration) return;
activeSourceUsesAssSyntax = false;
deps.logWarn?.('[secondary-subtitle-track] failed to parse selected source', error);
useLiveFallback();
} finally {
@@ -203,6 +333,7 @@ export function createSecondarySubtitleTrackController(deps: {
parsedCues = null;
parsedSourceKey = null;
parsedTrackIdentity = null;
activeSourceUsesAssSyntax = false;
secondaryDelaySeconds = 0;
lastLiveText = '';
publish('');
@@ -212,7 +343,9 @@ export function createSecondarySubtitleTrackController(deps: {
refresh,
scheduleRefresh,
handleLiveText(text: string): void {
lastLiveText = text;
lastLiveText = removeLiveGlyphFragmentLines(
activeSourceUsesAssSyntax ? removeAssControlDebrisLines(text) : text,
);
publish(resolveAtTime(deps.getCurrentTimePos()));
},
handleTimePos(timeSeconds: number): void {
@@ -70,3 +70,69 @@ test('persistSessionBindings keeps saved bindings when mpv reload notification f
fs.rmSync(root, { recursive: true, force: true });
}
});
test('native prefix conflicts publish the same effective bindings to the overlay and plugin and recover', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-conflict-'));
const sequence: CompiledSessionBinding = {
sourcePath: 'shortcuts.openSubtitleSelection',
originalKey: 'g-s',
key: { code: 'KeyG-KeyS', modifiers: [] },
actionType: 'session-action',
actionId: 'openSubtitleSelection',
};
let nativeKeys: unknown = [];
let failDiscovery = false;
let published: CompiledSessionBinding[] = [];
const events: CompiledSessionBinding[][] = [];
const warnings: string[] = [];
const client = {
connected: true,
send: () => {},
requestProperty: async () => {
if (failDiscovery) throw new Error('temporarily unavailable');
return nativeKeys;
},
};
const runtime = createSessionBindingsRuntime({
configDir: root,
getKeybindings: () => [],
getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never,
getResolvedConfig: () => ({ stats: { toggleKey: 's', markWatchedKey: 'w' } }) as ResolvedConfig,
getMpvClient: () => client,
setSessionBindings: (bindings) => {
published = bindings;
},
setSessionBindingsInitialized: () => {},
logWarn: () => {},
onBindingsChanged: (bindings) => events.push(bindings),
onWarning: (warning) => warnings.push(warning.message),
});
const readArtifact = () =>
JSON.parse(fs.readFileSync(path.join(root, 'session-bindings.json'), 'utf8'));
try {
runtime.persistSessionBindings([sequence]);
nativeKeys = [{ key: 'g', cmd: 'show-text single', priority: 1 }];
await runtime.refreshMpvSessionBindings();
assert.deepEqual(published, []);
assert.deepEqual(events.at(-1), readArtifact().bindings);
assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /mpv input binding "g"/);
await runtime.refreshMpvSessionBindings();
assert.equal(events.length, 2, 'unchanged discovery must not create a reload loop');
assert.equal(warnings.length, 1);
failDiscovery = true;
await runtime.refreshMpvSessionBindings();
assert.deepEqual(published, [], 'failed discovery retains the known conflict');
failDiscovery = false;
nativeKeys = [{ key: 'Shift+g', cmd: 'show-text shifted', priority: 1 }];
await runtime.refreshMpvSessionBindings();
assert.deepEqual(published, [sequence]);
assert.equal(readArtifact().bindings[0].key.code, 'KeyG-KeyS');
assert.deepEqual(readArtifact().warnings, []);
client.connected = false;
await runtime.refreshMpvSessionBindings();
assert.deepEqual(published, [sequence]);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
+83 -7
View File
@@ -6,16 +6,26 @@ import {
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types';
import { writeSessionBindingsArtifact } from './session-bindings-artifact';
import { parseMpvInputBindingKeys } from '../../shared/mpv-input-bindings';
import {
reserveMpvSequencePrefixes,
resolveSessionSequenceConflicts,
} from '../../shared/session-key-sequences';
import type { SessionBindingWarning } from '../../types/session-bindings';
export interface SessionBindingsRuntimeDeps {
configDir: string;
getKeybindings: () => Keybinding[];
getConfiguredShortcuts: () => ConfiguredShortcuts;
getResolvedConfig: () => ResolvedConfig;
getMpvClient: () => MpvRuntimeClientLike | null;
getMpvClient: () =>
| (MpvRuntimeClientLike & { requestProperty: (name: string) => Promise<unknown> })
| null;
setSessionBindings: (bindings: CompiledSessionBinding[]) => void;
setSessionBindingsInitialized: (initialized: boolean) => void;
logWarn: (message: string, details?: unknown) => void;
onBindingsChanged?: (bindings: CompiledSessionBinding[]) => void;
onWarning?: (warning: SessionBindingWarning) => void;
}
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
@@ -24,7 +34,20 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
warnings?: ReturnType<typeof compileSessionBindings>['warnings'],
) => void;
refreshCurrentSessionBindings: () => void;
refreshMpvSessionBindings: () => Promise<void>;
} {
let sourceBindings: CompiledSessionBinding[] = [];
let sourceWarnings: SessionBindingWarning[] = [];
let nativeSnapshot: {
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
keys: string[];
} | null = null;
let pending: {
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
promise: Promise<void>;
} | null = null;
let publishedSignature: string | null = null;
let reportedWarnings = new Set<string>();
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
if (process.platform === 'darwin') return 'darwin';
if (process.platform === 'win32') return 'win32';
@@ -49,8 +72,27 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
bindings: CompiledSessionBinding[],
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
): void {
sourceBindings = bindings;
sourceWarnings = warnings;
publishBindings();
}
function publishBindings(): void {
const client = deps.getMpvClient();
const keys = client?.connected && nativeSnapshot?.client === client ? nativeSnapshot.keys : [];
const result = resolveSessionSequenceConflicts(
sourceBindings,
reserveMpvSequencePrefixes(keys),
);
const warnings = [...sourceWarnings, ...result.warnings];
const signature = JSON.stringify([
result.bindings,
warnings,
deps.getConfiguredShortcuts().multiCopyTimeoutMs,
]);
if (signature === publishedSignature) return;
const artifact = buildPluginSessionBindingsArtifact({
bindings,
bindings: result.bindings,
warnings,
numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs,
});
@@ -60,8 +102,16 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
deps.logWarn('[session-bindings] Failed to write session bindings artifact');
throw error;
}
deps.setSessionBindings(bindings);
publishedSignature = signature;
deps.setSessionBindings(result.bindings);
deps.setSessionBindingsInitialized(true);
const nextWarnings = new Set(warnings.map((warning) => warning.message));
for (const warning of warnings) {
if (reportedWarnings.has(warning.message)) continue;
deps.logWarn(`[session-bindings] ${warning.message}`);
deps.onWarning?.(warning);
}
reportedWarnings = nextWarnings;
const mpvClient = deps.getMpvClient();
if (mpvClient?.connected) {
try {
@@ -70,15 +120,41 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error);
}
}
deps.onBindingsChanged?.(result.bindings);
}
async function refreshMpvSessionBindings(): Promise<void> {
const client = deps.getMpvClient();
if (!client?.connected) {
nativeSnapshot = null;
publishBindings();
return;
}
if (pending?.client === client) return pending.promise;
const promise = (async () => {
try {
const raw = await client.requestProperty('input-bindings');
if (client !== deps.getMpvClient() || !client.connected) return;
nativeSnapshot = { client, keys: parseMpvInputBindingKeys(raw, { includeIgnored: false }) };
publishBindings();
} catch {
// Keep the last successful snapshot if discovery is temporarily unavailable.
}
})();
const request = { client, promise };
pending = request;
try {
await promise;
} finally {
if (pending === request) pending = null;
}
}
function refreshCurrentSessionBindings(): void {
const compiled = compileCurrentSessionBindings();
for (const warning of compiled.warnings) {
deps.logWarn(`[session-bindings] ${warning.message}`);
}
persistSessionBindings(compiled.bindings, compiled.warnings);
void refreshMpvSessionBindings();
}
return { persistSessionBindings, refreshCurrentSessionBindings };
return { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings };
}
+6 -4
View File
@@ -57,8 +57,10 @@ export function createRunStatsCliCommandHandler(deps: {
}) => Promise<DuplicateSubtitleLineCleanupSummary>;
rebuildLifetimeSummaries?: () => Promise<LifetimeRebuildSummary>;
} | null;
ensureStatsServerStarted: () => string;
ensureBackgroundStatsServerStarted: () => BackgroundStatsStartResult;
ensureStatsServerStarted: () => Promise<string> | string;
ensureBackgroundStatsServerStarted: () =>
| Promise<BackgroundStatsStartResult>
| BackgroundStatsStartResult;
stopBackgroundStatsServer: () => Promise<BackgroundStatsStopResult> | BackgroundStatsStopResult;
openExternal: (url: string) => Promise<unknown>;
writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void;
@@ -115,7 +117,7 @@ export function createRunStatsCliCommandHandler(deps: {
}
if (args.statsBackground) {
const result = deps.ensureBackgroundStatsServerStarted();
const result = await deps.ensureBackgroundStatsServerStarted();
deps.logInfo(`Stats dashboard available at ${result.url}`);
writeResponseSafe(args.statsResponsePath, { ok: true, url: result.url });
if (!result.runningInCurrentProcess && source === 'initial') {
@@ -183,7 +185,7 @@ export function createRunStatsCliCommandHandler(deps: {
return;
}
const url = deps.ensureStatsServerStarted();
const url = await deps.ensureStatsServerStarted();
if (config.stats.autoOpenBrowser !== false) {
await deps.openExternal(url);
}
@@ -23,7 +23,7 @@ function createHarness(options?: {
return options?.processAlive ?? true;
},
hasLocalStatsServer: () => localServerStarted,
startLocalStatsServer: () => {
startLocalStatsServer: async () => {
calls.push('startLocalStatsServer');
localServerStarted = true;
},
@@ -36,23 +36,23 @@ function createHarness(options?: {
};
}
test('stats server routing defers to a live background daemon from another process', () => {
test('stats server routing defers to a live background daemon from another process', async () => {
const { calls, handler } = createHarness({
state: { pid: 200, port: 7979, startedAtMs: 1 },
processAlive: true,
});
assert.deepEqual(handler(), { url: 'http://127.0.0.1:7979', source: 'background' });
assert.deepEqual(await handler(), { url: 'http://127.0.0.1:7979', source: 'background' });
assert.deepEqual(calls, ['readBackgroundState', 'isProcessAlive']);
});
test('stats server routing clears dead daemon state and starts local server', () => {
test('stats server routing clears dead daemon state and starts local server', async () => {
const { calls, handler } = createHarness({
state: { pid: 200, port: 7979, startedAtMs: 1 },
processAlive: false,
});
assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
assert.deepEqual(await handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
assert.deepEqual(calls, [
'readBackgroundState',
'isProcessAlive',
@@ -61,13 +61,13 @@ test('stats server routing clears dead daemon state and starts local server', ()
]);
});
test('stats server routing clears self-owned stale state and starts local server', () => {
test('stats server routing clears self-owned stale state and starts local server', async () => {
const { calls, handler } = createHarness({
state: { pid: 100, port: 7979, startedAtMs: 1 },
processAlive: true,
});
assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
assert.deepEqual(await handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
assert.deepEqual(calls, [
'readBackgroundState',
'removeBackgroundState',
@@ -75,12 +75,12 @@ test('stats server routing clears self-owned stale state and starts local server
]);
});
test('stats server routing reuses a started local stats server', () => {
test('stats server routing reuses a started local stats server', async () => {
const { calls, handler } = createHarness({
state: null,
localServerStarted: true,
});
assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
assert.deepEqual(await handler(), { url: 'http://127.0.0.1:6969', source: 'local' });
assert.deepEqual(calls, ['readBackgroundState', 'removeBackgroundState']);
});
+4 -4
View File
@@ -6,7 +6,7 @@ type EnsureStatsServerUrlDeps = {
removeBackgroundState: () => void;
isProcessAlive: (pid: number) => boolean;
hasLocalStatsServer: () => boolean;
startLocalStatsServer: () => void;
startLocalStatsServer: () => Promise<void>;
getConfiguredPort: () => number;
};
@@ -18,8 +18,8 @@ export type EnsureStatsServerUrlResult = { url: string; source: 'background' | '
export function createEnsureStatsServerUrlHandler(
deps: EnsureStatsServerUrlDeps,
): () => EnsureStatsServerUrlResult {
return () => {
): () => Promise<EnsureStatsServerUrlResult> {
return async () => {
const state = deps.readBackgroundState();
if (!state) {
deps.removeBackgroundState();
@@ -32,7 +32,7 @@ export function createEnsureStatsServerUrlHandler(
}
if (!deps.hasLocalStatsServer()) {
deps.startLocalStatsServer();
await deps.startLocalStatsServer();
}
return { url: formatStatsServerUrl(deps.getConfiguredPort()), source: 'local' };
};
+223 -6
View File
@@ -1,10 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import test, { after } from 'node:test';
import { DEFAULT_CONFIG } from '../../config';
import { ImmersionTrackerService } from '../../core/services/immersion-tracker-service';
import { createAnilistRateLimiter } from '../../core/services/anilist/rate-limiter';
import {
createStatsServerRuntime,
isSelfOwnedBackgroundStatsDaemonState,
shouldClearAppStateStatsServerOnStop,
type StatsServerRuntimeDeps,
} from './stats-server-runtime';
import type { StatsServer } from '../../core/services/stats-server';
import type { BackgroundStatsServerState } from './stats-daemon';
function createDeferred<T>() {
let settle: ((value: T) => void) | null = null;
let fail: ((error: unknown) => void) | null = null;
const promise = new Promise<T>((resolve, reject) => {
settle = resolve;
fail = reject;
});
return {
promise,
resolve(value: T): void {
if (!settle) throw new Error('deferred promise is unavailable');
settle(value);
},
reject(error: unknown): void {
if (!fail) throw new Error('deferred promise is unavailable');
fail(error);
},
};
}
function createRuntimeHarness(
startServer: NonNullable<StatsServerRuntimeDeps['startServer']>,
backgroundState: BackgroundStatsServerState | null = null,
) {
const appStateValues: Array<StatsServer | null> = [];
const tracker = new ImmersionTrackerService({ dbPath: ':memory:' });
after(() => tracker.destroy());
const runtime = createStatsServerRuntime({
userDataPath: '/tmp/subminer-stats-runtime-test',
statsDistPath: '/tmp/stats-dist',
getResolvedConfig: () => ({
...DEFAULT_CONFIG,
stats: { ...DEFAULT_CONFIG.stats, serverPort: 5175 },
}),
getImmersionTracker: () => tracker,
setAppStateStatsServer: (server) => {
appStateValues.push(server);
},
getMpvSocketPath: () => '/tmp/mpv.sock',
getYomitanExt: () => null,
getYomitanSession: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
getYomitanAnkiDeckName: async () => 'Mining',
getAnilistRateLimiter: () => createAnilistRateLimiter(),
resolveAnkiNoteId: (noteId) => noteId,
trackDuplicateNoteIdsForNote: () => {},
resolveSentenceSearchHeadwords: async () => [],
ensureImmersionTrackerStarted: () => {},
setStatsStartupInProgress: () => {},
readBackgroundStatsServerState: () => backgroundState,
removeBackgroundStatsServerState: () => {},
isBackgroundStatsServerProcessAlive: () => false,
startServer,
});
return { runtime, appStateValues };
}
test('detects self-owned background stats daemon state', () => {
assert.equal(
@@ -13,10 +80,6 @@ test('detects self-owned background stats daemon state', () => {
);
});
test('stats server app-state reference should be cleared after private server stop', () => {
assert.equal(shouldClearAppStateStatsServerOnStop({ hadStatsServer: true }), true);
});
test('stopBackgroundStatsServer clears stale state when daemon identity mismatches', async () => {
const calls: string[] = [];
const runtime = createStatsServerRuntime({
@@ -57,3 +120,157 @@ test('stopBackgroundStatsServer clears stale state when daemon identity mismatch
assert.deepEqual(result, { ok: true, stale: true });
assert.deepEqual(calls, ['removeBackgroundStatsServerState']);
});
test('concurrent stats startup requests share one pending server', async () => {
const deferred = createDeferred<StatsServer>();
let startCalls = 0;
const server: StatsServer = { close: async () => {} };
const { runtime, appStateValues } = createRuntimeHarness(() => {
startCalls += 1;
return deferred.promise;
});
const first = runtime.ensureStatsServerStarted();
const second = runtime.ensureStatsServerStarted();
assert.equal(startCalls, 1);
assert.deepEqual(appStateValues, []);
deferred.resolve(server);
assert.deepEqual(await Promise.all([first, second]), [
{ url: 'http://127.0.0.1:5175', source: 'local' },
{ url: 'http://127.0.0.1:5175', source: 'local' },
]);
assert.deepEqual(appStateValues, [server]);
});
test('failed stats startup remains recoverable on the next request', async () => {
const first = createDeferred<StatsServer>();
const second = createDeferred<StatsServer>();
const attempts = [first, second];
let startCalls = 0;
const server: StatsServer = { close: async () => {} };
const { runtime, appStateValues } = createRuntimeHarness(() => {
const attempt = attempts[startCalls];
startCalls += 1;
if (!attempt) throw new Error('unexpected startup attempt');
return attempt.promise;
});
const failedStartup = runtime.ensureStatsServerStarted();
first.reject(Object.assign(new Error('address in use'), { code: 'EADDRINUSE' }));
await assert.rejects(failedStartup, /address in use/);
const retry = runtime.ensureStatsServerStarted();
second.resolve(server);
assert.deepEqual(await retry, { url: 'http://127.0.0.1:5175', source: 'local' });
assert.equal(startCalls, 2);
assert.deepEqual(appStateValues, [null, server]);
});
test('shutdown cancels pending startup and closes the late server', async () => {
const deferred = createDeferred<StatsServer>();
let closeCalls = 0;
const server: StatsServer = {
close: async () => {
closeCalls += 1;
},
};
const { runtime, appStateValues } = createRuntimeHarness(() => deferred.promise);
const startup = runtime.ensureStatsServerStarted();
const shutdown = runtime.stopStatsServer();
deferred.resolve(server);
await assert.rejects(startup, /startup was cancelled/);
await shutdown;
assert.equal(closeCalls, 1);
assert.deepEqual(appStateValues, [null, null]);
});
test('stopping a self-owned background server closes its local handle', async () => {
let closeCalls = 0;
const server: StatsServer = {
close: async () => {
closeCalls += 1;
},
};
const { runtime } = createRuntimeHarness(async () => server, {
pid: process.pid,
port: 5175,
startedAtMs: 1,
});
await runtime.ensureStatsServerStarted();
assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: false });
assert.equal(closeCalls, 1);
});
test('background stop leaves a foreground-only server available', async () => {
let closeCalls = 0;
let startCalls = 0;
const { runtime } = createRuntimeHarness(async () => {
startCalls += 1;
return {
close: async () => {
closeCalls += 1;
},
};
});
const foreground = await runtime.ensureStatsServerStarted();
assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: true });
assert.equal(closeCalls, 0);
assert.deepEqual(await runtime.ensureStatsServerStarted(), foreground);
assert.equal(startCalls, 1);
await runtime.stopStatsServer();
});
test('background stop leaves a pending foreground-only startup alone', async () => {
const deferred = createDeferred<StatsServer>();
const { runtime } = createRuntimeHarness(() => deferred.promise);
const startup = runtime.ensureStatsServerStarted();
assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: true });
deferred.resolve({ close: async () => {} });
assert.deepEqual(await startup, { url: 'http://127.0.0.1:5175', source: 'local' });
await runtime.stopStatsServer();
});
test('a startup requested during shutdown waits and then restarts', async () => {
const closeDeferred = createDeferred<void>();
const firstServer: StatsServer = { close: () => closeDeferred.promise };
const secondServer: StatsServer = { close: async () => {} };
const servers = [firstServer, secondServer];
let startCalls = 0;
const { runtime } = createRuntimeHarness(async () => {
const server = servers[startCalls];
startCalls += 1;
if (!server) throw new Error('unexpected startup attempt');
return server;
});
await runtime.ensureStatsServerStarted();
const shutdown = runtime.stopStatsServer();
const restart = runtime.ensureStatsServerStarted();
assert.equal(startCalls, 1);
closeDeferred.resolve();
await shutdown;
assert.deepEqual(await restart, { url: 'http://127.0.0.1:5175', source: 'local' });
assert.equal(startCalls, 2);
});
test('background stop cancels startup before daemon ownership is published', async () => {
const deferred = createDeferred<StatsServer>();
let closeCalls = 0;
const { runtime, appStateValues } = createRuntimeHarness(() => deferred.promise);
const startup = runtime.ensureBackgroundStatsServerStarted();
const shutdown = runtime.stopBackgroundStatsServer();
deferred.resolve({
close: async () => {
closeCalls += 1;
},
});
await assert.rejects(startup, /startup was cancelled/);
assert.deepEqual(await shutdown, { ok: true, stale: false });
assert.equal(closeCalls, 1);
assert.equal(appStateValues.at(-1), null);
});
+176 -88
View File
@@ -1,10 +1,12 @@
import { generateSentenceFurigana } from '../../core/services/tokenizer/sentence-furigana';
import path from 'node:path';
import type { BrowserWindow } from 'electron';
import {
addYomitanNoteViaSearch,
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
} from '../../core/services';
import { startStatsServer } from '../../core/services/stats-server';
import { startStatsServer, type StatsServer } from '../../core/services/stats-server';
import { createTmdbClient, createTmdbApiKeyResolver } from '../../core/services/tmdb/tmdb-client';
import { createLogger } from '../../logger';
import type { ResolvedConfig } from '../../types/config';
import type { AppState } from '../state';
@@ -27,12 +29,6 @@ export function isSelfOwnedBackgroundStatsDaemonState(state: {
return state.pid === process.pid;
}
export function shouldClearAppStateStatsServerOnStop(options: {
hadStatsServer: boolean;
}): boolean {
return options.hadStatsServer;
}
export interface StatsServerRuntimeDeps {
userDataPath: string;
statsDistPath: string;
@@ -52,6 +48,8 @@ export interface StatsServerRuntimeDeps {
getAnilistRateLimiter: () => NonNullable<
Parameters<typeof startStatsServer>[0]['anilistRateLimiter']
>;
/** Project TMDB key staged into release builds; null for source builds. */
getBundledTmdbApiKey?: () => string | null;
resolveAnkiNoteId: (noteId: number) => number;
trackDuplicateNoteIdsForNote: (noteId: number, duplicateNoteIds: number[]) => void;
resolveSentenceSearchHeadwords: (term: string) => Promise<string[]>;
@@ -62,19 +60,28 @@ export interface StatsServerRuntimeDeps {
isBackgroundStatsServerProcessAlive?: typeof defaultIsBackgroundStatsServerProcessAlive;
verifyBackgroundStatsServerIdentity?: typeof defaultVerifyBackgroundStatsServerIdentity;
killProcess?: (pid: number, signal: NodeJS.Signals) => void;
startServer?: typeof startStatsServer;
}
export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
stopStatsServer: () => void;
stopStatsServer: () => Promise<void>;
ensureStatsServerStarted: ReturnType<typeof createEnsureStatsServerUrlHandler>;
ensureBackgroundStatsServerStarted: () => {
ensureBackgroundStatsServerStarted: () => Promise<{
url: string;
runningInCurrentProcess: boolean;
};
}>;
stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>;
} {
let statsServer: ReturnType<typeof startStatsServer> | null = null;
type LocalStatsServerState =
| { kind: 'stopped' }
| { kind: 'starting'; token: symbol; promise: Promise<void> }
| { kind: 'running'; server: StatsServer }
| { kind: 'stopping'; token: symbol; promise: Promise<void> };
let localStatsServerState: LocalStatsServerState = { kind: 'stopped' };
const pendingBackgroundStarts = new Set<symbol>();
const statsDaemonStatePath = path.join(deps.userDataPath, 'stats-daemon.json');
const startServer = deps.startServer ?? startStatsServer;
const readDaemonState =
deps.readBackgroundStatsServerState ??
((statePath: string) => defaultReadBackgroundStatsServerState(statePath));
@@ -100,7 +107,7 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
removeDaemonState(statsDaemonStatePath);
return null;
}
if (state.pid === process.pid && !statsServer) {
if (state.pid === process.pid && localStatsServerState.kind !== 'running') {
removeDaemonState(statsDaemonStatePath);
return null;
}
@@ -118,74 +125,142 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
}
}
function stopStatsServer(): void {
if (!statsServer) {
return;
}
statsServer.close();
statsServer = null;
if (shouldClearAppStateStatsServerOnStop({ hadStatsServer: true })) {
deps.setAppStateStatsServer(null);
}
clearOwnedBackgroundStatsDaemonState();
}
const startLocalStatsServer = (): void => {
const buildStatsServerConfig = (): Parameters<typeof startStatsServer>[0] => {
const tracker = deps.getImmersionTracker();
if (!tracker) {
throw new Error('Immersion tracker failed to initialize.');
}
if (!statsServer) {
const yomitanDeps = {
getYomitanExt: () => deps.getYomitanExt(),
getYomitanSession: () => deps.getYomitanSession(),
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
setYomitanParserWindow: (w: BrowserWindow | null) => {
deps.setYomitanParserWindow(w);
},
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),
setYomitanParserReadyPromise: (p: Promise<void> | null) => {
deps.setYomitanParserReadyPromise(p);
},
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(),
setYomitanParserInitPromise: (p: Promise<boolean> | null) => {
deps.setYomitanParserInitPromise(p);
},
};
const yomitanLogger = createLogger('main:yomitan-stats');
statsServer = startStatsServer({
port: deps.getResolvedConfig().stats.serverPort,
staticDir: deps.statsDistPath,
tracker,
knownWordCachePath: path.join(deps.userDataPath, 'known-words-cache.json'),
mpvSocketPath: deps.getMpvSocketPath(),
getAnkiConnectConfig: () => deps.getResolvedConfig().ankiConnect,
getYomitanAnkiDeckName: deps.getYomitanAnkiDeckName,
getSecondarySubtitleLanguages: () =>
deps.getResolvedConfig().secondarySub.secondarySubLanguages,
getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path,
anilistRateLimiter: deps.getAnilistRateLimiter(),
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
addYomitanNote: async (word: string) => {
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
deck: ankiConnectConfig.deck,
});
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
if (result.noteId && result.duplicateNoteIds.length > 0) {
deps.trackDuplicateNoteIdsForNote(result.noteId, result.duplicateNoteIds);
}
return result.noteId;
},
});
deps.setAppStateStatsServer(statsServer);
}
deps.setAppStateStatsServer(statsServer);
const yomitanDeps = {
getYomitanExt: () => deps.getYomitanExt(),
getYomitanSession: () => deps.getYomitanSession(),
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
setYomitanParserWindow: (w: BrowserWindow | null) => {
deps.setYomitanParserWindow(w);
},
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),
setYomitanParserReadyPromise: (p: Promise<void> | null) => {
deps.setYomitanParserReadyPromise(p);
},
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(),
setYomitanParserInitPromise: (p: Promise<boolean> | null) => {
deps.setYomitanParserInitPromise(p);
},
};
const yomitanLogger = createLogger('main:yomitan-stats');
return {
port: deps.getResolvedConfig().stats.serverPort,
staticDir: deps.statsDistPath,
tracker,
knownWordCachePath: path.join(deps.userDataPath, 'known-words-cache.json'),
mpvSocketPath: deps.getMpvSocketPath(),
getAnkiConnectConfig: () => deps.getResolvedConfig().ankiConnect,
getYomitanAnkiDeckName: deps.getYomitanAnkiDeckName,
getSecondarySubtitleLanguages: () =>
deps.getResolvedConfig().secondarySub.secondarySubLanguages,
getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path,
anilistRateLimiter: deps.getAnilistRateLimiter(),
tmdbClient: createTmdbClient({
resolveApiKey: createTmdbApiKeyResolver(
() => deps.getResolvedConfig().tmdb,
() => deps.getBundledTmdbApiKey?.() ?? null,
),
}),
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
generateSentenceFurigana: (text, highlightedText) =>
generateSentenceFurigana(text, highlightedText, yomitanDeps, yomitanLogger),
addYomitanNote: async (word: string) => {
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
deck: ankiConnectConfig.deck,
});
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
if (result.noteId && result.duplicateNoteIds.length > 0) {
deps.trackDuplicateNoteIdsForNote(result.noteId, result.duplicateNoteIds);
}
return result.noteId;
},
};
};
const beginLocalStatsServerStartup = (): Promise<void> => {
const token = Symbol('stats-server-startup');
const promise = startServer(buildStatsServerConfig())
.then(async (server) => {
const state = localStatsServerState;
if (state.kind !== 'starting' || state.token !== token) {
await server.close();
throw new Error('Stats server startup was cancelled.');
}
localStatsServerState = { kind: 'running', server };
deps.setAppStateStatsServer(server);
})
.catch((error: unknown) => {
const state = localStatsServerState;
if (state.kind === 'starting' && state.token === token) {
localStatsServerState = { kind: 'stopped' };
deps.setAppStateStatsServer(null);
}
throw error;
});
localStatsServerState = { kind: 'starting', token, promise };
return promise;
};
const startLocalStatsServer = async (): Promise<void> => {
while (localStatsServerState.kind === 'stopping') {
await localStatsServerState.promise;
}
if (localStatsServerState.kind === 'running') {
deps.setAppStateStatsServer(localStatsServerState.server);
return;
}
if (localStatsServerState.kind === 'starting') {
await localStatsServerState.promise;
return;
}
await beginLocalStatsServerStartup();
};
function stopStatsServer(): Promise<void> {
const state = localStatsServerState;
if (state.kind === 'stopped') {
deps.setAppStateStatsServer(null);
clearOwnedBackgroundStatsDaemonState();
return Promise.resolve();
}
if (state.kind === 'stopping') {
return state.promise;
}
const token = Symbol('stats-server-shutdown');
const promise = Promise.resolve()
.then(async () => {
if (state.kind === 'starting') {
try {
await state.promise;
} catch {
// Startup owns cleanup of a server that finishes binding after cancellation.
}
return;
}
await state.server.close();
})
.finally(() => {
const current = localStatsServerState;
if (current.kind === 'stopping' && current.token === token) {
localStatsServerState = { kind: 'stopped' };
}
deps.setAppStateStatsServer(null);
clearOwnedBackgroundStatsDaemonState();
});
localStatsServerState = { kind: 'stopping', token, promise };
deps.setAppStateStatsServer(null);
return promise;
}
const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({
currentPid: process.pid,
readBackgroundState: () => readDaemonState(statsDaemonStatePath),
@@ -193,15 +268,15 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
removeDaemonState(statsDaemonStatePath);
},
isProcessAlive: (pid) => isDaemonAlive(pid),
hasLocalStatsServer: () => statsServer !== null,
hasLocalStatsServer: () => localStatsServerState.kind === 'running',
startLocalStatsServer,
getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort,
});
const ensureBackgroundStatsServerStarted = (): {
const ensureBackgroundStatsServerStarted = async (): Promise<{
url: string;
runningInCurrentProcess: boolean;
} => {
}> => {
const liveDaemon = readLiveBackgroundStatsDaemonState();
if (liveDaemon && liveDaemon.pid !== process.pid) {
return {
@@ -217,27 +292,40 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
deps.setStatsStartupInProgress(false);
}
const port = deps.getResolvedConfig().stats.serverPort;
const result = ensureStatsServerStarted();
if (result.source === 'local') {
writeBackgroundStatsServerState(statsDaemonStatePath, {
pid: process.pid,
port,
startedAtMs: Date.now(),
});
const request = Symbol('background-stats-startup');
pendingBackgroundStarts.add(request);
try {
const port = deps.getResolvedConfig().stats.serverPort;
const result = await ensureStatsServerStarted();
if (result.source === 'local') {
if (localStatsServerState.kind !== 'running') {
throw new Error('Stats server startup was cancelled.');
}
writeBackgroundStatsServerState(statsDaemonStatePath, {
pid: process.pid,
port,
startedAtMs: Date.now(),
});
}
return { url: result.url, runningInCurrentProcess: result.source === 'local' };
} finally {
pendingBackgroundStarts.delete(request);
}
return { url: result.url, runningInCurrentProcess: result.source === 'local' };
};
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
const state = readDaemonState(statsDaemonStatePath);
if (!state) {
if (pendingBackgroundStarts.size > 0) {
await stopStatsServer();
return { ok: true, stale: false };
}
removeDaemonState(statsDaemonStatePath);
return { ok: true, stale: true };
}
if (isSelfOwnedBackgroundStatsDaemonState(state)) {
removeDaemonState(statsDaemonStatePath);
return { ok: true, stale: true };
await stopStatsServer();
return { ok: true, stale: false };
}
if (!isDaemonAlive(state.pid)) {
removeDaemonState(statsDaemonStatePath);
@@ -0,0 +1,43 @@
import type { IpcMain, WebContents } from 'electron';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { isSubtitleGenerationModelId } from '../../shared/subtitle-generation-model-catalog';
import type { createSubtitleGenerationRuntime } from './subtitle-generation-runtime';
export function registerSubtitleGenerationIpc(deps: {
ipc: Pick<IpcMain, 'handle'>;
isAllowedSender: (sender: WebContents) => boolean;
openModal: () => Promise<boolean>;
runtime: ReturnType<typeof createSubtitleGenerationRuntime>;
}): void {
const handlers = [
[IPC_CHANNELS.request.requestSubtitleGenerationOpen, () => deps.openModal()],
[IPC_CHANNELS.request.getSubtitleGenerationStatus, () => deps.runtime.getStatus()],
[
IPC_CHANNELS.request.selectSubtitleGenerationModel,
(model: unknown) => {
if (!isSubtitleGenerationModelId(model))
throw new Error('Unknown subtitle generation model.');
return deps.runtime.selectModel(model);
},
],
[IPC_CHANNELS.request.startSubtitleGeneration, () => deps.runtime.start()],
[IPC_CHANNELS.request.downloadSubtitleGenerationModel, () => deps.runtime.download()],
[IPC_CHANNELS.request.downloadSubtitleGenerationVadModel, () => deps.runtime.downloadVad()],
[
IPC_CHANNELS.request.setSubtitleGenerationVadEnabled,
(enabled: unknown) => {
if (typeof enabled !== 'boolean')
throw new Error('Speech detection selection must be a boolean.');
return deps.runtime.setVadEnabled(enabled);
},
],
[IPC_CHANNELS.request.cancelSubtitleGeneration, () => deps.runtime.cancel()],
] as const;
for (const [channel, handler] of handlers) {
deps.ipc.handle(channel, (event, payload: unknown) => {
if (!deps.isAllowedSender(event.sender))
throw new Error('Subtitle generation is only available from the overlay.');
return handler(payload);
});
}
}
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { openSubtitleGenerationModal } from './subtitle-generation-open';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
test('subtitle generation opens in the dedicated modal window with normal close restoration', async () => {
const calls: string[] = [];
const opened = await openSubtitleGenerationModal({
ensureOverlayStartupPrereqs: () => {
calls.push('startup');
},
ensureOverlayWindowsReadyForVisibilityActions: () => {
calls.push('windows');
},
sendToActiveOverlayWindow: (channel, payload, options) => {
assert.deepEqual(calls, ['startup', 'windows']);
assert.equal(channel, IPC_CHANNELS.event.subtitleGenerationOpen);
assert.equal(payload, undefined);
assert.deepEqual(options, {
restoreOnModalClose: 'subtitle-generation',
preferModalWindow: true,
});
calls.push('open');
return true;
},
waitForModalOpen: async (modal) => {
assert.equal(modal, 'subtitle-generation');
return true;
},
logWarn: () => {
assert.fail('opening should not require a retry');
},
});
assert.equal(opened, true);
assert.deepEqual(calls, ['startup', 'windows', 'open']);
});

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