From ceab8a5fdfe84cfe226f45e47e4964cdf2f24c24 Mon Sep 17 00:00:00 2001 From: NimBold Date: Thu, 23 Jul 2026 02:55:33 +0330 Subject: [PATCH] feat(downloads): add media quality and queue reordering --- src-tauri/src/ipc.rs | 2 + src-tauri/src/lib.rs | 15 +- src-tauri/src/queue.rs | 68 +++++++- src-tauri/tests/queue_manager.rs | 27 ++++ src/bindings/DownloadItem.ts | 2 +- src/components/AddDownloadsModal.tsx | 50 +++++- src/components/DownloadItem.tsx | 52 +++++- src/components/DownloadTable.tsx | 219 +++++++++++++++++++++++++- src/i18n/catalogs/en.ts | 6 + src/i18n/catalogs/fa.ts | 6 + src/i18n/catalogs/he.ts | 6 + src/i18n/catalogs/ru.ts | 6 + src/i18n/catalogs/uk.ts | 6 + src/i18n/catalogs/zh-CN.ts | 6 + src/index.css | 73 +++++++++ src/ipc.ts | 2 +- src/store/useDownloadStore.test.ts | 46 ++++++ src/store/useDownloadStore.ts | 83 ++++++++++ src/utils/addDownloadMetadata.test.ts | 49 ++++++ src/utils/addDownloadMetadata.ts | 68 ++++++++ src/utils/queueOrdering.test.ts | 32 ++++ src/utils/queueOrdering.ts | 61 +++++++ 22 files changed, 869 insertions(+), 16 deletions(-) create mode 100644 src/utils/queueOrdering.test.ts create mode 100644 src/utils/queueOrdering.ts diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index c86d14f..f7bf43b 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -121,6 +121,8 @@ pub struct DownloadItem { #[ts(optional)] pub media_format_selector: Option, #[ts(optional)] + pub media_quality: Option, + #[ts(optional)] pub queue_id: Option, #[ts(optional)] pub queue_position: Option, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 93538b2..ccba712 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5650,11 +5650,18 @@ async fn move_many_in_queue( ids: Vec, queue_id: String, direction: crate::ipc::QueueDirection, + target_index: Option, ) -> Result, AppError> { - Ok(state - .queue_manager - .move_many_in_queue(&ids, &queue_id, direction) - .await) + Ok(match target_index { + Some(target_index) => state + .queue_manager + .move_many_in_queue_to(&ids, &queue_id, target_index) + .await, + None => state + .queue_manager + .move_many_in_queue(&ids, &queue_id, direction) + .await, + }) } #[tauri::command] diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index c3d954a..a4912b5 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -22,6 +22,34 @@ pub fn clamp_download_connections(connections: i32) -> i32 { connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX) } +fn reorder_selected_queue_tasks( + queue_tasks: &[QueuedTask], + ids: &[String], + target_index: usize, +) -> Option> { + let selected_ids = ids.iter().collect::>(); + let selected_tasks = queue_tasks + .iter() + .filter(|task| selected_ids.contains(&task.id)) + .cloned() + .collect::>(); + if selected_tasks.is_empty() { + return None; + } + + let unselected_tasks = queue_tasks + .iter() + .filter(|task| !selected_ids.contains(&task.id)) + .cloned() + .collect::>(); + let insert_index = target_index.min(unselected_tasks.len()); + let mut reordered = Vec::with_capacity(queue_tasks.len()); + reordered.extend_from_slice(&unselected_tasks[..insert_index]); + reordered.extend(selected_tasks); + reordered.extend_from_slice(&unselected_tasks[insert_index..]); + Some(reordered) +} + type Aria2ControlLocks = Arc>>>>; #[derive(Debug, Clone, PartialEq, Eq)] @@ -1591,10 +1619,8 @@ impl QueueManager { .saturating_add(1) .min(unselected_tasks.len()), }; - let mut reordered = Vec::with_capacity(queue_tasks.len()); - reordered.extend_from_slice(&unselected_tasks[..insert_index]); - reordered.extend(selected_tasks); - reordered.extend_from_slice(&unselected_tasks[insert_index..]); + let reordered = reorder_selected_queue_tasks(&queue_tasks, ids, insert_index) + .expect("selected queue tasks were present"); for (queue_index, pending_index) in queue_positions.iter().enumerate() { pending[*pending_index] = reordered[queue_index].clone(); @@ -1607,6 +1633,40 @@ impl QueueManager { .collect() } + /// Atomically place a selected block at an insertion index among the + /// unselected tasks in one queue. This is the drag/drop counterpart to + /// move_many_in_queue; the index is clamped at the backend boundary so a + /// stale pointer position cannot create an invalid queue state. + pub async fn move_many_in_queue_to( + &self, + ids: &[String], + queue_id: &str, + target_index: usize, + ) -> Vec { + let mut pending = self.pending.lock().await; + let queue_positions = pending + .iter() + .enumerate() + .filter_map(|(index, task)| (task.queue_id == queue_id).then_some(index)) + .collect::>(); + let queue_tasks = queue_positions + .iter() + .map(|index| pending[*index].clone()) + .collect::>(); + + if let Some(reordered) = reorder_selected_queue_tasks(&queue_tasks, ids, target_index) { + for (queue_index, pending_index) in queue_positions.iter().enumerate() { + pending[*pending_index] = reordered[queue_index].clone(); + } + } + + pending + .iter() + .filter(|task| task.queue_id == queue_id) + .map(|task| task.id.clone()) + .collect() + } + /// Remove a task from pending if present (used by remove_download). /// Does NOT release a permit (the caller handles active permits via /// release_permit if the task was already dispatched). diff --git a/src-tauri/tests/queue_manager.rs b/src-tauri/tests/queue_manager.rs index 93b5790..6032673 100644 --- a/src-tauri/tests/queue_manager.rs +++ b/src-tauri/tests/queue_manager.rs @@ -1247,6 +1247,33 @@ async fn multi_move_reorders_selected_items_as_one_atomic_block() { ); } +#[tokio::test] +async fn target_move_reorders_a_selected_block_and_clamps_the_target() { + use firelink_lib::ipc::QueueDirection; + + let (mgr, _spawner) = make_manager(3); + for id in ["a", "b", "c", "d", "e"] { + mgr.push(sample_task(id)).await.unwrap(); + } + + let selected = vec!["b".to_string(), "d".to_string()]; + assert_eq!( + mgr.move_many_in_queue_to(&selected, "main", 1).await, + vec!["a", "b", "d", "c", "e"] + ); + assert_eq!( + mgr.move_many_in_queue_to(&selected, "main", usize::MAX).await, + vec!["a", "c", "e", "b", "d"] + ); + + // The original direction API remains unchanged for keyboard/button moves. + assert_eq!( + mgr.move_many_in_queue(&selected, "main", QueueDirection::Up) + .await, + vec!["a", "c", "b", "d", "e"] + ); +} + #[tokio::test] async fn moving_one_queue_does_not_reorder_another_queue() { use firelink_lib::ipc::QueueDirection; diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index fdc5240..79992ed 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -2,4 +2,4 @@ import type { DownloadCategory } from "./DownloadCategory"; import type { DownloadStatus } from "./DownloadStatus"; -export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, }; +export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index c1b87d3..d03530d 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -33,12 +33,15 @@ import { localeDirection, localePluralVariant, resolveAppLocale } from '../i18n/ import { canSubmitMetadataRows, appendRequestUrlsAfterVersion, + commonMediaQualitiesForRows, mediaFileNameForSelectedFormat, + mediaQualityForRow, mediaFormatSelectorForRow, metadataSummaryState, playlistFilePrefix, reconcileDownloadRows, refreshFailedMetadataRows, + selectExactMediaQuality, updateRowIfCurrent, type AddDownloadDraftRow } from '../utils/addDownloadMetadata'; @@ -519,6 +522,7 @@ export const AddDownloadsModal = () => { const isApproximate = !exactBytes && approxBytes > 0; return { name: `${quality} ${container}`, + quality, ext: f.ext, bytes, isApproximate, @@ -1187,6 +1191,7 @@ export const AddDownloadsModal = () => { isMedia: item.isMedia, resumable: item.resumable, mediaFormatSelector: formatSelector, + mediaQuality: mediaQualityForRow(item), size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined), sizeBytes: item.sizeBytes }, action); @@ -1285,6 +1290,14 @@ export const AddDownloadsModal = () => { }; const selectedItems = parsedItems.filter(item => item.selected !== false); + const selectedReadyMediaItems = selectedItems.filter(item => + item.isMedia && item.status === 'ready' && Boolean(item.formats?.length) + ); + const bulkQualityOptions = commonMediaQualitiesForRows(selectedReadyMediaItems); + const applyBulkMediaQuality = (quality: string) => { + const selectedIds = selectedReadyMediaItems.map(item => item.id); + setParsedItems(items => selectExactMediaQuality(items, selectedIds, quality)); + }; const allRowsSelected = parsedItems.length > 0 && selectedItems.length === parsedItems.length; const requiredBytes = selectedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0); const hasApproximateSize = selectedItems.some(item => @@ -1524,7 +1537,14 @@ export const AddDownloadsModal = () => { aria-label={t($ => $.addDownloads.selectItem, { file: item.file })} className="me-2 shrink-0 accent-purple-500" /> -
{item.file}
+
+ {item.file} + {item.isMedia && item.status === 'ready' && mediaQualityForRow(item) ? ( + $.addDownloads.quality)}> + {mediaQualityForRow(item)} + + ) : null} +
{item.size || t($ => $.addDownloads.unknown)}
{item.status === 'loading' ? ( @@ -1554,6 +1574,34 @@ export const AddDownloadsModal = () => {
+ {selectedReadyMediaItems.length > 1 ? ( +
+
+ + {bulkQualityOptions.length > 0 ? ( +
$.addDownloads.applyQualityToSelected)}> + {bulkQualityOptions.map(quality => ( + + ))} +
+ ) : ( + + {t($ => $.addDownloads.noCommonQuality)} + + )} +
+
+ ) : null} + {/* Media Format (Dynamic) */} {selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index e3e2bfe..610676d 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useDownloadProgressStore } from '../store/downloadProgressStore'; -import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react'; +import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown, GripVertical } from 'lucide-react'; import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem'; import { canPauseDownload, canStartDownload } from '../utils/downloadActions'; import { useTranslation } from 'react-i18next'; @@ -32,7 +32,10 @@ interface DownloadItemProps { handleResume: (item: DownloadItemType) => void; getCategoryIcon: (category: string) => React.ReactNode; isSelected: boolean; + isQueueReorderable: boolean; + isQueueDragSource: boolean; onMoveInQueue: (id: string, direction: 'up' | 'down') => void; + onQueueDragStart: (id: string, event: React.PointerEvent) => void; onClick: (e: React.MouseEvent, item: DownloadItemType) => void; } @@ -50,7 +53,10 @@ export const DownloadItem = React.memo(({ handleResume, getCategoryIcon, isSelected, + isQueueReorderable, + isQueueDragSource, onMoveInQueue, + onQueueDragStart, onClick, }) => { const { t } = useTranslation(); @@ -60,6 +66,11 @@ export const DownloadItem = React.memo(({ const [isRowFocused, setIsRowFocused] = React.useState(false); const [actionPosition, setActionPosition] = React.useState(); const isActionVisible = isRowHovered || isRowFocused; + const mediaQualityLabel = (() => { + if (!download.isMedia || typeof download.mediaQuality !== 'string') return undefined; + const normalized = download.mediaQuality.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim(); + return normalized.length > 0 && normalized.length <= 48 ? normalized : undefined; + })(); const updateActionPosition = React.useCallback(() => { const row = rowRef.current; @@ -182,12 +193,32 @@ export const DownloadItem = React.memo(({ style={columnStyle('File Name')} >
+ {isQueueReorderable ? ( + + ) : null} {getCategoryIcon(download.category)} {download.fileName} + {mediaQualityLabel ? ( + $.addDownloads.quality)}> + {mediaQualityLabel} + + ) : null}
), @@ -314,7 +345,7 @@ export const DownloadItem = React.memo(({ onClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} > - {(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && ( + {isQueueReorderable && queueIndex !== -1 && ( <>