diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 2abd0f5..949d88f 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1110,8 +1110,13 @@ fn append_ytdlp_config_option(config: &mut String, option: &str, value: &str) {
let safe_value = sanitize_ytdlp_config_value(value);
if !safe_value.is_empty() {
config.push_str(option);
- config.push('\n');
- config.push_str(&safe_value);
+ config.push(' ');
+ // yt-dlp parses one configuration line at a time. Quoting keeps
+ // whitespace and cookie delimiters inside this option's single value,
+ // rather than turning them into additional input URLs.
+ config.push('\'');
+ config.push_str(&safe_value.replace('\'', "'\\''"));
+ config.push('\'');
config.push('\n');
}
}
@@ -4676,7 +4681,8 @@ fn set_extension_frontend_ready(state: tauri::State<'_, AppState>, ready: bool)
#[cfg(test)]
mod tests {
use super::{
- aggregate_media_fraction, append_ytdlp_http_headers, build_media_format_options,
+ aggregate_media_fraction, append_ytdlp_config_option, append_ytdlp_http_headers,
+ build_media_format_options,
collect_download_uris, drain_media_output_lines, filename_from_content_disposition,
filename_from_url_disposition_query, filename_from_url_path, is_excluded_yt_dlp_format,
is_browser_cookie_extraction_error, json_lower, media_metadata_cache_key,
@@ -4731,12 +4737,22 @@ mod tests {
append_ytdlp_http_headers(
&mut config,
Some("Referer: https://example.com/video"),
- Some("session=abc\r\n--proxy=http://bad.invalid"),
+ Some("session=abc; preference=high\r\n--proxy=http://bad.invalid"),
)
.unwrap();
- assert!(config.contains("--add-header\nReferer: https://example.com/video\n"));
- assert!(config.contains("--add-header\nCookie: session=abc--proxy=http://bad.invalid\n"));
+ assert_eq!(
+ config,
+ "--add-header 'Referer: https://example.com/video'\n--add-header 'Cookie: session=abc; preference=high--proxy=http://bad.invalid'\n"
+ );
+ }
+
+ #[test]
+ fn ytdlp_config_options_quote_embedded_single_quotes() {
+ let mut config = String::new();
+ append_ytdlp_config_option(&mut config, "--username", "sam's account");
+
+ assert_eq!(config, "--username 'sam'\\''s account'\n");
}
#[test]
diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx
index 9e299f0..00296c6 100644
--- a/src/components/AddDownloadsModal.tsx
+++ b/src/components/AddDownloadsModal.tsx
@@ -791,6 +791,10 @@ export const AddDownloadsModal = () => {
: 'Unknown';
const canSubmit = canSubmitMetadataRows(parsedItems);
const failedMetadataCount = parsedItems.filter(item => item.status === 'metadata-error').length;
+ const failedMediaMetadataCount = parsedItems.filter(
+ item => item.status === 'metadata-error' && item.isMedia
+ ).length;
+ const fallbackMetadataCount = failedMetadataCount - failedMediaMetadataCount;
return (
<>
@@ -844,7 +848,7 @@ export const AddDownloadsModal = () => {
/>
- {parsedItems.filter(item => item.status === 'ready').length} ready, {failedMetadataCount} fallback
+ {parsedItems.filter(item => item.status === 'ready').length} ready, {fallbackMetadataCount} fallback, {failedMediaMetadataCount} media retry
) : (
item.status === 'metadata-error'
- ? 'Fallback'
+ ? item.isMedia ? 'Metadata failed' : 'Fallback'
: item.status === 'invalid'
? 'Invalid'
: 'Ready'
@@ -987,7 +991,7 @@ export const AddDownloadsModal = () => {
) : (
- Metadata unavailable. Default media format will be used.
+ Metadata unavailable. Refresh metadata before adding this media.
)}
diff --git a/src/utils/addDownloadMetadata.test.ts b/src/utils/addDownloadMetadata.test.ts
index deafceb..fd1afbd 100644
--- a/src/utils/addDownloadMetadata.test.ts
+++ b/src/utils/addDownloadMetadata.test.ts
@@ -187,11 +187,15 @@ describe('add download metadata workflow', () => {
expect(updated[0]).toBe(current);
});
- it('allows ready and failed rows but blocks loading and invalid rows', () => {
+ it('allows normal-download fallback but blocks unresolved explicit media', () => {
expect(canSubmitMetadataRows([
row(),
row({ id: 'fallback', status: 'metadata-error' })
])).toBe(true);
+ expect(canSubmitMetadataRows([
+ row(),
+ row({ id: 'media-fallback', status: 'metadata-error', isMedia: true })
+ ])).toBe(false);
expect(canSubmitMetadataRows([row({ status: 'loading' })])).toBe(false);
expect(canSubmitMetadataRows([row({ status: 'invalid' })])).toBe(false);
});
@@ -246,6 +250,9 @@ describe('add download metadata workflow', () => {
expect(metadataSummaryMessage([
row({ status: 'metadata-error' })
])).toContain('can still be added');
+ expect(metadataSummaryMessage([
+ row({ status: 'metadata-error', isMedia: true })
+ ])).toContain('Refresh metadata before adding');
expect(metadataSummaryMessage([
row({ status: 'invalid' })
])).toContain('Correct or remove 1 invalid URL');
diff --git a/src/utils/addDownloadMetadata.ts b/src/utils/addDownloadMetadata.ts
index 96ef3ef..d63874e 100644
--- a/src/utils/addDownloadMetadata.ts
+++ b/src/utils/addDownloadMetadata.ts
@@ -146,7 +146,10 @@ export const refreshFailedMetadataRows = (
export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean =>
rows.length > 0
- && rows.every(row => row.status === 'ready' || row.status === 'metadata-error');
+ && rows.every(row =>
+ row.status === 'ready'
+ || (!row.isMedia && row.status === 'metadata-error')
+ );
export const mediaFormatSelectorForRow = (
row: AddDownloadDraftRow
@@ -203,7 +206,11 @@ export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
}
const failed = rows.filter(row => row.status === 'metadata-error').length;
+ const failedMedia = rows.filter(row => row.status === 'metadata-error' && row.isMedia).length;
const ready = rows.filter(row => row.status === 'ready').length;
+ if (failedMedia > 0) {
+ return `Media metadata is unavailable for ${failedMedia} item${failedMedia === 1 ? '' : 's'}. Refresh metadata before adding.`;
+ }
if (failed === rows.length) {
return 'Metadata is unavailable. Downloads can still be added using fallback details.';
}