');
+ });
});
diff --git a/src/tests/commandBuilders.test.ts b/src/tests/commandBuilders.test.ts
index 225b643..6a641b6 100644
--- a/src/tests/commandBuilders.test.ts
+++ b/src/tests/commandBuilders.test.ts
@@ -299,6 +299,58 @@ describe('command builders', () => {
expect(result.statusMessages).toContain('đš Using formats: video=137, audio=140');
});
+ describe('playlist scope', () => {
+ const url = 'https://example.com/watch?v=abc&list=PL123';
+ const build = (playlist?: {
+ mode: 'current' | 'all' | 'range';
+ start?: number;
+ end?: number;
+ }) =>
+ buildYtdlpArgs({
+ normalizedDownloadDir: '/tmp/downloads',
+ url,
+ settings: createSettings(),
+ options: { url, outputPath: '/tmp/downloads', ...(playlist ? { playlist } : {}) },
+ ffmpegLocation: null,
+ });
+
+ it('defaults to the single video when no selection is given', () => {
+ const args = build().args;
+ expect(args).toContain('--no-playlist');
+ expect(args).not.toContain('--yes-playlist');
+ expect(args.filter((arg) => arg === '--no-playlist')).toHaveLength(1);
+ });
+
+ it('honours the current-video selection', () => {
+ const args = build({ mode: 'current' }).args;
+ expect(args).toContain('--no-playlist');
+ expect(args).not.toContain('--yes-playlist');
+ });
+
+ it('downloads the entire playlist when requested', () => {
+ const args = build({ mode: 'all' }).args;
+ expect(args).toContain('--yes-playlist');
+ expect(args).not.toContain('--no-playlist');
+ expect(args).not.toContain('--playlist-items');
+ });
+
+ it('maps a validated range to --playlist-items before the URL separator', () => {
+ const args = build({ mode: 'range', start: 2, end: 5 }).args;
+ expect(args).toContain('--yes-playlist');
+ const itemsIndex = args.indexOf('--playlist-items');
+ expect(itemsIndex).toBeGreaterThan(-1);
+ expect(args[itemsIndex + 1]).toBe('2-5');
+ expect(itemsIndex).toBeLessThan(args.indexOf('--'));
+ expect(args[args.length - 1]).toBe(url);
+ });
+
+ it('falls back to the single video for out-of-bounds ranges', () => {
+ expect(build({ mode: 'range', start: 5, end: 2 }).args).toContain('--no-playlist');
+ expect(build({ mode: 'range', start: 0, end: 3 }).args).toContain('--no-playlist');
+ expect(build({ mode: 'range', start: 1, end: 999_999 }).args).toContain('--no-playlist');
+ });
+ });
+
it('places optional flags before the URL separator, not after it', () => {
const url = 'https://example.com/video';
const result = buildYtdlpArgs({
diff --git a/src/tests/downloadPresets.test.ts b/src/tests/downloadPresets.test.ts
new file mode 100644
index 0000000..b4c7570
--- /dev/null
+++ b/src/tests/downloadPresets.test.ts
@@ -0,0 +1,223 @@
+import { describe, it, expect, vi } from 'vitest';
+
+vi.mock('electron', () => ({
+ app: {
+ getPath: () => '/tmp/rosi-tests',
+ },
+ dialog: {
+ showErrorBox: vi.fn(),
+ showSaveDialog: vi.fn(),
+ showOpenDialog: vi.fn(),
+ },
+}));
+
+vi.mock('electron-log/main.js', () => ({
+ default: {
+ initialize: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+import {
+ downloadPresetToRequestOptions,
+ getDefaultSettings,
+ migrateSettings,
+ sanitizeDownloadPresets,
+} from '../main/settings';
+import { MAX_DOWNLOAD_PRESETS, MAX_PRESET_NAME_LENGTH } from '../main/constants';
+import {
+ validatePlaylistSelectionPayload,
+ validateSettingsPatchPayload,
+} from '../utils/ipcValidation';
+
+describe('download preset sanitization', () => {
+ it('defaults to an empty preset list', () => {
+ expect(getDefaultSettings().downloadPresets).toEqual([]);
+ expect(sanitizeDownloadPresets(undefined)).toEqual([]);
+ expect(sanitizeDownloadPresets('nope')).toEqual([]);
+ expect(sanitizeDownloadPresets([null, 42, 'x'])).toEqual([]);
+ });
+
+ it('keeps only known fields and generates safe ids', () => {
+ const presets = sanitizeDownloadPresets([
+ {
+ name: ' My Best Setup ',
+ profile: 'best-video',
+ convertEnabled: true,
+ convertFormat: 'mp4',
+ gpuType: 'nvidia',
+ subtitleLangs: 'en,es',
+ videoFormat: '299',
+ audioFormatId: '140',
+ playlist: { mode: 'range', start: 2, end: 5 },
+ rmDashArgs: '--exec rm -rf /',
+ },
+ ]);
+
+ expect(presets).toHaveLength(1);
+ const preset = presets[0]!;
+ expect(preset.name).toBe('My Best Setup');
+ expect(preset.id).toMatch(/^[A-Za-z0-9][A-Za-z0-9_-]*$/);
+ expect(preset.playlist).toEqual({ mode: 'range', start: 2, end: 5 });
+ expect(preset).not.toHaveProperty('rmDashArgs');
+ });
+
+ it('drops invalid enum, format, and playlist values', () => {
+ const presets = sanitizeDownloadPresets([
+ {
+ name: 'Sketchy',
+ profile: 'not-a-profile',
+ audioFormat: 'exe',
+ convertFormat: 'mkv',
+ gpuType: 'quantum',
+ subtitleLangs: 'en;rm -rf',
+ videoFormat: 'bad id!',
+ playlist: { mode: 'range', start: 9, end: 2 },
+ },
+ ]);
+
+ const preset = presets[0]!;
+ expect(preset.profile).toBe('best-video');
+ expect(preset.audioFormat).toBeUndefined();
+ expect(preset.convertFormat).toBeUndefined();
+ expect(preset.gpuType).toBeUndefined();
+ expect(preset.subtitleLangs).toBeUndefined();
+ expect(preset.videoFormat).toBeUndefined();
+ expect(preset.playlist).toBeUndefined();
+ });
+
+ it('deduplicates names and ids, and caps the preset count', () => {
+ const presets = sanitizeDownloadPresets([
+ { id: 'dup', name: 'Same', profile: 'audio' },
+ { id: 'dup', name: 'Same', profile: 'audio' },
+ ]);
+ expect(presets).toHaveLength(2);
+ expect(presets[0]!.id).not.toBe(presets[1]!.id);
+ expect(presets[0]!.name).not.toBe(presets[1]!.name);
+
+ const many = sanitizeDownloadPresets(
+ Array.from({ length: MAX_DOWNLOAD_PRESETS + 5 }, (_, index) => ({
+ name: `Preset ${index}`,
+ profile: 'best-video',
+ }))
+ );
+ expect(many).toHaveLength(MAX_DOWNLOAD_PRESETS);
+ });
+
+ it('truncates overly long names', () => {
+ const presets = sanitizeDownloadPresets([{ name: 'x'.repeat(200), profile: 'custom' }]);
+ expect(presets[0]!.name.length).toBeLessThanOrEqual(MAX_PRESET_NAME_LENGTH);
+ });
+
+ it('migrates presets through the settings schema', () => {
+ const migrated = migrateSettings({
+ downloadPresets: [{ name: 'Audio only', profile: 'audio', audioFormat: 'flac' }],
+ });
+ expect(migrated.downloadPresets).toHaveLength(1);
+ expect(migrated.downloadPresets[0]!.audioFormat).toBe('flac');
+ });
+
+ it('maps a preset onto safe request options', () => {
+ const options = downloadPresetToRequestOptions({
+ id: 'p1',
+ name: 'Audio',
+ profile: 'audio',
+ audioFormat: 'mp3',
+ writeSubtitles: true,
+ subtitleLangs: 'en',
+ playlist: { mode: 'all' },
+ });
+
+ expect(options).toMatchObject({
+ profileEnabled: true,
+ profile: 'audio',
+ presetId: 'p1',
+ presetName: 'Audio',
+ audioOnly: true,
+ audioOutputFormat: 'mp3',
+ writeSubtitles: true,
+ playlist: { mode: 'all' },
+ });
+ expect(Object.values(options).every((value) => value !== undefined)).toBe(true);
+ });
+});
+
+describe('playlist selection validation', () => {
+ it('accepts current and all without bounds', () => {
+ expect(validatePlaylistSelectionPayload({ mode: 'current' })).toEqual({
+ ok: true,
+ data: { mode: 'current' },
+ });
+ expect(validatePlaylistSelectionPayload({ mode: 'all' })).toEqual({
+ ok: true,
+ data: { mode: 'all' },
+ });
+ });
+
+ it('accepts a valid 1-based range', () => {
+ expect(validatePlaylistSelectionPayload({ mode: 'range', start: 3, end: 7 })).toEqual({
+ ok: true,
+ data: { mode: 'range', start: 3, end: 7 },
+ });
+ });
+
+ it('rejects malformed payloads and bad bounds', () => {
+ for (const payload of [
+ null,
+ 'range',
+ { mode: 'nope' },
+ { mode: 'all', start: 1 },
+ { mode: 'range' },
+ { mode: 'range', start: 0, end: 4 },
+ { mode: 'range', start: 5, end: 2 },
+ { mode: 'range', start: 1.5, end: 4 },
+ { mode: 'range', start: 1, end: 99_999 },
+ ]) {
+ expect(validatePlaylistSelectionPayload(payload).ok).toBe(false);
+ }
+ });
+});
+
+describe('settings patch validation for presets', () => {
+ it('accepts a well-formed preset list', () => {
+ const result = validateSettingsPatchPayload({
+ downloadPresets: [{ id: 'p1', name: 'Best', profile: 'best-video', convertFormat: 'mp4' }],
+ });
+ expect(result.ok).toBe(true);
+ if (result.ok) expect(result.data.downloadPresets).toHaveLength(1);
+ });
+
+ it('rejects invalid preset payloads', () => {
+ for (const presets of [
+ 'nope',
+ [null],
+ [{ id: 'bad id', name: 'A', profile: 'best-video' }],
+ [{ id: 'p1', name: '', profile: 'best-video' }],
+ [{ id: 'p1', name: 'A', profile: 'invalid' }],
+ [{ id: 'p1', name: 'A', profile: 'audio', audioFormat: 'exe' }],
+ [{ id: 'p1', name: 'A', profile: 'audio', convertFormat: 'mkv' }],
+ [{ id: 'p1', name: 'A', profile: 'audio', gpuType: 'quantum' }],
+ [{ id: 'p1', name: 'A', profile: 'audio', subtitleLangs: 'en;bad' }],
+ [{ id: 'p1', name: 'A', profile: 'audio', videoFormat: 'no spaces!' }],
+ [{ id: 'p1', name: 'A', profile: 'audio', convertEnabled: 'yes' }],
+ [{ id: 'p1', name: 'A', profile: 'audio', playlist: { mode: 'range', start: 4, end: 1 } }],
+ [
+ { id: 'p1', name: 'Same', profile: 'audio' },
+ { id: 'p2', name: 'same', profile: 'audio' },
+ ],
+ [
+ { id: 'p1', name: 'One', profile: 'audio' },
+ { id: 'p1', name: 'Two', profile: 'audio' },
+ ],
+ Array.from({ length: MAX_DOWNLOAD_PRESETS + 1 }, (_, i) => ({
+ id: `p${i}`,
+ name: `P${i}`,
+ profile: 'audio',
+ })),
+ ]) {
+ expect(validateSettingsPatchPayload({ downloadPresets: presets }).ok).toBe(false);
+ }
+ });
+});
diff --git a/src/tests/downloader.requestOptions.test.ts b/src/tests/downloader.requestOptions.test.ts
new file mode 100644
index 0000000..bbac8ae
--- /dev/null
+++ b/src/tests/downloader.requestOptions.test.ts
@@ -0,0 +1,269 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { EventEmitter } from 'events';
+import * as os from 'os';
+import * as path from 'path';
+import type { DownloadCompletion, DownloadPreset, Settings } from '../types';
+
+const { existsSyncMock, statSyncMock, spawnWithEnvMock, loadSettingsMock, recordDownloadMock } =
+ vi.hoisted(() => ({
+ existsSyncMock: vi.fn(),
+ statSyncMock: vi.fn(),
+ spawnWithEnvMock: vi.fn(),
+ loadSettingsMock: vi.fn(),
+ recordDownloadMock: vi.fn(),
+ }));
+
+vi.mock('fs', () => ({
+ existsSync: existsSyncMock,
+ statSync: statSyncMock,
+ mkdirSync: vi.fn(),
+ rmSync: vi.fn(),
+ readFileSync: vi.fn(() => ''),
+ renameSync: vi.fn(),
+ unlinkSync: vi.fn(),
+}));
+
+vi.mock('../main/platform', () => ({
+ spawnWithEnv: spawnWithEnvMock,
+ getEffectiveFfmpegPath: vi.fn(() => 'ffmpeg'),
+ resolveFfmpegLocationForYtdlp: vi.fn(() => null),
+ ytdlpBinary: 'yt-dlp',
+ isWindows: process.platform === 'win32',
+ isMac: process.platform === 'darwin',
+}));
+
+vi.mock('../main/settings', async (importOriginal) => {
+ const actual = await importOriginal
();
+ return {
+ downloadPresetToRequestOptions: actual.downloadPresetToRequestOptions,
+ loadSettings: loadSettingsMock,
+ recordDownload: recordDownloadMock,
+ };
+});
+
+vi.mock('electron', () => ({
+ app: { getPath: () => '/tmp/rosi-tests' },
+ dialog: { showMessageBox: vi.fn(), showErrorBox: vi.fn() },
+}));
+
+vi.mock('electron-log/main.js', () => ({
+ default: { initialize: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+import { cancelActiveSession, startDownload } from '../main/downloader';
+
+function baseSettings(overrides: Partial = {}): Settings {
+ return {
+ settingsVersion: 7,
+ theme: 'system',
+ showConsoleOutput: false,
+ consoleCollapsed: false,
+ queueCollapsed: false,
+ downloadProfilesEnabled: false,
+ downloadMode: 'best-video',
+ downloadPresets: [],
+ askDownloadLocation: false,
+ advancedOptions: false,
+ audioOnly: false,
+ audioFormat: 'mp3',
+ convertEnabled: false,
+ convertFormat: 'mp4',
+ keepOriginalAfterConvert: true,
+ firstLaunch: false,
+ hookBrowser: false,
+ browserChoice: 'chrome',
+ animateBackground: true,
+ flatUi: false,
+ notifications: true,
+ denoReminderDismissed: true,
+ gpuAcceleration: false,
+ gpuType: 'auto',
+ bestQuality: false,
+ ffmpegPath: '',
+ downloadFolder: '',
+ hideSupportModal: true,
+ checkUpdatesOnStartup: false,
+ updateChannel: 'auto',
+ writeSubtitles: false,
+ subtitleLangs: 'en',
+ embedThumbnail: false,
+ embedMetadata: false,
+ sponsorblockRemove: false,
+ showTaskbarProgress: true,
+ ...overrides,
+ };
+}
+
+function createProc() {
+ const proc = new EventEmitter() as EventEmitter & {
+ stdout: EventEmitter;
+ stderr: EventEmitter;
+ kill: () => void;
+ killed: boolean;
+ pid: number;
+ };
+ proc.stdout = new EventEmitter();
+ proc.stderr = new EventEmitter();
+ proc.pid = 4321;
+ proc.killed = false;
+ proc.kill = vi.fn(() => {
+ proc.killed = true;
+ });
+ return proc;
+}
+
+function createSender() {
+ return {
+ send: vi.fn(),
+ isDestroyed: () => false,
+ } as unknown as Electron.WebContents;
+}
+
+function lastYtdlpArgs(): string[] {
+ const call = spawnWithEnvMock.mock.calls.at(-1);
+ return (call?.[1] ?? []) as string[];
+}
+
+describe('downloader request options and presets', () => {
+ const outputPath = path.join(os.homedir(), 'Downloads', 'rosi-request-test');
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ existsSyncMock.mockImplementation((target: string) => {
+ const normalized = String(target).replace(/\\/g, '/');
+ return normalized.includes('yt-dlp') || normalized.includes('rosi-request-test');
+ });
+ statSyncMock.mockReturnValue({ isDirectory: () => true, isFile: () => true, size: 2048 });
+ spawnWithEnvMock.mockReturnValue(createProc());
+ loadSettingsMock.mockReturnValue(baseSettings());
+ });
+
+ afterEach(() => {
+ cancelActiveSession(false);
+ });
+
+ it('applies per-request enhancement and profile overrides over saved settings', () => {
+ startDownload('/tmp/yt-dlp', createSender(), {
+ url: 'https://example.com/video',
+ outputPath,
+ profileEnabled: true,
+ profile: 'audio',
+ audioOutputFormat: 'flac',
+ writeSubtitles: true,
+ subtitleLangs: 'en,es',
+ embedThumbnail: true,
+ embedMetadata: true,
+ sponsorblockRemove: true,
+ hookBrowser: true,
+ browserChoice: 'firefox',
+ });
+
+ const args = lastYtdlpArgs();
+ expect(args).toContain('-x');
+ expect(args).toContain('--audio-format');
+ expect(args).toContain('flac');
+ expect(args).toContain('--sub-langs');
+ expect(args).toContain('en,es');
+ expect(args).toContain('--embed-thumbnail');
+ expect(args).toContain('--embed-metadata');
+ expect(args).toContain('--sponsorblock-remove');
+ expect(args).toContain('--cookies-from-browser');
+ expect(args).toContain('firefox');
+ });
+
+ it('resolves a saved preset by id into the effective request', () => {
+ const preset: DownloadPreset = {
+ id: 'audio-preset',
+ name: 'Audio preset',
+ profile: 'audio',
+ audioFormat: 'opus',
+ embedMetadata: true,
+ playlist: { mode: 'range', start: 2, end: 3 },
+ };
+ loadSettingsMock.mockReturnValue(baseSettings({ downloadPresets: [preset] }));
+
+ startDownload('/tmp/yt-dlp', createSender(), {
+ url: 'https://example.com/watch?v=a&list=PL1',
+ outputPath,
+ presetId: 'audio-preset',
+ });
+
+ const args = lastYtdlpArgs();
+ expect(args).toContain('opus');
+ expect(args).toContain('--embed-metadata');
+ expect(args).toContain('--yes-playlist');
+ expect(args[args.indexOf('--playlist-items') + 1]).toBe('2-3');
+ });
+
+ it('ignores an unknown preset id and keeps the explicit request options', () => {
+ startDownload('/tmp/yt-dlp', createSender(), {
+ url: 'https://example.com/video',
+ outputPath,
+ presetId: 'does-not-exist',
+ profileEnabled: false,
+ audioOnly: false,
+ });
+
+ const args = lastYtdlpArgs();
+ expect(args).not.toContain('-x');
+ expect(args).toContain('--no-playlist');
+ });
+
+ it('reports structured completion metadata for failures', () => {
+ const completions: DownloadCompletion[] = [];
+ const proc = createProc();
+ spawnWithEnvMock.mockReturnValue(proc);
+
+ startDownload(
+ '/tmp/yt-dlp',
+ createSender(),
+ {
+ url: 'https://example.com/video',
+ outputPath,
+ profile: 'best-video',
+ presetId: 'p1',
+ presetName: 'P1',
+ },
+ null,
+ undefined,
+ 'queue',
+ { completedItems: 0, queueTotal: 1, queueItemId: 'q_1' },
+ (completion) => completions.push(completion)
+ );
+
+ proc.emit('close', 1);
+
+ expect(completions).toHaveLength(1);
+ const completion = completions[0]!;
+ expect(completion.outcome).toBe('failed');
+ expect(completion.owner).toBe('queue');
+ expect(completion.queueItemId).toBe('q_1');
+ expect(completion.url).toBe('https://example.com/video');
+ expect(completion.presetId).toBe('p1');
+ expect(completion.presetName).toBe('P1');
+ expect(completion.error).toBeTruthy();
+ expect(completion.request.outputPath).toBe(path.resolve(outputPath));
+ expect(completion.completedAt).toBeGreaterThanOrEqual(completion.startedAt);
+ expect(recordDownloadMock).toHaveBeenCalledWith('failed');
+ });
+
+ it('reports structured completion metadata for cancellations', () => {
+ const completions: DownloadCompletion[] = [];
+ startDownload(
+ '/tmp/yt-dlp',
+ createSender(),
+ { url: 'https://example.com/video', outputPath },
+ null,
+ undefined,
+ 'manual',
+ null,
+ (completion) => completions.push(completion)
+ );
+
+ cancelActiveSession(true);
+
+ expect(completions).toHaveLength(1);
+ expect(completions[0]!.outcome).toBe('cancelled');
+ expect(completions[0]!.error).toBeUndefined();
+ });
+});
diff --git a/src/tests/ipc.contract.test.ts b/src/tests/ipc.contract.test.ts
index 094205e..b2c1665 100644
--- a/src/tests/ipc.contract.test.ts
+++ b/src/tests/ipc.contract.test.ts
@@ -10,9 +10,30 @@ function extractQuotedCalls(source: string, pattern: RegExp): string[] {
return [...source.matchAll(pattern)].map((match) => match[1]).filter(Boolean);
}
+function extractMainToRendererSendChannels(mainSources: string): string[] {
+ const channels = new Set();
+ const patterns = [
+ /\.webContents\.send\('([^']+)'/g,
+ /\.send\('([^']+)'/g,
+ /safeSend\([^,]+,\s*'([^']+)'/g,
+ /sendToWindow\('([^']+)'/g,
+ ];
+ for (const pattern of patterns) {
+ for (const channel of extractQuotedCalls(mainSources, pattern)) {
+ channels.add(channel);
+ }
+ }
+ return [...channels];
+}
+
describe('IPC channel contracts', () => {
const preload = readRepoFile('src/main/preload.ts');
const main = readRepoFile('src/main/main.ts');
+ const appMenu = readRepoFile('src/main/appMenu.ts');
+ const jobProgressReporter = readRepoFile('src/main/download/jobProgressReporter.ts');
+ const updater = readRepoFile('src/main/updater.ts');
+ const downloader = readRepoFile('src/main/downloader.ts');
+ const mainSources = [main, appMenu, jobProgressReporter, updater, downloader].join('\n');
const invokeChannels = extractQuotedCalls(preload, /ipcRenderer\.invoke\('([^']+)'/g);
const sendChannels = extractQuotedCalls(preload, /ipcRenderer\.send\('([^']+)'/g);
@@ -34,6 +55,8 @@ describe('IPC channel contracts', () => {
expect(rendererEventChannels.sort()).toEqual(
[
'complete',
+ 'download-activity-update',
+ 'download-complete',
'job-progress',
'menu-action',
'prepare-for-close',
@@ -45,4 +68,11 @@ describe('IPC channel contracts', () => {
].sort()
);
});
+
+ it('emits every preload subscription event from main-process senders', () => {
+ const emitted = extractMainToRendererSendChannels(mainSources);
+ const missing = rendererEventChannels.filter((channel) => !emitted.includes(channel));
+ expect(missing, `main never sends: ${missing.join(', ')}`).toEqual([]);
+ expect(emitted.length).toBeGreaterThan(0);
+ });
});
diff --git a/src/tests/jobProgressReporter.test.ts b/src/tests/jobProgressReporter.test.ts
new file mode 100644
index 0000000..5752c23
--- /dev/null
+++ b/src/tests/jobProgressReporter.test.ts
@@ -0,0 +1,74 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { DownloadSession } from '../types';
+
+const applyTaskbarProgressMock = vi.fn();
+
+vi.mock('../main/taskbarProgress', () => ({
+ applyTaskbarProgress: (...args: unknown[]) => applyTaskbarProgressMock(...args),
+}));
+
+function createSession(): DownloadSession {
+ return {
+ id: 'test-session',
+ sender: {
+ isDestroyed: () => false,
+ send: vi.fn(),
+ },
+ lifecycle: 'active',
+ ytdlpPostprocess: false,
+ ytdlpDownloadFinished: false,
+ jobPhase: 'download',
+ } as unknown as DownloadSession;
+}
+
+describe('JobProgressReporter', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ applyTaskbarProgressMock.mockClear();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('clearTaskbar clears progress when enabled', async () => {
+ const { JobProgressReporter } = await import('../main/download/jobProgressReporter');
+ const mainWindow = {} as never;
+ const reporter = new JobProgressReporter(mainWindow, { expectConvert: false }, null, true);
+ reporter.clearTaskbar();
+ expect(applyTaskbarProgressMock).toHaveBeenCalledWith(mainWindow, 'none');
+ });
+
+ it('clearTaskbar is a no-op when taskbar progress is disabled', async () => {
+ const { JobProgressReporter } = await import('../main/download/jobProgressReporter');
+ const reporter = new JobProgressReporter(null, { expectConvert: false }, null, false);
+ reporter.clearTaskbar();
+ expect(applyTaskbarProgressMock).not.toHaveBeenCalled();
+ });
+
+ it('throttles duplicate job-progress emissions', async () => {
+ const { JobProgressReporter } = await import('../main/download/jobProgressReporter');
+ const session = createSession();
+ const reporter = new JobProgressReporter(null, { expectConvert: false }, null, false);
+
+ reporter.emitDownloadComplete(session);
+ reporter.emitDownloadComplete(session);
+
+ expect(session.sender.send).toHaveBeenCalledTimes(1);
+ vi.advanceTimersByTime(250);
+ reporter.emitDownloadComplete(session);
+ expect(session.sender.send).toHaveBeenCalledTimes(2);
+ });
+
+ it('sets indeterminate taskbar mode for indeterminate events', async () => {
+ const { JobProgressReporter } = await import('../main/download/jobProgressReporter');
+ const mainWindow = {} as never;
+ const session = createSession();
+ const reporter = new JobProgressReporter(mainWindow, { expectConvert: true }, null, true);
+
+ reporter.emitPhaseIndeterminate(session, 'merge', 'Merging...');
+ vi.advanceTimersByTime(250);
+
+ expect(applyTaskbarProgressMock).toHaveBeenCalledWith(mainWindow, 'indeterminate');
+ });
+});
diff --git a/src/tests/main.ipc.test.ts b/src/tests/main.ipc.test.ts
index 7a21e7b..d15645b 100644
--- a/src/tests/main.ipc.test.ts
+++ b/src/tests/main.ipc.test.ts
@@ -149,6 +149,7 @@ const {
getVersion: vi.fn(() => '4.0.0-beta.2'),
getAppPath: vi.fn(() => process.cwd()),
isReady: vi.fn(() => true),
+ setAboutPanelOptions: vi.fn(),
};
return {
@@ -248,6 +249,10 @@ vi.mock('electron', () => ({
once = notificationOnceMock;
show = notificationShowMock;
},
+ Menu: {
+ setApplicationMenu: vi.fn(),
+ buildFromTemplate: vi.fn(() => ({})),
+ },
}));
vi.mock('../main/platform', () => ({
@@ -298,7 +303,10 @@ vi.mock('../main/download/videoInfo', () => ({
cancelVideoInfo: cancelVideoInfoMock,
}));
-vi.mock('../main/constants', () => ({
+// Keep the real constants (ipcValidation and main both read many of them) and
+// override only the values this suite needs to control.
+vi.mock('../main/constants', async (importOriginal) => ({
+ ...(await importOriginal()),
SPLASH_SHOW_DELAY_MS: 0,
SPLASH_FADE_DELAY_MS: 0,
MAX_QUEUE_SIZE: 500,
@@ -397,7 +405,21 @@ async function initializeMainModule(options?: { beforeImport?: (userDataDir: str
fs.writeFileSync(ytdlpFixturePath, '');
vi.resetModules();
await import('../main/main');
- await new Promise((resolve) => setTimeout(resolve, 10));
+ await appMock.whenReady.mock.results.at(-1)?.value;
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await waitForPrimaryWindowReady();
+}
+
+async function waitForPrimaryWindowReady() {
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ try {
+ getPrimaryWindow();
+ return;
+ } catch {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ }
+ throw new Error('Primary window not ready after module init');
}
type MockFn = ReturnType;
@@ -485,7 +507,7 @@ describe('main process IPC wiring and queue behavior', () => {
const cancelQueue = handleHandlers['cancel-queue'];
const addResult = await addToQueue(authorizedEvent, ['https://example.com/a', 'invalid-url']);
- expect(addResult).toEqual({ ok: true, data: { added: 1 } });
+ expect(addResult).toEqual({ ok: true, data: { added: 1, skipped: 1 } });
let queue = await getQueue(authorizedEvent);
expect(queue).toHaveLength(1);
@@ -509,7 +531,7 @@ describe('main process IPC wiring and queue behavior', () => {
'https://example.com/b',
'https://example.com/c',
]);
- expect(secondAdd).toEqual({ ok: true, data: { added: 2 } });
+ expect(secondAdd).toEqual({ ok: true, data: { added: 2, skipped: 0 } });
const cancelResult = await cancelQueue(authorizedEvent);
expect(cancelResult).toEqual({ ok: true, data: undefined });
@@ -530,6 +552,182 @@ describe('main process IPC wiring and queue behavior', () => {
);
});
+ it('deduplicates queued URLs against the pending queue', async () => {
+ const addToQueue = handleHandlers['add-to-queue']!;
+
+ const first = await addToQueue(authorizedEvent, [
+ 'https://example.com/dupe',
+ 'https://example.com/dupe#fragment',
+ ]);
+ expect(first).toEqual({ ok: true, data: { added: 1, skipped: 1 } });
+
+ const second = await addToQueue(authorizedEvent, ['https://example.com/dupe']);
+ expect(second).toEqual({ ok: true, data: { added: 0, skipped: 1 } });
+
+ const queue = (await handleHandlers['get-queue']!(authorizedEvent)) as unknown[];
+ expect(queue).toHaveLength(1);
+ });
+
+ it('retries terminal queue items and refuses others', async () => {
+ const retry = handleHandlers['retry-queue-item']!;
+ await handleHandlers['add-to-queue']!(authorizedEvent, ['https://example.com/retry']);
+ const queue = (await handleHandlers['get-queue']!(authorizedEvent)) as Array<{
+ id: string;
+ status: string;
+ error?: string;
+ }>;
+ const item = queue[0]!;
+
+ // A pending item has nothing to retry.
+ expect(await retry(authorizedEvent, item.id)).toEqual(
+ expect.objectContaining({
+ ok: false,
+ error: expect.objectContaining({ code: 'VALIDATION_ERROR' }),
+ })
+ );
+ expect(await retry(authorizedEvent, 'q_missing')).toEqual(
+ expect.objectContaining({
+ ok: false,
+ error: expect.objectContaining({ code: 'NOT_AVAILABLE' }),
+ })
+ );
+ expect(await retry(authorizedEvent, 'bad id')).toEqual(expect.objectContaining({ ok: false }));
+
+ // Cancel it to reach a terminal state, then retry back to pending.
+ await handleHandlers['cancel-queue']!(authorizedEvent);
+ const cancelled = (await handleHandlers['get-queue']!(authorizedEvent)) as Array<{
+ id: string;
+ status: string;
+ }>;
+ expect(cancelled[0]?.status).toBe('cancelled');
+
+ expect(await retry(authorizedEvent, item.id)).toEqual({ ok: true, data: undefined });
+ const retried = (await handleHandlers['get-queue']!(authorizedEvent)) as Array<{
+ status: string;
+ completedAt?: number;
+ error?: string;
+ }>;
+ expect(retried[0]?.status).toBe('pending');
+ expect(retried[0]?.completedAt).toBeUndefined();
+ expect(retried[0]?.error).toBeUndefined();
+ });
+
+ it('reorders pending queue items and rejects invalid moves', async () => {
+ const reorder = handleHandlers['reorder-queue-item']!;
+ await handleHandlers['add-to-queue']!(authorizedEvent, [
+ 'https://example.com/first',
+ 'https://example.com/second',
+ ]);
+ const queue = (await handleHandlers['get-queue']!(authorizedEvent)) as Array<{
+ id: string;
+ url: string;
+ }>;
+ const [first, second] = [queue[0]!, queue[1]!];
+
+ expect(await reorder(authorizedEvent, { id: first.id, direction: 'down' })).toEqual({
+ ok: true,
+ data: undefined,
+ });
+ const reordered = (await handleHandlers['get-queue']!(authorizedEvent)) as Array<{
+ id: string;
+ }>;
+ expect(reordered[0]?.id).toBe(second.id);
+ expect(reordered[1]?.id).toBe(first.id);
+
+ // The item is now first, so it cannot move further up.
+ expect(await reorder(authorizedEvent, { id: second.id, direction: 'up' })).toEqual(
+ expect.objectContaining({
+ ok: false,
+ error: expect.objectContaining({ code: 'NOT_AVAILABLE' }),
+ })
+ );
+ expect(await reorder(authorizedEvent, { id: 'q_missing', direction: 'up' })).toEqual(
+ expect.objectContaining({
+ ok: false,
+ error: expect.objectContaining({ code: 'NOT_AVAILABLE' }),
+ })
+ );
+ expect(await reorder(authorizedEvent, { id: first.id, direction: 'sideways' })).toEqual(
+ expect.objectContaining({ ok: false })
+ );
+ });
+
+ it('exposes default settings and download activity to the main window only', async () => {
+ const unauthorized = { sender: { id: 999 } };
+
+ const defaults = await handleHandlers['get-default-settings']!(authorizedEvent);
+ expect(defaults).toEqual(expect.objectContaining({ ok: true }));
+ expect(await handleHandlers['get-default-settings']!(unauthorized)).toEqual(
+ expect.objectContaining({ ok: false })
+ );
+
+ const activity = await handleHandlers['get-download-activity']!(authorizedEvent);
+ expect(activity).toEqual({ ok: true, data: [] });
+ expect(await handleHandlers['get-download-activity']!(unauthorized)).toEqual(
+ expect.objectContaining({ ok: false })
+ );
+
+ expect(await handleHandlers['clear-download-activity']!(authorizedEvent)).toEqual({
+ ok: true,
+ data: undefined,
+ });
+ expect(await handleHandlers['clear-download-activity']!(unauthorized)).toEqual(
+ expect.objectContaining({ ok: false })
+ );
+ });
+
+ it('records structured download activity from completed queue items', async () => {
+ startDownloadMock.mockImplementationOnce(
+ (
+ _ytdlpPath: string,
+ _sender: unknown,
+ options: { url: string; outputPath: string },
+ _mainWindow: unknown,
+ _onComplete: unknown,
+ _owner: unknown,
+ _queueProgress: unknown,
+ onDownloadComplete?: (completion: Record) => void
+ ) => {
+ onDownloadComplete?.({
+ id: 'completion-1',
+ owner: 'queue',
+ outcome: 'success',
+ statusMessage: 'â
Done',
+ url: options.url,
+ // Activity records are re-validated, so the path must be allowed.
+ request: { url: options.url, outputPath: path.join(os.homedir(), 'Downloads') },
+ filename: 'clip.mp4',
+ sizeBytes: 1024,
+ startedAt: Date.now() - 100,
+ completedAt: Date.now(),
+ });
+ }
+ );
+
+ await handleHandlers['add-to-queue']!(authorizedEvent, ['https://example.com/activity']);
+ await handleHandlers['start-queue']!(authorizedEvent);
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ const activity = (await handleHandlers['get-download-activity']!(authorizedEvent)) as {
+ ok: boolean;
+ data: Array<{ id: string; outcome: string; filename?: string; sizeBytes?: number }>;
+ };
+ expect(activity.ok).toBe(true);
+ expect(activity.data).toHaveLength(1);
+ expect(activity.data[0]).toMatchObject({
+ id: 'completion-1',
+ outcome: 'success',
+ filename: 'clip.mp4',
+ sizeBytes: 1024,
+ });
+
+ const queue = (await handleHandlers['get-queue']!(authorizedEvent)) as Array<{
+ status: string;
+ filename?: string;
+ }>;
+ expect(queue[0]).toMatchObject({ status: 'completed', filename: 'clip.mp4' });
+ });
+
it('rejects malformed queue input and ignores duplicate completion callbacks', async () => {
expect(await handleHandlers['add-to-queue']!(authorizedEvent, 'not-array')).toEqual(
expect.objectContaining({
@@ -716,6 +914,7 @@ describe('main process IPC wiring and queue behavior', () => {
it('handles settings and utility IPC requests', async () => {
expect(await handleHandlers['get-app-version']!()).toBe('4.0.0-beta.2');
+ expect(await handleHandlers['get-app-platform']!()).toBe(process.platform);
expect(await handleHandlers['is-packaged']!()).toBe(true);
expect(await handleHandlers['get-settings']!(authorizedEvent)).toEqual(
expect.objectContaining({ convertFormat: 'mp4' })
@@ -791,9 +990,10 @@ describe('main process IPC wiring and queue behavior', () => {
it('handles renderer logs and main window navigation events', async () => {
const mainWindow = getPrimaryWindow();
- onHandlers['log-error']!({}, 'x'.repeat(2105));
- onHandlers['log-error']!({}, 'short');
- onHandlers['log-error']!({}, 123);
+ onHandlers['log-error']!(authorizedEvent, 'x'.repeat(2105));
+ onHandlers['log-error']!(authorizedEvent, 'short');
+ onHandlers['log-error']!(authorizedEvent, 123);
+ onHandlers['log-error']!({ sender: { id: 99999 } }, 'ignored');
const openHandler = mainWindow.webContents.setWindowOpenHandler.mock.calls[0]![0] as (input: {
url: string;
@@ -1393,7 +1593,11 @@ describe('main process IPC wiring and queue behavior', () => {
);
expect(result).toEqual({ ok: true, data: info });
- expect(fetchVideoInfoMock).toHaveBeenCalledWith(ytdlpFixturePath, 'https://example.com/video');
+ expect(fetchVideoInfoMock).toHaveBeenCalledWith(
+ ytdlpFixturePath,
+ 'https://example.com/video',
+ undefined
+ );
});
it('rejects invalid URLs for get-video-info', async () => {
diff --git a/src/tests/packaging.contract.test.ts b/src/tests/packaging.contract.test.ts
index fa3e32b..7c897aa 100644
--- a/src/tests/packaging.contract.test.ts
+++ b/src/tests/packaging.contract.test.ts
@@ -39,6 +39,9 @@ describe('packaging and desktop contracts', () => {
expect(splash).toMatch(//);
expect(splash).toMatch(/Content-Security-Policy/);
expect(splash).toMatch(/Loading ROSI<\/title>/);
+ expect(splash).toMatch(/theme-init\.js/);
+ expect(splash).toMatch(/css\/01-base\.css/);
+ expect(splash).toMatch(/css\/splash\.css/);
});
it('keeps AppStream launchable aligned with the desktop entry', () => {
diff --git a/src/tests/preload.test.ts b/src/tests/preload.test.ts
index ac3d7af..ced2504 100644
--- a/src/tests/preload.test.ts
+++ b/src/tests/preload.test.ts
@@ -86,13 +86,17 @@ describe('preload api contract', () => {
'cancelVideoInfo',
'checkDenoInstalled',
'checkForUpdates',
+ 'clearDownloadActivity',
'clearQueue',
'detectGpu',
'downloadUpdate',
'downloadVideo',
'exportSettings',
+ 'getAppPlatform',
'getAppVersion',
'getChannel',
+ 'getDefaultSettings',
+ 'getDownloadActivity',
'getFormats',
'getQueue',
'getSettings',
@@ -104,6 +108,8 @@ describe('preload api contract', () => {
'isPackaged',
'logError',
'notifySettingsFlushed',
+ 'onDownloadActivityUpdate',
+ 'onDownloadComplete',
'onJobProgress',
'onMenuAction',
'onComplete',
@@ -116,9 +122,11 @@ describe('preload api contract', () => {
'openExternal',
'openFileLocation',
'removeFromQueue',
+ 'reorderQueueItem',
'resetSettings',
'resetStats',
'restartApp',
+ 'retryQueueItem',
'saveSettings',
'selectDownloadLocation',
'showNotification',
@@ -142,6 +150,7 @@ describe('preload api contract', () => {
await expectInvokeCall(api, 'openExternal', 'open-external', ['https://rosie.run']);
await expectInvokeCall(api, 'downloadVideo', 'download-video', [downloadOptions]);
await expectInvokeCall(api, 'getAppVersion', 'get-app-version');
+ await expectInvokeCall(api, 'getAppPlatform', 'get-app-platform');
await expectInvokeCall(api, 'checkDenoInstalled', 'check-deno-installed');
await expectInvokeCall(api, 'installDeno', 'install-deno');
await expectInvokeCall(api, 'detectGpu', 'detect-gpu');
@@ -160,6 +169,35 @@ describe('preload api contract', () => {
await expectInvokeCall(api, 'getQueue', 'get-queue');
await expectInvokeCall(api, 'startQueue', 'start-queue');
await expectInvokeCall(api, 'cancelQueue', 'cancel-queue');
+ await expectInvokeCall(api, 'getDefaultSettings', 'get-default-settings');
+ await expectInvokeCall(api, 'getDownloadActivity', 'get-download-activity');
+ await expectInvokeCall(api, 'clearDownloadActivity', 'clear-download-activity');
+ await expectInvokeCall(api, 'retryQueueItem', 'retry-queue-item', ['q_1']);
+ await expectInvokeCall(api, 'reorderQueueItem', 'reorder-queue-item', [
+ { id: 'q_1', direction: 'up' },
+ ]);
+ });
+
+ it('forwards optional arguments only when provided', async () => {
+ const api = getExposedApi();
+
+ invokeMock.mockClear();
+ await getApiMethod(api, 'getVideoInfo')('https://example.com');
+ expect(invokeMock).toHaveBeenCalledWith('get-video-info', 'https://example.com');
+
+ invokeMock.mockClear();
+ await getApiMethod(api, 'getVideoInfo')('https://example.com', 'all');
+ expect(invokeMock).toHaveBeenCalledWith('get-video-info', 'https://example.com', 'all');
+
+ invokeMock.mockClear();
+ await getApiMethod(api, 'addToQueue')(['https://example.com/a']);
+ expect(invokeMock).toHaveBeenCalledWith('add-to-queue', ['https://example.com/a']);
+
+ invokeMock.mockClear();
+ await getApiMethod(api, 'addToQueue')(['https://example.com/a'], { presetId: 'p1' });
+ expect(invokeMock).toHaveBeenCalledWith('add-to-queue', ['https://example.com/a'], {
+ presetId: 'p1',
+ });
});
it('maps send-based methods to the correct IPC channels', () => {
@@ -215,6 +253,11 @@ describe('preload api contract', () => {
validateSubscription('onMenuAction', 'menu-action', 'open-settings');
validateSubscription('onComplete', 'complete', 'done');
validateSubscription('onQueueUpdate', 'queue-update', [{ id: 'q_1' }]);
+ validateSubscription('onDownloadComplete', 'download-complete', {
+ id: 'c1',
+ outcome: 'success',
+ });
+ validateSubscription('onDownloadActivityUpdate', 'download-activity-update', [{ id: 'a1' }]);
onMock.mockClear();
removeListenerMock.mockClear();
diff --git a/src/tests/queue.test.ts b/src/tests/queue.test.ts
index 4bbe116..513078c 100644
--- a/src/tests/queue.test.ts
+++ b/src/tests/queue.test.ts
@@ -112,6 +112,7 @@ const {
}),
getVersion: vi.fn(() => '4.0.0'),
getAppPath: vi.fn(() => process.cwd()),
+ setAboutPanelOptions: vi.fn(),
};
return {
@@ -173,6 +174,10 @@ vi.mock('electron', () => ({
on = vi.fn();
show = vi.fn();
},
+ Menu: {
+ setApplicationMenu: vi.fn(),
+ buildFromTemplate: vi.fn(() => ({})),
+ },
}));
vi.mock('../main/platform', () => ({
@@ -369,7 +374,7 @@ describe('queue edge cases and error handling', () => {
'invalid',
'https://example.com/b',
]);
- expect(result).toEqual({ ok: true, data: { added: 2 } });
+ expect(result).toEqual({ ok: true, data: { added: 2, skipped: 1 } });
});
it('rejects start-queue when no pending items', async () => {
diff --git a/src/tests/queueRecovery.test.ts b/src/tests/queueRecovery.test.ts
new file mode 100644
index 0000000..afab670
--- /dev/null
+++ b/src/tests/queueRecovery.test.ts
@@ -0,0 +1,109 @@
+import * as os from 'os';
+import * as path from 'path';
+import { describe, it, expect } from 'vitest';
+import {
+ validateDownloadRequestPayload,
+ validateQueueItemIdPayload,
+ validateQueueReorderPayload,
+} from '../utils/ipcValidation';
+
+describe('queue item id validation', () => {
+ it('accepts generated queue ids', () => {
+ const result = validateQueueItemIdPayload(' q_1234-abcd ');
+ expect(result).toEqual({ ok: true, data: 'q_1234-abcd' });
+ });
+
+ it('rejects non-strings and unsafe ids', () => {
+ for (const value of [
+ null,
+ 42,
+ '',
+ ' ',
+ '-leading',
+ 'has space',
+ 'bad/slash',
+ 'x'.repeat(200),
+ ]) {
+ expect(validateQueueItemIdPayload(value).ok).toBe(false);
+ }
+ });
+});
+
+describe('queue reorder validation', () => {
+ it('accepts up and down for a valid id', () => {
+ expect(validateQueueReorderPayload({ id: 'q_1', direction: 'up' })).toEqual({
+ ok: true,
+ data: { id: 'q_1', direction: 'up' },
+ });
+ expect(validateQueueReorderPayload({ id: 'q_1', direction: 'down' })).toEqual({
+ ok: true,
+ data: { id: 'q_1', direction: 'down' },
+ });
+ });
+
+ it('rejects malformed payloads and unknown directions', () => {
+ for (const payload of [
+ null,
+ 'q_1',
+ {},
+ { id: 'q_1' },
+ { id: 'q_1', direction: 'sideways' },
+ { id: 'bad id', direction: 'up' },
+ { direction: 'up' },
+ ]) {
+ expect(validateQueueReorderPayload(payload).ok).toBe(false);
+ }
+ });
+});
+
+describe('download request playlist and preset fields', () => {
+ const outputPath = path.join(os.homedir(), 'Downloads');
+
+ it('preserves a validated playlist range and preset identity', () => {
+ const result = validateDownloadRequestPayload({
+ url: 'https://example.com/watch?v=abc',
+ outputPath,
+ playlist: { mode: 'range', start: 2, end: 4 },
+ presetId: 'my-preset',
+ presetName: 'My Preset',
+ profile: 'best-video',
+ sponsorblockRemove: true,
+ });
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.data.playlist).toEqual({ mode: 'range', start: 2, end: 4 });
+ expect(result.data.presetId).toBe('my-preset');
+ expect(result.data.presetName).toBe('My Preset');
+ expect(result.data.profile).toBe('best-video');
+ expect(result.data.sponsorblockRemove).toBe(true);
+ });
+
+ it('rejects unsafe playlist and preset values', () => {
+ const base = { url: 'https://example.com/a', outputPath };
+ for (const patch of [
+ { playlist: { mode: 'range', start: 0, end: 3 } },
+ { playlist: 'all' },
+ { presetId: 'bad id' },
+ { presetId: '' },
+ { presetName: 'x'.repeat(60) },
+ { presetId: 5 },
+ { profile: 'ultra' },
+ { audioOutputFormat: 'exe' },
+ { gpuType: 'quantum' },
+ { subtitleLangs: 'en;rm -rf /' },
+ { convertFormat: 'mkv' },
+ { videoFormat: 'has space' },
+ { sponsorblockRemove: 'yes' },
+ { browserChoice: 'netscape' },
+ ]) {
+ expect(validateDownloadRequestPayload({ ...base, ...patch }).ok).toBe(false);
+ }
+ });
+
+ it('omits playlist when not provided', () => {
+ const result = validateDownloadRequestPayload({ url: 'https://example.com/a', outputPath });
+ expect(result.ok).toBe(true);
+ if (result.ok) expect(result.data.playlist).toBeUndefined();
+ });
+});
diff --git a/src/tests/rendererConstants.contract.test.ts b/src/tests/rendererConstants.contract.test.ts
new file mode 100644
index 0000000..3e922af
--- /dev/null
+++ b/src/tests/rendererConstants.contract.test.ts
@@ -0,0 +1,30 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { describe, it, expect } from 'vitest';
+import { MAX_DOWNLOAD_PRESETS, MAX_PLAYLIST_ITEM_INDEX } from '../main/constants';
+
+// rosiEngine runs as a plain browser script and cannot import main-process
+// constants, so a few limits are duplicated as literals. These checks fail if
+// the two copies ever drift apart.
+const ENGINE_SOURCE = fs.readFileSync(
+ path.join(__dirname, '..', 'renderer', 'rosiEngine.ts'),
+ 'utf-8'
+);
+
+function readNumericLiteral(name: string): number {
+ const match = ENGINE_SOURCE.match(new RegExp(`const ${name} = ([0-9_]+)`));
+ if (!match?.[1]) {
+ throw new Error(`Could not find "const ${name}" in rosiEngine.ts`);
+ }
+ return Number(match[1].replace(/_/g, ''));
+}
+
+describe('renderer constants mirror the main-process limits', () => {
+ it('uses the same saved-preset cap as the settings validator', () => {
+ expect(readNumericLiteral('MAX_PRESETS')).toBe(MAX_DOWNLOAD_PRESETS);
+ });
+
+ it('uses the same playlist index ceiling as the payload validator', () => {
+ expect(readNumericLiteral('MAX_PLAYLIST_INDEX')).toBe(MAX_PLAYLIST_ITEM_INDEX);
+ });
+});
diff --git a/src/tests/rendererModules.test.ts b/src/tests/rendererModules.test.ts
index f7ba938..7d41b51 100644
--- a/src/tests/rendererModules.test.ts
+++ b/src/tests/rendererModules.test.ts
@@ -60,18 +60,44 @@ type RosiModules = {
};
queue?: {
renderQueue: (
- queue: Array<{ id: string; status: string; url: string }>,
+ queue: Array<{ id: string; status: string; url: string; error?: string }>,
elements: {
queueList: HTMLElement | null;
queueSection: HTMLElement | null;
queueCount: HTMLElement | null;
},
deps: {
- escapeHtml: (value: string) => string;
removeFromQueue: (id: string) => unknown;
+ retryQueueItem: (id: string) => unknown;
+ reorderQueueItem: (id: string, direction: 'up' | 'down') => unknown;
+ copyDiagnostics: (item: { id: string; status: string; url: string }) => unknown;
+ openFileLocation?: (filePath: string) => unknown;
focusQueueItemId?: string | null;
}
) => void;
+ updateQueueItemProgress: (
+ item: {
+ id: string;
+ status: string;
+ url: string;
+ progress?: {
+ phase: string;
+ phasePercent: number;
+ itemOverallPercent: number;
+ overallPercent: number;
+ status: string;
+ details?: string;
+ indeterminate?: boolean;
+ speedBytesPerSecond?: number;
+ etaSeconds?: number;
+ };
+ },
+ elements: {
+ queueList: HTMLElement | null;
+ queueSection: HTMLElement | null;
+ queueCount: HTMLElement | null;
+ }
+ ) => boolean;
resolveQueueSectionElement: (root?: Document) => HTMLElement | null;
};
settings?: {
@@ -210,6 +236,17 @@ describe('renderer modules', () => {
});
describe('queue module', () => {
+ const queueDeps = (
+ overrides: Partial['renderQueue']>[2]> = {}
+ ) => ({
+ removeFromQueue: vi.fn(),
+ retryQueueItem: vi.fn(),
+ reorderQueueItem: vi.fn(),
+ copyDiagnostics: vi.fn(),
+ openFileLocation: vi.fn(),
+ ...overrides,
+ });
+
beforeEach(() => {
document.body.innerHTML =
'';
@@ -233,7 +270,7 @@ describe('renderer modules', () => {
queueSection,
queueCount: document.getElementById('queue-count'),
},
- { escapeHtml: (value) => value, removeFromQueue: vi.fn() }
+ queueDeps()
);
expect(queueSection.classList.contains('has-items')).toBe(false);
@@ -243,10 +280,8 @@ describe('renderer modules', () => {
});
it('does not allow crafted URL content to inject markup in renderQueue output', () => {
- // Intentionally pass a permissive escaper to prove the sink no longer
- // depends on escaping correctness: renderQueue now builds DOM nodes and
- // assigns untrusted values via textContent/title, so markup cannot inject.
- const escapeHtml = (value: string) => value;
+ // renderQueue builds DOM nodes and assigns untrusted values via
+ // textContent/title, so crafted markup cannot inject.
const maliciousUrl = 'https://example.com/">
';
modules().queue!.renderQueue(
[{ id: 'q_xss', status: 'pending', url: maliciousUrl }],
@@ -255,7 +290,7 @@ describe('renderer modules', () => {
queueSection: document.getElementById('queueSection'),
queueCount: document.getElementById('queue-count'),
},
- { escapeHtml, removeFromQueue: vi.fn() }
+ queueDeps()
);
const queueList = document.getElementById('queue-list')!;
@@ -267,11 +302,11 @@ describe('renderer modules', () => {
expect(node.getAttribute('onerror')).toBeNull();
expect(node.getAttribute('onmouseover')).toBeNull();
});
- const urlEl = queueList.querySelector('.queue-item-url')!;
- expect(urlEl.textContent).toContain('example.com');
+ const titleEl = queueList.querySelector('.queue-item-title')!;
+ expect(titleEl.textContent).toContain('example.com');
// The full raw URL is preserved verbatim as the title's text value
// (stored as data, never parsed as HTML).
- expect(urlEl.getAttribute('title')).toBe(maliciousUrl);
+ expect(titleEl.getAttribute('title')).toBe(maliciousUrl);
});
it('removes pending queue items through the remove button', async () => {
@@ -283,7 +318,7 @@ describe('renderer modules', () => {
queueSection: document.getElementById('queueSection'),
queueCount: document.getElementById('queue-count'),
},
- { escapeHtml: (value) => value, removeFromQueue }
+ queueDeps({ removeFromQueue })
);
const removeBtn = document.querySelector('.queue-item-remove');
@@ -291,6 +326,116 @@ describe('renderer modules', () => {
removeBtn!.click();
expect(removeFromQueue).toHaveBeenCalledWith('q1');
});
+
+ it('offers retry and diagnostics for failed queue items', () => {
+ const retryQueueItem = vi.fn().mockResolvedValue(undefined);
+ const copyDiagnostics = vi.fn().mockResolvedValue(undefined);
+ modules().queue!.renderQueue(
+ [
+ {
+ id: 'q_failed',
+ status: 'failed',
+ url: 'https://example.com/video',
+ error: 'yt-dlp exited with code 1',
+ },
+ ],
+ {
+ queueList: document.getElementById('queue-list'),
+ queueSection: document.getElementById('queueSection'),
+ queueCount: document.getElementById('queue-count'),
+ },
+ queueDeps({ retryQueueItem, copyDiagnostics })
+ );
+
+ document.querySelector('.queue-item-retry')!.click();
+ expect(retryQueueItem).toHaveBeenCalledWith('q_failed');
+ document.querySelector('.queue-item-copy')!.click();
+ expect(copyDiagnostics).toHaveBeenCalled();
+ expect(document.querySelector('.queue-item-details')?.textContent).toContain(
+ 'yt-dlp exited with code 1'
+ );
+ });
+
+ it('patches active-item progress in place without rebuilding the row', () => {
+ const elements = {
+ queueList: document.getElementById('queue-list'),
+ queueSection: document.getElementById('queueSection'),
+ queueCount: document.getElementById('queue-count'),
+ };
+ const item = {
+ id: 'q_active',
+ status: 'downloading',
+ url: 'https://example.com/video',
+ };
+ modules().queue!.renderQueue([item], elements, queueDeps());
+ const rowBefore = document.querySelector('.queue-item[data-queue-id="q_active"]');
+
+ const patched = modules().queue!.updateQueueItemProgress(
+ {
+ ...item,
+ progress: {
+ phase: 'download',
+ phasePercent: 42,
+ itemOverallPercent: 42,
+ overallPercent: 21,
+ status: 'Downloading...',
+ speedBytesPerSecond: 1_048_576,
+ etaSeconds: 65,
+ },
+ },
+ elements
+ );
+
+ expect(patched).toBe(true);
+ // Same DOM node: the row was patched, not re-created.
+ expect(document.querySelector('.queue-item[data-queue-id="q_active"]')).toBe(rowBefore);
+ expect(document.querySelector('.queue-item-progress-bar')?.getAttribute('style')).toContain(
+ '42%'
+ );
+ const details = document.querySelector('.queue-item-progress-details')?.textContent ?? '';
+ expect(details).toContain('/s');
+ expect(details).toContain('ETA 1:05');
+ expect(document.querySelectorAll('.queue-item-progress')).toHaveLength(1);
+ });
+
+ it('reports when there is no row to patch', () => {
+ const elements = {
+ queueList: document.getElementById('queue-list'),
+ queueSection: document.getElementById('queueSection'),
+ queueCount: document.getElementById('queue-count'),
+ };
+ modules().queue!.renderQueue([], elements, queueDeps());
+ expect(
+ modules().queue!.updateQueueItemProgress(
+ { id: 'q_missing', status: 'downloading', url: 'https://example.com/x' },
+ elements
+ )
+ ).toBe(false);
+ });
+
+ it('reorders pending queue items and disables unavailable directions', () => {
+ const reorderQueueItem = vi.fn().mockResolvedValue(undefined);
+ modules().queue!.renderQueue(
+ [
+ { id: 'q1', status: 'pending', url: 'https://example.com/a' },
+ { id: 'q2', status: 'pending', url: 'https://example.com/b' },
+ ],
+ {
+ queueList: document.getElementById('queue-list'),
+ queueSection: document.getElementById('queueSection'),
+ queueCount: document.getElementById('queue-count'),
+ },
+ queueDeps({ reorderQueueItem })
+ );
+
+ const upButtons = document.querySelectorAll('.queue-item-move-up');
+ const downButtons = document.querySelectorAll('.queue-item-move-down');
+ expect(upButtons[0]!.disabled).toBe(true);
+ expect(downButtons[1]!.disabled).toBe(true);
+
+ downButtons[0]!.click();
+ expect(reorderQueueItem).toHaveBeenCalledWith('q1', 'down');
+ });
});
describe('settings module', () => {
diff --git a/src/tests/rosiEngine.dom.test.ts b/src/tests/rosiEngine.dom.test.ts
index 24ecb74..6ba1779 100644
--- a/src/tests/rosiEngine.dom.test.ts
+++ b/src/tests/rosiEngine.dom.test.ts
@@ -41,13 +41,14 @@ interface MockApi {
function defaultSettings() {
return {
- settingsVersion: 6,
+ settingsVersion: 7,
theme: 'dark',
showConsoleOutput: false,
consoleCollapsed: false,
queueCollapsed: false,
downloadProfilesEnabled: false,
downloadMode: 'best-video',
+ downloadPresets: [],
askDownloadLocation: false,
advancedOptions: false,
audioOnly: false,
@@ -88,6 +89,7 @@ function buildMockApi(overrides: Partial = {}): MockApi {
saveSettings: vi.fn((s: unknown) => ok(s)),
resetSettings: vi.fn(),
getAppVersion: vi.fn(() => Promise.resolve('4.1.0')),
+ getAppPlatform: vi.fn(() => Promise.resolve('darwin' as NodeJS.Platform)),
isPackaged: vi.fn(() => Promise.resolve(false)),
checkDenoInstalled: vi.fn(() => Promise.resolve(true)),
getQueue: vi.fn(() => Promise.resolve([])),
@@ -98,13 +100,18 @@ function buildMockApi(overrides: Partial = {}): MockApi {
downloadVideo: vi.fn(() => ok({ started: true })),
cancelDownload: vi.fn(),
cancelFormats: vi.fn(),
- addToQueue: vi.fn(() => ok({ added: 1 })),
+ addToQueue: vi.fn(() => ok({ added: 1, skipped: 0 })),
removeFromQueue: vi.fn(() => ok(undefined)),
+ retryQueueItem: vi.fn(() => ok(undefined)),
+ reorderQueueItem: vi.fn(() => ok(undefined)),
clearQueue: vi.fn(() => ok(undefined)),
startQueue: vi.fn(() => ok({ started: true })),
cancelQueue: vi.fn(() => ok(undefined)),
getStats: vi.fn(() => Promise.resolve({})),
resetStats: vi.fn(() => ok(undefined)),
+ getDefaultSettings: vi.fn(() => ok(defaultSettings())),
+ getDownloadActivity: vi.fn(() => ok([])),
+ clearDownloadActivity: vi.fn(() => ok(undefined)),
openExternal: vi.fn(() => ok({ opened: true })),
openFileLocation: vi.fn(() => ok({ opened: true })),
showNotification: vi.fn(() => ok({ shown: true })),
@@ -123,6 +130,8 @@ function buildMockApi(overrides: Partial = {}): MockApi {
onJobProgress: noop,
onMenuAction: noop,
onComplete: noop,
+ onDownloadComplete: noop,
+ onDownloadActivityUpdate: noop,
onQueueUpdate: noop,
onPrepareForClose: noop,
onUpdaterStatus: noop,
@@ -302,6 +311,46 @@ describe('rosiEngine DOM wiring', () => {
);
});
+ it('asks once for a destination when Ask every time is enabled', async () => {
+ const api = buildMockApi({
+ getSettings: vi.fn(() =>
+ Promise.resolve({ ...defaultSettings(), askDownloadLocation: true })
+ ) as unknown as ReturnType,
+ selectDownloadLocation: vi.fn(() => Promise.resolve('/tmp/queue-target')),
+ });
+ await loadEngine(api);
+
+ const queueInput = document.getElementById('queueUrlInput') as HTMLTextAreaElement;
+ queueInput.value = 'https://example.com/a\nhttps://example.com/b';
+ (document.getElementById('addToQueueBtn') as HTMLButtonElement).click();
+ await flush(30);
+
+ // One prompt for the whole batch, and the choice is sent as an override.
+ expect(api.selectDownloadLocation).toHaveBeenCalledTimes(1);
+ expect(api.addToQueue).toHaveBeenCalledWith(
+ ['https://example.com/a', 'https://example.com/b'],
+ expect.objectContaining({ outputPath: '/tmp/queue-target' })
+ );
+ });
+
+ it('queues nothing when the destination prompt is dismissed', async () => {
+ const api = buildMockApi({
+ getSettings: vi.fn(() =>
+ Promise.resolve({ ...defaultSettings(), askDownloadLocation: true })
+ ) as unknown as ReturnType,
+ selectDownloadLocation: vi.fn(() => Promise.resolve(null)),
+ });
+ await loadEngine(api);
+
+ const queueInput = document.getElementById('queueUrlInput') as HTMLTextAreaElement;
+ queueInput.value = 'https://example.com/a';
+ (document.getElementById('addToQueueBtn') as HTMLButtonElement).click();
+ await flush(30);
+
+ expect(api.addToQueue).not.toHaveBeenCalled();
+ expect(queueInput.value).toBe('https://example.com/a');
+ });
+
it('adds URLs to the queue via the queue input', async () => {
const api = buildMockApi();
await loadEngine(api);
@@ -549,7 +598,7 @@ describe('rosiEngine DOM wiring', () => {
(document.getElementById('previewBtn') as HTMLButtonElement).click();
await flush(50);
- expect(api.getVideoInfo).toHaveBeenCalledWith('https://youtube.com/watch?v=abc');
+ expect(api.getVideoInfo).toHaveBeenCalledWith('https://youtube.com/watch?v=abc', 'current');
const card = document.getElementById('preview-card') as HTMLElement;
expect(card.classList.contains('visible')).toBe(true);
expect(document.getElementById('preview-title')?.textContent).toBe('Test Clip');
@@ -585,20 +634,80 @@ describe('rosiEngine DOM wiring', () => {
expect(backBtn.hasAttribute('hidden')).toBe(true);
});
- it('records download history in localStorage on completion', async () => {
- let completeCb: ((msg: string) => void) | null = null;
+ it('renders activity from the main process instead of localStorage', async () => {
+ let activityCb: ((entries: unknown[]) => void) | null = null;
const api = buildMockApi({
- onComplete: ((cb: (msg: string) => void) => {
- completeCb = cb;
+ onDownloadActivityUpdate: ((cb: (entries: unknown[]) => void) => {
+ activityCb = cb;
return () => {};
}) as unknown as ReturnType,
});
await loadEngine(api);
- expect(typeof completeCb).toBe('function');
- completeCb!('â
Download complete (no conversion).');
+ expect(api.getDownloadActivity).toHaveBeenCalled();
+ expect(document.querySelector('.history-empty')?.textContent).toContain('No downloads yet');
+
+ expect(typeof activityCb).toBe('function');
+ activityCb!([
+ {
+ id: 'a1',
+ owner: 'manual',
+ outcome: 'success',
+ statusMessage: 'Download complete.',
+ url: 'https://example.com/video',
+ request: { url: 'https://example.com/video', outputPath: '/tmp' },
+ filename: 'video.mp4',
+ sizeBytes: 2048,
+ startedAt: Date.now() - 1000,
+ completedAt: Date.now(),
+ },
+ ]);
+ await flush();
+
+ expect(document.getElementById('history-count')?.textContent).toBe('1');
+ expect(document.querySelector('.history-filename')?.textContent).toBe('video.mp4');
+ expect(localStorage.getItem('rosi-download-history')).toBeNull();
+ });
+
+ it('filters activity rows by outcome', async () => {
+ const api = buildMockApi({
+ getDownloadActivity: vi.fn(() =>
+ Promise.resolve({
+ ok: true,
+ data: [
+ {
+ id: 'ok1',
+ owner: 'manual',
+ outcome: 'success',
+ statusMessage: 'done',
+ url: 'https://example.com/a',
+ request: { url: 'https://example.com/a', outputPath: '/tmp' },
+ filename: 'a.mp4',
+ startedAt: 1,
+ completedAt: 2,
+ },
+ {
+ id: 'bad1',
+ owner: 'queue',
+ outcome: 'failed',
+ statusMessage: 'failed',
+ url: 'https://example.com/b',
+ request: { url: 'https://example.com/b', outputPath: '/tmp' },
+ error: 'network unreachable',
+ startedAt: 1,
+ completedAt: 2,
+ },
+ ],
+ })
+ ) as unknown as ReturnType,
+ });
+ await loadEngine(api);
+ await flush(20);
+ expect(document.querySelectorAll('.history-item')).toHaveLength(2);
+
+ document.querySelector('[data-activity-filter="failed"]')!.click();
await flush();
- const history = JSON.parse(localStorage.getItem('rosi-download-history') || '[]');
- expect(history.length).toBe(1);
- expect(history[0].status).toBe('success');
+ const rows = document.querySelectorAll('.history-item');
+ expect(rows).toHaveLength(1);
+ expect(document.querySelector('.history-error')?.textContent).toBe('network unreachable');
});
});
diff --git a/src/tests/settingsUpgrade.beta2.test.ts b/src/tests/settingsUpgrade.beta2.test.ts
new file mode 100644
index 0000000..c19d8bb
--- /dev/null
+++ b/src/tests/settingsUpgrade.beta2.test.ts
@@ -0,0 +1,88 @@
+import { describe, it, expect, vi } from 'vitest';
+
+vi.mock('electron', () => ({
+ app: { getPath: () => '/tmp/rosi-upgrade-tests' },
+ dialog: { showErrorBox: vi.fn() },
+}));
+
+vi.mock('electron-log/main.js', () => ({
+ default: { initialize: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+import { CURRENT_SETTINGS_VERSION, migrateSettings } from '../main/settings';
+
+// A realistic schema v6 (4.3.0-beta.1) settings payload, to prove that an
+// in-place upgrade keeps every existing preference.
+const v6Settings = {
+ settingsVersion: 6,
+ theme: 'purple',
+ downloadProfilesEnabled: true,
+ downloadMode: 'audio',
+ audioFormat: 'flac',
+ convertEnabled: true,
+ convertFormat: 'mp3',
+ queueCollapsed: true,
+ consoleCollapsed: true,
+ askDownloadLocation: true,
+ showTaskbarProgress: false,
+ subtitleLangs: 'en,es',
+ writeSubtitles: true,
+ embedMetadata: true,
+ embedThumbnail: true,
+ sponsorblockRemove: true,
+ updateChannel: 'beta',
+ firstLaunch: false,
+ hideSupportModal: true,
+ hookBrowser: true,
+ browserChoice: 'firefox',
+ gpuAcceleration: true,
+ gpuType: 'nvidia',
+ flatUi: true,
+};
+
+describe('settings upgrade from 4.3.0-beta.1 (schema v6)', () => {
+ it('preserves every stored preference and adds an empty preset list', () => {
+ const migrated = migrateSettings(v6Settings);
+
+ // migrateSettings keeps a valid stored version as-is; loadSettings/saveSettings
+ // are what normalize it up to the current schema.
+ expect(migrated.settingsVersion).toBe(6);
+ expect(migrated.theme).toBe('purple');
+ expect(migrated.downloadProfilesEnabled).toBe(true);
+ expect(migrated.downloadMode).toBe('audio');
+ expect(migrated.audioOnly).toBe(true);
+ expect(migrated.audioFormat).toBe('flac');
+ expect(migrated.convertEnabled).toBe(true);
+ expect(migrated.convertFormat).toBe('mp3');
+ expect(migrated.queueCollapsed).toBe(true);
+ expect(migrated.consoleCollapsed).toBe(true);
+ expect(migrated.askDownloadLocation).toBe(true);
+ expect(migrated.showTaskbarProgress).toBe(false);
+ expect(migrated.subtitleLangs).toBe('en,es');
+ expect(migrated.writeSubtitles).toBe(true);
+ expect(migrated.embedMetadata).toBe(true);
+ expect(migrated.embedThumbnail).toBe(true);
+ expect(migrated.sponsorblockRemove).toBe(true);
+ expect(migrated.updateChannel).toBe('beta');
+ expect(migrated.firstLaunch).toBe(false);
+ expect(migrated.hideSupportModal).toBe(true);
+ expect(migrated.hookBrowser).toBe(true);
+ expect(migrated.browserChoice).toBe('firefox');
+ expect(migrated.gpuAcceleration).toBe(true);
+ expect(migrated.gpuType).toBe('nvidia');
+ expect(migrated.flatUi).toBe(true);
+
+ // The only new key in schema v7.
+ expect(migrated.downloadPresets).toEqual([]);
+ });
+
+ it('does not trust a schema version newer than this build', () => {
+ const migrated = migrateSettings({ ...v6Settings, settingsVersion: 99 });
+ expect(migrated.settingsVersion).toBe(CURRENT_SETTINGS_VERSION);
+ });
+
+ it('drops unknown keys rather than persisting them', () => {
+ const migrated = migrateSettings({ ...v6Settings, somethingInjected: 'rm -rf /' });
+ expect(migrated).not.toHaveProperty('somethingInjected');
+ });
+});
diff --git a/src/tests/videoInfo.test.ts b/src/tests/videoInfo.test.ts
index 7b01d0b..586bce8 100644
--- a/src/tests/videoInfo.test.ts
+++ b/src/tests/videoInfo.test.ts
@@ -85,6 +85,37 @@ describe('parseVideoInfo', () => {
expect(info?.thumbnail).toBe('https://example.com/a.jpg');
});
+ it('reports no playlist count when the entry listing was truncated', () => {
+ // Without playlist_count, a truncated listing must not present the fetch
+ // limit as though it were the real playlist length.
+ const entries = Array.from({ length: 4 }, (_, index) => ({ title: `Item ${index}` }));
+ const truncated = parseVideoInfo(
+ JSON.stringify({ _type: 'playlist', title: 'Big playlist', entries }),
+ 4
+ );
+ expect(truncated?.isPlaylist).toBe(true);
+ expect(truncated?.playlistCount).toBeNull();
+
+ const complete = parseVideoInfo(
+ JSON.stringify({ _type: 'playlist', title: 'Small playlist', entries }),
+ 10
+ );
+ expect(complete?.playlistCount).toBe(4);
+ });
+
+ it('prefers the extractor playlist_count over the entry length', () => {
+ const info = parseVideoInfo(
+ JSON.stringify({
+ _type: 'playlist',
+ title: 'Huge playlist',
+ playlist_count: 4200,
+ entries: [{ title: 'One' }, { title: 'Two' }],
+ }),
+ 2
+ );
+ expect(info?.playlistCount).toBe(4200);
+ });
+
it('uses Playlist as the fallback title for playlist payloads', () => {
const info = parseVideoInfo(JSON.stringify({ _type: 'playlist', entries: [{ title: 'a' }] }));
expect(info?.title).toBe('Playlist');
diff --git a/src/types.ts b/src/types.ts
index f68efd9..309c3b3 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,6 +1,34 @@
export type AudioFormat = 'mp3' | 'flac' | 'ogg' | 'wav' | 'm4a' | 'opus';
export type DownloadProfile = 'best-video' | 'audio' | 'custom';
+export interface PlaylistSelection {
+ mode: 'current' | 'all' | 'range';
+ start?: number;
+ end?: number;
+}
+
+export interface DownloadPreset {
+ id: string;
+ name: string;
+ profile: DownloadProfile;
+ bestQuality?: boolean;
+ audioOnly?: boolean;
+ audioFormat?: AudioFormat;
+ videoFormat?: string;
+ audioFormatId?: string;
+ convertEnabled?: boolean;
+ convertFormat?: string;
+ keepOriginalAfterConvert?: boolean;
+ gpuAcceleration?: boolean;
+ gpuType?: 'auto' | 'nvidia' | 'amd' | 'intel';
+ writeSubtitles?: boolean;
+ subtitleLangs?: string;
+ embedThumbnail?: boolean;
+ embedMetadata?: boolean;
+ sponsorblockRemove?: boolean;
+ playlist?: PlaylistSelection;
+}
+
export interface Settings {
settingsVersion: number;
theme: ThemePreference;
@@ -9,6 +37,7 @@ export interface Settings {
queueCollapsed: boolean;
downloadProfilesEnabled: boolean;
downloadMode: DownloadProfile;
+ downloadPresets: DownloadPreset[];
askDownloadLocation: boolean;
advancedOptions: boolean;
audioOnly: boolean;
@@ -44,10 +73,18 @@ export type DownloadJobPhase = 'download' | 'merge' | 'convert' | 'idle';
export interface JobProgressEvent {
phase: DownloadJobPhase;
phasePercent: number;
+ /** Progress for only the active item, before queue weighting. */
+ itemOverallPercent: number;
+ /** Queue-weighted progress for queue downloads, otherwise itemOverallPercent. */
overallPercent: number;
+ queueItemId?: string;
status: string;
details?: string;
indeterminate?: boolean;
+ downloadedBytes?: number;
+ totalBytes?: number;
+ speedBytesPerSecond?: number;
+ etaSeconds?: number;
}
export type MenuAction = 'check-for-updates' | 'open-settings' | 'show-licenses' | 'toggle-sidebar';
@@ -55,6 +92,7 @@ export type MenuAction = 'check-for-updates' | 'open-settings' | 'show-licenses'
export interface QueueDownloadProgress {
completedItems: number;
queueTotal: number;
+ queueItemId?: string;
}
export type UpdateChannel = 'auto' | 'stable' | 'beta';
@@ -87,30 +125,78 @@ export type DownloadOutcome = 'success' | 'failed' | 'cancelled';
export type DownloadSessionOwner = 'manual' | 'queue';
+export interface DownloadRequestOptions {
+ url: string;
+ outputPath: string;
+ ffmpegPath?: string;
+ convertEnabled?: boolean;
+ convertFormat?: string;
+ keepOriginal?: boolean;
+ videoFormat?: string;
+ /** A selected yt-dlp audio format ID. */
+ audioFormat?: string;
+ playlist?: PlaylistSelection;
+ profileEnabled?: boolean;
+ profile?: DownloadProfile;
+ presetId?: string;
+ presetName?: string;
+ bestQuality?: boolean;
+ advancedOptions?: boolean;
+ audioOnly?: boolean;
+ /** Audio extraction format, separate from the yt-dlp audio format ID above. */
+ audioOutputFormat?: AudioFormat;
+ hookBrowser?: boolean;
+ browserChoice?: string;
+ gpuAcceleration?: boolean;
+ gpuType?: 'auto' | 'nvidia' | 'amd' | 'intel';
+ writeSubtitles?: boolean;
+ subtitleLangs?: string;
+ embedThumbnail?: boolean;
+ embedMetadata?: boolean;
+ sponsorblockRemove?: boolean;
+}
+
+export type QueueRequestOverrides = Partial>;
+
+export interface DownloadCompletion {
+ id: string;
+ sessionId?: number;
+ owner: DownloadSessionOwner;
+ queueItemId?: string;
+ outcome: DownloadOutcome;
+ statusMessage: string;
+ url: string;
+ profile?: DownloadProfile;
+ presetId?: string;
+ presetName?: string;
+ request: DownloadRequestOptions;
+ filename?: string;
+ outputPath?: string;
+ sizeBytes?: number;
+ format?: string;
+ error?: string;
+ startedAt: number;
+ completedAt: number;
+}
+
export interface DownloadSession {
id: number;
+ completionId: string;
+ startedAt: number;
+ request: DownloadRequestOptions;
sender: Electron.WebContents;
owner: DownloadSessionOwner;
lifecycle: DownloadLifecycleState;
ytdlpProcess: import('child_process').ChildProcess | null;
ffmpegProcess: import('child_process').ChildProcess | null;
onComplete?: (statusMessage: string, outcome: DownloadOutcome) => void;
+ onDownloadComplete?: (completion: DownloadCompletion) => void;
queueProgress: QueueDownloadProgress | null;
jobPhase: DownloadJobPhase;
ytdlpPostprocess: boolean;
ytdlpDownloadFinished: boolean;
}
-export interface DownloadRequestOptions {
- url: string;
- outputPath: string;
- ffmpegPath?: string;
- convertFormat?: string;
- keepOriginal?: boolean;
- videoFormat?: string;
- audioFormat?: string;
-}
-
export interface GpuDetectionResult {
nvidia: boolean;
amd: boolean;
@@ -156,10 +242,26 @@ export interface QueueItem {
url: string;
status: 'pending' | 'downloading' | 'completed' | 'failed' | 'cancelled';
addedAt: number;
+ startedAt?: number;
+ completedAt?: number;
+ request?: DownloadRequestOptions;
+ progress?: JobProgressEvent;
filename?: string;
+ outputPath?: string;
+ sizeBytes?: number;
error?: string;
}
+export type QueueReorderDirection = 'up' | 'down';
+
+export interface QueueReorderRequest {
+ id: string;
+ direction: QueueReorderDirection;
+}
+
+/** A persisted download outcome. Structurally identical to a completion event. */
+export type DownloadActivity = DownloadCompletion;
+
export type UpdaterStatusEvent =
| { status: 'checking' }
| {
@@ -192,10 +294,11 @@ export interface RendererApi {
restartApp: () => Promise;
getChannel: () => DistributionChannel;
getFormats: (url: string) => Promise>;
- getVideoInfo: (url: string) => Promise>;
+ getVideoInfo: (url: string, playlistMode?: 'current' | 'all') => Promise>;
cancelVideoInfo: () => void;
selectDownloadLocation: () => Promise;
getSettings: () => Promise;
+ getDefaultSettings: () => Promise>;
saveSettings: (settings: Partial) => Promise>;
resetSettings: () => void;
openExternal: (url: string) => Promise>;
@@ -203,6 +306,7 @@ export interface RendererApi {
cancelDownload: () => void;
cancelFormats: () => void;
getAppVersion: () => Promise;
+ getAppPlatform: () => Promise;
checkDenoInstalled: () => Promise;
installDeno: () => Promise<{
success?: boolean;
@@ -222,16 +326,25 @@ export interface RendererApi {
onJobProgress: (callback: (data: JobProgressEvent) => void) => () => void;
onMenuAction: (callback: (action: MenuAction) => void) => () => void;
onComplete: (callback: (message: string) => void) => () => void;
+ onDownloadComplete: (callback: (completion: DownloadCompletion) => void) => () => void;
openFileLocation: (filePath: string) => Promise>;
showNotification: (options: NotificationRequest) => Promise>;
exportSettings: () => Promise>;
importSettings: () => Promise>;
getStats: () => Promise;
resetStats: () => Promise>;
+ getDownloadActivity: () => Promise>;
+ clearDownloadActivity: () => Promise>;
+ onDownloadActivityUpdate: (callback: (activity: DownloadActivity[]) => void) => () => void;
logError: (message: string) => void;
notifySettingsFlushed: () => void;
- addToQueue: (urls: string[]) => Promise>;
+ addToQueue: (
+ urls: string[],
+ options?: QueueRequestOverrides
+ ) => Promise>;
removeFromQueue: (id: string) => Promise>;
+ retryQueueItem: (id: string) => Promise>;
+ reorderQueueItem: (request: QueueReorderRequest) => Promise>;
clearQueue: () => Promise>;
getQueue: () => Promise;
startQueue: () => Promise>;
diff --git a/src/utils/downloadJobProgress.ts b/src/utils/downloadJobProgress.ts
index bb9d542..8c4a987 100644
--- a/src/utils/downloadJobProgress.ts
+++ b/src/utils/downloadJobProgress.ts
@@ -1,15 +1,6 @@
-export type DownloadJobPhase = 'download' | 'merge' | 'convert' | 'idle';
-
-export interface JobProgressEvent {
- phase: DownloadJobPhase;
- /** 0â100 within the current phase; NaN means indeterminate for that phase */
- phasePercent: number;
- /** 0â100 for the current queue item (or single download) */
- overallPercent: number;
- status: string;
- details?: string;
- indeterminate?: boolean;
-}
+import type { DownloadJobPhase, JobProgressEvent } from '../types';
+
+export type { DownloadJobPhase, JobProgressEvent } from '../types';
export interface ParsedYtdlpProgress {
percent: number;
@@ -26,6 +17,14 @@ export interface JobProgressPlan {
export interface QueueProgressContext {
completedItems: number;
queueTotal: number;
+ queueItemId?: string;
+}
+
+export interface JobProgressMetrics {
+ downloadedBytes?: number;
+ totalBytes?: number;
+ speedBytesPerSecond?: number;
+ etaSeconds?: number;
}
export interface YtdlpProgressJson {
@@ -207,20 +206,29 @@ export function buildJobProgressEvent(
queue: QueueProgressContext | null,
status: string,
details?: string,
- indeterminate?: boolean
+ indeterminate?: boolean,
+ metrics: JobProgressMetrics = {}
): JobProgressEvent {
- const itemOverall = computeItemOverallPercent(phase, phasePercent, plan);
- const overallPercent = applyQueueWeighting(itemOverall, queue);
+ const itemOverallPercent = computeItemOverallPercent(phase, phasePercent, plan);
+ const overallPercent = applyQueueWeighting(itemOverallPercent, queue);
const isIndeterminate =
indeterminate === true || (indeterminate !== false && !Number.isFinite(phasePercent));
+ const nonNegativeMetric = (value: number | undefined): number | undefined =>
+ typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
return {
phase,
phasePercent: Number.isFinite(phasePercent) ? phasePercent : Number.NaN,
+ itemOverallPercent,
overallPercent,
+ queueItemId: queue?.queueItemId,
status,
details,
indeterminate: isIndeterminate,
+ downloadedBytes: nonNegativeMetric(metrics.downloadedBytes),
+ totalBytes: nonNegativeMetric(metrics.totalBytes),
+ speedBytesPerSecond: nonNegativeMetric(metrics.speedBytesPerSecond),
+ etaSeconds: nonNegativeMetric(metrics.etaSeconds),
};
}
diff --git a/src/utils/ipcValidation.ts b/src/utils/ipcValidation.ts
index a396905..d329eb0 100644
--- a/src/utils/ipcValidation.ts
+++ b/src/utils/ipcValidation.ts
@@ -2,11 +2,14 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type {
+ DownloadPreset,
DownloadRequestOptions,
IpcErrorCode,
IpcErrorPayload,
IpcResult,
NotificationRequest,
+ PlaylistSelection,
+ QueueReorderRequest,
Settings,
} from '../types';
import { isSafeExternalUrl, isSafeHttpUrl } from './validation';
@@ -16,12 +19,19 @@ import {
ALLOWED_CONVERT_FORMATS,
CURRENT_SETTINGS_VERSION,
FORMAT_ID_PATTERN,
+ MAX_DOWNLOAD_PRESETS,
+ MAX_PLAYLIST_ITEM_INDEX,
+ MAX_PRESET_ID_LENGTH,
+ MAX_PRESET_NAME_LENGTH,
SUBTITLE_LANGS_PATTERN,
} from '../main/constants';
const ALLOWED_GPU_TYPES = new Set(['auto', 'nvidia', 'amd', 'intel']);
const ALLOWED_UPDATE_CHANNELS = new Set(['auto', 'stable', 'beta']);
const ALLOWED_THEMES = new Set(['system', 'light', 'dark', 'purple']);
+const ALLOWED_DOWNLOAD_PROFILES = new Set(['best-video', 'audio', 'custom']);
+const PRESET_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
+const QUEUE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
type ValidationResult = { ok: true; data: T } | { ok: false; error: IpcErrorPayload };
@@ -57,6 +67,8 @@ function isPathWithinBase(resolvedPath: string, basePath: string): boolean {
}
function isAllowedDownloadBase(resolvedPath: string): boolean {
+ // macOS/Linux: home plus known external mount roots. Windows: any absolute path
+ // outside blocked system directories (broader by design for drive-letter layouts).
const homeDir = os.homedir();
if (homeDir && isPathWithinBase(resolvedPath, homeDir)) {
return true;
@@ -187,6 +199,230 @@ export function validateExternalUrlPayload(value: unknown): ValidationResult {
+ if (!isRecord(value)) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', 'playlist must be an object.'),
+ };
+ }
+ const { mode, start, end } = value;
+ if (mode !== 'current' && mode !== 'all' && mode !== 'range') {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', 'playlist.mode must be current, all, or range.'),
+ };
+ }
+ if (mode !== 'range') {
+ if (start !== undefined || end !== undefined) {
+ return {
+ ok: false,
+ error: buildError(
+ 'VALIDATION_ERROR',
+ 'playlist.start and playlist.end are only valid for range mode.'
+ ),
+ };
+ }
+ return { ok: true, data: { mode } };
+ }
+ if (
+ typeof start !== 'number' ||
+ typeof end !== 'number' ||
+ !Number.isInteger(start) ||
+ !Number.isInteger(end) ||
+ start < 1 ||
+ end < 1 ||
+ start > end ||
+ end > MAX_PLAYLIST_ITEM_INDEX
+ ) {
+ return {
+ ok: false,
+ error: buildError(
+ 'VALIDATION_ERROR',
+ `Playlist range must use 1-based integer bounds with start <= end and end <= ${MAX_PLAYLIST_ITEM_INDEX}.`
+ ),
+ };
+ }
+ return { ok: true, data: { mode, start, end } };
+}
+
+function validatePresetList(value: unknown): ValidationResult {
+ if (!Array.isArray(value) || value.length > MAX_DOWNLOAD_PRESETS) {
+ return {
+ ok: false,
+ error: buildError(
+ 'VALIDATION_ERROR',
+ `downloadPresets must be an array with at most ${MAX_DOWNLOAD_PRESETS} entries.`
+ ),
+ };
+ }
+
+ const presets: DownloadPreset[] = [];
+ const ids = new Set();
+ const names = new Set();
+ for (const rawPreset of value) {
+ if (!isRecord(rawPreset)) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', 'Each download preset must be an object.'),
+ };
+ }
+ const id = isString(rawPreset.id) ? rawPreset.id.trim() : '';
+ const name = isString(rawPreset.name) ? rawPreset.name.trim() : '';
+ const profile = rawPreset.profile;
+ if (!id || id.length > MAX_PRESET_ID_LENGTH || !PRESET_ID_PATTERN.test(id) || ids.has(id)) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', 'Preset IDs must be unique safe identifiers.'),
+ };
+ }
+ const normalizedName = name.toLocaleLowerCase();
+ if (!name || name.length > MAX_PRESET_NAME_LENGTH || names.has(normalizedName)) {
+ return {
+ ok: false,
+ error: buildError(
+ 'VALIDATION_ERROR',
+ `Preset names must be unique and at most ${MAX_PRESET_NAME_LENGTH} characters.`
+ ),
+ };
+ }
+ if (!isString(profile) || !ALLOWED_DOWNLOAD_PROFILES.has(profile)) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', 'Preset profile is invalid.'),
+ };
+ }
+
+ const preset: DownloadPreset = {
+ id,
+ name,
+ profile: profile as DownloadPreset['profile'],
+ };
+ const booleanFields = [
+ 'bestQuality',
+ 'audioOnly',
+ 'convertEnabled',
+ 'keepOriginalAfterConvert',
+ 'gpuAcceleration',
+ 'writeSubtitles',
+ 'embedThumbnail',
+ 'embedMetadata',
+ 'sponsorblockRemove',
+ ] as const;
+ for (const key of booleanFields) {
+ const fieldValue = rawPreset[key];
+ if (fieldValue !== undefined && !isBoolean(fieldValue)) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', `Preset ${key} must be a boolean.`),
+ };
+ }
+ if (typeof fieldValue === 'boolean') preset[key] = fieldValue;
+ }
+
+ if (rawPreset.audioFormat !== undefined) {
+ if (!isString(rawPreset.audioFormat) || !ALLOWED_AUDIO_FORMATS.has(rawPreset.audioFormat)) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', 'Preset audioFormat is invalid.'),
+ };
+ }
+ preset.audioFormat = rawPreset.audioFormat as DownloadPreset['audioFormat'];
+ }
+ for (const key of ['videoFormat', 'audioFormatId'] as const) {
+ const fieldValue = rawPreset[key];
+ if (
+ fieldValue !== undefined &&
+ (!isString(fieldValue) || !FORMAT_ID_PATTERN.test(fieldValue.trim()))
+ ) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', `Preset ${key} is invalid.`),
+ };
+ }
+ if (isString(fieldValue)) preset[key] = fieldValue.trim();
+ }
+ if (rawPreset.convertFormat !== undefined) {
+ if (
+ !isString(rawPreset.convertFormat) ||
+ !ALLOWED_CONVERT_FORMATS.has(rawPreset.convertFormat)
+ ) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', 'Preset convertFormat is invalid.'),
+ };
+ }
+ preset.convertFormat = rawPreset.convertFormat;
+ }
+ if (rawPreset.gpuType !== undefined) {
+ if (!isString(rawPreset.gpuType) || !ALLOWED_GPU_TYPES.has(rawPreset.gpuType)) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', 'Preset gpuType is invalid.'),
+ };
+ }
+ preset.gpuType = rawPreset.gpuType as DownloadPreset['gpuType'];
+ }
+ if (rawPreset.subtitleLangs !== undefined) {
+ const langs = isString(rawPreset.subtitleLangs) ? rawPreset.subtitleLangs.trim() : '';
+ if (!langs || langs.length > 256 || !SUBTITLE_LANGS_PATTERN.test(langs)) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', 'Preset subtitleLangs is invalid.'),
+ };
+ }
+ preset.subtitleLangs = langs;
+ }
+ if (rawPreset.playlist !== undefined) {
+ const playlistValidation = validatePlaylistSelectionPayload(rawPreset.playlist);
+ if (!playlistValidation.ok) return playlistValidation;
+ preset.playlist = playlistValidation.data;
+ }
+
+ ids.add(id);
+ names.add(normalizedName);
+ presets.push(preset);
+ }
+ return { ok: true, data: presets };
+}
+
+export function validateQueueItemIdPayload(value: unknown): ValidationResult {
+ if (!isString(value)) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', 'Queue item ID must be a string.'),
+ };
+ }
+ const id = value.trim();
+ if (!QUEUE_ID_PATTERN.test(id)) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', 'Queue item ID is invalid.'),
+ };
+ }
+ return { ok: true, data: id };
+}
+
+export function validateQueueReorderPayload(value: unknown): ValidationResult {
+ if (!isRecord(value)) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', 'Queue reorder payload must be an object.'),
+ };
+ }
+ const idValidation = validateQueueItemIdPayload(value.id);
+ if (!idValidation.ok) return idValidation;
+ if (value.direction !== 'up' && value.direction !== 'down') {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', 'Queue reorder direction must be up or down.'),
+ };
+ }
+ return { ok: true, data: { id: idValidation.data, direction: value.direction } };
+}
+
export function validateDownloadRequestPayload(
value: unknown
): ValidationResult {
@@ -197,97 +433,197 @@ export function validateDownloadRequestPayload(
};
}
- const { url, outputPath, ffmpegPath, convertFormat, keepOriginal, videoFormat, audioFormat } =
- value;
-
+ const { url, outputPath } = value;
if (!isString(url) || !isSafeHttpUrl(url)) {
return {
ok: false,
error: buildError('INVALID_URL', 'Download URL must be a valid http/https URL.'),
};
}
-
if (!isString(outputPath) || outputPath.trim() === '') {
return {
ok: false,
error: buildError('INVALID_PATH', 'Download outputPath must be a non-empty string path.'),
};
}
-
const outputPathValidation = validateOutputPath(outputPath);
- if (!outputPathValidation.ok) {
- return outputPathValidation;
- }
+ if (!outputPathValidation.ok) return outputPathValidation;
- if (!isOptionalString(ffmpegPath)) {
+ if (!isOptionalString(value.ffmpegPath)) {
return {
ok: false,
error: buildError('VALIDATION_ERROR', 'ffmpegPath must be a string when provided.'),
};
}
- const ffmpegPathValidation = validateFfmpegPathValue(ffmpegPath?.trim() || undefined);
- if (!ffmpegPathValidation.ok) {
- return ffmpegPathValidation;
- }
- if (!isOptionalString(convertFormat)) {
+ const ffmpegPathValidation = validateFfmpegPathValue(value.ffmpegPath?.trim() || undefined);
+ if (!ffmpegPathValidation.ok) return ffmpegPathValidation;
+
+ if (!isOptionalString(value.convertFormat)) {
return {
ok: false,
error: buildError('VALIDATION_ERROR', 'convertFormat must be a string when provided.'),
};
}
- const normalizedConvertFormat = convertFormat?.trim() || undefined;
- if (
- normalizedConvertFormat !== undefined &&
- !ALLOWED_CONVERT_FORMATS.has(normalizedConvertFormat)
- ) {
+ const convertFormat = value.convertFormat?.trim() || undefined;
+ if (convertFormat !== undefined && !ALLOWED_CONVERT_FORMATS.has(convertFormat)) {
return {
ok: false,
error: buildError('VALIDATION_ERROR', 'convertFormat must be one of: mp4, mov, mp3, m4a.'),
};
}
- if (keepOriginal !== undefined && !isBoolean(keepOriginal)) {
+
+ for (const key of ['videoFormat', 'audioFormat'] as const) {
+ const fieldValue = value[key];
+ if (!isOptionalString(fieldValue)) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', `${key} must be a string when provided.`),
+ };
+ }
+ if (fieldValue !== undefined && !FORMAT_ID_PATTERN.test(fieldValue.trim())) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', `${key} must be a valid yt-dlp format ID.`),
+ };
+ }
+ }
+
+ const booleanFields = [
+ 'convertEnabled',
+ 'keepOriginal',
+ 'profileEnabled',
+ 'bestQuality',
+ 'advancedOptions',
+ 'audioOnly',
+ 'hookBrowser',
+ 'gpuAcceleration',
+ 'writeSubtitles',
+ 'embedThumbnail',
+ 'embedMetadata',
+ 'sponsorblockRemove',
+ ] as const;
+ for (const key of booleanFields) {
+ if (value[key] !== undefined && !isBoolean(value[key])) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', `${key} must be a boolean when provided.`),
+ };
+ }
+ }
+
+ if (
+ value.profile !== undefined &&
+ (!isString(value.profile) || !ALLOWED_DOWNLOAD_PROFILES.has(value.profile))
+ ) {
return {
ok: false,
- error: buildError('VALIDATION_ERROR', 'keepOriginal must be a boolean when provided.'),
+ error: buildError('VALIDATION_ERROR', 'profile must be best-video, audio, or custom.'),
};
}
- if (!isOptionalString(videoFormat)) {
+ if (
+ value.audioOutputFormat !== undefined &&
+ (!isString(value.audioOutputFormat) || !ALLOWED_AUDIO_FORMATS.has(value.audioOutputFormat))
+ ) {
return {
ok: false,
- error: buildError('VALIDATION_ERROR', 'videoFormat must be a string when provided.'),
+ error: buildError('VALIDATION_ERROR', 'audioOutputFormat is invalid.'),
};
}
- if (videoFormat !== undefined && !FORMAT_ID_PATTERN.test(videoFormat.trim())) {
+ if (
+ value.gpuType !== undefined &&
+ (!isString(value.gpuType) || !ALLOWED_GPU_TYPES.has(value.gpuType))
+ ) {
return {
ok: false,
- error: buildError('VALIDATION_ERROR', 'videoFormat must be a valid yt-dlp format ID.'),
+ error: buildError('VALIDATION_ERROR', 'gpuType must be auto, nvidia, amd, or intel.'),
};
}
- if (!isOptionalString(audioFormat)) {
+ if (value.browserChoice !== undefined) {
+ if (
+ !isString(value.browserChoice) ||
+ !ALLOWED_BROWSERS.has(value.browserChoice.toLowerCase())
+ ) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', 'browserChoice is not an allowed browser.'),
+ };
+ }
+ }
+ if (value.subtitleLangs !== undefined) {
+ const langs = isString(value.subtitleLangs) ? value.subtitleLangs.trim() : '';
+ if (!langs || langs.length > 256 || !SUBTITLE_LANGS_PATTERN.test(langs)) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', 'subtitleLangs is invalid.'),
+ };
+ }
+ }
+ for (const key of ['presetId', 'presetName'] as const) {
+ if (value[key] !== undefined && !isString(value[key])) {
+ return {
+ ok: false,
+ error: buildError('VALIDATION_ERROR', `${key} must be a string when provided.`),
+ };
+ }
+ }
+ const presetId = isString(value.presetId) ? value.presetId.trim() : undefined;
+ if (
+ presetId !== undefined &&
+ (!presetId || presetId.length > MAX_PRESET_ID_LENGTH || !PRESET_ID_PATTERN.test(presetId))
+ ) {
return {
ok: false,
- error: buildError('VALIDATION_ERROR', 'audioFormat must be a string when provided.'),
+ error: buildError('VALIDATION_ERROR', 'presetId must be a safe preset identifier.'),
};
}
- if (audioFormat !== undefined && !FORMAT_ID_PATTERN.test(audioFormat.trim())) {
+ const presetName = isString(value.presetName) ? value.presetName.trim() : undefined;
+ if (presetName !== undefined && (!presetName || presetName.length > MAX_PRESET_NAME_LENGTH)) {
return {
ok: false,
- error: buildError('VALIDATION_ERROR', 'audioFormat must be a valid yt-dlp format ID.'),
+ error: buildError(
+ 'VALIDATION_ERROR',
+ `presetName must be at most ${MAX_PRESET_NAME_LENGTH} characters.`
+ ),
};
}
- return {
- ok: true,
- data: {
- url: url.trim(),
- outputPath: outputPathValidation.data,
- ffmpegPath: ffmpegPathValidation.data,
- convertFormat: normalizedConvertFormat,
- keepOriginal,
- videoFormat: videoFormat?.trim() || undefined,
- audioFormat: audioFormat?.trim() || undefined,
- },
+ let playlist: PlaylistSelection | undefined;
+ if (value.playlist !== undefined) {
+ const playlistValidation = validatePlaylistSelectionPayload(value.playlist);
+ if (!playlistValidation.ok) return playlistValidation;
+ playlist = playlistValidation.data;
+ }
+
+ const data: DownloadRequestOptions = {
+ url: url.trim(),
+ outputPath: outputPathValidation.data,
+ ffmpegPath: ffmpegPathValidation.data,
+ convertFormat,
+ videoFormat: isString(value.videoFormat) ? value.videoFormat.trim() : undefined,
+ audioFormat: isString(value.audioFormat) ? value.audioFormat.trim() : undefined,
+ playlist,
+ profile: isString(value.profile)
+ ? (value.profile as DownloadRequestOptions['profile'])
+ : undefined,
+ presetId,
+ presetName,
+ audioOutputFormat: isString(value.audioOutputFormat)
+ ? (value.audioOutputFormat as DownloadRequestOptions['audioOutputFormat'])
+ : undefined,
+ browserChoice: isString(value.browserChoice)
+ ? value.browserChoice.trim().toLowerCase()
+ : undefined,
+ gpuType: isString(value.gpuType)
+ ? (value.gpuType as DownloadRequestOptions['gpuType'])
+ : undefined,
+ subtitleLangs: isString(value.subtitleLangs) ? value.subtitleLangs.trim() : undefined,
};
+ for (const key of booleanFields) {
+ if (typeof value[key] === 'boolean') {
+ (data as unknown as Record)[key] = value[key];
+ }
+ }
+ return { ok: true, data };
}
function isValidSettingsKey(key: string): key is keyof Settings {
@@ -299,6 +635,7 @@ function isValidSettingsKey(key: string): key is keyof Settings {
key === 'queueCollapsed' ||
key === 'downloadProfilesEnabled' ||
key === 'downloadMode' ||
+ key === 'downloadPresets' ||
key === 'askDownloadLocation' ||
key === 'advancedOptions' ||
key === 'audioOnly' ||
@@ -361,6 +698,13 @@ export function validateSettingsPatchPayload(value: unknown): ValidationResult