mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-19 15:46:17 +00:00
fix(downloads): handle root category folders and cookie fallback
Allow explicit empty category subfolders to resolve to the base download folder across settings, UI, and backend ownership paths. Retry yt-dlp metadata and media downloads without browser cookies when the selected browser cookie database cannot be copied, while preserving real auth failures. Fixes #6 Fixes #7
This commit is contained in:
@@ -178,10 +178,13 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
|
|||||||
.get(&category)
|
.get(&category)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| category.clone());
|
.unwrap_or_else(|| category.clone());
|
||||||
std::path::PathBuf::from(&settings.base_download_folder)
|
let base = std::path::PathBuf::from(&settings.base_download_folder);
|
||||||
.join(subfolder)
|
let destination = if subfolder.is_empty() {
|
||||||
.to_string_lossy()
|
base
|
||||||
.to_string()
|
} else {
|
||||||
|
base.join(subfolder)
|
||||||
|
};
|
||||||
|
destination.to_string_lossy().to_string()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
let default_destination = settings
|
let default_destination = settings
|
||||||
|
|||||||
+65
-10
@@ -1007,6 +1007,14 @@ fn append_ytdlp_http_headers(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_browser_cookie_extraction_error(message: &str) -> bool {
|
||||||
|
let lower = message.to_ascii_lowercase();
|
||||||
|
lower.contains("could not copy") && lower.contains("cookie database")
|
||||||
|
|| lower.contains("could not access browser cookie database")
|
||||||
|
|| lower.contains("failed to read browser cookie")
|
||||||
|
|| lower.contains("failed to decrypt with dpapi")
|
||||||
|
}
|
||||||
|
|
||||||
fn should_cleanup_media_artifacts_after_failure(
|
fn should_cleanup_media_artifacts_after_failure(
|
||||||
failure_reason: &str,
|
failure_reason: &str,
|
||||||
strike: usize,
|
strike: usize,
|
||||||
@@ -1332,15 +1340,28 @@ async fn fetch_media_metadata(
|
|||||||
drop(cache_guard);
|
drop(cache_guard);
|
||||||
|
|
||||||
let result = fetch_media_metadata_uncached(
|
let result = fetch_media_metadata_uncached(
|
||||||
app_handle,
|
app_handle.clone(),
|
||||||
url,
|
url.clone(),
|
||||||
cookie_browser,
|
cookie_browser.clone(),
|
||||||
username,
|
username.clone(),
|
||||||
password,
|
password.clone(),
|
||||||
proxy,
|
proxy.clone(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
let result = match (result, cookie_browser.as_deref()) {
|
||||||
|
(Err(error), Some(browser))
|
||||||
|
if !browser.trim().is_empty() && is_browser_cookie_extraction_error(&error) =>
|
||||||
|
{
|
||||||
|
log::warn!(
|
||||||
|
"yt-dlp could not read browser cookies from {}; retrying media metadata without browser cookies",
|
||||||
|
browser
|
||||||
|
);
|
||||||
|
fetch_media_metadata_uncached(app_handle, url, None, username, password, proxy).await
|
||||||
|
}
|
||||||
|
(result, _) => result,
|
||||||
|
};
|
||||||
|
|
||||||
let result = match result {
|
let result = match result {
|
||||||
Ok(metadata) if metadata.formats.is_empty() => {
|
Ok(metadata) if metadata.formats.is_empty() => {
|
||||||
Err("yt-dlp returned no usable media formats for this URL".to_string())
|
Err("yt-dlp returned no usable media formats for this URL".to_string())
|
||||||
@@ -2667,6 +2688,8 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
let max_retries = max_tries.unwrap_or(0).max(0) as usize;
|
let max_retries = max_tries.unwrap_or(0).max(0) as usize;
|
||||||
let mut strike = 0_usize;
|
let mut strike = 0_usize;
|
||||||
let mut processing_started = false;
|
let mut processing_started = false;
|
||||||
|
let mut effective_cookie_source = cookie_source.clone();
|
||||||
|
let mut browser_cookie_fallback_used = false;
|
||||||
|
|
||||||
while strike <= max_retries {
|
while strike <= max_retries {
|
||||||
let ytdlp_path = resolve_bundled_binary_path(&app_handle, "yt-dlp")?;
|
let ytdlp_path = resolve_bundled_binary_path(&app_handle, "yt-dlp")?;
|
||||||
@@ -2714,7 +2737,7 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(cs) = cookie_source.as_ref() {
|
if let Some(cs) = effective_cookie_source.as_ref() {
|
||||||
let mut cs = cs.clone();
|
let mut cs = cs.clone();
|
||||||
if !cs.is_empty() && cs != "none" {
|
if !cs.is_empty() && cs != "none" {
|
||||||
if cs == "safari" {
|
if cs == "safari" {
|
||||||
@@ -2754,7 +2777,7 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
let mut temp_info_path = None;
|
let mut temp_info_path = None;
|
||||||
if let Ok(mut cache) = get_metadata_cache().lock() {
|
if let Ok(mut cache) = get_metadata_cache().lock() {
|
||||||
let info_cache_key =
|
let info_cache_key =
|
||||||
metadata_info_cache_key(&url, cookie_source.as_deref(), proxy.as_deref());
|
metadata_info_cache_key(&url, effective_cookie_source.as_deref(), proxy.as_deref());
|
||||||
if let Some(json_str) = cache.remove(&info_cache_key) {
|
if let Some(json_str) = cache.remove(&info_cache_key) {
|
||||||
let temp_dir = std::env::temp_dir();
|
let temp_dir = std::env::temp_dir();
|
||||||
let path = temp_dir.join(format!("firelink_ytdlp_{}.info.json", id));
|
let path = temp_dir.join(format!("firelink_ytdlp_{}.info.json", id));
|
||||||
@@ -2941,6 +2964,21 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
if should_cleanup_media_artifacts_after_failure(&failure_reason, strike, max_retries) {
|
if should_cleanup_media_artifacts_after_failure(&failure_reason, strike, max_retries) {
|
||||||
cleanup_media_artifacts(&out_path, false).await;
|
cleanup_media_artifacts(&out_path, false).await;
|
||||||
}
|
}
|
||||||
|
if !browser_cookie_fallback_used
|
||||||
|
&& effective_cookie_source
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|source| !source.trim().is_empty() && source != "none")
|
||||||
|
&& is_browser_cookie_extraction_error(&failure_reason)
|
||||||
|
{
|
||||||
|
let source = effective_cookie_source.clone().unwrap_or_default();
|
||||||
|
log::warn!(
|
||||||
|
"yt-dlp could not read browser cookies from {}; retrying media download without browser cookies",
|
||||||
|
source
|
||||||
|
);
|
||||||
|
effective_cookie_source = None;
|
||||||
|
browser_cookie_fallback_used = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if !(transient && strikes_left) {
|
if !(transient && strikes_left) {
|
||||||
return Err(failure_reason);
|
return Err(failure_reason);
|
||||||
}
|
}
|
||||||
@@ -4423,8 +4461,9 @@ mod tests {
|
|||||||
aggregate_media_fraction, append_ytdlp_http_headers, build_media_format_options,
|
aggregate_media_fraction, append_ytdlp_http_headers, build_media_format_options,
|
||||||
collect_download_uris, filename_from_content_disposition,
|
collect_download_uris, filename_from_content_disposition,
|
||||||
filename_from_url_disposition_query, filename_from_url_path, is_excluded_yt_dlp_format,
|
filename_from_url_disposition_query, filename_from_url_path, is_excluded_yt_dlp_format,
|
||||||
json_lower, media_output_template, media_progress_speed, normalize_speed_limit_for_aria2,
|
is_browser_cookie_extraction_error, json_lower, media_output_template,
|
||||||
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line, redact_log_line,
|
media_progress_speed, normalize_speed_limit_for_aria2, parse_firelink_deep_link,
|
||||||
|
parse_ffmpeg_version, parse_media_progress_line, redact_log_line,
|
||||||
sanitize_ytdlp_config_value, should_cleanup_media_artifacts_after_failure,
|
sanitize_ytdlp_config_value, should_cleanup_media_artifacts_after_failure,
|
||||||
FirelinkDeepLink, MediaProgress, MEDIA_PROGRESS_PREFIX,
|
FirelinkDeepLink, MediaProgress, MEDIA_PROGRESS_PREFIX,
|
||||||
};
|
};
|
||||||
@@ -4714,6 +4753,22 @@ mod tests {
|
|||||||
assert_eq!(option.filesize_approx, Some(1_468_000_000));
|
assert_eq!(option.filesize_approx, Some(1_468_000_000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_browser_cookie_database_errors_for_fallback() {
|
||||||
|
assert!(is_browser_cookie_extraction_error(
|
||||||
|
"ERROR: Could not copy Chrome cookie database. See https://github.com/yt-dlp/yt-dlp/issues/7271"
|
||||||
|
));
|
||||||
|
assert!(is_browser_cookie_extraction_error(
|
||||||
|
"failed to read browser cookie data"
|
||||||
|
));
|
||||||
|
assert!(!is_browser_cookie_extraction_error(
|
||||||
|
"ERROR: Sign in to confirm you are not a bot"
|
||||||
|
));
|
||||||
|
assert!(!is_browser_cookie_extraction_error(
|
||||||
|
"ERROR: requested format is not available"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "requires network and a local yt-dlp executable"]
|
#[ignore = "requires network and a local yt-dlp executable"]
|
||||||
fn filters_live_youtube_metadata_from_env() {
|
fn filters_live_youtube_metadata_from_env() {
|
||||||
|
|||||||
@@ -150,6 +150,10 @@ fn default_category_subfolders() -> HashMap<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_category_subfolder(value: &str, fallback: &str) -> String {
|
fn normalize_category_subfolder(value: &str, fallback: &str) -> String {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
let parts = value
|
let parts = value
|
||||||
.split(['/', '\\'])
|
.split(['/', '\\'])
|
||||||
.filter(|part| !part.is_empty() && *part != "." && *part != ".." && !part.ends_with(':'))
|
.filter(|part| !part.is_empty() && *part != "." && *part != ".." && !part.ends_with(':'))
|
||||||
@@ -181,7 +185,7 @@ fn migrate_location_settings(state: &mut Value) -> Result<(), String> {
|
|||||||
let mut subfolders = default_category_subfolders();
|
let mut subfolders = default_category_subfolders();
|
||||||
if let Some(persisted) = state.get("categorySubfolders").and_then(Value::as_object) {
|
if let Some(persisted) = state.get("categorySubfolders").and_then(Value::as_object) {
|
||||||
for (category, value) in persisted {
|
for (category, value) in persisted {
|
||||||
if let Some(folder) = value.as_str().filter(|folder| !folder.trim().is_empty()) {
|
if let Some(folder) = value.as_str() {
|
||||||
let fallback = subfolders
|
let fallback = subfolders
|
||||||
.get(category)
|
.get(category)
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -251,11 +255,13 @@ fn normalize_location_path(path: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn derived_location_path(base: &str, subfolder: &str) -> String {
|
fn derived_location_path(base: &str, subfolder: &str) -> String {
|
||||||
format!(
|
let base = normalize_location_path(base);
|
||||||
"{}/{}",
|
let subfolder = subfolder.trim_matches(|character| character == '/' || character == '\\');
|
||||||
normalize_location_path(base),
|
if subfolder.is_empty() {
|
||||||
subfolder.trim_matches(|character| character == '/' || character == '\\')
|
base
|
||||||
)
|
} else {
|
||||||
|
format!("{base}/{subfolder}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_settings() -> PersistedSettings {
|
fn default_settings() -> PersistedSettings {
|
||||||
@@ -451,6 +457,24 @@ mod tests {
|
|||||||
assert_eq!(settings.category_subfolders["Documents"], "Documents");
|
assert_eq!(settings.category_subfolders["Documents"], "Documents");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_empty_category_subfolder_as_base_folder() {
|
||||||
|
let stored = json!({
|
||||||
|
"state": {
|
||||||
|
"baseDownloadFolder": "/Users/test/Downloads",
|
||||||
|
"categorySubfolders": {
|
||||||
|
"Movies": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 3
|
||||||
|
});
|
||||||
|
|
||||||
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(settings.category_subfolders["Movies"], "");
|
||||||
|
assert_eq!(settings.category_subfolders["Documents"], "Documents");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replaces_zero_concurrency_with_the_safe_default() {
|
fn replaces_zero_concurrency_with_the_safe_default() {
|
||||||
let stored = json!({"state": {"maxConcurrentDownloads": 0}, "version": 0});
|
let stored = json!({"state": {"maxConcurrentDownloads": 0}, "version": 0});
|
||||||
|
|||||||
@@ -175,7 +175,9 @@ const CategoryFolderInput = ({
|
|||||||
onBrowse: () => void;
|
onBrowse: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const base = settings.baseDownloadFolder || '~/Downloads';
|
const base = settings.baseDownloadFolder || '~/Downloads';
|
||||||
const sub = settings.categorySubfolders[category] || DEFAULT_CATEGORY_SUBFOLDERS[category as keyof typeof DEFAULT_CATEGORY_SUBFOLDERS];
|
const sub = Object.prototype.hasOwnProperty.call(settings.categorySubfolders, category)
|
||||||
|
? settings.categorySubfolders[category]
|
||||||
|
: DEFAULT_CATEGORY_SUBFOLDERS[category as keyof typeof DEFAULT_CATEGORY_SUBFOLDERS];
|
||||||
const override = settings.categoryDirectoryOverrides[category];
|
const override = settings.categoryDirectoryOverrides[category];
|
||||||
const displayPath = override ?? formatDerivedCategoryPath(base, sub);
|
const displayPath = override ?? formatDerivedCategoryPath(base, sub);
|
||||||
|
|
||||||
@@ -480,7 +482,9 @@ runEngineChecks(false);
|
|||||||
DOWNLOAD_CATEGORIES.map(category => [
|
DOWNLOAD_CATEGORIES.map(category => [
|
||||||
category,
|
category,
|
||||||
normalizeCategorySubfolder(
|
normalizeCategorySubfolder(
|
||||||
settings.categorySubfolders[category] || '',
|
Object.prototype.hasOwnProperty.call(settings.categorySubfolders, category)
|
||||||
|
? settings.categorySubfolders[category]
|
||||||
|
: DEFAULT_CATEGORY_SUBFOLDERS[category],
|
||||||
DEFAULT_CATEGORY_SUBFOLDERS[category]
|
DEFAULT_CATEGORY_SUBFOLDERS[category]
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -65,6 +65,18 @@ describe('download locations', () => {
|
|||||||
expect(await resolveCategoryDestination(automatic, 'Movies')).toBe('/Volumes/Media');
|
expect(await resolveCategoryDestination(automatic, 'Movies')).toBe('/Volumes/Media');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps an explicit empty category subfolder as the base folder', async () => {
|
||||||
|
const settings = normalizeDownloadLocationSettings({
|
||||||
|
baseDownloadFolder: '/Users/test/Downloads',
|
||||||
|
categorySubfolders: { Movies: '' }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(settings.categorySubfolders.Movies).toBe('');
|
||||||
|
expect(settings.categorySubfolders.Documents).toBe('Documents');
|
||||||
|
expect(formatDerivedCategoryPath('/Users/test/Downloads', '')).toBe('/Users/test/Downloads');
|
||||||
|
expect(await resolveCategoryDestination(settings, 'Movies')).toBe('/Users/test/Downloads');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps category subfolders relative and permits nested folders', () => {
|
it('keeps category subfolders relative and permits nested folders', () => {
|
||||||
expect(normalizeCategorySubfolder('../Media/./Movies', 'Movies')).toBe('Media/Movies');
|
expect(normalizeCategorySubfolder('../Media/./Movies', 'Movies')).toBe('Media/Movies');
|
||||||
expect(normalizeCategorySubfolder('C:\\Media\\Movies', 'Movies')).toBe('Media/Movies');
|
expect(normalizeCategorySubfolder('C:\\Media\\Movies', 'Movies')).toBe('Media/Movies');
|
||||||
|
|||||||
@@ -55,11 +55,16 @@ const stringRecord = (value: unknown): Record<string, string> => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasOwn = (value: Record<string, string>, key: string): boolean =>
|
||||||
|
Object.prototype.hasOwnProperty.call(value, key);
|
||||||
|
|
||||||
const normalizedForComparison = (value: string): string =>
|
const normalizedForComparison = (value: string): string =>
|
||||||
value.replace(/\\/g, '/').replace(/\/+$/, '');
|
value.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||||
|
|
||||||
const legacyDerivedPath = (base: string, subfolder: string): string =>
|
const legacyDerivedPath = (base: string, subfolder: string): string =>
|
||||||
`${normalizedForComparison(base)}/${subfolder.replace(/^[\\/]+|[\\/]+$/g, '')}`;
|
[normalizedForComparison(base), subfolder.replace(/^[\\/]+|[\\/]+$/g, '')]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('/');
|
||||||
|
|
||||||
const isWindowsLikePath = (value: string): boolean =>
|
const isWindowsLikePath = (value: string): boolean =>
|
||||||
/^[a-z]:[\\/]/i.test(value) || value.startsWith('\\\\') || value.includes('\\');
|
/^[a-z]:[\\/]/i.test(value) || value.startsWith('\\\\') || value.includes('\\');
|
||||||
@@ -67,6 +72,7 @@ const isWindowsLikePath = (value: string): boolean =>
|
|||||||
export const formatDerivedCategoryPath = (base: string, subfolder: string): string => {
|
export const formatDerivedCategoryPath = (base: string, subfolder: string): string => {
|
||||||
const trimmedBase = base.trim() || '~/Downloads';
|
const trimmedBase = base.trim() || '~/Downloads';
|
||||||
const relative = subfolder.replace(/^[\\/]+|[\\/]+$/g, '');
|
const relative = subfolder.replace(/^[\\/]+|[\\/]+$/g, '');
|
||||||
|
if (!relative) return trimmedBase.replace(/[\\/]+$/, '');
|
||||||
const separator = isWindowsLikePath(trimmedBase) ? '\\' : '/';
|
const separator = isWindowsLikePath(trimmedBase) ? '\\' : '/';
|
||||||
return `${trimmedBase.replace(/[\\/]+$/, '')}${separator}${relative}`;
|
return `${trimmedBase.replace(/[\\/]+$/, '')}${separator}${relative}`;
|
||||||
};
|
};
|
||||||
@@ -90,6 +96,9 @@ export const normalizeCategorySubfolder = (
|
|||||||
value: string,
|
value: string,
|
||||||
fallback: string
|
fallback: string
|
||||||
): string => {
|
): string => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
|
||||||
const parts = value
|
const parts = value
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\\/g, '/')
|
.replace(/\\/g, '/')
|
||||||
@@ -107,13 +116,15 @@ export const normalizeDownloadLocationSettings = (
|
|||||||
'~/Downloads';
|
'~/Downloads';
|
||||||
const persistedSubfolders = stringRecord(value.categorySubfolders);
|
const persistedSubfolders = stringRecord(value.categorySubfolders);
|
||||||
const categorySubfolders = Object.fromEntries(
|
const categorySubfolders = Object.fromEntries(
|
||||||
DOWNLOAD_CATEGORIES.map(category => [
|
DOWNLOAD_CATEGORIES.map(category => {
|
||||||
category,
|
const persistedValue = hasOwn(persistedSubfolders, category)
|
||||||
normalizeCategorySubfolder(
|
? persistedSubfolders[category]
|
||||||
persistedSubfolders[category] || '',
|
: DEFAULT_CATEGORY_SUBFOLDERS[category];
|
||||||
DEFAULT_CATEGORY_SUBFOLDERS[category]
|
return [
|
||||||
)
|
category,
|
||||||
])
|
normalizeCategorySubfolder(persistedValue, DEFAULT_CATEGORY_SUBFOLDERS[category])
|
||||||
|
];
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const categoryDirectoryOverrides = stringRecord(value.categoryDirectoryOverrides);
|
const categoryDirectoryOverrides = stringRecord(value.categoryDirectoryOverrides);
|
||||||
const legacyDirectories = stringRecord(value.downloadDirectories);
|
const legacyDirectories = stringRecord(value.downloadDirectories);
|
||||||
@@ -153,11 +164,14 @@ export const resolveCategoryDestination = async (
|
|||||||
|
|
||||||
const base = settings.baseDownloadFolder.trim() || '~/Downloads';
|
const base = settings.baseDownloadFolder.trim() || '~/Downloads';
|
||||||
const expandedBase = await expandTilde(base);
|
const expandedBase = await expandTilde(base);
|
||||||
const subfolder =
|
const persistedValue = hasOwn(settings.categorySubfolders, category)
|
||||||
normalizeCategorySubfolder(
|
? settings.categorySubfolders[category]
|
||||||
settings.categorySubfolders[category] || '',
|
: DEFAULT_CATEGORY_SUBFOLDERS[category];
|
||||||
DEFAULT_CATEGORY_SUBFOLDERS[category]
|
const subfolder = normalizeCategorySubfolder(
|
||||||
);
|
persistedValue,
|
||||||
|
DEFAULT_CATEGORY_SUBFOLDERS[category]
|
||||||
|
);
|
||||||
|
if (!subfolder) return expandedBase;
|
||||||
return join(expandedBase, subfolder);
|
return join(expandedBase, subfolder);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user