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
|
||||
|
||||
### 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.
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 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)
|
||||
- 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.
|
||||
|
||||
@@ -558,7 +558,7 @@ export function buildSubminerScriptOpts(
|
||||
const parts = [
|
||||
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
|
||||
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
|
||||
...extraParts,
|
||||
...extraParts.map(sanitizeScriptOptValue),
|
||||
];
|
||||
if (logLevel !== 'info') {
|
||||
parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`);
|
||||
|
||||
@@ -75,11 +75,5 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
args.jellyfin ||
|
||||
args.jellyfinLogin ||
|
||||
args.jellyfinLogout ||
|
||||
args.jellyfinPlay ||
|
||||
args.jellyfinDiscovery,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
||||
import { getDefaultLauncherLogFile, getDefaultMpvLogFile } from './types.js';
|
||||
|
||||
test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const resolved = getDefaultMpvLogFile({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\tester',
|
||||
@@ -17,13 +18,14 @@ test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
||||
'C:\\Users\\tester\\AppData\\Roaming',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`mpv-${new Date().toISOString().slice(0, 10)}.log`,
|
||||
`mpv-${today}.log`,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('getDefaultLauncherLogFile uses launcher prefix', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const resolved = getDefaultLauncherLogFile({
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
@@ -36,7 +38,7 @@ test('getDefaultLauncherLogFile uses launcher prefix', () => {
|
||||
'.config',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`launcher-${new Date().toISOString().slice(0, 10)}.log`,
|
||||
`launcher-${today}.log`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -789,7 +789,8 @@ function stopManagedOverlayApp(args: Args): void {
|
||||
const stopArgs = ['--stop'];
|
||||
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',
|
||||
env: buildAppEnv(),
|
||||
});
|
||||
@@ -919,7 +920,7 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): vo
|
||||
proc.once('error', (error) => {
|
||||
fail(`Failed to run app command: ${error.message}`);
|
||||
});
|
||||
proc.once('exit', (code) => {
|
||||
proc.once('close', (code) => {
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
@@ -970,7 +971,7 @@ export function runAppCommandAttached(
|
||||
proc.once('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
proc.once('exit', (code, signal) => {
|
||||
proc.once('close', (code, signal) => {
|
||||
if (code !== null) {
|
||||
resolve(code);
|
||||
} else if (signal) {
|
||||
|
||||
@@ -310,6 +310,7 @@ test(
|
||||
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
|
||||
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
|
||||
await waitForJsonLines(appStartPath, 1);
|
||||
await waitForJsonLines(appStopPath, 1);
|
||||
|
||||
const appStartEntries = readJsonLines(appStartPath);
|
||||
const appStopEntries = readJsonLines(appStopPath);
|
||||
|
||||
@@ -4,6 +4,7 @@ local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
|
||||
local OVERLAY_START_MAX_ATTEMPTS = 6
|
||||
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
|
||||
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
||||
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
|
||||
|
||||
function M.create(ctx)
|
||||
local mp = ctx.mp
|
||||
@@ -47,7 +48,7 @@ function M.create(ctx)
|
||||
return parsed
|
||||
end
|
||||
end
|
||||
return 15
|
||||
return DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS
|
||||
end
|
||||
|
||||
local function normalize_socket_path(path)
|
||||
|
||||
@@ -48,6 +48,8 @@ function resolvePreferredUrlFromMpvEdlSource(
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -423,7 +423,6 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.stats ||
|
||||
args.jellyfin ||
|
||||
args.jellyfinPlay ||
|
||||
Boolean(args.youtubePlay) ||
|
||||
args.texthooker
|
||||
) {
|
||||
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', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
const args = makeArgs({ start: true });
|
||||
|
||||
@@ -152,6 +152,93 @@ test('initializeOverlayAnkiIntegration can initialize Anki transport after overl
|
||||
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', () => {
|
||||
let createdIntegrations = 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 () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
} else {
|
||||
deps.createMpvClient();
|
||||
deps.createSubtitleTimingTracker();
|
||||
await deps.loadYomitanExtension();
|
||||
deps.initializeOverlayRuntime();
|
||||
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 {
|
||||
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;
|
||||
return `${base} (${input.kind})`;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,6 @@ import path from 'node:path';
|
||||
import { retimeYoutubeSubtitle } from './retime';
|
||||
|
||||
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-'));
|
||||
try {
|
||||
const primaryPath = path.join(root, 'primary.vtt');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export async function retimeYoutubeSubtitle(input: {
|
||||
primaryPath: string;
|
||||
secondaryPath: string | null;
|
||||
}): Promise<{ ok: boolean; path: string; strategy: 'none'; message: string }> {
|
||||
}): Promise<{ ok: boolean; path: string; strategy: 'none' | 'alass' | 'ffsubsync'; message: string }> {
|
||||
return {
|
||||
ok: true,
|
||||
path: input.primaryPath,
|
||||
|
||||
@@ -74,16 +74,31 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
|
||||
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 unclampedEnd = row.startMs + row.durationMs;
|
||||
const clampedEnd =
|
||||
nextRow && unclampedEnd > nextRow.startMs
|
||||
? Math.max(row.startMs, nextRow.startMs - 1)
|
||||
: 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`;
|
||||
}
|
||||
|
||||
@@ -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_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) => {
|
||||
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error(`yt-dlp timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
proc.stdout.setEncoding('utf8');
|
||||
proc.stderr.setEncoding('utf8');
|
||||
proc.stdout.on('data', (chunk) => {
|
||||
@@ -21,8 +37,12 @@ function runCapture(command: string, args: string[]): Promise<{ stdout: string;
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
proc.once('error', reject);
|
||||
proc.once('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
proc.once('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
return;
|
||||
@@ -35,11 +55,16 @@ function runCapture(command: string, args: string[]): Promise<{ stdout: string;
|
||||
function runCaptureDetailed(
|
||||
command: string,
|
||||
args: string[],
|
||||
timeoutMs = YOUTUBE_DOWNLOAD_TIMEOUT_MS,
|
||||
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error(`yt-dlp timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
proc.stdout.setEncoding('utf8');
|
||||
proc.stderr.setEncoding('utf8');
|
||||
proc.stdout.on('data', (chunk) => {
|
||||
@@ -48,8 +73,12 @@ function runCaptureDetailed(
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
proc.once('error', reject);
|
||||
proc.once('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
proc.once('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr, code: code ?? 1 });
|
||||
});
|
||||
});
|
||||
@@ -125,8 +154,13 @@ async function downloadSubtitleFromUrl(input: {
|
||||
: YOUTUBE_SUBTITLE_EXTENSIONS.has(`.${ext}`)
|
||||
? ext
|
||||
: 'vtt';
|
||||
const targetPath = path.join(input.outputDir, `${input.prefix}.${input.track.sourceLanguage}.${safeExt}`);
|
||||
const response = await fetch(input.track.downloadUrl);
|
||||
const targetPath = path.join(
|
||||
input.outputDir,
|
||||
`${input.prefix}.${input.track.sourceLanguage}.${safeExt}`,
|
||||
);
|
||||
const response = await fetch(input.track.downloadUrl, {
|
||||
signal: createFetchTimeoutSignal(YOUTUBE_DOWNLOAD_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} while downloading ${input.track.sourceLanguage}`);
|
||||
}
|
||||
@@ -195,6 +229,8 @@ export async function downloadYoutubeSubtitleTracks(input: {
|
||||
mode: YoutubeFlowMode;
|
||||
}): Promise<Map<string, string>> {
|
||||
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)) {
|
||||
if (name.startsWith(`${YOUTUBE_BATCH_PREFIX}.`)) {
|
||||
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>();
|
||||
for (const track of input.tracks) {
|
||||
const download = await downloadSubtitleFromUrl({
|
||||
outputDir: input.outputDir,
|
||||
prefix: YOUTUBE_BATCH_PREFIX,
|
||||
prefix: track.id.replace(/[^a-z0-9_-]+/gi, '-'),
|
||||
track,
|
||||
});
|
||||
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 {
|
||||
const scriptPath = path.join(dir, 'yt-dlp');
|
||||
const stdoutBody = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||
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');
|
||||
if (process.platform !== 'win32') {
|
||||
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> {
|
||||
@@ -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 type { YoutubeTrackOption } from '../../../types';
|
||||
import {
|
||||
formatYoutubeTrackLabel,
|
||||
normalizeYoutubeLangCode,
|
||||
type YoutubeTrackKind,
|
||||
} from './labels';
|
||||
import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels';
|
||||
|
||||
export type YoutubeTrackProbeResult = {
|
||||
videoId: string;
|
||||
@@ -102,7 +98,21 @@ export type { YoutubeTrackOption };
|
||||
|
||||
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
|
||||
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')];
|
||||
return {
|
||||
videoId: info.id || '',
|
||||
|
||||
@@ -933,7 +933,8 @@ async function runYoutubePlaybackFlowMain(request: {
|
||||
mode: 'download' | 'generate';
|
||||
source: CliCommandSource;
|
||||
}): Promise<void> {
|
||||
const shouldResumeWarmupsAfterFlow = appState.youtubePlaybackFlowPending;
|
||||
const wasYoutubePlaybackFlowPending = appState.youtubePlaybackFlowPending;
|
||||
appState.youtubePlaybackFlowPending = true;
|
||||
if (process.platform === 'win32' && !appState.mpvClient?.connected) {
|
||||
const launchResult = launchWindowsMpv(
|
||||
[request.url],
|
||||
@@ -964,8 +965,8 @@ async function runYoutubePlaybackFlowMain(request: {
|
||||
});
|
||||
logger.info(`YouTube playback flow completed from ${request.source}.`);
|
||||
} finally {
|
||||
if (shouldResumeWarmupsAfterFlow) {
|
||||
appState.youtubePlaybackFlowPending = false;
|
||||
appState.youtubePlaybackFlowPending = wasYoutubePlaybackFlowPending;
|
||||
if (!wasYoutubePlaybackFlowPending) {
|
||||
startBackgroundWarmupsIfAllowed();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,3 +449,60 @@ test('youtube flow waits for tokenization readiness before releasing playback',
|
||||
'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();
|
||||
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 = {
|
||||
sessionId,
|
||||
@@ -416,7 +430,22 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
deps.showMpvOsd('Getting subtitles...');
|
||||
const pickerSelection = createPickerSelectionPromise(sessionId);
|
||||
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) {
|
||||
activeSession?.reject(new Error('Unable to open YouTube subtitle picker.'));
|
||||
activeSession = null;
|
||||
|
||||
@@ -6,6 +6,14 @@ test('resolveOverlayLayerFromArgv returns null when argv is unavailable', () =>
|
||||
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', () => {
|
||||
assert.equal(resolveOverlayLayerFromArgv(['electron', '--overlay-layer=modal']), 'modal');
|
||||
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', () => {
|
||||
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) => {
|
||||
updatePointerPosition(e);
|
||||
if (!ctx.state.isDragging) return;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const response = await window.electronAPI.youtubePickerResolve({
|
||||
let response;
|
||||
try {
|
||||
response =
|
||||
action === 'use-selected'
|
||||
? await window.electronAPI.youtubePickerResolve({
|
||||
sessionId: payload.sessionId,
|
||||
action,
|
||||
primaryTrackId: action === 'use-selected' ? ctx.dom.youtubePickerPrimarySelect.value || null : null,
|
||||
secondaryTrackId:
|
||||
action === 'use-selected' ? ctx.dom.youtubePickerSecondarySelect.value || null : null,
|
||||
action: 'use-selected',
|
||||
primaryTrackId: ctx.dom.youtubePickerPrimarySelect.value || null,
|
||||
secondaryTrackId: ctx.dom.youtubePickerSecondarySelect.value || 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) {
|
||||
setStatus(response.message, true);
|
||||
return;
|
||||
@@ -129,7 +142,12 @@ export function createYoutubeTrackPickerModal(
|
||||
}
|
||||
|
||||
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;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
applyPayload(payload);
|
||||
|
||||
@@ -259,6 +259,17 @@ export function parseYoutubePickerResolveRequest(value: unknown): YoutubePickerR
|
||||
if (!isObject(value)) 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 === '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') {
|
||||
return null;
|
||||
}
|
||||
@@ -271,7 +282,7 @@ export function parseYoutubePickerResolveRequest(value: unknown): YoutubePickerR
|
||||
}
|
||||
return {
|
||||
sessionId: value.sessionId,
|
||||
action: value.action,
|
||||
action: 'use-selected',
|
||||
primaryTrackId: value.primaryTrackId ?? null,
|
||||
secondaryTrackId: value.secondaryTrackId ?? null,
|
||||
};
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
} from './log-files';
|
||||
|
||||
test('resolveDefaultLogFilePath uses app prefix by default', () => {
|
||||
const now = new Date('2026-03-22T12:00:00.000Z');
|
||||
const resolved = resolveDefaultLogFilePath('app', {
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
now,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
@@ -22,7 +24,7 @@ test('resolveDefaultLogFilePath uses app prefix by default', () => {
|
||||
'.config',
|
||||
'SubMiner',
|
||||
'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 { YoutubeTrackKind } from './core/services/youtube/kinds';
|
||||
|
||||
export enum PartOfSpeech {
|
||||
noun = 'noun',
|
||||
@@ -561,7 +562,7 @@ export interface ControllerRuntimeSnapshot {
|
||||
|
||||
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
||||
export type YoutubeFlowMode = 'download' | 'generate';
|
||||
export type YoutubeTrackKind = 'manual' | 'auto';
|
||||
export type { YoutubeTrackKind };
|
||||
|
||||
export interface YoutubeTrackOption {
|
||||
id: string;
|
||||
@@ -584,12 +585,19 @@ export interface YoutubePickerOpenPayload {
|
||||
hasTracks: boolean;
|
||||
}
|
||||
|
||||
export interface YoutubePickerResolveRequest {
|
||||
export type YoutubePickerResolveRequest =
|
||||
| {
|
||||
sessionId: string;
|
||||
action: 'use-selected' | 'continue-without-subtitles';
|
||||
action: 'continue-without-subtitles';
|
||||
primaryTrackId: null;
|
||||
secondaryTrackId: null;
|
||||
}
|
||||
| {
|
||||
sessionId: string;
|
||||
action: 'use-selected';
|
||||
primaryTrackId: string | null;
|
||||
secondaryTrackId: string | null;
|
||||
}
|
||||
};
|
||||
|
||||
export interface YoutubePickerResolveResult {
|
||||
ok: boolean;
|
||||
|
||||
Reference in New Issue
Block a user