mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-01 23:54:31 -07:00
fix(anki): sync field grouping fixtures with main
This commit is contained in:
@@ -64,11 +64,17 @@ test('anki action main deps builders map callbacks', async () => {
|
||||
const mine = createBuildMineSentenceCardMainDepsHandler({
|
||||
getAnkiIntegration: () => ({ enabled: true }),
|
||||
getMpvClient: () => ({ connected: true }),
|
||||
getPrimarySubtitle: () => ({ text: '正式な字幕', startTime: 1, endTime: 3 }),
|
||||
showMpvOsd: (text) => calls.push(`mine:${text}`),
|
||||
mineSentenceCardCore: async () => true,
|
||||
recordCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
})();
|
||||
assert.deepEqual(mine.getMpvClient(), { connected: true });
|
||||
assert.deepEqual(mine.getPrimarySubtitle?.(), {
|
||||
text: '正式な字幕',
|
||||
startTime: 1,
|
||||
endTime: 3,
|
||||
});
|
||||
mine.showMpvOsd('m');
|
||||
await mine.mineSentenceCardCore({
|
||||
ankiIntegration: { enabled: true },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { createRefreshKnownWordCacheHandler } from './anki-actions';
|
||||
import type { createRefreshKnownWordCacheHandler, PrimarySubtitle } from './anki-actions';
|
||||
|
||||
type RefreshKnownWordCacheMainDeps = Parameters<typeof createRefreshKnownWordCacheHandler>[0];
|
||||
|
||||
@@ -72,10 +72,12 @@ export function createBuildMarkLastCardAsAudioCardMainDepsHandler<TAnki>(deps: {
|
||||
export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
||||
getAnkiIntegration: () => TAnki;
|
||||
getMpvClient: () => TMpv;
|
||||
getPrimarySubtitle?: () => PrimarySubtitle | null;
|
||||
showMpvOsd: (text: string) => void;
|
||||
mineSentenceCardCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
mpvClient: TMpv;
|
||||
primarySubtitle?: PrimarySubtitle;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
@@ -83,10 +85,14 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
||||
return () => ({
|
||||
getAnkiIntegration: () => deps.getAnkiIntegration(),
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
...(deps.getPrimarySubtitle
|
||||
? { getPrimarySubtitle: () => deps.getPrimarySubtitle?.() ?? null }
|
||||
: {}),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
mineSentenceCardCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
mpvClient: TMpv;
|
||||
primarySubtitle?: PrimarySubtitle;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => deps.mineSentenceCardCore(options),
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => deps.recordCardsMined(count, noteIds),
|
||||
|
||||
@@ -87,3 +87,20 @@ test('mine sentence handler records mined cards only when core returns true', as
|
||||
await mineSentenceCard();
|
||||
assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']);
|
||||
});
|
||||
|
||||
test('mine sentence handler forwards the canonical primary subtitle snapshot', async () => {
|
||||
const primarySubtitle = { text: '正式な字幕', startTime: 1, endTime: 3 };
|
||||
const mineSentenceCard = createMineSentenceCardHandler({
|
||||
getAnkiIntegration: () => ({}),
|
||||
getMpvClient: () => ({}),
|
||||
getPrimarySubtitle: () => primarySubtitle,
|
||||
showMpvOsd: () => {},
|
||||
mineSentenceCardCore: async (options) => {
|
||||
assert.equal(options.primarySubtitle, primarySubtitle);
|
||||
return true;
|
||||
},
|
||||
recordCardsMined: () => {},
|
||||
});
|
||||
|
||||
await mineSentenceCard();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,12 @@ type AnkiIntegrationLike = {
|
||||
refreshKnownWordCache: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type PrimarySubtitle = {
|
||||
text: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
|
||||
export function createUpdateLastCardFromClipboardHandler<TAnki>(deps: {
|
||||
getAnkiIntegration: () => TAnki;
|
||||
readClipboardText: () => string;
|
||||
@@ -69,18 +75,22 @@ export function createMarkLastCardAsAudioCardHandler<TAnki>(deps: {
|
||||
export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
|
||||
getAnkiIntegration: () => TAnki;
|
||||
getMpvClient: () => TMpv;
|
||||
getPrimarySubtitle?: () => PrimarySubtitle | null;
|
||||
showMpvOsd: (text: string) => void;
|
||||
mineSentenceCardCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
mpvClient: TMpv;
|
||||
primarySubtitle?: PrimarySubtitle;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
const primarySubtitle = deps.getPrimarySubtitle?.();
|
||||
const created = await deps.mineSentenceCardCore({
|
||||
ankiIntegration: deps.getAnkiIntegration(),
|
||||
mpvClient: deps.getMpvClient(),
|
||||
...(primarySubtitle ? { primarySubtitle } : {}),
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
});
|
||||
if (created) {
|
||||
|
||||
@@ -43,6 +43,7 @@ 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'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
@@ -50,10 +51,11 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 34);
|
||||
assert.equal(calls.length, 35);
|
||||
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'));
|
||||
@@ -97,6 +99,7 @@ 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'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
@@ -104,7 +107,11 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
|
||||
});
|
||||
|
||||
assert.throws(() => cleanup(), /stop failed/);
|
||||
assert.deepEqual(calls, ['stop-jellyfin-remote', 'cleanup-jellyfin-subtitles']);
|
||||
assert.deepEqual(calls, [
|
||||
'stop-jellyfin-remote',
|
||||
'cleanup-jellyfin-subtitles',
|
||||
'cleanup-internal-subtitles',
|
||||
]);
|
||||
});
|
||||
|
||||
test('should restore windows on activate requires initialized runtime and no windows', () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
destroyYomitanSettingsWindow: () => void;
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupInternalSubtitleTrackCache: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
cleanupYoutubeMediaCache: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
@@ -67,7 +68,11 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
try {
|
||||
deps.stopJellyfinRemoteSession();
|
||||
} finally {
|
||||
deps.cleanupJellyfinSubtitleCache();
|
||||
try {
|
||||
deps.cleanupJellyfinSubtitleCache();
|
||||
} finally {
|
||||
deps.cleanupInternalSubtitleTrackCache();
|
||||
}
|
||||
}
|
||||
deps.cleanupYoutubeSubtitleTempDirs();
|
||||
deps.cleanupYoutubeMediaCache();
|
||||
|
||||
@@ -72,6 +72,7 @@ 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'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
@@ -95,6 +96,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
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'));
|
||||
@@ -152,6 +154,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupInternalSubtitleTrackCache: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
@@ -204,6 +207,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupInternalSubtitleTrackCache: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
|
||||
@@ -58,6 +58,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupInternalSubtitleTrackCache: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
cleanupYoutubeMediaCache: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
@@ -144,6 +145,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
},
|
||||
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||
cleanupInternalSubtitleTrackCache: () => deps.cleanupInternalSubtitleTrackCache(),
|
||||
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
|
||||
cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(),
|
||||
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SubtitleCue, SubtitleData } from '../../types';
|
||||
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
|
||||
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
|
||||
import { resolvePrimarySubtitleText } from './primary-subtitle-text';
|
||||
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
|
||||
|
||||
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
|
||||
@@ -11,6 +12,7 @@ type AutoplaySubtitlePrimingMpvClient = {
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
currentVideoPath?: string;
|
||||
currentTimePos?: number;
|
||||
currentSubText?: string;
|
||||
currentSecondarySubText?: string;
|
||||
setCurrentSecondarySubText?: (text: string) => void;
|
||||
};
|
||||
@@ -106,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;
|
||||
}
|
||||
|
||||
@@ -141,6 +151,16 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveLivePrimarySubtitleText(text: string): string {
|
||||
const client = deps.getMpvClient();
|
||||
const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos());
|
||||
return resolvePrimarySubtitleText({
|
||||
liveText: text,
|
||||
currentTimeSec,
|
||||
cues: deps.getActiveParsedSubtitleCues(),
|
||||
});
|
||||
}
|
||||
|
||||
async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise<void> {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) {
|
||||
@@ -155,7 +175,8 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
||||
);
|
||||
return null;
|
||||
});
|
||||
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
|
||||
const liveText = typeof subTextRaw === 'string' ? subTextRaw : '';
|
||||
const text = resolveLivePrimarySubtitleText(liveText);
|
||||
if (emitAutoplayPrimedSubtitle(mediaPath, text)) {
|
||||
return;
|
||||
}
|
||||
@@ -175,6 +196,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
||||
async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
|
||||
await primeVisibleOverlaySubtitleFromMpv({
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
resolvePrimarySubtitleText: (text) => resolveLivePrimarySubtitleText(text),
|
||||
setCurrentSubText: (text) => {
|
||||
deps.setCurrentSubText(text);
|
||||
},
|
||||
@@ -239,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;
|
||||
}
|
||||
|
||||
@@ -252,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 {
|
||||
|
||||
@@ -49,6 +49,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: async () => {},
|
||||
cleanupInternalSubtitleTrackCache: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
|
||||
@@ -46,6 +46,7 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
|
||||
export async function primeVisibleOverlaySubtitleFromMpv(deps: {
|
||||
getMpvClient: () => CurrentSubtitleMpvClient | null;
|
||||
setCurrentSubText: (text: string) => void;
|
||||
resolvePrimarySubtitleText?: (text: string) => string;
|
||||
getCurrentSubtitleData: () => SubtitleData | null;
|
||||
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
@@ -73,7 +74,8 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
|
||||
const liveText = typeof subTextRaw === 'string' ? subTextRaw : '';
|
||||
const text = deps.resolvePrimarySubtitleText?.(liveText) ?? liveText;
|
||||
deps.setCurrentSubText(text);
|
||||
|
||||
const primeSecondarySubtitle = async (): Promise<void> => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 ?? (() => {}),
|
||||
};
|
||||
@@ -377,20 +365,17 @@ test('preload jellyfin subtitles waits for delayed external japanese track inste
|
||||
|
||||
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 +446,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 +466,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 +482,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;
|
||||
@@ -257,54 +249,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 +260,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 {
|
||||
@@ -357,6 +297,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
itemId: string;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
resetManagedSubtitleDelay();
|
||||
try {
|
||||
cleanupActiveCache();
|
||||
} catch (error) {
|
||||
@@ -369,8 +310,6 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
);
|
||||
const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl));
|
||||
if (externalTracks.length === 0) {
|
||||
deps.setActiveSubtitleDelayKey?.(null);
|
||||
resetManagedSubtitleDelay();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -427,40 +366,13 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
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();
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
if (englishSecondaryId !== null) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -191,6 +191,8 @@ test('mpv event bindings register all expected events', () => {
|
||||
onSubtitleAssChange: () => {},
|
||||
onSecondarySubtitleChange: () => {},
|
||||
onSubtitleTrackChange: () => {},
|
||||
onSecondarySubtitleTrackChange: () => {},
|
||||
onSecondarySubtitleDelayChange: () => {},
|
||||
onSubtitleTrackListChange: () => {},
|
||||
onSubtitleTiming: () => {},
|
||||
onMediaPathChange: () => {},
|
||||
@@ -215,6 +217,8 @@ test('mpv event bindings register all expected events', () => {
|
||||
'subtitle-ass-change',
|
||||
'secondary-subtitle-change',
|
||||
'subtitle-track-change',
|
||||
'secondary-subtitle-track-change',
|
||||
'secondary-subtitle-delay-change',
|
||||
'subtitle-track-list-change',
|
||||
'subtitle-timing',
|
||||
'media-path-change',
|
||||
|
||||
@@ -4,6 +4,8 @@ type MpvBindingEventName =
|
||||
| 'subtitle-ass-change'
|
||||
| 'secondary-subtitle-change'
|
||||
| 'subtitle-track-change'
|
||||
| 'secondary-subtitle-track-change'
|
||||
| 'secondary-subtitle-delay-change'
|
||||
| 'subtitle-track-list-change'
|
||||
| 'subtitle-timing'
|
||||
| 'media-path-change'
|
||||
@@ -90,6 +92,8 @@ export function createBindMpvClientEventHandlers(deps: {
|
||||
onSubtitleAssChange: (payload: { text: string }) => void;
|
||||
onSecondarySubtitleChange: (payload: { text: string }) => void;
|
||||
onSubtitleTrackChange: (payload: { sid: number | null }) => void;
|
||||
onSecondarySubtitleTrackChange: (payload: { sid: number | null }) => void;
|
||||
onSecondarySubtitleDelayChange: (payload: { delay: number }) => void;
|
||||
onSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void;
|
||||
onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
|
||||
onMediaPathChange: (payload: { path: string | null }) => void;
|
||||
@@ -107,6 +111,8 @@ export function createBindMpvClientEventHandlers(deps: {
|
||||
mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange);
|
||||
mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange);
|
||||
mpvClient.on('subtitle-track-change', deps.onSubtitleTrackChange);
|
||||
mpvClient.on('secondary-subtitle-track-change', deps.onSecondarySubtitleTrackChange);
|
||||
mpvClient.on('secondary-subtitle-delay-change', deps.onSecondarySubtitleDelayChange);
|
||||
mpvClient.on('subtitle-track-list-change', deps.onSubtitleTrackListChange);
|
||||
mpvClient.on('subtitle-timing', deps.onSubtitleTiming);
|
||||
mpvClient.on('media-path-change', deps.onMediaPathChange);
|
||||
|
||||
@@ -26,6 +26,29 @@ test('subtitle change handler updates state and forwards uncached text without r
|
||||
assert.deepEqual(calls, ['set:line', 'process:line', 'presence']);
|
||||
});
|
||||
|
||||
test('subtitle change handler consistently forwards resolved canonical text', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleChangeHandler({
|
||||
resolveSubtitleText: () => '今 手にある物差しでは',
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getImmediateSubtitlePayload: (text) => {
|
||||
calls.push(`lookup:${text}`);
|
||||
return null;
|
||||
},
|
||||
broadcastSubtitle: () => {},
|
||||
onSubtitleChange: (text) => calls.push(`process:${text}`),
|
||||
refreshDiscordPresence: () => {},
|
||||
});
|
||||
|
||||
handler({ text: '今今今手手手ににに' });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'set:今 手にある物差しでは',
|
||||
'lookup:今 手にある物差しでは',
|
||||
'process:今 手にある物差しでは',
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitle change handler clears immediately for empty subtitle text', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleChangeHandler({
|
||||
@@ -335,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,7 +4,10 @@ type AnilistPostWatchRunOptions = {
|
||||
watchedSeconds?: number;
|
||||
};
|
||||
|
||||
const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
|
||||
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;
|
||||
|
||||
function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): boolean {
|
||||
if (previousTime === null || !Number.isFinite(previousTime) || !Number.isFinite(nextTime)) {
|
||||
@@ -14,6 +17,7 @@ function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): bo
|
||||
}
|
||||
|
||||
export function createHandleMpvSubtitleChangeHandler(deps: {
|
||||
resolveSubtitleText?: (text: string) => string;
|
||||
setCurrentSubText: (text: string) => void;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||
@@ -22,7 +26,8 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
|
||||
refreshDiscordPresence: () => void;
|
||||
logDebug?: (message: string) => void;
|
||||
}) {
|
||||
return ({ text }: { text: string }): void => {
|
||||
return ({ text: liveText }: { text: string }): void => {
|
||||
const text = deps.resolveSubtitleText?.(liveText) ?? liveText;
|
||||
deps.setCurrentSubText(text);
|
||||
const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null;
|
||||
if (immediatePayload) {
|
||||
@@ -135,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;
|
||||
}
|
||||
@@ -150,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}`),
|
||||
@@ -37,6 +53,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
|
||||
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
|
||||
onSubtitleTrackChange: () => calls.push('subtitle-track-change'),
|
||||
onSecondarySubtitleTrackChange: () => calls.push('secondary-subtitle-track-change'),
|
||||
onSecondarySubtitleDelayChange: (delay) =>
|
||||
calls.push(`secondary-subtitle-delay-change:${delay}`),
|
||||
onSubtitleTrackListChange: () => calls.push('subtitle-track-list-change'),
|
||||
|
||||
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
|
||||
@@ -57,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'),
|
||||
@@ -73,12 +95,20 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
handlers.get('connection-change')?.({ connected: true });
|
||||
handlers.get('subtitle-change')?.({ text: 'line' });
|
||||
handlers.get('subtitle-track-change')?.({ sid: 3 });
|
||||
handlers.get('secondary-subtitle-track-change')?.({ sid: 4 });
|
||||
handlers.get('secondary-subtitle-delay-change')?.({ delay: 0.5 });
|
||||
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
|
||||
handlers.get('media-path-change')?.({ path: '/tmp/video.mkv' });
|
||||
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'));
|
||||
@@ -86,6 +116,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
assert.equal(calls.includes('broadcast-sub:line'), true);
|
||||
assert.ok(calls.includes('subtitle-change:line'));
|
||||
assert.ok(calls.includes('subtitle-track-change'));
|
||||
assert.ok(calls.includes('secondary-subtitle-track-change'));
|
||||
assert.ok(calls.includes('secondary-subtitle-delay-change:0.5'));
|
||||
assert.ok(calls.includes('subtitle-track-list-change'));
|
||||
assert.ok(calls.includes('media-title:Episode 1'));
|
||||
assert.ok(calls.includes('media-path:/tmp/video.mkv'));
|
||||
|
||||
@@ -43,6 +43,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||
|
||||
setCurrentSubText: (text: string) => void;
|
||||
resolveSubtitleText?: (text: string) => string;
|
||||
getCurrentLiveSubtitleText?: () => string;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||
broadcastSubtitle: (payload: SubtitleData) => void;
|
||||
@@ -54,6 +56,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
broadcastSubtitleAss: (text: string) => void;
|
||||
broadcastSecondarySubtitle: (text: string) => void;
|
||||
onSubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSecondarySubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSecondarySubtitleDelayChange?: (delay: number) => void;
|
||||
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||
|
||||
updateCurrentMediaPath: (path: string) => void;
|
||||
@@ -75,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;
|
||||
|
||||
@@ -117,6 +122,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
||||
});
|
||||
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
|
||||
resolveSubtitleText: deps.resolveSubtitleText,
|
||||
setCurrentSubText: (text) => deps.setCurrentSubText(text),
|
||||
getImmediateSubtitlePayload: (text) => deps.getImmediateSubtitlePayload?.(text) ?? null,
|
||||
emitImmediateSubtitle: deps.emitImmediateSubtitle
|
||||
@@ -167,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),
|
||||
@@ -189,6 +203,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
onSubtitleAssChange: handleMpvSubtitleAssChange,
|
||||
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
|
||||
onSubtitleTrackChange: ({ sid }) => deps.onSubtitleTrackChange?.(sid),
|
||||
onSecondarySubtitleTrackChange: ({ sid }) => deps.onSecondarySubtitleTrackChange?.(sid),
|
||||
onSecondarySubtitleDelayChange: ({ delay }) => deps.onSecondarySubtitleDelayChange?.(delay),
|
||||
onSubtitleTrackListChange: ({ trackList }) => deps.onSubtitleTrackListChange?.(trackList),
|
||||
onSubtitleTiming: handleMpvSubtitleTiming,
|
||||
onMediaPathChange: handleMpvMediaPathChange,
|
||||
|
||||
@@ -47,6 +47,9 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
calls.push(`broadcast:${channel}:${String(payload)}`),
|
||||
onSecondarySubtitleChange: (text) => calls.push(`secondary:${text}`),
|
||||
onSecondarySubtitleTrackChange: (sid) => calls.push(`secondary-track:${String(sid)}`),
|
||||
onSecondarySubtitleDelayChange: (delay) => calls.push(`secondary-delay:${delay}`),
|
||||
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
|
||||
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
|
||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||
@@ -86,6 +89,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
deps.setCurrentSubAssText('ass');
|
||||
deps.broadcastSubtitleAss('ass');
|
||||
deps.broadcastSecondarySubtitle('sec');
|
||||
deps.onSecondarySubtitleTrackChange?.(4);
|
||||
deps.onSecondarySubtitleDelayChange?.(0.5);
|
||||
deps.updateCurrentMediaPath('/tmp/video');
|
||||
deps.restoreMpvSubVisibility();
|
||||
deps.resetSubtitleSidebarEmbeddedLayout();
|
||||
@@ -116,6 +121,10 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
assert.ok(calls.includes('sync-overlay-mpv-sub'));
|
||||
assert.ok(calls.includes('anilist-post-watch'));
|
||||
assert.ok(calls.includes('timing:y:secondary'));
|
||||
assert.ok(calls.includes('secondary:sec'));
|
||||
assert.ok(calls.includes('secondary-track:4'));
|
||||
assert.ok(calls.includes('secondary-delay:0.5'));
|
||||
assert.ok(!calls.includes('broadcast:secondary-subtitle:set:sec'));
|
||||
assert.ok(calls.includes('ensure-immersion'));
|
||||
assert.ok(calls.includes('sync-immersion'));
|
||||
assert.ok(calls.includes('autoplay:/tmp/video'));
|
||||
@@ -387,3 +396,182 @@ test('subtitle-track transitions ignore stale parsed cues until replacement cues
|
||||
handlers.recordImmersionSubtitleLine('飛び上がる', 20.04, 20.08);
|
||||
assert.deepEqual(recordedStarts.slice(-1), [20]);
|
||||
});
|
||||
|
||||
test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => {
|
||||
const immersion: Array<{ text: string; start: number; end: number }> = [];
|
||||
const timing: Array<{ text: string; start: number; end: number }> = [];
|
||||
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
||||
appState: {
|
||||
initialArgs: null,
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: { currentTimePos: 2 },
|
||||
immersionTracker: {
|
||||
recordSubtitleLine: (text: string, start: number, end: number) =>
|
||||
immersion.push({ text, start, end }),
|
||||
},
|
||||
subtitleTimingTracker: {
|
||||
recordSubtitle: (text: string, start: number, end: number) =>
|
||||
timing.push({ text, start, end }),
|
||||
},
|
||||
activeParsedSubtitleCues: [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある物差しでは',
|
||||
source: 'canonical-ass',
|
||||
},
|
||||
{
|
||||
startTime: 3,
|
||||
endTime: 6,
|
||||
text: '飛び越えてみたくて',
|
||||
source: 'canonical-ass',
|
||||
},
|
||||
{
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
text: 'MaidCafeMaidCafe',
|
||||
source: 'reconstructed-ass',
|
||||
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
|
||||
},
|
||||
],
|
||||
currentMediaPath: '/video.mkv',
|
||||
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: () => {},
|
||||
})();
|
||||
|
||||
assert.equal(handlers.resolveSubtitleText?.('今\n今\n今\n手\n手\n手'), '今 手にある物差しでは');
|
||||
handlers.recordImmersionSubtitleLine('今', 0.8, 1.5);
|
||||
handlers.recordImmersionSubtitleLine('手', 0.86, 1.56);
|
||||
handlers.recordSubtitleTiming('今', 0.8, 1.5);
|
||||
|
||||
assert.deepEqual(immersion, [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
|
||||
assert.deepEqual(timing, [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
|
||||
|
||||
// Concurrent dialogue during the song is not part of the animation: it must be
|
||||
// recorded as itself -- without the fragment lines beside it -- and must not cause
|
||||
// the song line to be recorded again when the animation frames resume.
|
||||
assert.equal(handlers.resolveSubtitleText?.('普通のセリフ\n今\n手'), '普通のセリフ\n今\n手');
|
||||
handlers.recordImmersionSubtitleLine('普通のセリフ\n今\n手', 1.9, 3.2);
|
||||
handlers.recordImmersionSubtitleLine('にある', 2.1, 2.9);
|
||||
handlers.recordSubtitleTiming('次のセリフ', 3.9, 5.0);
|
||||
|
||||
assert.deepEqual(immersion.slice(1), [{ text: '普通のセリフ', start: 1.9, end: 3.2 }]);
|
||||
assert.deepEqual(timing.slice(1), [{ text: '次のセリフ', start: 3.9, end: 5 }]);
|
||||
|
||||
// Overlapping canonical lines resolve as shifting subsets (A, then A+B, then A).
|
||||
// Every recorded cue is remembered, so each authored line still records exactly once.
|
||||
handlers.recordImmersionSubtitleLine('飛び越えて', 3.2, 3.4);
|
||||
handlers.recordImmersionSubtitleLine('手にある', 3.5, 3.7);
|
||||
handlers.recordSubtitleTiming('飛び越えて', 3.2, 3.4);
|
||||
handlers.recordSubtitleTiming('手にある', 3.5, 3.7);
|
||||
|
||||
assert.deepEqual(immersion.slice(2), [{ text: '飛び越えてみたくて', start: 3, end: 6 }]);
|
||||
assert.deepEqual(timing.slice(2), [{ text: '飛び越えてみたくて', start: 3, end: 6 }]);
|
||||
|
||||
// A backward seek means the user is rewatching: the timing history (a viewing log)
|
||||
// records the revisited line again, while immersion stays once-per-media.
|
||||
handlers.onTimePosUpdate?.(30);
|
||||
handlers.onTimePosUpdate?.(2);
|
||||
handlers.recordSubtitleTiming('今', 0.8, 1.5);
|
||||
handlers.recordImmersionSubtitleLine('今', 0.8, 1.5);
|
||||
|
||||
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);
|
||||
handlers.recordSubtitleTiming('今', 0.8, 1.5);
|
||||
|
||||
assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
|
||||
|
||||
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', () => {
|
||||
const appState = {
|
||||
initialArgs: null,
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: { currentTimePos: 2 },
|
||||
immersionTracker: { recordSubtitleLine: () => {} },
|
||||
subtitleTimingTracker: { recordSubtitle: () => {} },
|
||||
activeParsedSubtitleCues: [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある物差しでは',
|
||||
source: 'canonical-ass' as const,
|
||||
},
|
||||
] as Array<{ startTime: number; endTime: number; text: string; source?: 'canonical-ass' }>,
|
||||
activeParsedSubtitleSource: 'track-a.ass' as string | null,
|
||||
currentMediaPath: '/video.mkv',
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
playbackPaused: null,
|
||||
previousSecondarySubVisibility: false,
|
||||
};
|
||||
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
||||
appState,
|
||||
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: () => {},
|
||||
})();
|
||||
|
||||
assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今 手にある物差しでは');
|
||||
|
||||
// The new track's cues arrive only after an async re-parse; until then, the old
|
||||
// track's canonical lyric must not replace the new track's live text.
|
||||
handlers.onSubtitleTrackChange?.(2);
|
||||
|
||||
assert.deepEqual(appState.activeParsedSubtitleCues, []);
|
||||
assert.equal(appState.activeParsedSubtitleSource, null);
|
||||
assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今\n手にある');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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,
|
||||
} from './primary-subtitle-text';
|
||||
|
||||
type AnilistPostWatchRunOptions = {
|
||||
watchedSeconds?: number;
|
||||
@@ -15,6 +21,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
overlayRuntimeInitialized: boolean;
|
||||
mpvClient: {
|
||||
connected?: boolean;
|
||||
currentSubText?: string;
|
||||
currentSecondarySubText?: string;
|
||||
currentTimePos?: number;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
@@ -36,6 +43,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
recordSubtitle?: (text: string, start: number, end: number, secondaryText?: string) => void;
|
||||
} | null;
|
||||
activeParsedSubtitleCues?: SubtitleCue[] | null;
|
||||
/** Cache key of the source the cues were parsed from; cleared with the cues. */
|
||||
activeParsedSubtitleSource?: string | null;
|
||||
currentMediaPath?: string | null;
|
||||
currentSubText: string;
|
||||
currentSubAssText: string;
|
||||
@@ -53,11 +62,14 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
recordAnilistMediaDuration?: (durationSec: number) => void;
|
||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
onSecondarySubtitleChange?: (text: string) => void;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
logSubtitleProcessingDebug?: (message: string) => void;
|
||||
onSubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSecondarySubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSecondarySubtitleDelayChange?: (delay: number) => void;
|
||||
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||
updateCurrentMediaPath: (path: string) => void;
|
||||
restoreMpvSubVisibility: () => void;
|
||||
@@ -74,6 +86,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;
|
||||
@@ -93,6 +106,38 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
const immersionLineDedupGate = createSubtitleLineDedupGate({
|
||||
getParsedCues: () => deps.appState.activeParsedSubtitleCues,
|
||||
});
|
||||
// One seen-set per consumer: canonical cues overlap, so live samples resolve to
|
||||
// shifting subsets (A, then A+B, then B). Remembering every recorded cue -- not just
|
||||
// the previous sample -- keeps each authored line recorded exactly once per source.
|
||||
const recordedImmersionCanonicalKeys = new Set<string>();
|
||||
const recordedTimingCanonicalKeys = new Set<string>();
|
||||
// Bumped on track/media changes so an immersion record whose tokenization resolves
|
||||
// after the change is dropped instead of landing in the next session.
|
||||
let subtitleSessionEpoch = 0;
|
||||
let lastTimePosForTimingReset: number | null = null;
|
||||
const canonicalCueKey = (cue: SubtitleCue): string =>
|
||||
`${cue.startTime}|${cue.endTime}|${cue.text}`;
|
||||
const resetSubtitleDeduplication = (): void => {
|
||||
immersionLineDedupGate.reset();
|
||||
recordedImmersionCanonicalKeys.clear();
|
||||
recordedTimingCanonicalKeys.clear();
|
||||
subtitleSessionEpoch += 1;
|
||||
lastTimePosForTimingReset = null;
|
||||
};
|
||||
const resolveCanonicalSample = (liveText: string, startSec: number) =>
|
||||
resolveCanonicalPrimarySubtitle({
|
||||
liveText,
|
||||
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({
|
||||
liveText,
|
||||
currentTimeSec: startSec,
|
||||
cues: deps.appState.activeParsedSubtitleCues,
|
||||
});
|
||||
const hasInitialPlaybackQuitOnDisconnectArg = (): boolean =>
|
||||
Boolean(
|
||||
deps.appState.initialArgs?.managedPlayback ||
|
||||
@@ -111,45 +156,107 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback),
|
||||
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
|
||||
quitApp: () => deps.quitApp(),
|
||||
resolveSubtitleText: (liveText: string) =>
|
||||
resolvePrimarySubtitleText({
|
||||
liveText,
|
||||
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;
|
||||
if (!tracker?.recordSubtitleLine) {
|
||||
return;
|
||||
}
|
||||
const recordLine = (lineText: string, startSec: number, endSec: number): void => {
|
||||
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null;
|
||||
const cachedTokens =
|
||||
deps.appState.currentSubtitleData?.text === lineText
|
||||
? deps.appState.currentSubtitleData.tokens
|
||||
: null;
|
||||
if (cachedTokens) {
|
||||
tracker.recordSubtitleLine?.(lineText, startSec, endSec, cachedTokens, secondaryText);
|
||||
return;
|
||||
}
|
||||
if (!deps.tokenizeSubtitleForImmersion) {
|
||||
tracker.recordSubtitleLine?.(lineText, startSec, endSec, null, secondaryText);
|
||||
return;
|
||||
}
|
||||
const epochAtRecord = subtitleSessionEpoch;
|
||||
void deps
|
||||
.tokenizeSubtitleForImmersion(lineText)
|
||||
.then((payload) => {
|
||||
if (subtitleSessionEpoch !== epochAtRecord) {
|
||||
return;
|
||||
}
|
||||
tracker.recordSubtitleLine?.(
|
||||
lineText,
|
||||
startSec,
|
||||
endSec,
|
||||
payload?.tokens ?? null,
|
||||
secondaryText,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (subtitleSessionEpoch !== epochAtRecord) {
|
||||
return;
|
||||
}
|
||||
tracker.recordSubtitleLine?.(lineText, startSec, endSec, null, secondaryText);
|
||||
});
|
||||
};
|
||||
const canonical = resolveCanonicalSample(text, start);
|
||||
if (canonical) {
|
||||
for (const cue of canonical.cues) {
|
||||
const key = canonicalCueKey(cue);
|
||||
if (recordedImmersionCanonicalKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
recordedImmersionCanonicalKeys.add(key);
|
||||
recordLine(cue.text, cue.startTime, cue.endTime);
|
||||
}
|
||||
return;
|
||||
}
|
||||
text = stripFragmentsForRecording(text, start);
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
if (!immersionLineDedupGate.shouldRecord({ text, startSec: start, endSec: end })) {
|
||||
return;
|
||||
}
|
||||
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null;
|
||||
const cachedTokens =
|
||||
deps.appState.currentSubtitleData?.text === text
|
||||
? deps.appState.currentSubtitleData.tokens
|
||||
: null;
|
||||
if (cachedTokens) {
|
||||
tracker.recordSubtitleLine(text, start, end, cachedTokens, secondaryText);
|
||||
return;
|
||||
}
|
||||
if (!deps.tokenizeSubtitleForImmersion) {
|
||||
tracker.recordSubtitleLine(text, start, end, null, secondaryText);
|
||||
return;
|
||||
}
|
||||
void deps
|
||||
.tokenizeSubtitleForImmersion(text)
|
||||
.then((payload) => {
|
||||
tracker.recordSubtitleLine?.(text, start, end, payload?.tokens ?? null, secondaryText);
|
||||
})
|
||||
.catch(() => {
|
||||
tracker.recordSubtitleLine?.(text, start, end, null, secondaryText);
|
||||
});
|
||||
recordLine(text, start, end);
|
||||
},
|
||||
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
||||
recordSubtitleTiming: (text: string, start: number, end: number) =>
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||
text,
|
||||
start,
|
||||
end,
|
||||
deps.appState.mpvClient?.currentSecondarySubText || undefined,
|
||||
),
|
||||
recordSubtitleTiming: (text: string, start: number, end: number) => {
|
||||
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
|
||||
const canonical = resolveCanonicalSample(text, start);
|
||||
if (!canonical) {
|
||||
const recordableText = stripFragmentsForRecording(text, start);
|
||||
if (!recordableText.trim()) {
|
||||
return;
|
||||
}
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||
recordableText,
|
||||
start,
|
||||
end,
|
||||
secondaryText,
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const cue of canonical.cues) {
|
||||
const key = canonicalCueKey(cue);
|
||||
if (recordedTimingCanonicalKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
recordedTimingCanonicalKeys.add(key);
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||
cue.text,
|
||||
cue.startTime,
|
||||
cue.endTime,
|
||||
secondaryText,
|
||||
);
|
||||
}
|
||||
},
|
||||
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) =>
|
||||
deps.maybeRunAnilistPostWatchUpdate(options),
|
||||
logSubtitleTimingError: (message: string, error: unknown) =>
|
||||
@@ -170,9 +277,22 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
? (message: string) => deps.logSubtitleProcessingDebug!(message)
|
||||
: undefined,
|
||||
onSubtitleTrackChange: (sid: number | null) => {
|
||||
immersionLineDedupGate.reset();
|
||||
resetSubtitleDeduplication();
|
||||
// The replacement track's cues arrive only after an async re-read and re-parse.
|
||||
// Clearing synchronously keeps the previous track's canonical cues from
|
||||
// substituting into, or recording against, the new track's live text. The source
|
||||
// key is cleared with the cues so cue-list consumers (the sidebar snapshot)
|
||||
// re-parse on demand instead of trusting the stale pairing.
|
||||
deps.appState.activeParsedSubtitleCues = [];
|
||||
deps.appState.activeParsedSubtitleSource = null;
|
||||
deps.onSubtitleTrackChange?.(sid);
|
||||
},
|
||||
onSecondarySubtitleTrackChange: deps.onSecondarySubtitleTrackChange
|
||||
? (sid: number | null) => deps.onSecondarySubtitleTrackChange!(sid)
|
||||
: undefined,
|
||||
onSecondarySubtitleDelayChange: deps.onSecondarySubtitleDelayChange
|
||||
? (delay: number) => deps.onSecondarySubtitleDelayChange!(delay)
|
||||
: undefined,
|
||||
onSubtitleTrackListChange: deps.onSubtitleTrackListChange
|
||||
? (trackList: unknown[] | null) => deps.onSubtitleTrackListChange!(trackList)
|
||||
: undefined,
|
||||
@@ -182,10 +302,15 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
},
|
||||
broadcastSubtitleAss: (text: string) =>
|
||||
deps.broadcastToOverlayWindows('subtitle-ass:set', text),
|
||||
broadcastSecondarySubtitle: (text: string) =>
|
||||
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
|
||||
broadcastSecondarySubtitle: (text: string) => {
|
||||
if (deps.onSecondarySubtitleChange) {
|
||||
deps.onSecondarySubtitleChange(text);
|
||||
return;
|
||||
}
|
||||
deps.broadcastToOverlayWindows('secondary-subtitle:set', text);
|
||||
},
|
||||
updateCurrentMediaPath: (path: string) => {
|
||||
immersionLineDedupGate.reset();
|
||||
resetSubtitleDeduplication();
|
||||
deps.updateCurrentMediaPath(path);
|
||||
},
|
||||
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
||||
@@ -217,9 +342,23 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
},
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
onTimePosUpdate: deps.onTimePosUpdate
|
||||
? (time: number) => deps.onTimePosUpdate!(time)
|
||||
: undefined,
|
||||
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.
|
||||
if (
|
||||
Number.isFinite(time) &&
|
||||
lastTimePosForTimingReset !== null &&
|
||||
time <= lastTimePosForTimingReset - SEEK_LIKE_TIME_DELTA_SECONDS
|
||||
) {
|
||||
recordedTimingCanonicalKeys.clear();
|
||||
}
|
||||
if (Number.isFinite(time)) {
|
||||
lastTimePosForTimingReset = time;
|
||||
}
|
||||
deps.onTimePosUpdate?.(time);
|
||||
},
|
||||
onFullscreenChange: deps.onFullscreenChange
|
||||
? (fullscreen: boolean) => deps.onFullscreenChange!(fullscreen)
|
||||
: undefined,
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
}
|
||||
@@ -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,676 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
||||
import {
|
||||
resolveCanonicalPrimarySubtitle,
|
||||
resolvePrimarySubtitleText,
|
||||
stripCanonicalFragmentLines,
|
||||
} from './primary-subtitle-text';
|
||||
|
||||
test('resolvePrimarySubtitleText collapses full-span ASS style layers through parsed cues', () => {
|
||||
const ass = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 3,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)\\bord0\\blur0.8}鏡の奥まで目を凝らして',
|
||||
'Dialogue: 2,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)}鏡の奥まで目を凝らして',
|
||||
'Dialogue: 1,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)\\bord6}鏡の奥まで目を凝らして',
|
||||
'Dialogue: 0,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)\\bord8\\blur4}鏡の奥まで目を凝らして',
|
||||
].join('\n');
|
||||
const cues = parseSubtitleCues(ass, 'polar-opposites-s01e08.ass');
|
||||
|
||||
assert.deepEqual(cues, [
|
||||
{ startTime: 22 * 60 + 40.05, endTime: 22 * 60 + 45.76, text: '鏡の奥まで目を凝らして' },
|
||||
]);
|
||||
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: [
|
||||
'鏡の奥まで目を凝らして',
|
||||
'鏡の奥まで目を凝らして',
|
||||
'鏡の奥まで目を凝らして',
|
||||
'鏡の奥まで目を凝らして',
|
||||
].join('\n'),
|
||||
currentTimeSec: 22 * 60 + 44,
|
||||
cues,
|
||||
}),
|
||||
'鏡の奥まで目を凝らして',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText keeps live text when active parsed cues do not explain it all', () => {
|
||||
const liveText = '普通のセリフ\n鏡の奥まで目を凝らして\n鏡の奥まで目を凝らして';
|
||||
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText,
|
||||
currentTimeSec: 2,
|
||||
cues: [{ startTime: 1, endTime: 3, text: '鏡の奥まで目を凝らして' }],
|
||||
}),
|
||||
liveText,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () => {
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: '一行目\n一行目\n二行目\n二行目',
|
||||
currentTimeSec: 2,
|
||||
cues: [
|
||||
{ startTime: 1, endTime: 3, text: '一行目' },
|
||||
{ startTime: 1, endTime: 3, text: '二行目' },
|
||||
],
|
||||
}),
|
||||
'一行目\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,
|
||||
}),
|
||||
'少しだけ好きになる',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText tolerates stale time-pos at a parsed cue edge', () => {
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: '新しい行\n新しい行',
|
||||
currentTimeSec: 0.8,
|
||||
cues: [{ startTime: 1, endTime: 3, text: '新しい行' }],
|
||||
}),
|
||||
'新しい行',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText prefers an active canonical cue over flattened mpv glyphs', () => {
|
||||
// mpv renders each simultaneously active ASS event on its own sub-text line.
|
||||
const text = resolvePrimarySubtitleText({
|
||||
liveText: '今\n今\n今\n手\n手\n手\nにある\nにある\nにある',
|
||||
currentTimeSec: 2,
|
||||
cues: [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(text, '今 手にある');
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText preserves live text outside canonical cue timing', () => {
|
||||
const text = resolvePrimarySubtitleText({
|
||||
liveText: '通常の会話',
|
||||
currentTimeSec: 8,
|
||||
cues: [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(text, '通常の会話');
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText keeps concurrent dialogue that is not part of the animation', () => {
|
||||
// An insert song's canonical window can overlap real dialogue on the same track.
|
||||
const text = resolvePrimarySubtitleText({
|
||||
liveText: '普通のセリフ\n今\n手にある',
|
||||
currentTimeSec: 2,
|
||||
cues: [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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: '次のセリフ',
|
||||
currentTimeSec: 4.1,
|
||||
cues: [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(text, '次のセリフ');
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText survives overlapping frames of consecutive karaoke lines', () => {
|
||||
// Near a line boundary the previous line's exit frames and the next line's entrance
|
||||
// frames render together; neither line alone explains every live segment.
|
||||
const cues = [
|
||||
{ startTime: 1.2, endTime: 3.8, text: '今 手にある', source: 'canonical-ass' as const },
|
||||
{ startTime: 3.8, endTime: 6.4, text: '物差しでは', source: 'canonical-ass' as const },
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: '手にある\n物差し\nでは',
|
||||
currentTimeSec: 3.6,
|
||||
cues,
|
||||
}),
|
||||
'今 手にある',
|
||||
);
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: '手にある\n物差し\nでは',
|
||||
currentTimeSec: 3.9,
|
||||
cues,
|
||||
}),
|
||||
'物差しでは',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText combines simultaneous canonical cues in source order', () => {
|
||||
const text = resolvePrimarySubtitleText({
|
||||
liveText: 'fir\nst\nsecond',
|
||||
currentTimeSec: 2,
|
||||
cues: [
|
||||
{ startTime: 1, endTime: 3, text: 'first', source: 'canonical-ass' },
|
||||
{ startTime: 1.5, endTime: 2.5, text: 'second', source: 'canonical-ass' },
|
||||
],
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const cue = {
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass' as const,
|
||||
};
|
||||
const resolved = resolveCanonicalPrimarySubtitle({
|
||||
liveText: '今\n手にある',
|
||||
currentTimeSec: 0.8,
|
||||
cues: [cue],
|
||||
});
|
||||
|
||||
assert.deepEqual(resolved, {
|
||||
text: '今 手にある',
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
cues: [cue],
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveCanonicalPrimarySubtitle covers exit frames that outlive the authored timing', () => {
|
||||
// Real generated animations keep exit fragments on screen well past the authored
|
||||
// comment window; the recorded animation envelope is what makes them resolvable.
|
||||
const cue = {
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass' as const,
|
||||
animationStartTime: 0.8,
|
||||
animationEndTime: 5.6,
|
||||
};
|
||||
const resolved = resolveCanonicalPrimarySubtitle({
|
||||
liveText: '今\n手にある',
|
||||
currentTimeSec: 5.4,
|
||||
cues: [cue],
|
||||
});
|
||||
|
||||
assert.deepEqual(resolved, {
|
||||
text: '今 手にある',
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
cues: [cue],
|
||||
});
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText handles late exit frames overlapping the next active line', () => {
|
||||
// The previous line's exit fragments can persist more than a second into the next
|
||||
// authored line. The next line supplies the text; the previous line's envelope
|
||||
// explains its lingering fragments.
|
||||
const cues = [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass' as const,
|
||||
animationStartTime: 0.8,
|
||||
animationEndTime: 5.6,
|
||||
},
|
||||
{
|
||||
startTime: 3.8,
|
||||
endTime: 6.4,
|
||||
text: '物差しでは',
|
||||
source: 'canonical-ass' as const,
|
||||
animationStartTime: 3.4,
|
||||
animationEndTime: 7.0,
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: '手にある\n手にある\n物差し\nでは',
|
||||
currentTimeSec: 5.2,
|
||||
cues,
|
||||
}),
|
||||
'物差しでは',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCanonicalPrimarySubtitle rejects unrelated live text at the animation edge', () => {
|
||||
const resolved = resolveCanonicalPrimarySubtitle({
|
||||
liveText: '次のセリフ',
|
||||
currentTimeSec: 4.1,
|
||||
cues: [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
|
||||
test('stripCanonicalFragmentLines drops fragment lines but keeps concurrent dialogue', () => {
|
||||
const cues = [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass' as const,
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
stripCanonicalFragmentLines({
|
||||
liveText: '普通のセリフ\n今\n手にある',
|
||||
currentTimeSec: 2,
|
||||
cues,
|
||||
}),
|
||||
'普通のセリフ',
|
||||
);
|
||||
// No canonical cue nearby: nothing to strip.
|
||||
assert.equal(
|
||||
stripCanonicalFragmentLines({ liveText: '普通のセリフ\n今', currentTimeSec: 30, cues }),
|
||||
'普通のセリフ\n今',
|
||||
);
|
||||
// Everything matched (defensive): return the input rather than empty text.
|
||||
assert.equal(
|
||||
stripCanonicalFragmentLines({ liveText: '今\n手にある', currentTimeSec: 2, cues }),
|
||||
'今\n手にある',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCanonicalPrimarySubtitle picks the cue its fragments spell, not the nearest', () => {
|
||||
// In the gap between two authored spans, the next line sits closer in time while only
|
||||
// the previous line's exit fragments are on screen: the fragments decide.
|
||||
const cues = [
|
||||
{
|
||||
startTime: 1,
|
||||
endTime: 3,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass' as const,
|
||||
animationStartTime: 0.6,
|
||||
animationEndTime: 3.9,
|
||||
},
|
||||
{
|
||||
startTime: 4,
|
||||
endTime: 6,
|
||||
text: '物差しでは',
|
||||
source: 'canonical-ass' as const,
|
||||
animationStartTime: 3.5,
|
||||
animationEndTime: 6.4,
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
resolveCanonicalPrimarySubtitle({ liveText: '手にある', currentTimeSec: 3.8, cues })?.text,
|
||||
'今 手にある',
|
||||
);
|
||||
// Fragments of both lines in the gap: both envelopes cover the moment (distance 0),
|
||||
// and the earlier line wins the tie while it is still animating out.
|
||||
assert.equal(
|
||||
resolveCanonicalPrimarySubtitle({ liveText: '手にある\n物差し', currentTimeSec: 3.8, cues })
|
||||
?.text,
|
||||
'今 手にある',
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
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
|
||||
// entrance/exit frames actually run past the authored timing.
|
||||
const LIVE_CUE_EDGE_TOLERANCE_SECONDS = 1;
|
||||
|
||||
export interface ResolvedPrimarySubtitle {
|
||||
text: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
/** The parsed cues behind `text`, for consumers that record lines individually. */
|
||||
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 animationSpan(cue: SubtitleCue): { start: number; end: number } {
|
||||
return {
|
||||
start: cue.animationStartTime ?? cue.startTime,
|
||||
end: cue.animationEndTime ?? cue.endTime,
|
||||
};
|
||||
}
|
||||
|
||||
function nearbyCanonicalCues(
|
||||
cues: readonly SubtitleCue[] | null | undefined,
|
||||
currentTimeSec: number,
|
||||
includeFragmentGrids = false,
|
||||
): SubtitleCue[] {
|
||||
return (cues ?? []).filter((cue) => {
|
||||
if (
|
||||
(cue.source !== 'canonical-ass' && cue.source !== 'reconstructed-ass') ||
|
||||
(!includeFragmentGrids && cue.assLayout?.kind === 'fragment-grid')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const span = animationSpan(cue);
|
||||
return (
|
||||
span.end >= currentTimeSec - LIVE_CUE_EDGE_TOLERANCE_SECONDS &&
|
||||
span.start <= currentTimeSec + LIVE_CUE_EDGE_TOLERANCE_SECONDS
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function compactWhitespace(text: string): string {
|
||||
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[] {
|
||||
return text.split('\n').map(compactWhitespace).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed cues have already collapsed exact ASS layers and animation runs. Trust that
|
||||
* cleaner view only when every live mpv line is accounted for by an active parsed cue.
|
||||
* This keeps unrelated concurrent dialogue on the live fallback while removing style
|
||||
* stacks where mpv repeats one full lyric for fill, border, blur, and shadow layers.
|
||||
*/
|
||||
function resolveActiveParsedPrimarySubtitle(options: {
|
||||
liveText: string;
|
||||
currentTimeSec: number;
|
||||
cues: readonly SubtitleCue[] | null | undefined;
|
||||
}): ResolvedPrimarySubtitle | null {
|
||||
if (!Number.isFinite(options.currentTimeSec)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const liveSegments = compactLineSegments(options.liveText);
|
||||
if (liveSegments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const liveSegmentSet = new Set(liveSegments);
|
||||
const selected = (options.cues ?? []).filter((cue) => {
|
||||
if (
|
||||
cue.startTime > options.currentTimeSec + LIVE_CUE_EDGE_TOLERANCE_SECONDS ||
|
||||
cue.endTime <= options.currentTimeSec - LIVE_CUE_EDGE_TOLERANCE_SECONDS
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const cueSegments = compactLineSegments(cue.text);
|
||||
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 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;
|
||||
}
|
||||
|
||||
// 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: groups.join(CUE_BOUNDARY),
|
||||
startTime: Math.min(...displayCues.map((cue) => cue.startTime)),
|
||||
endTime: Math.max(...displayCues.map((cue) => cue.endTime)),
|
||||
cues: displayCues,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* mpv's `sub-text` renders each simultaneously active ASS event on its own line, so
|
||||
* while a generated animation plays every live line is a contiguous piece of the
|
||||
* authored text. A line that is not -- concurrent dialogue during an insert song, or a
|
||||
* fresh line starting just after the animation ended -- proves the live text is not this
|
||||
* animation, and substituting the canonical line would swallow real dialogue.
|
||||
*/
|
||||
function liveTextIsFromCues(liveText: string, cues: readonly SubtitleCue[]): boolean {
|
||||
const compactCues = cues.map((cue) => compactWhitespace(cue.text));
|
||||
const segments = liveText.split('\n').map(compactWhitespace).filter(Boolean);
|
||||
return (
|
||||
segments.length > 0 &&
|
||||
segments.every((segment) => compactCues.some((cueText) => cueText.includes(segment)))
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCanonicalPrimarySubtitle(options: {
|
||||
liveText: string;
|
||||
currentTimeSec: number;
|
||||
cues: readonly SubtitleCue[] | null | undefined;
|
||||
}): ResolvedPrimarySubtitle | null {
|
||||
if (!Number.isFinite(options.currentTimeSec)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Consecutive karaoke lines overlap: one line's exit frames are still on screen while
|
||||
// the next line's entrance frames appear. The fragment check therefore runs against
|
||||
// every canonical cue whose animation envelope reaches the current time, while only
|
||||
// the active (or single nearest) cue supplies the displayed text.
|
||||
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec);
|
||||
const active = nearby.filter(
|
||||
(cue) => cue.startTime <= options.currentTimeSec && cue.endTime > options.currentTimeSec,
|
||||
);
|
||||
const liveSegments = options.liveText.split('\n').map(compactWhitespace).filter(Boolean);
|
||||
const selected =
|
||||
active.length > 0
|
||||
? active
|
||||
: nearby
|
||||
// Between authored spans, proximity alone can pick the wrong neighbor: the
|
||||
// next line can sit closer while only the previous line's exit fragments are
|
||||
// on screen. Only cues that explain at least one live line may be selected.
|
||||
.filter((cue) => {
|
||||
const cueText = compactWhitespace(cue.text);
|
||||
return liveSegments.some((segment) => cueText.includes(segment));
|
||||
})
|
||||
.map((cue) => {
|
||||
const span = animationSpan(cue);
|
||||
const distance =
|
||||
options.currentTimeSec < span.start
|
||||
? span.start - options.currentTimeSec
|
||||
: Math.max(0, options.currentTimeSec - span.end);
|
||||
return { cue, distance };
|
||||
})
|
||||
.sort((a, b) => a.distance - b.distance || a.cue.startTime - b.cue.startTime)
|
||||
.slice(0, 1)
|
||||
.map(({ cue }) => cue);
|
||||
if (selected.length === 0 || !liveTextIsFromCues(options.liveText, nearby)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groups = uniqueCueTextGroups(orderCuesForDisplay(selected));
|
||||
return {
|
||||
text: groups.join(CUE_BOUNDARY),
|
||||
startTime: Math.min(...selected.map((cue) => cue.startTime)),
|
||||
endTime: Math.max(...selected.map((cue) => cue.endTime)),
|
||||
cues: selected,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. An
|
||||
* all-fragment visual grid becomes empty; other all-matched input remains unchanged as a
|
||||
* defensive fallback.
|
||||
*/
|
||||
export function stripCanonicalFragmentLines(options: {
|
||||
liveText: string;
|
||||
currentTimeSec: number;
|
||||
cues: readonly SubtitleCue[] | null | undefined;
|
||||
}): string {
|
||||
if (!Number.isFinite(options.currentTimeSec)) {
|
||||
return removeLiveGlyphFragmentLines(options.liveText);
|
||||
}
|
||||
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec, true);
|
||||
if (nearby.length === 0) {
|
||||
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));
|
||||
});
|
||||
if (kept.length > 0) return removeLiveGlyphFragmentLines(kept.join('\n'));
|
||||
if (nearby.some((cue) => cue.assLayout?.kind === 'fragment-grid')) return '';
|
||||
return removeLiveGlyphFragmentLines(options.liveText);
|
||||
}
|
||||
|
||||
export function resolvePrimarySubtitleText(options: {
|
||||
liveText: string;
|
||||
currentTimeSec: number;
|
||||
cues: readonly SubtitleCue[] | null | undefined;
|
||||
}): string {
|
||||
const liveText = cuesUseAssSyntax(options.cues)
|
||||
? removeAssControlDebrisLines(options.liveText)
|
||||
: options.liveText;
|
||||
if (!liveText.trim()) {
|
||||
return liveText;
|
||||
}
|
||||
return (
|
||||
resolveCanonicalPrimarySubtitle({
|
||||
liveText,
|
||||
currentTimeSec: options.currentTimeSec,
|
||||
cues: options.cues,
|
||||
})?.text ??
|
||||
resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ??
|
||||
removeLiveGlyphFragmentLines(liveText)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
||||
import {
|
||||
createSecondarySubtitleTrackController,
|
||||
findActiveSubtitleText,
|
||||
} from './secondary-subtitle-track';
|
||||
|
||||
test('findActiveSubtitleText combines unique simultaneous parsed cues', () => {
|
||||
assert.equal(
|
||||
findActiveSubtitleText(
|
||||
[
|
||||
{ startTime: 1, endTime: 3, text: 'Your' },
|
||||
{ startTime: 1, endTime: 3, text: 'Your' },
|
||||
{ startTime: 1, endTime: 3, text: 'mosaic' },
|
||||
],
|
||||
2,
|
||||
),
|
||||
'Your\nmosaic',
|
||||
);
|
||||
});
|
||||
|
||||
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 = '';
|
||||
const resolverInputs: Array<{ allowSelectedFallback?: boolean }> = [];
|
||||
const ass = `[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
|
||||
Dialogue: 1,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
|
||||
Dialogue: 2,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
|
||||
Dialogue: 3,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
|
||||
Dialogue: 4,0:00:01.00,0:00:03.00,Sign,,0,0,0,,mosaic`;
|
||||
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';
|
||||
if (name === 'secondary-sub-delay') return 0;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => 2,
|
||||
resolveSubtitleSource: async (input) => {
|
||||
resolverInputs.push(input);
|
||||
return { path: '/subs/english.ass', sourceKey: '/subs/english.ass' };
|
||||
},
|
||||
loadSubtitleSourceText: async () => ass,
|
||||
parseSubtitleCues,
|
||||
setCurrentSecondaryText: (text) => {
|
||||
currentText = text;
|
||||
},
|
||||
broadcastSecondaryText: (text) => broadcasts.push(text),
|
||||
});
|
||||
|
||||
await controller.refresh();
|
||||
controller.handleLiveText('Your\nYour\nYour\nYour\nmosaic');
|
||||
|
||||
assert.equal(resolverInputs[0]?.allowSelectedFallback, false);
|
||||
assert.equal(currentText, 'Your\nmosaic');
|
||||
assert.deepEqual(broadcasts, ['Your\nmosaic']);
|
||||
});
|
||||
|
||||
test('secondary track controller follows parsed cue timing and subtitle delay', async () => {
|
||||
const broadcasts: string[] = [];
|
||||
let time = 2.25;
|
||||
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';
|
||||
if (name === 'secondary-sub-delay') return 0.5;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => time,
|
||||
resolveSubtitleSource: async () => ({ path: '/subs/english.srt', sourceKey: 'english' }),
|
||||
loadSubtitleSourceText: async () => '',
|
||||
parseSubtitleCues: () => [
|
||||
{ startTime: 1, endTime: 2, text: 'first' },
|
||||
{ startTime: 2, endTime: 3, text: 'second' },
|
||||
],
|
||||
setCurrentSecondaryText: () => {},
|
||||
broadcastSecondaryText: (text) => broadcasts.push(text),
|
||||
});
|
||||
|
||||
await controller.refresh();
|
||||
controller.handleDelayChange(0);
|
||||
time = 3.25;
|
||||
controller.handleTimePos(time);
|
||||
|
||||
assert.deepEqual(broadcasts, ['first', 'second', '']);
|
||||
});
|
||||
|
||||
test('secondary track controller clears old parsed text immediately on a track change', async () => {
|
||||
const broadcasts: string[] = [];
|
||||
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, external: true }];
|
||||
if (name === 'path') return '/media/video.mkv';
|
||||
if (name === 'secondary-sub-delay') return 0;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => 2,
|
||||
resolveSubtitleSource: async () => ({ path: '/subs/old.ass', sourceKey: 'old' }),
|
||||
loadSubtitleSourceText: async () => '',
|
||||
parseSubtitleCues: () => [{ startTime: 1, endTime: 3, text: 'old parsed text' }],
|
||||
setCurrentSecondaryText: (text) => {
|
||||
currentText = text;
|
||||
},
|
||||
broadcastSecondaryText: (text) => broadcasts.push(text),
|
||||
});
|
||||
|
||||
await controller.refresh();
|
||||
controller.handleTrackChange();
|
||||
controller.handleLiveText('new live text');
|
||||
|
||||
assert.equal(currentText, 'new live text');
|
||||
assert.deepEqual(broadcasts, ['old parsed text', '', 'new live text']);
|
||||
});
|
||||
|
||||
test('secondary track controller falls back to live mpv text without a readable source', async () => {
|
||||
const broadcasts: string[] = [];
|
||||
let currentText = '';
|
||||
const controller = createSecondarySubtitleTrackController({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'secondary-sid') return 'no';
|
||||
if (name === 'path') return '/media/video.mkv';
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => 2,
|
||||
resolveSubtitleSource: async () => null,
|
||||
loadSubtitleSourceText: async () => '',
|
||||
parseSubtitleCues: () => [],
|
||||
setCurrentSecondaryText: (text) => {
|
||||
currentText = text;
|
||||
},
|
||||
broadcastSecondaryText: (text) => broadcasts.push(text),
|
||||
});
|
||||
|
||||
controller.handleLiveText('live fallback');
|
||||
await controller.refresh();
|
||||
|
||||
assert.equal(currentText, 'live fallback');
|
||||
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;
|
||||
const controller = createSecondarySubtitleTrackController({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') {
|
||||
return [{ type: 'sub', id: 2, external: false, 'ff-index': 3 }];
|
||||
}
|
||||
if (name === 'path') return '/media/video.mkv';
|
||||
if (name === 'secondary-sub-delay') return 0;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => 2,
|
||||
resolveSubtitleSource: async () => {
|
||||
resolveCalls += 1;
|
||||
return { path: `/tmp/extracted-${resolveCalls}.ass`, sourceKey: 'embedded-track-2' };
|
||||
},
|
||||
loadSubtitleSourceText: async () => '',
|
||||
parseSubtitleCues: () => {
|
||||
parseCalls += 1;
|
||||
return [{ startTime: 1, endTime: 3, text: 'parsed' }];
|
||||
},
|
||||
setCurrentSecondaryText: () => {},
|
||||
broadcastSecondaryText: () => {},
|
||||
});
|
||||
|
||||
await controller.refresh();
|
||||
await controller.refresh();
|
||||
|
||||
assert.equal(resolveCalls, 1);
|
||||
assert.equal(parseCalls, 1);
|
||||
});
|
||||
|
||||
test('secondary track controller ignores and cleans up a refresh invalidated by reset', async () => {
|
||||
const broadcasts: string[] = [];
|
||||
let notifyResolveStarted: (() => void) | undefined;
|
||||
let releaseResolve: (() => void) | undefined;
|
||||
let cleanupCalls = 0;
|
||||
let parseCalls = 0;
|
||||
const resolveStarted = new Promise<void>((resolve) => {
|
||||
notifyResolveStarted = resolve;
|
||||
});
|
||||
const resolveGate = new Promise<void>((resolve) => {
|
||||
releaseResolve = resolve;
|
||||
});
|
||||
const controller = createSecondarySubtitleTrackController({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') return [{ type: 'sub', id: 2, external: true }];
|
||||
if (name === 'path') return '/media/video.mkv';
|
||||
if (name === 'secondary-sub-delay') return 0;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => 2,
|
||||
resolveSubtitleSource: async () => {
|
||||
notifyResolveStarted?.();
|
||||
await resolveGate;
|
||||
return {
|
||||
path: '/subs/secondary.ass',
|
||||
sourceKey: 'secondary',
|
||||
cleanup: async () => {
|
||||
cleanupCalls += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
loadSubtitleSourceText: async () => '',
|
||||
parseSubtitleCues: () => {
|
||||
parseCalls += 1;
|
||||
return [{ startTime: 1, endTime: 3, text: 'stale' }];
|
||||
},
|
||||
setCurrentSecondaryText: () => {},
|
||||
broadcastSecondaryText: (text) => broadcasts.push(text),
|
||||
});
|
||||
|
||||
const refresh = controller.refresh();
|
||||
await resolveStarted;
|
||||
controller.reset();
|
||||
releaseResolve?.();
|
||||
await refresh;
|
||||
|
||||
assert.deepEqual(broadcasts, ['']);
|
||||
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, 'それよりも ノート…');
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
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;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type ResolvedSubtitleSource = {
|
||||
path: string;
|
||||
sourceKey: string;
|
||||
cleanup?: () => Promise<void>;
|
||||
};
|
||||
|
||||
type SecondarySubtitleSourceInput = {
|
||||
currentExternalFilenameRaw: unknown;
|
||||
currentTrackRaw: unknown;
|
||||
trackListRaw: unknown;
|
||||
sidRaw: unknown;
|
||||
videoPath: string;
|
||||
allowSelectedFallback?: boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function trackId(value: unknown): number | null {
|
||||
if (typeof value !== 'number' && typeof value !== 'string') return null;
|
||||
const number = typeof value === 'number' ? value : Number(value.trim());
|
||||
return Number.isInteger(number) ? number : null;
|
||||
}
|
||||
|
||||
function buildSelectedTrackIdentity(
|
||||
trackListRaw: unknown,
|
||||
sidRaw: unknown,
|
||||
videoPath: string,
|
||||
): string | null {
|
||||
if (!Array.isArray(trackListRaw)) return null;
|
||||
const sid = trackId(sidRaw);
|
||||
if (sid === null) return null;
|
||||
|
||||
const selectedTrack = trackListRaw.find((entry: unknown) => {
|
||||
if (!entry || typeof entry !== 'object') return false;
|
||||
const track = entry as Record<string, unknown>;
|
||||
return track.type === 'sub' && trackId(track.id) === sid;
|
||||
}) as Record<string, unknown> | undefined;
|
||||
if (!selectedTrack) return null;
|
||||
|
||||
return JSON.stringify([
|
||||
videoPath,
|
||||
sid,
|
||||
selectedTrack.external === true,
|
||||
selectedTrack['external-filename'] ?? null,
|
||||
trackId(selectedTrack['ff-index']),
|
||||
]);
|
||||
}
|
||||
|
||||
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 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[] = [];
|
||||
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');
|
||||
}
|
||||
|
||||
export function createSecondarySubtitleTrackController(deps: {
|
||||
getMpvClient: () => SecondarySubtitleMpvClient | null;
|
||||
getCurrentTimePos: () => number;
|
||||
resolveSubtitleSource: (
|
||||
input: SecondarySubtitleSourceInput,
|
||||
) => Promise<ResolvedSubtitleSource | null>;
|
||||
loadSubtitleSourceText: (source: string) => Promise<string>;
|
||||
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
|
||||
setCurrentSecondaryText: (text: string) => void;
|
||||
broadcastSecondaryText: (text: string) => void;
|
||||
logDebug?: (message: string) => void;
|
||||
logWarn?: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
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;
|
||||
let refreshGeneration = 0;
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const publish = (text: string): void => {
|
||||
deps.setCurrentSecondaryText(text);
|
||||
if (text === lastBroadcastText) return;
|
||||
lastBroadcastText = text;
|
||||
deps.broadcastSecondaryText(text);
|
||||
};
|
||||
|
||||
const resolveAtTime = (timeSeconds: number): string => {
|
||||
if (!parsedCues) return lastLiveText;
|
||||
return findActiveSubtitleText(parsedCues, timeSeconds - secondaryDelaySeconds);
|
||||
};
|
||||
|
||||
const useLiveFallback = (): void => {
|
||||
parsedCues = null;
|
||||
parsedSourceKey = null;
|
||||
parsedTrackIdentity = null;
|
||||
publish(lastLiveText);
|
||||
};
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
const generation = ++refreshGeneration;
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected) {
|
||||
activeSourceUsesAssSyntax = false;
|
||||
useLiveFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
let resolvedSource: ResolvedSubtitleSource | null = null;
|
||||
try {
|
||||
const [secondarySid, trackList, videoPathRaw, secondaryDelayRaw] = await Promise.all([
|
||||
client.requestProperty('secondary-sid').catch(() => null),
|
||||
client.requestProperty('track-list').catch(() => null),
|
||||
client.requestProperty('path').catch(() => null),
|
||||
client.requestProperty('secondary-sub-delay').catch(() => 0),
|
||||
]);
|
||||
if (generation !== refreshGeneration) return;
|
||||
|
||||
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw.trim() : '';
|
||||
if (!videoPath || secondarySid === null || secondarySid === 'no') {
|
||||
activeSourceUsesAssSyntax = false;
|
||||
useLiveFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
secondaryDelaySeconds = finiteNumber(secondaryDelayRaw);
|
||||
const selectedTrackIdentity = buildSelectedTrackIdentity(trackList, secondarySid, videoPath);
|
||||
if (selectedTrackIdentity && selectedTrackIdentity === parsedTrackIdentity && parsedCues) {
|
||||
publish(resolveAtTime(deps.getCurrentTimePos()));
|
||||
return;
|
||||
}
|
||||
|
||||
resolvedSource = await deps.resolveSubtitleSource({
|
||||
currentExternalFilenameRaw: null,
|
||||
currentTrackRaw: null,
|
||||
trackListRaw: trackList,
|
||||
sidRaw: secondarySid,
|
||||
videoPath,
|
||||
allowSelectedFallback: false,
|
||||
});
|
||||
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()));
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await deps.loadSubtitleSourceText(resolvedSource.path);
|
||||
const cues = deps.parseSubtitleCues(content, resolvedSource.path);
|
||||
if (generation !== refreshGeneration) return;
|
||||
if (cues.length === 0) {
|
||||
deps.logDebug?.('[secondary-subtitle-track] selected source contained no parsed cues');
|
||||
useLiveFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
parsedCues = cues;
|
||||
parsedSourceKey = resolvedSource.sourceKey;
|
||||
parsedTrackIdentity = selectedTrackIdentity;
|
||||
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 {
|
||||
await resolvedSource?.cleanup?.().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRefresh = (delayMs = DEFAULT_REFRESH_DELAY_MS): void => {
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(() => {
|
||||
refreshTimer = null;
|
||||
void refresh();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const clearSelectedTrack = (): void => {
|
||||
refreshGeneration += 1;
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
refreshTimer = null;
|
||||
parsedCues = null;
|
||||
parsedSourceKey = null;
|
||||
parsedTrackIdentity = null;
|
||||
activeSourceUsesAssSyntax = false;
|
||||
secondaryDelaySeconds = 0;
|
||||
lastLiveText = '';
|
||||
publish('');
|
||||
};
|
||||
|
||||
return {
|
||||
refresh,
|
||||
scheduleRefresh,
|
||||
handleLiveText(text: string): void {
|
||||
lastLiveText = removeLiveGlyphFragmentLines(
|
||||
activeSourceUsesAssSyntax ? removeAssControlDebrisLines(text) : text,
|
||||
);
|
||||
publish(resolveAtTime(deps.getCurrentTimePos()));
|
||||
},
|
||||
handleTimePos(timeSeconds: number): void {
|
||||
if (!parsedCues) return;
|
||||
publish(resolveAtTime(timeSeconds));
|
||||
},
|
||||
handleTrackChange(): void {
|
||||
clearSelectedTrack();
|
||||
},
|
||||
handleDelayChange(delaySeconds: number): void {
|
||||
secondaryDelaySeconds = finiteNumber(delaySeconds);
|
||||
if (parsedCues) {
|
||||
publish(resolveAtTime(deps.getCurrentTimePos()));
|
||||
}
|
||||
},
|
||||
reset: clearSelectedTrack,
|
||||
};
|
||||
}
|
||||
@@ -101,6 +101,32 @@ test('subtitle prefetch runtime preserves parsed cues when YouTube active track
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('subtitle prefetch runtime preserves parsed cues when a network mount source is unresolved', async () => {
|
||||
const calls: string[] = [];
|
||||
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => (name === 'path' ? '/Volumes/jellyfin/movie.mkv' : null),
|
||||
}),
|
||||
getLastObservedTimePos: () => 12,
|
||||
subtitlePrefetchInitController: {
|
||||
cancelPendingInit: () => {
|
||||
calls.push('cancel');
|
||||
},
|
||||
initSubtitlePrefetch: async () => {
|
||||
calls.push('init');
|
||||
},
|
||||
},
|
||||
resolveActiveSubtitleSidebarSource: async () => null,
|
||||
shouldKeepExistingCuesOnMissingSource: async (videoPath) =>
|
||||
videoPath.startsWith('/Volumes/jellyfin/'),
|
||||
});
|
||||
|
||||
await refresh();
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('subtitle prefetch runtime does not extract internal subtitle tracks from remote media urls', async () => {
|
||||
let extracted = false;
|
||||
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
@@ -131,6 +157,36 @@ test('subtitle prefetch runtime does not extract internal subtitle tracks from r
|
||||
assert.equal(extracted, false);
|
||||
});
|
||||
|
||||
test('subtitle prefetch runtime extracts internal subtitle tracks from network-mounted media', async () => {
|
||||
let extracted = false;
|
||||
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
getFfmpegPath: () => 'ffmpeg-custom',
|
||||
extractInternalSubtitleTrack: async () => {
|
||||
extracted = true;
|
||||
return {
|
||||
path: '/tmp/subminer-sidebar-123/track_7.ass',
|
||||
cleanup: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await resolveSource({
|
||||
currentExternalFilenameRaw: null,
|
||||
currentTrackRaw: {
|
||||
type: 'sub',
|
||||
id: 3,
|
||||
'ff-index': 7,
|
||||
codec: 'ass',
|
||||
},
|
||||
trackListRaw: [],
|
||||
sidRaw: 3,
|
||||
videoPath: '/Volumes/jellyfin/movie.mkv',
|
||||
});
|
||||
|
||||
assert.equal(resolved?.path, '/tmp/subminer-sidebar-123/track_7.ass');
|
||||
assert.equal(extracted, true);
|
||||
});
|
||||
|
||||
test('subtitle prefetch refresh logs a warning when source resolution throws', async () => {
|
||||
const warnings: string[] = [];
|
||||
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||
@@ -248,3 +304,31 @@ test('subtitle source resolver logs debug when no active subtitle track is selec
|
||||
assert.equal(debugs.length, 1);
|
||||
assert.match(debugs[0]!, /\[subtitle-prefetch\].*no active subtitle track/);
|
||||
});
|
||||
|
||||
test('subtitle source resolver does not fall back to the primary selected track for secondary', async () => {
|
||||
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
getFfmpegPath: () => 'ffmpeg',
|
||||
extractInternalSubtitleTrack: async () => {
|
||||
throw new Error('should not extract the primary track');
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await resolveSource({
|
||||
currentExternalFilenameRaw: null,
|
||||
currentTrackRaw: null,
|
||||
trackListRaw: [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
selected: true,
|
||||
external: true,
|
||||
'external-filename': '/subs/primary.ass',
|
||||
},
|
||||
],
|
||||
sidRaw: null,
|
||||
videoPath: '/media/video.mkv',
|
||||
allowSelectedFallback: false,
|
||||
});
|
||||
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ function parseTrackId(value: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRemoteMediaPath(value: string): boolean {
|
||||
function isRemoteMediaUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
@@ -41,6 +41,7 @@ function getActiveSubtitleTrack(
|
||||
currentTrackRaw: unknown,
|
||||
trackListRaw: unknown,
|
||||
sidRaw: unknown,
|
||||
allowSelectedFallback: boolean,
|
||||
): MpvSubtitleTrackLike | null {
|
||||
if (currentTrackRaw && typeof currentTrackRaw === 'object') {
|
||||
const track = currentTrackRaw as MpvSubtitleTrackLike;
|
||||
@@ -68,6 +69,10 @@ function getActiveSubtitleTrack(
|
||||
return bySid;
|
||||
}
|
||||
|
||||
if (!allowSelectedFallback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
(trackListRaw.find((entry: unknown) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
@@ -94,6 +99,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
||||
trackListRaw: unknown;
|
||||
sidRaw: unknown;
|
||||
videoPath: string;
|
||||
allowSelectedFallback?: boolean;
|
||||
}): Promise<ActiveSubtitleSidebarSource | null> => {
|
||||
const currentExternalFilename =
|
||||
typeof input.currentExternalFilenameRaw === 'string'
|
||||
@@ -103,7 +109,12 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
||||
return { path: currentExternalFilename, sourceKey: currentExternalFilename };
|
||||
}
|
||||
|
||||
const track = getActiveSubtitleTrack(input.currentTrackRaw, input.trackListRaw, input.sidRaw);
|
||||
const track = getActiveSubtitleTrack(
|
||||
input.currentTrackRaw,
|
||||
input.trackListRaw,
|
||||
input.sidRaw,
|
||||
input.allowSelectedFallback !== false,
|
||||
);
|
||||
if (!track) {
|
||||
deps.logDebug?.('[subtitle-prefetch] no active subtitle track selected yet');
|
||||
return null;
|
||||
@@ -115,7 +126,10 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
||||
return { path: externalFilename, sourceKey: externalFilename };
|
||||
}
|
||||
|
||||
if (isRemoteMediaPath(input.videoPath)) {
|
||||
// Network-mounted files extract like local ones: demuxing reads the whole
|
||||
// container (~10s/GB on gigabit), which a LAN handles alongside playback.
|
||||
// Only true remote URLs have no on-disk container to demux.
|
||||
if (isRemoteMediaUrl(input.videoPath)) {
|
||||
deps.logDebug?.('[subtitle-prefetch] skipping internal subtitle extraction for remote media');
|
||||
return null;
|
||||
}
|
||||
@@ -145,7 +159,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
} | null;
|
||||
getLastObservedTimePos: () => number;
|
||||
shouldKeepExistingCuesOnMissingSource?: (videoPath: string) => boolean;
|
||||
shouldKeepExistingCuesOnMissingSource?: (videoPath: string) => boolean | Promise<boolean>;
|
||||
subtitlePrefetchInitController: SubtitlePrefetchInitController;
|
||||
resolveActiveSubtitleSidebarSource: (
|
||||
input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0],
|
||||
@@ -184,7 +198,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
||||
videoPath,
|
||||
});
|
||||
if (!resolvedSource) {
|
||||
if (deps.shouldKeepExistingCuesOnMissingSource?.(videoPath) === true) {
|
||||
if ((await deps.shouldKeepExistingCuesOnMissingSource?.(videoPath)) === true) {
|
||||
deps.logDebug?.(
|
||||
'[subtitle-prefetch] no active subtitle source resolved; keeping existing cues',
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from './support-assets';
|
||||
|
||||
type SupportAssetsResultWithComponent = SupportAssetsUpdateResult & {
|
||||
component?: 'theme' | 'plugin';
|
||||
component?: 'theme' | 'thumbnailer' | 'plugin';
|
||||
};
|
||||
|
||||
function sha256(data: Buffer): string {
|
||||
@@ -22,18 +22,30 @@ function sha256(data: Buffer): string {
|
||||
|
||||
function makeSupportAssetsArchive(options?: {
|
||||
themeContent?: string;
|
||||
thumbnailerContent?: string;
|
||||
includeThumbnailer?: boolean;
|
||||
pluginVersion?: string | null;
|
||||
pluginMainContent?: string;
|
||||
extraPluginFiles?: Array<{ relativePath: string; content: string }>;
|
||||
}): { archive: Buffer; tempDir: string } {
|
||||
const themeContent = options?.themeContent ?? 'new theme\n';
|
||||
const thumbnailerContent = options?.thumbnailerContent ?? '[Thumbnailer Entry]\n';
|
||||
const pluginVersion = options && 'pluginVersion' in options ? options.pluginVersion : '0.12.0';
|
||||
const pluginMainContent = options?.pluginMainContent ?? 'new plugin\n';
|
||||
const extraPluginFiles = options?.extraPluginFiles ?? [];
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-support-assets-test-'));
|
||||
fs.mkdirSync(path.join(tempDir, 'assets/themes'), { recursive: true });
|
||||
if (options?.includeThumbnailer !== false) {
|
||||
fs.mkdirSync(path.join(tempDir, 'assets/thumbnailers'), { recursive: true });
|
||||
}
|
||||
fs.mkdirSync(path.join(tempDir, 'plugin/subminer'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempDir, 'assets/themes/subminer.rasi'), themeContent);
|
||||
if (options?.includeThumbnailer !== false) {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer'),
|
||||
thumbnailerContent,
|
||||
);
|
||||
}
|
||||
fs.writeFileSync(path.join(tempDir, 'plugin/subminer/main.lua'), pluginMainContent);
|
||||
if (pluginVersion !== null) {
|
||||
fs.writeFileSync(
|
||||
@@ -93,7 +105,7 @@ test('detectSupportAssetDataDirs only returns Linux support-asset locations', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('buildProtectedSupportAssetsCommand installs both theme and plugin assets', () => {
|
||||
test('buildProtectedSupportAssetsCommand installs theme, thumbnailer, and plugin assets', () => {
|
||||
const command = buildProtectedSupportAssetsCommand(
|
||||
"https://example.test/subminer assets.tar.gz?sig='abc'",
|
||||
'ABCDEF1234',
|
||||
@@ -110,11 +122,28 @@ test('buildProtectedSupportAssetsCommand installs both theme and plugin assets',
|
||||
command,
|
||||
/printf '%s %s\\n' 'abcdef1234' "\$tmp\/subminer-assets\.tar\.gz" \| sha256sum -c -/,
|
||||
);
|
||||
const requiredAssetChecks = [
|
||||
'test -f "$tmp/assets/themes/subminer.rasi"',
|
||||
'test -f "$tmp/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer"',
|
||||
'test -f "$tmp/plugin/subminer/main.lua"',
|
||||
'test -f "$tmp/plugin/subminer/version.lua"',
|
||||
];
|
||||
const firstSudoIndex = command.indexOf('sudo ');
|
||||
assert.notEqual(firstSudoIndex, -1);
|
||||
for (const check of requiredAssetChecks) {
|
||||
const checkIndex = command.indexOf(check);
|
||||
assert.notEqual(checkIndex, -1);
|
||||
assert.ok(checkIndex < firstSudoIndex);
|
||||
}
|
||||
assert.match(command, /sudo mkdir -p '\/usr\/local\/share\/SubMiner'\\''s data'\/themes/);
|
||||
assert.match(
|
||||
command,
|
||||
/sudo cp "\$tmp\/assets\/themes\/subminer\.rasi" '\/usr\/local\/share\/SubMiner'\\''s data'\/themes\/subminer\.rasi/,
|
||||
);
|
||||
assert.match(
|
||||
command,
|
||||
/sudo cp "\$tmp\/assets\/thumbnailers\/subminer-ffmpegthumbnailer\.thumbnailer" .*thumbnailers\/subminer-ffmpegthumbnailer\.thumbnailer/,
|
||||
);
|
||||
assert.match(command, /sudo mkdir -p '\/usr\/local\/share\/SubMiner'\\''s data'\/plugin/);
|
||||
assert.match(command, /sudo rm -rf .*plugin\/subminer\.next/);
|
||||
assert.match(command, /sudo cp -R "\$tmp\/plugin\/subminer" .*plugin\/subminer\.next/);
|
||||
@@ -209,6 +238,12 @@ test('updateSupportAssetsFromRelease installs missing plugin into a root with a
|
||||
path: dataDir,
|
||||
message: 'Updated theme.',
|
||||
},
|
||||
{
|
||||
status: 'updated',
|
||||
component: 'thumbnailer',
|
||||
path: dataDir,
|
||||
message: 'Installed rofi thumbnailer.',
|
||||
},
|
||||
{
|
||||
status: 'updated',
|
||||
component: 'plugin',
|
||||
@@ -220,6 +255,13 @@ test('updateSupportAssetsFromRelease installs missing plugin into a root with a
|
||||
fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'),
|
||||
'new theme\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(dataDir, 'thumbnailers/subminer-ffmpegthumbnailer.thumbnailer'),
|
||||
'utf8',
|
||||
),
|
||||
'[Thumbnailer Entry]\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'),
|
||||
'new plugin\n',
|
||||
@@ -345,8 +387,13 @@ test('updateSupportAssetsFromRelease skips identical theme and up-to-date plugin
|
||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dataDir, 'thumbnailers'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'same theme\n');
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, 'thumbnailers/subminer-ffmpegthumbnailer.thumbnailer'),
|
||||
'[Thumbnailer Entry]\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'same plugin\n');
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, 'plugin/subminer/version.lua'),
|
||||
@@ -371,6 +418,12 @@ test('updateSupportAssetsFromRelease skips identical theme and up-to-date plugin
|
||||
path: dataDir,
|
||||
message: 'Theme already up to date.',
|
||||
},
|
||||
{
|
||||
status: 'skipped',
|
||||
component: 'thumbnailer',
|
||||
path: dataDir,
|
||||
message: 'Rofi thumbnailer already up to date.',
|
||||
},
|
||||
{
|
||||
status: 'skipped',
|
||||
component: 'plugin',
|
||||
@@ -396,8 +449,13 @@ test('updateSupportAssetsFromRelease updates changed theme and outdated plugin w
|
||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dataDir, 'thumbnailers'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'old theme\n');
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, 'thumbnailers/subminer-ffmpegthumbnailer.thumbnailer'),
|
||||
'[Old Thumbnailer]\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'old plugin\n');
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, 'plugin/subminer/version.lua'),
|
||||
@@ -406,6 +464,7 @@ test('updateSupportAssetsFromRelease updates changed theme and outdated plugin w
|
||||
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/stale.lua'), 'stale\n');
|
||||
const { archive, tempDir } = makeSupportAssetsArchive({
|
||||
themeContent: 'new theme\n',
|
||||
thumbnailerContent: '[Thumbnailer Entry]\n',
|
||||
pluginVersion: '0.12.0',
|
||||
pluginMainContent: 'new plugin main\n',
|
||||
extraPluginFiles: [{ relativePath: 'fresh.lua', content: 'fresh\n' }],
|
||||
@@ -424,6 +483,12 @@ test('updateSupportAssetsFromRelease updates changed theme and outdated plugin w
|
||||
path: dataDir,
|
||||
message: 'Updated theme.',
|
||||
},
|
||||
{
|
||||
status: 'updated',
|
||||
component: 'thumbnailer',
|
||||
path: dataDir,
|
||||
message: 'Updated rofi thumbnailer.',
|
||||
},
|
||||
{
|
||||
status: 'updated',
|
||||
component: 'plugin',
|
||||
@@ -435,6 +500,13 @@ test('updateSupportAssetsFromRelease updates changed theme and outdated plugin w
|
||||
fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'),
|
||||
'new theme\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(dataDir, 'thumbnailers/subminer-ffmpegthumbnailer.thumbnailer'),
|
||||
'utf8',
|
||||
),
|
||||
'[Thumbnailer Entry]\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'),
|
||||
'new plugin main\n',
|
||||
@@ -479,6 +551,12 @@ test('updateSupportAssetsFromRelease returns protected commands for managed root
|
||||
path: dataDir,
|
||||
command: true,
|
||||
},
|
||||
{
|
||||
status: 'protected',
|
||||
component: 'thumbnailer',
|
||||
path: dataDir,
|
||||
command: true,
|
||||
},
|
||||
{
|
||||
status: 'protected',
|
||||
component: 'plugin',
|
||||
@@ -488,6 +566,10 @@ test('updateSupportAssetsFromRelease returns protected commands for managed root
|
||||
],
|
||||
);
|
||||
assert.match(results[0]?.command ?? '', /themes\/subminer\.rasi/);
|
||||
assert.match(
|
||||
results[0]?.command ?? '',
|
||||
/thumbnailers\/subminer-ffmpegthumbnailer\.thumbnailer/,
|
||||
);
|
||||
assert.match(results[0]?.command ?? '', /plugin\/subminer/);
|
||||
} finally {
|
||||
fs.chmodSync(dataDir, originalMode);
|
||||
@@ -522,3 +604,25 @@ test('updateSupportAssetsFromRelease returns missing-asset when release plugin v
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('updateSupportAssetsFromRelease rejects archives without the rofi thumbnailer', async () => {
|
||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'managed theme\n');
|
||||
const { archive, tempDir } = makeSupportAssetsArchive({ includeThumbnailer: false });
|
||||
|
||||
try {
|
||||
const results = await runLinuxSupportAssetUpdate({ archive, xdgDataHome });
|
||||
|
||||
assert.deepEqual(results, [
|
||||
{
|
||||
status: 'missing-asset',
|
||||
message: 'Support asset archive is missing the rofi thumbnailer.',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,13 +9,17 @@ import { compareSemverLike, findReleaseAsset } from './release-assets';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const THEME_RELATIVE_PATH = path.join('themes', 'subminer.rasi');
|
||||
const THUMBNAILER_RELATIVE_PATH = path.join(
|
||||
'thumbnailers',
|
||||
'subminer-ffmpegthumbnailer.thumbnailer',
|
||||
);
|
||||
const PLUGIN_ENTRYPOINT_RELATIVE_PATH = path.join('plugin', 'subminer', 'main.lua');
|
||||
const PLUGIN_VERSION_RELATIVE_PATH = path.join('plugin', 'subminer', 'version.lua');
|
||||
const PLUGIN_DIR_RELATIVE_PATH = path.join('plugin', 'subminer');
|
||||
|
||||
export interface SupportAssetsUpdateResult {
|
||||
status: 'updated' | 'skipped' | 'protected' | 'hash-mismatch' | 'missing-asset';
|
||||
component?: 'theme' | 'plugin';
|
||||
component?: 'theme' | 'thumbnailer' | 'plugin';
|
||||
path?: string;
|
||||
command?: string;
|
||||
message?: string;
|
||||
@@ -69,11 +73,12 @@ async function readInstalledPluginVersion(pluginDir: string): Promise<string | n
|
||||
async function detectManagedSupportAssetDataDirs(dataDirs: string[]): Promise<string[]> {
|
||||
const managedDataDirs: string[] = [];
|
||||
for (const dataDir of dataDirs) {
|
||||
const [hasTheme, hasPlugin] = await Promise.all([
|
||||
const [hasTheme, hasThumbnailer, hasPlugin] = await Promise.all([
|
||||
pathExists(path.join(dataDir, THEME_RELATIVE_PATH)),
|
||||
pathExists(path.join(dataDir, THUMBNAILER_RELATIVE_PATH)),
|
||||
pathExists(path.join(dataDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH)),
|
||||
]);
|
||||
if (hasTheme || hasPlugin) {
|
||||
if (hasTheme || hasThumbnailer || hasPlugin) {
|
||||
managedDataDirs.push(dataDir);
|
||||
}
|
||||
}
|
||||
@@ -163,8 +168,14 @@ export function buildProtectedSupportAssetsCommand(
|
||||
`curl -fSL ${shellQuote(assetUrl)} -o "$tmp/subminer-assets.tar.gz"`,
|
||||
`printf '%s %s\\n' ${quotedExpectedSha256} "$tmp/subminer-assets.tar.gz" | sha256sum -c -`,
|
||||
'tar -xzf "$tmp/subminer-assets.tar.gz" -C "$tmp"',
|
||||
'test -f "$tmp/assets/themes/subminer.rasi"',
|
||||
'test -f "$tmp/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer"',
|
||||
'test -f "$tmp/plugin/subminer/main.lua"',
|
||||
'test -f "$tmp/plugin/subminer/version.lua"',
|
||||
`sudo mkdir -p ${quotedDir}/themes`,
|
||||
`sudo cp "$tmp/assets/themes/subminer.rasi" ${quotedDir}/themes/subminer.rasi`,
|
||||
`sudo mkdir -p ${quotedDir}/thumbnailers`,
|
||||
`sudo cp "$tmp/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer" ${quotedDir}/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer`,
|
||||
`sudo mkdir -p ${quotedDir}/plugin`,
|
||||
`sudo rm -rf ${quotedStagedPluginDir} ${quotedBackupPluginDir}`,
|
||||
`sudo cp -R "$tmp/plugin/subminer" ${quotedStagedPluginDir}`,
|
||||
@@ -216,6 +227,12 @@ export async function updateSupportAssetsFromRelease(options: {
|
||||
dataDir,
|
||||
'Support asset path is not a directory.',
|
||||
),
|
||||
makeSupportAssetResult(
|
||||
'skipped',
|
||||
'thumbnailer',
|
||||
dataDir,
|
||||
'Support asset path is not a directory.',
|
||||
),
|
||||
makeSupportAssetResult(
|
||||
'skipped',
|
||||
'plugin',
|
||||
@@ -244,6 +261,13 @@ export async function updateSupportAssetsFromRelease(options: {
|
||||
'Theme install requires a manual command.',
|
||||
command,
|
||||
),
|
||||
makeSupportAssetResult(
|
||||
'protected',
|
||||
'thumbnailer',
|
||||
dataDir,
|
||||
'Rofi thumbnailer install requires a manual command.',
|
||||
command,
|
||||
),
|
||||
makeSupportAssetResult(
|
||||
'protected',
|
||||
'plugin',
|
||||
@@ -284,6 +308,17 @@ export async function updateSupportAssetsFromRelease(options: {
|
||||
}
|
||||
const themeBytes = await fs.promises.readFile(themeSourcePath);
|
||||
|
||||
const thumbnailerSourcePath = path.join(tempDir, 'assets', THUMBNAILER_RELATIVE_PATH);
|
||||
if (!(await pathExists(thumbnailerSourcePath))) {
|
||||
return [
|
||||
{
|
||||
status: 'missing-asset',
|
||||
message: 'Support asset archive is missing the rofi thumbnailer.',
|
||||
},
|
||||
];
|
||||
}
|
||||
const thumbnailerBytes = await fs.promises.readFile(thumbnailerSourcePath);
|
||||
|
||||
const sourcePluginDir = path.join(tempDir, PLUGIN_DIR_RELATIVE_PATH);
|
||||
const sourcePluginEntrypoint = path.join(tempDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH);
|
||||
if (!(await pathExists(sourcePluginEntrypoint))) {
|
||||
@@ -328,6 +363,33 @@ export async function updateSupportAssetsFromRelease(options: {
|
||||
);
|
||||
}
|
||||
|
||||
const targetThumbnailerPath = path.join(dataDir, THUMBNAILER_RELATIVE_PATH);
|
||||
const existingThumbnailerBytes = await readFileIfExists(targetThumbnailerPath);
|
||||
if (
|
||||
existingThumbnailerBytes &&
|
||||
Buffer.compare(existingThumbnailerBytes, thumbnailerBytes) === 0
|
||||
) {
|
||||
results.push(
|
||||
makeSupportAssetResult(
|
||||
'skipped',
|
||||
'thumbnailer',
|
||||
dataDir,
|
||||
'Rofi thumbnailer already up to date.',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await fs.promises.mkdir(path.dirname(targetThumbnailerPath), { recursive: true });
|
||||
await fs.promises.writeFile(targetThumbnailerPath, thumbnailerBytes);
|
||||
results.push(
|
||||
makeSupportAssetResult(
|
||||
'updated',
|
||||
'thumbnailer',
|
||||
dataDir,
|
||||
existingThumbnailerBytes ? 'Updated rofi thumbnailer.' : 'Installed rofi thumbnailer.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const targetPluginDir = path.join(dataDir, PLUGIN_DIR_RELATIVE_PATH);
|
||||
const targetPluginEntrypoint = path.join(dataDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH);
|
||||
const installedPluginVersion = await readInstalledPluginVersion(targetPluginDir);
|
||||
|
||||
Reference in New Issue
Block a user