mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-23 00:11:28 -07:00
fix: address CodeRabbit review feedback
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
# SubMiner
|
# SubMiner
|
||||||
|
|
||||||
### Turn mpv into a sentence-mining workstation.
|
## Turn mpv into a sentence-mining workstation.
|
||||||
|
|
||||||
Look up words with Yomitan, export to Anki in one key, track your immersion — all without leaving mpv.
|
Look up words with Yomitan, export to Anki in one key, track your immersion — all without leaving mpv.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.9.0 (2026-03-22)
|
||||||
|
- Added an app-owned YouTube subtitle picker flow that boots mpv paused, opens an overlay picker, and downloads selected subtitles into external files before playback resumes.
|
||||||
|
- Added explicit launcher/app YouTube subtitle modes `download` and `generate`, with `download` as the default path.
|
||||||
|
- Disabled mpv native YouTube subtitle auto-loading for the app-owned flow so injected external subtitle files stay authoritative.
|
||||||
|
- Added OSD status updates covering YouTube playback startup, subtitle acquisition, and subtitle loading.
|
||||||
|
- Improved sidebar startup/resume behavior and overlay/sidebar subtitle synchronization.
|
||||||
|
|
||||||
## v0.8.0 (2026-03-22)
|
## v0.8.0 (2026-03-22)
|
||||||
- Added a configurable subtitle sidebar feature (`subtitleSidebar`) with overlay/embedded rendering, click-to-seek cue list, and hot-reloadable visibility and behavior controls.
|
- Added a configurable subtitle sidebar feature (`subtitleSidebar`) with overlay/embedded rendering, click-to-seek cue list, and hot-reloadable visibility and behavior controls.
|
||||||
- Added a rendered sidebar modal with cue list display, click-to-seek, active-cue highlighting, and embedded layout support.
|
- Added a rendered sidebar modal with cue list display, click-to-seek, active-cue highlighting, and embedded layout support.
|
||||||
|
|||||||
@@ -558,7 +558,7 @@ export function buildSubminerScriptOpts(
|
|||||||
const parts = [
|
const parts = [
|
||||||
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
|
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
|
||||||
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
|
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
|
||||||
...extraParts,
|
...extraParts.map(sanitizeScriptOptValue),
|
||||||
];
|
];
|
||||||
if (logLevel !== 'info') {
|
if (logLevel !== 'info') {
|
||||||
parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`);
|
parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`);
|
||||||
|
|||||||
@@ -75,11 +75,5 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Boolean(
|
return false;
|
||||||
args.jellyfin ||
|
|
||||||
args.jellyfinLogin ||
|
|
||||||
args.jellyfinLogout ||
|
|
||||||
args.jellyfinPlay ||
|
|
||||||
args.jellyfinDiscovery,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|||||||
import { getDefaultLauncherLogFile, getDefaultMpvLogFile } from './types.js';
|
import { getDefaultLauncherLogFile, getDefaultMpvLogFile } from './types.js';
|
||||||
|
|
||||||
test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const resolved = getDefaultMpvLogFile({
|
const resolved = getDefaultMpvLogFile({
|
||||||
platform: 'win32',
|
platform: 'win32',
|
||||||
homeDir: 'C:\\Users\\tester',
|
homeDir: 'C:\\Users\\tester',
|
||||||
@@ -17,13 +18,14 @@ test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
|||||||
'C:\\Users\\tester\\AppData\\Roaming',
|
'C:\\Users\\tester\\AppData\\Roaming',
|
||||||
'SubMiner',
|
'SubMiner',
|
||||||
'logs',
|
'logs',
|
||||||
`mpv-${new Date().toISOString().slice(0, 10)}.log`,
|
`mpv-${today}.log`,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getDefaultLauncherLogFile uses launcher prefix', () => {
|
test('getDefaultLauncherLogFile uses launcher prefix', () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const resolved = getDefaultLauncherLogFile({
|
const resolved = getDefaultLauncherLogFile({
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
homeDir: '/home/tester',
|
homeDir: '/home/tester',
|
||||||
@@ -36,7 +38,7 @@ test('getDefaultLauncherLogFile uses launcher prefix', () => {
|
|||||||
'.config',
|
'.config',
|
||||||
'SubMiner',
|
'SubMiner',
|
||||||
'logs',
|
'logs',
|
||||||
`launcher-${new Date().toISOString().slice(0, 10)}.log`,
|
`launcher-${today}.log`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -789,7 +789,8 @@ function stopManagedOverlayApp(args: Args): void {
|
|||||||
const stopArgs = ['--stop'];
|
const stopArgs = ['--stop'];
|
||||||
if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel);
|
||||||
|
|
||||||
const result = spawnSync(state.appPath, stopArgs, {
|
const target = resolveAppSpawnTarget(state.appPath, stopArgs);
|
||||||
|
const result = spawnSync(target.command, target.args, {
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
env: buildAppEnv(),
|
env: buildAppEnv(),
|
||||||
});
|
});
|
||||||
@@ -919,7 +920,7 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): vo
|
|||||||
proc.once('error', (error) => {
|
proc.once('error', (error) => {
|
||||||
fail(`Failed to run app command: ${error.message}`);
|
fail(`Failed to run app command: ${error.message}`);
|
||||||
});
|
});
|
||||||
proc.once('exit', (code) => {
|
proc.once('close', (code) => {
|
||||||
process.exit(code ?? 0);
|
process.exit(code ?? 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -970,7 +971,7 @@ export function runAppCommandAttached(
|
|||||||
proc.once('error', (error) => {
|
proc.once('error', (error) => {
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
proc.once('exit', (code, signal) => {
|
proc.once('close', (code, signal) => {
|
||||||
if (code !== null) {
|
if (code !== null) {
|
||||||
resolve(code);
|
resolve(code);
|
||||||
} else if (signal) {
|
} else if (signal) {
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ test(
|
|||||||
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
|
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
|
||||||
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
|
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
|
||||||
await waitForJsonLines(appStartPath, 1);
|
await waitForJsonLines(appStartPath, 1);
|
||||||
|
await waitForJsonLines(appStopPath, 1);
|
||||||
|
|
||||||
const appStartEntries = readJsonLines(appStartPath);
|
const appStartEntries = readJsonLines(appStartPath);
|
||||||
const appStopEntries = readJsonLines(appStopPath);
|
const appStopEntries = readJsonLines(appStopPath);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
|
|||||||
local OVERLAY_START_MAX_ATTEMPTS = 6
|
local OVERLAY_START_MAX_ATTEMPTS = 6
|
||||||
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
|
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
|
||||||
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
||||||
|
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
|
||||||
|
|
||||||
function M.create(ctx)
|
function M.create(ctx)
|
||||||
local mp = ctx.mp
|
local mp = ctx.mp
|
||||||
@@ -47,7 +48,7 @@ function M.create(ctx)
|
|||||||
return parsed
|
return parsed
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return 15
|
return DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS
|
||||||
end
|
end
|
||||||
|
|
||||||
local function normalize_socket_path(path)
|
local function normalize_socket_path(path)
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ function resolvePreferredUrlFromMpvEdlSource(
|
|||||||
return typedMatch;
|
return typedMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mpv EDL sources usually list audio streams first and video streams last, so
|
||||||
|
// when classifyMediaUrl cannot identify a typed URL we fall back to stream order.
|
||||||
return kind === 'audio' ? urls[0] ?? null : urls[urls.length - 1] ?? null;
|
return kind === 'audio' ? urls[0] ?? null : urls[urls.length - 1] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -423,7 +423,6 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.stats ||
|
args.stats ||
|
||||||
args.jellyfin ||
|
args.jellyfin ||
|
||||||
args.jellyfinPlay ||
|
args.jellyfinPlay ||
|
||||||
Boolean(args.youtubePlay) ||
|
|
||||||
args.texthooker
|
args.texthooker
|
||||||
) {
|
) {
|
||||||
if (args.launchMpv) {
|
if (args.launchMpv) {
|
||||||
|
|||||||
@@ -250,6 +250,21 @@ test('handleCliCommand starts youtube playback flow on initial launch', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('handleCliCommand defaults youtube mode to download when omitted', () => {
|
||||||
|
const { deps, calls } = createDeps({
|
||||||
|
runYoutubePlaybackFlow: async (request) => {
|
||||||
|
calls.push(`youtube:${request.url}:${request.mode}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handleCliCommand(makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }), 'initial', deps);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'initializeOverlayRuntime',
|
||||||
|
'youtube:https://youtube.com/watch?v=abc:download',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('handleCliCommand processes --start for second-instance when overlay runtime is not initialized', () => {
|
test('handleCliCommand processes --start for second-instance when overlay runtime is not initialized', () => {
|
||||||
const { deps, calls } = createDeps();
|
const { deps, calls } = createDeps();
|
||||||
const args = makeArgs({ start: true });
|
const args = makeArgs({ start: true });
|
||||||
|
|||||||
@@ -152,6 +152,93 @@ test('initializeOverlayAnkiIntegration can initialize Anki transport after overl
|
|||||||
assert.equal(setIntegrationCalls, 1);
|
assert.equal(setIntegrationCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('initializeOverlayAnkiIntegration returns false when integration already exists', () => {
|
||||||
|
let createdIntegrations = 0;
|
||||||
|
let startedIntegrations = 0;
|
||||||
|
let setIntegrationCalls = 0;
|
||||||
|
|
||||||
|
const result = initializeOverlayAnkiIntegration({
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
ankiConnect: { enabled: true } as never,
|
||||||
|
}),
|
||||||
|
getSubtitleTimingTracker: () => ({}),
|
||||||
|
getMpvClient: () => ({
|
||||||
|
send: () => {},
|
||||||
|
}),
|
||||||
|
getRuntimeOptionsManager: () => ({
|
||||||
|
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||||
|
}),
|
||||||
|
getAnkiIntegration: () => ({}),
|
||||||
|
createAnkiIntegration: () => {
|
||||||
|
createdIntegrations += 1;
|
||||||
|
return {
|
||||||
|
start: () => {
|
||||||
|
startedIntegrations += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setAnkiIntegration: () => {
|
||||||
|
setIntegrationCalls += 1;
|
||||||
|
},
|
||||||
|
showDesktopNotification: () => {},
|
||||||
|
createFieldGroupingCallback: () => async () => ({
|
||||||
|
keepNoteId: 11,
|
||||||
|
deleteNoteId: 12,
|
||||||
|
deleteDuplicate: false,
|
||||||
|
cancelled: false,
|
||||||
|
}),
|
||||||
|
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, false);
|
||||||
|
assert.equal(createdIntegrations, 0);
|
||||||
|
assert.equal(startedIntegrations, 0);
|
||||||
|
assert.equal(setIntegrationCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initializeOverlayAnkiIntegration returns false when ankiConnect is disabled', () => {
|
||||||
|
let createdIntegrations = 0;
|
||||||
|
let startedIntegrations = 0;
|
||||||
|
let setIntegrationCalls = 0;
|
||||||
|
|
||||||
|
const result = initializeOverlayAnkiIntegration({
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
ankiConnect: { enabled: false } as never,
|
||||||
|
}),
|
||||||
|
getSubtitleTimingTracker: () => ({}),
|
||||||
|
getMpvClient: () => ({
|
||||||
|
send: () => {},
|
||||||
|
}),
|
||||||
|
getRuntimeOptionsManager: () => ({
|
||||||
|
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||||
|
}),
|
||||||
|
createAnkiIntegration: () => {
|
||||||
|
createdIntegrations += 1;
|
||||||
|
return {
|
||||||
|
start: () => {
|
||||||
|
startedIntegrations += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setAnkiIntegration: () => {
|
||||||
|
setIntegrationCalls += 1;
|
||||||
|
},
|
||||||
|
showDesktopNotification: () => {},
|
||||||
|
createFieldGroupingCallback: () => async () => ({
|
||||||
|
keepNoteId: 11,
|
||||||
|
deleteNoteId: 12,
|
||||||
|
deleteDuplicate: false,
|
||||||
|
cancelled: false,
|
||||||
|
}),
|
||||||
|
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, false);
|
||||||
|
assert.equal(createdIntegrations, 0);
|
||||||
|
assert.equal(startedIntegrations, 0);
|
||||||
|
assert.equal(setIntegrationCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('initializeOverlayRuntime can skip starting Anki integration transport', () => {
|
test('initializeOverlayRuntime can skip starting Anki integration transport', () => {
|
||||||
let createdIntegrations = 0;
|
let createdIntegrations = 0;
|
||||||
let startedIntegrations = 0;
|
let startedIntegrations = 0;
|
||||||
|
|||||||
@@ -195,6 +195,80 @@ test('runAppReadyRuntime headless refresh bootstraps Anki runtime without UI sta
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runAppReadyRuntime loads Yomitan before headless overlay fallback initialization', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await runAppReadyRuntime({
|
||||||
|
ensureDefaultConfigBootstrap: () => {
|
||||||
|
calls.push('bootstrap');
|
||||||
|
},
|
||||||
|
loadSubtitlePosition: () => {
|
||||||
|
calls.push('load-subtitle-position');
|
||||||
|
},
|
||||||
|
resolveKeybindings: () => {
|
||||||
|
calls.push('resolve-keybindings');
|
||||||
|
},
|
||||||
|
createMpvClient: () => {
|
||||||
|
calls.push('create-mpv');
|
||||||
|
},
|
||||||
|
reloadConfig: () => {
|
||||||
|
calls.push('reload-config');
|
||||||
|
},
|
||||||
|
getResolvedConfig: () => ({}),
|
||||||
|
getConfigWarnings: () => [],
|
||||||
|
logConfigWarning: () => {},
|
||||||
|
setLogLevel: () => {},
|
||||||
|
initRuntimeOptionsManager: () => {
|
||||||
|
calls.push('init-runtime-options');
|
||||||
|
},
|
||||||
|
setSecondarySubMode: () => {},
|
||||||
|
defaultSecondarySubMode: 'hover',
|
||||||
|
defaultWebsocketPort: 0,
|
||||||
|
defaultAnnotationWebsocketPort: 0,
|
||||||
|
defaultTexthookerPort: 0,
|
||||||
|
hasMpvWebsocketPlugin: () => false,
|
||||||
|
startSubtitleWebsocket: () => {},
|
||||||
|
startAnnotationWebsocket: () => {},
|
||||||
|
startTexthooker: () => {},
|
||||||
|
log: () => {},
|
||||||
|
createMecabTokenizerAndCheck: async () => {},
|
||||||
|
createSubtitleTimingTracker: () => {
|
||||||
|
calls.push('subtitle-timing');
|
||||||
|
},
|
||||||
|
createImmersionTracker: () => {},
|
||||||
|
startJellyfinRemoteSession: async () => {},
|
||||||
|
loadYomitanExtension: async () => {
|
||||||
|
calls.push('load-yomitan');
|
||||||
|
},
|
||||||
|
handleFirstRunSetup: async () => {},
|
||||||
|
prewarmSubtitleDictionaries: async () => {},
|
||||||
|
startBackgroundWarmups: () => {},
|
||||||
|
texthookerOnlyMode: false,
|
||||||
|
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||||
|
setVisibleOverlayVisible: () => {},
|
||||||
|
initializeOverlayRuntime: () => {
|
||||||
|
calls.push('init-overlay');
|
||||||
|
},
|
||||||
|
handleInitialArgs: () => {
|
||||||
|
calls.push('handle-initial-args');
|
||||||
|
},
|
||||||
|
shouldRunHeadlessInitialCommand: () => true,
|
||||||
|
shouldUseMinimalStartup: () => false,
|
||||||
|
shouldSkipHeavyStartup: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'bootstrap',
|
||||||
|
'reload-config',
|
||||||
|
'init-runtime-options',
|
||||||
|
'create-mpv',
|
||||||
|
'subtitle-timing',
|
||||||
|
'load-yomitan',
|
||||||
|
'init-overlay',
|
||||||
|
'handle-initial-args',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime', async () => {
|
test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
} else {
|
} else {
|
||||||
deps.createMpvClient();
|
deps.createMpvClient();
|
||||||
deps.createSubtitleTimingTracker();
|
deps.createSubtitleTimingTracker();
|
||||||
|
await deps.loadYomitanExtension();
|
||||||
deps.initializeOverlayRuntime();
|
deps.initializeOverlayRuntime();
|
||||||
deps.handleInitialArgs();
|
deps.handleInitialArgs();
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/core/services/youtube/kinds.ts
Normal file
1
src/core/services/youtube/kinds.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type YoutubeTrackKind = 'manual' | 'auto';
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
export type YoutubeTrackKind = 'manual' | 'auto';
|
import type { YoutubeTrackKind } from './kinds';
|
||||||
|
|
||||||
|
export type { YoutubeTrackKind };
|
||||||
|
|
||||||
export function normalizeYoutubeLangCode(value: string): string {
|
export function normalizeYoutubeLangCode(value: string): string {
|
||||||
return value.trim().toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9-]+/g, '');
|
return value.trim().toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9-]+/g, '');
|
||||||
@@ -37,4 +39,3 @@ export function formatYoutubeTrackLabel(input: {
|
|||||||
const base = input.title?.trim() || language;
|
const base = input.title?.trim() || language;
|
||||||
return `${base} (${input.kind})`;
|
return `${base} (${input.kind})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ import path from 'node:path';
|
|||||||
import { retimeYoutubeSubtitle } from './retime';
|
import { retimeYoutubeSubtitle } from './retime';
|
||||||
|
|
||||||
test('retimeYoutubeSubtitle uses the downloaded subtitle path as-is', async () => {
|
test('retimeYoutubeSubtitle uses the downloaded subtitle path as-is', async () => {
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-retime-'));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-retime-'));
|
||||||
try {
|
try {
|
||||||
const primaryPath = path.join(root, 'primary.vtt');
|
const primaryPath = path.join(root, 'primary.vtt');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export async function retimeYoutubeSubtitle(input: {
|
export async function retimeYoutubeSubtitle(input: {
|
||||||
primaryPath: string;
|
primaryPath: string;
|
||||||
secondaryPath: string | null;
|
secondaryPath: string | null;
|
||||||
}): Promise<{ ok: boolean; path: string; strategy: 'none'; message: string }> {
|
}): Promise<{ ok: boolean; path: string; strategy: 'none' | 'alass' | 'ffsubsync'; message: string }> {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
path: input.primaryPath,
|
path: input.primaryPath,
|
||||||
|
|||||||
@@ -74,16 +74,31 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
|
|||||||
return 'WEBVTT\n';
|
return 'WEBVTT\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocks = rows.map((row, index) => {
|
const blocks: string[] = [];
|
||||||
|
let previousText = '';
|
||||||
|
for (let index = 0; index < rows.length; index += 1) {
|
||||||
|
const row = rows[index]!;
|
||||||
const nextRow = rows[index + 1];
|
const nextRow = rows[index + 1];
|
||||||
const unclampedEnd = row.startMs + row.durationMs;
|
const unclampedEnd = row.startMs + row.durationMs;
|
||||||
const clampedEnd =
|
const clampedEnd =
|
||||||
nextRow && unclampedEnd > nextRow.startMs
|
nextRow && unclampedEnd > nextRow.startMs
|
||||||
? Math.max(row.startMs, nextRow.startMs - 1)
|
? Math.max(row.startMs, nextRow.startMs - 1)
|
||||||
: unclampedEnd;
|
: unclampedEnd;
|
||||||
|
if (clampedEnd <= row.startMs) {
|
||||||
|
previousText = row.text;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
return `${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${row.text}`;
|
const text =
|
||||||
});
|
previousText && row.text.startsWith(previousText)
|
||||||
|
? row.text.slice(previousText.length).trimStart()
|
||||||
|
: row.text;
|
||||||
|
previousText = row.text;
|
||||||
|
if (!text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
blocks.push(`${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
return `WEBVTT\n\n${blocks.join('\n\n')}\n`;
|
return `WEBVTT\n\n${blocks.join('\n\n')}\n`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -470,3 +470,48 @@ test('downloadYoutubeSubtitleTracks prefers direct download URLs when available'
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTracks keeps duplicate source-language direct downloads distinct', async () => {
|
||||||
|
await withTempDir(async (root) => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
await withStubFetch(
|
||||||
|
async (url) => {
|
||||||
|
seen.push(url);
|
||||||
|
return new Response(`WEBVTT\n${url}\n`, { status: 200 });
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await downloadYoutubeSubtitleTracks({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir: path.join(root, 'out'),
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
downloadUrl: 'https://example.com/subs/ja-auto.vtt',
|
||||||
|
fileExtension: 'vtt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'manual:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'manual',
|
||||||
|
label: 'Japanese (manual)',
|
||||||
|
downloadUrl: 'https://example.com/subs/ja-manual.vtt',
|
||||||
|
fileExtension: 'vtt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(seen, [
|
||||||
|
'https://example.com/subs/ja-auto.vtt',
|
||||||
|
'https://example.com/subs/ja-manual.vtt',
|
||||||
|
]);
|
||||||
|
assert.notEqual(result.get('auto:ja-orig'), result.get('manual:ja-orig'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,12 +7,28 @@ import { convertYoutubeTimedTextToVtt, isYoutubeTimedTextExtension } from './tim
|
|||||||
|
|
||||||
const YOUTUBE_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass']);
|
const YOUTUBE_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass']);
|
||||||
const YOUTUBE_BATCH_PREFIX = 'youtube-batch';
|
const YOUTUBE_BATCH_PREFIX = 'youtube-batch';
|
||||||
|
const YOUTUBE_DOWNLOAD_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
function runCapture(command: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
function createFetchTimeoutSignal(timeoutMs: number): AbortSignal | undefined {
|
||||||
|
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
||||||
|
return AbortSignal.timeout(timeoutMs);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCapture(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
timeoutMs = YOUTUBE_DOWNLOAD_TIMEOUT_MS,
|
||||||
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
proc.kill();
|
||||||
|
reject(new Error(`yt-dlp timed out after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
proc.stdout.setEncoding('utf8');
|
proc.stdout.setEncoding('utf8');
|
||||||
proc.stderr.setEncoding('utf8');
|
proc.stderr.setEncoding('utf8');
|
||||||
proc.stdout.on('data', (chunk) => {
|
proc.stdout.on('data', (chunk) => {
|
||||||
@@ -21,8 +37,12 @@ function runCapture(command: string, args: string[]): Promise<{ stdout: string;
|
|||||||
proc.stderr.on('data', (chunk) => {
|
proc.stderr.on('data', (chunk) => {
|
||||||
stderr += String(chunk);
|
stderr += String(chunk);
|
||||||
});
|
});
|
||||||
proc.once('error', reject);
|
proc.once('error', (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
proc.once('close', (code) => {
|
proc.once('close', (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve({ stdout, stderr });
|
resolve({ stdout, stderr });
|
||||||
return;
|
return;
|
||||||
@@ -35,11 +55,16 @@ function runCapture(command: string, args: string[]): Promise<{ stdout: string;
|
|||||||
function runCaptureDetailed(
|
function runCaptureDetailed(
|
||||||
command: string,
|
command: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
|
timeoutMs = YOUTUBE_DOWNLOAD_TIMEOUT_MS,
|
||||||
): Promise<{ stdout: string; stderr: string; code: number }> {
|
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
proc.kill();
|
||||||
|
reject(new Error(`yt-dlp timed out after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
proc.stdout.setEncoding('utf8');
|
proc.stdout.setEncoding('utf8');
|
||||||
proc.stderr.setEncoding('utf8');
|
proc.stderr.setEncoding('utf8');
|
||||||
proc.stdout.on('data', (chunk) => {
|
proc.stdout.on('data', (chunk) => {
|
||||||
@@ -48,8 +73,12 @@ function runCaptureDetailed(
|
|||||||
proc.stderr.on('data', (chunk) => {
|
proc.stderr.on('data', (chunk) => {
|
||||||
stderr += String(chunk);
|
stderr += String(chunk);
|
||||||
});
|
});
|
||||||
proc.once('error', reject);
|
proc.once('error', (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
proc.once('close', (code) => {
|
proc.once('close', (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
resolve({ stdout, stderr, code: code ?? 1 });
|
resolve({ stdout, stderr, code: code ?? 1 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -125,8 +154,13 @@ async function downloadSubtitleFromUrl(input: {
|
|||||||
: YOUTUBE_SUBTITLE_EXTENSIONS.has(`.${ext}`)
|
: YOUTUBE_SUBTITLE_EXTENSIONS.has(`.${ext}`)
|
||||||
? ext
|
? ext
|
||||||
: 'vtt';
|
: 'vtt';
|
||||||
const targetPath = path.join(input.outputDir, `${input.prefix}.${input.track.sourceLanguage}.${safeExt}`);
|
const targetPath = path.join(
|
||||||
const response = await fetch(input.track.downloadUrl);
|
input.outputDir,
|
||||||
|
`${input.prefix}.${input.track.sourceLanguage}.${safeExt}`,
|
||||||
|
);
|
||||||
|
const response = await fetch(input.track.downloadUrl, {
|
||||||
|
signal: createFetchTimeoutSignal(YOUTUBE_DOWNLOAD_TIMEOUT_MS),
|
||||||
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status} while downloading ${input.track.sourceLanguage}`);
|
throw new Error(`HTTP ${response.status} while downloading ${input.track.sourceLanguage}`);
|
||||||
}
|
}
|
||||||
@@ -195,6 +229,8 @@ export async function downloadYoutubeSubtitleTracks(input: {
|
|||||||
mode: YoutubeFlowMode;
|
mode: YoutubeFlowMode;
|
||||||
}): Promise<Map<string, string>> {
|
}): Promise<Map<string, string>> {
|
||||||
fs.mkdirSync(input.outputDir, { recursive: true });
|
fs.mkdirSync(input.outputDir, { recursive: true });
|
||||||
|
const hasDuplicateSourceLanguages =
|
||||||
|
new Set(input.tracks.map((track) => track.sourceLanguage)).size !== input.tracks.length;
|
||||||
for (const name of fs.readdirSync(input.outputDir)) {
|
for (const name of fs.readdirSync(input.outputDir)) {
|
||||||
if (name.startsWith(`${YOUTUBE_BATCH_PREFIX}.`)) {
|
if (name.startsWith(`${YOUTUBE_BATCH_PREFIX}.`)) {
|
||||||
try {
|
try {
|
||||||
@@ -204,12 +240,12 @@ export async function downloadYoutubeSubtitleTracks(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (input.tracks.every(canDownloadSubtitleFromUrl)) {
|
if (hasDuplicateSourceLanguages || input.tracks.every(canDownloadSubtitleFromUrl)) {
|
||||||
const results = new Map<string, string>();
|
const results = new Map<string, string>();
|
||||||
for (const track of input.tracks) {
|
for (const track of input.tracks) {
|
||||||
const download = await downloadSubtitleFromUrl({
|
const download = await downloadSubtitleFromUrl({
|
||||||
outputDir: input.outputDir,
|
outputDir: input.outputDir,
|
||||||
prefix: YOUTUBE_BATCH_PREFIX,
|
prefix: track.id.replace(/[^a-z0-9_-]+/gi, '-'),
|
||||||
track,
|
track,
|
||||||
});
|
});
|
||||||
results.set(track.id, download.path);
|
results.set(track.id, download.path);
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|||||||
|
|
||||||
function makeFakeYtDlpScript(dir: string, payload: unknown): void {
|
function makeFakeYtDlpScript(dir: string, payload: unknown): void {
|
||||||
const scriptPath = path.join(dir, 'yt-dlp');
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
|
const stdoutBody = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||||
const script = `#!/usr/bin/env node
|
const script = `#!/usr/bin/env node
|
||||||
process.stdout.write(${JSON.stringify(JSON.stringify(payload))});
|
process.stdout.write(${JSON.stringify(stdoutBody)});
|
||||||
`;
|
`;
|
||||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
fs.chmodSync(scriptPath, 0o755);
|
fs.chmodSync(scriptPath, 0o755);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(scriptPath + '.cmd', `@echo off\r\nnode "${scriptPath}"\r\n`, 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withFakeYtDlp<T>(payload: unknown, fn: () => Promise<T>): Promise<T> {
|
async function withFakeYtDlp<T>(payload: unknown, fn: () => Promise<T>): Promise<T> {
|
||||||
@@ -78,3 +82,12 @@ test('probeYoutubeTracks keeps preferring srt for manual captions', async () =>
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('probeYoutubeTracks reports malformed yt-dlp JSON with context', async () => {
|
||||||
|
await withFakeYtDlp('not-json', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
async () => await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123'),
|
||||||
|
/Failed to parse yt-dlp output as JSON/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type { YoutubeTrackOption } from '../../../types';
|
import type { YoutubeTrackOption } from '../../../types';
|
||||||
import {
|
import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels';
|
||||||
formatYoutubeTrackLabel,
|
|
||||||
normalizeYoutubeLangCode,
|
|
||||||
type YoutubeTrackKind,
|
|
||||||
} from './labels';
|
|
||||||
|
|
||||||
export type YoutubeTrackProbeResult = {
|
export type YoutubeTrackProbeResult = {
|
||||||
videoId: string;
|
videoId: string;
|
||||||
@@ -102,7 +98,21 @@ export type { YoutubeTrackOption };
|
|||||||
|
|
||||||
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
|
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
|
||||||
const { stdout } = await runCapture('yt-dlp', ['--dump-single-json', '--no-warnings', targetUrl]);
|
const { stdout } = await runCapture('yt-dlp', ['--dump-single-json', '--no-warnings', targetUrl]);
|
||||||
const info = JSON.parse(stdout) as YtDlpInfo;
|
const trimmedStdout = stdout.trim();
|
||||||
|
if (!trimmedStdout) {
|
||||||
|
throw new Error('yt-dlp returned empty output while probing subtitle tracks');
|
||||||
|
}
|
||||||
|
let info: YtDlpInfo;
|
||||||
|
try {
|
||||||
|
info = JSON.parse(trimmedStdout) as YtDlpInfo;
|
||||||
|
} catch (error) {
|
||||||
|
const snippet = trimmedStdout.slice(0, 200);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to parse yt-dlp output as JSON: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}${snippet ? `; stdout=${snippet}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const tracks = [...toTracks(info.subtitles, 'manual'), ...toTracks(info.automatic_captions, 'auto')];
|
const tracks = [...toTracks(info.subtitles, 'manual'), ...toTracks(info.automatic_captions, 'auto')];
|
||||||
return {
|
return {
|
||||||
videoId: info.id || '',
|
videoId: info.id || '',
|
||||||
|
|||||||
@@ -933,7 +933,8 @@ async function runYoutubePlaybackFlowMain(request: {
|
|||||||
mode: 'download' | 'generate';
|
mode: 'download' | 'generate';
|
||||||
source: CliCommandSource;
|
source: CliCommandSource;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const shouldResumeWarmupsAfterFlow = appState.youtubePlaybackFlowPending;
|
const wasYoutubePlaybackFlowPending = appState.youtubePlaybackFlowPending;
|
||||||
|
appState.youtubePlaybackFlowPending = true;
|
||||||
if (process.platform === 'win32' && !appState.mpvClient?.connected) {
|
if (process.platform === 'win32' && !appState.mpvClient?.connected) {
|
||||||
const launchResult = launchWindowsMpv(
|
const launchResult = launchWindowsMpv(
|
||||||
[request.url],
|
[request.url],
|
||||||
@@ -964,8 +965,8 @@ async function runYoutubePlaybackFlowMain(request: {
|
|||||||
});
|
});
|
||||||
logger.info(`YouTube playback flow completed from ${request.source}.`);
|
logger.info(`YouTube playback flow completed from ${request.source}.`);
|
||||||
} finally {
|
} finally {
|
||||||
if (shouldResumeWarmupsAfterFlow) {
|
appState.youtubePlaybackFlowPending = wasYoutubePlaybackFlowPending;
|
||||||
appState.youtubePlaybackFlowPending = false;
|
if (!wasYoutubePlaybackFlowPending) {
|
||||||
startBackgroundWarmupsIfAllowed();
|
startBackgroundWarmupsIfAllowed();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -449,3 +449,60 @@ test('youtube flow waits for tokenization readiness before releasing playback',
|
|||||||
'focus-overlay',
|
'focus-overlay',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('youtube flow cleans up paused picker state when opening the picker throws', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const warns: string[] = [];
|
||||||
|
const focusOverlayCalls: string[] = [];
|
||||||
|
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [primaryTrack],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async () => new Map<string, string>(),
|
||||||
|
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
|
||||||
|
startTokenizationWarmups: async () => {},
|
||||||
|
waitForTokenizationReady: async () => {},
|
||||||
|
waitForAnkiReady: async () => {},
|
||||||
|
waitForPlaybackWindowReady: async () => {},
|
||||||
|
waitForOverlayGeometryReady: async () => {},
|
||||||
|
focusOverlayWindow: () => {
|
||||||
|
focusOverlayCalls.push('focus-overlay');
|
||||||
|
},
|
||||||
|
openPicker: async () => {
|
||||||
|
throw new Error('picker boom');
|
||||||
|
},
|
||||||
|
pauseMpv: () => {
|
||||||
|
commands.push(['set_property', 'pause', 'yes']);
|
||||||
|
},
|
||||||
|
resumeMpv: () => {
|
||||||
|
commands.push(['set_property', 'pause', 'no']);
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
},
|
||||||
|
requestMpvProperty: async () => null,
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
wait: async () => {},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
warn: (message) => {
|
||||||
|
warns.push(message);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
|
||||||
|
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['script-message', 'subminer-autoplay-ready'],
|
||||||
|
['set_property', 'pause', 'no'],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
|
||||||
|
assert.equal(warns.some((message) => message.includes('picker boom')), true);
|
||||||
|
assert.equal(runtime.hasActiveSession(), false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -392,12 +392,26 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const probe = await deps.probeYoutubeTracks(input.url);
|
|
||||||
const defaults = chooseDefaultYoutubeTrackIds(probe.tracks);
|
|
||||||
const sessionId = createSessionId();
|
|
||||||
const outputDir = normalizeOutputPath(deps.getYoutubeOutputDir());
|
|
||||||
|
|
||||||
deps.pauseMpv();
|
deps.pauseMpv();
|
||||||
|
const outputDir = normalizeOutputPath(deps.getYoutubeOutputDir());
|
||||||
|
|
||||||
|
let probe: YoutubeTrackProbeResult;
|
||||||
|
try {
|
||||||
|
probe = await deps.probeYoutubeTracks(input.url);
|
||||||
|
} catch (error) {
|
||||||
|
deps.warn(
|
||||||
|
`Failed to probe YouTube subtitle tracks: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
releasePlaybackGate(deps);
|
||||||
|
restoreOverlayInputFocus(deps);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = chooseDefaultYoutubeTrackIds(probe.tracks);
|
||||||
|
const sessionId = createSessionId();
|
||||||
|
|
||||||
const openPayload: YoutubePickerOpenPayload = {
|
const openPayload: YoutubePickerOpenPayload = {
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -416,7 +430,22 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
deps.showMpvOsd('Getting subtitles...');
|
deps.showMpvOsd('Getting subtitles...');
|
||||||
const pickerSelection = createPickerSelectionPromise(sessionId);
|
const pickerSelection = createPickerSelectionPromise(sessionId);
|
||||||
void pickerSelection.catch(() => undefined);
|
void pickerSelection.catch(() => undefined);
|
||||||
const opened = await deps.openPicker(openPayload);
|
let opened = false;
|
||||||
|
try {
|
||||||
|
opened = await deps.openPicker(openPayload);
|
||||||
|
} catch (error) {
|
||||||
|
activeSession?.reject(
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
);
|
||||||
|
deps.warn(
|
||||||
|
`Unable to open YouTube subtitle picker: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
releasePlaybackGate(deps);
|
||||||
|
restoreOverlayInputFocus(deps);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
activeSession?.reject(new Error('Unable to open YouTube subtitle picker.'));
|
activeSession?.reject(new Error('Unable to open YouTube subtitle picker.'));
|
||||||
activeSession = null;
|
activeSession = null;
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ test('resolveOverlayLayerFromArgv returns null when argv is unavailable', () =>
|
|||||||
assert.equal(resolveOverlayLayerFromArgv(null), null);
|
assert.equal(resolveOverlayLayerFromArgv(null), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveOverlayLayerFromArgv returns null for undefined argv', () => {
|
||||||
|
assert.equal(resolveOverlayLayerFromArgv(undefined), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveOverlayLayerFromArgv returns null for empty argv', () => {
|
||||||
|
assert.equal(resolveOverlayLayerFromArgv([]), null);
|
||||||
|
});
|
||||||
|
|
||||||
test('resolveOverlayLayerFromArgv returns parsed overlay layer when present', () => {
|
test('resolveOverlayLayerFromArgv returns parsed overlay layer when present', () => {
|
||||||
assert.equal(resolveOverlayLayerFromArgv(['electron', '--overlay-layer=modal']), 'modal');
|
assert.equal(resolveOverlayLayerFromArgv(['electron', '--overlay-layer=modal']), 'modal');
|
||||||
assert.equal(resolveOverlayLayerFromArgv(['electron', '--overlay-layer=visible']), 'visible');
|
assert.equal(resolveOverlayLayerFromArgv(['electron', '--overlay-layer=visible']), 'visible');
|
||||||
@@ -13,4 +21,5 @@ test('resolveOverlayLayerFromArgv returns parsed overlay layer when present', ()
|
|||||||
|
|
||||||
test('resolveOverlayLayerFromArgv ignores unsupported overlay layers', () => {
|
test('resolveOverlayLayerFromArgv ignores unsupported overlay layers', () => {
|
||||||
assert.equal(resolveOverlayLayerFromArgv(['electron', '--overlay-layer=secondary']), null);
|
assert.equal(resolveOverlayLayerFromArgv(['electron', '--overlay-layer=secondary']), null);
|
||||||
|
assert.equal(resolveOverlayLayerFromArgv(['electron', '--overlay-layer=']), null);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -259,7 +259,6 @@ export function createMouseHandlers(
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('mousemove', (e: MouseEvent) => {
|
document.addEventListener('mousemove', (e: MouseEvent) => {
|
||||||
updatePointerPosition(e);
|
|
||||||
if (!ctx.state.isDragging) return;
|
if (!ctx.state.isDragging) return;
|
||||||
|
|
||||||
const deltaY = ctx.state.dragStartY - e.clientY;
|
const deltaY = ctx.state.dragStartY - e.clientY;
|
||||||
|
|||||||
@@ -172,3 +172,179 @@ test('youtube track picker close restores focus and mouse-ignore state', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('youtube track picker re-acknowledges repeated open requests', () => {
|
||||||
|
const openedNotifications: string[] = [];
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createFakeElement(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
dispatchEvent: () => true,
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
notifyOverlayModalOpened: (modal: string) => {
|
||||||
|
openedNotifications.push(modal);
|
||||||
|
},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
youtubePickerResolve: async () => ({ ok: true, message: '' }),
|
||||||
|
setIgnoreMouseEvents: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
const dom = {
|
||||||
|
overlay: {
|
||||||
|
classList: createClassList(),
|
||||||
|
focus: () => {},
|
||||||
|
},
|
||||||
|
youtubePickerModal: createFakeElement(),
|
||||||
|
youtubePickerTitle: createFakeElement(),
|
||||||
|
youtubePickerPrimarySelect: createFakeElement(),
|
||||||
|
youtubePickerSecondarySelect: createFakeElement(),
|
||||||
|
youtubePickerTracks: createFakeElement(),
|
||||||
|
youtubePickerStatus: createFakeElement(),
|
||||||
|
youtubePickerContinueButton: createFakeElement(),
|
||||||
|
youtubePickerCloseButton: createFakeElement(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createYoutubeTrackPickerModal(
|
||||||
|
{
|
||||||
|
state,
|
||||||
|
dom,
|
||||||
|
platform: {
|
||||||
|
shouldToggleMouseIgnore: false,
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
modalStateReader: { isAnyModalOpen: () => true },
|
||||||
|
restorePointerInteractionState: () => {},
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.openYoutubePickerModal({
|
||||||
|
sessionId: 'yt-1',
|
||||||
|
url: 'https://example.com/one',
|
||||||
|
mode: 'download',
|
||||||
|
tracks: [],
|
||||||
|
defaultPrimaryTrackId: null,
|
||||||
|
defaultSecondaryTrackId: null,
|
||||||
|
hasTracks: false,
|
||||||
|
});
|
||||||
|
modal.openYoutubePickerModal({
|
||||||
|
sessionId: 'yt-2',
|
||||||
|
url: 'https://example.com/two',
|
||||||
|
mode: 'generate',
|
||||||
|
tracks: [],
|
||||||
|
defaultPrimaryTrackId: null,
|
||||||
|
defaultSecondaryTrackId: null,
|
||||||
|
hasTracks: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(openedNotifications, ['youtube-track-picker', 'youtube-track-picker']);
|
||||||
|
assert.equal(state.youtubePickerPayload?.sessionId, 'yt-2');
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube track picker surfaces rejected resolve calls as modal status', async () => {
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createFakeElement(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
dispatchEvent: () => true,
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
notifyOverlayModalOpened: () => {},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
youtubePickerResolve: async () => {
|
||||||
|
throw new Error('resolve failed');
|
||||||
|
},
|
||||||
|
setIgnoreMouseEvents: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
const dom = {
|
||||||
|
overlay: {
|
||||||
|
classList: createClassList(),
|
||||||
|
focus: () => {},
|
||||||
|
},
|
||||||
|
youtubePickerModal: createFakeElement(),
|
||||||
|
youtubePickerTitle: createFakeElement(),
|
||||||
|
youtubePickerPrimarySelect: createFakeElement(),
|
||||||
|
youtubePickerSecondarySelect: createFakeElement(),
|
||||||
|
youtubePickerTracks: createFakeElement(),
|
||||||
|
youtubePickerStatus: createFakeElement(),
|
||||||
|
youtubePickerContinueButton: createFakeElement(),
|
||||||
|
youtubePickerCloseButton: createFakeElement(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createYoutubeTrackPickerModal(
|
||||||
|
{
|
||||||
|
state,
|
||||||
|
dom,
|
||||||
|
platform: {
|
||||||
|
shouldToggleMouseIgnore: false,
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
modalStateReader: { isAnyModalOpen: () => true },
|
||||||
|
restorePointerInteractionState: () => {},
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.openYoutubePickerModal({
|
||||||
|
sessionId: 'yt-1',
|
||||||
|
url: 'https://example.com',
|
||||||
|
mode: 'download',
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultPrimaryTrackId: 'auto:ja-orig',
|
||||||
|
defaultSecondaryTrackId: null,
|
||||||
|
hasTracks: true,
|
||||||
|
});
|
||||||
|
modal.wireDomEvents();
|
||||||
|
|
||||||
|
const listeners = dom.youtubePickerContinueButton.listeners.get('click') ?? [];
|
||||||
|
await Promise.all(listeners.map((listener) => listener()));
|
||||||
|
|
||||||
|
assert.equal(state.youtubePickerModalOpen, true);
|
||||||
|
assert.equal(dom.youtubePickerStatus.textContent, 'resolve failed');
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -114,13 +114,26 @@ export function createYoutubeTrackPickerModal(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await window.electronAPI.youtubePickerResolve({
|
let response;
|
||||||
|
try {
|
||||||
|
response =
|
||||||
|
action === 'use-selected'
|
||||||
|
? await window.electronAPI.youtubePickerResolve({
|
||||||
sessionId: payload.sessionId,
|
sessionId: payload.sessionId,
|
||||||
action,
|
action: 'use-selected',
|
||||||
primaryTrackId: action === 'use-selected' ? ctx.dom.youtubePickerPrimarySelect.value || null : null,
|
primaryTrackId: ctx.dom.youtubePickerPrimarySelect.value || null,
|
||||||
secondaryTrackId:
|
secondaryTrackId: ctx.dom.youtubePickerSecondarySelect.value || null,
|
||||||
action === 'use-selected' ? ctx.dom.youtubePickerSecondarySelect.value || null : null,
|
})
|
||||||
|
: await window.electronAPI.youtubePickerResolve({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
action: 'continue-without-subtitles',
|
||||||
|
primaryTrackId: null,
|
||||||
|
secondaryTrackId: null,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setStatus(response.message, true);
|
setStatus(response.message, true);
|
||||||
return;
|
return;
|
||||||
@@ -129,7 +142,12 @@ export function createYoutubeTrackPickerModal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openYoutubePickerModal(payload: YoutubePickerOpenPayload): void {
|
function openYoutubePickerModal(payload: YoutubePickerOpenPayload): void {
|
||||||
if (ctx.state.youtubePickerModalOpen) return;
|
if (ctx.state.youtubePickerModalOpen) {
|
||||||
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
|
applyPayload(payload);
|
||||||
|
window.electronAPI.notifyOverlayModalOpened('youtube-track-picker');
|
||||||
|
return;
|
||||||
|
}
|
||||||
ctx.state.youtubePickerModalOpen = true;
|
ctx.state.youtubePickerModalOpen = true;
|
||||||
options.syncSettingsModalSubtitleSuppression();
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
|
|||||||
@@ -259,6 +259,17 @@ export function parseYoutubePickerResolveRequest(value: unknown): YoutubePickerR
|
|||||||
if (!isObject(value)) return null;
|
if (!isObject(value)) return null;
|
||||||
if (typeof value.sessionId !== 'string' || !value.sessionId.trim()) return null;
|
if (typeof value.sessionId !== 'string' || !value.sessionId.trim()) return null;
|
||||||
if (value.action !== 'use-selected' && value.action !== 'continue-without-subtitles') return null;
|
if (value.action !== 'use-selected' && value.action !== 'continue-without-subtitles') return null;
|
||||||
|
if (value.action === 'continue-without-subtitles') {
|
||||||
|
if (value.primaryTrackId !== null || value.secondaryTrackId !== null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sessionId: value.sessionId,
|
||||||
|
action: 'continue-without-subtitles',
|
||||||
|
primaryTrackId: null,
|
||||||
|
secondaryTrackId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (value.primaryTrackId !== null && value.primaryTrackId !== undefined && typeof value.primaryTrackId !== 'string') {
|
if (value.primaryTrackId !== null && value.primaryTrackId !== undefined && typeof value.primaryTrackId !== 'string') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -271,7 +282,7 @@ export function parseYoutubePickerResolveRequest(value: unknown): YoutubePickerR
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
sessionId: value.sessionId,
|
sessionId: value.sessionId,
|
||||||
action: value.action,
|
action: 'use-selected',
|
||||||
primaryTrackId: value.primaryTrackId ?? null,
|
primaryTrackId: value.primaryTrackId ?? null,
|
||||||
secondaryTrackId: value.secondaryTrackId ?? null,
|
secondaryTrackId: value.secondaryTrackId ?? null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import {
|
|||||||
} from './log-files';
|
} from './log-files';
|
||||||
|
|
||||||
test('resolveDefaultLogFilePath uses app prefix by default', () => {
|
test('resolveDefaultLogFilePath uses app prefix by default', () => {
|
||||||
|
const now = new Date('2026-03-22T12:00:00.000Z');
|
||||||
const resolved = resolveDefaultLogFilePath('app', {
|
const resolved = resolveDefaultLogFilePath('app', {
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
homeDir: '/home/tester',
|
homeDir: '/home/tester',
|
||||||
|
now,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -22,7 +24,7 @@ test('resolveDefaultLogFilePath uses app prefix by default', () => {
|
|||||||
'.config',
|
'.config',
|
||||||
'SubMiner',
|
'SubMiner',
|
||||||
'logs',
|
'logs',
|
||||||
`app-${new Date().toISOString().slice(0, 10)}.log`,
|
`app-${now.toISOString().slice(0, 10)}.log`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
16
src/types.ts
16
src/types.ts
@@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SubtitleCue } from './core/services/subtitle-cue-parser';
|
import type { SubtitleCue } from './core/services/subtitle-cue-parser';
|
||||||
|
import type { YoutubeTrackKind } from './core/services/youtube/kinds';
|
||||||
|
|
||||||
export enum PartOfSpeech {
|
export enum PartOfSpeech {
|
||||||
noun = 'noun',
|
noun = 'noun',
|
||||||
@@ -561,7 +562,7 @@ export interface ControllerRuntimeSnapshot {
|
|||||||
|
|
||||||
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
||||||
export type YoutubeFlowMode = 'download' | 'generate';
|
export type YoutubeFlowMode = 'download' | 'generate';
|
||||||
export type YoutubeTrackKind = 'manual' | 'auto';
|
export type { YoutubeTrackKind };
|
||||||
|
|
||||||
export interface YoutubeTrackOption {
|
export interface YoutubeTrackOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -584,12 +585,19 @@ export interface YoutubePickerOpenPayload {
|
|||||||
hasTracks: boolean;
|
hasTracks: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YoutubePickerResolveRequest {
|
export type YoutubePickerResolveRequest =
|
||||||
|
| {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
action: 'use-selected' | 'continue-without-subtitles';
|
action: 'continue-without-subtitles';
|
||||||
|
primaryTrackId: null;
|
||||||
|
secondaryTrackId: null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
sessionId: string;
|
||||||
|
action: 'use-selected';
|
||||||
primaryTrackId: string | null;
|
primaryTrackId: string | null;
|
||||||
secondaryTrackId: string | null;
|
secondaryTrackId: string | null;
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface YoutubePickerResolveResult {
|
export interface YoutubePickerResolveResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user