From e0cb124720d872efd008152c8db14627a0ff2863 Mon Sep 17 00:00:00 2001 From: NimBold Date: Mon, 20 Jul 2026 19:02:46 +0330 Subject: [PATCH] feat(downloads): reuse unfinished filename matches Fixes #26 --- src/components/AddDownloadsModal.tsx | 146 +++++++++--- src/components/DuplicateResolutionModal.tsx | 1 + src/i18n/catalogs/en.ts | 3 + src/i18n/catalogs/fa.ts | 3 + src/i18n/catalogs/he.ts | 3 + src/i18n/catalogs/ru.ts | 3 + src/i18n/catalogs/uk.ts | 3 + src/i18n/catalogs/zh-CN.ts | 3 + src/store/useDownloadStore.test.ts | 126 ++++++++++ src/store/useDownloadStore.ts | 248 +++++++++++--------- src/utils/downloads.test.ts | 32 ++- src/utils/downloads.ts | 23 ++ 12 files changed, 460 insertions(+), 134 deletions(-) diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 1afd95c..50697ff 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -7,12 +7,13 @@ import { type PendingAddRequestContext } from '../store/useDownloadStore'; import { useSettingsStore } from '../store/useSettingsStore'; +import type { DownloadItem } from '../bindings/DownloadItem'; import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata'; import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; import { invokeCommand as invoke } from '../ipc'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; -import { canonicalizeDownloadFileName, categoryForFileName } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNamesMatch, downloadMediaKindsMatch } from '../utils/downloads'; import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata'; import { expandTilde, @@ -752,6 +753,7 @@ export const AddDownloadsModal = () => { const store = useDownloadStore.getState(); const newConflicts: DuplicateConflict[] = []; const plannedTargets: Array<{ location: string; fileName: string }> = []; + const reservedFilenameMatchIds = new Set(); for (let i = 0; i < parsedItems.length; i++) { const item = parsedItems[i]; @@ -784,7 +786,59 @@ export const AddDownloadsModal = () => { replaceAllowed: false }); } else { - let fileExistsInStore = false; + const filenameCandidates: Array<{ + download: DownloadItem; + sameDestination: boolean; + }> = []; + for (const download of store.downloads) { + if ( + download.status === 'completed' + || !downloadMediaKindsMatch(download.isMedia, item.isMedia) + || !downloadFileNamesMatch(download.fileName, finalFile) + ) { + continue; + } + const destination = download.destination || + await resolveCategoryDestination(settings, download.category); + filenameCandidates.push({ + download, + sameDestination: downloadLocationEquals( + destination, + download.fileName, + itemLocation, + finalFile, + platform.os + ) + }); + } + filenameCandidates.sort((left, right) => + Number(right.sameDestination) - Number(left.sameDestination) + || Number(reservedFilenameMatchIds.has(left.download.id)) - Number(reservedFilenameMatchIds.has(right.download.id)) + || (right.download.downloadedBytes ?? 0) - (left.download.downloadedBytes ?? 0) + || (right.download.fraction ?? 0) - (left.download.fraction ?? 0) + || left.download.dateAdded.localeCompare(right.download.dateAdded) + ); + const filenameMatch = filenameCandidates.find(candidate => + !reservedFilenameMatchIds.has(candidate.download.id) + )?.download || filenameCandidates[0]?.download; + + if (filenameMatch) { + const canReplace = !reservedFilenameMatchIds.has(filenameMatch.id) + && !isTransferLocked(filenameMatch.status); + newConflicts.push({ + id: i.toString(), + fileName: finalFile, + reason: { type: 'file', msg: t($ => $.addDownloads.matchingDownloadFilename) }, + resolution: canReplace ? 'replace' : 'rename', + replaceAllowed: canReplace, + existingDownloadId: filenameMatch.id + }); + reservedFilenameMatchIds.add(filenameMatch.id); + plannedTargets.push({ location: itemLocation, fileName: finalFile }); + continue; + } + + let existingDownload; for (const download of store.downloads) { const destination = download.destination || await resolveCategoryDestination(settings, download.category); @@ -797,7 +851,7 @@ export const AddDownloadsModal = () => { platform.os ) ) { - fileExistsInStore = true; + existingDownload = download; break; } } @@ -811,18 +865,19 @@ export const AddDownloadsModal = () => { console.error("Failed to check if file exists on disk:", e); } - if (fileExistsInStore || fileExistsOnDisk) { + if (existingDownload || fileExistsOnDisk) { newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'file', - msg: fileExistsInStore + msg: existingDownload ? t($ => $.addDownloads.existingDownloadDestination) : t($ => $.addDownloads.fileExistsOnDisk) }, resolution: 'rename', - replaceAllowed: fileExistsInStore + replaceAllowed: Boolean(existingDownload), + existingDownloadId: existingDownload?.id }); } } @@ -859,6 +914,7 @@ export const AddDownloadsModal = () => { item.selected === false ? null : item ); const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' })); + let updatedCount = 0; if (resolutions) { for (const res of resolutions) { @@ -941,29 +997,33 @@ export const AddDownloadsModal = () => { itemsToAdd[idx] = null; continue; } - let finalFile = item.isMedia + const finalFile = item.isMedia ? mediaFileNameForSelectedFormat(item.file, item) : canonicalizeDownloadFileName(item.file); const itemLocation = useSharedDestination ? finalLocation : destinationOverrides[idx] || await categoryLocationForFile(finalFile); const store = useDownloadStore.getState(); - let existingItem; + let existingItem = conflict?.existingDownloadId + ? store.downloads.find(download => download.id === conflict.existingDownloadId) + : undefined; const currentSettings = useSettingsStore.getState(); - for (const download of store.downloads) { - const destination = download.destination || - await resolveCategoryDestination(currentSettings, download.category); - if ( - downloadLocationEquals( - destination, - download.fileName, - itemLocation, - finalFile, - platform.os - ) - ) { - existingItem = download; - break; + if (!existingItem && !conflict?.existingDownloadId) { + for (const download of store.downloads) { + const destination = download.destination || + await resolveCategoryDestination(currentSettings, download.category); + if ( + downloadLocationEquals( + destination, + download.fileName, + itemLocation, + finalFile, + platform.os + ) + ) { + existingItem = download; + break; + } } } @@ -974,11 +1034,36 @@ export const AddDownloadsModal = () => { if (!existingItem) { throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile })); } - // Let the backend decide whether resumable sidecars still - // exist after stopping the old transfer. This avoids a race - // where a paused item finishes while the replacement is - // being prepared. - await store.removeDownload(existingItem.id, true, existingItem.status !== 'completed'); + const incomingMediaFormat = mediaFormatSelectorForRow(item); + const mediaFormatChanged = item.isMedia + && existingItem.mediaFormatSelector !== incomingMediaFormat; + if (existingItem.status === 'completed' || mediaFormatChanged) { + // Completed replacements must remove the old file so the + // new transfer cannot be treated as an already-complete + // aria2 target. Unfinished rows use the in-place path to + // preserve their resumable assets and progress. + await store.removeDownload(existingItem.id, true, false); + } else { + const contextUrl = requestContextUrlForRow(item); + const replaced = await store.replaceDownload(existingItem.id, { + url: item.downloadUrl, + username: useAuth ? username.trim() : undefined, + password: useAuth ? password.trim() : undefined, + headers: headersForRow(contextUrl) || undefined, + cookies: cookiesForRow(contextUrl, item.downloadUrl) || undefined, + mirrors: mirrors.trim() || undefined, + lastError: undefined + }, pendingAction); + if (!replaced) { + throw new Error(t($ => $.addDownloads.backendRejectedStart)); + } + + // The existing row was updated in place; do not create a + // second identity for the same filename. + itemsToAdd[idx] = null; + updatedCount += 1; + continue; + } } } } @@ -1056,6 +1141,13 @@ export const AddDownloadsModal = () => { : t($ => $.addDownloads.addedMany, { count: addedCount }), variant: 'success' }); + } else if (updatedCount > 0) { + addToast({ + message: updatedCount === 1 + ? t($ => $.addDownloads.updatedOne) + : t($ => $.addDownloads.updatedMany, { count: updatedCount }), + variant: 'success' + }); } }; diff --git a/src/components/DuplicateResolutionModal.tsx b/src/components/DuplicateResolutionModal.tsx index e60a139..35a6700 100644 --- a/src/components/DuplicateResolutionModal.tsx +++ b/src/components/DuplicateResolutionModal.tsx @@ -10,6 +10,7 @@ export interface DuplicateConflict { reason: DuplicateReason; resolution: DuplicateResolution; replaceAllowed?: boolean; + existingDownloadId?: string; } interface Props { diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index e0ab363..963748e 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -391,9 +391,12 @@ const common = { addedWithFailures: '{{added}} added, {{failed}} failed. {{detail}}', addedOne: '1 download added', addedMany: '{{count}} downloads added', + updatedOne: '1 download updated', + updatedMany: '{{count}} downloads updated', urlAlreadyQueued: 'URL already in queue', destinationConflict: 'Another selected download uses this destination', existingDownloadDestination: 'Existing Firelink download uses this destination', + matchingDownloadFilename: 'An unfinished download already has this filename', fileExistsOnDisk: 'File exists on disk; rename or skip to avoid deleting unrelated data', backendRejectedStart: 'Backend rejected download start.', playlistNoEntries: 'Playlist contains no downloadable entries', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 417db18..70cdccf 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -391,9 +391,12 @@ const fa = { addedWithFailures: '{{added}} اضافه شد، {{failed}} ناموفق بود. {{detail}}', addedOne: '۱ دانلود اضافه شد', addedMany: '{{count}} دانلود اضافه شد', + updatedOne: '۱ دانلود به‌روزرسانی شد', + updatedMany: '{{count}} دانلود به‌روزرسانی شد', urlAlreadyQueued: 'URL در حال حاضر در صف است', destinationConflict: 'یک دانلود انتخاب‌شده دیگر از این مقصد استفاده می‌کند', existingDownloadDestination: 'دانلود Firelink موجود از این مقصد استفاده می‌کند', + matchingDownloadFilename: 'یک دانلود ناتمام از قبل همین نام فایل را دارد', fileExistsOnDisk: 'فایل در دیسک وجود دارد؛ برای جلوگیری از حذف داده‌های نامرتبط، تغییر نام دهید یا رد شوید', backendRejectedStart: 'شروع دانلود توسط موتور دانلود رد شد.', playlistNoEntries: 'لیست پخش شامل هیچ ورودی قابل دانلودی نیست', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 001fa89..e0e54ae 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -391,9 +391,12 @@ const he = { addedWithFailures: '{{added}} נוספו, {{failed}} נכשלו. {{detail}}', addedOne: 'הורדה אחת נוספה', addedMany: '{{count}} הורדות נוספו', + updatedOne: 'הורדה אחת עודכנה', + updatedMany: '{{count}} הורדות עודכנו', urlAlreadyQueued: 'URL כבר בתור', destinationConflict: 'הורדה אחרת שנבחרה משתמשת ביעד זה', existingDownloadDestination: 'הורדת Firelink קיימת משתמשת ביעד זה', + matchingDownloadFilename: 'להורדה שלא הושלמה יש כבר שם קובץ זה', fileExistsOnDisk: 'הקובץ קיים בדיסק; שנה שם או דלג כדי למנוע מחיקת נתונים שאינם קשורים', backendRejectedStart: 'העורף דחה את התחלת ההורדה.', playlistNoEntries: 'הפלייליסט אינו מכיל רשומות הניתנות להורדה', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 6ccf454..39f7684 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -391,9 +391,12 @@ const ru = { addedWithFailures: '{{added}} добавлено, {{failed}} с ошибкой. {{detail}}', addedOne: 'Добавлена 1 загрузка', addedMany: 'Добавлено {{count}} загрузок', + updatedOne: 'Обновлена 1 загрузка', + updatedMany: 'Обновлено {{count}} загрузок', urlAlreadyQueued: 'URL уже в очереди', destinationConflict: 'Другая выбранная загрузка использует это место назначения', existingDownloadDestination: 'Существующая загрузка Firelink использует это место назначения', + matchingDownloadFilename: 'Незавершённая загрузка уже имеет это имя файла', fileExistsOnDisk: 'Файл существует на диске; переименуйте или пропустите, чтобы избежать удаления не связанных данных', backendRejectedStart: 'Бэкенд отклонил запуск загрузки.', playlistNoEntries: 'Плейлист не содержит доступных для загрузки записей', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 91b3489..b3191ed 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -391,9 +391,12 @@ const uk = { addedWithFailures: '{{added}} додано, {{failed}} не вдалося. {{detail}}', addedOne: '1 завантаження додано', addedMany: '{{count}} завантажень додано', + updatedOne: '1 завантаження оновлено', + updatedMany: 'Оновлено {{count}} завантажень', urlAlreadyQueued: 'URL-адреса вже в черзі', destinationConflict: 'Інше вибране завантаження використовує це місце призначення', existingDownloadDestination: 'Існуюче завантаження Firelink використовує це місце призначення', + matchingDownloadFilename: 'Незавершене завантаження вже має це ім’я файлу', fileExistsOnDisk: 'Файл існує на диску; перейменуйте або пропустіть, щоб уникнути видалення незв\'язаних даних', backendRejectedStart: 'Бекенд відхилив початок завантаження.', playlistNoEntries: 'Плейлист не містить елементів для завантаження', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index ba0c692..f2b407d 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -391,9 +391,12 @@ const zhCN = { addedWithFailures: '添加了 {{added}} 个,失败了 {{failed}} 个。{{detail}}', addedOne: '添加了 1 个下载', addedMany: '添加了 {{count}} 个下载', + updatedOne: '已更新 1 个下载', + updatedMany: '已更新 {{count}} 个下载', urlAlreadyQueued: 'URL 已在队列中', destinationConflict: '其他选定的下载已使用此目标路径', existingDownloadDestination: '现有的 Firelink 下载已使用此目标路径', + matchingDownloadFilename: '已有一个未完成的下载使用此文件名', fileExistsOnDisk: '磁盘上已存在该文件;请重命名或跳过以避免删除无关数据', backendRejectedStart: '后端拒绝开始下载。', playlistNoEntries: '播放列表不包含可下载的条目', diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index e498ba0..9b5c43b 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -124,6 +124,132 @@ describe('useDownloadStore', () => { expect(state.pendingAddRequestContexts['https://example.com/file.bin']?.media).toBe(false); }); + it('replaces a paused download URL in place and preserves its progress', async () => { + useDownloadStore.setState({ + downloads: [{ + id: 'replace-in-place', + url: 'https://expired.example/file.bin', + fileName: 'file.bin', + status: 'paused', + category: 'Other', + dateAdded: '2026-07-15T00:00:00.000Z', + downloadedBytes: 1024, + totalBytes: 4096, + fraction: 0.25 + }] as any[] + }); + vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never); + + const replaced = await useDownloadStore.getState().replaceDownload( + 'replace-in-place', + { url: 'https://fresh.example/file.bin', lastError: undefined }, + { type: 'add-to-queue', queueId: 'main' } + ); + + expect(replaced).toBe(true); + expect(useDownloadStore.getState().downloads).toEqual([expect.objectContaining({ + id: 'replace-in-place', + url: 'https://fresh.example/file.bin', + status: 'paused', + downloadedBytes: 1024, + totalBytes: 4096, + fraction: 0.25 + })]); + expect(ipc.invokeCommand).not.toHaveBeenCalledWith('remove_download', expect.anything()); + }); + + it('resumes a replaced paused download without creating a second row', async () => { + useDownloadStore.setState({ + downloads: [{ + id: 'replace-and-resume', + url: 'https://expired.example/file.bin', + fileName: 'file.bin', + status: 'paused', + category: 'Other', + dateAdded: '', + destination: '/tmp', + downloadedBytes: 2048, + totalBytes: 4096 + }] as any[] + }); + vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => { + if (command === 'resume_download') return false; + if (command === 'enqueue_download') { + return { id: 'replace-and-resume', filename: 'file.bin' }; + } + if (command === 'get_pending_order') return []; + return undefined; + }); + + const replaced = await useDownloadStore.getState().replaceDownload( + 'replace-and-resume', + { url: 'https://fresh.example/file.bin' }, + { type: 'start-now' } + ); + + expect(replaced).toBe(true); + expect(useDownloadStore.getState().downloads).toHaveLength(1); + expect(useDownloadStore.getState().downloads[0]).toMatchObject({ + id: 'replace-and-resume', + url: 'https://fresh.example/file.bin', + downloadedBytes: 2048, + totalBytes: 4096, + hasBeenDispatched: true + }); + expect(ipc.invokeCommand).not.toHaveBeenCalledWith('remove_download', expect.anything()); + }); + + it('serializes a replacement and a concurrent pause as one lifecycle operation', async () => { + useDownloadStore.setState({ + downloads: [{ + id: 'replace-pause-race', + url: 'https://expired.example/file.bin', + fileName: 'file.bin', + status: 'paused', + category: 'Other', + dateAdded: '' + }] as any[] + }); + + let releaseResume!: () => void; + let signalResumeStarted!: () => void; + const resumeStarted = new Promise(resolve => { + signalResumeStarted = resolve; + }); + const resumeGate = new Promise(resolve => { + releaseResume = resolve; + }); + vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => { + if (command === 'resume_download') { + signalResumeStarted(); + await resumeGate; + return true; + } + return undefined; + }); + + const replacing = useDownloadStore.getState().replaceDownload( + 'replace-pause-race', + { url: 'https://fresh.example/file.bin' }, + { type: 'start-now' } + ); + await resumeStarted; + + const pausing = useDownloadStore.getState().pauseDownload('replace-pause-race'); + await Promise.resolve(); + expect(ipc.invokeCommand).not.toHaveBeenCalledWith('pause_download', { id: 'replace-pause-race' }); + + releaseResume(); + await replacing; + await pausing; + + expect(ipc.invokeCommand).toHaveBeenCalledWith('pause_download', { id: 'replace-pause-race' }); + expect(useDownloadStore.getState().downloads[0]).toMatchObject({ + url: 'https://fresh.example/file.bin', + status: 'paused' + }); + }); + it('rejects empty and duplicate queue names', () => { useDownloadStore.setState({ queues: [ diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index e4a691b..4a3c4a1 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -676,6 +676,7 @@ interface DownloadState { closeDeleteModal: () => void; setSelectedPropertiesDownloadId: (id: string | null) => void; addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise; + replaceDownload: (id: string, updates: Partial, action: AddDownloadAction) => Promise; updateDownload: (id: string, updates: Partial) => void; removeDownload: (id: string, deleteFile?: boolean, preserveResumable?: boolean) => Promise; pauseDownload: (id: string) => Promise; @@ -694,7 +695,115 @@ interface DownloadState { } -export const useDownloadStore = create((set, get) => ({ +export const useDownloadStore = create((set, get) => { + const applyPropertiesInternal = async (id: string, updates: Partial): Promise => { + await waitForPendingStartupResume(); + const wasDispatching = await invalidateAndWaitForDispatch(id); + const state = get(); + const item = state.downloads.find(d => d.id === id); + if (!item) return; + + if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') { + throw new Error(i18n.t($ => $.downloadTable.transferActive)); + } + + if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') { + state.updateDownload(id, updates); + return; + } + + // Queued or Paused + const isRegistered = state.backendRegisteredIds.has(id); + + if (item.status === 'queued') { + if (isRegistered) { + await invoke('detach_download_for_reconfigure', { id }); + state.unregisterBackendIds([id]); + set(current => ({ pendingOrder: current.pendingOrder.filter(value => value !== id) })); + } + state.updateDownload(id, updates); + if (isRegistered || wasDispatching) { + const dispatched = await dispatchItemInternal(id); + if (dispatched) { + state.updateDownload(id, { hasBeenDispatched: true }); + } else { + state.removeFromQueue(id); + } + } + } else if (item.status === 'paused') { + if (isRegistered) { + try { + await invoke('detach_download_for_reconfigure', { id }); + } catch (e) { + console.error("Failed to detach for reconfigure:", e); + throw e; // Preserve old properties if detach fails + } + state.unregisterBackendIds([id]); + } + state.updateDownload(id, updates); + } + }; + + const resumeDownloadInternal = async (id: string): Promise => { + await waitForPendingStartupResume(); + const targetItem = get().downloads.find(d => d.id === id); + if (!targetItem) return false; + + setDownloadControlIntent(id, 'resume'); + try { + if (targetItem.status === 'ready' || targetItem.status === 'staged') { + get().updateDownload(id, { status: 'queued', hasBeenDispatched: true }); + if (await dispatchItemInternal(id)) { + return true; + } + get().updateDownload(id, { status: targetItem.status }); + clearDownloadControlIntent(id, 'resume'); + return false; + } + + const prevStatus = targetItem.status; + const queueItems = get().downloads.filter(d => + (d.queueId || MAIN_QUEUE_ID) === (targetItem.queueId || MAIN_QUEUE_ID) + ); + const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1); + + get().updateDownload(id, { + status: 'queued', + speed: '-', + eta: '-', + queuePosition: maxPos + 1, + lastTry: new Date().toISOString() + }); + + const resumedExisting = await invoke('resume_download', { id }); + + let dispatchSucceeded = resumedExisting; + if (!dispatchSucceeded) { + get().unregisterBackendIds([id]); + // A terminal aria2 gid is intentionally re-enqueued as a new + // lifecycle. Advance and cancel the old generation before dispatching + // so QueueManager does not reject the legitimate user retry as stale. + await invalidateAndWaitForDispatch(id); + dispatchSucceeded = await dispatchItemInternal(id); + } + + if (dispatchSucceeded) { + return true; + } else { + console.error("Failed to re-enqueue for resume"); + get().updateDownload(id, { status: prevStatus }); + clearDownloadControlIntent(id, 'resume'); + return false; + } + } catch (e) { + console.error("Failed to resume download:", e); + get().updateDownload(id, { status: targetItem.status }); + clearDownloadControlIntent(id, 'resume'); + return false; + } + }; + + return { downloads: [], queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }], pendingOrder: [], @@ -951,53 +1060,30 @@ export const useDownloadStore = create((set, get) => ({ } return false; }, - applyProperties: (id, updates) => runDownloadLifecycleOperation(id, 'properties', async () => { - await waitForPendingStartupResume(); - const wasDispatching = await invalidateAndWaitForDispatch(id); - const state = get(); - const item = state.downloads.find(d => d.id === id); - if (!item) return; - - if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') { - throw new Error(i18n.t($ => $.downloadTable.transferActive)); - } - - if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') { - state.updateDownload(id, updates); - return; - } - - // Queued or Paused - const isRegistered = state.backendRegisteredIds.has(id); - - if (item.status === 'queued') { - if (isRegistered) { - await invoke('detach_download_for_reconfigure', { id }); - state.unregisterBackendIds([id]); - set(current => ({ pendingOrder: current.pendingOrder.filter(value => value !== id) })); + replaceDownload: (id, updates, action) => runDownloadLifecycleOperation( + id, + 'replace', + async () => { + if (!get().downloads.some(download => download.id === id)) return false; + await applyPropertiesInternal(id, updates); + if (!get().downloads.some(download => download.id === id)) return false; + if (action.type === 'start-now') { + const resumed = await resumeDownloadInternal(id); + if (resumed) get().updateDownload(id, { hasBeenDispatched: true }); + return resumed; } - state.updateDownload(id, updates); - if (isRegistered || wasDispatching) { - const dispatched = await dispatchItemInternal(id); - if (dispatched) { - state.updateDownload(id, { hasBeenDispatched: true }); - } else { - state.removeFromQueue(id); - } - } - } else if (item.status === 'paused') { - if (isRegistered) { - try { - await invoke('detach_download_for_reconfigure', { id }); - } catch (e) { - console.error("Failed to detach for reconfigure:", e); - throw e; // Preserve old properties if detach fails - } - state.unregisterBackendIds([id]); - } - state.updateDownload(id, updates); - } - }, false, preemptDispatch), + return true; + }, + false, + preemptDispatch + ), + applyProperties: (id, updates) => runDownloadLifecycleOperation( + id, + 'properties', + () => applyPropertiesInternal(id, updates), + false, + preemptDispatch + ), updateDownload: (id, updates) => { set((state) => ({ downloads: state.downloads.map(d => { @@ -1126,64 +1212,13 @@ export const useDownloadStore = create((set, get) => ({ info(`Download ${id} redownloaded (queued)`); } }, true, preemptDispatch), - resumeDownload: (id) => runDownloadLifecycleOperation(id, 'resume', async () => { - await waitForPendingStartupResume(); - const targetItem = get().downloads.find(d => d.id === id); - if (!targetItem) return false; - - setDownloadControlIntent(id, 'resume'); - try { - if (targetItem.status === 'ready' || targetItem.status === 'staged') { - get().updateDownload(id, { status: 'queued', hasBeenDispatched: true }); - if (await dispatchItemInternal(id)) { - return true; - } - get().updateDownload(id, { status: targetItem.status }); - clearDownloadControlIntent(id, 'resume'); - return false; - } - - const prevStatus = targetItem.status; - const queueItems = get().downloads.filter(d => - (d.queueId || MAIN_QUEUE_ID) === (targetItem.queueId || MAIN_QUEUE_ID) - ); - const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1); - - get().updateDownload(id, { - status: 'queued', - speed: '-', - eta: '-', - queuePosition: maxPos + 1, - lastTry: new Date().toISOString() - }); - - const resumedExisting = await invoke('resume_download', { id }); - - let dispatchSucceeded = resumedExisting; - if (!dispatchSucceeded) { - get().unregisterBackendIds([id]); - // A terminal aria2 gid is intentionally re-enqueued as a new - // lifecycle. Advance and cancel the old generation before dispatching - // so QueueManager does not reject the legitimate user retry as stale. - await invalidateAndWaitForDispatch(id); - dispatchSucceeded = await dispatchItemInternal(id); - } - - if (dispatchSucceeded) { - return true; - } else { - console.error("Failed to re-enqueue for resume"); - get().updateDownload(id, { status: prevStatus }); - clearDownloadControlIntent(id, 'resume'); - return false; - } - } catch (e) { - console.error("Failed to resume download:", e); - get().updateDownload(id, { status: targetItem.status }); - clearDownloadControlIntent(id, 'resume'); - return false; - } - }, true, preemptDispatch), + resumeDownload: (id) => runDownloadLifecycleOperation( + id, + 'resume', + () => resumeDownloadInternal(id), + true, + preemptDispatch + ), startQueue: (queueId) => { const requestedGeneration = currentQueueControlGeneration(queueId); const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]); @@ -1654,7 +1689,8 @@ export const useDownloadStore = create((set, get) => ({ throw e; } } -})); + }; +}); let lastSavedDownloads = ''; let isSavingDownloads = false; diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts index 4f5e836..f9d0471 100644 --- a/src/utils/downloads.test.ts +++ b/src/utils/downloads.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import type { DownloadItem } from '../bindings/DownloadItem'; -import { redactDownloadForPersistence, resolveDownloadConnections } from './downloads'; +import { + downloadFileNamesMatch, + downloadMediaKindsMatch, + redactDownloadForPersistence, + resolveDownloadConnections +} from './downloads'; const item = (status: DownloadItem['status']): DownloadItem => ({ id: 'download-1', @@ -56,3 +61,28 @@ describe('download connection resolution', () => { expect(resolveDownloadConnections(Number.NaN, 8)).toBe(8); }); }); + +describe('download filename matching', () => { + it('matches case and path spelling while preserving the actual filename', () => { + expect(downloadFileNamesMatch( + 'Media\\Example.Show.S01E01.MKV', + 'example.show.s01e01.mkv' + )).toBe(true); + }); + + it('does not collapse distinct extensions or names', () => { + expect(downloadFileNamesMatch('example.zip', 'example.tar')).toBe(false); + expect(downloadFileNamesMatch('example-1.zip', 'example.zip')).toBe(false); + }); + + it('does not auto-match weak metadata fallback names', () => { + expect(downloadFileNamesMatch('download', 'download')).toBe(false); + expect(downloadFileNamesMatch('identifier', 'IDENTIFIER')).toBe(false); + expect(downloadFileNamesMatch('real-file.bin', 'real-file.bin')).toBe(true); + }); + + it('treats omitted media flags as ordinary downloads', () => { + expect(downloadMediaKindsMatch(undefined, false)).toBe(true); + expect(downloadMediaKindsMatch(undefined, true)).toBe(false); + }); +}); diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index 4712a50..ac3a32c 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -132,6 +132,29 @@ export const canonicalizeDownloadFileName = (fileName: string): string => { return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download'; }; +/** + * Compare metadata-derived names without allowing path spelling or case to + * turn the same download into a second queue entry. Keep the extension and + * the rest of the name intact: URL query strings are not part of this value. + */ +export const normalizeDownloadFileNameForMatch = (fileName: string): string => + canonicalizeDownloadFileName(fileName).normalize('NFKC').toLowerCase(); + +export const downloadMediaKindsMatch = ( + left: boolean | undefined, + right: boolean | undefined +): boolean => Boolean(left) === Boolean(right); + +const WEAK_DOWNLOAD_FILE_NAMES = new Set(['download', 'identifier', 'view', 'uc']); + +export const downloadFileNamesMatch = (left: string, right: string): boolean => { + const normalizedLeft = normalizeDownloadFileNameForMatch(left); + const normalizedRight = normalizeDownloadFileNameForMatch(right); + return !WEAK_DOWNLOAD_FILE_NAMES.has(normalizedLeft) + && !WEAK_DOWNLOAD_FILE_NAMES.has(normalizedRight) + && normalizedLeft === normalizedRight; +}; + export const isMediaUrl = (rawUrl: string): boolean => { try { const url = new URL(rawUrl);