mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-27 11:07:08 +00:00
fix(downloads): correct extension metadata and list UI
This commit is contained in:
+141
-36
@@ -18,6 +18,113 @@ fn get_metadata_cache() -> &'static std::sync::Mutex<HashMap<String, String>> {
|
|||||||
CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
|
CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sanitize_metadata_filename(filename: &str) -> Option<String> {
|
||||||
|
let normalized = filename.trim().replace('\\', "/");
|
||||||
|
let basename = std::path::Path::new(&normalized)
|
||||||
|
.file_name()?
|
||||||
|
.to_str()?
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches(|c| c == '.' || c == ' ');
|
||||||
|
|
||||||
|
if basename.is_empty() || basename == "." || basename == ".." || basename.len() > 255 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(basename.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_decode_metadata_value(value: &str) -> Option<String> {
|
||||||
|
let bytes = value.as_bytes();
|
||||||
|
let mut decoded = Vec::with_capacity(bytes.len());
|
||||||
|
let mut index = 0;
|
||||||
|
|
||||||
|
while index < bytes.len() {
|
||||||
|
if bytes[index] == b'%' && index + 2 < bytes.len() {
|
||||||
|
let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).ok()?;
|
||||||
|
if let Ok(byte) = u8::from_str_radix(hex, 16) {
|
||||||
|
decoded.push(byte);
|
||||||
|
index += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded.push(bytes[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
String::from_utf8(decoded).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filename_from_content_disposition(disposition: &str) -> Option<String> {
|
||||||
|
let mut fallback = None;
|
||||||
|
|
||||||
|
for part in disposition.split(';').map(str::trim) {
|
||||||
|
let Some((name, value)) = part.split_once('=') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let normalized_name = name.trim().to_ascii_lowercase();
|
||||||
|
let raw_value = value.trim().trim_matches('"').trim_matches('\'');
|
||||||
|
|
||||||
|
if normalized_name == "filename*" {
|
||||||
|
let encoded = raw_value
|
||||||
|
.split_once("''")
|
||||||
|
.map(|(_, value)| value)
|
||||||
|
.unwrap_or(raw_value);
|
||||||
|
if let Some(filename) = percent_decode_metadata_value(encoded)
|
||||||
|
.and_then(|value| sanitize_metadata_filename(&value))
|
||||||
|
{
|
||||||
|
return Some(filename);
|
||||||
|
}
|
||||||
|
} else if normalized_name == "filename" {
|
||||||
|
fallback = sanitize_metadata_filename(raw_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filename_from_url_disposition_query(raw_url: &str) -> Option<String> {
|
||||||
|
let parsed = reqwest::Url::parse(raw_url).ok()?;
|
||||||
|
|
||||||
|
for (name, value) in parsed.query_pairs() {
|
||||||
|
if name.eq_ignore_ascii_case("response-content-disposition")
|
||||||
|
|| name.eq_ignore_ascii_case("rscd")
|
||||||
|
{
|
||||||
|
if let Some(filename) = filename_from_content_disposition(&value) {
|
||||||
|
return Some(filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filename_from_url_path(raw_url: &str) -> Option<String> {
|
||||||
|
let parsed = reqwest::Url::parse(raw_url).ok()?;
|
||||||
|
let last = parsed.path_segments()?.next_back()?;
|
||||||
|
sanitize_metadata_filename(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_filename_from_response(
|
||||||
|
response: &reqwest::Response,
|
||||||
|
current_url: &str,
|
||||||
|
original_url: &str,
|
||||||
|
) -> String {
|
||||||
|
if let Some(filename) = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(filename_from_content_disposition)
|
||||||
|
{
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
filename_from_url_disposition_query(current_url)
|
||||||
|
.or_else(|| filename_from_url_path(original_url))
|
||||||
|
.or_else(|| filename_from_url_path(current_url))
|
||||||
|
.unwrap_or_else(|| "download".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, TS)]
|
#[derive(Serialize, TS)]
|
||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
pub struct MetadataResponse {
|
pub struct MetadataResponse {
|
||||||
@@ -1002,39 +1109,7 @@ async fn fetch_metadata(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut filename = String::new();
|
let filename = metadata_filename_from_response(&res, ¤t_url, &url);
|
||||||
if let Some(cd) = res.headers().get(reqwest::header::CONTENT_DISPOSITION) {
|
|
||||||
if let Ok(cd_str) = cd.to_str() {
|
|
||||||
if let Some(idx) = cd_str.find("filename=") {
|
|
||||||
let rest = &cd_str[idx + 9..];
|
|
||||||
let raw_filename = rest.trim_matches(|c| c == '"' || c == '\'');
|
|
||||||
let normalized = raw_filename.replace('\\', "/");
|
|
||||||
filename = std::path::Path::new(&normalized)
|
|
||||||
.file_name()
|
|
||||||
.and_then(|n| n.to_str())
|
|
||||||
.unwrap_or("download")
|
|
||||||
.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if filename.is_empty() {
|
|
||||||
if let Ok(parsed) = reqwest::Url::parse(¤t_url) {
|
|
||||||
if let Some(mut segments) = parsed.path_segments() {
|
|
||||||
if let Some(last) = segments.next_back() {
|
|
||||||
let normalized = last.replace('\\', "/");
|
|
||||||
filename = std::path::Path::new(&normalized)
|
|
||||||
.file_name()
|
|
||||||
.and_then(|n| n.to_str())
|
|
||||||
.unwrap_or("download")
|
|
||||||
.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if filename.is_empty() {
|
|
||||||
filename = "download".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut size_str = "Unknown".to_string();
|
let mut size_str = "Unknown".to_string();
|
||||||
let mut size_bytes = 0;
|
let mut size_bytes = 0;
|
||||||
@@ -4225,9 +4300,11 @@ fn set_extension_frontend_ready(state: tauri::State<'_, AppState>, ready: bool)
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
aggregate_media_fraction, build_media_format_options, collect_download_uris,
|
aggregate_media_fraction, build_media_format_options, collect_download_uris,
|
||||||
is_excluded_yt_dlp_format, json_lower, media_output_template, media_progress_speed,
|
filename_from_content_disposition, filename_from_url_disposition_query,
|
||||||
normalize_speed_limit_for_aria2, parse_firelink_deep_link, parse_media_progress_line,
|
filename_from_url_path, is_excluded_yt_dlp_format, json_lower, media_output_template,
|
||||||
redact_log_line, FirelinkDeepLink, MediaProgress, MEDIA_PROGRESS_PREFIX,
|
media_progress_speed, normalize_speed_limit_for_aria2, parse_firelink_deep_link,
|
||||||
|
parse_media_progress_line, redact_log_line, FirelinkDeepLink, MediaProgress,
|
||||||
|
MEDIA_PROGRESS_PREFIX,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -4249,6 +4326,34 @@ mod tests {
|
|||||||
assert_eq!(template, destination.join("clip.mp4"));
|
assert_eq!(template, destination.join("clip.mp4"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_filename_prefers_content_disposition_filename() {
|
||||||
|
assert_eq!(
|
||||||
|
filename_from_content_disposition(
|
||||||
|
"attachment; filename*=UTF-8''OnionHop-3.5-macOS-arm64.dmg; filename=ignored.bin"
|
||||||
|
),
|
||||||
|
Some("OnionHop-3.5-macOS-arm64.dmg".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
filename_from_content_disposition("attachment; filename=OnionHop-3.5-macOS-arm64.dmg"),
|
||||||
|
Some("OnionHop-3.5-macOS-arm64.dmg".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_filename_reads_redirect_disposition_query_before_opaque_path() {
|
||||||
|
let redirected = "https://release-assets.githubusercontent.com/github-production-release-asset/1117828249/7aae36e6-00ec-4e7d-8dec-f14ace170bdb?rscd=attachment%3B+filename%3DOnionHop-3.5-macOS-arm64.dmg";
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
filename_from_url_disposition_query(redirected),
|
||||||
|
Some("OnionHop-3.5-macOS-arm64.dmg".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
filename_from_url_path(redirected),
|
||||||
|
Some("7aae36e6-00ec-4e7d-8dec-f14ace170bdb".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normalizes_bare_global_speed_limits_as_kib_per_second() {
|
fn normalizes_bare_global_speed_limits_as_kib_per_second() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -451,7 +451,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
className={`${index === 5 ? 'download-cell-right' : ''} ${filter.startsWith('queue:') ? 'opacity-70 cursor-default' : 'cursor-pointer hover:text-text-primary transition-colors'} flex items-center justify-between`}
|
className={`${index === 5 ? 'download-cell-right' : ''} ${filter.startsWith('queue:') ? 'opacity-70 cursor-default' : 'cursor-pointer hover:text-text-primary transition-colors'} flex items-center justify-between`}
|
||||||
onClick={() => handleSort(label)}
|
onClick={() => handleSort(label)}
|
||||||
>
|
>
|
||||||
<div className={`flex items-center gap-1 w-full h-full select-none ${index > 0 ? 'justify-center' : ''}`}>
|
<div className="flex items-center gap-1 w-full h-full select-none">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{sortConfig?.column === label && (
|
{sortConfig?.column === label && (
|
||||||
sortConfig.direction === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
sortConfig.direction === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||||
|
|||||||
@@ -60,11 +60,6 @@ const browserCookieSourceOptions = [
|
|||||||
{ value: 'whale', label: 'Whale' },
|
{ value: 'whale', label: 'Whale' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const normalizeBrowserCookieSource = (source: string) => {
|
|
||||||
const trimmed = source.trim();
|
|
||||||
return trimmed.length > 0 ? trimmed : 'none';
|
|
||||||
};
|
|
||||||
|
|
||||||
type EngineCheck = typeof engineChecks[number];
|
type EngineCheck = typeof engineChecks[number];
|
||||||
|
|
||||||
const engineStatusCache = new Map<string, EngineStatusItem>();
|
const engineStatusCache = new Map<string, EngineStatusItem>();
|
||||||
@@ -1121,32 +1116,18 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="grid grid-cols-[180px_1fr] items-start gap-4 text-[13px] border-t border-border-modal/50 pt-3 mt-2">
|
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px] border-t border-border-modal/50 pt-3 mt-2">
|
||||||
<label className="text-text-secondary font-semibold pt-1.5">Browser Cookies Source:</label>
|
<label className="text-text-secondary font-semibold pt-1.5">Browser Cookies Source:</label>
|
||||||
<div className="space-y-2">
|
|
||||||
<select
|
<select
|
||||||
value={browserCookieSourceOptions.some(option => option.value === settings.mediaCookieSource) ? settings.mediaCookieSource : 'custom'}
|
value={browserCookieSourceOptions.some(option => option.value === settings.mediaCookieSource) ? settings.mediaCookieSource : 'none'}
|
||||||
onChange={(e) => {
|
onChange={(e) => settings.setMediaCookieSource(e.target.value)}
|
||||||
if (e.target.value !== 'custom') {
|
|
||||||
settings.setMediaCookieSource(e.target.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="bg-bg-input border border-border-modal rounded-lg p-1.5 text-[13px] text-text-primary focus:outline-none focus:border-accent w-full"
|
className="bg-bg-input border border-border-modal rounded-lg p-1.5 text-[13px] text-text-primary focus:outline-none focus:border-accent w-full"
|
||||||
>
|
>
|
||||||
{browserCookieSourceOptions.map(option => (
|
{browserCookieSourceOptions.map(option => (
|
||||||
<option key={option.value} value={option.value}>{option.label}</option>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
))}
|
))}
|
||||||
<option value="custom">Custom yt-dlp source</option>
|
|
||||||
</select>
|
</select>
|
||||||
<input
|
|
||||||
value={settings.mediaCookieSource === 'none' ? '' : settings.mediaCookieSource}
|
|
||||||
onChange={(e) => settings.setMediaCookieSource(normalizeBrowserCookieSource(e.target.value))}
|
|
||||||
placeholder="firefox:/path/to/profile or chrome:Profile 1"
|
|
||||||
className="app-control w-full px-3 py-1.5 bg-surface-overlay/50 border-border-color/50 focus:border-accent-color focus:bg-surface-overlay text-[13px]"
|
|
||||||
aria-label="Custom yt-dlp browser cookie source"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<p className="text-text-muted text-xs mt-1">yt-dlp reads browser cookies to bypass video download limits or access restricted media. Firelink does not save browser cookies.</p>
|
<p className="text-text-muted text-xs mt-1">yt-dlp reads browser cookies to bypass video download limits or access restricted media. Firelink does not save browser cookies.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+26
-7
@@ -1667,6 +1667,7 @@ html[data-list-density="relaxed"] {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
--download-column-padding-x: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-table-header > div {
|
.download-table-header > div {
|
||||||
@@ -1676,6 +1677,15 @@ html[data-list-density="relaxed"] {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding-inline: var(--download-column-padding-x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-table-header > div:first-child {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-table-header > div:last-child {
|
||||||
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-table-header > div > span {
|
.download-table-header > div > span {
|
||||||
@@ -1686,7 +1696,7 @@ html[data-list-density="relaxed"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.download-table-header > div:not(:first-child) {
|
.download-table-header > div:not(:first-child) {
|
||||||
padding-left: 0;
|
padding-left: var(--download-column-padding-x);
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-resize-handle {
|
.column-resize-handle {
|
||||||
@@ -1766,6 +1776,15 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
|||||||
.download-row > div {
|
.download-row > div {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding-inline: var(--download-column-padding-x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-row > div:first-child {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-row > div:last-child {
|
||||||
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1805,7 +1824,7 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
|||||||
.download-cell-truncate {
|
.download-cell-truncate {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -1813,7 +1832,7 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
|||||||
.download-cell-truncate > span,
|
.download-cell-truncate > span,
|
||||||
.download-cell-right > span {
|
.download-cell-right > span {
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1826,9 +1845,9 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.download-cell-right {
|
.download-cell-right {
|
||||||
text-align: center;
|
text-align: left;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1858,7 +1877,7 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
|||||||
.download-status-cell {
|
.download-status-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
gap: var(--download-status-gap);
|
gap: var(--download-status-gap);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1868,7 +1887,7 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
Reference in New Issue
Block a user