mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-19 07:36:17 +00:00
fix(downloads): harden add modal and retry state
Route explicit no-limit speed overrides without inheriting the global cap. Preserve dotted media titles when switching formats and drain retry gid completions through remember_gid.
This commit is contained in:
+13
-12
@@ -2837,10 +2837,11 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
.arg(output_template.to_string_lossy().to_string())
|
.arg(output_template.to_string_lossy().to_string())
|
||||||
.env("PATH", &trusted_path);
|
.env("PATH", &trusted_path);
|
||||||
|
|
||||||
if let Some(limit) = speed_limit.as_ref() {
|
if let Some(limit) = speed_limit
|
||||||
if !limit.is_empty() {
|
.as_deref()
|
||||||
cmd = cmd.arg("--limit-rate").arg(limit);
|
.and_then(normalize_speed_limit_for_aria2)
|
||||||
}
|
{
|
||||||
|
cmd = cmd.arg("--limit-rate").arg(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(p) = proxy.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
if let Some(p) = proxy.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
@@ -3280,6 +3281,13 @@ async fn resume_download(
|
|||||||
queue_manager.release_permit(&id_clone).await;
|
queue_manager.release_permit(&id_clone).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let _ = app_handle_clone.emit(
|
||||||
|
"download-state",
|
||||||
|
crate::ipc::DownloadStateEvent::new(
|
||||||
|
&id_clone,
|
||||||
|
crate::ipc::DownloadStatus::Downloading,
|
||||||
|
),
|
||||||
|
);
|
||||||
let result = match rpc_call(
|
let result = match rpc_call(
|
||||||
aria2_port,
|
aria2_port,
|
||||||
&aria2_secret,
|
&aria2_secret,
|
||||||
@@ -3332,13 +3340,6 @@ async fn resume_download(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log::info!("aria2 resume [{}]: unpaused gid {}", id_clone, gid_clone);
|
log::info!("aria2 resume [{}]: unpaused gid {}", id_clone, gid_clone);
|
||||||
let _ = app_handle_clone.emit(
|
|
||||||
"download-state",
|
|
||||||
crate::ipc::DownloadStateEvent::new(
|
|
||||||
&id_clone,
|
|
||||||
crate::ipc::DownloadStatus::Downloading,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
@@ -3970,7 +3971,7 @@ async fn set_concurrent_limit(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_speed_limit_for_aria2(limit: &str) -> Option<String> {
|
pub(crate) fn normalize_speed_limit_for_aria2(limit: &str) -> Option<String> {
|
||||||
let trimmed = limit.trim();
|
let trimmed = limit.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
|
|||||||
+9
-19
@@ -596,20 +596,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
removed.last().cloned()
|
removed.last().cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Overwrite a stale aria2 gid with the fresh gid minted by a retry
|
|
||||||
/// `addUri`. Failing to call this after re-add leaks the semaphore permit.
|
|
||||||
pub fn rotate_aria2_gid(&self, id: &str, stale_gid: &str, new_gid: &str) {
|
|
||||||
let mut gids = self.aria2_gids.write().unwrap();
|
|
||||||
gids.remove(stale_gid);
|
|
||||||
gids.insert(new_gid.to_string(), id.to_string());
|
|
||||||
log::info!(
|
|
||||||
"aria2 gid transition [{}]: rotated {} -> {}",
|
|
||||||
id,
|
|
||||||
stale_gid,
|
|
||||||
new_gid
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn wait_permit_released(self: &Arc<Self>, id: &str) {
|
async fn wait_permit_released(self: &Arc<Self>, id: &str) {
|
||||||
loop {
|
loop {
|
||||||
if !self.active_permits.lock().await.contains_key(id) {
|
if !self.active_permits.lock().await.contains_key(id) {
|
||||||
@@ -695,7 +681,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let this = Arc::clone(self);
|
let this = Arc::clone(self);
|
||||||
let stale_gid = gid.to_string();
|
|
||||||
let id_for_task = id.clone();
|
let id_for_task = id.clone();
|
||||||
let error_for_emit = error.clone();
|
let error_for_emit = error.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
@@ -752,11 +737,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.rotate_aria2_gid(&id_for_task, &stale_gid, &new_gid);
|
let retained_gid = new_gid.clone();
|
||||||
|
this.remember_gid(id_for_task.clone(), new_gid).await;
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"aria2 retry cancellation [{}]: retained late gid {} mapping for remove retry",
|
"aria2 retry cancellation [{}]: retained late gid {} mapping for remove retry",
|
||||||
id_for_task,
|
id_for_task,
|
||||||
new_gid
|
retained_gid
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -764,8 +750,8 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.insert(id_for_task.clone(), strike + 1);
|
.insert(id_for_task.clone(), strike + 1);
|
||||||
this.rotate_aria2_gid(&id_for_task, &stale_gid, &new_gid);
|
|
||||||
this.emit_state(&id_for_task, DownloadStatus::Downloading);
|
this.emit_state(&id_for_task, DownloadStatus::Downloading);
|
||||||
|
this.remember_gid(id_for_task.clone(), new_gid).await;
|
||||||
}
|
}
|
||||||
Err(retry_error) => {
|
Err(retry_error) => {
|
||||||
this.apply_completion(&id_for_task, PendingOutcome::Error(retry_error))
|
this.apply_completion(&id_for_task, PendingOutcome::Error(retry_error))
|
||||||
@@ -1204,7 +1190,11 @@ impl SidecarSpawner for ProductionSpawner {
|
|||||||
options.insert("max-tries".to_string(), serde_json::json!(mt.to_string()));
|
options.insert("max-tries".to_string(), serde_json::json!(mt.to_string()));
|
||||||
options.insert("retry-wait".to_string(), serde_json::json!("2"));
|
options.insert("retry-wait".to_string(), serde_json::json!("2"));
|
||||||
options.insert("continue".to_string(), serde_json::json!("true"));
|
options.insert("continue".to_string(), serde_json::json!("true"));
|
||||||
if let Some(speed) = &payload.speed_limit {
|
if let Some(speed) = payload
|
||||||
|
.speed_limit
|
||||||
|
.as_deref()
|
||||||
|
.and_then(crate::normalize_speed_limit_for_aria2)
|
||||||
|
{
|
||||||
options.insert("max-download-limit".to_string(), serde_json::json!(speed));
|
options.insert("max-download-limit".to_string(), serde_json::json!(speed));
|
||||||
}
|
}
|
||||||
if let Some(user) = &payload.username {
|
if let Some(user) = &payload.username {
|
||||||
|
|||||||
@@ -18,15 +18,14 @@
|
|||||||
//! ### aria2 GID-rotation contract (CRITICAL)
|
//! ### aria2 GID-rotation contract (CRITICAL)
|
||||||
//!
|
//!
|
||||||
//! When aria2 retries via a fresh `aria2.addUri`, it mints a **brand-new GID**.
|
//! When aria2 retries via a fresh `aria2.addUri`, it mints a **brand-new GID**.
|
||||||
//! The caller MUST overwrite the stale GID → download-id mapping in
|
//! The caller MUST store the new GID through `QueueManager::remember_gid` on
|
||||||
//! `QueueManager::aria2_gids` with the new GID on every successful re-add.
|
//! every successful re-add. Failing to do so detaches subsequent
|
||||||
//! Failing to do so detaches subsequent `onDownloadComplete` /
|
//! `onDownloadComplete` / `onDownloadError` WebSocket events from the original
|
||||||
//! `onDownloadError` WebSocket events from the original id, which leaks the
|
//! id, or strands a terminal event that arrived before the fresh GID was stored.
|
||||||
//! semaphore permit permanently. Concretely, after every retry-driven
|
//! Concretely, after every retry-driven `addUri` that returns `new_gid`:
|
||||||
//! `addUri` that returns `new_gid`:
|
|
||||||
//!
|
//!
|
||||||
//! ```ignore
|
//! ```ignore
|
||||||
//! queue_manager.rotate_aria2_gid(&id, &stale_gid, &new_gid);
|
//! queue_manager.remember_gid(id.clone(), new_gid).await;
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { isTransferLocked } from '../utils/downloadActions';
|
|||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import {
|
import {
|
||||||
canSubmitMetadataRows,
|
canSubmitMetadataRows,
|
||||||
|
mediaFileNameForSelectedFormat,
|
||||||
mediaFormatSelectorForRow,
|
mediaFormatSelectorForRow,
|
||||||
metadataSummaryMessage,
|
metadataSummaryMessage,
|
||||||
reconcileDownloadRows,
|
reconcileDownloadRows,
|
||||||
@@ -387,7 +388,7 @@ export const AddDownloadsModal = () => {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
}, [parsedItems, pendingAddFilename, password, useAuth, username, headers, cookies]);
|
}, [parsedItems, pendingAddFilename, pendingAddMediaUrls]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (parsedItems.length === 0) {
|
if (parsedItems.length === 0) {
|
||||||
@@ -481,12 +482,9 @@ export const AddDownloadsModal = () => {
|
|||||||
|
|
||||||
for (let i = 0; i < parsedItems.length; i++) {
|
for (let i = 0; i < parsedItems.length; i++) {
|
||||||
const item = parsedItems[i];
|
const item = parsedItems[i];
|
||||||
let finalFile = canonicalizeDownloadFileName(item.file);
|
let finalFile = item.isMedia
|
||||||
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
|
? mediaFileNameForSelectedFormat(item.file, item)
|
||||||
const selectedFormat = item.formats[item.selectedFormat];
|
: canonicalizeDownloadFileName(item.file);
|
||||||
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
|
||||||
finalFile = `${baseName}.${selectedFormat.ext}`;
|
|
||||||
}
|
|
||||||
const itemLocation = useSharedDestination
|
const itemLocation = useSharedDestination
|
||||||
? finalLocation
|
? finalLocation
|
||||||
: destinationOverrides[i] || await categoryLocationForFile(finalFile);
|
: destinationOverrides[i] || await categoryLocationForFile(finalFile);
|
||||||
@@ -577,12 +575,9 @@ export const AddDownloadsModal = () => {
|
|||||||
if (res.resolution === 'skip') {
|
if (res.resolution === 'skip') {
|
||||||
itemsToAdd[idx] = null;
|
itemsToAdd[idx] = null;
|
||||||
} else if (res.resolution === 'rename') {
|
} else if (res.resolution === 'rename') {
|
||||||
let finalFile = canonicalizeDownloadFileName(item.file);
|
let finalFile = item.isMedia
|
||||||
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
|
? mediaFileNameForSelectedFormat(item.file, item)
|
||||||
const selectedFormat = item.formats[item.selectedFormat];
|
: canonicalizeDownloadFileName(item.file);
|
||||||
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
|
||||||
finalFile = `${baseName}.${selectedFormat.ext}`;
|
|
||||||
}
|
|
||||||
const itemLocation = useSharedDestination
|
const itemLocation = useSharedDestination
|
||||||
? finalLocation
|
? finalLocation
|
||||||
: destinationOverrides[idx] || await categoryLocationForFile(finalFile);
|
: destinationOverrides[idx] || await categoryLocationForFile(finalFile);
|
||||||
@@ -633,12 +628,9 @@ export const AddDownloadsModal = () => {
|
|||||||
itemsToAdd[idx] = null;
|
itemsToAdd[idx] = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let finalFile = canonicalizeDownloadFileName(item.file);
|
let finalFile = item.isMedia
|
||||||
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
|
? mediaFileNameForSelectedFormat(item.file, item)
|
||||||
const selectedFormat = item.formats[item.selectedFormat];
|
: canonicalizeDownloadFileName(item.file);
|
||||||
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
|
||||||
finalFile = `${baseName}.${selectedFormat.ext}`;
|
|
||||||
}
|
|
||||||
const itemLocation = useSharedDestination
|
const itemLocation = useSharedDestination
|
||||||
? finalLocation
|
? finalLocation
|
||||||
: destinationOverrides[idx] || await categoryLocationForFile(finalFile);
|
: destinationOverrides[idx] || await categoryLocationForFile(finalFile);
|
||||||
@@ -682,17 +674,11 @@ export const AddDownloadsModal = () => {
|
|||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
try {
|
try {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
let finalFile = canonicalizeDownloadFileName(item.file);
|
let finalFile = item.isMedia
|
||||||
|
? mediaFileNameForSelectedFormat(item.file, item)
|
||||||
|
: canonicalizeDownloadFileName(item.file);
|
||||||
let formatSelector = mediaFormatSelectorForRow(item);
|
let formatSelector = mediaFormatSelectorForRow(item);
|
||||||
|
|
||||||
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
|
|
||||||
const selectedFormat = item.formats[item.selectedFormat];
|
|
||||||
if (!finalFile.endsWith(`.${selectedFormat.ext}`)) {
|
|
||||||
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
|
||||||
finalFile = `${baseName}.${selectedFormat.ext}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const category = categoryForFileName(finalFile);
|
const category = categoryForFileName(finalFile);
|
||||||
const added = await addDownload({
|
const added = await addDownload({
|
||||||
id,
|
id,
|
||||||
@@ -701,7 +687,7 @@ export const AddDownloadsModal = () => {
|
|||||||
category,
|
category,
|
||||||
dateAdded: new Date().toISOString(),
|
dateAdded: new Date().toISOString(),
|
||||||
connections: Number(connections),
|
connections: Number(connections),
|
||||||
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
|
speedLimit: speedLimitEnabled ? `${speedLimit}K` : '0',
|
||||||
username: useAuth ? username.trim() : undefined,
|
username: useAuth ? username.trim() : undefined,
|
||||||
password: useAuth ? password.trim() : undefined,
|
password: useAuth ? password.trim() : undefined,
|
||||||
headers: headers.trim() || undefined,
|
headers: headers.trim() || undefined,
|
||||||
@@ -763,7 +749,6 @@ export const AddDownloadsModal = () => {
|
|||||||
const format = selectedItem?.formats?.[index];
|
const format = selectedItem?.formats?.[index];
|
||||||
if (!selectedItem || !format) return;
|
if (!selectedItem || !format) return;
|
||||||
|
|
||||||
const baseName = selectedItem.file.substring(0, selectedItem.file.lastIndexOf('.')) || selectedItem.file;
|
|
||||||
setParsedItems(items => items.map((item, itemIndex) =>
|
setParsedItems(items => items.map((item, itemIndex) =>
|
||||||
itemIndex === selectedItemIndex
|
itemIndex === selectedItemIndex
|
||||||
? {
|
? {
|
||||||
@@ -771,7 +756,10 @@ export const AddDownloadsModal = () => {
|
|||||||
selectedFormat: index,
|
selectedFormat: index,
|
||||||
size: format.bytes ? format.detail : undefined,
|
size: format.bytes ? format.detail : undefined,
|
||||||
sizeBytes: format.bytes || undefined,
|
sizeBytes: format.bytes || undefined,
|
||||||
file: canonicalizeDownloadFileName(`${baseName}.${format.ext}`)
|
file: mediaFileNameForSelectedFormat(item.file, {
|
||||||
|
formats: item.formats,
|
||||||
|
selectedFormat: index
|
||||||
|
})
|
||||||
}
|
}
|
||||||
: item
|
: item
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1801,6 +1801,7 @@ body.is-resizing .column-resize-handle:hover::after {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: hsl(var(--text-primary));
|
color: hsl(var(--text-primary));
|
||||||
font-size: var(--download-row-font-size);
|
font-size: var(--download-row-font-size);
|
||||||
|
overflow: hidden;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,29 @@ vi.mock('./useSettingsStore', () => ({
|
|||||||
describe('useDownloadStore', () => {
|
describe('useDownloadStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||||
|
proxyMode: 'none',
|
||||||
|
siteLogins: [],
|
||||||
|
globalSpeedLimit: '',
|
||||||
|
speedLimitPresetValues: [1, 5, 10],
|
||||||
|
logsEnabled: false,
|
||||||
|
perServerConnections: 16,
|
||||||
|
customUserAgent: '',
|
||||||
|
maxAutomaticRetries: 3,
|
||||||
|
mediaCookieSource: 'none',
|
||||||
|
baseDownloadFolder: '~/Downloads',
|
||||||
|
categorySubfoldersEnabled: true,
|
||||||
|
categorySubfolders: {
|
||||||
|
Musics: 'Musics',
|
||||||
|
Movies: 'Movies',
|
||||||
|
Compressed: 'Compressed',
|
||||||
|
Documents: 'Documents',
|
||||||
|
Pictures: 'Pictures',
|
||||||
|
Applications: 'Applications',
|
||||||
|
Other: 'Other',
|
||||||
|
},
|
||||||
|
categoryDirectoryOverrides: {},
|
||||||
|
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [],
|
downloads: [],
|
||||||
backendRegisteredIds: new Set(),
|
backendRegisteredIds: new Set(),
|
||||||
@@ -253,6 +276,67 @@ describe('useDownloadStore', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not replace an explicit no-limit item speed with the global speed limit', async () => {
|
||||||
|
const defaultSettings = useSettingsStore.getState();
|
||||||
|
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||||
|
...defaultSettings,
|
||||||
|
globalSpeedLimit: '2M'
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
|
if (cmd === 'get_pending_order') return ['uncapped'];
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDownloadStore.getState().addDownload({
|
||||||
|
id: 'uncapped',
|
||||||
|
url: 'https://example.com/uncapped.bin',
|
||||||
|
fileName: 'uncapped.bin',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: '',
|
||||||
|
speedLimit: '0'
|
||||||
|
}, { type: 'start-now' });
|
||||||
|
|
||||||
|
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||||
|
'enqueue_download',
|
||||||
|
expect.objectContaining({
|
||||||
|
item: expect.objectContaining({
|
||||||
|
id: 'uncapped',
|
||||||
|
speed_limit: '0'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the global speed limit only when an item has no explicit speed override', async () => {
|
||||||
|
const defaultSettings = useSettingsStore.getState();
|
||||||
|
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||||
|
...defaultSettings,
|
||||||
|
globalSpeedLimit: '2M'
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
|
if (cmd === 'get_pending_order') return ['inherits-global'];
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDownloadStore.getState().addDownload({
|
||||||
|
id: 'inherits-global',
|
||||||
|
url: 'https://example.com/inherits-global.bin',
|
||||||
|
fileName: 'inherits-global.bin',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: ''
|
||||||
|
}, { type: 'start-now' });
|
||||||
|
|
||||||
|
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||||
|
'enqueue_download',
|
||||||
|
expect.objectContaining({
|
||||||
|
item: expect.objectContaining({
|
||||||
|
id: 'inherits-global',
|
||||||
|
speed_limit: '2M'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('reports a rejected immediate start instead of claiming success', async () => {
|
it('reports a rejected immediate start instead of claiming success', async () => {
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||||
if (command === 'enqueue_download') {
|
if (command === 'enqueue_download') {
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ const backendDispatchPromises = new Map<string, Promise<boolean>>();
|
|||||||
const errorMessage = (error: unknown): string =>
|
const errorMessage = (error: unknown): string =>
|
||||||
error instanceof Error ? error.message : String(error);
|
error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
const speedLimitForDispatch = (itemSpeedLimit: string | undefined, globalSpeedLimit: string): string | null => {
|
||||||
|
const explicitLimit = itemSpeedLimit?.trim();
|
||||||
|
if (explicitLimit) {
|
||||||
|
return normalizeSpeedLimitForBackend(explicitLimit) || (explicitLimit === '0' ? '0' : null);
|
||||||
|
}
|
||||||
|
return normalizeSpeedLimitForBackend(globalSpeedLimit);
|
||||||
|
};
|
||||||
|
|
||||||
export async function dispatchItem(id: string): Promise<boolean> {
|
export async function dispatchItem(id: string): Promise<boolean> {
|
||||||
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
|
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
|
||||||
|
|
||||||
@@ -50,7 +58,7 @@ export async function dispatchItem(id: string): Promise<boolean> {
|
|||||||
destination,
|
destination,
|
||||||
filename: item.fileName,
|
filename: item.fileName,
|
||||||
connections: item.connections || settings.perServerConnections || null,
|
connections: item.connections || settings.perServerConnections || null,
|
||||||
speed_limit: item.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
|
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit),
|
||||||
username: item.username || (login ? login.username : null),
|
username: item.username || (login ? login.username : null),
|
||||||
password: item.password || keychainPassword,
|
password: item.password || keychainPassword,
|
||||||
headers: item.headers || null,
|
headers: item.headers || null,
|
||||||
@@ -860,7 +868,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
destination: destPath,
|
destination: destPath,
|
||||||
filename: item.fileName,
|
filename: item.fileName,
|
||||||
connections: item.connections || settings.perServerConnections || null,
|
connections: item.connections || settings.perServerConnections || null,
|
||||||
speed_limit: item.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
|
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit),
|
||||||
username: item.username || (login ? login.username : null),
|
username: item.username || (login ? login.username : null),
|
||||||
password: item.password || keychainPassword,
|
password: item.password || keychainPassword,
|
||||||
headers: item.headers || null,
|
headers: item.headers || null,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
canSubmitMetadataRows,
|
canSubmitMetadataRows,
|
||||||
mediaFormatSelectorForRow,
|
mediaFormatSelectorForRow,
|
||||||
|
mediaFileNameForSelectedFormat,
|
||||||
metadataSummaryMessage,
|
metadataSummaryMessage,
|
||||||
reconcileDownloadRows,
|
reconcileDownloadRows,
|
||||||
refreshFailedMetadataRows,
|
refreshFailedMetadataRows,
|
||||||
@@ -154,6 +155,36 @@ describe('add download metadata workflow', () => {
|
|||||||
expect(mediaFormatSelectorForRow(failedMedia)).toBeUndefined();
|
expect(mediaFormatSelectorForRow(failedMedia)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('replaces only known media container suffixes when selecting formats', () => {
|
||||||
|
const mediaRow = row({
|
||||||
|
isMedia: true,
|
||||||
|
formats: [
|
||||||
|
{
|
||||||
|
name: '1080p MP4',
|
||||||
|
selector: 'mp4',
|
||||||
|
ext: 'mp4',
|
||||||
|
formatLabel: '1080p',
|
||||||
|
detail: '10 MB',
|
||||||
|
type: 'Video',
|
||||||
|
bytes: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '1080p MKV',
|
||||||
|
selector: 'mkv',
|
||||||
|
ext: 'mkv',
|
||||||
|
formatLabel: '1080p',
|
||||||
|
detail: '11 MB',
|
||||||
|
type: 'Video',
|
||||||
|
bytes: 11
|
||||||
|
}
|
||||||
|
],
|
||||||
|
selectedFormat: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mediaFileNameForSelectedFormat('Version 1.5', mediaRow)).toBe('Version 1.5.mkv');
|
||||||
|
expect(mediaFileNameForSelectedFormat('Version 1.5.mp4', mediaRow)).toBe('Version 1.5.mkv');
|
||||||
|
});
|
||||||
|
|
||||||
it('reports fallback and invalid states accurately', () => {
|
it('reports fallback and invalid states accurately', () => {
|
||||||
expect(metadataSummaryMessage([
|
expect(metadataSummaryMessage([
|
||||||
row(),
|
row(),
|
||||||
|
|||||||
@@ -148,6 +148,38 @@ export const mediaFormatSelectorForRow = (
|
|||||||
return row.formats?.[row.selectedFormat]?.selector;
|
return row.formats?.[row.selectedFormat]?.selector;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const mediaFileNameForSelectedFormat = (
|
||||||
|
fileName: string,
|
||||||
|
row: Pick<AddDownloadDraftRow, 'formats' | 'selectedFormat'>
|
||||||
|
): string => {
|
||||||
|
const selectedFormat = row.selectedFormat === undefined
|
||||||
|
? undefined
|
||||||
|
: row.formats?.[row.selectedFormat];
|
||||||
|
if (!selectedFormat) return canonicalizeDownloadFileName(fileName);
|
||||||
|
|
||||||
|
const cleanFileName = canonicalizeDownloadFileName(fileName);
|
||||||
|
const selectedExt = selectedFormat.ext.replace(/^\.+/, '');
|
||||||
|
if (!selectedExt) return cleanFileName;
|
||||||
|
|
||||||
|
const lowerFileName = cleanFileName.toLowerCase();
|
||||||
|
if (lowerFileName.endsWith(`.${selectedExt.toLowerCase()}`)) {
|
||||||
|
return cleanFileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastDot = cleanFileName.lastIndexOf('.');
|
||||||
|
const currentExt = lastDot > 0 ? cleanFileName.slice(lastDot + 1).toLowerCase() : '';
|
||||||
|
const knownFormatExts = new Set(
|
||||||
|
(row.formats || [])
|
||||||
|
.map(format => format.ext.replace(/^\.+/, '').toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
const baseName = currentExt && knownFormatExts.has(currentExt)
|
||||||
|
? cleanFileName.slice(0, lastDot)
|
||||||
|
: cleanFileName;
|
||||||
|
|
||||||
|
return canonicalizeDownloadFileName(`${baseName}.${selectedExt}`);
|
||||||
|
};
|
||||||
|
|
||||||
export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
|
export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
|
||||||
if (rows.length === 0) return 'Paste one or more links.';
|
if (rows.length === 0) return 'Paste one or more links.';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user