mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-23 00:11:28 -07:00
feat(subtitle-sidebar): add sidebar config surface (#28)
This commit is contained in:
@@ -63,6 +63,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle'];
|
||||
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
|
||||
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
|
||||
getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot'];
|
||||
getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused'];
|
||||
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
|
||||
getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition'];
|
||||
@@ -212,6 +213,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
|
||||
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
|
||||
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
|
||||
getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot,
|
||||
getPlaybackPaused: params.getPlaybackPaused,
|
||||
getSubtitlePosition: params.getSubtitlePosition,
|
||||
getSubtitleStyle: params.getSubtitleStyle,
|
||||
|
||||
@@ -36,6 +36,7 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe
|
||||
return {
|
||||
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
|
||||
subtitleStyle: resolveSubtitleStyleForRenderer(config),
|
||||
subtitleSidebar: config.subtitleSidebar,
|
||||
secondarySubMode: config.secondarySub.defaultMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ test('media path change handler reports stop for empty path and probes media key
|
||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||
getCurrentAnilistMediaKey: () => 'show:1',
|
||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||
@@ -97,6 +98,7 @@ test('media path change handler reports stop for empty path and probes media key
|
||||
assert.deepEqual(calls, [
|
||||
'flush-playback',
|
||||
'path:',
|
||||
'reset-sidebar-layout',
|
||||
'stopped',
|
||||
'restore-mpv-sub',
|
||||
'reset:show:1',
|
||||
@@ -113,6 +115,7 @@ test('media path change handler signals autoplay-ready fast path for warm non-em
|
||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||
@@ -128,35 +131,7 @@ test('media path change handler signals autoplay-ready fast path for warm non-em
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'path:/tmp/video.mkv',
|
||||
'reset:null',
|
||||
'sync',
|
||||
'dict-sync',
|
||||
'autoplay:/tmp/video.mkv',
|
||||
'presence',
|
||||
]);
|
||||
});
|
||||
|
||||
test('media path change handler ignores playback flush for non-empty path', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvMediaPathChangeHandler({
|
||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||
syncImmersionMediaState: () => calls.push('sync'),
|
||||
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
|
||||
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
||||
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
});
|
||||
|
||||
handler({ path: '/tmp/video.mkv' });
|
||||
assert.ok(!calls.includes('flush-playback'));
|
||||
assert.deepEqual(calls, [
|
||||
'path:/tmp/video.mkv',
|
||||
'reset-sidebar-layout',
|
||||
'reset:null',
|
||||
'sync',
|
||||
'dict-sync',
|
||||
|
||||
@@ -46,6 +46,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
||||
updateCurrentMediaPath: (path: string) => void;
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
restoreMpvSubVisibility: () => void;
|
||||
resetSubtitleSidebarEmbeddedLayout: () => void;
|
||||
getCurrentAnilistMediaKey: () => string | null;
|
||||
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
||||
maybeProbeAnilistDuration: (mediaKey: string) => void;
|
||||
@@ -62,6 +63,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
||||
deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath);
|
||||
}
|
||||
deps.updateCurrentMediaPath(normalizedPath);
|
||||
deps.resetSubtitleSidebarEmbeddedLayout();
|
||||
if (!normalizedPath) {
|
||||
deps.reportJellyfinRemoteStopped();
|
||||
deps.restoreMpvSubVisibility();
|
||||
|
||||
@@ -9,6 +9,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
const bind = createBindMpvMainEventHandlersHandler({
|
||||
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||
hasInitialJellyfinPlayArg: () => false,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
isQuitOnDisconnectArmed: () => false,
|
||||
@@ -67,6 +68,7 @@ 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('subtitle-track-list-change')?.({ trackList: [] });
|
||||
@@ -76,6 +78,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
handlers.get('pause-change')?.({ paused: true });
|
||||
|
||||
assert.ok(calls.includes('set-sub:line'));
|
||||
assert.ok(calls.includes('reset-sidebar-layout'));
|
||||
assert.ok(calls.includes('broadcast-sub:line'));
|
||||
assert.ok(calls.includes('subtitle-change:line'));
|
||||
assert.ok(calls.includes('subtitle-track-change'));
|
||||
|
||||
@@ -21,6 +21,7 @@ type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandl
|
||||
export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
resetSubtitleSidebarEmbeddedLayout: () => void;
|
||||
scheduleCharacterDictionarySync?: () => void;
|
||||
hasInitialJellyfinPlayArg: () => boolean;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
@@ -83,6 +84,12 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
isMpvConnected: () => deps.isMpvConnected(),
|
||||
quitApp: () => deps.quitApp(),
|
||||
});
|
||||
const handleMpvConnectionChangeWithSidebarReset = ({ connected }: { connected: boolean }): void => {
|
||||
if (connected) {
|
||||
deps.resetSubtitleSidebarEmbeddedLayout();
|
||||
}
|
||||
handleMpvConnectionChange({ connected });
|
||||
};
|
||||
const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({
|
||||
recordImmersionSubtitleLine: (text, start, end) =>
|
||||
deps.recordImmersionSubtitleLine(text, start, end),
|
||||
@@ -110,6 +117,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path),
|
||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
||||
resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout(),
|
||||
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
|
||||
resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey),
|
||||
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
|
||||
@@ -150,7 +158,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
});
|
||||
|
||||
createBindMpvClientEventHandlers({
|
||||
onConnectionChange: handleMpvConnectionChange,
|
||||
onConnectionChange: handleMpvConnectionChangeWithSidebarReset,
|
||||
onSubtitleChange: handleMpvSubtitleChange,
|
||||
onSubtitleAssChange: handleMpvSubtitleAssChange,
|
||||
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
|
||||
|
||||
@@ -47,6 +47,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
|
||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||
getCurrentAnilistMediaKey: () => 'media-key',
|
||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`),
|
||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||
@@ -82,6 +83,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
deps.broadcastSecondarySubtitle('sec');
|
||||
deps.updateCurrentMediaPath('/tmp/video');
|
||||
deps.restoreMpvSubVisibility();
|
||||
deps.resetSubtitleSidebarEmbeddedLayout();
|
||||
assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key');
|
||||
deps.resetAnilistMediaTracking('media-key');
|
||||
deps.maybeProbeAnilistDuration('media-key');
|
||||
@@ -112,6 +114,5 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
assert.ok(calls.includes('metrics'));
|
||||
assert.ok(calls.includes('presence-refresh'));
|
||||
assert.ok(calls.includes('restore-mpv-sub'));
|
||||
assert.ok(calls.includes('immersion-time:12.25'));
|
||||
assert.ok(calls.includes('immersion-time:18.75'));
|
||||
assert.ok(calls.includes('reset-sidebar-layout'));
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||
updateCurrentMediaPath: (path: string) => void;
|
||||
restoreMpvSubVisibility: () => void;
|
||||
resetSubtitleSidebarEmbeddedLayout?: () => void;
|
||||
getCurrentAnilistMediaKey: () => string | null;
|
||||
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
||||
maybeProbeAnilistDuration: (mediaKey: string) => void;
|
||||
@@ -146,6 +147,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
|
||||
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
|
||||
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
||||
resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout?.(),
|
||||
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
|
||||
resetAnilistMediaTracking: (mediaKey: string | null) =>
|
||||
deps.resetAnilistMediaTracking(mediaKey),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { SubtitleCue } from '../../core/services/subtitle-cue-parser';
|
||||
import type { SubtitlePrefetchService } from '../../core/services/subtitle-prefetch';
|
||||
import type { SubtitleCue } from '../../types';
|
||||
import { createSubtitlePrefetchInitController } from './subtitle-prefetch-init';
|
||||
|
||||
function createDeferred<T>(): {
|
||||
@@ -112,3 +112,125 @@ test('cancelPendingInit prevents an in-flight load from attaching a stale servic
|
||||
assert.equal(currentService, null);
|
||||
assert.deepEqual(started, []);
|
||||
});
|
||||
|
||||
test('subtitle prefetch init publishes parsed cues and clears them on cancel', async () => {
|
||||
const deferred = createDeferred<string>();
|
||||
let currentService: SubtitlePrefetchService | null = null;
|
||||
const cueUpdates: Array<SubtitleCue[] | null> = [];
|
||||
|
||||
const controller = createSubtitlePrefetchInitController({
|
||||
getCurrentService: () => currentService,
|
||||
setCurrentService: (service) => {
|
||||
currentService = service;
|
||||
},
|
||||
loadSubtitleSourceText: async () => await deferred.promise,
|
||||
parseSubtitleCues: () => [
|
||||
{ startTime: 1, endTime: 2, text: 'first' },
|
||||
{ startTime: 3, endTime: 4, text: 'second' },
|
||||
],
|
||||
createSubtitlePrefetchService: () => ({
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
onSeek: () => {},
|
||||
pause: () => {},
|
||||
resume: () => {},
|
||||
}),
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
onParsedSubtitleCuesChanged: (cues) => {
|
||||
cueUpdates.push(cues);
|
||||
},
|
||||
});
|
||||
|
||||
const initPromise = controller.initSubtitlePrefetch('episode.ass', 12);
|
||||
deferred.resolve('content');
|
||||
await initPromise;
|
||||
|
||||
controller.cancelPendingInit();
|
||||
|
||||
assert.deepEqual(cueUpdates, [
|
||||
[
|
||||
{ startTime: 1, endTime: 2, text: 'first' },
|
||||
{ startTime: 3, endTime: 4, text: 'second' },
|
||||
],
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitle prefetch init publishes the provided stable source key instead of the load path', async () => {
|
||||
const deferred = createDeferred<string>();
|
||||
let currentService: SubtitlePrefetchService | null = null;
|
||||
const sourceUpdates: Array<string | null> = [];
|
||||
|
||||
const controller = createSubtitlePrefetchInitController({
|
||||
getCurrentService: () => currentService,
|
||||
setCurrentService: (service) => {
|
||||
currentService = service;
|
||||
},
|
||||
loadSubtitleSourceText: async () => await deferred.promise,
|
||||
parseSubtitleCues: () => [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
createSubtitlePrefetchService: () => ({
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
onSeek: () => {},
|
||||
pause: () => {},
|
||||
resume: () => {},
|
||||
}),
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
onParsedSubtitleCuesChanged: (_cues, source) => {
|
||||
sourceUpdates.push(source);
|
||||
},
|
||||
});
|
||||
|
||||
const initPromise = controller.initSubtitlePrefetch(
|
||||
'/tmp/subminer-sidebar-123/track_7.ass',
|
||||
12,
|
||||
'internal:/media/episode01.mkv:track:3:ff:7',
|
||||
);
|
||||
deferred.resolve('content');
|
||||
await initPromise;
|
||||
|
||||
assert.deepEqual(sourceUpdates, ['internal:/media/episode01.mkv:track:3:ff:7']);
|
||||
});
|
||||
|
||||
test('subtitle prefetch init clears parsed cues when initialization fails', async () => {
|
||||
const cueUpdates: Array<SubtitleCue[] | null> = [];
|
||||
let currentService: SubtitlePrefetchService | null = null;
|
||||
|
||||
const controller = createSubtitlePrefetchInitController({
|
||||
getCurrentService: () => currentService,
|
||||
setCurrentService: (service) => {
|
||||
currentService = service;
|
||||
},
|
||||
loadSubtitleSourceText: async () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
parseSubtitleCues: () => [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
createSubtitlePrefetchService: () => ({
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
onSeek: () => {},
|
||||
pause: () => {},
|
||||
resume: () => {},
|
||||
}),
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
onParsedSubtitleCuesChanged: (cues) => {
|
||||
cueUpdates.push(cues);
|
||||
},
|
||||
});
|
||||
|
||||
await controller.initSubtitlePrefetch('episode.ass', 12);
|
||||
|
||||
assert.deepEqual(cueUpdates, [null]);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { SubtitleCue } from '../../core/services/subtitle-cue-parser';
|
||||
import type {
|
||||
SubtitlePrefetchService,
|
||||
SubtitlePrefetchServiceDeps,
|
||||
} from '../../core/services/subtitle-prefetch';
|
||||
import type { SubtitleData } from '../../types';
|
||||
import type { SubtitleCue } from '../../types';
|
||||
|
||||
export interface SubtitlePrefetchInitControllerDeps {
|
||||
getCurrentService: () => SubtitlePrefetchService | null;
|
||||
@@ -16,11 +16,16 @@ export interface SubtitlePrefetchInitControllerDeps {
|
||||
isCacheFull: () => boolean;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
|
||||
}
|
||||
|
||||
export interface SubtitlePrefetchInitController {
|
||||
cancelPendingInit: () => void;
|
||||
initSubtitlePrefetch: (externalFilename: string, currentTimePos: number) => Promise<void>;
|
||||
initSubtitlePrefetch: (
|
||||
sourcePath: string,
|
||||
currentTimePos: number,
|
||||
sourceKey?: string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export function createSubtitlePrefetchInitController(
|
||||
@@ -32,24 +37,29 @@ export function createSubtitlePrefetchInitController(
|
||||
initRevision += 1;
|
||||
deps.getCurrentService()?.stop();
|
||||
deps.setCurrentService(null);
|
||||
deps.onParsedSubtitleCuesChanged?.(null, null);
|
||||
};
|
||||
|
||||
const initSubtitlePrefetch = async (
|
||||
externalFilename: string,
|
||||
sourcePath: string,
|
||||
currentTimePos: number,
|
||||
sourceKey = sourcePath,
|
||||
): Promise<void> => {
|
||||
const revision = ++initRevision;
|
||||
deps.getCurrentService()?.stop();
|
||||
deps.setCurrentService(null);
|
||||
|
||||
try {
|
||||
const content = await deps.loadSubtitleSourceText(externalFilename);
|
||||
const content = await deps.loadSubtitleSourceText(sourcePath);
|
||||
if (revision !== initRevision) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cues = deps.parseSubtitleCues(content, externalFilename);
|
||||
const cues = deps.parseSubtitleCues(content, sourcePath);
|
||||
if (revision !== initRevision || cues.length === 0) {
|
||||
if (revision === initRevision) {
|
||||
deps.onParsedSubtitleCuesChanged?.(null, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,12 +75,14 @@ export function createSubtitlePrefetchInitController(
|
||||
}
|
||||
|
||||
deps.setCurrentService(nextService);
|
||||
deps.onParsedSubtitleCuesChanged?.(cues, sourceKey);
|
||||
nextService.start(currentTimePos);
|
||||
deps.logInfo(
|
||||
`[subtitle-prefetch] started prefetching ${cues.length} cues from ${externalFilename}`,
|
||||
`[subtitle-prefetch] started prefetching ${cues.length} cues from ${sourcePath}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (revision === initRevision) {
|
||||
deps.onParsedSubtitleCuesChanged?.(null, null);
|
||||
deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildSubtitleSidebarSourceKey,
|
||||
getActiveExternalSubtitleSource,
|
||||
resolveSubtitleSourcePath,
|
||||
} from './subtitle-prefetch-source';
|
||||
@@ -17,6 +18,15 @@ test('getActiveExternalSubtitleSource returns the active external subtitle path'
|
||||
assert.equal(source, 'https://host/subs.ass');
|
||||
});
|
||||
|
||||
test('getActiveExternalSubtitleSource normalizes integer-like string track ids', () => {
|
||||
const source = getActiveExternalSubtitleSource(
|
||||
[{ type: 'sub', id: '2', external: true, 'external-filename': ' /tmp/subs.ass ' }],
|
||||
'2',
|
||||
);
|
||||
|
||||
assert.equal(source, '/tmp/subs.ass');
|
||||
});
|
||||
|
||||
test('getActiveExternalSubtitleSource returns null when the selected track is not external', () => {
|
||||
const source = getActiveExternalSubtitleSource(
|
||||
[{ type: 'sub', id: 2, external: false, 'external-filename': '/tmp/subs.ass' }],
|
||||
@@ -48,3 +58,38 @@ test('resolveSubtitleSourcePath returns the original source for malformed file U
|
||||
|
||||
assert.equal(resolveSubtitleSourcePath(source), source);
|
||||
});
|
||||
|
||||
test('buildSubtitleSidebarSourceKey uses a stable identifier for internal subtitle tracks', () => {
|
||||
const firstKey = buildSubtitleSidebarSourceKey('/media/episode01.mkv', {
|
||||
id: 3,
|
||||
'ff-index': 7,
|
||||
title: 'English',
|
||||
lang: 'eng',
|
||||
codec: 'ass',
|
||||
});
|
||||
const secondKey = buildSubtitleSidebarSourceKey('/media/episode01.mkv', {
|
||||
id: 3,
|
||||
'ff-index': 7,
|
||||
title: 'English',
|
||||
lang: 'eng',
|
||||
codec: 'ass',
|
||||
});
|
||||
|
||||
assert.equal(firstKey, secondKey);
|
||||
assert.equal(firstKey, 'internal:/media/episode01.mkv:track:3:ff:7');
|
||||
});
|
||||
|
||||
test('buildSubtitleSidebarSourceKey normalizes integer-like string track metadata', () => {
|
||||
const key = buildSubtitleSidebarSourceKey('/media/episode01.mkv', {
|
||||
id: '3',
|
||||
'ff-index': '7',
|
||||
});
|
||||
|
||||
assert.equal(key, 'internal:/media/episode01.mkv:track:3:ff:7');
|
||||
});
|
||||
|
||||
test('buildSubtitleSidebarSourceKey falls back to source path when no track metadata is available', () => {
|
||||
const key = buildSubtitleSidebarSourceKey('/media/episode01.mkv', null, '/tmp/subtitle.ass');
|
||||
|
||||
assert.equal(key, '/tmp/subtitle.ass');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
function parseTrackId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getActiveExternalSubtitleSource(
|
||||
trackListRaw: unknown,
|
||||
sidRaw: unknown,
|
||||
@@ -8,9 +19,8 @@ export function getActiveExternalSubtitleSource(
|
||||
return null;
|
||||
}
|
||||
|
||||
const sid =
|
||||
typeof sidRaw === 'number' ? sidRaw : typeof sidRaw === 'string' ? Number(sidRaw) : null;
|
||||
if (sid == null || !Number.isFinite(sid)) {
|
||||
const sid = parseTrackId(sidRaw);
|
||||
if (sid === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -19,7 +29,7 @@ export function getActiveExternalSubtitleSource(
|
||||
return false;
|
||||
}
|
||||
const track = entry as Record<string, unknown>;
|
||||
return track.type === 'sub' && track.id === sid && track.external === true;
|
||||
return track.type === 'sub' && parseTrackId(track.id) === sid && track.external === true;
|
||||
}) as Record<string, unknown> | undefined;
|
||||
|
||||
const externalFilename =
|
||||
@@ -40,3 +50,21 @@ export function resolveSubtitleSourcePath(source: string): string {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSubtitleSidebarSourceKey(
|
||||
videoPath: string,
|
||||
track: unknown,
|
||||
fallbackSourcePath?: string,
|
||||
): string {
|
||||
const normalizedVideoPath = videoPath.trim();
|
||||
if (track && typeof track === 'object' && normalizedVideoPath) {
|
||||
const subtitleTrack = track as Record<string, unknown>;
|
||||
const trackId = parseTrackId(subtitleTrack.id);
|
||||
const ffIndex = parseTrackId(subtitleTrack['ff-index']);
|
||||
if (trackId !== null || ffIndex !== null) {
|
||||
return `internal:${normalizedVideoPath}:track:${trackId ?? 'unknown'}:ff:${ffIndex ?? 'unknown'}`;
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackSourcePath ?? normalizedVideoPath;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
KikuFieldGroupingChoice,
|
||||
JlptLevel,
|
||||
FrequencyDictionaryLookup,
|
||||
SubtitleCue,
|
||||
} from '../types';
|
||||
import type { CliArgs } from '../cli/args';
|
||||
import type { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
@@ -158,6 +159,8 @@ export interface AppState {
|
||||
currentSubText: string;
|
||||
currentSubAssText: string;
|
||||
currentSubtitleData: SubtitleData | null;
|
||||
activeParsedSubtitleCues: SubtitleCue[];
|
||||
activeParsedSubtitleSource: string | null;
|
||||
windowTracker: BaseWindowTracker | null;
|
||||
subtitlePosition: SubtitlePosition | null;
|
||||
currentMediaPath: string | null;
|
||||
@@ -238,6 +241,8 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
currentSubtitleData: null,
|
||||
activeParsedSubtitleCues: [],
|
||||
activeParsedSubtitleSource: null,
|
||||
windowTracker: null,
|
||||
subtitlePosition: null,
|
||||
currentMediaPath: null,
|
||||
|
||||
Reference in New Issue
Block a user