mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-30 17:40:53 -07:00
chore: merge main and update Electron to 43.7.2
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}.`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 } },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -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
@@ -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, []);
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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本命 受かったら 大学 近いし➡'],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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, 'それよりも ノート…');
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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' };
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user