mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
fix: delegate multi-line digit selection to visible overlay (#78)
This commit is contained in:
@@ -21,7 +21,7 @@ test('manual watched session action starts immersion tracker before marking watc
|
||||
);
|
||||
});
|
||||
|
||||
test('media path changes clear rendered subtitle state', () => {
|
||||
test('media path changes clear rendered subtitle state without clearing same-youtube parsed cues', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);/,
|
||||
@@ -31,8 +31,11 @@ test('media path changes clear rendered subtitle state', () => {
|
||||
assert.match(actionBlock, /appState\.currentSubText = '';/);
|
||||
assert.match(actionBlock, /appState\.currentSubAssText = '';/);
|
||||
assert.match(actionBlock, /appState\.currentSubtitleData = null;/);
|
||||
assert.match(actionBlock, /isSameYoutubeMediaPath\(/);
|
||||
assert.match(actionBlock, /if \(!preserveParsedSubtitleCues\)/);
|
||||
assert.match(actionBlock, /appState\.activeParsedSubtitleCues = \[\];/);
|
||||
assert.match(actionBlock, /appState\.activeParsedSubtitleSource = null;/);
|
||||
assert.match(actionBlock, /appState\.activeParsedSubtitleMediaPath = null;/);
|
||||
assert.match(actionBlock, /lastObservedTimePos = 0;/);
|
||||
assert.match(actionBlock, /broadcastToOverlayWindows\('subtitle:set',/);
|
||||
assert.match(actionBlock, /subtitleWsService\.broadcast\(/);
|
||||
@@ -52,3 +55,19 @@ test('main process uses one shared mpv plugin runtime config helper', () => {
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active-source parsing', () => {
|
||||
const source = readMainSource();
|
||||
const snapshotBlock = source.match(
|
||||
/getSubtitleSidebarSnapshot:\s*async\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler)/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(snapshotBlock);
|
||||
assert.match(snapshotBlock, /shouldUseCachedYoutubeParsedCues\(/);
|
||||
assert.match(snapshotBlock, /cachedMediaPath:\s*appState\.activeParsedSubtitleMediaPath/);
|
||||
assert.match(snapshotBlock, /cachedCueCount:\s*appState\.activeParsedSubtitleCues\.length/);
|
||||
assert.ok(
|
||||
snapshotBlock.indexOf('shouldUseCachedYoutubeParsedCues(') <
|
||||
snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,15 +40,17 @@ 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'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 30);
|
||||
assert.equal(calls.length, 31);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
destroyYomitanSettingsWindow: () => void;
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
@@ -60,6 +61,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.destroyYomitanSettingsWindow();
|
||||
deps.clearYomitanSettingsWindow();
|
||||
deps.stopJellyfinRemoteSession();
|
||||
deps.cleanupYoutubeSubtitleTempDirs();
|
||||
deps.stopDiscordPresenceService();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +69,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'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
@@ -89,6 +90,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-youtube-subtitles'));
|
||||
assert.ok(calls.includes('stop-discord-presence'));
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
@@ -142,6 +144,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
@@ -190,6 +193,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
@@ -139,6 +140,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
},
|
||||
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
|
||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,3 +83,39 @@ test('cli command runtime handler skips generic overlay prerequisites for youtub
|
||||
|
||||
assert.deepEqual(calls, ['context', 'cli:initial:ctx']);
|
||||
});
|
||||
|
||||
test('cli command runtime handler ensures tray for managed playback commands', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createCliCommandRuntimeHandler({
|
||||
handleTexthookerOnlyModeTransitionMainDeps: {
|
||||
isTexthookerOnlyMode: () => false,
|
||||
setTexthookerOnlyMode: () => calls.push('set-mode'),
|
||||
commandNeedsOverlayStartupPrereqs: () => false,
|
||||
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||
startBackgroundWarmups: () => calls.push('warmups'),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
},
|
||||
ensureTrayForCommand: (args) => {
|
||||
if (args.managedPlayback) {
|
||||
calls.push('ensure-tray');
|
||||
}
|
||||
},
|
||||
createCliCommandContext: () => {
|
||||
calls.push('context');
|
||||
return { id: 'ctx' };
|
||||
},
|
||||
handleCliCommandRuntimeServiceWithContext: (_args, source, context) => {
|
||||
calls.push(`cli:${source}:${context.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
handler(
|
||||
{
|
||||
managedPlayback: true,
|
||||
youtubePlay: 'https://youtube.com/watch?v=abc',
|
||||
} as never,
|
||||
'second-instance',
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['ensure-tray', 'context', 'cli:second-instance:ctx']);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ type HandleTexthookerOnlyModeTransitionMainDeps = Parameters<
|
||||
|
||||
export function createCliCommandRuntimeHandler<TCliContext>(deps: {
|
||||
handleTexthookerOnlyModeTransitionMainDeps: HandleTexthookerOnlyModeTransitionMainDeps;
|
||||
ensureTrayForCommand?: (args: CliArgs, source: CliCommandSource) => void;
|
||||
createCliCommandContext: () => TCliContext;
|
||||
handleCliCommandRuntimeServiceWithContext: (
|
||||
args: CliArgs,
|
||||
@@ -29,6 +30,7 @@ export function createCliCommandRuntimeHandler<TCliContext>(deps: {
|
||||
) {
|
||||
deps.handleTexthookerOnlyModeTransitionMainDeps.ensureOverlayStartupPrereqs();
|
||||
}
|
||||
deps.ensureTrayForCommand?.(args, source);
|
||||
const cliContext = deps.createCliCommandContext();
|
||||
deps.handleCliCommandRuntimeServiceWithContext(args, source, cliContext);
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: async () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
},
|
||||
shouldRestoreWindowsOnActivateMainDeps: {
|
||||
|
||||
@@ -9,6 +9,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: {
|
||||
connected: true,
|
||||
currentSecondarySubText: 'secondary',
|
||||
currentTimePos: 12.25,
|
||||
requestProperty: async () => 18.75,
|
||||
},
|
||||
@@ -20,7 +21,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`),
|
||||
},
|
||||
subtitleTimingTracker: {
|
||||
recordSubtitle: (text: string) => calls.push(`timing:${text}`),
|
||||
recordSubtitle: (text: string, _start: number, _end: number, secondaryText?: string) =>
|
||||
calls.push(`timing:${text}:${secondaryText ?? ''}`),
|
||||
},
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
@@ -113,6 +115,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
assert.ok(calls.includes('remote-stopped'));
|
||||
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('ensure-immersion'));
|
||||
assert.ok(calls.includes('sync-immersion'));
|
||||
assert.ok(calls.includes('autoplay:/tmp/video'));
|
||||
|
||||
@@ -32,7 +32,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
recordPauseState?: (paused: boolean) => void;
|
||||
} | null;
|
||||
subtitleTimingTracker: {
|
||||
recordSubtitle?: (text: string, start: number, end: number) => void;
|
||||
recordSubtitle?: (text: string, start: number, end: number, secondaryText?: string) => void;
|
||||
} | null;
|
||||
currentMediaPath?: string | null;
|
||||
currentSubText: string;
|
||||
@@ -132,7 +132,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
},
|
||||
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
||||
recordSubtitleTiming: (text: string, start: number, end: number) =>
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end),
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||
text,
|
||||
start,
|
||||
end,
|
||||
deps.appState.mpvClient?.currentSecondarySubText || undefined,
|
||||
),
|
||||
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) =>
|
||||
deps.maybeRunAnilistPostWatchUpdate(options),
|
||||
logSubtitleTimingError: (message: string, error: unknown) =>
|
||||
|
||||
@@ -33,3 +33,28 @@ test('numeric shortcut session runtime handlers compose cancel/start handlers',
|
||||
'mine-sentence:digit:3',
|
||||
]);
|
||||
});
|
||||
|
||||
test('numeric shortcut session runtime handlers prefer overlay digit selection when available', () => {
|
||||
const calls: string[] = [];
|
||||
const createSession = (name: string) => ({
|
||||
start: () => calls.push(`${name}:start`),
|
||||
cancel: () => calls.push(`${name}:cancel`),
|
||||
});
|
||||
|
||||
const runtime = createNumericShortcutSessionRuntimeHandlers({
|
||||
multiCopySession: createSession('multi-copy'),
|
||||
mineSentenceSession: createSession('mine-sentence'),
|
||||
onMultiCopyDigit: () => calls.push('multi-copy:digit'),
|
||||
onMineSentenceDigit: () => calls.push('mine-sentence:digit'),
|
||||
tryBeginMultiCopyOverlaySelection: (timeoutMs) => {
|
||||
calls.push(`multi-copy:overlay:${timeoutMs}`);
|
||||
return true;
|
||||
},
|
||||
tryBeginMineSentenceOverlaySelection: () => false,
|
||||
});
|
||||
|
||||
runtime.startPendingMultiCopy(500);
|
||||
runtime.startPendingMineSentenceMultiple(700);
|
||||
|
||||
assert.deepEqual(calls, ['multi-copy:overlay:500', 'mine-sentence:start']);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ export function createNumericShortcutSessionRuntimeHandlers(deps: {
|
||||
mineSentenceSession: CancelNumericShortcutSessionMainDeps['session'];
|
||||
onMultiCopyDigit: (count: number) => void;
|
||||
onMineSentenceDigit: (count: number) => void;
|
||||
tryBeginMultiCopyOverlaySelection?: (timeoutMs: number) => boolean;
|
||||
tryBeginMineSentenceOverlaySelection?: (timeoutMs: number) => boolean;
|
||||
}) {
|
||||
const cancelPendingMultiCopyMainDeps = createBuildCancelNumericShortcutSessionMainDepsHandler({
|
||||
session: deps.multiCopySession,
|
||||
@@ -61,9 +63,14 @@ export function createNumericShortcutSessionRuntimeHandlers(deps: {
|
||||
|
||||
return {
|
||||
cancelPendingMultiCopy: () => cancelPendingMultiCopyHandler(),
|
||||
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopyHandler(timeoutMs),
|
||||
startPendingMultiCopy: (timeoutMs: number) => {
|
||||
if (deps.tryBeginMultiCopyOverlaySelection?.(timeoutMs)) return;
|
||||
startPendingMultiCopyHandler(timeoutMs);
|
||||
},
|
||||
cancelPendingMineSentenceMultiple: () => cancelPendingMineSentenceMultipleHandler(),
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) =>
|
||||
startPendingMineSentenceMultipleHandler(timeoutMs),
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) => {
|
||||
if (deps.tryBeginMineSentenceOverlaySelection?.(timeoutMs)) return;
|
||||
startPendingMineSentenceMultipleHandler(timeoutMs);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { tryBeginVisibleOverlayNumericSelection } from './overlay-numeric-selection';
|
||||
|
||||
function createWindowStub(
|
||||
options: {
|
||||
destroyed?: boolean;
|
||||
visible?: boolean;
|
||||
focused?: boolean;
|
||||
webContentsFocused?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
window: {
|
||||
isDestroyed: () => options.destroyed === true,
|
||||
isVisible: () => options.visible !== false,
|
||||
isFocused: () => options.focused === true,
|
||||
setIgnoreMouseEvents: (ignore: boolean) => {
|
||||
calls.push(`mouse:${ignore}`);
|
||||
},
|
||||
focus: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
webContents: {
|
||||
isFocused: () => options.webContentsFocused === true,
|
||||
focus: () => {
|
||||
calls.push('web-focus');
|
||||
},
|
||||
send: (channel: string, payload: unknown) => {
|
||||
calls.push(`send:${channel}:${JSON.stringify(payload)}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('tryBeginVisibleOverlayNumericSelection focuses visible overlay and sends selector event', () => {
|
||||
const { window, calls } = createWindowStub();
|
||||
|
||||
const handled = tryBeginVisibleOverlayNumericSelection({
|
||||
actionId: 'copySubtitleMultiple',
|
||||
timeoutMs: 1234,
|
||||
getMainWindow: () => window,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, [
|
||||
'mouse:false',
|
||||
'focus',
|
||||
'web-focus',
|
||||
`send:${IPC_CHANNELS.event.sessionNumericSelectionStart}:{"actionId":"copySubtitleMultiple","timeoutMs":1234}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test('tryBeginVisibleOverlayNumericSelection skips hidden visible overlay', () => {
|
||||
const { window, calls } = createWindowStub({ visible: false });
|
||||
|
||||
const handled = tryBeginVisibleOverlayNumericSelection({
|
||||
actionId: 'mineSentenceMultiple',
|
||||
timeoutMs: 3000,
|
||||
getMainWindow: () => window,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import type { SessionNumericSelectionStartPayload } from '../../types/runtime';
|
||||
|
||||
type OverlayNumericSelectionWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
isFocused?: () => boolean;
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||
focus: () => void;
|
||||
webContents: {
|
||||
isFocused?: () => boolean;
|
||||
focus: () => void;
|
||||
send: (channel: string, payload: SessionNumericSelectionStartPayload) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function tryBeginVisibleOverlayNumericSelection(options: {
|
||||
actionId: SessionNumericSelectionStartPayload['actionId'];
|
||||
timeoutMs: number;
|
||||
getMainWindow: () => OverlayNumericSelectionWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
}): boolean {
|
||||
if (!options.getVisibleOverlayVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mainWindow = options.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
if (typeof mainWindow.isFocused !== 'function' || !mainWindow.isFocused()) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
if (
|
||||
typeof mainWindow.webContents.isFocused !== 'function' ||
|
||||
!mainWindow.webContents.isFocused()
|
||||
) {
|
||||
mainWindow.webContents.focus();
|
||||
}
|
||||
mainWindow.webContents.send(IPC_CHANNELS.event.sessionNumericSelectionStart, {
|
||||
actionId: options.actionId,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createResolveActiveSubtitleSidebarSourceHandler } from './subtitle-prefetch-runtime';
|
||||
import {
|
||||
createRefreshSubtitlePrefetchFromActiveTrackHandler,
|
||||
createResolveActiveSubtitleSidebarSourceHandler,
|
||||
} from './subtitle-prefetch-runtime';
|
||||
|
||||
test('subtitle prefetch runtime resolves direct external subtitle sources first', async () => {
|
||||
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
@@ -57,3 +60,43 @@ test('subtitle prefetch runtime extracts internal subtitle tracks into a stable
|
||||
cleanup: resolved?.cleanup,
|
||||
});
|
||||
});
|
||||
|
||||
test('subtitle prefetch runtime preserves parsed cues when YouTube active track source is unresolved', async () => {
|
||||
const calls: string[] = [];
|
||||
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'path') return 'https://www.youtube.com/watch?v=video123';
|
||||
if (name === 'track-list') {
|
||||
return [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 4,
|
||||
lang: 'ja',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (name === 'sid') return 4;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getLastObservedTimePos: () => 12,
|
||||
subtitlePrefetchInitController: {
|
||||
cancelPendingInit: () => {
|
||||
calls.push('cancel');
|
||||
},
|
||||
initSubtitlePrefetch: async () => {
|
||||
calls.push('init');
|
||||
},
|
||||
},
|
||||
resolveActiveSubtitleSidebarSource: async () => null,
|
||||
shouldKeepExistingCuesOnMissingSource: (videoPath) => videoPath.includes('youtube.com'),
|
||||
});
|
||||
|
||||
await refresh();
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
@@ -126,6 +126,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
} | null;
|
||||
getLastObservedTimePos: () => number;
|
||||
shouldKeepExistingCuesOnMissingSource?: (videoPath: string) => boolean;
|
||||
subtitlePrefetchInitController: SubtitlePrefetchInitController;
|
||||
resolveActiveSubtitleSidebarSource: (
|
||||
input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0],
|
||||
@@ -160,6 +161,9 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
||||
videoPath,
|
||||
});
|
||||
if (!resolvedSource) {
|
||||
if (deps.shouldKeepExistingCuesOnMissingSource?.(videoPath) === true) {
|
||||
return;
|
||||
}
|
||||
deps.subtitlePrefetchInitController.cancelPendingInit();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -104,6 +104,55 @@ test('ensure tray creates new tray and binds click handler', () => {
|
||||
assert.ok(calls.includes('bind-click'));
|
||||
});
|
||||
|
||||
test('ensure tray logs Linux tray registration failures without crashing startup', () => {
|
||||
const calls: string[] = [];
|
||||
let trayRef: unknown = null;
|
||||
|
||||
const ensureTray = createEnsureTrayHandler({
|
||||
getTray: () => null,
|
||||
setTray: (tray) => {
|
||||
trayRef = tray;
|
||||
calls.push('set-tray');
|
||||
},
|
||||
buildTrayMenu: () => ({ id: 'menu' }),
|
||||
resolveTrayIconPath: () => '/tmp/icon.png',
|
||||
createImageFromPath: () =>
|
||||
({
|
||||
isEmpty: () => false,
|
||||
resize: () => ({
|
||||
isEmpty: () => false,
|
||||
resize: () => {
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
setTemplateImage: () => {},
|
||||
}),
|
||||
setTemplateImage: () => {},
|
||||
}) as never,
|
||||
createEmptyImage: () =>
|
||||
({
|
||||
isEmpty: () => true,
|
||||
resize: () => {
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
setTemplateImage: () => {},
|
||||
}) as never,
|
||||
createTray: () => {
|
||||
throw new Error('StatusNotifier watcher unavailable');
|
||||
},
|
||||
trayTooltip: 'SubMiner',
|
||||
platform: 'linux',
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'),
|
||||
});
|
||||
|
||||
ensureTray();
|
||||
|
||||
assert.equal(trayRef, null);
|
||||
assert.deepEqual(calls, [
|
||||
'warn:Unable to create Linux tray icon. Ensure your desktop has a StatusNotifier/AppIndicator tray host. StatusNotifier watcher unavailable',
|
||||
]);
|
||||
});
|
||||
|
||||
test('destroy tray handler destroys active tray and clears ref', () => {
|
||||
const calls: string[] = [];
|
||||
let tray: { destroy: () => void } | null = {
|
||||
|
||||
@@ -48,7 +48,20 @@ export function createEnsureTrayHandler(deps: {
|
||||
trayIcon = trayIcon.resize({ width: 20, height: 20 });
|
||||
}
|
||||
|
||||
const tray = deps.createTray(trayIcon);
|
||||
let tray: TrayLike;
|
||||
try {
|
||||
tray = deps.createTray(trayIcon);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
if (deps.platform === 'linux') {
|
||||
deps.logWarn(
|
||||
`Unable to create Linux tray icon. Ensure your desktop has a StatusNotifier/AppIndicator tray host. ${reason}`,
|
||||
);
|
||||
} else {
|
||||
deps.logWarn(`Unable to create tray icon. ${reason}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
tray.setToolTip(deps.trayTooltip);
|
||||
tray.setContextMenu(deps.buildTrayMenu());
|
||||
tray.on('click', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { createYoutubeFlowRuntime } from './youtube-flow';
|
||||
import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types';
|
||||
@@ -306,6 +307,7 @@ test('youtube flow reports probe failure through the configured reporter in manu
|
||||
|
||||
test('youtube flow does not report failure when subtitle track binds before cue text appears', async () => {
|
||||
const failures: string[] = [];
|
||||
const loadedSignals: string[] = [];
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
@@ -358,6 +360,9 @@ test('youtube flow does not report failure when subtitle track binds before cue
|
||||
reportSubtitleFailure: (message) => {
|
||||
failures.push(message);
|
||||
},
|
||||
notifyPrimarySubtitleLoaded: () => {
|
||||
loadedSignals.push('loaded');
|
||||
},
|
||||
warn: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
@@ -368,6 +373,7 @@ test('youtube flow does not report failure when subtitle track binds before cue
|
||||
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||
|
||||
assert.deepEqual(failures, []);
|
||||
assert.deepEqual(loadedSignals, ['loaded']);
|
||||
});
|
||||
|
||||
test('youtube flow does not fail when mpv reports sub-text as unavailable after track bind', async () => {
|
||||
@@ -781,11 +787,13 @@ test('youtube flow leaves non-authoritative youtube subtitle tracks untouched af
|
||||
);
|
||||
});
|
||||
|
||||
test('youtube flow reuses existing manual youtube subtitle tracks when both requested languages already exist', async () => {
|
||||
test('youtube flow injects downloaded primary while reusing existing manual secondary tracks', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let selectedSecondarySid: number | null = null;
|
||||
let downloadedPrimaryAdded = false;
|
||||
const refreshedSidebarSources: string[] = [];
|
||||
const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt';
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
@@ -813,7 +821,7 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||
if (track.language === 'ja') {
|
||||
return { path: '/tmp/manual-ja.ja.srt' };
|
||||
return { path: downloadedPrimaryPath };
|
||||
}
|
||||
throw new Error('should not download secondary track when manual english already exists');
|
||||
},
|
||||
@@ -832,6 +840,13 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
if (
|
||||
command[0] === 'sub-add' &&
|
||||
command[1] === downloadedPrimaryPath &&
|
||||
command[2] === 'select'
|
||||
) {
|
||||
downloadedPrimaryAdded = true;
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
@@ -853,7 +868,7 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
||||
if (name === 'secondary-sid') {
|
||||
return selectedSecondarySid;
|
||||
}
|
||||
return [
|
||||
const tracks: Array<Record<string, unknown>> = [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
@@ -887,6 +902,17 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
||||
'external-filename': null,
|
||||
},
|
||||
];
|
||||
if (downloadedPrimaryAdded) {
|
||||
tracks.push({
|
||||
type: 'sub',
|
||||
id: 9,
|
||||
lang: 'ja',
|
||||
title: path.basename(downloadedPrimaryPath),
|
||||
external: true,
|
||||
'external-filename': downloadedPrimaryPath,
|
||||
});
|
||||
}
|
||||
return tracks;
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
refreshSubtitleSidebarSource: async (sourcePath) => {
|
||||
@@ -912,24 +938,451 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||
|
||||
assert.equal(selectedPrimarySid, 2);
|
||||
assert.equal(selectedPrimarySid, 9);
|
||||
assert.equal(selectedSecondarySid, 1);
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'sub-add'),
|
||||
false,
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(command) =>
|
||||
command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select',
|
||||
),
|
||||
);
|
||||
assert.deepEqual(refreshedSidebarSources, ['/tmp/manual-ja.ja.srt']);
|
||||
assert.deepEqual(refreshedSidebarSources, [downloadedPrimaryPath]);
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'sub-remove'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('youtube flow waits for manual youtube tracks to appear before falling back to injected copies', async () => {
|
||||
test('youtube flow injects downloaded primary subtitles instead of reusing streamed youtube tracks', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const refreshedSidebarSources: string[] = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let downloadedPrimaryAdded = false;
|
||||
const downloadedPrimaryPath = '/tmp/subminer-youtube-subtitles-abc/manual-ja.ja.vtt';
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
videoId: 'video123',
|
||||
title: 'Video 123',
|
||||
tracks: [
|
||||
{
|
||||
...primaryTrack,
|
||||
id: 'manual:ja',
|
||||
sourceLanguage: 'ja',
|
||||
kind: 'manual',
|
||||
title: 'Japanese',
|
||||
},
|
||||
],
|
||||
}),
|
||||
acquireYoutubeSubtitleTracks: async () => {
|
||||
throw new Error('single primary selection should not batch download');
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||
assert.equal(track.id, 'manual:ja');
|
||||
return { path: downloadedPrimaryPath };
|
||||
},
|
||||
openPicker: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
void runtime.resolveActivePicker({
|
||||
sessionId: payload.sessionId,
|
||||
action: 'use-selected',
|
||||
primaryTrackId: 'manual:ja',
|
||||
secondaryTrackId: null,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
pauseMpv: () => {},
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
if (
|
||||
command[0] === 'sub-add' &&
|
||||
command[1] === downloadedPrimaryPath &&
|
||||
command[2] === 'select'
|
||||
) {
|
||||
downloadedPrimaryAdded = true;
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'sub-text') {
|
||||
return '字幕です';
|
||||
}
|
||||
if (name === 'sid') {
|
||||
return selectedPrimarySid;
|
||||
}
|
||||
return downloadedPrimaryAdded
|
||||
? [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 2,
|
||||
lang: 'ja',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/mpv-ytdl-track-ja.vtt',
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 9,
|
||||
lang: 'ja',
|
||||
title: path.basename(downloadedPrimaryPath),
|
||||
external: true,
|
||||
'external-filename': downloadedPrimaryPath,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 2,
|
||||
lang: 'ja',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/mpv-ytdl-track-ja.vtt',
|
||||
},
|
||||
];
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
refreshSubtitleSidebarSource: async (sourcePath) => {
|
||||
refreshedSidebarSources.push(sourcePath);
|
||||
},
|
||||
startTokenizationWarmups: async () => {},
|
||||
waitForTokenizationReady: async () => {},
|
||||
waitForAnkiReady: async () => {},
|
||||
wait: async () => {},
|
||||
waitForPlaybackWindowReady: async () => {},
|
||||
waitForOverlayGeometryReady: async () => {},
|
||||
focusOverlayWindow: () => {},
|
||||
showMpvOsd: () => {},
|
||||
reportSubtitleFailure: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
warn: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
log: () => {},
|
||||
getYoutubeOutputDir: () => '/tmp',
|
||||
});
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
|
||||
|
||||
assert.equal(selectedPrimarySid, 9);
|
||||
assert.deepEqual(refreshedSidebarSources, [downloadedPrimaryPath]);
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(command) =>
|
||||
command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('youtube flow confirms primary subtitle load before sidebar and tokenization waits', async () => {
|
||||
const events: string[] = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let downloadedPrimaryAdded = false;
|
||||
const downloadedPrimaryPath = '/tmp/subminer-youtube-subtitles-abc/auto-ja-orig.vtt';
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
videoId: 'video123',
|
||||
title: 'Video 123',
|
||||
tracks: [primaryTrack],
|
||||
}),
|
||||
acquireYoutubeSubtitleTracks: async () => {
|
||||
throw new Error('single primary selection should not batch download');
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async () => ({ path: downloadedPrimaryPath }),
|
||||
openPicker: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
void runtime.resolveActivePicker({
|
||||
sessionId: payload.sessionId,
|
||||
action: 'use-selected',
|
||||
primaryTrackId: primaryTrack.id,
|
||||
secondaryTrackId: null,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
pauseMpv: () => {},
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
if (
|
||||
command[0] === 'sub-add' &&
|
||||
command[1] === downloadedPrimaryPath &&
|
||||
command[2] === 'select'
|
||||
) {
|
||||
downloadedPrimaryAdded = true;
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'sub-text') {
|
||||
return '字幕です';
|
||||
}
|
||||
if (name === 'sid') {
|
||||
return selectedPrimarySid;
|
||||
}
|
||||
return downloadedPrimaryAdded
|
||||
? [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 9,
|
||||
lang: 'ja-orig',
|
||||
title: path.basename(downloadedPrimaryPath),
|
||||
external: true,
|
||||
'external-filename': downloadedPrimaryPath,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
refreshSubtitleSidebarSource: async () => {
|
||||
events.push('sidebar');
|
||||
assert.ok(
|
||||
events.includes('notify'),
|
||||
'primary load should be confirmed before sidebar parsing can delay',
|
||||
);
|
||||
},
|
||||
startTokenizationWarmups: async () => {},
|
||||
waitForTokenizationReady: async () => {
|
||||
events.push('tokenization');
|
||||
assert.ok(
|
||||
events.includes('notify'),
|
||||
'primary load should be confirmed before tokenization waits can delay',
|
||||
);
|
||||
},
|
||||
waitForAnkiReady: async () => {},
|
||||
wait: async () => {},
|
||||
waitForPlaybackWindowReady: async () => {},
|
||||
waitForOverlayGeometryReady: async () => {},
|
||||
focusOverlayWindow: () => {},
|
||||
showMpvOsd: () => {},
|
||||
reportSubtitleFailure: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
notifyPrimarySubtitleLoaded: () => {
|
||||
events.push('notify');
|
||||
},
|
||||
warn: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
log: () => {},
|
||||
getYoutubeOutputDir: () => '/tmp',
|
||||
});
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
|
||||
|
||||
assert.deepEqual(events, ['notify', 'sidebar', 'tokenization']);
|
||||
});
|
||||
|
||||
test('youtube flow downloads subtitles into temporary dirs and exposes cleanup', async () => {
|
||||
const outputDirs: string[] = [];
|
||||
const cleanupCalls: string[][] = [];
|
||||
let tempDirIndex = 0;
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let addedSubtitlePath: string | null = null;
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
videoId: 'video123',
|
||||
title: 'Video 123',
|
||||
tracks: [primaryTrack],
|
||||
}),
|
||||
acquireYoutubeSubtitleTracks: async () => {
|
||||
throw new Error('single primary selection should not batch download');
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ outputDir }) => {
|
||||
outputDirs.push(outputDir);
|
||||
return { path: path.join(outputDir, 'auto-ja-orig.vtt') };
|
||||
},
|
||||
openPicker: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
void runtime.resolveActivePicker({
|
||||
sessionId: payload.sessionId,
|
||||
action: 'use-selected',
|
||||
primaryTrackId: primaryTrack.id,
|
||||
secondaryTrackId: null,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
pauseMpv: () => {},
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
if (command[0] === 'sub-add' && typeof command[1] === 'string') {
|
||||
addedSubtitlePath = command[1];
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'sub-text') {
|
||||
return '字幕です';
|
||||
}
|
||||
if (name === 'sid') {
|
||||
return selectedPrimarySid;
|
||||
}
|
||||
return addedSubtitlePath
|
||||
? [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 10 + tempDirIndex,
|
||||
lang: 'ja-orig',
|
||||
title: path.basename(addedSubtitlePath),
|
||||
external: true,
|
||||
'external-filename': addedSubtitlePath,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
refreshSubtitleSidebarSource: async () => {},
|
||||
startTokenizationWarmups: async () => {},
|
||||
waitForTokenizationReady: async () => {},
|
||||
waitForAnkiReady: async () => {},
|
||||
wait: async () => {},
|
||||
waitForPlaybackWindowReady: async () => {},
|
||||
waitForOverlayGeometryReady: async () => {},
|
||||
focusOverlayWindow: () => {},
|
||||
showMpvOsd: () => {},
|
||||
reportSubtitleFailure: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
warn: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
log: () => {},
|
||||
getYoutubeOutputDir: () => '/tmp/unused-youtube-cache',
|
||||
createSubtitleTempDir: async () => {
|
||||
tempDirIndex += 1;
|
||||
return `/tmp/subminer-youtube-subtitles-${tempDirIndex}`;
|
||||
},
|
||||
cleanupSubtitleTempDirs: (dirs) => {
|
||||
cleanupCalls.push([...dirs]);
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
|
||||
addedSubtitlePath = null;
|
||||
selectedPrimarySid = null;
|
||||
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
|
||||
runtime.cleanupSubtitleTempDirs();
|
||||
runtime.cleanupSubtitleTempDirs();
|
||||
|
||||
assert.deepEqual(outputDirs, [
|
||||
'/tmp/subminer-youtube-subtitles-1',
|
||||
'/tmp/subminer-youtube-subtitles-2',
|
||||
]);
|
||||
assert.deepEqual(cleanupCalls, [
|
||||
['/tmp/subminer-youtube-subtitles-1'],
|
||||
['/tmp/subminer-youtube-subtitles-2'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('youtube flow falls back to configured output dir when subtitle temp dir creation fails', async () => {
|
||||
const outputDirs: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let addedSubtitlePath: string | null = null;
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
videoId: 'video123',
|
||||
title: 'Video 123',
|
||||
tracks: [primaryTrack],
|
||||
}),
|
||||
acquireYoutubeSubtitleTracks: async () => {
|
||||
throw new Error('single primary selection should not batch download');
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ outputDir }) => {
|
||||
outputDirs.push(outputDir);
|
||||
return { path: path.join(outputDir, 'auto-ja-orig.vtt') };
|
||||
},
|
||||
openPicker: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
void runtime.resolveActivePicker({
|
||||
sessionId: payload.sessionId,
|
||||
action: 'use-selected',
|
||||
primaryTrackId: primaryTrack.id,
|
||||
secondaryTrackId: null,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
pauseMpv: () => {},
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
if (command[0] === 'sub-add' && typeof command[1] === 'string') {
|
||||
addedSubtitlePath = command[1];
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'sub-text') {
|
||||
return '字幕です';
|
||||
}
|
||||
if (name === 'sid') {
|
||||
return selectedPrimarySid;
|
||||
}
|
||||
return addedSubtitlePath
|
||||
? [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 11,
|
||||
lang: 'ja-orig',
|
||||
title: path.basename(addedSubtitlePath),
|
||||
external: true,
|
||||
'external-filename': addedSubtitlePath,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
refreshSubtitleSidebarSource: async () => {},
|
||||
startTokenizationWarmups: async () => {},
|
||||
waitForTokenizationReady: async () => {},
|
||||
waitForAnkiReady: async () => {},
|
||||
wait: async () => {},
|
||||
waitForPlaybackWindowReady: async () => {},
|
||||
waitForOverlayGeometryReady: async () => {},
|
||||
focusOverlayWindow: () => {},
|
||||
showMpvOsd: () => {},
|
||||
reportSubtitleFailure: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
warn: (message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
log: () => {},
|
||||
getYoutubeOutputDir: () => '/tmp/youtube-cache',
|
||||
createSubtitleTempDir: async () => {
|
||||
throw new Error('tmp unavailable');
|
||||
},
|
||||
cleanupSubtitleTempDirs: () => {},
|
||||
});
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
|
||||
|
||||
assert.deepEqual(outputDirs, ['/tmp/youtube-cache']);
|
||||
assert.deepEqual(warnings, [
|
||||
'Failed to create YouTube subtitle temp dir; using configured output dir: tmp unavailable',
|
||||
]);
|
||||
});
|
||||
|
||||
test('youtube flow waits for manual secondary tracks while injecting downloaded primary', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let selectedSecondarySid: number | null = null;
|
||||
let trackListReads = 0;
|
||||
let downloadedPrimaryAdded = false;
|
||||
const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt';
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
@@ -957,7 +1410,7 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||
if (track.language === 'ja') {
|
||||
return { path: '/tmp/manual-ja.ja.srt' };
|
||||
return { path: downloadedPrimaryPath };
|
||||
}
|
||||
throw new Error('should not download secondary track when manual english appears in mpv');
|
||||
},
|
||||
@@ -976,6 +1429,13 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
if (
|
||||
command[0] === 'sub-add' &&
|
||||
command[1] === downloadedPrimaryPath &&
|
||||
command[2] === 'select'
|
||||
) {
|
||||
downloadedPrimaryAdded = true;
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
@@ -1001,7 +1461,7 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
||||
if (trackListReads === 1) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
const tracks: Array<Record<string, unknown>> = [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
@@ -1035,6 +1495,17 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
||||
'external-filename': null,
|
||||
},
|
||||
];
|
||||
if (downloadedPrimaryAdded) {
|
||||
tracks.push({
|
||||
type: 'sub',
|
||||
id: 9,
|
||||
lang: 'ja',
|
||||
title: path.basename(downloadedPrimaryPath),
|
||||
external: true,
|
||||
'external-filename': downloadedPrimaryPath,
|
||||
});
|
||||
}
|
||||
return tracks;
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
startTokenizationWarmups: async () => {},
|
||||
@@ -1057,18 +1528,22 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||
|
||||
assert.equal(selectedPrimarySid, 2);
|
||||
assert.equal(selectedPrimarySid, 9);
|
||||
assert.equal(selectedSecondarySid, 1);
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'sub-add'),
|
||||
false,
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(command) =>
|
||||
command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('youtube flow reuses manual youtube tracks even when mpv exposes external filenames', async () => {
|
||||
test('youtube flow injects downloaded primary even when reusable manual youtube tracks exist', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let selectedSecondarySid: number | null = null;
|
||||
let downloadedPrimaryAdded = false;
|
||||
const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt';
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
@@ -1098,7 +1573,7 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||
if (track.id === 'manual:ja') {
|
||||
return { path: '/tmp/manual-ja.ja.srt' };
|
||||
return { path: downloadedPrimaryPath };
|
||||
}
|
||||
throw new Error(
|
||||
'should not download secondary track when existing manual english track is reusable',
|
||||
@@ -1109,6 +1584,13 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
if (
|
||||
command[0] === 'sub-add' &&
|
||||
command[1] === downloadedPrimaryPath &&
|
||||
command[2] === 'select'
|
||||
) {
|
||||
downloadedPrimaryAdded = true;
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid') {
|
||||
selectedPrimarySid = Number(command[2]);
|
||||
}
|
||||
@@ -1118,7 +1600,7 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
||||
},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'track-list') {
|
||||
return [
|
||||
const tracks: Array<Record<string, unknown>> = [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
@@ -1144,6 +1626,17 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
||||
'external-filename': '/tmp/mpv-ytdl-track-ja-en.vtt',
|
||||
},
|
||||
];
|
||||
if (downloadedPrimaryAdded) {
|
||||
tracks.push({
|
||||
type: 'sub',
|
||||
id: 9,
|
||||
lang: 'ja',
|
||||
title: path.basename(downloadedPrimaryPath),
|
||||
external: true,
|
||||
'external-filename': downloadedPrimaryPath,
|
||||
});
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
if (name === 'sid') {
|
||||
return selectedPrimarySid;
|
||||
@@ -1181,11 +1674,13 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
||||
mode: 'download',
|
||||
});
|
||||
|
||||
assert.equal(selectedPrimarySid, 2);
|
||||
assert.equal(selectedPrimarySid, 9);
|
||||
assert.equal(selectedSecondarySid, 1);
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'sub-add'),
|
||||
false,
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(command) =>
|
||||
command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ type YoutubeFlowDeps = {
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
requestMpvProperty: (name: string) => Promise<unknown>;
|
||||
refreshCurrentSubtitle: (text: string) => void;
|
||||
refreshSubtitleSidebarSource?: (sourcePath: string) => Promise<void>;
|
||||
refreshSubtitleSidebarSource?: (sourcePath: string, mediaPath?: string) => Promise<void>;
|
||||
startTokenizationWarmups: () => Promise<void>;
|
||||
waitForTokenizationReady: () => Promise<void>;
|
||||
waitForAnkiReady: () => Promise<void>;
|
||||
@@ -42,9 +42,12 @@ type YoutubeFlowDeps = {
|
||||
focusOverlayWindow: () => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
reportSubtitleFailure: (message: string) => void;
|
||||
notifyPrimarySubtitleLoaded?: () => void;
|
||||
warn: (message: string) => void;
|
||||
log: (message: string) => void;
|
||||
getYoutubeOutputDir: () => string;
|
||||
createSubtitleTempDir?: () => Promise<string>;
|
||||
cleanupSubtitleTempDirs?: (dirs: string[]) => void;
|
||||
};
|
||||
|
||||
type YoutubeFlowSession = {
|
||||
@@ -349,7 +352,9 @@ async function injectDownloadedSubtitles(
|
||||
}
|
||||
|
||||
let trackListRaw: unknown = await deps.requestMpvProperty('track-list');
|
||||
let primaryTrackId: number | null = primarySelection.existingTrackId;
|
||||
let primaryTrackId: number | null = primarySelection.injectedPath
|
||||
? null
|
||||
: primarySelection.existingTrackId;
|
||||
let secondaryTrackId: number | null = secondarySelection?.existingTrackId ?? null;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
if (attempt > 0 || primarySelection.injectedPath || secondarySelection?.injectedPath) {
|
||||
@@ -423,6 +428,53 @@ async function injectDownloadedSubtitles(
|
||||
|
||||
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
let activeSession: YoutubeFlowSession | null = null;
|
||||
const activeSubtitleTempDirs = new Set<string>();
|
||||
|
||||
const cleanupSubtitleTempDirs = (): void => {
|
||||
const dirs = [...activeSubtitleTempDirs];
|
||||
if (dirs.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!deps.cleanupSubtitleTempDirs) {
|
||||
activeSubtitleTempDirs.clear();
|
||||
return;
|
||||
}
|
||||
deps.cleanupSubtitleTempDirs(dirs);
|
||||
for (const dir of dirs) {
|
||||
activeSubtitleTempDirs.delete(dir);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupSubtitleTempDirsForNextLoad = (): void => {
|
||||
try {
|
||||
cleanupSubtitleTempDirs();
|
||||
} catch (error) {
|
||||
deps.warn(
|
||||
`Failed to cleanup YouTube subtitle temp files: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const prepareSubtitleOutputDir = async (fallbackOutputDir: string): Promise<string> => {
|
||||
if (!deps.createSubtitleTempDir || !deps.cleanupSubtitleTempDirs) {
|
||||
return fallbackOutputDir;
|
||||
}
|
||||
cleanupSubtitleTempDirsForNextLoad();
|
||||
try {
|
||||
const tempDir = await deps.createSubtitleTempDir();
|
||||
activeSubtitleTempDirs.add(tempDir);
|
||||
return tempDir;
|
||||
} catch (error) {
|
||||
deps.warn(
|
||||
`Failed to create YouTube subtitle temp dir; using configured output dir: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return fallbackOutputDir;
|
||||
}
|
||||
};
|
||||
|
||||
const acquireSelectedTracks = async (input: {
|
||||
targetUrl: string;
|
||||
@@ -567,6 +619,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
osdProgress.setMessage('Downloading subtitles...');
|
||||
}
|
||||
try {
|
||||
const outputDir = await prepareSubtitleOutputDir(input.outputDir);
|
||||
let initialTrackListRaw: unknown = null;
|
||||
let existingPrimaryTrackId: number | null = null;
|
||||
let existingSecondaryTrackId: number | null = null;
|
||||
@@ -602,19 +655,11 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
let primaryInjectedPath: string | null = null;
|
||||
let secondaryInjectedPath: string | null = null;
|
||||
|
||||
if (existingPrimaryTrackId !== null) {
|
||||
primarySidebarPath = (
|
||||
await deps.acquireYoutubeSubtitleTrack({
|
||||
targetUrl: input.url,
|
||||
outputDir: input.outputDir,
|
||||
track: input.primaryTrack,
|
||||
})
|
||||
).path;
|
||||
} else if (existingSecondaryTrackId !== null || !input.secondaryTrack) {
|
||||
if (existingSecondaryTrackId !== null || !input.secondaryTrack) {
|
||||
primaryInjectedPath = (
|
||||
await deps.acquireYoutubeSubtitleTrack({
|
||||
targetUrl: input.url,
|
||||
outputDir: input.outputDir,
|
||||
outputDir,
|
||||
track: input.primaryTrack,
|
||||
})
|
||||
).path;
|
||||
@@ -622,7 +667,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
} else {
|
||||
const acquired = await acquireSelectedTracks({
|
||||
targetUrl: input.url,
|
||||
outputDir: input.outputDir,
|
||||
outputDir,
|
||||
primaryTrack: input.primaryTrack,
|
||||
secondaryTrack: existingSecondaryTrackId === null ? input.secondaryTrack : null,
|
||||
secondaryFailureLabel: input.secondaryFailureLabel,
|
||||
@@ -641,7 +686,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
secondaryInjectedPath = (
|
||||
await deps.acquireYoutubeSubtitleTrack({
|
||||
targetUrl: input.url,
|
||||
outputDir: input.outputDir,
|
||||
outputDir,
|
||||
track: input.secondaryTrack,
|
||||
})
|
||||
).path;
|
||||
@@ -685,8 +730,9 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
if (!refreshedActiveSubtitle) {
|
||||
return false;
|
||||
}
|
||||
deps.notifyPrimarySubtitleLoaded?.();
|
||||
try {
|
||||
await deps.refreshSubtitleSidebarSource?.(primarySidebarPath);
|
||||
await deps.refreshSubtitleSidebarSource?.(primarySidebarPath, input.url);
|
||||
} catch (error) {
|
||||
deps.warn(
|
||||
`Failed to refresh parsed subtitle cues for sidebar: ${
|
||||
@@ -877,5 +923,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
resolveActivePicker,
|
||||
cancelActivePicker,
|
||||
hasActiveSession: () => Boolean(activeSession),
|
||||
cleanupSubtitleTempDirs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { isYoutubeMediaPath, isYoutubePlaybackActive } from './youtube-playback';
|
||||
import {
|
||||
isSameYoutubeMediaPath,
|
||||
isYoutubeMediaPath,
|
||||
isYoutubePlaybackActive,
|
||||
shouldUseCachedYoutubeParsedCues,
|
||||
} from './youtube-playback';
|
||||
|
||||
test('isYoutubeMediaPath detects youtube watch and short urls', () => {
|
||||
assert.equal(isYoutubeMediaPath('https://www.youtube.com/watch?v=abc123'), true);
|
||||
@@ -22,3 +27,49 @@ test('isYoutubePlaybackActive checks both current media and mpv video paths', ()
|
||||
assert.equal(isYoutubePlaybackActive('https://www.youtube.com/watch?v=abc123', null), true);
|
||||
assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', '/tmp/video.mkv'), false);
|
||||
});
|
||||
|
||||
test('isSameYoutubeMediaPath matches equivalent youtube urls by video id', () => {
|
||||
assert.equal(
|
||||
isSameYoutubeMediaPath('https://www.youtube.com/watch?v=abc123&t=30', 'https://youtu.be/abc123'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isSameYoutubeMediaPath(
|
||||
'https://www.youtube.com/embed/abc123',
|
||||
'https://www.youtube-nocookie.com/embed/abc123',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isSameYoutubeMediaPath('https://www.youtube.com/watch?v=abc123', 'https://youtu.be/xyz789'),
|
||||
false,
|
||||
);
|
||||
assert.equal(isSameYoutubeMediaPath('/tmp/video.mkv', 'https://youtu.be/abc123'), false);
|
||||
});
|
||||
|
||||
test('shouldUseCachedYoutubeParsedCues requires cached cues for the same youtube video', () => {
|
||||
assert.equal(
|
||||
shouldUseCachedYoutubeParsedCues({
|
||||
videoPath: 'https://www.youtube.com/watch?v=abc123&t=30',
|
||||
cachedMediaPath: 'https://youtu.be/abc123',
|
||||
cachedCueCount: 12,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldUseCachedYoutubeParsedCues({
|
||||
videoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
cachedMediaPath: 'https://youtu.be/abc123',
|
||||
cachedCueCount: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldUseCachedYoutubeParsedCues({
|
||||
videoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
cachedMediaPath: 'https://youtu.be/other',
|
||||
cachedCueCount: 12,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,39 @@ function matchesYoutubeHost(hostname: string, expectedHost: string): boolean {
|
||||
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
|
||||
}
|
||||
|
||||
function extractYoutubeVideoId(mediaPath: string | null | undefined): string | null {
|
||||
const normalized = trimToNull(mediaPath);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (matchesYoutubeHost(host, 'youtu.be')) {
|
||||
return parsed.pathname.replace(/^\/+/, '').split('/')[0]?.trim() || null;
|
||||
}
|
||||
if (
|
||||
!matchesYoutubeHost(host, 'youtube.com') &&
|
||||
!matchesYoutubeHost(host, 'youtube-nocookie.com')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.pathname === '/watch') {
|
||||
return parsed.searchParams.get('v')?.trim() || null;
|
||||
}
|
||||
const pathSegments = parsed.pathname.replace(/^\/+/, '').split('/');
|
||||
if (pathSegments[0] === 'shorts' || pathSegments[0] === 'embed') {
|
||||
return pathSegments[1]?.trim() || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolean {
|
||||
const normalized = trimToNull(mediaPath);
|
||||
if (!normalized) {
|
||||
@@ -31,6 +64,26 @@ export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolea
|
||||
);
|
||||
}
|
||||
|
||||
export function isSameYoutubeMediaPath(
|
||||
left: string | null | undefined,
|
||||
right: string | null | undefined,
|
||||
): boolean {
|
||||
const leftId = extractYoutubeVideoId(left);
|
||||
const rightId = extractYoutubeVideoId(right);
|
||||
return Boolean(leftId && rightId && leftId === rightId);
|
||||
}
|
||||
|
||||
export function shouldUseCachedYoutubeParsedCues(input: {
|
||||
videoPath: string | null | undefined;
|
||||
cachedMediaPath: string | null | undefined;
|
||||
cachedCueCount: number;
|
||||
}): boolean {
|
||||
return (
|
||||
input.cachedCueCount > 0 &&
|
||||
isSameYoutubeMediaPath(input.videoPath, input.cachedMediaPath)
|
||||
);
|
||||
}
|
||||
|
||||
export function isYoutubePlaybackActive(
|
||||
currentMediaPath: string | null | undefined,
|
||||
currentVideoPath: string | null | undefined,
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
|
||||
function createTimerHarness() {
|
||||
let nextId = 1;
|
||||
const timers = new Map<number, () => void>();
|
||||
const timers = new Map<number, () => void | Promise<void>>();
|
||||
return {
|
||||
schedule: (fn: () => void): YoutubePrimarySubtitleNotificationTimer => {
|
||||
schedule: (fn: () => void | Promise<void>): YoutubePrimarySubtitleNotificationTimer => {
|
||||
const id = nextId++;
|
||||
timers.set(id, fn);
|
||||
return { id };
|
||||
@@ -26,7 +26,14 @@ function createTimerHarness() {
|
||||
const pending = [...timers.values()];
|
||||
timers.clear();
|
||||
for (const fn of pending) {
|
||||
fn();
|
||||
void fn();
|
||||
}
|
||||
},
|
||||
runAllAsync: async () => {
|
||||
const pending = [...timers.values()];
|
||||
timers.clear();
|
||||
for (const fn of pending) {
|
||||
await fn();
|
||||
}
|
||||
},
|
||||
size: () => timers.size,
|
||||
@@ -195,3 +202,80 @@ test('notifier suppresses timer while app-owned youtube flow is still settling',
|
||||
'Primary subtitle failed to download or load. Try again from the subtitle modal.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('notifier suppresses stale delayed failure after primary subtitle load is confirmed', () => {
|
||||
const notifications: string[] = [];
|
||||
const timers = createTimerHarness();
|
||||
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
|
||||
getPrimarySubtitleLanguages: () => ['ja'],
|
||||
notifyFailure: (message) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
schedule: (fn) => timers.schedule(fn),
|
||||
clearSchedule: (timer) => timers.clear(timer),
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
|
||||
runtime.handleSubtitleTrackChange(null);
|
||||
runtime.handleSubtitleTrackListChange([
|
||||
{ type: 'sub', id: 2, lang: 'en', title: 'English', external: true },
|
||||
]);
|
||||
runtime.markCurrentMediaPrimarySubtitleLoaded();
|
||||
|
||||
assert.equal(timers.size(), 0);
|
||||
timers.runAll();
|
||||
assert.deepEqual(notifications, []);
|
||||
});
|
||||
|
||||
test('notifier suppresses delayed failure when live mpv state has downloaded primary selected', async () => {
|
||||
const notifications: string[] = [];
|
||||
const timers = createTimerHarness();
|
||||
let liveStateReads = 0;
|
||||
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
|
||||
getPrimarySubtitleLanguages: () => ['ja'],
|
||||
notifyFailure: (message) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
schedule: (fn) => timers.schedule(fn),
|
||||
clearSchedule: (timer) => timers.clear(timer),
|
||||
getCurrentSubtitleState: async () => {
|
||||
liveStateReads += 1;
|
||||
return {
|
||||
sid: 22,
|
||||
trackList: [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
lang: 'en',
|
||||
title: 'English',
|
||||
external: true,
|
||||
selected: true,
|
||||
'main-selection': 1,
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 22,
|
||||
lang: 'ja',
|
||||
title: 'manual-ja.ja.srt',
|
||||
external: true,
|
||||
selected: true,
|
||||
'main-selection': 0,
|
||||
'external-filename': '/tmp/subminer-youtube-subtitles-aahLWu/manual-ja.ja.srt',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=uO2jfacqjYQ');
|
||||
runtime.handleSubtitleTrackChange(null);
|
||||
runtime.handleSubtitleTrackListChange([
|
||||
{ type: 'sub', id: 1, lang: 'en', title: 'English', external: true, selected: false },
|
||||
]);
|
||||
|
||||
assert.equal(timers.size(), 1);
|
||||
await timers.runAllAsync();
|
||||
|
||||
assert.equal(liveStateReads, 1);
|
||||
assert.deepEqual(notifications, []);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,11 @@ type SubtitleTrackEntry = {
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
type CurrentSubtitleState = {
|
||||
sid: unknown;
|
||||
trackList: unknown[] | null;
|
||||
};
|
||||
|
||||
function parseTrackId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||
return value;
|
||||
@@ -101,8 +106,12 @@ function hasSelectedPrimarySubtitle(
|
||||
export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
getPrimarySubtitleLanguages: () => string[];
|
||||
notifyFailure: (message: string) => void;
|
||||
schedule: (fn: () => void, delayMs: number) => YoutubePrimarySubtitleNotificationTimer;
|
||||
schedule: (
|
||||
fn: () => void | Promise<void>,
|
||||
delayMs: number,
|
||||
) => YoutubePrimarySubtitleNotificationTimer;
|
||||
clearSchedule: (timer: YoutubePrimarySubtitleNotificationTimer | null) => void;
|
||||
getCurrentSubtitleState?: () => CurrentSubtitleState | null | Promise<CurrentSubtitleState | null>;
|
||||
delayMs?: number;
|
||||
}) {
|
||||
const delayMs = deps.delayMs ?? 5000;
|
||||
@@ -112,13 +121,35 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
let pendingTimer: YoutubePrimarySubtitleNotificationTimer | null = null;
|
||||
let lastReportedMediaPath: string | null = null;
|
||||
let appOwnedFlowInFlight = false;
|
||||
let primarySubtitleLoadedForCurrentMedia = false;
|
||||
|
||||
const clearPendingTimer = (): void => {
|
||||
deps.clearSchedule(pendingTimer);
|
||||
pendingTimer = null;
|
||||
};
|
||||
|
||||
const maybeReportFailure = (): void => {
|
||||
const refreshCurrentSubtitleState = async (
|
||||
preferredLanguages: Set<string>,
|
||||
): Promise<boolean> => {
|
||||
const getCurrentSubtitleState = deps.getCurrentSubtitleState;
|
||||
if (!getCurrentSubtitleState) {
|
||||
return false;
|
||||
}
|
||||
let state: CurrentSubtitleState | null;
|
||||
try {
|
||||
state = await getCurrentSubtitleState();
|
||||
} catch {
|
||||
state = null;
|
||||
}
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
currentSid = parseTrackId(state.sid);
|
||||
currentTrackList = Array.isArray(state.trackList) ? state.trackList : null;
|
||||
return hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages);
|
||||
};
|
||||
|
||||
const maybeReportFailure = async (): Promise<void> => {
|
||||
const mediaPath = currentMediaPath?.trim() || '';
|
||||
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
|
||||
return;
|
||||
@@ -126,13 +157,30 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
if (lastReportedMediaPath === mediaPath) {
|
||||
return;
|
||||
}
|
||||
if (appOwnedFlowInFlight) {
|
||||
return;
|
||||
}
|
||||
const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages());
|
||||
if (preferredLanguages.size === 0) {
|
||||
return;
|
||||
}
|
||||
if (primarySubtitleLoadedForCurrentMedia) {
|
||||
return;
|
||||
}
|
||||
if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) {
|
||||
return;
|
||||
}
|
||||
if (deps.getCurrentSubtitleState && (await refreshCurrentSubtitleState(preferredLanguages))) {
|
||||
clearPendingTimer();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
currentMediaPath?.trim() !== mediaPath ||
|
||||
appOwnedFlowInFlight ||
|
||||
primarySubtitleLoadedForCurrentMedia
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastReportedMediaPath = mediaPath;
|
||||
deps.notifyFailure(
|
||||
'Primary subtitle failed to download or load. Try again from the subtitle modal.',
|
||||
@@ -148,9 +196,12 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
|
||||
return;
|
||||
}
|
||||
pendingTimer = deps.schedule(() => {
|
||||
if (primarySubtitleLoadedForCurrentMedia) {
|
||||
return;
|
||||
}
|
||||
pendingTimer = deps.schedule(async () => {
|
||||
pendingTimer = null;
|
||||
maybeReportFailure();
|
||||
await maybeReportFailure();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
@@ -160,6 +211,7 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
typeof path === 'string' && path.trim().length > 0 ? path.trim() : null;
|
||||
if (currentMediaPath !== normalizedPath) {
|
||||
lastReportedMediaPath = null;
|
||||
primarySubtitleLoadedForCurrentMedia = false;
|
||||
}
|
||||
currentMediaPath = normalizedPath;
|
||||
currentSid = null;
|
||||
@@ -180,6 +232,14 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
clearPendingTimer();
|
||||
}
|
||||
},
|
||||
markCurrentMediaPrimarySubtitleLoaded: (): void => {
|
||||
const mediaPath = currentMediaPath?.trim() || '';
|
||||
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
|
||||
return;
|
||||
}
|
||||
primarySubtitleLoadedForCurrentMedia = true;
|
||||
clearPendingTimer();
|
||||
},
|
||||
setAppOwnedFlowInFlight: (inFlight: boolean): void => {
|
||||
appOwnedFlowInFlight = inFlight;
|
||||
if (inFlight) {
|
||||
|
||||
@@ -163,6 +163,7 @@ export interface AppState {
|
||||
currentSubtitleData: SubtitleData | null;
|
||||
activeParsedSubtitleCues: SubtitleCue[];
|
||||
activeParsedSubtitleSource: string | null;
|
||||
activeParsedSubtitleMediaPath: string | null;
|
||||
windowTracker: BaseWindowTracker | null;
|
||||
subtitlePosition: SubtitlePosition | null;
|
||||
currentMediaPath: string | null;
|
||||
@@ -248,6 +249,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
||||
currentSubtitleData: null,
|
||||
activeParsedSubtitleCues: [],
|
||||
activeParsedSubtitleSource: null,
|
||||
activeParsedSubtitleMediaPath: null,
|
||||
windowTracker: null,
|
||||
subtitlePosition: null,
|
||||
currentMediaPath: null,
|
||||
|
||||
Reference in New Issue
Block a user