Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@

| <img height="20" src="https://github.com/user-attachments/assets/340d360e-79b1-4c70-bfab-d944085f75df" /> Windows | <img height="20" src="https://github.com/user-attachments/assets/42d7e887-4616-4e8c-b1d3-e44e01340f8c" /> macOS | <img height="20" src="https://github.com/user-attachments/assets/e0cc4f33-4516-408b-9c5c-be71a3ac316b" /> 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) |
| <div align="center"><a href="https://apps.microsoft.com/detail/9p4q134b2jw3?referrer=appbadge&mode=direct"><img src="https://get.microsoft.com/images/en-us%20dark.svg" width="150"/></a></div> | **[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) |
| <div align="center"><a href="https://apps.microsoft.com/detail/9p4q134b2jw3?referrer=appbadge&mode=direct"><img src="https://get.microsoft.com/images/en-us%20dark.svg" width="150"/></a></div> | **[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.
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions com.burnttoasters.rosi.metainfo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
</screenshot>
</screenshots>
<releases>
<release version="4.3.0-beta.2" date="2026-07-25"/>
<release version="4.3.0-beta.1" date="2026-07-25"/>
<release version="4.2.1" date="2026-07-20"/>
<release version="4.2.0" date="2026-07-14"/>
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 6 additions & 1 deletion src/main/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
72 changes: 58 additions & 14 deletions src/main/download/commandBuilders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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',
Expand All @@ -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,
];
Expand Down Expand Up @@ -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');
}
Expand Down
10 changes: 9 additions & 1 deletion src/main/download/jobProgressReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -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;
Expand Down
53 changes: 49 additions & 4 deletions src/main/download/videoInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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),
Expand All @@ -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),
};
}
Expand All @@ -72,7 +88,26 @@ export function cancelVideoInfo(): void {
}
}

export function fetchVideoInfo(ytdlpPath: string, url: string): Promise<VideoInfo> {
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<VideoInfo> {
if (!isSafeHttpUrl(url)) {
return Promise.reject('Invalid URL provided');
}
Expand All @@ -89,9 +124,19 @@ export function fetchVideoInfo(ytdlpPath: string, url: string): Promise<VideoInf
}
}

const resolvedPlaylistMode = resolveVideoInfoPlaylistMode(url, playlistMode);
const playlistArgs =
resolvedPlaylistMode === 'all'
? [
'--yes-playlist',
'--flat-playlist',
'--playlist-end',
String(PLAYLIST_PREVIEW_ENTRY_LIMIT),
]
: ['--no-playlist'];
const proc = spawnWithEnv(ytdlpPath, [
'--dump-single-json',
'--no-playlist',
...playlistArgs,
'--no-warnings',
'--skip-download',
'--',
Expand Down
Loading
Loading