mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-30 04:19:28 +00:00
@@ -7,12 +7,13 @@ import {
|
|||||||
type PendingAddRequestContext
|
type PendingAddRequestContext
|
||||||
} from '../store/useDownloadStore';
|
} from '../store/useDownloadStore';
|
||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
|
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||||
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
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 { 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 { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { invokeCommand as invoke } from '../ipc';
|
import { invokeCommand as invoke } from '../ipc';
|
||||||
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
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 { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
|
||||||
import {
|
import {
|
||||||
expandTilde,
|
expandTilde,
|
||||||
@@ -752,6 +753,7 @@ export const AddDownloadsModal = () => {
|
|||||||
const store = useDownloadStore.getState();
|
const store = useDownloadStore.getState();
|
||||||
const newConflicts: DuplicateConflict[] = [];
|
const newConflicts: DuplicateConflict[] = [];
|
||||||
const plannedTargets: Array<{ location: string; fileName: string }> = [];
|
const plannedTargets: Array<{ location: string; fileName: string }> = [];
|
||||||
|
const reservedFilenameMatchIds = new Set<string>();
|
||||||
|
|
||||||
for (let i = 0; i < parsedItems.length; i++) {
|
for (let i = 0; i < parsedItems.length; i++) {
|
||||||
const item = parsedItems[i];
|
const item = parsedItems[i];
|
||||||
@@ -784,7 +786,59 @@ export const AddDownloadsModal = () => {
|
|||||||
replaceAllowed: false
|
replaceAllowed: false
|
||||||
});
|
});
|
||||||
} else {
|
} 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) {
|
for (const download of store.downloads) {
|
||||||
const destination = download.destination ||
|
const destination = download.destination ||
|
||||||
await resolveCategoryDestination(settings, download.category);
|
await resolveCategoryDestination(settings, download.category);
|
||||||
@@ -797,7 +851,7 @@ export const AddDownloadsModal = () => {
|
|||||||
platform.os
|
platform.os
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
fileExistsInStore = true;
|
existingDownload = download;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -811,18 +865,19 @@ export const AddDownloadsModal = () => {
|
|||||||
console.error("Failed to check if file exists on disk:", e);
|
console.error("Failed to check if file exists on disk:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileExistsInStore || fileExistsOnDisk) {
|
if (existingDownload || fileExistsOnDisk) {
|
||||||
newConflicts.push({
|
newConflicts.push({
|
||||||
id: i.toString(),
|
id: i.toString(),
|
||||||
fileName: finalFile,
|
fileName: finalFile,
|
||||||
reason: {
|
reason: {
|
||||||
type: 'file',
|
type: 'file',
|
||||||
msg: fileExistsInStore
|
msg: existingDownload
|
||||||
? t($ => $.addDownloads.existingDownloadDestination)
|
? t($ => $.addDownloads.existingDownloadDestination)
|
||||||
: t($ => $.addDownloads.fileExistsOnDisk)
|
: t($ => $.addDownloads.fileExistsOnDisk)
|
||||||
},
|
},
|
||||||
resolution: 'rename',
|
resolution: 'rename',
|
||||||
replaceAllowed: fileExistsInStore
|
replaceAllowed: Boolean(existingDownload),
|
||||||
|
existingDownloadId: existingDownload?.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -859,6 +914,7 @@ export const AddDownloadsModal = () => {
|
|||||||
item.selected === false ? null : item
|
item.selected === false ? null : item
|
||||||
);
|
);
|
||||||
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
|
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
|
||||||
|
let updatedCount = 0;
|
||||||
|
|
||||||
if (resolutions) {
|
if (resolutions) {
|
||||||
for (const res of resolutions) {
|
for (const res of resolutions) {
|
||||||
@@ -941,29 +997,33 @@ export const AddDownloadsModal = () => {
|
|||||||
itemsToAdd[idx] = null;
|
itemsToAdd[idx] = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let finalFile = item.isMedia
|
const finalFile = item.isMedia
|
||||||
? mediaFileNameForSelectedFormat(item.file, item)
|
? mediaFileNameForSelectedFormat(item.file, item)
|
||||||
: canonicalizeDownloadFileName(item.file);
|
: canonicalizeDownloadFileName(item.file);
|
||||||
const itemLocation = useSharedDestination
|
const itemLocation = useSharedDestination
|
||||||
? finalLocation
|
? finalLocation
|
||||||
: destinationOverrides[idx] || await categoryLocationForFile(finalFile);
|
: destinationOverrides[idx] || await categoryLocationForFile(finalFile);
|
||||||
const store = useDownloadStore.getState();
|
const store = useDownloadStore.getState();
|
||||||
let existingItem;
|
let existingItem = conflict?.existingDownloadId
|
||||||
|
? store.downloads.find(download => download.id === conflict.existingDownloadId)
|
||||||
|
: undefined;
|
||||||
const currentSettings = useSettingsStore.getState();
|
const currentSettings = useSettingsStore.getState();
|
||||||
for (const download of store.downloads) {
|
if (!existingItem && !conflict?.existingDownloadId) {
|
||||||
const destination = download.destination ||
|
for (const download of store.downloads) {
|
||||||
await resolveCategoryDestination(currentSettings, download.category);
|
const destination = download.destination ||
|
||||||
if (
|
await resolveCategoryDestination(currentSettings, download.category);
|
||||||
downloadLocationEquals(
|
if (
|
||||||
destination,
|
downloadLocationEquals(
|
||||||
download.fileName,
|
destination,
|
||||||
itemLocation,
|
download.fileName,
|
||||||
finalFile,
|
itemLocation,
|
||||||
platform.os
|
finalFile,
|
||||||
)
|
platform.os
|
||||||
) {
|
)
|
||||||
existingItem = download;
|
) {
|
||||||
break;
|
existingItem = download;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -974,11 +1034,36 @@ export const AddDownloadsModal = () => {
|
|||||||
if (!existingItem) {
|
if (!existingItem) {
|
||||||
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
|
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
|
||||||
}
|
}
|
||||||
// Let the backend decide whether resumable sidecars still
|
const incomingMediaFormat = mediaFormatSelectorForRow(item);
|
||||||
// exist after stopping the old transfer. This avoids a race
|
const mediaFormatChanged = item.isMedia
|
||||||
// where a paused item finishes while the replacement is
|
&& existingItem.mediaFormatSelector !== incomingMediaFormat;
|
||||||
// being prepared.
|
if (existingItem.status === 'completed' || mediaFormatChanged) {
|
||||||
await store.removeDownload(existingItem.id, true, existingItem.status !== 'completed');
|
// 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 }),
|
: t($ => $.addDownloads.addedMany, { count: addedCount }),
|
||||||
variant: 'success'
|
variant: 'success'
|
||||||
});
|
});
|
||||||
|
} else if (updatedCount > 0) {
|
||||||
|
addToast({
|
||||||
|
message: updatedCount === 1
|
||||||
|
? t($ => $.addDownloads.updatedOne)
|
||||||
|
: t($ => $.addDownloads.updatedMany, { count: updatedCount }),
|
||||||
|
variant: 'success'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface DuplicateConflict {
|
|||||||
reason: DuplicateReason;
|
reason: DuplicateReason;
|
||||||
resolution: DuplicateResolution;
|
resolution: DuplicateResolution;
|
||||||
replaceAllowed?: boolean;
|
replaceAllowed?: boolean;
|
||||||
|
existingDownloadId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -391,9 +391,12 @@ const common = {
|
|||||||
addedWithFailures: '{{added}} added, {{failed}} failed. {{detail}}',
|
addedWithFailures: '{{added}} added, {{failed}} failed. {{detail}}',
|
||||||
addedOne: '1 download added',
|
addedOne: '1 download added',
|
||||||
addedMany: '{{count}} downloads added',
|
addedMany: '{{count}} downloads added',
|
||||||
|
updatedOne: '1 download updated',
|
||||||
|
updatedMany: '{{count}} downloads updated',
|
||||||
urlAlreadyQueued: 'URL already in queue',
|
urlAlreadyQueued: 'URL already in queue',
|
||||||
destinationConflict: 'Another selected download uses this destination',
|
destinationConflict: 'Another selected download uses this destination',
|
||||||
existingDownloadDestination: 'Existing Firelink 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',
|
fileExistsOnDisk: 'File exists on disk; rename or skip to avoid deleting unrelated data',
|
||||||
backendRejectedStart: 'Backend rejected download start.',
|
backendRejectedStart: 'Backend rejected download start.',
|
||||||
playlistNoEntries: 'Playlist contains no downloadable entries',
|
playlistNoEntries: 'Playlist contains no downloadable entries',
|
||||||
|
|||||||
@@ -391,9 +391,12 @@ const fa = {
|
|||||||
addedWithFailures: '{{added}} اضافه شد، {{failed}} ناموفق بود. {{detail}}',
|
addedWithFailures: '{{added}} اضافه شد، {{failed}} ناموفق بود. {{detail}}',
|
||||||
addedOne: '۱ دانلود اضافه شد',
|
addedOne: '۱ دانلود اضافه شد',
|
||||||
addedMany: '{{count}} دانلود اضافه شد',
|
addedMany: '{{count}} دانلود اضافه شد',
|
||||||
|
updatedOne: '۱ دانلود بهروزرسانی شد',
|
||||||
|
updatedMany: '{{count}} دانلود بهروزرسانی شد',
|
||||||
urlAlreadyQueued: 'URL در حال حاضر در صف است',
|
urlAlreadyQueued: 'URL در حال حاضر در صف است',
|
||||||
destinationConflict: 'یک دانلود انتخابشده دیگر از این مقصد استفاده میکند',
|
destinationConflict: 'یک دانلود انتخابشده دیگر از این مقصد استفاده میکند',
|
||||||
existingDownloadDestination: 'دانلود Firelink موجود از این مقصد استفاده میکند',
|
existingDownloadDestination: 'دانلود Firelink موجود از این مقصد استفاده میکند',
|
||||||
|
matchingDownloadFilename: 'یک دانلود ناتمام از قبل همین نام فایل را دارد',
|
||||||
fileExistsOnDisk: 'فایل در دیسک وجود دارد؛ برای جلوگیری از حذف دادههای نامرتبط، تغییر نام دهید یا رد شوید',
|
fileExistsOnDisk: 'فایل در دیسک وجود دارد؛ برای جلوگیری از حذف دادههای نامرتبط، تغییر نام دهید یا رد شوید',
|
||||||
backendRejectedStart: 'شروع دانلود توسط موتور دانلود رد شد.',
|
backendRejectedStart: 'شروع دانلود توسط موتور دانلود رد شد.',
|
||||||
playlistNoEntries: 'لیست پخش شامل هیچ ورودی قابل دانلودی نیست',
|
playlistNoEntries: 'لیست پخش شامل هیچ ورودی قابل دانلودی نیست',
|
||||||
|
|||||||
@@ -391,9 +391,12 @@ const he = {
|
|||||||
addedWithFailures: '{{added}} נוספו, {{failed}} נכשלו. {{detail}}',
|
addedWithFailures: '{{added}} נוספו, {{failed}} נכשלו. {{detail}}',
|
||||||
addedOne: 'הורדה אחת נוספה',
|
addedOne: 'הורדה אחת נוספה',
|
||||||
addedMany: '{{count}} הורדות נוספו',
|
addedMany: '{{count}} הורדות נוספו',
|
||||||
|
updatedOne: 'הורדה אחת עודכנה',
|
||||||
|
updatedMany: '{{count}} הורדות עודכנו',
|
||||||
urlAlreadyQueued: 'URL כבר בתור',
|
urlAlreadyQueued: 'URL כבר בתור',
|
||||||
destinationConflict: 'הורדה אחרת שנבחרה משתמשת ביעד זה',
|
destinationConflict: 'הורדה אחרת שנבחרה משתמשת ביעד זה',
|
||||||
existingDownloadDestination: 'הורדת Firelink קיימת משתמשת ביעד זה',
|
existingDownloadDestination: 'הורדת Firelink קיימת משתמשת ביעד זה',
|
||||||
|
matchingDownloadFilename: 'להורדה שלא הושלמה יש כבר שם קובץ זה',
|
||||||
fileExistsOnDisk: 'הקובץ קיים בדיסק; שנה שם או דלג כדי למנוע מחיקת נתונים שאינם קשורים',
|
fileExistsOnDisk: 'הקובץ קיים בדיסק; שנה שם או דלג כדי למנוע מחיקת נתונים שאינם קשורים',
|
||||||
backendRejectedStart: 'העורף דחה את התחלת ההורדה.',
|
backendRejectedStart: 'העורף דחה את התחלת ההורדה.',
|
||||||
playlistNoEntries: 'הפלייליסט אינו מכיל רשומות הניתנות להורדה',
|
playlistNoEntries: 'הפלייליסט אינו מכיל רשומות הניתנות להורדה',
|
||||||
|
|||||||
@@ -391,9 +391,12 @@ const ru = {
|
|||||||
addedWithFailures: '{{added}} добавлено, {{failed}} с ошибкой. {{detail}}',
|
addedWithFailures: '{{added}} добавлено, {{failed}} с ошибкой. {{detail}}',
|
||||||
addedOne: 'Добавлена 1 загрузка',
|
addedOne: 'Добавлена 1 загрузка',
|
||||||
addedMany: 'Добавлено {{count}} загрузок',
|
addedMany: 'Добавлено {{count}} загрузок',
|
||||||
|
updatedOne: 'Обновлена 1 загрузка',
|
||||||
|
updatedMany: 'Обновлено {{count}} загрузок',
|
||||||
urlAlreadyQueued: 'URL уже в очереди',
|
urlAlreadyQueued: 'URL уже в очереди',
|
||||||
destinationConflict: 'Другая выбранная загрузка использует это место назначения',
|
destinationConflict: 'Другая выбранная загрузка использует это место назначения',
|
||||||
existingDownloadDestination: 'Существующая загрузка Firelink использует это место назначения',
|
existingDownloadDestination: 'Существующая загрузка Firelink использует это место назначения',
|
||||||
|
matchingDownloadFilename: 'Незавершённая загрузка уже имеет это имя файла',
|
||||||
fileExistsOnDisk: 'Файл существует на диске; переименуйте или пропустите, чтобы избежать удаления не связанных данных',
|
fileExistsOnDisk: 'Файл существует на диске; переименуйте или пропустите, чтобы избежать удаления не связанных данных',
|
||||||
backendRejectedStart: 'Бэкенд отклонил запуск загрузки.',
|
backendRejectedStart: 'Бэкенд отклонил запуск загрузки.',
|
||||||
playlistNoEntries: 'Плейлист не содержит доступных для загрузки записей',
|
playlistNoEntries: 'Плейлист не содержит доступных для загрузки записей',
|
||||||
|
|||||||
@@ -391,9 +391,12 @@ const uk = {
|
|||||||
addedWithFailures: '{{added}} додано, {{failed}} не вдалося. {{detail}}',
|
addedWithFailures: '{{added}} додано, {{failed}} не вдалося. {{detail}}',
|
||||||
addedOne: '1 завантаження додано',
|
addedOne: '1 завантаження додано',
|
||||||
addedMany: '{{count}} завантажень додано',
|
addedMany: '{{count}} завантажень додано',
|
||||||
|
updatedOne: '1 завантаження оновлено',
|
||||||
|
updatedMany: 'Оновлено {{count}} завантажень',
|
||||||
urlAlreadyQueued: 'URL-адреса вже в черзі',
|
urlAlreadyQueued: 'URL-адреса вже в черзі',
|
||||||
destinationConflict: 'Інше вибране завантаження використовує це місце призначення',
|
destinationConflict: 'Інше вибране завантаження використовує це місце призначення',
|
||||||
existingDownloadDestination: 'Існуюче завантаження Firelink використовує це місце призначення',
|
existingDownloadDestination: 'Існуюче завантаження Firelink використовує це місце призначення',
|
||||||
|
matchingDownloadFilename: 'Незавершене завантаження вже має це ім’я файлу',
|
||||||
fileExistsOnDisk: 'Файл існує на диску; перейменуйте або пропустіть, щоб уникнути видалення незв\'язаних даних',
|
fileExistsOnDisk: 'Файл існує на диску; перейменуйте або пропустіть, щоб уникнути видалення незв\'язаних даних',
|
||||||
backendRejectedStart: 'Бекенд відхилив початок завантаження.',
|
backendRejectedStart: 'Бекенд відхилив початок завантаження.',
|
||||||
playlistNoEntries: 'Плейлист не містить елементів для завантаження',
|
playlistNoEntries: 'Плейлист не містить елементів для завантаження',
|
||||||
|
|||||||
@@ -391,9 +391,12 @@ const zhCN = {
|
|||||||
addedWithFailures: '添加了 {{added}} 个,失败了 {{failed}} 个。{{detail}}',
|
addedWithFailures: '添加了 {{added}} 个,失败了 {{failed}} 个。{{detail}}',
|
||||||
addedOne: '添加了 1 个下载',
|
addedOne: '添加了 1 个下载',
|
||||||
addedMany: '添加了 {{count}} 个下载',
|
addedMany: '添加了 {{count}} 个下载',
|
||||||
|
updatedOne: '已更新 1 个下载',
|
||||||
|
updatedMany: '已更新 {{count}} 个下载',
|
||||||
urlAlreadyQueued: 'URL 已在队列中',
|
urlAlreadyQueued: 'URL 已在队列中',
|
||||||
destinationConflict: '其他选定的下载已使用此目标路径',
|
destinationConflict: '其他选定的下载已使用此目标路径',
|
||||||
existingDownloadDestination: '现有的 Firelink 下载已使用此目标路径',
|
existingDownloadDestination: '现有的 Firelink 下载已使用此目标路径',
|
||||||
|
matchingDownloadFilename: '已有一个未完成的下载使用此文件名',
|
||||||
fileExistsOnDisk: '磁盘上已存在该文件;请重命名或跳过以避免删除无关数据',
|
fileExistsOnDisk: '磁盘上已存在该文件;请重命名或跳过以避免删除无关数据',
|
||||||
backendRejectedStart: '后端拒绝开始下载。',
|
backendRejectedStart: '后端拒绝开始下载。',
|
||||||
playlistNoEntries: '播放列表不包含可下载的条目',
|
playlistNoEntries: '播放列表不包含可下载的条目',
|
||||||
|
|||||||
@@ -124,6 +124,132 @@ describe('useDownloadStore', () => {
|
|||||||
expect(state.pendingAddRequestContexts['https://example.com/file.bin']?.media).toBe(false);
|
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<void>(resolve => {
|
||||||
|
signalResumeStarted = resolve;
|
||||||
|
});
|
||||||
|
const resumeGate = new Promise<void>(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', () => {
|
it('rejects empty and duplicate queue names', () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
queues: [
|
queues: [
|
||||||
|
|||||||
+142
-106
@@ -676,6 +676,7 @@ interface DownloadState {
|
|||||||
closeDeleteModal: () => void;
|
closeDeleteModal: () => void;
|
||||||
setSelectedPropertiesDownloadId: (id: string | null) => void;
|
setSelectedPropertiesDownloadId: (id: string | null) => void;
|
||||||
addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise<boolean>;
|
addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise<boolean>;
|
||||||
|
replaceDownload: (id: string, updates: Partial<DownloadItem>, action: AddDownloadAction) => Promise<boolean>;
|
||||||
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
|
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
|
||||||
removeDownload: (id: string, deleteFile?: boolean, preserveResumable?: boolean) => Promise<void>;
|
removeDownload: (id: string, deleteFile?: boolean, preserveResumable?: boolean) => Promise<void>;
|
||||||
pauseDownload: (id: string) => Promise<void>;
|
pauseDownload: (id: string) => Promise<void>;
|
||||||
@@ -694,7 +695,115 @@ interface DownloadState {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDownloadStore = create<DownloadState>((set, get) => ({
|
export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||||
|
const applyPropertiesInternal = async (id: string, updates: Partial<DownloadItem>): Promise<void> => {
|
||||||
|
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<boolean> => {
|
||||||
|
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: [],
|
downloads: [],
|
||||||
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
||||||
pendingOrder: [],
|
pendingOrder: [],
|
||||||
@@ -951,53 +1060,30 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
applyProperties: (id, updates) => runDownloadLifecycleOperation(id, 'properties', async () => {
|
replaceDownload: (id, updates, action) => runDownloadLifecycleOperation(
|
||||||
await waitForPendingStartupResume();
|
id,
|
||||||
const wasDispatching = await invalidateAndWaitForDispatch(id);
|
'replace',
|
||||||
const state = get();
|
async () => {
|
||||||
const item = state.downloads.find(d => d.id === id);
|
if (!get().downloads.some(download => download.id === id)) return false;
|
||||||
if (!item) return;
|
await applyPropertiesInternal(id, updates);
|
||||||
|
if (!get().downloads.some(download => download.id === id)) return false;
|
||||||
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') {
|
if (action.type === 'start-now') {
|
||||||
throw new Error(i18n.t($ => $.downloadTable.transferActive));
|
const resumed = await resumeDownloadInternal(id);
|
||||||
}
|
if (resumed) get().updateDownload(id, { hasBeenDispatched: true });
|
||||||
|
return resumed;
|
||||||
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);
|
return true;
|
||||||
if (isRegistered || wasDispatching) {
|
},
|
||||||
const dispatched = await dispatchItemInternal(id);
|
false,
|
||||||
if (dispatched) {
|
preemptDispatch
|
||||||
state.updateDownload(id, { hasBeenDispatched: true });
|
),
|
||||||
} else {
|
applyProperties: (id, updates) => runDownloadLifecycleOperation(
|
||||||
state.removeFromQueue(id);
|
id,
|
||||||
}
|
'properties',
|
||||||
}
|
() => applyPropertiesInternal(id, updates),
|
||||||
} else if (item.status === 'paused') {
|
false,
|
||||||
if (isRegistered) {
|
preemptDispatch
|
||||||
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),
|
|
||||||
updateDownload: (id, updates) => {
|
updateDownload: (id, updates) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
downloads: state.downloads.map(d => {
|
downloads: state.downloads.map(d => {
|
||||||
@@ -1126,64 +1212,13 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
info(`Download ${id} redownloaded (queued)`);
|
info(`Download ${id} redownloaded (queued)`);
|
||||||
}
|
}
|
||||||
}, true, preemptDispatch),
|
}, true, preemptDispatch),
|
||||||
resumeDownload: (id) => runDownloadLifecycleOperation(id, 'resume', async () => {
|
resumeDownload: (id) => runDownloadLifecycleOperation(
|
||||||
await waitForPendingStartupResume();
|
id,
|
||||||
const targetItem = get().downloads.find(d => d.id === id);
|
'resume',
|
||||||
if (!targetItem) return false;
|
() => resumeDownloadInternal(id),
|
||||||
|
true,
|
||||||
setDownloadControlIntent(id, 'resume');
|
preemptDispatch
|
||||||
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),
|
|
||||||
startQueue: (queueId) => {
|
startQueue: (queueId) => {
|
||||||
const requestedGeneration = currentQueueControlGeneration(queueId);
|
const requestedGeneration = currentQueueControlGeneration(queueId);
|
||||||
const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]);
|
const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]);
|
||||||
@@ -1654,7 +1689,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
let lastSavedDownloads = '';
|
let lastSavedDownloads = '';
|
||||||
let isSavingDownloads = false;
|
let isSavingDownloads = false;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||||
import { redactDownloadForPersistence, resolveDownloadConnections } from './downloads';
|
import {
|
||||||
|
downloadFileNamesMatch,
|
||||||
|
downloadMediaKindsMatch,
|
||||||
|
redactDownloadForPersistence,
|
||||||
|
resolveDownloadConnections
|
||||||
|
} from './downloads';
|
||||||
|
|
||||||
const item = (status: DownloadItem['status']): DownloadItem => ({
|
const item = (status: DownloadItem['status']): DownloadItem => ({
|
||||||
id: 'download-1',
|
id: 'download-1',
|
||||||
@@ -56,3 +61,28 @@ describe('download connection resolution', () => {
|
|||||||
expect(resolveDownloadConnections(Number.NaN, 8)).toBe(8);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -132,6 +132,29 @@ export const canonicalizeDownloadFileName = (fileName: string): string => {
|
|||||||
return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
|
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 => {
|
export const isMediaUrl = (rawUrl: string): boolean => {
|
||||||
try {
|
try {
|
||||||
const url = new URL(rawUrl);
|
const url = new URL(rawUrl);
|
||||||
|
|||||||
Reference in New Issue
Block a user