fix: delegate multi-line digit selection to visible overlay (#78)

This commit is contained in:
2026-05-24 00:39:23 -07:00
committed by GitHub
parent c02edc90cc
commit da3c971ee6
62 changed files with 1822 additions and 209 deletions
+20 -1
View File
@@ -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'));
+7 -2
View File
@@ -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;
}
+49
View File
@@ -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 = {
+14 -1
View File
@@ -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', () => {
+517 -22
View File
@@ -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',
),
);
});
+62 -15
View File
@@ -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,
};
}
+52 -1
View File
@@ -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,
);
});
+53
View File
@@ -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) {
+2
View File
@@ -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,