diff --git a/CHANGELOG.md b/CHANGELOG.md index 11cfdca..e8c0146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,9 @@ | Windows | macOS | Linux | | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **EXE:** [x64](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.1/ROSI-Windows-x64.exe) / [arm64](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.1/ROSI-Windows-arm64.exe) | **[Universal DMG](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.1/ROSI-MacOS-universal.dmg)** | **AppImage:** [x64](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.1/ROSI-Linux-x86_64.AppImage) / [arm64](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.1/ROSI-Linux-arm64.AppImage) | -|
| **[Universal ZIP](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.1/ROSI-MacOS-universal.zip)** | **DEB:** [x64](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.1/ROSI-Linux-amd64.deb) / [arm64](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.1/ROSI-Linux-arm64.deb) | -| | | **RPM:** [x64](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.1/ROSI-Linux-x86_64.rpm) / [arm64](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.1/ROSI-Linux-aarch64.rpm) | +| **EXE:** [x64](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.2/ROSI-Windows-x64.exe) / [arm64](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.2/ROSI-Windows-arm64.exe) | **[Universal DMG](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.2/ROSI-MacOS-universal.dmg)** | **AppImage:** [x64](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.2/ROSI-Linux-x86_64.AppImage) / [arm64](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.2/ROSI-Linux-arm64.AppImage) | +|
| **[Universal ZIP](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.2/ROSI-MacOS-universal.zip)** | **DEB:** [x64](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.2/ROSI-Linux-amd64.deb) / [arm64](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.2/ROSI-Linux-arm64.deb) | +| | | **RPM:** [x64](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.2/ROSI-Linux-x86_64.rpm) / [arm64](https://github.com/BurntToasters/ROSI/releases/download/v4.3.0-beta.2/ROSI-Linux-aarch64.rpm) | > [!IMPORTANT] > The `.sig` files in this repo are NOT normal GPG signatures — they are for ROSI's built-in updater to verify the integrity of updates before downloading and installing. @@ -18,6 +18,28 @@ --- +## Changes in `v4.3.0-beta.2:` + +- **NEW - Queue recovery:** Failed and cancelled queue items now show the reason inline with **Retry** / **Requeue** and **Copy details** actions, so a single bad link no longer forces you to rebuild the queue. +- **NEW - Per-item queue progress:** The active queue row shows its own phase, percentage, speed, and ETA. The main bar still reports overall queue progress. +- **NEW - Queue reordering:** Pending items can be moved with **Up** / **Down** buttons or `Alt`+`ArrowUp` / `Alt`+`ArrowDown`. Active and finished items stay put. +- **NEW - Smart multi-link intake:** Pasting or dropping several links is detected everywhere. The main button switches to **Add N to Queue**, and links are deduplicated with a summary of what was added, skipped, or ignored. +- **NEW - Playlist scope:** When a preview detects a playlist you can download the current video, the entire playlist, or a numbered range. +- **NEW - Saved presets:** Name and reuse a download setup (profile, conversion, GPU, subtitles, metadata, SponsorBlock, playlist scope) and apply it to direct downloads or queued items. Up to 20 presets. +- **NEW - Activity center:** Recent Downloads is now **Activity**, stored by the app rather than the browser cache. Rows show source, profile, size, and failure reason, with **Download again**, **Copy source**, **Open folder**, status filters, and a real empty state. +- **NEW - Automatic previews:** Metadata loads on its own shortly after a valid link is entered, with results cached briefly. The button becomes **Refresh**, or **Retry preview** if lookup fails. +- **NEW - Searchable settings:** The sidebar has a search field that filters settings and expands matching sections, plus a **Reset section** action for each group. +- **Setup:** First-run setup now covers the download folder, "ask every time", and a starting profile, so the first download no longer stops to ask. +- **Queue:** With **Ask every time** enabled, adding links now prompts once for the whole batch instead of using the saved folder, and nothing is queued if you dismiss the picker. +- **Preview:** Playlist previews read up to 500 entries and no longer report the listing limit as the playlist length when the real total is unavailable. +- **Settings:** Settings schema migrated to version `7` for saved presets. +- **Splash:** Loading screen now follows your saved theme tokens, improved contrast on status text, and shows the live app version at runtime. +- **UI / a11y:** Toast dismiss buttons, wizard toggles, and modal placeholders were polished for keyboard focus and cleaner first paint. +- **Queue:** Status indicators use theme-colored dots instead of emoji for clearer light/dark/purple rendering. +- **Linux:** Taskbar / Dock progress setting is hidden with a note that the feature is Windows and macOS only. +- **Lifecycle:** Quitting on Windows and Linux now stops active downloads and the queue the same way as closing the window on macOS. +- **Testing:** Added coverage for queue retry, diagnostics, reordering, per-item progress updates, playlist arguments, saved presets, and the activity list. + ## Changes in `v4.3.0-beta.1:` - **macOS:** Added a native application menu (About, Settings, Check for Updates, Edit, View, Window, Help) with actions wired into the existing UI. diff --git a/com.burnttoasters.rosi.metainfo.xml b/com.burnttoasters.rosi.metainfo.xml index de3e019..c68e9ca 100644 --- a/com.burnttoasters.rosi.metainfo.xml +++ b/com.burnttoasters.rosi.metainfo.xml @@ -34,6 +34,7 @@ + diff --git a/package-lock.json b/package-lock.json index c26cee5..bd0fc46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rosi", - "version": "4.3.0-beta.1", + "version": "4.3.0-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rosi", - "version": "4.3.0-beta.1", + "version": "4.3.0-beta.2", "license": "MPL-2.0", "dependencies": { "electron-log": "^5.3.4", diff --git a/package.json b/package.json index d60a99b..2f8f5df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rosi", - "version": "4.3.0-beta.1", + "version": "4.3.0-beta.2", "private": true, "description": "Electron GUI for yt-dlp", "desktopName": "com.burnttoasters.rosi.desktop", diff --git a/src/main/constants.ts b/src/main/constants.ts index ddff654..b2a31de 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -25,6 +25,11 @@ export const ALLOWED_BROWSERS = new Set([ 'whale', ]); export const MAX_QUEUE_SIZE = 500; +export const MAX_DOWNLOAD_PRESETS = 20; +export const MAX_PRESET_NAME_LENGTH = 40; +export const MAX_PRESET_ID_LENGTH = 64; +export const MAX_PLAYLIST_ITEM_INDEX = 10_000; +export const MAX_DOWNLOAD_ACTIVITY = 100; export const MAX_FORMAT_COUNTS = 10_000; export const MAX_SETTINGS_IMPORT_BYTES = 1_048_576; -export const CURRENT_SETTINGS_VERSION = 6; +export const CURRENT_SETTINGS_VERSION = 7; diff --git a/src/main/download/commandBuilders.ts b/src/main/download/commandBuilders.ts index 6b8c9bc..14e315d 100644 --- a/src/main/download/commandBuilders.ts +++ b/src/main/download/commandBuilders.ts @@ -7,6 +7,7 @@ import { ALLOWED_BROWSERS, FORMAT_ID_PATTERN, MAX_ERROR_BUFFER, + MAX_PLAYLIST_ITEM_INDEX, SUBTITLE_LANGS_PATTERN, } from '../constants'; import { parseFfmpegDurationFromProbe } from '../../utils/downloadJobProgress'; @@ -172,6 +173,27 @@ function insertBeforeUrlSeparator(args: string[], ...items: string[]): void { args.splice(separatorIndex, 0, ...items); } +function buildPlaylistArgs(options: DownloadRequestOptions): string[] { + const selection = options.playlist; + if (!selection || selection.mode === 'current') { + return ['--no-playlist']; + } + if (selection.mode === 'all') { + return ['--yes-playlist']; + } + const { start, end } = selection; + if ( + Number.isInteger(start) && + Number.isInteger(end) && + (start as number) >= 1 && + (end as number) >= (start as number) && + (end as number) <= MAX_PLAYLIST_ITEM_INDEX + ) { + return ['--yes-playlist', '--playlist-items', `${start as number}-${end as number}`]; + } + return ['--no-playlist']; +} + export function buildYtdlpArgs({ normalizedDownloadDir, url, @@ -180,10 +202,29 @@ export function buildYtdlpArgs({ ffmpegLocation, pathOutputFile, }: BuildYtdlpArgsInput): BuildYtdlpArgsResult { + const configuredProfile = settings.downloadProfilesEnabled ? settings.downloadMode : null; + const requestProfile = options.profileEnabled === false ? null : options.profile; + const profile = requestProfile ?? configuredProfile; + let bestQuality = settings.bestQuality; + let audioOnly = settings.audioOnly; + + if (profile === 'best-video') { + bestQuality = true; + audioOnly = false; + } else if (profile === 'audio') { + bestQuality = false; + audioOnly = true; + } else if (profile === 'custom') { + bestQuality = false; + audioOnly = false; + } + if (typeof options.bestQuality === 'boolean') bestQuality = options.bestQuality; + if (typeof options.audioOnly === 'boolean') audioOnly = options.audioOnly; + const args = [ '-P', normalizedDownloadDir, - '--no-playlist', + ...buildPlaylistArgs(options), '--print', 'after_move:filepath', '--newline', @@ -195,7 +236,7 @@ export function buildYtdlpArgs({ '--progress-template', 'postprocess:%(progress)j', '-f', - settings.bestQuality ? 'bestvideo+bestaudio/best' : 'best[ext=mp4]/best[ext=webm]/best', + bestQuality ? 'bestvideo+bestaudio/best' : 'best[ext=mp4]/best[ext=webm]/best', '--', url, ]; @@ -225,41 +266,44 @@ export function buildYtdlpArgs({ statusMessages.push(`đŸŽĩ Using audio format: ${audioFmt}`); } - if (settings.audioOnly && !validVideo && !validAudio) { + if (audioOnly && !validVideo && !validAudio) { args.splice(formatFlagIndex, 2); - const audioOutputFmt = ALLOWED_AUDIO_FORMATS.has(settings.audioFormat) - ? settings.audioFormat + const requestedAudioFormat = options.audioOutputFormat ?? settings.audioFormat; + const audioOutputFmt = ALLOWED_AUDIO_FORMATS.has(requestedAudioFormat) + ? requestedAudioFormat : 'mp3'; insertBeforeUrlSeparator(args, '-x', '--audio-format', audioOutputFmt, '--audio-quality', '0'); statusMessages.push(`đŸŽĩ Audio-only mode enabled (${audioOutputFmt.toUpperCase()})`); } - if (settings.hookBrowser && settings.browserChoice) { - const normalized = settings.browserChoice.toLowerCase(); + const hookBrowser = options.hookBrowser ?? settings.hookBrowser; + const browserChoice = options.browserChoice ?? settings.browserChoice; + if (hookBrowser && browserChoice) { + const normalized = browserChoice.toLowerCase(); if (ALLOWED_BROWSERS.has(normalized)) { insertBeforeUrlSeparator(args, '--cookies-from-browser', normalized); } } - if (settings.writeSubtitles) { - const langs = SUBTITLE_LANGS_PATTERN.test(settings.subtitleLangs) - ? settings.subtitleLangs - : 'en'; + const writeSubtitles = options.writeSubtitles ?? settings.writeSubtitles; + if (writeSubtitles) { + const requestedLangs = options.subtitleLangs ?? settings.subtitleLangs; + const langs = SUBTITLE_LANGS_PATTERN.test(requestedLangs) ? requestedLangs : 'en'; insertBeforeUrlSeparator(args, '--write-subs', '--embed-subs', '--sub-langs', langs); statusMessages.push(`đŸ’Ŧ Subtitles enabled (${langs})`); } - if (settings.embedThumbnail) { + if (options.embedThumbnail ?? settings.embedThumbnail) { insertBeforeUrlSeparator(args, '--embed-thumbnail'); statusMessages.push('đŸ–ŧī¸ Embedding thumbnail'); } - if (settings.embedMetadata) { + if (options.embedMetadata ?? settings.embedMetadata) { insertBeforeUrlSeparator(args, '--embed-metadata'); statusMessages.push('đŸˇī¸ Embedding metadata'); } - if (settings.sponsorblockRemove) { + if (options.sponsorblockRemove ?? settings.sponsorblockRemove) { insertBeforeUrlSeparator(args, '--sponsorblock-remove', 'default'); statusMessages.push('â­ī¸ SponsorBlock: removing segments'); } diff --git a/src/main/download/jobProgressReporter.ts b/src/main/download/jobProgressReporter.ts index b42551b..b23f3b7 100644 --- a/src/main/download/jobProgressReporter.ts +++ b/src/main/download/jobProgressReporter.ts @@ -47,7 +47,9 @@ export class JobProgressReporter { this.emit(session, { phase: 'idle', phasePercent: 0, + itemOverallPercent: 0, overallPercent: 0, + queueItemId: this.queue?.queueItemId, status: 'Ready', indeterminate: false, }); @@ -83,7 +85,13 @@ export class JobProgressReporter { this.queue, label, details, - !Number.isFinite(phasePercent) + !Number.isFinite(phasePercent), + { + downloadedBytes: json.downloaded_bytes, + totalBytes: json.total_bytes ?? json.total_bytes_estimate, + speedBytesPerSecond: json.speed, + etaSeconds: json.eta, + } ) ); return true; diff --git a/src/main/download/videoInfo.ts b/src/main/download/videoInfo.ts index 793a214..93c881e 100644 --- a/src/main/download/videoInfo.ts +++ b/src/main/download/videoInfo.ts @@ -20,7 +20,16 @@ function pickThumbnail(value: unknown): string | null { return typeof value === 'string' && isSafeHttpUrl(value) ? value : null; } -export function parseVideoInfo(jsonString: string): VideoInfo | null { +/** + * How many flat playlist entries a preview will pull. Entries are small, but + * the listing still has to finish quickly, so it stays bounded. + */ +export const PLAYLIST_PREVIEW_ENTRY_LIMIT = 500; + +export function parseVideoInfo( + jsonString: string, + entryLimit = PLAYLIST_PREVIEW_ENTRY_LIMIT +): VideoInfo | null { let parsed: unknown; try { parsed = JSON.parse(jsonString); @@ -52,6 +61,13 @@ export function parseVideoInfo(jsonString: string): VideoInfo | null { const title = pickString(data.title) ?? pickString(data.fulltitle) ?? (isPlaylist ? 'Playlist' : 'Untitled'); + // Prefer the extractor's own total. Only fall back to counting entries when + // the listing was not truncated, otherwise the limit would be reported as if + // it were the real playlist length. + const reportedCount = pickNumber(data.playlist_count); + const entriesWereTruncated = entries !== null && entries.length >= entryLimit; + const playlistCount = reportedCount ?? (entries && !entriesWereTruncated ? entries.length : null); + return { title, uploader: pickString(data.uploader) ?? pickString(data.channel) ?? pickString(data.creator), @@ -60,7 +76,7 @@ export function parseVideoInfo(jsonString: string): VideoInfo | null { ext: pickString(data.ext), viewCount: pickNumber(data.view_count), isPlaylist, - playlistCount: pickNumber(data.playlist_count) ?? (entries ? entries.length : null), + playlistCount, webpageUrl: pickThumbnail(data.webpage_url), }; } @@ -72,7 +88,26 @@ export function cancelVideoInfo(): void { } } -export function fetchVideoInfo(ytdlpPath: string, url: string): Promise { +function resolveVideoInfoPlaylistMode( + url: string, + requestedMode?: 'current' | 'all' +): 'current' | 'all' { + if (requestedMode) return requestedMode; + try { + const parsed = new URL(url); + return parsed.searchParams.has('list') || /\/playlist(?:\/|$)/i.test(parsed.pathname) + ? 'all' + : 'current'; + } catch { + return 'current'; + } +} + +export function fetchVideoInfo( + ytdlpPath: string, + url: string, + playlistMode?: 'current' | 'all' +): Promise { if (!isSafeHttpUrl(url)) { return Promise.reject('Invalid URL provided'); } @@ -89,9 +124,19 @@ export function fetchVideoInfo(ytdlpPath: string, url: string): Promise candidate.id === options.presetId) + : undefined; + if (!preset) return options; + const definedOptions = Object.fromEntries( + Object.entries(options).filter(([, value]) => value !== undefined) + ) as DownloadRequestOptions; + return { + ...downloadPresetToRequestOptions(preset), + ...definedOptions, + presetName: options.presetName ?? preset.name, + } as DownloadRequestOptions; +} + +function applyRequestToSettings(settings: Settings, options: DownloadRequestOptions): Settings { + const effective: Settings = { + ...settings, + downloadPresets: Array.isArray(settings.downloadPresets) + ? settings.downloadPresets.map((preset) => ({ + ...preset, + playlist: preset.playlist ? { ...preset.playlist } : undefined, + })) + : [], + }; + + if (typeof options.profileEnabled === 'boolean') { + effective.downloadProfilesEnabled = options.profileEnabled; + } + if (options.profile) { + effective.downloadMode = options.profile; + if (options.profileEnabled === undefined) effective.downloadProfilesEnabled = true; + } + if (effective.downloadProfilesEnabled) { + effective.advancedOptions = effective.downloadMode === 'custom'; + effective.audioOnly = effective.downloadMode === 'audio'; + effective.bestQuality = effective.downloadMode === 'best-video'; + } else if (options.profileEnabled === false) { + effective.advancedOptions = false; + effective.audioOnly = false; + effective.bestQuality = false; + } + + if (typeof options.advancedOptions === 'boolean') { + effective.advancedOptions = options.advancedOptions; + } + if (typeof options.audioOnly === 'boolean') effective.audioOnly = options.audioOnly; + if (typeof options.bestQuality === 'boolean') effective.bestQuality = options.bestQuality; + if (options.audioOutputFormat) effective.audioFormat = options.audioOutputFormat; + + if (typeof options.convertEnabled === 'boolean') { + effective.convertEnabled = options.convertEnabled; + } + if (options.convertFormat !== undefined) { + if (options.convertFormat.trim() !== '') { + effective.convertFormat = options.convertFormat; + if (options.convertEnabled === undefined) effective.convertEnabled = true; + } else if (options.convertEnabled === undefined) { + effective.convertEnabled = false; + } + } + if (typeof options.keepOriginal === 'boolean') { + effective.keepOriginalAfterConvert = options.keepOriginal; + } + + if (typeof options.hookBrowser === 'boolean') effective.hookBrowser = options.hookBrowser; + if (options.browserChoice) effective.browserChoice = options.browserChoice; + if (typeof options.gpuAcceleration === 'boolean') { + effective.gpuAcceleration = options.gpuAcceleration; + } + if (options.gpuType) effective.gpuType = options.gpuType; + if (typeof options.writeSubtitles === 'boolean') { + effective.writeSubtitles = options.writeSubtitles; + } + if (options.subtitleLangs) effective.subtitleLangs = options.subtitleLangs; + if (typeof options.embedThumbnail === 'boolean') { + effective.embedThumbnail = options.embedThumbnail; + } + if (typeof options.embedMetadata === 'boolean') { + effective.embedMetadata = options.embedMetadata; + } + if (typeof options.sponsorblockRemove === 'boolean') { + effective.sponsorblockRemove = options.sponsorblockRemove; + } + if (options.ffmpegPath !== undefined) effective.ffmpegPath = options.ffmpegPath; + return effective; +} + +function buildResolvedRequestSnapshot( + options: DownloadRequestOptions, + effectiveSettings: Settings +): DownloadRequestOptions { + return { + url: options.url.trim(), + outputPath: options.outputPath, + ffmpegPath: options.ffmpegPath || effectiveSettings.ffmpegPath || undefined, + convertEnabled: effectiveSettings.convertEnabled, + convertFormat: effectiveSettings.convertFormat, + keepOriginal: effectiveSettings.keepOriginalAfterConvert, + videoFormat: options.videoFormat, + audioFormat: options.audioFormat, + playlist: options.playlist ? { ...options.playlist } : { mode: 'current' }, + profileEnabled: effectiveSettings.downloadProfilesEnabled, + profile: effectiveSettings.downloadMode, + presetId: options.presetId, + presetName: options.presetName, + bestQuality: effectiveSettings.bestQuality, + advancedOptions: effectiveSettings.advancedOptions, + audioOnly: effectiveSettings.audioOnly, + audioOutputFormat: effectiveSettings.audioFormat, + hookBrowser: effectiveSettings.hookBrowser, + browserChoice: effectiveSettings.browserChoice, + gpuAcceleration: effectiveSettings.gpuAcceleration, + gpuType: effectiveSettings.gpuType, + writeSubtitles: effectiveSettings.writeSubtitles, + subtitleLangs: effectiveSettings.subtitleLangs, + embedThumbnail: effectiveSettings.embedThumbnail, + embedMetadata: effectiveSettings.embedMetadata, + sponsorblockRemove: effectiveSettings.sponsorblockRemove, + }; +} + interface CompletionMeta { format?: string; bytes?: number; + filePath?: string; + error?: string; progressMessage?: string | null; + emitLegacyComplete?: boolean; } function statFileSize(filePath: string): number | undefined { try { const stat = fs.statSync(filePath); - return stat.isFile() && stat.size > 0 ? stat.size : undefined; + return stat.isFile() && stat.size >= 0 ? stat.size : undefined; } catch { return undefined; } @@ -146,21 +278,65 @@ function completeSession( if (!session || !isActiveSession(session)) return; if (!shouldEmitTerminalEvent(session.lifecycle)) return; session.lifecycle = markTerminalEventEmitted(session.lifecycle); + + const completedAt = Date.now(); + const finalPath = meta.filePath ? path.resolve(meta.filePath) : undefined; + const sizeBytes = meta.bytes ?? (finalPath ? statFileSize(finalPath) : undefined); + const completion: DownloadCompletion = { + id: session.completionId, + sessionId: session.id, + owner: session.owner, + queueItemId: session.queueProgress?.queueItemId, + outcome, + statusMessage, + url: session.request.url, + profile: session.request.profile, + presetId: session.request.presetId, + presetName: session.request.presetName, + request: { + ...session.request, + playlist: session.request.playlist ? { ...session.request.playlist } : undefined, + }, + filename: finalPath ? path.basename(finalPath) : undefined, + outputPath: finalPath, + sizeBytes, + format: meta.format, + error: outcome === 'failed' ? (meta.error ?? statusMessage) : undefined, + startedAt: session.startedAt, + completedAt, + }; + if (meta.progressMessage) { safeSend(session.sender, 'progress', meta.progressMessage); } - safeSend(session.sender, 'complete', statusMessage); + if (meta.emitLegacyComplete !== false) { + safeSend(session.sender, 'complete', statusMessage); + } + safeSend(session.sender, 'download-complete', completion); + + if (typeof session.onDownloadComplete === 'function') { + try { + session.onDownloadComplete(completion); + } catch (error) { + log.error('Error in structured download completion callback:', error); + } + } if (typeof session.onComplete === 'function') { try { session.onComplete(statusMessage, outcome); } catch (error) { - log.error('Error in download completion callback:', error); + log.error( + meta.emitLegacyComplete === false && outcome === 'cancelled' + ? 'Error in download cancellation callback:' + : 'Error in download completion callback:', + error + ); } } if (outcome === 'success') { - recordDownload('success', meta.format, meta.bytes); + recordDownload('success', meta.format, sizeBytes); } else { recordDownload(outcome); } @@ -189,29 +365,11 @@ export function cancelActiveSession(notify = true) { if (!isActiveSession(session)) return; - if (notify || session.owner === 'manual') { - completeSession(session, 'âšī¸ Cancelled.', 'cancelled', { - progressMessage: 'âšī¸ Download/Conversion cancelled by user.', - }); - return; - } - - if (typeof session.onComplete === 'function') { - try { - session.onComplete('âšī¸ Cancelled.', 'cancelled'); - } catch (error) { - log.error('Error in download cancellation callback:', error); - } - } - - recordDownload('cancelled'); - session.lifecycle = markTerminalEventEmitted(session.lifecycle); - activeDownloadSession = null; - downloadSessionOwner = null; - if (activeProgressReporter) { - activeProgressReporter.clearTaskbar(); - activeProgressReporter = null; - } + completeSession(session, 'âšī¸ Cancelled.', 'cancelled', { + progressMessage: + notify || session.owner === 'manual' ? 'âšī¸ Download/Conversion cancelled by user.' : null, + emitLegacyComplete: notify || session.owner === 'manual', + }); } export function killAllProcesses() { @@ -222,23 +380,9 @@ export function killAllProcesses() { killProcess(session.ffmpegProcess, 'ffmpeg'); session.ytdlpProcess = null; session.ffmpegProcess = null; - - if (typeof session.onComplete === 'function') { - try { - session.onComplete('âšī¸ Cancelled.', 'cancelled'); - } catch (error) { - log.error('Error in download cancellation callback:', error); - } - } - - recordDownload('cancelled'); - session.lifecycle = markTerminalEventEmitted(session.lifecycle); - activeDownloadSession = null; - downloadSessionOwner = null; - if (activeProgressReporter) { - activeProgressReporter.clearTaskbar(); - activeProgressReporter = null; - } + completeSession(session, 'âšī¸ Cancelled.', 'cancelled', { + emitLegacyComplete: false, + }); } export function fetchFormats(ytdlpPath: string, url: string): Promise { @@ -366,6 +510,7 @@ async function runConversion( completeSession(session, `✅ Done (Already ${targetFormat.toUpperCase()}).`, 'success', { format: targetFormat, bytes: statFileSize(inputPath), + filePath: inputPath, }); return; } @@ -464,6 +609,7 @@ async function runConversion( completeSession(session, 'đŸŽŦ Conversion complete.', 'success', { format: targetFormat, bytes: statFileSize(outputPath), + filePath: outputPath, }); } else { sendProgress( @@ -519,9 +665,10 @@ export function startDownload( sender: Electron.WebContents, options: DownloadRequestOptions, mainWindow: Electron.BrowserWindow | null, - onComplete?: (statusMessage: string, outcome?: DownloadOutcome) => void, + onComplete?: (statusMessage: string, outcome: DownloadOutcome) => void, owner: DownloadSessionOwner = 'manual', - queueProgress: QueueDownloadProgress | null = null + queueProgress: QueueDownloadProgress | null = null, + onDownloadComplete?: (completion: DownloadCompletion) => void ) { if (activeDownloadSession && downloadSessionOwner !== owner) { throw new Error('Download session already active with a different owner.'); @@ -530,15 +677,24 @@ export function startDownload( cancelActiveSession(false); } + const settings = loadSettings(); + const requestOptions = resolvePresetRequestOptions(settings, options); + const effectiveSettings = applyRequestToSettings(settings, requestOptions); + const requestSnapshot = buildResolvedRequestSnapshot(requestOptions, effectiveSettings); + downloadSessionCounter += 1; const session: DownloadSession = { id: downloadSessionCounter, + completionId: randomUUID(), + startedAt: Date.now(), + request: requestSnapshot, sender, owner, lifecycle: createDownloadLifecycleState(), ytdlpProcess: null, ffmpegProcess: null, onComplete, + onDownloadComplete, queueProgress, jobPhase: 'download', ytdlpPostprocess: false, @@ -547,25 +703,11 @@ export function startDownload( activeDownloadSession = session; downloadSessionOwner = owner; - const settings = loadSettings(); - const effectiveSettings: Settings = { ...settings }; - const ffmpegCommand = getEffectiveFfmpegPath(options.ffmpegPath || settings.ffmpegPath); - const ffmpegLocation = resolveFfmpegLocationForYtdlp(options.ffmpegPath || settings.ffmpegPath); - - if (options.convertFormat !== undefined) { - if (typeof options.convertFormat === 'string' && options.convertFormat.trim() !== '') { - effectiveSettings.convertFormat = options.convertFormat; - effectiveSettings.convertEnabled = true; - } else { - effectiveSettings.convertEnabled = false; - } - } - if (typeof options.keepOriginal === 'boolean') { - effectiveSettings.keepOriginalAfterConvert = options.keepOriginal; - } - - const url = options.url; - const downloadDir = options.outputPath; + const requestedFfmpegPath = requestOptions.ffmpegPath || settings.ffmpegPath; + const ffmpegCommand = getEffectiveFfmpegPath(requestedFfmpegPath); + const ffmpegLocation = resolveFfmpegLocationForYtdlp(requestedFfmpegPath); + const url = requestOptions.url; + const downloadDir = requestOptions.outputPath; if (!isSafeHttpUrl(url)) { sendProgress(session, 'âš ī¸ Invalid or missing URL.'); @@ -585,6 +727,7 @@ export function startDownload( try { const normalizedDownloadDir = path.resolve(downloadDir); + session.request.outputPath = normalizedDownloadDir; if (!fs.existsSync(normalizedDownloadDir)) { sendProgress(session, `📂 Creating directory: ${normalizedDownloadDir}`); fs.mkdirSync(normalizedDownloadDir, { recursive: true }); @@ -611,7 +754,7 @@ export function startDownload( normalizedDownloadDir, url, settings: effectiveSettings, - options, + options: requestOptions, ffmpegLocation, pathOutputFile, }); @@ -771,6 +914,7 @@ export function startDownload( completeSession(session, '✅ Download complete (no conversion).', 'success', { format: ext, bytes: statFileSize(downloadedFilePath), + filePath: downloadedFilePath, }); } }); diff --git a/src/main/main.ts b/src/main/main.ts index 8eb6386..f5eb240 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -12,6 +12,7 @@ import { resetStats, exportSettingsToFile, importSettingsFromFile, + downloadPresetToRequestOptions, } from './settings'; import { setupAutoUpdater, @@ -43,10 +44,25 @@ import { validateNotificationPayload, validateSettingsPatchPayload, validateDownloadPath, + validateQueueItemIdPayload, + validateQueueReorderPayload, } from '../utils/ipcValidation'; -import { SPLASH_SHOW_DELAY_MS, SPLASH_FADE_DELAY_MS, MAX_QUEUE_SIZE } from './constants'; +import { + SPLASH_SHOW_DELAY_MS, + SPLASH_FADE_DELAY_MS, + MAX_DOWNLOAD_ACTIVITY, + MAX_QUEUE_SIZE, +} from './constants'; import { installDarwinApplicationMenu } from './appMenu'; -import type { DownloadRequestOptions, DownloadOutcome, QueueItem } from '../types'; +import type { + DownloadActivity, + DownloadCompletion, + DownloadRequestOptions, + DownloadOutcome, + QueueItem, + QueueRequestOverrides, + Settings, +} from '../types'; log.initialize(); @@ -138,6 +154,13 @@ function createSplashWindow() { ...(process.platform === 'darwin' ? { roundedCorners: true } : {}), }); void splashWindow.loadFile(path.join(__dirname, '..', '..', 'src', 'renderer', 'splash.html')); + splashWindow.webContents.once('did-finish-load', () => { + if (!splashWindow || splashWindow.isDestroyed()) return; + const versionLiteral = JSON.stringify(app.getVersion()); + void splashWindow.webContents.executeJavaScript( + `(function(){var el=document.getElementById('version-display');if(el)el.textContent='v'+${versionLiteral};})()` + ); + }); splashWindow.center(); setTimeout(() => { if (splashWindow && !splashWindow.isDestroyed()) { @@ -173,7 +196,9 @@ async function runRendererSmokeChecks(windowRef: BrowserWindow): Promise { return; } - if (!isSmokeRun) { + if (isPackaged && !isSmokeRun && !process.env.VITEST) { createSplashWindow(); } verifyBundledFfmpeg(); @@ -500,29 +526,18 @@ app.on('activate', () => { app.on('before-quit', () => { appQuitting = true; + stopActiveDownloadsAndQueue(); flushQueueOnShutdown(); - try { - killAllProcesses(); - } catch (error) { - log.error('Error killing processes on quit:', error); - } - try { - cancelFormats(); - } catch (error) { - log.error('Error cancelling formats on quit:', error); - } - try { - cancelVideoInfo(); - } catch (error) { - log.error('Error cancelling video info on quit:', error); - } }); if (!process.windowsStore) { setupAutoUpdater(getMainWindow, loadSettings); } -ipcMain.on('log-error', (_, message) => { +ipcMain.on('log-error', (event, message) => { + if (!assertMainWindowSender(event)) { + return; + } if (typeof message === 'string') { const truncated = message.length > 2000 ? message.slice(0, 2000) + '...(truncated)' : message; log.error(`[renderer] ${truncated}`); @@ -545,6 +560,7 @@ ipcMain.on('settings-flush-complete', (event) => { }); ipcMain.handle('get-app-version', () => app.getVersion()); +ipcMain.handle('get-app-platform', () => process.platform); ipcMain.handle('is-packaged', () => isPackaged); if (!process.windowsStore) { ipcMain.handle('check-for-updates', () => checkForUpdates(isPackaged, loadSettings)); @@ -589,6 +605,12 @@ ipcMain.handle('get-settings', (event) => { } return loadSettings(); }); +ipcMain.handle('get-default-settings', (event) => { + if (!assertMainWindowSender(event)) { + return errorResult('VALIDATION_ERROR', 'Unauthorized sender.'); + } + return okResult(getDefaultSettings()); +}); ipcMain.handle('save-settings', (event, data) => { if (!assertMainWindowSender(event)) { return errorResult('VALIDATION_ERROR', 'Unauthorized sender.'); @@ -709,16 +731,19 @@ ipcMain.on('cancel-formats', (event) => { cancelFormats(); }); -ipcMain.handle('get-video-info', async (event, url) => { +ipcMain.handle('get-video-info', async (event, url, playlistMode) => { if (!assertMainWindowSender(event)) { return errorResult('VALIDATION_ERROR', 'Unauthorized sender.'); } if (typeof url !== 'string' || !isSafeHttpUrl(url)) { return errorResult('INVALID_URL', 'Invalid URL provided.'); } + if (playlistMode !== undefined && playlistMode !== 'current' && playlistMode !== 'all') { + return errorResult('VALIDATION_ERROR', 'Playlist preview mode must be current or all.'); + } try { - const info = await fetchVideoInfo(getYtdlpPath(), url); + const info = await fetchVideoInfo(getYtdlpPath(), url.trim(), playlistMode); return okResult(info); } catch (error) { const message = @@ -760,7 +785,9 @@ ipcMain.handle('download-video', (event, options) => { validation.data as DownloadRequestOptions, mainWindow, undefined, - 'manual' + 'manual', + null, + (completion) => recordDownloadActivity(completion) ); return okResult({ started: true }); } catch (error) { @@ -921,6 +948,205 @@ ipcMain.handle('reset-stats', (event) => { return okResult(undefined); }); +const activityPath = path.join(app.getPath('userData'), 'download-activity.json'); + +function isPlainRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function cloneDownloadRequest(request: DownloadRequestOptions): DownloadRequestOptions { + return { + ...request, + playlist: request.playlist ? { ...request.playlist } : undefined, + }; +} + +function normalizeStoredRequest( + value: unknown, + authoritativeUrl?: string +): DownloadRequestOptions | undefined { + if (!isPlainRecord(value)) return undefined; + const candidate = { + ...value, + ...(authoritativeUrl ? { url: authoritativeUrl } : {}), + }; + let validation = validateDownloadRequestPayload(candidate); + if (!validation.ok && value.ffmpegPath !== undefined) { + validation = validateDownloadRequestPayload({ ...candidate, ffmpegPath: undefined }); + } + return validation.ok ? validation.data : undefined; +} + +function normalizeActivityRecord(value: unknown): DownloadActivity | null { + if (!isPlainRecord(value)) return null; + const id = typeof value.id === 'string' ? value.id.trim() : ''; + const outcome = value.outcome; + const owner = value.owner; + const url = typeof value.url === 'string' ? normalizeQueueUrl(value.url) : null; + const request = normalizeStoredRequest(value.request, url ?? undefined); + if ( + !id || + id.length > 128 || + (outcome !== 'success' && outcome !== 'failed' && outcome !== 'cancelled') || + (owner !== 'manual' && owner !== 'queue') || + !url || + !request + ) { + return null; + } + + const startedAt = + typeof value.startedAt === 'number' && Number.isFinite(value.startedAt) && value.startedAt > 0 + ? value.startedAt + : Date.now(); + const completedAt = + typeof value.completedAt === 'number' && + Number.isFinite(value.completedAt) && + value.completedAt >= startedAt + ? value.completedAt + : startedAt; + const statusMessage = + typeof value.statusMessage === 'string' + ? value.statusMessage.slice(0, 2000) + : outcome === 'success' + ? 'Download completed.' + : outcome === 'cancelled' + ? 'Download cancelled.' + : 'Download failed.'; + + let outputPath: string | undefined; + if (typeof value.outputPath === 'string') { + const pathValidation = validateFileLocationPayload(value.outputPath); + if (pathValidation.ok) outputPath = pathValidation.data; + } + const filename = + typeof value.filename === 'string' && value.filename.trim() + ? value.filename.trim().slice(0, 1024) + : outputPath + ? path.basename(outputPath) + : undefined; + const sizeBytes = + typeof value.sizeBytes === 'number' && Number.isFinite(value.sizeBytes) && value.sizeBytes >= 0 + ? value.sizeBytes + : undefined; + + return { + id, + sessionId: + typeof value.sessionId === 'number' && Number.isInteger(value.sessionId) + ? value.sessionId + : undefined, + owner, + queueItemId: + typeof value.queueItemId === 'string' ? value.queueItemId.slice(0, 128) : undefined, + outcome, + statusMessage, + url, + profile: + value.profile === 'best-video' || value.profile === 'audio' || value.profile === 'custom' + ? value.profile + : request.profile, + presetId: typeof value.presetId === 'string' ? value.presetId.slice(0, 64) : request.presetId, + presetName: + typeof value.presetName === 'string' ? value.presetName.slice(0, 40) : request.presetName, + request, + filename, + outputPath, + sizeBytes, + format: + typeof value.format === 'string' && value.format.length <= 32 ? value.format : undefined, + error: + outcome === 'failed' && typeof value.error === 'string' + ? value.error.slice(0, 2000) + : outcome === 'failed' + ? statusMessage + : undefined, + startedAt, + completedAt, + }; +} + +function loadDownloadActivity(): DownloadActivity[] { + try { + if (!fs.existsSync(activityPath)) return []; + const parsed: unknown = JSON.parse(fs.readFileSync(activityPath, 'utf-8')); + if (!Array.isArray(parsed)) return []; + return parsed + .map(normalizeActivityRecord) + .filter((entry): entry is DownloadActivity => entry !== null) + .slice(0, MAX_DOWNLOAD_ACTIVITY); + } catch (error) { + log.warn('Failed to load download activity:', error); + return []; + } +} + +let downloadActivity: DownloadActivity[] = loadDownloadActivity(); + +function cloneDownloadActivity(): DownloadActivity[] { + return downloadActivity.map((entry) => ({ + ...entry, + request: cloneDownloadRequest(entry.request), + })); +} + +function persistDownloadActivity(): boolean { + const tempPath = `${activityPath}.tmp`; + try { + const dir = path.dirname(activityPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(tempPath, JSON.stringify(downloadActivity, null, 2), { + encoding: 'utf-8', + mode: 0o600, + }); + fs.renameSync(tempPath, activityPath); + return true; + } catch (error) { + try { + if (fs.existsSync(tempPath)) fs.rmSync(tempPath, { force: true }); + } catch {} + log.error('Failed to persist download activity:', error); + return false; + } +} + +function broadcastDownloadActivity(): void { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('download-activity-update', cloneDownloadActivity()); + } +} + +function recordDownloadActivity(completion: DownloadCompletion): void { + if (downloadActivity.some((entry) => entry.id === completion.id)) return; + const normalized = normalizeActivityRecord(completion); + if (!normalized) { + log.warn('Ignoring invalid structured download completion metadata.'); + return; + } + downloadActivity = [normalized, ...downloadActivity].slice(0, MAX_DOWNLOAD_ACTIVITY); + persistDownloadActivity(); + broadcastDownloadActivity(); +} + +ipcMain.handle('get-download-activity', (event) => { + if (!assertMainWindowSender(event)) { + return errorResult('VALIDATION_ERROR', 'Unauthorized sender.'); + } + return okResult(cloneDownloadActivity()); +}); + +ipcMain.handle('clear-download-activity', (event) => { + if (!assertMainWindowSender(event)) { + return errorResult('VALIDATION_ERROR', 'Unauthorized sender.'); + } + downloadActivity = []; + if (!persistDownloadActivity()) { + return errorResult('INTERNAL_ERROR', 'Failed to clear download activity.'); + } + broadcastDownloadActivity(); + return okResult(undefined); +}); + let downloadQueue: QueueItem[] = []; let isQueueRunning = false; let queueCancelled = false; @@ -930,24 +1156,103 @@ let queueProcessingLock = false; const queuePath = path.join(app.getPath('userData'), 'download-queue.json'); const queueBackupPath = path.join(app.getPath('userData'), 'download-queue.backup.json'); +function normalizeQueueUrl(value: string): string | null { + const trimmed = value.trim(); + if (!isSafeHttpUrl(trimmed)) return null; + try { + const parsed = new URL(trimmed); + parsed.hash = ''; + return parsed.toString(); + } catch { + return null; + } +} + +function generateQueueId(): string { + return `q_${randomUUID()}`; +} + +function normalizeQueueItem(value: unknown, usedIds: Set): QueueItem | null { + if (!isPlainRecord(value) || typeof value.url !== 'string') return null; + const url = normalizeQueueUrl(value.url); + if (!url) return null; + + const rawId = typeof value.id === 'string' ? value.id.trim() : ''; + let id = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(rawId) ? rawId : generateQueueId(); + while (usedIds.has(id)) id = generateQueueId(); + usedIds.add(id); + + const rawStatus = value.status; + const restoredStatus = + rawStatus === 'pending' || + rawStatus === 'completed' || + rawStatus === 'failed' || + rawStatus === 'cancelled' + ? rawStatus + : 'pending'; + const status = rawStatus === 'downloading' ? 'pending' : restoredStatus; + const addedAt = + typeof value.addedAt === 'number' && Number.isFinite(value.addedAt) && value.addedAt > 0 + ? value.addedAt + : Date.now(); + const item: QueueItem = { id, url, status, addedAt }; + const request = normalizeStoredRequest(value.request, url); + if (request) item.request = request; + + if (status === 'completed' || status === 'failed' || status === 'cancelled') { + if ( + typeof value.startedAt === 'number' && + Number.isFinite(value.startedAt) && + value.startedAt > 0 + ) { + item.startedAt = value.startedAt; + } + if ( + typeof value.completedAt === 'number' && + Number.isFinite(value.completedAt) && + value.completedAt > 0 + ) { + item.completedAt = value.completedAt; + } + if (typeof value.filename === 'string' && value.filename.trim()) { + item.filename = value.filename.trim().slice(0, 1024); + } + if (typeof value.outputPath === 'string') { + const outputValidation = validateFileLocationPayload(value.outputPath); + if (outputValidation.ok) item.outputPath = outputValidation.data; + } + if ( + typeof value.sizeBytes === 'number' && + Number.isFinite(value.sizeBytes) && + value.sizeBytes >= 0 + ) { + item.sizeBytes = value.sizeBytes; + } + if (status === 'failed' && typeof value.error === 'string') { + item.error = value.error.slice(0, 2000); + } + } + return item; +} + function readQueueFromDisk(filePath: string): QueueItem[] | null { try { if (!fs.existsSync(filePath)) return null; - const raw = fs.readFileSync(filePath, 'utf-8'); - const parsed: unknown = JSON.parse(raw); + const parsed: unknown = JSON.parse(fs.readFileSync(filePath, 'utf-8')); if (!Array.isArray(parsed)) return null; - return parsed - .filter( - (item: unknown): item is QueueItem => - item !== null && - typeof item === 'object' && - typeof (item as QueueItem).id === 'string' && - typeof (item as QueueItem).url === 'string' - ) - .map((item: QueueItem) => ({ - ...item, - status: item.status === 'downloading' ? ('pending' as const) : item.status, - })); + const usedIds = new Set(); + const nonterminalUrls = new Set(); + const normalized: QueueItem[] = []; + for (const rawItem of parsed.slice(0, MAX_QUEUE_SIZE)) { + const item = normalizeQueueItem(rawItem, usedIds); + if (!item) continue; + if (item.status === 'pending' || item.status === 'downloading') { + if (nonterminalUrls.has(item.url)) continue; + nonterminalUrls.add(item.url); + } + normalized.push(item); + } + return normalized; } catch { return null; } @@ -955,9 +1260,7 @@ function readQueueFromDisk(filePath: string): QueueItem[] | null { function loadPersistedQueue(): QueueItem[] { const queueFromPrimary = readQueueFromDisk(queuePath); - if (queueFromPrimary) { - return queueFromPrimary; - } + if (queueFromPrimary) return queueFromPrimary; const queueFromBackup = readQueueFromDisk(queueBackupPath); if (queueFromBackup) { log.warn('Primary queue file could not be read. Restoring queue from backup.'); @@ -974,12 +1277,14 @@ function persistQueue(): void { const dir = path.dirname(queuePath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); const serialized = JSON.stringify(downloadQueue, null, 2); - fs.writeFileSync(tempPath, serialized, 'utf-8'); - if (fs.existsSync(queuePath)) { - fs.copyFileSync(queuePath, queueBackupPath); + fs.writeFileSync(tempPath, serialized, { encoding: 'utf-8', mode: 0o600 }); + if (fs.existsSync(queuePath)) fs.copyFileSync(queuePath, queueBackupPath); + try { + fs.renameSync(tempPath, queuePath); + } catch { fs.rmSync(queuePath, { force: true }); + fs.renameSync(tempPath, queuePath); } - fs.renameSync(tempPath, queuePath); fs.copyFileSync(queuePath, queueBackupPath); } catch (error) { try { @@ -991,14 +1296,8 @@ function persistQueue(): void { downloadQueue = loadPersistedQueue(); -function generateQueueId(): string { - return `q_${randomUUID()}`; -} - function schedulePersistQueue(): void { - if (persistQueueTimer) { - clearTimeout(persistQueueTimer); - } + if (persistQueueTimer) clearTimeout(persistQueueTimer); persistQueueTimer = setTimeout(() => { persistQueueTimer = null; persistQueue(); @@ -1038,6 +1337,132 @@ function broadcastQueue() { schedulePersistQueue(); } +function resolveQueueOutputPath(settings: Settings): string { + const configured = settings.downloadFolder?.trim(); + if (configured) { + const validation = validateDownloadPath(configured); + if (validation.ok && validation.data) return validation.data; + } + return app.getPath('downloads'); +} + +function requestFromSettings(url: string, settings: Settings): DownloadRequestOptions { + return { + url, + outputPath: resolveQueueOutputPath(settings), + ffmpegPath: settings.ffmpegPath || undefined, + convertEnabled: settings.convertEnabled, + convertFormat: settings.convertFormat, + keepOriginal: settings.keepOriginalAfterConvert, + playlist: { mode: 'current' }, + profileEnabled: settings.downloadProfilesEnabled, + profile: settings.downloadMode, + bestQuality: settings.bestQuality, + advancedOptions: settings.advancedOptions, + audioOnly: settings.audioOnly, + audioOutputFormat: settings.audioFormat, + hookBrowser: settings.hookBrowser, + browserChoice: settings.browserChoice, + gpuAcceleration: settings.gpuAcceleration, + gpuType: settings.gpuType, + writeSubtitles: settings.writeSubtitles, + subtitleLangs: settings.subtitleLangs, + embedThumbnail: settings.embedThumbnail, + embedMetadata: settings.embedMetadata, + sponsorblockRemove: settings.sponsorblockRemove, + }; +} + +function buildQueueRequest( + url: string, + settings: Settings, + overrides?: QueueRequestOverrides +): ReturnType { + const presets = Array.isArray(settings.downloadPresets) ? settings.downloadPresets : []; + const requestedPresetId = + overrides && typeof overrides.presetId === 'string' ? overrides.presetId.trim() : undefined; + const preset = requestedPresetId + ? presets.find((candidate) => candidate.id === requestedPresetId) + : undefined; + const presetOptions = preset ? downloadPresetToRequestOptions(preset) : {}; + const definedOverrides = overrides + ? (Object.fromEntries( + Object.entries(overrides).filter(([, value]) => value !== undefined) + ) as QueueRequestOverrides) + : undefined; + const candidate = { + ...requestFromSettings(url, settings), + ...presetOptions, + ...(definedOverrides ?? {}), + url, + } as DownloadRequestOptions; + // Always validate: a snapshot that cannot be re-validated later would be + // silently discarded on reload and would never reach the activity log. + return validateDownloadRequestPayload(candidate); +} + +function resolveQueueRequest(item: QueueItem): DownloadRequestOptions | null { + const stored = normalizeStoredRequest(item.request, item.url); + if (stored) return stored; + const settings = loadSettings(); + const validated = buildQueueRequest(item.url, settings); + if (validated.ok) return validated.data; + // Settings-derived values can sit outside the strict download-path allow-list + // (an unusual system Downloads location, for example). Those are already + // validated when saved, and startDownload still enforces that the finished + // file stays inside the target directory, so run rather than fail here. + log.warn(`Running queued ${item.url} from unvalidated settings-derived options.`); + return requestFromSettings(item.url, settings); +} + +function clearQueueAttemptMetadata(item: QueueItem): void { + delete item.startedAt; + delete item.completedAt; + delete item.progress; + delete item.filename; + delete item.outputPath; + delete item.sizeBytes; + delete item.error; +} + +function createSyntheticQueueCompletion( + item: QueueItem, + request: DownloadRequestOptions, + outcome: DownloadOutcome, + statusMessage: string, + error?: string +): DownloadCompletion { + const completedAt = Date.now(); + return { + id: randomUUID(), + owner: 'queue', + queueItemId: item.id, + outcome, + statusMessage, + url: item.url, + profile: request.profile, + presetId: request.presetId, + presetName: request.presetName, + request: cloneDownloadRequest(request), + error: outcome === 'failed' ? (error ?? statusMessage) : undefined, + startedAt: item.startedAt ?? completedAt, + completedAt, + }; +} + +function applyQueueCompletion(item: QueueItem, completion: DownloadCompletion): void { + item.status = completion.outcome === 'success' ? 'completed' : completion.outcome; + item.request = cloneDownloadRequest(completion.request); + item.completedAt = completion.completedAt; + delete item.progress; + item.filename = completion.filename; + item.outputPath = completion.outputPath; + item.sizeBytes = completion.sizeBytes; + item.error = + completion.outcome === 'failed' ? (completion.error ?? completion.statusMessage) : undefined; + recordDownloadActivity(completion); +} + async function processQueue() { if (!isQueueRunning || queueProcessingLock) return; queueProcessingLock = true; @@ -1052,36 +1477,43 @@ async function processQueue() { return; } + clearQueueAttemptMetadata(nextItem); nextItem.status = 'downloading'; + nextItem.startedAt = Date.now(); queueActiveItemId = nextItem.id; broadcastQueue(); await new Promise((resolve) => { - if (!mainWindow || mainWindow.isDestroyed()) { - nextItem.status = 'failed'; - nextItem.error = 'Window closed'; + const options = resolveQueueRequest(nextItem); + if (!options) { + const fallback = requestFromSettings(nextItem.url, loadSettings()); + const completion = createSyntheticQueueCompletion( + nextItem, + fallback, + 'failed', + 'Failed to restore the queued download request.' + ); + applyQueueCompletion(nextItem, completion); queueActiveItemId = null; broadcastQueue(); resolve(); return; } + nextItem.request = cloneDownloadRequest(options); - const settings = loadSettings(); - let outputPath = app.getPath('downloads'); - const rawFolder = settings.downloadFolder?.trim(); - if (rawFolder) { - const folderValidation = validateDownloadPath(rawFolder); - if (folderValidation.ok && folderValidation.data) { - outputPath = folderValidation.data; - } + if (!mainWindow || mainWindow.isDestroyed()) { + const completion = createSyntheticQueueCompletion( + nextItem, + options, + 'failed', + 'Window closed' + ); + applyQueueCompletion(nextItem, completion); + queueActiveItemId = null; + broadcastQueue(); + resolve(); + return; } - const options: DownloadRequestOptions = { - url: nextItem.url, - outputPath, - ffmpegPath: settings.ffmpegPath || undefined, - convertFormat: settings.convertEnabled ? settings.convertFormat : undefined, - keepOriginal: settings.convertEnabled ? settings.keepOriginalAfterConvert : undefined, - }; const completedItems = downloadQueue.filter( (item) => @@ -1090,27 +1522,27 @@ async function processQueue() { const queueProgress = { completedItems, queueTotal: downloadQueue.length, + queueItemId: nextItem.id, }; let settled = false; - const completeListener = (statusMessage: string, outcome?: DownloadOutcome) => { + const finish = (completion: DownloadCompletion) => { if (settled) return; settled = true; - - if (outcome === 'cancelled') { - nextItem.status = 'cancelled'; - nextItem.error = undefined; - } else if (outcome === 'success') { - nextItem.status = 'completed'; - nextItem.error = undefined; - } else { - nextItem.status = 'failed'; - nextItem.error = statusMessage; + // Always resolve: a failure while recording the outcome must not stall + // the rest of the queue. + try { + applyQueueCompletion(nextItem, completion); + } catch (error) { + log.error('Failed to apply queue completion:', error); + } finally { + queueActiveItemId = null; + broadcastQueue(); + resolve(); } - - queueActiveItemId = null; - broadcastQueue(); - resolve(); + }; + const completeListener = (statusMessage: string, outcome: DownloadOutcome = 'failed') => { + finish(createSyntheticQueueCompletion(nextItem, options, outcome, statusMessage)); }; try { @@ -1121,11 +1553,21 @@ async function processQueue() { mainWindow, completeListener, 'queue', - queueProgress + queueProgress, + finish ); } catch (error) { - nextItem.status = 'failed'; - nextItem.error = (error as Error).message; + if (settled) return; + settled = true; + const message = error instanceof Error ? error.message : 'Failed to start queue download.'; + const completion = createSyntheticQueueCompletion( + nextItem, + options, + 'failed', + message, + message + ); + applyQueueCompletion(nextItem, completion); queueActiveItemId = null; broadcastQueue(); resolve(); @@ -1133,52 +1575,155 @@ async function processQueue() { }); } finally { queueProcessingLock = false; - if (isQueueRunning && !queueCancelled) { - void processQueue(); - } + if (isQueueRunning && !queueCancelled) void processQueue(); } } -ipcMain.handle('add-to-queue', (event, urls) => { +ipcMain.handle('add-to-queue', (event, urls, requestOverrides) => { if (!assertMainWindowSender(event)) { return errorResult('VALIDATION_ERROR', 'Unauthorized sender.'); } if (!Array.isArray(urls)) { return errorResult('VALIDATION_ERROR', 'URLs must be an array.'); } - const validUrls = urls.filter((u): u is string => typeof u === 'string' && isSafeHttpUrl(u)); - if (validUrls.length === 0) { + if (requestOverrides !== undefined && !isPlainRecord(requestOverrides)) { + return errorResult('VALIDATION_ERROR', 'Queue request options must be an object.'); + } + + const settings = loadSettings(); + const nonterminalUrls = new Set( + downloadQueue + .filter((item) => item.status === 'pending' || item.status === 'downloading') + .map((item) => item.url) + ); + const batchUrls = new Set(); + const pendingItems: QueueItem[] = []; + let validUrlCount = 0; + let skipped = 0; + + for (const rawUrl of urls) { + if (typeof rawUrl !== 'string') { + skipped += 1; + continue; + } + const url = normalizeQueueUrl(rawUrl); + if (!url) { + skipped += 1; + continue; + } + validUrlCount += 1; + if (nonterminalUrls.has(url) || batchUrls.has(url)) { + skipped += 1; + continue; + } + const requestValidation = buildQueueRequest( + url, + settings, + requestOverrides as QueueRequestOverrides | undefined + ); + if (!requestValidation.ok && requestOverrides !== undefined) { + // The caller supplied the offending values, so surface the problem. + return errorResult( + requestValidation.error.code, + requestValidation.error.message, + requestValidation.error.details + ); + } + if (!requestValidation.ok) { + // Derived purely from saved settings: keep the item queued and let it + // resolve from settings at run time instead of storing a bad snapshot. + log.warn(`Queued ${url} without a request snapshot: ${requestValidation.error.message}`); + } + batchUrls.add(url); + pendingItems.push({ + id: generateQueueId(), + url, + status: 'pending', + addedAt: Date.now(), + ...(requestValidation.ok ? { request: requestValidation.data } : {}), + }); + } + + if (validUrlCount === 0) { return errorResult('VALIDATION_ERROR', 'No valid URLs provided.'); } - if (downloadQueue.length + validUrls.length > MAX_QUEUE_SIZE) { + if (downloadQueue.length + pendingItems.length > MAX_QUEUE_SIZE) { return errorResult('VALIDATION_ERROR', `Queue limit reached (max ${MAX_QUEUE_SIZE} items).`); } - const newItems: QueueItem[] = validUrls.map((url) => ({ - id: generateQueueId(), - url, - status: 'pending' as const, - addedAt: Date.now(), - })); - downloadQueue.push(...newItems); - broadcastQueue(); - return okResult({ added: validUrls.length }); + if (pendingItems.length > 0) { + downloadQueue.push(...pendingItems); + broadcastQueue(); + } + return okResult({ added: pendingItems.length, skipped }); }); ipcMain.handle('remove-from-queue', (event, id) => { if (!assertMainWindowSender(event)) { return errorResult('VALIDATION_ERROR', 'Unauthorized sender.'); } - if (typeof id !== 'string') { - return errorResult('VALIDATION_ERROR', 'Queue item ID must be a string.'); + const idValidation = validateQueueItemIdPayload(id); + if (!idValidation.ok) { + return errorResult(idValidation.error.code, idValidation.error.message); } - const idx = downloadQueue.findIndex((item) => item.id === id); - if (idx === -1) return errorResult('NOT_AVAILABLE', 'Queue item not found.'); - const item = downloadQueue[idx]; + const index = downloadQueue.findIndex((item) => item.id === idValidation.data); + if (index === -1) return errorResult('NOT_AVAILABLE', 'Queue item not found.'); + const item = downloadQueue[index]; if (!item) return errorResult('NOT_AVAILABLE', 'Queue item not found.'); if (item.status === 'downloading') { return errorResult('VALIDATION_ERROR', 'Cannot remove an actively downloading item.'); } - downloadQueue.splice(idx, 1); + downloadQueue.splice(index, 1); + broadcastQueue(); + return okResult(undefined); +}); + +ipcMain.handle('retry-queue-item', (event, id) => { + if (!assertMainWindowSender(event)) { + return errorResult('VALIDATION_ERROR', 'Unauthorized sender.'); + } + const idValidation = validateQueueItemIdPayload(id); + if (!idValidation.ok) { + return errorResult(idValidation.error.code, idValidation.error.message); + } + const item = downloadQueue.find((candidate) => candidate.id === idValidation.data); + if (!item) return errorResult('NOT_AVAILABLE', 'Queue item not found.'); + if (item.status !== 'failed' && item.status !== 'cancelled') { + return errorResult('VALIDATION_ERROR', 'Only failed or cancelled queue items can be retried.'); + } + clearQueueAttemptMetadata(item); + item.status = 'pending'; + broadcastQueue(); + return okResult(undefined); +}); + +ipcMain.handle('reorder-queue-item', (event, payload) => { + if (!assertMainWindowSender(event)) { + return errorResult('VALIDATION_ERROR', 'Unauthorized sender.'); + } + const validation = validateQueueReorderPayload(payload); + if (!validation.ok) { + return errorResult(validation.error.code, validation.error.message); + } + const itemIndex = downloadQueue.findIndex((item) => item.id === validation.data.id); + if (itemIndex === -1) return errorResult('NOT_AVAILABLE', 'Queue item not found.'); + const item = downloadQueue[itemIndex]; + if (!item || item.status !== 'pending') { + return errorResult('VALIDATION_ERROR', 'Only pending queue items can be reordered.'); + } + + const pendingIndexes = downloadQueue + .map((candidate, index) => (candidate.status === 'pending' ? index : -1)) + .filter((index) => index >= 0); + const position = pendingIndexes.indexOf(itemIndex); + const destinationPosition = validation.data.direction === 'up' ? position - 1 : position + 1; + const destinationIndex = pendingIndexes[destinationPosition]; + if (destinationIndex === undefined) { + return errorResult('NOT_AVAILABLE', `Queue item cannot move ${validation.data.direction}.`); + } + const destination = downloadQueue[destinationIndex]; + if (!destination) return errorResult('NOT_AVAILABLE', 'Queue destination not found.'); + downloadQueue[itemIndex] = destination; + downloadQueue[destinationIndex] = item; broadcastQueue(); return okResult(undefined); }); @@ -1200,6 +1745,8 @@ ipcMain.handle('clear-queue', (event) => { ipcMain.handle('get-queue', (event) => { if (!assertMainWindowSender(event)) { + // Deliberately an error rather than an empty array: an unauthorized read of + // the queue should be visible, not silently answered with a plausible value. return errorResult('VALIDATION_ERROR', 'Unauthorized sender.'); } return downloadQueue; @@ -1209,8 +1756,7 @@ ipcMain.handle('start-queue', (event) => { if (!assertMainWindowSender(event)) { return errorResult('VALIDATION_ERROR', 'Unauthorized sender.'); } - const pending = downloadQueue.filter((item) => item.status === 'pending'); - if (pending.length === 0) { + if (!downloadQueue.some((item) => item.status === 'pending')) { return errorResult('NOT_AVAILABLE', 'No pending items in queue.'); } if (isQueueRunning) { @@ -1232,15 +1778,20 @@ ipcMain.handle('cancel-queue', (event) => { try { queueCancelled = true; isQueueRunning = false; - if (queueActiveItemId) { - cancelActiveSession(true); - queueActiveItemId = null; + if (queueActiveItemId) cancelActiveSession(true); + + for (const item of downloadQueue) { + if (item.status !== 'pending' && item.status !== 'downloading') continue; + const request = resolveQueueRequest(item) ?? requestFromSettings(item.url, loadSettings()); + const completion = createSyntheticQueueCompletion( + item, + request, + 'cancelled', + 'âšī¸ Cancelled.' + ); + applyQueueCompletion(item, completion); } - downloadQueue.forEach((item) => { - if (item.status === 'pending' || item.status === 'downloading') { - item.status = 'cancelled'; - } - }); + queueActiveItemId = null; broadcastQueue(); return okResult(undefined); } catch (error) { diff --git a/src/main/preload.ts b/src/main/preload.ts index 32f52bf..c8598df 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -1,11 +1,15 @@ import { contextBridge, ipcRenderer } from 'electron'; import type { + DownloadActivity, + DownloadCompletion, DownloadRequestOptions, DownloadStats, JobProgressEvent, MenuAction, NotificationRequest, QueueItem, + QueueReorderRequest, + QueueRequestOverrides, RendererApi, Settings, UpdateDownloadResult, @@ -18,10 +22,14 @@ const api: RendererApi = { getChannel: () => process.env.CHANNEL === 'msstore' || process.windowsStore ? 'msstore' : 'github', getFormats: (url: string) => ipcRenderer.invoke('getFormats', url), - getVideoInfo: (url: string) => ipcRenderer.invoke('get-video-info', url), + getVideoInfo: (url: string, playlistMode?: 'current' | 'all') => + playlistMode === undefined + ? ipcRenderer.invoke('get-video-info', url) + : ipcRenderer.invoke('get-video-info', url, playlistMode), cancelVideoInfo: () => ipcRenderer.send('cancel-video-info'), selectDownloadLocation: () => ipcRenderer.invoke('select-download-location'), getSettings: () => ipcRenderer.invoke('get-settings'), + getDefaultSettings: () => ipcRenderer.invoke('get-default-settings'), saveSettings: (settings: Partial) => ipcRenderer.invoke('save-settings', settings), resetSettings: () => ipcRenderer.send('reset-settings'), openExternal: (url: string) => ipcRenderer.invoke('open-external', url), @@ -29,6 +37,7 @@ const api: RendererApi = { cancelDownload: () => ipcRenderer.send('cancel-download'), cancelFormats: () => ipcRenderer.send('cancel-formats'), getAppVersion: () => ipcRenderer.invoke('get-app-version'), + getAppPlatform: () => ipcRenderer.invoke('get-app-platform'), checkDenoInstalled: () => ipcRenderer.invoke('check-deno-installed'), installDeno: () => ipcRenderer.invoke('install-deno'), detectGpu: () => ipcRenderer.invoke('detect-gpu'), @@ -67,6 +76,12 @@ const api: RendererApi = { ipcRenderer.on('complete', listener); return () => ipcRenderer.removeListener('complete', listener); }, + onDownloadComplete: (callback: (completion: DownloadCompletion) => void) => { + const listener = (_: Electron.IpcRendererEvent, completion: DownloadCompletion) => + callback(completion); + ipcRenderer.on('download-complete', listener); + return () => ipcRenderer.removeListener('download-complete', listener); + }, openFileLocation: (filePath: string) => ipcRenderer.invoke('open-file-location', filePath), showNotification: (options: NotificationRequest) => ipcRenderer.invoke('show-notification', options), @@ -74,10 +89,24 @@ const api: RendererApi = { importSettings: () => ipcRenderer.invoke('import-settings'), getStats: () => ipcRenderer.invoke('get-stats') as Promise, resetStats: () => ipcRenderer.invoke('reset-stats'), + getDownloadActivity: () => ipcRenderer.invoke('get-download-activity'), + clearDownloadActivity: () => ipcRenderer.invoke('clear-download-activity'), + onDownloadActivityUpdate: (callback: (activity: DownloadActivity[]) => void) => { + const listener = (_: Electron.IpcRendererEvent, activity: DownloadActivity[]) => + callback(activity); + ipcRenderer.on('download-activity-update', listener); + return () => ipcRenderer.removeListener('download-activity-update', listener); + }, logError: (message: string) => ipcRenderer.send('log-error', message), notifySettingsFlushed: () => ipcRenderer.send('settings-flush-complete'), - addToQueue: (urls: string[]) => ipcRenderer.invoke('add-to-queue', urls), + addToQueue: (urls: string[], options?: QueueRequestOverrides) => + options === undefined + ? ipcRenderer.invoke('add-to-queue', urls) + : ipcRenderer.invoke('add-to-queue', urls, options), removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id), + retryQueueItem: (id: string) => ipcRenderer.invoke('retry-queue-item', id), + reorderQueueItem: (request: QueueReorderRequest) => + ipcRenderer.invoke('reorder-queue-item', request), clearQueue: () => ipcRenderer.invoke('clear-queue'), getQueue: () => ipcRenderer.invoke('get-queue') as Promise, startQueue: () => ipcRenderer.invoke('start-queue'), diff --git a/src/main/settings.ts b/src/main/settings.ts index 0de2ead..49d7133 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -2,12 +2,25 @@ import * as path from 'path'; import * as fs from 'fs'; import { app, dialog } from 'electron'; import log from 'electron-log/main.js'; -import type { AudioFormat, DownloadProfile, DownloadStats, Settings } from '../types'; +import type { + AudioFormat, + DownloadPreset, + DownloadProfile, + DownloadRequestOptions, + DownloadStats, + PlaylistSelection, + Settings, +} from '../types'; import { ALLOWED_AUDIO_FORMATS, ALLOWED_BROWSERS, ALLOWED_CONVERT_FORMATS, + FORMAT_ID_PATTERN, + MAX_DOWNLOAD_PRESETS, MAX_FORMAT_COUNTS, + MAX_PLAYLIST_ITEM_INDEX, + MAX_PRESET_ID_LENGTH, + MAX_PRESET_NAME_LENGTH, MAX_SETTINGS_IMPORT_BYTES, CURRENT_SETTINGS_VERSION, SUBTITLE_LANGS_PATTERN, @@ -27,6 +40,7 @@ const defaultSettings: Settings = { queueCollapsed: false, downloadProfilesEnabled: false, downloadMode: 'best-video', + downloadPresets: [], askDownloadLocation: false, advancedOptions: false, audioOnly: false, @@ -57,8 +71,24 @@ const defaultSettings: Settings = { showTaskbarProgress: true, }; +function clonePlaylistSelection( + playlist: PlaylistSelection | undefined +): PlaylistSelection | undefined { + return playlist ? { ...playlist } : undefined; +} + +function cloneDownloadPreset(preset: DownloadPreset): DownloadPreset { + return { + ...preset, + playlist: clonePlaylistSelection(preset.playlist), + }; +} + export function getDefaultSettings(): Settings { - return { ...defaultSettings }; + return { + ...defaultSettings, + downloadPresets: defaultSettings.downloadPresets.map(cloneDownloadPreset), + }; } function isRecord(value: unknown): value is Record { @@ -122,6 +152,173 @@ function readGpuType(value: unknown): Settings['gpuType'] { : defaultSettings.gpuType; } +function sanitizePresetName(rawName: unknown, index: number, usedNames: Set): string { + const fallback = `Preset ${index + 1}`; + const base = + typeof rawName === 'string' + ? rawName.trim().replace(/\s+/g, ' ').slice(0, MAX_PRESET_NAME_LENGTH) || fallback + : fallback; + let candidate = base; + let suffix = 2; + while (usedNames.has(candidate.toLowerCase())) { + const marker = ` (${suffix})`; + candidate = `${base.slice(0, Math.max(1, MAX_PRESET_NAME_LENGTH - marker.length))}${marker}`; + suffix += 1; + } + usedNames.add(candidate.toLowerCase()); + return candidate; +} + +function sanitizePresetId( + rawId: unknown, + name: string, + index: number, + usedIds: Set +): string { + const safePattern = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; + const provided = typeof rawId === 'string' ? rawId.trim() : ''; + const slug = name + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, ''); + const fallback = `preset-${index + 1}${slug ? `-${slug}` : ''}`; + const base = + provided && provided.length <= MAX_PRESET_ID_LENGTH && safePattern.test(provided) + ? provided + : fallback.slice(0, MAX_PRESET_ID_LENGTH); + let candidate = base; + let suffix = 2; + while (usedIds.has(candidate)) { + const marker = `-${suffix}`; + candidate = `${base.slice(0, Math.max(1, MAX_PRESET_ID_LENGTH - marker.length))}${marker}`; + suffix += 1; + } + usedIds.add(candidate); + return candidate; +} + +function sanitizePresetPlaylist(value: unknown): PlaylistSelection | undefined { + if (!isRecord(value)) return undefined; + if (value.mode === 'current' || value.mode === 'all') { + return { mode: value.mode }; + } + if ( + value.mode === 'range' && + typeof value.start === 'number' && + typeof value.end === 'number' && + Number.isInteger(value.start) && + Number.isInteger(value.end) && + value.start >= 1 && + value.end >= value.start && + value.end <= MAX_PLAYLIST_ITEM_INDEX + ) { + return { mode: 'range', start: value.start, end: value.end }; + } + return undefined; +} + +export function sanitizeDownloadPresets(value: unknown): DownloadPreset[] { + if (!Array.isArray(value)) return []; + const presets: DownloadPreset[] = []; + const usedIds = new Set(); + const usedNames = new Set(); + + for (const [index, rawPreset] of value.slice(0, MAX_DOWNLOAD_PRESETS).entries()) { + if (!isRecord(rawPreset)) continue; + const name = sanitizePresetName(rawPreset.name, index, usedNames); + const id = sanitizePresetId(rawPreset.id, name, index, usedIds); + const profile = readDownloadMode(rawPreset.profile, 'best-video'); + const preset: DownloadPreset = { id, name, profile }; + + for (const key of [ + 'bestQuality', + 'audioOnly', + 'convertEnabled', + 'keepOriginalAfterConvert', + 'gpuAcceleration', + 'writeSubtitles', + 'embedThumbnail', + 'embedMetadata', + 'sponsorblockRemove', + ] as const) { + if (typeof rawPreset[key] === 'boolean') preset[key] = rawPreset[key]; + } + if ( + typeof rawPreset.audioFormat === 'string' && + ALLOWED_AUDIO_FORMATS.has(rawPreset.audioFormat) + ) { + preset.audioFormat = rawPreset.audioFormat as AudioFormat; + } + if ( + typeof rawPreset.videoFormat === 'string' && + FORMAT_ID_PATTERN.test(rawPreset.videoFormat.trim()) + ) { + preset.videoFormat = rawPreset.videoFormat.trim(); + } + if ( + typeof rawPreset.audioFormatId === 'string' && + FORMAT_ID_PATTERN.test(rawPreset.audioFormatId.trim()) + ) { + preset.audioFormatId = rawPreset.audioFormatId.trim(); + } + if ( + typeof rawPreset.convertFormat === 'string' && + ALLOWED_CONVERT_FORMATS.has(rawPreset.convertFormat) + ) { + preset.convertFormat = rawPreset.convertFormat; + } + if ( + rawPreset.gpuType === 'auto' || + rawPreset.gpuType === 'nvidia' || + rawPreset.gpuType === 'amd' || + rawPreset.gpuType === 'intel' + ) { + preset.gpuType = rawPreset.gpuType; + } + if (typeof rawPreset.subtitleLangs === 'string') { + const langs = rawPreset.subtitleLangs.trim(); + if (langs && langs.length <= 256 && SUBTITLE_LANGS_PATTERN.test(langs)) { + preset.subtitleLangs = langs; + } + } + const playlist = sanitizePresetPlaylist(rawPreset.playlist); + if (playlist) preset.playlist = playlist; + presets.push(preset); + } + return presets; +} + +export function downloadPresetToRequestOptions( + preset: DownloadPreset +): Partial { + const mapped: Partial = { + profileEnabled: true, + profile: preset.profile, + presetId: preset.id, + presetName: preset.name, + bestQuality: preset.bestQuality ?? preset.profile === 'best-video', + advancedOptions: preset.profile === 'custom', + audioOnly: preset.audioOnly ?? preset.profile === 'audio', + audioOutputFormat: preset.audioFormat, + videoFormat: preset.videoFormat, + audioFormat: preset.audioFormatId, + convertEnabled: preset.convertEnabled, + convertFormat: preset.convertFormat, + keepOriginal: preset.keepOriginalAfterConvert, + gpuAcceleration: preset.gpuAcceleration, + gpuType: preset.gpuType, + writeSubtitles: preset.writeSubtitles, + subtitleLangs: preset.subtitleLangs, + embedThumbnail: preset.embedThumbnail, + embedMetadata: preset.embedMetadata, + sponsorblockRemove: preset.sponsorblockRemove, + playlist: clonePlaylistSelection(preset.playlist), + }; + return Object.fromEntries( + Object.entries(mapped).filter(([, value]) => value !== undefined) + ) as Partial; +} + function readBrowserChoice(value: unknown): string { const raw = readString(value, defaultSettings.browserChoice); const capped = raw.length > 64 ? raw.slice(0, 64) : raw; @@ -169,7 +366,7 @@ function readSettingsVersion(value: unknown): number { export function migrateSettings(rawSettings: unknown): Settings { if (!isRecord(rawSettings)) { - return { ...defaultSettings }; + return getDefaultSettings(); } const downloadProfilesEnabled = readBoolean( @@ -200,6 +397,7 @@ export function migrateSettings(rawSettings: unknown): Settings { queueCollapsed: readBoolean(rawSettings.queueCollapsed, defaultSettings.queueCollapsed), downloadProfilesEnabled, downloadMode, + downloadPresets: sanitizeDownloadPresets(rawSettings.downloadPresets), askDownloadLocation: readBoolean( rawSettings.askDownloadLocation, defaultSettings.askDownloadLocation @@ -262,14 +460,14 @@ function normalizeSettingsVersion(settings: Settings): Settings { export function loadSettings(): Settings { try { if (!fs.existsSync(settingsPath)) { - return { ...defaultSettings }; + return getDefaultSettings(); } const raw = fs.readFileSync(settingsPath, 'utf-8'); const loaded: unknown = JSON.parse(raw); return normalizeSettingsVersion(migrateSettings(loaded)); } catch (error) { log.warn('Failed to load settings, using defaults:', error); - return { ...defaultSettings }; + return getDefaultSettings(); } } diff --git a/src/renderer/css/02-sidebar.css b/src/renderer/css/02-sidebar.css index 81bc121..8b2be61 100644 --- a/src/renderer/css/02-sidebar.css +++ b/src/renderer/css/02-sidebar.css @@ -543,3 +543,73 @@ select option { color: var(--warning-contrast); line-height: 1.4; } + +/* ── Settings search ── */ +.settings-search { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + padding: var(--spacing-sm) var(--spacing-lg) 0; +} + +.settings-search label { + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-tertiary); +} + +.settings-search input { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + background: var(--surface-2); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: var(--text-base); +} + +.settings-search input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-subtle); +} + +.settings-search-status { + margin: 0; + min-height: 1rem; + font-size: var(--text-xs); + color: var(--text-muted); +} + +.search-hidden { + display: none !important; +} + +/* ── Per-section reset ── */ +.settings-section-reset { + margin-top: var(--spacing-xs); + margin-bottom: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--button-neutral-bg); + border: 1px dashed var(--border-default); + border-radius: var(--radius-sm); + color: var(--text-tertiary); + font-size: var(--text-xs); + cursor: pointer; + transition: var(--button-transition); +} + +.settings-section-reset:hover { + background: var(--button-danger-bg); + border-color: var(--danger); + border-style: solid; + color: var(--danger); +} + +.settings-section-reset:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + box-shadow: 0 0 0 4px var(--accent-subtle); +} diff --git a/src/renderer/css/03-main.css b/src/renderer/css/03-main.css index 84df479..339f518 100644 --- a/src/renderer/css/03-main.css +++ b/src/renderer/css/03-main.css @@ -816,3 +816,174 @@ color: var(--text-secondary); font-weight: var(--weight-medium); } + +/* ── Saved presets ── */ +.preset-manager { + margin-top: var(--spacing-md); + padding-top: var(--spacing-sm); + border-top: 1px solid var(--border-subtle); +} + +.preset-manager-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +.preset-manager-heading h3 { + margin: 0; + font-size: var(--text-base); + color: var(--text-primary); +} + +.preset-manager-heading span { + color: var(--text-muted); + font-size: var(--text-xs); +} + +.preset-manager-row { + display: flex; + align-items: center; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); + flex-wrap: wrap; +} + +.preset-manager-row select, +.preset-manager-row input { + flex: 1; + min-width: 140px; + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--surface-2); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: var(--text-sm); +} + +.preset-manager-row select:focus, +.preset-manager-row input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-subtle); +} + +.preset-action-btn { + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--button-neutral-bg); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: var(--text-sm); + cursor: pointer; + transition: var(--button-transition); + box-shadow: var(--button-neutral-shadow); +} + +.preset-action-btn:hover:not(:disabled) { + background: var(--button-neutral-bg-hover); + border-color: var(--border-strong); + color: var(--text-primary); + transform: var(--button-lift); +} + +.preset-action-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.preset-save-btn { + background: var(--button-primary-bg); + border-color: var(--accent-dark); + color: var(--accent-contrast); + box-shadow: var(--button-primary-shadow); +} + +.preset-save-btn:hover:not(:disabled) { + background: var(--button-primary-bg-hover); + color: var(--accent-contrast); +} + +.preset-action-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.preset-status { + margin: 0; + min-height: 1rem; + color: var(--text-muted); + font-size: var(--text-xs); +} + +/* ── Playlist scope ── */ +.playlist-scope { + margin: var(--spacing-md) 0 0; + padding: var(--spacing-sm) var(--spacing-md); + background: var(--surface-1); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); +} + +.playlist-scope.hidden { + display: none; +} + +.playlist-scope legend { + padding: 0 var(--spacing-xs); + color: var(--text-secondary); + font-size: var(--text-sm); + font-weight: var(--weight-medium); +} + +.playlist-scope-options { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-md); +} + +.playlist-scope-options label, +.playlist-range-fields label { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + color: var(--text-secondary); + font-size: var(--text-sm); + cursor: pointer; +} + +.playlist-range-fields { + display: flex; + align-items: center; + gap: var(--spacing-xs); + margin-top: var(--spacing-sm); +} + +.playlist-range-fields.hidden { + display: none; +} + +.playlist-range-fields input { + width: 84px; + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--surface-2); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: var(--text-sm); +} + +.playlist-range-fields input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-subtle); +} + +.playlist-scope-error { + margin: var(--spacing-xs) 0 0; + min-height: 1rem; + color: var(--danger); + font-size: var(--text-xs); +} diff --git a/src/renderer/css/04-panels.css b/src/renderer/css/04-panels.css index a1d4a34..3317a08 100644 --- a/src/renderer/css/04-panels.css +++ b/src/renderer/css/04-panels.css @@ -380,3 +380,61 @@ .footer-credit { color: var(--text-muted); } + +/* ── Activity center (4.3) ── */ +.activity-filters { + display: flex; + gap: var(--spacing-xs); + padding: var(--spacing-sm) var(--spacing-md); + border-bottom: 1px solid var(--border-subtle); + flex-wrap: wrap; +} + +.download-history.collapsed .activity-filters { + display: none; +} + +.activity-filter { + padding: 2px var(--spacing-sm); + background: var(--button-neutral-bg); + border: 1px solid var(--border-default); + border-radius: var(--radius-full); + color: var(--text-tertiary); + font-size: var(--text-xs); + cursor: pointer; + transition: var(--button-transition); +} + +.activity-filter:hover { + background: var(--button-neutral-bg-hover); + color: var(--text-primary); +} + +.activity-filter.selected { + background: var(--button-primary-bg); + border-color: var(--accent-dark); + color: var(--accent-contrast); +} + +.activity-filter:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + box-shadow: 0 0 0 4px var(--accent-subtle); +} + +.history-item-actions { + flex-wrap: wrap; + justify-content: flex-end; +} + +.history-open-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + box-shadow: 0 0 0 4px var(--accent-subtle); +} + +.history-error { + color: var(--danger); + font-size: var(--text-xs); + word-break: break-word; +} diff --git a/src/renderer/css/05-overlays.css b/src/renderer/css/05-overlays.css index 4e8e37c..b0f0362 100644 --- a/src/renderer/css/05-overlays.css +++ b/src/renderer/css/05-overlays.css @@ -1,3 +1,8 @@ +.modal-overlay:not(.active) .modal-box h3, +.modal-overlay:not(.active) .modal-message { + visibility: hidden; +} + .modal-overlay { position: fixed; inset: 0; @@ -371,6 +376,14 @@ background: var(--surface-3); } +.toast-dismiss:focus-visible { + outline: none; + color: var(--text-primary); + box-shadow: + 0 0 0 2px var(--accent-subtle), + 0 0 0 4px var(--accent); +} + .toast-warning { border-left: 3px solid var(--warning); } diff --git a/src/renderer/css/07-queue.css b/src/renderer/css/07-queue.css index 409cd48..0b4d5a4 100644 --- a/src/renderer/css/07-queue.css +++ b/src/renderer/css/07-queue.css @@ -247,8 +247,43 @@ .queue-item-status { flex-shrink: 0; - width: 20px; - text-align: center; + width: 10px; + height: 10px; + border-radius: var(--radius-full); + background: var(--text-muted); +} + +.queue-pending .queue-item-status { + background: var(--text-muted); +} + +.queue-downloading .queue-item-status { + background: var(--accent); + box-shadow: 0 0 0 2px var(--accent-subtle); + animation: queueStatusPulse 1.2s ease-in-out infinite; +} + +.queue-completed .queue-item-status { + background: var(--success); +} + +.queue-failed .queue-item-status { + background: var(--danger); +} + +.queue-cancelled .queue-item-status { + background: var(--text-muted); + opacity: 0.55; +} + +@keyframes queueStatusPulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } } .queue-item-url { @@ -307,62 +342,176 @@ color: var(--text-muted); } -.wizard-overlay { - position: fixed; - inset: 0; - display: none; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(8px); - z-index: 2000; +/* ── Rich queue rows (4.3) ── */ +.queue-item { + flex-wrap: wrap; + align-items: flex-start; + padding: var(--spacing-sm) var(--spacing-md); } -.wizard-overlay.active { - display: flex; +.queue-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; } -.wizard-card { - width: 90%; - max-width: 520px; - background: color-mix(in srgb, var(--surface-1) 32%, transparent); - backdrop-filter: blur(var(--blur-lg)); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: var(--radius-xl); - box-shadow: - var(--shadow-lg), - 0 16px 48px rgba(0, 0, 0, 0.4); - padding: var(--spacing-2xl) var(--spacing-xl) var(--spacing-xl); +.queue-item-status { + margin-top: 5px; +} + +.queue-item-content { + flex: 1; + min-width: 0; display: flex; flex-direction: column; - animation: cardEntrance 0.5s cubic-bezier(0.16, 1, 0.3, 1) both; + gap: 2px; +} + +.queue-item-title { + color: var(--text-secondary); + font-size: var(--text-sm); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.queue-item-meta { + color: var(--text-muted); + font-size: var(--text-xs); +} + +.queue-completed .queue-item-title { + color: var(--success); } -.wizard-progress { - height: 3px; +.queue-failed .queue-item-title { + color: var(--danger); +} + +.queue-cancelled .queue-item-title { + color: var(--text-muted); +} + +.queue-item-progress { + margin-top: var(--spacing-xs); +} + +.queue-item-progress-header { + display: flex; + justify-content: space-between; + gap: var(--spacing-sm); + color: var(--text-muted); + font-size: var(--text-xs); +} + +.queue-item-progress-track { + height: 4px; + margin-top: 3px; background: var(--surface-3); border-radius: 2px; - margin-bottom: var(--spacing-xl); overflow: hidden; } -.wizard-progress-bar { +.queue-item-progress-bar { height: 100%; - width: 25%; + width: 0%; background: var(--accent); border-radius: 2px; - transition: width var(--transition-normal); + transition: width 0.3s ease; } -.wizard-step { - display: none; - flex-direction: column; - align-items: center; - text-align: center; - min-height: 280px; +.queue-item-progress-bar.indeterminate { + width: 100%; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + background-size: 200% 100%; + animation: indeterminate 1.5s linear infinite; +} + +.queue-item-progress-details { + margin-top: 2px; + color: var(--text-muted); + font-size: var(--text-xs); + font-variant-numeric: tabular-nums; } -.wizard-step.active { +.queue-item-actions { display: flex; - animation: wizardFadeIn 0.3s ease both; + align-items: center; + gap: var(--spacing-xs); + flex-shrink: 0; + flex-wrap: wrap; +} + +.queue-item-action { + min-height: 26px; + padding: 2px var(--spacing-sm); + background: var(--button-neutral-bg); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: var(--text-xs); + cursor: pointer; + transition: var(--button-transition); + box-shadow: var(--button-neutral-shadow); +} + +.queue-item-action:hover:not(:disabled) { + background: var(--button-neutral-bg-hover); + border-color: var(--border-strong); + color: var(--text-primary); +} + +.queue-item-action:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.queue-item-action:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + box-shadow: 0 0 0 4px var(--accent-subtle); +} + +.queue-item-retry { + border-color: var(--accent-dark); + background: var(--button-primary-bg); + color: var(--accent-contrast); + box-shadow: var(--button-primary-shadow); +} + +.queue-item-retry:hover:not(:disabled) { + background: var(--button-primary-bg-hover); + color: var(--accent-contrast); +} + +.queue-item-remove:hover:not(:disabled) { + background: var(--button-danger-bg); + border-color: var(--danger); + color: var(--danger); +} + +.queue-item-details { + flex-basis: 100%; + margin-top: var(--spacing-xs); + color: var(--text-muted); + font-size: var(--text-xs); +} + +.queue-item-details summary { + cursor: pointer; + color: var(--text-tertiary); +} + +.queue-item-details summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.queue-item-details p { + margin: var(--spacing-xs) 0 0; + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--surface-1); + border-left: 2px solid var(--danger); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + font-family: var(--font-mono); + word-break: break-word; } diff --git a/src/renderer/css/08-wizard.css b/src/renderer/css/08-wizard.css index 23160eb..ce1b99c 100644 --- a/src/renderer/css/08-wizard.css +++ b/src/renderer/css/08-wizard.css @@ -9,6 +9,66 @@ } } +.wizard-overlay { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: var(--overlay-bg-heavy); + backdrop-filter: blur(var(--blur-md)); + z-index: var(--z-modal); +} + +.wizard-overlay.active { + display: flex; +} + +.wizard-card { + width: 90%; + max-width: 520px; + background: color-mix(in srgb, var(--surface-1) 32%, transparent); + backdrop-filter: blur(var(--blur-lg)); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: var(--radius-xl); + box-shadow: + var(--shadow-lg), + 0 16px 48px rgba(0, 0, 0, 0.4); + padding: var(--spacing-2xl) var(--spacing-xl) var(--spacing-xl); + display: flex; + flex-direction: column; + animation: cardEntrance 0.5s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.wizard-progress { + height: 3px; + background: var(--surface-3); + border-radius: 2px; + margin-bottom: var(--spacing-xl); + overflow: hidden; +} + +.wizard-progress-bar { + height: 100%; + width: 25%; + background: var(--accent); + border-radius: 2px; + transition: width var(--transition-normal); +} + +.wizard-step { + display: none; + flex-direction: column; + align-items: center; + text-align: center; + min-height: 280px; +} + +.wizard-step.active { + display: flex; + animation: wizardFadeIn 0.3s ease both; +} + .wizard-icon { margin-bottom: var(--spacing-lg); } @@ -134,13 +194,17 @@ .wizard-toggle input { position: absolute; opacity: 0; - pointer-events: none; + width: var(--toggle-width); + height: var(--toggle-height); + margin: 0; + cursor: pointer; + z-index: 1; } -/* Focus ring on wizard toggle slider proxy. */ .wizard-toggle input:focus-visible + .toggle-slider { - outline: 2px solid var(--accent); - outline-offset: 2px; + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 4px var(--accent-subtle); } .wizard-toggle input:checked + .toggle-slider { @@ -259,3 +323,91 @@ padding: var(--spacing-xl) var(--spacing-md) var(--spacing-md); } } + +/* ── Wizard download setup step (4.3) ── */ +.wizard-folder-choice { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + background: var(--surface-2); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); +} + +.wizard-folder-choice span { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.wizard-folder-choice strong { + font-size: var(--text-base); + font-weight: var(--weight-medium); + color: var(--text-primary); +} + +.wizard-folder-choice small { + font-size: var(--text-sm); + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wizard-inline-btn { + flex-shrink: 0; + padding: var(--spacing-xs) var(--spacing-md); + background: var(--button-neutral-bg); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: var(--text-sm); + cursor: pointer; + transition: var(--button-transition); +} + +.wizard-inline-btn:hover:not(:disabled) { + background: var(--button-neutral-bg-hover); + border-color: var(--border-strong); + color: var(--text-primary); +} + +.wizard-inline-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.wizard-inline-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + box-shadow: 0 0 0 4px var(--accent-subtle); +} + +.wizard-profile-choice { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + padding: var(--spacing-sm) var(--spacing-md); + background: var(--surface-2); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); +} + +.wizard-profile-choice legend { + padding: 0 var(--spacing-xs); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--text-primary); +} + +.wizard-profile-choice label { + display: flex; + align-items: center; + gap: var(--spacing-xs); + color: var(--text-secondary); + font-size: var(--text-sm); + cursor: pointer; +} diff --git a/src/renderer/css/splash.css b/src/renderer/css/splash.css new file mode 100644 index 0000000..5637f68 --- /dev/null +++ b/src/renderer/css/splash.css @@ -0,0 +1,256 @@ +body.splash-screen { + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100vh; + overflow: hidden; + background: var(--bg-gradient-mid); +} + +.splash-container { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 2rem; +} + +.logo-wrapper { + position: relative; + margin-bottom: 2rem; + animation: splashLogoEntrance 0.8s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +@keyframes splashLogoEntrance { + from { + opacity: 0; + transform: scale(0.8) translateY(10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.logo { + width: 100px; + height: 100px; + filter: none; + animation: splashLogoPulse 3s ease-in-out infinite; + position: relative; + z-index: 2; +} + +.logo-glow { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 160px; + height: 160px; + background: radial-gradient(circle, var(--accent-glow) 0%, transparent 70%); + border-radius: var(--radius-full); + animation: splashGlowPulse 3s ease-in-out infinite; + z-index: 0; + opacity: 0.5; +} + +.logo-ring { + position: absolute; + top: 50%; + left: 50%; + width: 140px; + height: 140px; + margin-top: -70px; + margin-left: -70px; + border: 1px solid var(--accent-subtle); + border-radius: var(--radius-full); + animation: + splashRingRotate 8s linear infinite, + splashRingFade 3s ease-in-out infinite; + z-index: 1; +} + +.logo-ring::before { + content: ''; + position: absolute; + top: -3px; + left: 50%; + width: 6px; + height: 6px; + margin-left: -3px; + background: var(--accent-light); + border-radius: var(--radius-full); +} + +.logo-ring-2 { + position: absolute; + top: 50%; + left: 50%; + width: 170px; + height: 170px; + margin-top: -85px; + margin-left: -85px; + border: 1px solid color-mix(in srgb, var(--accent-subtle) 55%, transparent); + border-radius: var(--radius-full); + animation: + splashRingRotate 12s linear infinite reverse, + splashRingFade 4s ease-in-out infinite 1s; + z-index: 0; +} + +@keyframes splashRingRotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes splashRingFade { + 0%, + 100% { + opacity: 0.4; + } + 50% { + opacity: 0.8; + } +} + +@keyframes splashLogoPulse { + 0%, + 100% { + transform: translateY(0) scale(1); + } + 50% { + transform: translateY(-6px) scale(1.02); + } +} + +@keyframes splashGlowPulse { + 0%, + 100% { + opacity: 0.4; + transform: translate(-50%, -50%) scale(1); + } + 50% { + opacity: 0.7; + transform: translate(-50%, -50%) scale(1.15); + } +} + +.app-title { + font-size: 2.2rem; + font-weight: 700; + letter-spacing: 0.12em; + margin-bottom: 0.4rem; + color: var(--text-primary); + animation: splashTitleEntrance 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.2s both; +} + +@keyframes splashTitleEntrance { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.app-subtitle { + font-size: 0.85rem; + color: var(--text-muted); + font-weight: 400; + letter-spacing: 0.04em; + margin-bottom: 2.5rem; + animation: splashTitleEntrance 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.35s both; +} + +.loading-container { + width: 180px; + animation: splashTitleEntrance 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.5s both; +} + +.loading-track { + height: 3px; + background: var(--surface-2); + border-radius: 2px; + overflow: hidden; + position: relative; +} + +.loading-track::after { + content: ''; + position: absolute; + top: 0; + left: -50%; + width: 50%; + height: 100%; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + border-radius: 2px; + animation: splashLoadingSlide 1.4s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +@keyframes splashLoadingSlide { + 0% { + left: -50%; + } + 100% { + left: 100%; + } +} + +.loading-label { + display: flex; + justify-content: center; + margin-top: 1rem; + font-size: 0.7rem; + color: var(--text-muted); + letter-spacing: 0.15em; + text-transform: uppercase; + animation: splashLoadingFade 2s ease-in-out infinite; +} + +@keyframes splashLoadingFade { + 0%, + 100% { + opacity: 0.55; + } + 50% { + opacity: 1; + } +} + +.version { + position: absolute; + bottom: 1.2rem; + right: 1.2rem; + font-size: 0.7rem; + font-family: var(--font-mono); + color: var(--text-muted); + letter-spacing: 0.05em; + opacity: 0; + animation: splashVersionFade 0.5s ease 0.8s forwards; +} + +@keyframes splashVersionFade { + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .splash-screen *, + .splash-screen *::before, + .splash-screen *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html index d3dc4eb..2b7cae4 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -41,6 +41,23 @@

Settings

+ + + @@ -220,6 +240,9 @@

Settings

aria-live="polite" >

+ @@ -288,6 +311,9 @@

Settings

yt-dlp cannot read, so ROSI uses Firefox cookies here. + @@ -348,11 +374,17 @@

Settings

Download notifications - +
+
+

Saved presets

+ Reuse this setup for downloads and queues +
+
+ + + +
+
+ + + + +
+

+
@@ -854,6 +926,49 @@

Download profile

+ +
@@ -1101,7 +1216,7 @@

Download profile

-
+
+
+ + + + +
@@ -1290,8 +1439,8 @@

Download profile

aria-hidden="true" > @@ -1350,7 +1499,7 @@

Open Source Licenses

class="wizard-progress" role="progressbar" aria-valuemin="1" - aria-valuemax="4" + aria-valuemax="5" aria-valuenow="1" aria-label="Setup progress" > @@ -1382,17 +1531,17 @@

Choose Your Theme

- +
-

Download Preferences

-

- Standard compatible video is ready by default. Enable Download Profiles later in - Settings for Best video, Audio, and Custom modes. -

+

Set Up Downloads

+

Choose where files go and a starting download profile.

+
+
+ + Download folder + Choose a folder or ask each time + + +
+ +
+ Default profile + + + +
+
+
+ + +
+

App Preferences

+

Choose how ROSI keeps you informed.

-
+
string; removeFromQueue: (id: string) => Promise | unknown; + retryQueueItem: (id: string) => Promise | unknown; + reorderQueueItem: (id: string, direction: QueueDirection) => Promise | unknown; + copyDiagnostics: (item: QueueItem) => Promise | unknown; + openFileLocation?: (filePath: string) => Promise | unknown; focusQueueItemId?: string | null; } interface QueueModule { renderQueue: (queue: QueueItem[], elements: QueueElements, deps: QueueDeps) => void; + updateQueueItemProgress: (item: QueueItem, elements: QueueElements) => boolean; resolveQueueSectionElement: (root?: Document) => HTMLElement | null; } @@ -38,14 +60,6 @@ cancelled: 'Cancelled', }; - const STATUS_ICONS: Record = { - pending: 'â¸ī¸', - downloading: 'âŗ', - completed: '✅', - failed: '❌', - cancelled: 'âšī¸', - }; - function resolveQueueSectionElement(root?: Document) { const doc = root ?? document; return doc.getElementById('queueSection') || doc.getElementById('queue-section'); @@ -59,9 +73,164 @@ } } + /** + * Find a row by id without building a selector, so no CSS.escape dependency + * and no chance of a crafted id breaking the lookup. + */ + function findQueueRow(queueList: HTMLElement, id: string): HTMLElement | null { + const rows = queueList.querySelectorAll('.queue-item'); + for (const row of rows) { + if (row.dataset.queueId === id) return row; + } + return null; + } + + function getUrlDisplay(url: string) { + try { + const parsed = new URL(url); + return parsed.hostname + parsed.pathname.slice(0, 40); + } catch { + return url.slice(0, 56); + } + } + + function formatBytes(bytes: number | undefined) { + if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return ''; + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / 1024 ** index; + return `${value >= 10 || index === 0 ? Math.round(value) : value.toFixed(1)} ${units[index]}`; + } + + function formatEta(seconds: number | undefined) { + if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return ''; + const rounded = Math.round(seconds); + const hours = Math.floor(rounded / 3600); + const minutes = Math.floor((rounded % 3600) / 60); + const remaining = rounded % 60; + return hours > 0 + ? `${hours}:${String(minutes).padStart(2, '0')}:${String(remaining).padStart(2, '0')}` + : `${minutes}:${String(remaining).padStart(2, '0')}`; + } + + function createActionButton( + label: string, + className: string, + item: QueueItem, + action: () => Promise | unknown, + ariaLabel = label + ) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `queue-item-action ${className}`; + button.dataset.queueId = item.id; + button.dataset.queueAction = className; + button.setAttribute('aria-label', ariaLabel); + button.textContent = label; + button.addEventListener('click', () => { + button.disabled = true; + button.setAttribute('aria-busy', 'true'); + void Promise.resolve(action()).finally(() => { + button.disabled = false; + button.removeAttribute('aria-busy'); + }); + }); + return button; + } + + function buildProgressBlock(item: QueueItem): HTMLElement | null { + const progress = item.progress; + if (item.status !== 'downloading' || !progress || progress.phase === 'idle') return null; + + const row = document.createElement('div'); + row.className = 'queue-item-progress'; + const header = document.createElement('div'); + header.className = 'queue-item-progress-header'; + const status = document.createElement('span'); + status.textContent = progress.status || STATUS_LABELS.downloading; + const percent = document.createElement('span'); + const clamped = Math.max(0, Math.min(100, progress.itemOverallPercent)); + percent.textContent = progress.indeterminate ? 'Workingâ€Ļ' : `${Math.round(clamped)}%`; + header.append(status, percent); + + const track = document.createElement('div'); + track.className = 'queue-item-progress-track'; + track.setAttribute('role', 'progressbar'); + track.setAttribute('aria-label', `${getHostname(item.url)} download progress`); + track.setAttribute('aria-valuemin', '0'); + track.setAttribute('aria-valuemax', '100'); + const bar = document.createElement('div'); + bar.className = 'queue-item-progress-bar'; + if (progress.indeterminate) { + bar.classList.add('indeterminate'); + track.removeAttribute('aria-valuenow'); + track.setAttribute('aria-valuetext', progress.status || 'Working'); + } else { + bar.style.width = `${clamped}%`; + track.setAttribute('aria-valuenow', String(Math.round(clamped))); + } + track.appendChild(bar); + + const detailParts: string[] = []; + if (progress.details) detailParts.push(progress.details); + if (typeof progress.speedBytesPerSecond === 'number') { + detailParts.push(`${formatBytes(progress.speedBytesPerSecond)}/s`); + } + const eta = formatEta(progress.etaSeconds); + if (eta) detailParts.push(`ETA ${eta}`); + const details = document.createElement('div'); + details.className = 'queue-item-progress-details'; + details.textContent = detailParts.join(' â€ĸ '); + + row.append(header, track); + if (details.textContent) row.appendChild(details); + return row; + } + + function appendProgress(content: HTMLElement, item: QueueItem) { + const block = buildProgressBlock(item); + if (block) content.appendChild(block); + } + + /** + * Patch only the active row's progress block. Progress arrives several times + * a second, so re-rendering the whole list would be wasteful and would steal + * focus from any queue control the user is currently on. Returns false when + * the row is not present, so the caller can fall back to a full render. + */ + function updateQueueItemProgress(item: QueueItem, elements: QueueElements): boolean { + const { queueList } = elements; + if (!queueList) return false; + const row = findQueueRow(queueList, item.id); + if (!row || !row.classList.contains(`queue-${item.status}`)) return false; + const content = row.querySelector('.queue-item-content'); + if (!content) return false; + + const existing = content.querySelector('.queue-item-progress'); + const next = buildProgressBlock(item); + if (!next) { + existing?.remove(); + return true; + } + if (existing) { + existing.replaceWith(next); + } else { + content.appendChild(next); + } + return true; + } + function renderQueue(queue: QueueItem[], elements: QueueElements, deps: QueueDeps) { const { queueList, queueSection, queueCount } = elements; - const { removeFromQueue, focusQueueItemId = null } = deps; + const { + removeFromQueue, + retryQueueItem, + reorderQueueItem, + copyDiagnostics, + openFileLocation, + focusQueueItemId = null, + } = deps; if (!queueList || !queueSection) return; if (queueCount) queueCount.textContent = String(queue.length); @@ -70,83 +239,146 @@ queueList.replaceChildren(); const emptyMessage = document.createElement('p'); emptyMessage.className = 'queue-empty-message'; - emptyMessage.textContent = 'No items in queue. Add URLs above to get started.'; + emptyMessage.textContent = 'No items in queue. Paste one or more links above to get started.'; queueList.appendChild(emptyMessage); return; } + + const pendingItems = queue.filter((item) => item.status === 'pending'); queueSection.classList.add('has-items'); queueList.replaceChildren(); const fragment = document.createDocumentFragment(); - queue.forEach((item) => { - // Build the row entirely with DOM APIs. All untrusted values (item.url, - // item.id, hostname) are assigned via textContent / properties / dataset, - // which the DOM escapes automatically — no HTML string interpolation, so - // a crafted URL cannot inject markup or attributes here. - const el = document.createElement('div'); - el.className = `queue-item queue-${item.status}`; - el.setAttribute('role', 'listitem'); - el.dataset.queueId = item.id; - const statusLabel = STATUS_LABELS[item.status]; - const statusIcon = STATUS_ICONS[item.status]; - - let urlDisplay: string; - try { - const parsed = new URL(item.url); - urlDisplay = parsed.hostname + parsed.pathname.slice(0, 30); - } catch { - urlDisplay = item.url.slice(0, 40); - } + queue.forEach((item) => { const hostname = getHostname(item.url); + const statusLabel = STATUS_LABELS[item.status]; + const row = document.createElement('div'); + row.className = `queue-item queue-${item.status}`; + row.setAttribute('role', 'listitem'); + row.dataset.queueId = item.id; const statusEl = document.createElement('span'); statusEl.className = 'queue-item-status'; statusEl.setAttribute('aria-hidden', 'true'); - statusEl.textContent = statusIcon; - const srEl = document.createElement('span'); srEl.className = 'sr-only'; srEl.textContent = statusLabel; - const urlEl = document.createElement('span'); - urlEl.className = 'queue-item-url'; - urlEl.title = item.url; - urlEl.textContent = urlDisplay; + const content = document.createElement('div'); + content.className = 'queue-item-content'; + const title = document.createElement('span'); + title.className = 'queue-item-title'; + title.title = item.filename || item.url; + title.textContent = item.filename || getUrlDisplay(item.url); + const meta = document.createElement('span'); + meta.className = 'queue-item-meta'; + const metaParts = [statusLabel]; + if (item.filename) metaParts.push(hostname); + const size = formatBytes(item.sizeBytes); + if (size) metaParts.push(size); + meta.textContent = metaParts.join(' â€ĸ '); + content.append(title, meta); + appendProgress(content, item); - el.append(statusEl, srEl, urlEl); + const actions = document.createElement('div'); + actions.className = 'queue-item-actions'; if (item.status === 'pending') { - const removeBtn = document.createElement('button'); - removeBtn.type = 'button'; - removeBtn.className = 'queue-item-remove'; - removeBtn.dataset.queueId = item.id; - removeBtn.setAttribute('aria-label', `Remove ${hostname} from queue`); - removeBtn.textContent = '✕'; - removeBtn.addEventListener('click', () => { - removeBtn.disabled = true; - void Promise.resolve(removeFromQueue(item.id)).finally(() => { - removeBtn.disabled = false; - }); + const pendingIndex = pendingItems.findIndex((candidate) => candidate.id === item.id); + const up = createActionButton( + 'Up', + 'queue-item-move-up', + item, + () => reorderQueueItem(item.id, 'up'), + `Move ${hostname} up in queue` + ); + up.disabled = pendingIndex <= 0; + const down = createActionButton( + 'Down', + 'queue-item-move-down', + item, + () => reorderQueueItem(item.id, 'down'), + `Move ${hostname} down in queue` + ); + down.disabled = pendingIndex === pendingItems.length - 1; + const remove = createActionButton( + 'Remove', + 'queue-item-remove', + item, + () => removeFromQueue(item.id), + `Remove ${hostname} from queue` + ); + actions.append(up, down, remove); + // The shortcut listens on the row so it works from any control inside + // it. The row itself is deliberately not focusable: a long queue would + // otherwise add hundreds of extra tab stops. + row.addEventListener('keydown', (event) => { + if (!event.altKey || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) return; + event.preventDefault(); + const direction: QueueDirection = event.key === 'ArrowUp' ? 'up' : 'down'; + if ((direction === 'up' && up.disabled) || (direction === 'down' && down.disabled)) + return; + void Promise.resolve(reorderQueueItem(item.id, direction)); }); - el.appendChild(removeBtn); + } else if (item.status === 'failed' || item.status === 'cancelled') { + const retry = createActionButton( + item.status === 'failed' ? 'Retry' : 'Requeue', + 'queue-item-retry', + item, + () => retryQueueItem(item.id), + `${item.status === 'failed' ? 'Retry' : 'Requeue'} ${hostname}` + ); + const copy = createActionButton( + 'Copy details', + 'queue-item-copy', + item, + () => copyDiagnostics(item), + `Copy diagnostics for ${hostname}` + ); + actions.append(retry, copy); + } else if (item.status === 'completed' && item.outputPath && openFileLocation) { + actions.append( + createActionButton( + 'Open folder', + 'queue-item-open', + item, + () => openFileLocation(item.outputPath as string), + `Open folder containing ${item.filename || hostname}` + ) + ); + } + + row.append(statusEl, srEl, content); + if (actions.childElementCount > 0) row.appendChild(actions); + + if (item.status === 'failed' || item.status === 'cancelled') { + const details = document.createElement('details'); + details.className = 'queue-item-details'; + const summary = document.createElement('summary'); + summary.textContent = item.status === 'failed' ? 'Failure details' : 'Cancellation details'; + const message = document.createElement('p'); + message.textContent = + item.error || + (item.status === 'cancelled' + ? 'This item was cancelled before it completed.' + : 'The download failed without additional diagnostic information.'); + details.append(summary, message); + row.appendChild(details); } - fragment.appendChild(el); + + fragment.appendChild(row); }); + queueList.appendChild(fragment); if (focusQueueItemId) { - const focusTarget = queueList.querySelector( - `.queue-item-remove[data-queue-id="${CSS.escape(focusQueueItemId)}"]` - ); - if (focusTarget) { - focusTarget.focus(); - return; - } - const pendingRemoves = queueList.querySelectorAll('.queue-item-remove'); - const lastPending = pendingRemoves[pendingRemoves.length - 1]; - if (lastPending) { - lastPending.focus(); - } + // Prefer an enabled control: after a retry or removal the first action can + // be disabled (for example "Up" on the new first item), and focusing a + // disabled button silently drops focus to the body. + const focusRow = findQueueRow(queueList, focusQueueItemId); + const focusTarget = + focusRow?.querySelector('[data-queue-action]:not([disabled])') ?? null; + focusTarget?.focus(); } } @@ -154,6 +386,7 @@ const moduleTarget = (windowRef.rosiModules ?? {}) as QueueModules; moduleTarget.queue = { renderQueue, + updateQueueItemProgress, resolveQueueSectionElement, }; windowRef.rosiModules = moduleTarget; diff --git a/src/renderer/modules/ui.ts b/src/renderer/modules/ui.ts index 687e82d..cfaa51d 100644 --- a/src/renderer/modules/ui.ts +++ b/src/renderer/modules/ui.ts @@ -120,6 +120,7 @@ typeof message === 'string' ? message : message == null ? '' : String(message); const dismissBtn = document.createElement('button'); + dismissBtn.type = 'button'; dismissBtn.className = 'toast-dismiss'; dismissBtn.setAttribute('aria-label', 'Dismiss'); dismissBtn.innerHTML = diff --git a/src/renderer/renderer-globals.d.ts b/src/renderer/renderer-globals.d.ts index 1ded808..eb01b45 100644 --- a/src/renderer/renderer-globals.d.ts +++ b/src/renderer/renderer-globals.d.ts @@ -27,10 +27,16 @@ interface RosiUpdaterProgressEvent { interface RosiJobProgressEvent { phase: 'download' | 'merge' | 'convert' | 'idle'; phasePercent: number; + itemOverallPercent: number; overallPercent: number; + queueItemId?: string; status: string; details?: string; indeterminate?: boolean; + downloadedBytes?: number; + totalBytes?: number; + speedBytesPerSecond?: number; + etaSeconds?: number; } interface RosiDownloadStats { totalDownloads: number; @@ -42,15 +48,37 @@ interface RosiDownloadStats { firstDownloadAt: number | null; lastDownloadAt: number | null; } +interface RosiDownloadCompletion { + id: string; + sessionId?: number; + owner: 'manual' | 'queue'; + queueItemId?: string; + outcome: 'success' | 'failed' | 'cancelled'; + statusMessage: string; + url: string; + profile?: 'best-video' | 'audio' | 'custom'; + presetId?: string; + presetName?: string; + request: Record; + filename?: string; + outputPath?: string; + sizeBytes?: number; + format?: string; + error?: string; + startedAt: number; + completedAt: number; +} +type RosiDownloadActivity = RosiDownloadCompletion; interface RosiRendererApi { restartApp: () => Promise; getChannel: () => 'github' | 'msstore'; 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: Record) => Promise>; resetSettings: () => void; openExternal: (url: string) => Promise>; @@ -58,6 +86,7 @@ interface RosiRendererApi { cancelDownload: () => void; cancelFormats: () => void; getAppVersion: () => Promise; + getAppPlatform: () => Promise; checkDenoInstalled: () => Promise; installDeno: () => Promise<{ success?: boolean; @@ -81,6 +110,7 @@ interface RosiRendererApi { ) => void ) => () => void; onComplete: (callback: (message: string) => void) => () => void; + onDownloadComplete: (callback: (completion: RosiDownloadCompletion) => void) => () => void; openFileLocation: (filePath: string) => Promise>; showNotification: (options: { title?: string; @@ -91,10 +121,21 @@ interface RosiRendererApi { importSettings: () => Promise>; getStats: () => Promise; resetStats: () => Promise>; + getDownloadActivity: () => Promise>; + clearDownloadActivity: () => Promise>; + onDownloadActivityUpdate: (callback: (activity: RosiDownloadActivity[]) => void) => () => void; logError: (message: string) => void; notifySettingsFlushed: () => void; - addToQueue: (urls: string[]) => Promise>; + addToQueue: ( + urls: string[], + options?: Record + ) => Promise>; removeFromQueue: (id: string) => Promise>; + retryQueueItem: (id: string) => Promise>; + reorderQueueItem: (request: { + id: string; + direction: 'up' | 'down'; + }) => Promise>; clearQueue: () => Promise>; getQueue: () => Promise; startQueue: () => Promise>; @@ -142,6 +183,15 @@ interface RosiQueueItem { id: string; status: 'pending' | 'downloading' | 'completed' | 'failed' | 'cancelled'; url: string; + addedAt?: number; + startedAt?: number; + completedAt?: number; + request?: Record; + progress?: RosiJobProgressEvent; + filename?: string; + outputPath?: string; + sizeBytes?: number; + error?: string; } interface RosiQueueModule { @@ -153,11 +203,22 @@ interface RosiQueueModule { queueCount: HTMLElement | null; }, deps: { - escapeHtml: (value: string) => string; removeFromQueue: (id: string) => Promise | unknown; + retryQueueItem: (id: string) => Promise | unknown; + reorderQueueItem: (id: string, direction: 'up' | 'down') => Promise | unknown; + copyDiagnostics: (item: RosiQueueItem) => Promise | unknown; + openFileLocation?: (filePath: string) => Promise | unknown; focusQueueItemId?: string | null; } ) => void; + updateQueueItemProgress: ( + item: RosiQueueItem, + elements: { + queueList: HTMLElement | null; + queueSection: HTMLElement | null; + queueCount: HTMLElement | null; + } + ) => boolean; resolveQueueSectionElement: (root?: Document) => HTMLElement | null; } diff --git a/src/renderer/rosiEngine.ts b/src/renderer/rosiEngine.ts index 97882e7..1d5cf72 100644 --- a/src/renderer/rosiEngine.ts +++ b/src/renderer/rosiEngine.ts @@ -6,6 +6,34 @@ function logError(context: string, error?: unknown) { } } +interface RosiPlaylistSelection { + mode: 'current' | 'all' | 'range'; + start?: number; + end?: number; +} + +interface RosiDownloadPreset { + id: string; + name: string; + profile: 'best-video' | 'audio' | 'custom'; + bestQuality?: boolean; + audioOnly?: boolean; + audioFormat?: string; + 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?: RosiPlaylistSelection; +} + interface RosiSettings { settingsVersion: number; theme: 'system' | 'light' | 'dark' | 'purple'; @@ -14,6 +42,7 @@ interface RosiSettings { queueCollapsed: boolean; downloadProfilesEnabled: boolean; downloadMode: 'best-video' | 'audio' | 'custom'; + downloadPresets: RosiDownloadPreset[]; askDownloadLocation: boolean; advancedOptions: boolean; audioOnly: boolean; @@ -47,10 +76,16 @@ interface RosiSettings { interface RosiJobProgressEvent { phase: 'download' | 'merge' | 'convert' | 'idle'; phasePercent: number; + itemOverallPercent: number; overallPercent: number; + queueItemId?: string; status: string; details?: string; indeterminate?: boolean; + downloadedBytes?: number; + totalBytes?: number; + speedBytesPerSecond?: number; + etaSeconds?: number; } function resolveProgressPhaseFlags( @@ -117,6 +152,47 @@ function isValidUrl(string: string) { } } +interface ExtractedUrlSet { + urls: string[]; + rejected: number; +} + +function normalizeHttpUrl(value: string): string | null { + const trimmed = value.trim(); + if (!isValidUrl(trimmed)) return null; + try { + const url = new URL(trimmed); + url.hash = ''; + return url.toString(); + } catch { + return null; + } +} + +function extractHttpUrls(rawValue: string): ExtractedUrlSet { + const candidates: string[] = []; + rawValue.split(/\r?\n/).forEach((line) => { + const trimmedLine = line.trim(); + if (!trimmedLine || trimmedLine.startsWith('#')) return; + candidates.push(...trimmedLine.split(/\s+/).filter(Boolean)); + }); + + const urls: string[] = []; + const seen = new Set(); + let rejected = 0; + candidates.forEach((candidate) => { + const normalized = normalizeHttpUrl(candidate); + if (!normalized) { + rejected += 1; + return; + } + if (seen.has(normalized)) return; + seen.add(normalized); + urls.push(normalized); + }); + return { urls, rejected }; +} + type ThemeName = 'system' | 'light' | 'dark' | 'purple'; let systemThemeMediaQuery: MediaQueryList | null = null; @@ -525,7 +601,7 @@ function showKeyboardShortcuts() { const modKey = getModifierKeyName(); showModal({ title: 'Keyboard Shortcuts', - message: `${modKey}+D - Restart application\n${modKey}+F - Focus URL input field\n${modKey}+, - Open settings (macOS menu)\n${modKey}+Shift+, - Toggle settings sidebar\n${modKey}+Enter - Submit queue URLs (when focused)`, + message: `${modKey}+D - Restart application\n${modKey}+F - Focus URL input field\n${modKey}+, - Open settings (macOS menu)\n${modKey}+Shift+, - Toggle settings sidebar\n${modKey}+Enter - Submit queue URLs (when focused)\nAlt+↑ / Alt+↓ - Move a pending queue item (when focused)`, buttons: [{ label: 'OK', primary: true }], }); } @@ -926,57 +1002,96 @@ function formatBytes(bytes: number) { } const HISTORY_KEY = 'rosi-download-history'; -const HISTORY_MAX = 20; -interface HistoryEntry { +interface LegacyHistoryEntry { filename: string; path: string | null; timestamp: number; status: 'success' | 'failed' | 'cancelled'; } -function loadHistory(): HistoryEntry[] { +/** + * Pre-4.3 downloads were tracked in localStorage. The main process is now the + * authoritative store, so these records are only read for display when the + * durable activity log is still empty. + */ +function loadLegacyHistory(): LegacyHistoryEntry[] { try { const data = localStorage.getItem(HISTORY_KEY); - return data ? (JSON.parse(data) as HistoryEntry[]) : []; + const parsed: unknown = data ? JSON.parse(data) : []; + return Array.isArray(parsed) ? (parsed as LegacyHistoryEntry[]) : []; } catch { return []; } } -function saveHistory(history: HistoryEntry[]) { +type ActivityFilter = 'all' | 'success' | 'failed' | 'cancelled'; + +interface ActivityRow { + id: string; + outcome: 'success' | 'failed' | 'cancelled'; + title: string; + subtitle: string; + timestamp: number; + url: string | null; + outputPath?: string; + error?: string; + request?: Record; +} + +let activityEntries: RosiDownloadActivity[] = []; +let activityFilter: ActivityFilter = 'all'; +let activityReplayHandler: ((entry: RosiDownloadActivity) => void) | null = null; + +function hostFromUrl(url: string | null | undefined) { + if (!url) return ''; try { - localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); - } catch (e) { - if ( - e instanceof DOMException && - (e.name === 'QuotaExceededError' || (e as DOMException).code === 22) - ) { - history.length = Math.max(1, Math.floor(history.length / 2)); - try { - localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); - } catch { - /* give up */ - } - } + return new URL(url).hostname; + } catch { + return ''; } } -function addHistoryEntry(entry: { - filename: string; - path: string | null; - status: HistoryEntry['status']; -}) { - const history = loadHistory(); - history.unshift({ - filename: entry.filename, - path: entry.path || null, - timestamp: Date.now(), - status: entry.status, - }); - if (history.length > HISTORY_MAX) history.length = HISTORY_MAX; - saveHistory(history); - renderHistory(); +function describeActivityProfile(entry: RosiDownloadActivity) { + if (entry.presetName) return entry.presetName; + if (entry.profile === 'best-video') return 'Best video'; + if (entry.profile === 'audio') return 'Audio'; + if (entry.profile === 'custom') return 'Custom'; + return ''; +} + +function toActivityRows(): ActivityRow[] { + if (activityEntries.length > 0) { + return activityEntries.map((entry) => { + const parts = [ + hostFromUrl(entry.url), + describeActivityProfile(entry), + typeof entry.sizeBytes === 'number' ? formatBytes(entry.sizeBytes) : '', + formatRelativeTime(entry.completedAt), + ].filter(Boolean); + return { + id: entry.id, + outcome: entry.outcome, + title: entry.filename || hostFromUrl(entry.url) || entry.url, + subtitle: parts.join(' â€ĸ '), + timestamp: entry.completedAt, + url: entry.url, + outputPath: entry.outputPath, + error: entry.error, + request: entry.request, + }; + }); + } + + return loadLegacyHistory().map((entry, index) => ({ + id: `legacy-${index}`, + outcome: entry.status, + title: entry.filename || 'Unknown file', + subtitle: formatRelativeTime(entry.timestamp), + timestamp: entry.timestamp, + url: null, + outputPath: entry.path ?? undefined, + })); } function formatRelativeTime(timestamp: number) { @@ -992,43 +1107,69 @@ function formatRelativeTime(timestamp: number) { return new Date(timestamp).toLocaleDateString(); } -function escapeHtml(str: string) { - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); +/** Opening a folder can fail (moved file, unmounted volume); surface that. */ +async function revealFileLocation(filePath: string) { + try { + const result = await window.api.openFileLocation(filePath); + if (!result || !result.ok) { + showToast(result?.error?.message || 'Could not open that file location.', { + type: 'warning', + }); + } + } catch { + showToast('Could not open that file location.', { type: 'warning' }); + } +} + +function createActivityActionButton(label: string, ariaLabel: string, action: () => void) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'history-open-btn'; + button.setAttribute('aria-label', ariaLabel); + button.textContent = label; + button.addEventListener('click', (event) => { + event.stopPropagation(); + action(); + }); + return button; } -function renderHistory() { +function renderActivity() { const historySection = document.getElementById('download-history'); const listEl = document.getElementById('history-list'); const countEl = document.getElementById('history-count'); if (!listEl || !historySection) return; - const history = loadHistory(); - if (countEl) countEl.textContent = String(history.length); + const rows = toActivityRows(); + const visibleRows = + activityFilter === 'all' ? rows : rows.filter((row) => row.outcome === activityFilter); + if (countEl) countEl.textContent = String(rows.length); - if (history.length === 0) { - historySection.classList.remove('visible'); - listEl.innerHTML = ''; + // The panel now stays mounted so the empty state remains discoverable. + historySection.classList.add('visible'); + listEl.replaceChildren(); + + if (visibleRows.length === 0) { + const empty = document.createElement('p'); + empty.className = 'history-empty'; + empty.textContent = + rows.length === 0 + ? 'No downloads yet. Finished, failed, and cancelled downloads will appear here.' + : 'No downloads match this filter.'; + listEl.appendChild(empty); return; } - historySection.classList.add('visible'); - listEl.innerHTML = ''; const fragment = document.createDocumentFragment(); - - history.forEach((entry) => { + visibleRows.forEach((row) => { const item = document.createElement('div'); item.className = 'history-item'; item.setAttribute('role', 'listitem'); const statusLabel = - entry.status === 'success' + row.outcome === 'success' ? 'Completed' - : entry.status === 'cancelled' + : row.outcome === 'cancelled' ? 'Cancelled' : 'Failed'; @@ -1036,44 +1177,98 @@ function renderHistory() { info.className = 'history-item-info'; const filenameEl = document.createElement('span'); filenameEl.className = 'history-filename'; - filenameEl.title = entry.filename; - filenameEl.textContent = entry.filename; + filenameEl.title = row.url || row.title; + filenameEl.textContent = row.title; const timeEl = document.createElement('span'); timeEl.className = 'history-time'; - timeEl.textContent = formatRelativeTime(entry.timestamp); + timeEl.textContent = row.subtitle || formatRelativeTime(row.timestamp); info.append(filenameEl, timeEl); + if (row.outcome === 'failed' && row.error) { + const errorEl = document.createElement('span'); + errorEl.className = 'history-error'; + errorEl.textContent = row.error; + info.appendChild(errorEl); + } const actions = document.createElement('div'); actions.className = 'history-item-actions'; const statusEl = document.createElement('span'); - statusEl.className = `history-status ${entry.status}`; + statusEl.className = `history-status ${row.outcome}`; statusEl.textContent = statusLabel; actions.appendChild(statusEl); - if (entry.status === 'success' && entry.path) { - const filePath = entry.path; - const openBtn = document.createElement('button'); - openBtn.type = 'button'; - openBtn.className = 'history-open-btn'; - openBtn.setAttribute('aria-label', `Open file location for ${entry.filename}`); - openBtn.textContent = 'Open'; - openBtn.addEventListener('click', (e) => { - e.stopPropagation(); - void window.api.openFileLocation(filePath); - }); - actions.appendChild(openBtn); + if (row.request && activityReplayHandler) { + const entry = activityEntries.find((candidate) => candidate.id === row.id); + if (entry) { + actions.appendChild( + createActivityActionButton('Download again', `Download ${row.title} again`, () => { + activityReplayHandler?.(entry); + }) + ); + } + } + if (row.url) { + const sourceUrl = row.url; + actions.appendChild( + createActivityActionButton('Copy source', `Copy source link for ${row.title}`, () => { + void navigator.clipboard.writeText(sourceUrl).then( + () => showToast('Source link copied.', { type: 'info' }), + () => showToast('Could not copy the source link.', { type: 'warning' }) + ); + }) + ); + } + if (row.outcome === 'success' && row.outputPath) { + const filePath = row.outputPath; + actions.appendChild( + createActivityActionButton('Open folder', `Open file location for ${row.title}`, () => { + void revealFileLocation(filePath); + }) + ); } item.append(info, actions); - fragment.appendChild(item); }); listEl.appendChild(fragment); } -function clearHistory() { - saveHistory([]); - renderHistory(); +function setActivityEntries(entries: RosiDownloadActivity[]) { + activityEntries = Array.isArray(entries) ? entries : []; + renderActivity(); +} + +function setActivityFilter(filter: ActivityFilter) { + activityFilter = filter; + document.querySelectorAll('.activity-filter').forEach((button) => { + const isSelected = button.dataset.activityFilter === filter; + button.classList.toggle('selected', isSelected); + button.setAttribute('aria-pressed', String(isSelected)); + }); + renderActivity(); +} + +async function clearActivity() { + try { + localStorage.removeItem(HISTORY_KEY); + } catch { + /* ignore */ + } + if (typeof window.api.clearDownloadActivity !== 'function') { + setActivityEntries([]); + return; + } + try { + const result = await window.api.clearDownloadActivity(); + if (!result || !result.ok) { + showToast(result?.error?.message || 'Could not clear activity.', { type: 'error' }); + return; + } + } catch { + showToast('Could not clear activity.', { type: 'error' }); + return; + } + setActivityEntries([]); } let isManualUpdateCheck = false; @@ -1529,7 +1724,7 @@ function launchSetupWizard( persistSettingsFn: (silent?: boolean, immediate?: boolean) => Promise | void, onComplete: () => void ) { - const TOTAL_STEPS = 4; + const TOTAL_STEPS = 5; let currentStep = 0; const overlay = document.getElementById('setup-wizard'); @@ -1572,6 +1767,54 @@ function launchSetupWizard( }); }); + // Destination step: the folder is optional when asking every time. + let wizardChosenFolder = settings.downloadFolder?.trim() || ''; + const wizardFolderSummary = document.getElementById('wizard-folder-summary'); + const wizardChooseFolderBtn = document.getElementById( + 'wizard-choose-folder' + ) as HTMLButtonElement | null; + const wizardAskLocation = document.getElementById( + 'wizard-ask-location' + ) as HTMLInputElement | null; + + const syncWizardFolderSummary = () => { + if (!wizardFolderSummary) return; + if (wizardAskLocation?.checked) { + wizardFolderSummary.textContent = 'ROSI will ask before every download'; + return; + } + wizardFolderSummary.textContent = wizardChosenFolder || 'Choose a folder or ask each time'; + }; + if (wizardAskLocation) { + wizardAskLocation.checked = !!settings.askDownloadLocation; + wizardAskLocation.addEventListener('change', syncWizardFolderSummary); + } + if (wizardChooseFolderBtn) { + wizardChooseFolderBtn.addEventListener('click', async () => { + wizardChooseFolderBtn.disabled = true; + try { + const chosen = await window.api.selectDownloadLocation(); + if (chosen) { + wizardChosenFolder = chosen; + if (wizardAskLocation) wizardAskLocation.checked = false; + syncWizardFolderSummary(); + } + } finally { + wizardChooseFolderBtn.disabled = false; + } + }); + } + const initialProfileValue = settings.downloadProfilesEnabled + ? settings.downloadMode === 'audio' + ? 'audio' + : 'best-video' + : 'standard'; + const initialProfileRadio = overlayEl.querySelector( + `input[name="wizard-profile"][value="${initialProfileValue}"]` + ); + if (initialProfileRadio) initialProfileRadio.checked = true; + syncWizardFolderSummary(); + function updateUI() { // Steps steps.forEach((step, i) => { @@ -1621,6 +1864,30 @@ function launchSetupWizard( applyThemeFn(settings.theme); } + // Destination + const askLocation = document.getElementById('wizard-ask-location') as HTMLInputElement | null; + if (askLocation) settings.askDownloadLocation = askLocation.checked; + if (wizardChosenFolder) settings.downloadFolder = wizardChosenFolder; + + // Default profile + const selectedProfile = overlayEl.querySelector( + 'input[name="wizard-profile"]:checked' + ); + const profile = selectedProfile?.value; + if (profile === 'best-video' || profile === 'audio') { + settings.downloadProfilesEnabled = true; + settings.downloadMode = profile; + settings.bestQuality = profile === 'best-video'; + settings.audioOnly = profile === 'audio'; + settings.advancedOptions = false; + if (profile === 'audio') settings.convertEnabled = false; + } else { + settings.downloadProfilesEnabled = false; + settings.bestQuality = false; + settings.audioOnly = false; + settings.advancedOptions = false; + } + // Download prefs const notifications = document.getElementById( 'wizard-notifications' @@ -1646,6 +1913,20 @@ function launchSetupWizard( 'checkUpdatesOnStartupToggle' ) as HTMLInputElement | null; if (checkUpdatesToggle) checkUpdatesToggle.checked = settings.checkUpdatesOnStartup; + const askLocationToggle = document.getElementById( + 'askDownloadLocationToggle' + ) as HTMLInputElement | null; + if (askLocationToggle) askLocationToggle.checked = settings.askDownloadLocation; + const profilesToggle = document.getElementById( + 'downloadProfilesToggle' + ) as HTMLInputElement | null; + if (profilesToggle) profilesToggle.checked = settings.downloadProfilesEnabled; + const folderSummary = document.getElementById('downloadFolderSummary'); + if (folderSummary) { + const folder = settings.downloadFolder?.trim(); + folderSummary.textContent = folder || 'Choose a folder'; + folderSummary.title = folder || 'Choose a folder before downloading'; + } onComplete(); } @@ -1758,13 +2039,14 @@ document.addEventListener('DOMContentLoaded', async () => { } catch (error) { logError('Failed to load settings', error); settings = { - settingsVersion: 6, + settingsVersion: 7, theme: 'system', showConsoleOutput: false, consoleCollapsed: false, queueCollapsed: false, downloadProfilesEnabled: false, downloadMode: 'best-video', + downloadPresets: [], askDownloadLocation: false, advancedOptions: false, audioFormat: 'mp3', @@ -1836,6 +2118,18 @@ document.addEventListener('DOMContentLoaded', async () => { } } + try { + const platform = await window.api.getAppPlatform(); + const taskbarSetting = document.getElementById('taskbarProgressSetting'); + const taskbarLinuxNote = document.getElementById('taskbarProgressLinuxNote'); + if (platform === 'linux') { + taskbarSetting?.setAttribute('hidden', ''); + taskbarLinuxNote?.classList.remove('hidden'); + } + } catch (e) { + logError('Could not resolve app platform for settings UI', e); + } + if (window.api.getChannel() !== 'msstore') { try { setupAutoUpdater(); @@ -2326,8 +2620,612 @@ document.addEventListener('DOMContentLoaded', async () => { }); } + // ── Playlist scope ────────────────────────────────────────────────────────── + const playlistScope = byId('playlistScope'); + const playlistRangeFields = byId('playlistRangeFields'); + const playlistRangeStart = byId('playlistRangeStart'); + const playlistRangeEnd = byId('playlistRangeEnd'); + const playlistScopeError = byId('playlistScopeError'); + const MAX_PLAYLIST_INDEX = 10_000; + + function getPlaylistMode(): 'current' | 'all' | 'range' { + const selected = document.querySelector( + 'input[name="playlist-scope"]:checked' + ); + const value = selected?.value; + return value === 'all' || value === 'range' ? value : 'current'; + } + + function syncPlaylistRangeVisibility() { + playlistRangeFields?.classList.toggle('hidden', getPlaylistMode() !== 'range'); + } + + function resetPlaylistScope() { + playlistScope?.classList.add('hidden'); + if (playlistScopeError) playlistScopeError.textContent = ''; + const currentRadio = document.querySelector( + 'input[name="playlist-scope"][value="current"]' + ); + if (currentRadio) currentRadio.checked = true; + syncPlaylistRangeVisibility(); + } + + function showPlaylistScope(itemCount: number | null) { + const wasHidden = !playlistScope || playlistScope.classList.contains('hidden'); + playlistScope?.classList.remove('hidden'); + // Only seed the range on first reveal so a typed value is never clobbered + // by a repeated preview of the same URL. + if (wasHidden && playlistRangeEnd && itemCount && itemCount > 0) { + playlistRangeEnd.value = String(Math.min(itemCount, MAX_PLAYLIST_INDEX)); + } + syncPlaylistRangeVisibility(); + } + + /** Returns a validated typed selection, or null when the range is invalid. */ + function resolvePlaylistSelection(): RosiPlaylistSelection | null { + if (!playlistScope || playlistScope.classList.contains('hidden')) { + return { mode: 'current' }; + } + const mode = getPlaylistMode(); + if (mode !== 'range') { + if (playlistScopeError) playlistScopeError.textContent = ''; + return { mode }; + } + const start = Number(playlistRangeStart?.value); + const end = Number(playlistRangeEnd?.value); + if ( + !Number.isInteger(start) || + !Number.isInteger(end) || + start < 1 || + end < start || + end > MAX_PLAYLIST_INDEX + ) { + const message = `Enter a playlist range using whole numbers from 1 to ${MAX_PLAYLIST_INDEX}, with the first value no larger than the second.`; + if (playlistScopeError) playlistScopeError.textContent = message; + showToast(message, { type: 'warning' }); + return null; + } + if (playlistScopeError) playlistScopeError.textContent = ''; + return { mode: 'range', start, end }; + } + + document.querySelectorAll('input[name="playlist-scope"]').forEach((radio) => { + radio.addEventListener('change', () => { + syncPlaylistRangeVisibility(); + if (playlistScopeError) playlistScopeError.textContent = ''; + }); + }); + [playlistRangeStart, playlistRangeEnd].forEach((input) => { + input?.addEventListener('input', () => { + if (playlistScopeError) playlistScopeError.textContent = ''; + }); + }); + resetPlaylistScope(); + + // ── Preview cache and debounce ────────────────────────────────────────────── + // Declared ahead of the URL wiring because the first syncPrimaryActionState() + // call happens during initialization. + const PREVIEW_CACHE_MAX = 20; + const PREVIEW_CACHE_TTL_MS = 10 * 60 * 1000; + const PREVIEW_DEBOUNCE_MS = 450; + const previewCache = new Map(); + let previewDebounceTimer: ReturnType | null = null; + let previewRequestToken = 0; + + function readPreviewCache(url: string): RosiVideoInfo | null { + const cached = previewCache.get(url); + if (!cached) return null; + if (Date.now() - cached.storedAt > PREVIEW_CACHE_TTL_MS) { + previewCache.delete(url); + return null; + } + return cached.info; + } + + function writePreviewCache(url: string, info: RosiVideoInfo) { + previewCache.set(url, { info, storedAt: Date.now() }); + while (previewCache.size > PREVIEW_CACHE_MAX) { + const oldestKey = previewCache.keys().next().value; + if (typeof oldestKey !== 'string') break; + previewCache.delete(oldestKey); + } + } + + function looksLikePlaylistUrl(url: string) { + try { + const parsed = new URL(url); + return parsed.searchParams.has('list') || /\/playlist(?:\/|$)/i.test(parsed.pathname); + } catch { + return false; + } + } + + // Remembered because setButtonLoading(false) restores the button's original + // markup, which would otherwise discard the label set during a request. + let previewButtonLabel = 'Preview'; + + function setPreviewButtonLabel(label: string) { + previewButtonLabel = label; + if (!previewBtn || previewBtn.classList.contains('loading')) return; + const textNodes = Array.from(previewBtn.childNodes).filter( + (node) => node.nodeType === Node.TEXT_NODE && (node.textContent || '').trim().length > 0 + ); + const target = textNodes[textNodes.length - 1]; + if (target) target.textContent = ` ${label}`; + } + + function restorePreviewButtonLabel() { + setPreviewButtonLabel(previewButtonLabel); + } + + function cancelScheduledPreview() { + if (previewDebounceTimer) { + clearTimeout(previewDebounceTimer); + previewDebounceTimer = null; + } + previewRequestToken += 1; + setPreviewButtonLabel('Preview'); + } + + function applyPreviewResult(url: string, info: RosiVideoInfo) { + lastPreviewUrl = url; + renderVideoPreview(info); + if (info.isPlaylist) { + showPlaylistScope(info.playlistCount); + } else { + resetPlaylistScope(); + } + setPreviewButtonLabel('Refresh'); + } + + /** Auto-preview is best effort: failures stay silent until asked manually. */ + function schedulePreview(url: string) { + if (previewDebounceTimer) clearTimeout(previewDebounceTimer); + const cached = readPreviewCache(url); + if (cached) { + applyPreviewResult(url, cached); + return; + } + previewDebounceTimer = setTimeout(() => { + previewDebounceTimer = null; + void runVideoPreview(true); + }, PREVIEW_DEBOUNCE_MS); + } + + // ── Saved presets ─────────────────────────────────────────────────────────── + const MAX_PRESETS = 20; + const downloadPresetSelect = byId('downloadPresetSelect'); + const applyPresetBtn = byId('applyPresetBtn'); + const presetNameInput = byId('presetNameInput'); + const savePresetBtn = byId('savePresetBtn'); + const deletePresetBtn = byId('deletePresetBtn'); + const presetStatus = byId('presetStatus'); + + function getPresets(): RosiDownloadPreset[] { + return Array.isArray(settings.downloadPresets) ? settings.downloadPresets : []; + } + + function getSelectedPreset(): RosiDownloadPreset | null { + const id = downloadPresetSelect?.value; + if (!id) return null; + return getPresets().find((preset) => preset.id === id) ?? null; + } + + function setPresetStatus(message: string) { + if (presetStatus) presetStatus.textContent = message; + } + + function renderPresetOptions(selectedId = downloadPresetSelect?.value ?? '') { + if (!downloadPresetSelect) return; + const presets = getPresets(); + downloadPresetSelect.replaceChildren(); + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = presets.length === 0 ? 'No saved presets' : 'Use current settings'; + downloadPresetSelect.appendChild(placeholder); + presets.forEach((preset) => { + const option = document.createElement('option'); + option.value = preset.id; + option.textContent = preset.name; + downloadPresetSelect.appendChild(option); + }); + downloadPresetSelect.value = presets.some((preset) => preset.id === selectedId) + ? selectedId + : ''; + downloadPresetSelect.disabled = presets.length === 0; + if (applyPresetBtn) applyPresetBtn.disabled = !downloadPresetSelect.value; + if (deletePresetBtn) deletePresetBtn.disabled = !downloadPresetSelect.value; + } + + function createPresetId(name: string) { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); + const base = slug || 'preset'; + const existing = new Set(getPresets().map((preset) => preset.id)); + let candidate = base; + let suffix = 2; + while (existing.has(candidate)) { + candidate = `${base}-${suffix}`; + suffix += 1; + } + return candidate; + } + + /** Snapshot only the safe, user-visible download options. */ + function buildPresetFromCurrentSettings(id: string, name: string): RosiDownloadPreset { + const preset: RosiDownloadPreset = { + id, + name, + profile: settings.downloadProfilesEnabled ? settings.downloadMode : 'best-video', + bestQuality: settings.bestQuality, + audioOnly: settings.audioOnly, + audioFormat: settings.audioFormat, + convertEnabled: settings.convertEnabled, + convertFormat: settings.convertFormat, + keepOriginalAfterConvert: settings.keepOriginalAfterConvert, + gpuAcceleration: settings.gpuAcceleration, + gpuType: settings.gpuType, + writeSubtitles: settings.writeSubtitles, + subtitleLangs: settings.subtitleLangs, + embedThumbnail: settings.embedThumbnail, + embedMetadata: settings.embedMetadata, + sponsorblockRemove: settings.sponsorblockRemove, + }; + const playlist = resolvePlaylistSelection(); + if (playlist && playlist.mode !== 'current') preset.playlist = playlist; + return preset; + } + + function applyPresetToSettings(preset: RosiDownloadPreset) { + settings.downloadProfilesEnabled = true; + applyDownloadProfile(preset.profile); + if (typeof preset.bestQuality === 'boolean') settings.bestQuality = preset.bestQuality; + if (typeof preset.audioOnly === 'boolean') settings.audioOnly = preset.audioOnly; + if (preset.audioFormat) settings.audioFormat = preset.audioFormat; + if (typeof preset.convertEnabled === 'boolean') settings.convertEnabled = preset.convertEnabled; + if (preset.convertFormat) settings.convertFormat = preset.convertFormat; + if (typeof preset.keepOriginalAfterConvert === 'boolean') { + settings.keepOriginalAfterConvert = preset.keepOriginalAfterConvert; + } + if (typeof preset.gpuAcceleration === 'boolean') { + settings.gpuAcceleration = preset.gpuAcceleration; + } + if (preset.gpuType) settings.gpuType = preset.gpuType; + if (typeof preset.writeSubtitles === 'boolean') settings.writeSubtitles = preset.writeSubtitles; + if (preset.subtitleLangs) settings.subtitleLangs = preset.subtitleLangs; + if (typeof preset.embedThumbnail === 'boolean') settings.embedThumbnail = preset.embedThumbnail; + if (typeof preset.embedMetadata === 'boolean') settings.embedMetadata = preset.embedMetadata; + if (typeof preset.sponsorblockRemove === 'boolean') { + settings.sponsorblockRemove = preset.sponsorblockRemove; + } + updateUIFromSettings(); + } + + /** Per-job overrides sent with queue additions. */ + function buildQueueRequestOverrides(outputPath?: string): Record | undefined { + const overrides: Record = {}; + if (outputPath) overrides.outputPath = outputPath; + const preset = getSelectedPreset(); + if (preset) { + overrides.presetId = preset.id; + overrides.presetName = preset.name; + } + const playlist = resolvePlaylistSelection(); + if (playlist && playlist.mode !== 'current') overrides.playlist = playlist; + return Object.keys(overrides).length > 0 ? overrides : undefined; + } + + if (downloadPresetSelect) { + downloadPresetSelect.addEventListener('change', () => { + if (applyPresetBtn) applyPresetBtn.disabled = !downloadPresetSelect.value; + if (deletePresetBtn) deletePresetBtn.disabled = !downloadPresetSelect.value; + const preset = getSelectedPreset(); + setPresetStatus(preset ? `${preset.name} selected for the next download.` : ''); + }); + } + if (applyPresetBtn) { + applyPresetBtn.addEventListener('click', () => { + const preset = getSelectedPreset(); + if (!preset) return; + applyPresetToSettings(preset); + void persistSettings(true, true); + setPresetStatus(`Applied ${preset.name}.`); + }); + } + if (savePresetBtn) { + savePresetBtn.addEventListener('click', async () => { + const name = presetNameInput?.value.trim() ?? ''; + if (!name) { + setPresetStatus('Enter a name before saving a preset.'); + presetNameInput?.focus(); + return; + } + const presets = getPresets(); + const existingIndex = presets.findIndex( + (preset) => preset.name.toLowerCase() === name.toLowerCase() + ); + if (existingIndex === -1 && presets.length >= MAX_PRESETS) { + setPresetStatus(`Preset limit reached (${MAX_PRESETS}). Delete one first.`); + return; + } + const id = + existingIndex >= 0 + ? (presets[existingIndex] as RosiDownloadPreset).id + : createPresetId(name); + const preset = buildPresetFromCurrentSettings(id, name); + const nextPresets = [...presets]; + if (existingIndex >= 0) { + nextPresets[existingIndex] = preset; + } else { + nextPresets.push(preset); + } + settings.downloadPresets = nextPresets; + const saved = await persistSettings(true, true); + renderPresetOptions(saved ? id : downloadPresetSelect?.value); + if (saved) { + if (presetNameInput) presetNameInput.value = ''; + setPresetStatus(`Saved ${preset.name}.`); + } else { + setPresetStatus('Could not save the preset.'); + } + }); + } + if (deletePresetBtn) { + deletePresetBtn.addEventListener('click', async () => { + const preset = getSelectedPreset(); + if (!preset) return; + settings.downloadPresets = getPresets().filter((candidate) => candidate.id !== preset.id); + const saved = await persistSettings(true, true); + renderPresetOptions(''); + setPresetStatus(saved ? `Deleted ${preset.name}.` : 'Could not delete the preset.'); + }); + } + renderPresetOptions(); + + // ── Settings search and per-section reset ─────────────────────────────────── + const settingsSearchInput = byId('settingsSearch'); + const settingsSearchStatus = byId('settingsSearchStatus'); + const settingsSections = Array.from(document.querySelectorAll('.settings-section')); + let collapseStateBeforeSearch: boolean[] | null = null; + + function applySettingsSearch(rawQuery: string) { + const query = rawQuery.trim().toLowerCase(); + if (!query) { + settingsSections.forEach((section, index) => { + section.classList.remove('search-hidden'); + section.querySelectorAll('.search-hidden').forEach((child) => { + child.classList.remove('search-hidden'); + }); + // Restore the collapse state the user had before searching. + if (collapseStateBeforeSearch) { + section.classList.toggle('collapsed', collapseStateBeforeSearch[index] === true); + const header = section.querySelector('.settings-section-header'); + header?.setAttribute('aria-expanded', String(collapseStateBeforeSearch[index] !== true)); + } + }); + collapseStateBeforeSearch = null; + if (settingsSearchStatus) settingsSearchStatus.textContent = ''; + return; + } + + if (!collapseStateBeforeSearch) { + collapseStateBeforeSearch = settingsSections.map((section) => + section.classList.contains('collapsed') + ); + } + + let matches = 0; + settingsSections.forEach((section) => { + const controls = Array.from( + section.querySelectorAll( + '.toggle-switch, .select-label, .settings-btn, .settings-link-btn, .sub-option, .preset-manager, .toggle-row' + ) + ).filter( + (control) => + !control.closest('.sub-option, .toggle-row') || + control.matches('.sub-option, .toggle-row') + ); + let sectionMatches = false; + controls.forEach((control) => { + const isMatch = (control.textContent || '').toLowerCase().includes(query); + control.classList.toggle('search-hidden', !isMatch); + if (isMatch) sectionMatches = true; + }); + const title = ( + section.querySelector('.settings-section-title')?.textContent || '' + ).toLowerCase(); + if (title.includes(query)) { + sectionMatches = true; + controls.forEach((control) => control.classList.remove('search-hidden')); + } + section.classList.toggle('search-hidden', !sectionMatches); + if (sectionMatches) { + matches += 1; + section.classList.remove('collapsed'); + section + .querySelector('.settings-section-header') + ?.setAttribute('aria-expanded', 'true'); + } + }); + + if (settingsSearchStatus) { + settingsSearchStatus.textContent = + matches === 0 + ? 'No settings match your search.' + : `${matches} section${matches === 1 ? '' : 's'} match your search.`; + } + } + + if (settingsSearchInput) { + settingsSearchInput.addEventListener('input', () => { + applySettingsSearch(settingsSearchInput.value); + }); + + // Clear a stale filter when the sidebar closes, so reopening Settings never + // shows a partially hidden list the user has forgotten about. + const sidebarEl = document.getElementById('sidebar'); + if (sidebarEl && typeof MutationObserver === 'function') { + let sidebarWasOpen = sidebarEl.classList.contains('open'); + const sidebarObserver = new MutationObserver(() => { + const isOpen = sidebarEl.classList.contains('open'); + if (sidebarWasOpen && !isOpen && settingsSearchInput.value) { + settingsSearchInput.value = ''; + applySettingsSearch(''); + } + sidebarWasOpen = isOpen; + }); + sidebarObserver.observe(sidebarEl, { attributes: true, attributeFilter: ['class'] }); + } + settingsSearchInput.addEventListener('keydown', (event) => { + if (event.key !== 'Escape' || !settingsSearchInput.value) return; + event.stopPropagation(); + settingsSearchInput.value = ''; + applySettingsSearch(''); + }); + } + + const SECTION_RESET_KEYS: Record> = { + download: [ + 'downloadProfilesEnabled', + 'downloadMode', + 'bestQuality', + 'audioOnly', + 'advancedOptions', + 'audioFormat', + 'convertEnabled', + 'convertFormat', + 'keepOriginalAfterConvert', + 'gpuAcceleration', + 'gpuType', + 'ffmpegPath', + ], + enhancements: [ + 'embedMetadata', + 'embedThumbnail', + 'sponsorblockRemove', + 'writeSubtitles', + 'subtitleLangs', + ], + browser: ['hookBrowser', 'browserChoice'], + interface: [ + 'showConsoleOutput', + 'consoleCollapsed', + 'animateBackground', + 'theme', + 'flatUi', + 'notifications', + 'showTaskbarProgress', + ], + application: ['checkUpdatesOnStartup', 'updateChannel'], + }; + + let defaultSettingsCache: RosiSettings | null = null; + async function loadDefaultSettings(): Promise { + if (defaultSettingsCache) return defaultSettingsCache; + if (typeof window.api.getDefaultSettings !== 'function') return null; + try { + const result = await window.api.getDefaultSettings(); + if (result && result.ok) { + defaultSettingsCache = result.data as RosiSettings; + return defaultSettingsCache; + } + } catch { + /* fall through */ + } + return null; + } + + document.querySelectorAll('.settings-section-reset').forEach((button) => { + button.addEventListener('click', async (event) => { + // Keep the click from bubbling into the section collapse handler. + event.stopPropagation(); + const sectionName = button.dataset.resetSection ?? ''; + const keys = SECTION_RESET_KEYS[sectionName]; + if (!keys) return; + const defaults = await loadDefaultSettings(); + if (!defaults) { + showToast('Could not load default settings.', { type: 'error' }); + return; + } + const settingsRecord = settings as unknown as Record; + const defaultsRecord = defaults as unknown as Record; + keys.forEach((key) => { + settingsRecord[key] = defaultsRecord[key]; + }); + updateUIFromSettings(); + applyTheme(settings.theme ?? 'system'); + const saved = await persistSettings(true, true); + showToast(saved ? 'Section restored to defaults.' : 'Could not save the restored section.', { + type: saved ? 'success' : 'error', + }); + }); + }); + + // ── Activity replay ───────────────────────────────────────────────────────── + async function replayActivityDownload(entry: RosiDownloadActivity) { + if (isDownloading) { + showToast('Wait for the current download to finish first.', { type: 'warning' }); + return; + } + const request = { ...(entry.request || {}) } as Record; + const outputPath = + typeof request.outputPath === 'string' && request.outputPath.trim() + ? request.outputPath + : settings.downloadFolder?.trim() || (await window.api.selectDownloadLocation()); + if (!outputPath) return; + request.outputPath = outputPath; + + isDownloading = true; + if (outputEl) outputEl.textContent = ''; + downloadAbort = () => { + isDownloading = false; + setButtonLoading(downloadBtn, false); + syncPrimaryActionState(); + }; + setButtonLoading(downloadBtn, true, () => { + window.api.cancelDownload(); + downloadAbort?.(); + hideProgressBar(); + }); + applyActiveDownloadProgressPhases(settings, 'Starting download...'); + try { + const result = await window.api.downloadVideo(request); + if (!result || result.ok !== true) { + isDownloading = false; + setButtonLoading(downloadBtn, false); + syncPrimaryActionState(); + hideProgressBar(); + showToast(result?.error?.message || 'Could not start that download again.', { + type: 'error', + }); + } + } catch (error) { + logError('Failed to replay download', error); + isDownloading = false; + setButtonLoading(downloadBtn, false); + syncPrimaryActionState(); + hideProgressBar(); + showToast('Could not start that download again.', { type: 'error' }); + } + } + let hasUrlValidationIntent = false; let lastPreviewUrl: string | null = null; + let pendingBatchUrls: string[] = []; + + function setDownloadButtonLabel(label: string) { + if (!downloadBtn) return; + const textSpan = downloadBtn.querySelector('span'); + if (textSpan) { + textSpan.textContent = label; + } else { + downloadBtn.textContent = label; + } + } + function syncPrimaryActionState() { const hasInput = !!urlInput; const hasPrimaryButton = !!downloadBtn; @@ -2335,7 +3233,10 @@ document.addEventListener('DOMContentLoaded', async () => { const raw = urlInput.value || ''; const trimmed = raw.trim(); const hasValue = trimmed.length > 0; - const validUrl = hasValue && isValidUrl(trimmed); + const extracted = extractHttpUrls(raw); + pendingBatchUrls = extracted.urls.length > 1 ? extracted.urls : []; + const isBatch = pendingBatchUrls.length > 1; + const validUrl = hasValue && (isBatch || isValidUrl(trimmed)); const showInvalid = hasUrlValidationIntent && hasValue && !validUrl; if (urlInputContainer) { @@ -2345,6 +3246,7 @@ document.addEventListener('DOMContentLoaded', async () => { } if (downloadCard) { downloadCard.classList.toggle('is-ready', validUrl); + downloadCard.classList.toggle('is-batch', isBatch); } if (showInvalid) { urlInput.setAttribute('aria-invalid', 'true'); @@ -2354,7 +3256,9 @@ document.addEventListener('DOMContentLoaded', async () => { } else { urlInput.removeAttribute('aria-invalid'); if (urlValidationMessage) { - urlValidationMessage.textContent = ''; + urlValidationMessage.textContent = isBatch + ? `${pendingBatchUrls.length} links detected. They will be added to the queue.` + : ''; } } @@ -2362,14 +3266,80 @@ document.addEventListener('DOMContentLoaded', async () => { if (!isLoading) { downloadBtn.disabled = !validUrl; downloadBtn.classList.toggle('is-disabled', !validUrl); + setDownloadButtonLabel(isBatch ? `Add ${pendingBatchUrls.length} to Queue` : 'Download'); } if (previewBtn && !previewBtn.classList.contains('loading')) { - previewBtn.disabled = !validUrl; + previewBtn.disabled = !validUrl || isBatch; } - if (trimmed !== lastPreviewUrl) { + if (isBatch || trimmed !== lastPreviewUrl) { hideVideoPreview(); lastPreviewUrl = null; + resetPlaylistScope(); + } + if (!isBatch && validUrl) { + schedulePreview(trimmed); + } else { + cancelScheduledPreview(); + } + } + + /** + * With "Ask every time" enabled, prompt once for the whole batch rather than + * per item, so a queue run is never interrupted by folder pickers. + */ + async function resolveQueueDestination(): Promise<{ outputPath?: string; cancelled: boolean }> { + if (!settings.askDownloadLocation) return { cancelled: false }; + try { + const chosen = await window.api.selectDownloadLocation(); + if (!chosen) return { cancelled: true }; + return { outputPath: chosen, cancelled: false }; + } catch (error) { + logError('Could not open the folder picker for the queue', error); + return { cancelled: true }; + } + } + + async function addUrlsToQueue(urls: string[], rejected = 0) { + // Guard here as well as in the button handler: the folder prompt below is + // awaited, and without the lock a second click could queue the batch twice. + if (queueActionLocks > 0) return false; + const endQueueAction = beginQueueAction(); + try { + const destination = await resolveQueueDestination(); + if (destination.cancelled) { + setQueueStatusMessage('Nothing was queued because no folder was chosen.'); + return false; + } + // Omit the second argument entirely when there is nothing to override. + const overrides = buildQueueRequestOverrides(destination.outputPath); + const result = overrides + ? await window.api.addToQueue(urls, overrides) + : await window.api.addToQueue(urls); + if (result && result.ok) { + const parts = [ + queueMessageForCount( + result.data.added, + 'Added 1 link to the queue.', + 'Added {count} links to the queue.' + ), + ]; + if (result.data.skipped > 0) parts.push(`${result.data.skipped} already queued.`); + if (rejected > 0) parts.push(`${rejected} ignored as invalid.`); + announceQueueAction(parts.join(' ')); + return true; + } + const message = result?.error?.message || 'Could not add links to the queue.'; + setQueueStatusMessage(message); + showToast(message, { type: 'error' }); + return false; + } catch { + const message = 'Could not add links to the queue.'; + setQueueStatusMessage(message); + showToast(message, { type: 'error' }); + return false; + } finally { + endQueueAction(); } } @@ -2398,8 +3368,7 @@ document.addEventListener('DOMContentLoaded', async () => { urlInput.addEventListener('keydown', (e) => { if (e.key !== 'Enter') return; e.preventDefault(); - const trimmed = urlInput.value.trim(); - if (trimmed && isValidUrl(trimmed) && downloadBtn && !downloadBtn.disabled) { + if (downloadBtn && !downloadBtn.disabled) { downloadBtn.click(); } else { hasUrlValidationIntent = true; @@ -2414,7 +3383,8 @@ document.addEventListener('DOMContentLoaded', async () => { try { const text = await navigator.clipboard.readText(); if (text && text.trim()) { - urlInput.value = text.trim(); + const { urls } = extractHttpUrls(text); + urlInput.value = urls.length > 1 ? urls.join('\n') : (urls[0] ?? text.trim()); urlInput.dispatchEvent(new Event('input')); urlInput.focus(); hasUrlValidationIntent = true; @@ -2442,25 +3412,42 @@ document.addEventListener('DOMContentLoaded', async () => { downloadCard.classList.remove('drag-over'); const dt = dragEvent.dataTransfer; const text = dt ? dt.getData('text/uri-list') || dt.getData('text/plain') : ''; - if (text && isValidUrl(text.trim()) && urlInput) { - urlInput.value = text.trim(); + const { urls } = extractHttpUrls(text); + if (urls.length > 0 && urlInput) { + urlInput.value = urls.length > 1 ? urls.join('\n') : (urls[0] as string); urlInput.dispatchEvent(new Event('input')); hasUrlValidationIntent = true; syncPrimaryActionState(); } else if (text) { - showToast('Dropped content is not a valid URL.', { type: 'warning' }); + showToast('Dropped content did not contain a valid http or https link.', { + type: 'warning', + }); } }); } - async function runVideoPreview() { + async function runVideoPreview(auto = false) { if (!urlInput || !previewBtn) return; const url = urlInput.value.trim(); if (!url || !isValidUrl(url)) { - showToast('Enter a valid URL first.', { type: 'warning' }); + if (!auto) showToast('Enter a valid URL first.', { type: 'warning' }); return; } - if (isFetchingPreview) return; + const cached = readPreviewCache(url); + if (cached && auto) { + applyPreviewResult(url, cached); + return; + } + if (isFetchingPreview) { + if (auto) return; + // Supersede the in-flight lookup so its cleanup cannot clear the loading + // state we are about to set. + window.api.cancelVideoInfo(); + previewAbort?.(); + } + // Stale in-flight replies are ignored via this generation token. + previewRequestToken += 1; + const requestToken = previewRequestToken; isFetchingPreview = true; const card = document.getElementById('preview-card'); @@ -2484,34 +3471,50 @@ document.addEventListener('DOMContentLoaded', async () => { ); try { - const result = await window.api.getVideoInfo(url); - if (wasCancelled) return; + const result = await window.api.getVideoInfo( + url, + looksLikePlaylistUrl(url) ? 'all' : 'current' + ); + if (wasCancelled || requestToken !== previewRequestToken) return; if (!result || result.ok !== true) { const message = result?.error?.message || 'Could not load preview.'; if (typeof message === 'string' && message.toLowerCase().includes('cancel')) return; hideVideoPreview(); - showToast(`Could not load preview. ${message}`, { type: 'error' }); + if (auto) { + setPreviewButtonLabel('Retry preview'); + } else { + showToast(`Could not load preview. ${message}`, { type: 'error' }); + } return; } - lastPreviewUrl = url; - renderVideoPreview(result.data as RosiVideoInfo); + const info = result.data as RosiVideoInfo; + writePreviewCache(url, info); + applyPreviewResult(url, info); } catch (e) { - if (!wasCancelled) { + if (!wasCancelled && requestToken === previewRequestToken) { hideVideoPreview(); - showToast('Could not load preview.', { type: 'error' }); + if (auto) { + setPreviewButtonLabel('Retry preview'); + } else { + showToast('Could not load preview.', { type: 'error' }); + } logError('Video preview failed', e); } } finally { if (!wasCancelled) { isFetchingPreview = false; setButtonLoading(previewBtn, false); + restorePreviewButtonLabel(); } } } + const runManualVideoPreview = () => { + void runVideoPreview(false); + }; if (previewBtn) { - previewBtn._originalClick = runVideoPreview; - previewBtn.onclick = runVideoPreview; + previewBtn._originalClick = runManualVideoPreview; + previewBtn.onclick = runManualVideoPreview; } if (previewCloseBtn) { previewCloseBtn.addEventListener('click', () => { @@ -2521,7 +3524,34 @@ document.addEventListener('DOMContentLoaded', async () => { }); } - renderHistory(); + renderActivity(); + + document.querySelectorAll('.activity-filter').forEach((button) => { + button.addEventListener('click', () => { + const filter = button.dataset.activityFilter; + if ( + filter === 'all' || + filter === 'success' || + filter === 'failed' || + filter === 'cancelled' + ) { + setActivityFilter(filter); + } + }); + }); + + activityReplayHandler = (entry) => { + void replayActivityDownload(entry); + }; + + if (typeof window.api.getDownloadActivity === 'function') { + window.api + .getDownloadActivity() + .then((result) => { + if (result && result.ok) setActivityEntries(result.data); + }) + .catch(() => {}); + } if (historyToggle) { const historySection = document.getElementById('download-history'); @@ -2566,16 +3596,17 @@ document.addEventListener('DOMContentLoaded', async () => { clearHistoryBtn.addEventListener('click', (e) => { e.stopPropagation(); showModal({ - title: 'Clear History', - message: 'Clear all download history?', + title: 'Clear Activity', + message: 'Clear the recorded download activity?', buttons: [ { label: 'Cancel' }, { label: 'Clear', danger: true, action: () => { - clearHistory(); - showToast('Download history cleared.', { type: 'info' }); + void clearActivity().then(() => { + showToast('Download activity cleared.', { type: 'info' }); + }); }, }, ], @@ -3104,8 +4135,74 @@ document.addEventListener('DOMContentLoaded', async () => { queue, { queueList, queueSection, queueCount }, { - escapeHtml, focusQueueItemId, + retryQueueItem: async (id: string) => { + if (typeof window.api.retryQueueItem !== 'function') return; + focusQueueItemId = id; + const endQueueAction = beginQueueAction(); + try { + const result = await window.api.retryQueueItem(id); + if (!result || !result.ok) { + focusQueueItemId = null; + const message = result?.error?.message || 'Could not retry the queue item.'; + setQueueStatusMessage(message); + showToast(message, { type: 'error' }); + } else { + // A running queue picks the item up on its own; otherwise the + // user still has to start it. + const queueIsRunning = currentQueue.some((item) => item.status === 'downloading'); + announceQueueAction( + queueIsRunning + ? 'Queued the item again. It will run after the current download.' + : 'Queued the item again. Start the queue to run it.' + ); + } + } catch { + focusQueueItemId = null; + const message = 'Could not retry the queue item.'; + setQueueStatusMessage(message); + showToast(message, { type: 'error' }); + } finally { + endQueueAction(); + } + }, + reorderQueueItem: async (id: string, direction: 'up' | 'down') => { + if (typeof window.api.reorderQueueItem !== 'function') return; + focusQueueItemId = id; + const endQueueAction = beginQueueAction(); + try { + const result = await window.api.reorderQueueItem({ id, direction }); + if (!result || !result.ok) { + focusQueueItemId = null; + const message = result?.error?.message || 'Could not reorder the queue item.'; + setQueueStatusMessage(message); + } else { + setQueueStatusMessage(`Moved item ${direction} in the queue.`); + } + } catch { + focusQueueItemId = null; + setQueueStatusMessage('Could not reorder the queue item.'); + } finally { + endQueueAction(); + } + }, + copyDiagnostics: async (item: RosiQueueItem) => { + const diagnostics = [ + `URL: ${item.url}`, + `Status: ${item.status}`, + item.filename ? `File: ${item.filename}` : '', + item.error ? `Error: ${item.error}` : '', + ] + .filter(Boolean) + .join('\n'); + try { + await navigator.clipboard.writeText(diagnostics); + setQueueStatusMessage('Copied diagnostics to the clipboard.'); + } catch { + showToast('Could not copy diagnostics to the clipboard.', { type: 'warning' }); + } + }, + openFileLocation: (filePath: string) => revealFileLocation(filePath), removeFromQueue: async (id: string) => { const removeIndex = currentQueue.findIndex((item) => item.id === id); const nextFocusId = @@ -3145,6 +4242,28 @@ document.addEventListener('DOMContentLoaded', async () => { } } + /** + * Attach transient per-item progress to the matching queue row. Progress is + * never persisted; a later queue-update broadcast replaces it wholesale. + */ + function applyQueueItemProgress(event: RosiJobProgressEvent) { + if (!event.queueItemId) return; + const target = currentQueue.find((item) => item.id === event.queueItemId); + if (!target) return; + if (event.phase === 'idle') { + delete target.progress; + } else { + target.progress = event; + } + // Patch the single active row; only fall back to a full render if the row + // is missing, so frequent progress ticks cannot steal focus. + const patched = + queueModule && typeof queueModule.updateQueueItemProgress === 'function' + ? queueModule.updateQueueItemProgress(target, { queueList, queueSection, queueCount }) + : false; + if (!patched) renderQueue(currentQueue); + } + let queueStatusTimer: ReturnType | null = null; function setQueueStatusMessage(message: unknown) { const statusEl = queueStatusMessage; @@ -3253,10 +4372,16 @@ document.addEventListener('DOMContentLoaded', async () => { queueUrlInput.classList.remove('drag-over'); const dt = dragEvent.dataTransfer; const text = dt ? dt.getData('text/uri-list') || dt.getData('text/plain') : ''; - if (text.trim()) { + const { urls } = extractHttpUrls(text); + const dropped = urls.length > 0 ? urls.join('\n') : ''; + if (dropped) { const existing = queueUrlInput.value.trim(); - queueUrlInput.value = existing ? `${existing}\n${text.trim()}` : text.trim(); + queueUrlInput.value = existing ? `${existing}\n${dropped}` : dropped; queueUrlInput.dispatchEvent(new Event('input')); + } else if (text.trim()) { + showToast('Dropped content did not contain a valid http or https link.', { + type: 'warning', + }); } }); @@ -3269,33 +4394,15 @@ document.addEventListener('DOMContentLoaded', async () => { showToast(message, { type: 'warning' }); return; } - const endQueueAction = beginQueueAction(); - try { - const urls = raw - .split(/\r?\n+/) - .map((u) => u.trim()) - .filter((u) => u.length > 0); - const result = await window.api.addToQueue(urls); - if (result && result.ok) { - queueUrlInput.value = ''; - const message = queueMessageForCount( - result.data.added, - 'Added 1 URL to the queue.', - 'Added {count} URLs to the queue.' - ); - announceQueueAction(message); - } else { - const message = result?.error?.message || 'Could not add URLs to the queue.'; - setQueueStatusMessage(message); - showToast(message, { type: 'error' }); - } - } catch { - const message = 'Could not add URLs to the queue.'; + const { urls, rejected } = extractHttpUrls(raw); + if (urls.length === 0) { + const message = 'No valid http or https links were found.'; setQueueStatusMessage(message); - showToast(message, { type: 'error' }); - } finally { - endQueueAction(); + showToast(message, { type: 'warning' }); + return; } + const added = await addUrlsToQueue(urls, rejected); + if (added) queueUrlInput.value = ''; }); } @@ -3418,6 +4525,20 @@ document.addEventListener('DOMContentLoaded', async () => { return; } + // Multiple links go to the queue instead of the single-download path. + const batch = extractHttpUrls(url); + if (batch.urls.length > 1) { + isDownloading = false; + const added = await addUrlsToQueue(batch.urls, batch.rejected); + if (added && urlInput) { + urlInput.value = ''; + hasUrlValidationIntent = false; + updateUrlButtons(); + } + syncPrimaryActionState(); + return; + } + // Validate URL format if (!isValidUrl(url.trim())) { isDownloading = false; @@ -3494,14 +4615,25 @@ document.addEventListener('DOMContentLoaded', async () => { audioFormat, }); const keepOriginal = settings.convertEnabled ? keepOriginalToggle?.checked : undefined; + const playlist = resolvePlaylistSelection(); + if (!playlist) { + isDownloading = false; + setButtonLoading(downloadBtn, false); + syncPrimaryActionState(); + hideProgressBar(); + return; + } + const activePreset = getSelectedPreset(); const startResult = await window.api.downloadVideo({ - url, + url: url.trim(), videoFormat, audioFormat, outputPath: savePath, convertFormat, keepOriginal, ffmpegPath: settings.ffmpegPath, + playlist, + ...(activePreset ? { presetId: activePreset.id, presetName: activePreset.name } : {}), }); if (!startResult || startResult.ok !== true) { isDownloading = false; @@ -3552,6 +4684,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (container && !container.classList.contains('visible') && event.phase !== 'idle') { showProgressBar(event.status); } + applyQueueItemProgress(event); if (event.phase === 'idle') { return; } @@ -3613,15 +4746,7 @@ document.addEventListener('DOMContentLoaded', async () => { hideProgressBar(); }, 2000); - const filename = lastDownloadedFilePath - ? (lastDownloadedFilePath.split(/[/\\]/).pop() ?? 'Unknown file') - : 'Unknown file'; - addHistoryEntry({ - filename, - path: lastDownloadedFilePath, - status: isSuccess ? 'success' : isCancelled ? 'cancelled' : 'failed', - }); - + // Activity records now come from the main process via download-complete. const restoreDefaultDownloadButton = () => { setButtonLoading(downloadBtn, false); syncPrimaryActionState(); @@ -3639,7 +4764,7 @@ document.addEventListener('DOMContentLoaded', async () => { lastDownloadedFilePath = null; }, 8000); } else if (isSuccess) { - downloadBtn.innerHTML = `✅ Download Complete!`; + downloadBtn.innerHTML = `Download complete`; downloadBtn.disabled = false; setTimeout(() => { restoreDefaultDownloadButton(); @@ -3656,6 +4781,14 @@ document.addEventListener('DOMContentLoaded', async () => { }) ); + if (typeof window.api.onDownloadActivityUpdate === 'function') { + ipcCleanupFunctions.push( + window.api.onDownloadActivityUpdate((activity) => { + setActivityEntries(activity); + }) + ); + } + ipcCleanupFunctions.push( window.api.onQueueUpdate((queue) => { renderQueue(queue); @@ -3695,6 +4828,7 @@ document.addEventListener('DOMContentLoaded', async () => { .catch(() => {}); window.addEventListener('beforeunload', () => { + cancelScheduledPreview(); ipcCleanupFunctions.forEach((cleanup) => { if (typeof cleanup === 'function') { try { diff --git a/src/renderer/splash.html b/src/renderer/splash.html index 5d5cb29..88be49d 100644 --- a/src/renderer/splash.html +++ b/src/renderer/splash.html @@ -4,315 +4,14 @@ Loading ROSI - + + + - +
@@ -327,7 +26,7 @@

ROSI

Loading
-
v4.3.0-beta.1
+
v4.3.0-beta.2
diff --git a/src/tests/buildScripts.updateMetainfo.test.ts b/src/tests/buildScripts.updateMetainfo.test.ts index 2d4cb3d..263fc86 100644 --- a/src/tests/buildScripts.updateMetainfo.test.ts +++ b/src/tests/buildScripts.updateMetainfo.test.ts @@ -3,7 +3,7 @@ import os from 'os'; import path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -const { updateMetainfo } = require('../../build-scripts/update-metainfo.js'); +const { updateMetainfo, updateSplash } = require('../../build-scripts/update-metainfo.js'); function makeTempRepo() { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rosi-metainfo-')); @@ -86,4 +86,15 @@ describe('update-metainfo helper', () => { '\n \n ' ); }); + + it('updates splash.html version via updateSplash', () => { + const repoRoot = makeTempRepo(); + tempDirs.push(repoRoot); + writeRepoFiles(repoRoot, '4.1.6', ' '); + + updateSplash({ repoRoot, version: '4.1.6' }); + + const splash = fs.readFileSync(path.join(repoRoot, 'src', 'renderer', 'splash.html'), 'utf8'); + expect(splash).toContain('id="version-display">v4.1.6
'); + }); }); 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<Parameters<NonNullable<RosiModules['queue']>['renderQueue']>[2]> = {} + ) => ({ + removeFromQueue: vi.fn(), + retryQueueItem: vi.fn(), + reorderQueueItem: vi.fn(), + copyDiagnostics: vi.fn(), + openFileLocation: vi.fn(), + ...overrides, + }); + beforeEach(() => { document.body.innerHTML = '<section id="queueSection"><div id="queue-list"></div><span id="queue-count"></span></section>'; @@ -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/"><img src=x onerror=alert(1)>'; 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<HTMLButtonElement>('.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<HTMLButtonElement>('.queue-item-retry')!.click(); + expect(retryQueueItem).toHaveBeenCalledWith('q_failed'); + document.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.queue-item-move-up'); + const downButtons = document.querySelectorAll<HTMLButtonElement>('.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> = {}): 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> = {}): 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> = {}): 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<typeof vi.fn>, + 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<typeof vi.fn>, + 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<typeof vi.fn>, }); 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<typeof vi.fn>, + }); + await loadEngine(api); + await flush(20); + expect(document.querySelectorAll('.history-item')).toHaveLength(2); + + document.querySelector<HTMLButtonElement>('[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<Omit<DownloadRequestOptions, 'url'>>; + +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<void>; getChannel: () => DistributionChannel; getFormats: (url: string) => Promise<IpcResult<string>>; - getVideoInfo: (url: string) => Promise<IpcResult<VideoInfo>>; + getVideoInfo: (url: string, playlistMode?: 'current' | 'all') => Promise<IpcResult<VideoInfo>>; cancelVideoInfo: () => void; selectDownloadLocation: () => Promise<string | null>; getSettings: () => Promise<Settings>; + getDefaultSettings: () => Promise<IpcResult<Settings>>; saveSettings: (settings: Partial<Settings>) => Promise<IpcResult<Settings>>; resetSettings: () => void; openExternal: (url: string) => Promise<IpcResult<{ opened: boolean }>>; @@ -203,6 +306,7 @@ export interface RendererApi { cancelDownload: () => void; cancelFormats: () => void; getAppVersion: () => Promise<string>; + getAppPlatform: () => Promise<NodeJS.Platform>; checkDenoInstalled: () => Promise<boolean>; 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<IpcResult<{ opened: boolean }>>; showNotification: (options: NotificationRequest) => Promise<IpcResult<{ shown: boolean }>>; exportSettings: () => Promise<IpcResult<{ exported: boolean }>>; importSettings: () => Promise<IpcResult<{ imported: boolean }>>; getStats: () => Promise<DownloadStats>; resetStats: () => Promise<IpcResult<void>>; + getDownloadActivity: () => Promise<IpcResult<DownloadActivity[]>>; + clearDownloadActivity: () => Promise<IpcResult<void>>; + onDownloadActivityUpdate: (callback: (activity: DownloadActivity[]) => void) => () => void; logError: (message: string) => void; notifySettingsFlushed: () => void; - addToQueue: (urls: string[]) => Promise<IpcResult<{ added: number }>>; + addToQueue: ( + urls: string[], + options?: QueueRequestOverrides + ) => Promise<IpcResult<{ added: number; skipped: number }>>; removeFromQueue: (id: string) => Promise<IpcResult<void>>; + retryQueueItem: (id: string) => Promise<IpcResult<void>>; + reorderQueueItem: (request: QueueReorderRequest) => Promise<IpcResult<void>>; clearQueue: () => Promise<IpcResult<void>>; getQueue: () => Promise<QueueItem[]>; startQueue: () => Promise<IpcResult<{ started: boolean }>>; 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<T> = { 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<str return { ok: true, data: value.trim() }; } +export function validatePlaylistSelectionPayload( + value: unknown +): ValidationResult<PlaylistSelection> { + 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<DownloadPreset[]> { + 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<string>(); + const names = new Set<string>(); + 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<string> { + 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<QueueReorderRequest> { + 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<DownloadRequestOptions> { @@ -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<string, unknown>)[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<P continue; } + if (rawKey === 'downloadPresets') { + const presetValidation = validatePresetList(rawValue); + if (!presetValidation.ok) return presetValidation; + patch.downloadPresets = presetValidation.data; + continue; + } + if ( rawKey === 'showConsoleOutput' || rawKey === 'consoleCollapsed' || diff --git a/vitest.config.ts b/vitest.config.ts index 5b50ac9..efcd224 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,9 @@ export default defineConfig({ test: { globals: true, environment: 'node', + env: { + VITEST: 'true', + }, include: ['src/**/*.test.ts'], testTimeout: 10_000, hookTimeout: 10_000,