Files
Firelink/src/components/AddDownloadsModal.tsx
T
2026-07-21 08:39:54 +03:30

1889 lines
82 KiB
TypeScript

import { useCallback, useState, useEffect, useRef } from 'react';
import {
useDownloadStore,
getSiteLogin,
getProxyArgs,
type AddDownloadAction,
type PendingAddRequestContext
} from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import type { DownloadItem } from '../bindings/DownloadItem';
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
import { FolderPlus, Save, 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, downloadFileNamesMatch, downloadMediaKindsMatch } from '../utils/downloads';
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
import {
expandTilde,
resolveCategoryDestination,
deriveBatchFolderName,
resolveDownloadFilePath,
resolveSubfolderDestination,
sanitizeBatchFolderName,
downloadLocationEquals,
resolveInitialAddWindowLocation
} from '../utils/downloadLocations';
import { getPlatformInfo } from '../utils/platform';
import { isTransferLocked } from '../utils/downloadActions';
import { useToast } from '../contexts/ToastContext';
import { useTranslation } from 'react-i18next';
import { localeDirection, localePluralVariant, resolveAppLocale } from '../i18n/locales';
import {
canSubmitMetadataRows,
appendRequestUrlsAfterVersion,
mediaFileNameForSelectedFormat,
mediaFormatSelectorForRow,
metadataSummaryState,
playlistFilePrefix,
reconcileDownloadRows,
refreshFailedMetadataRows,
updateRowIfCurrent,
type AddDownloadDraftRow
} from '../utils/addDownloadMetadata';
const formatBytes = (bytes: number) => {
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
const normalizeComparableUrl = (rawUrl: string) => {
try {
return new URL(rawUrl).href;
} catch {
return rawUrl.trim();
}
};
const urlsHaveDifferentOrigins = (sourceUrl: string, targetUrl: string) => {
try {
const source = new URL(sourceUrl);
const target = new URL(targetUrl);
return source.protocol !== target.protocol
|| source.hostname.toLowerCase() !== target.hostname.toLowerCase()
|| source.port !== target.port;
} catch {
return false;
}
};
const cookieScopeForUrl = (context: PendingAddRequestContext | undefined, targetUrl: string) => {
if (!context?.cookieScopes?.length) return '';
try {
const target = new URL(targetUrl);
return context.cookieScopes.find(scope => {
try {
const scopeUrl = new URL(scope.url);
const sameGoogleusercontentSite = scopeUrl.protocol === target.protocol
&& scopeUrl.port === target.port
&& scopeUrl.hostname.toLowerCase() === 'googleusercontent.com'
&& target.hostname.toLowerCase().endsWith('.googleusercontent.com');
return sameGoogleusercontentSite || (scopeUrl.protocol === target.protocol
&& scopeUrl.hostname.toLowerCase() === target.hostname.toLowerCase()
&& scopeUrl.port === target.port);
} catch {
return false;
}
})?.cookies.trim() || '';
} catch {
return '';
}
};
const isGoogleAuthenticatedCaptureUrl = (rawUrl: string) => {
try {
const hostname = new URL(rawUrl).hostname.toLowerCase();
return hostname === 'mail.google.com'
|| hostname === 'accounts.google.com'
|| hostname === 'googleusercontent.com'
|| hostname.endsWith('.googleusercontent.com');
} catch {
return false;
}
};
const extensionHeaders = (context: PendingAddRequestContext | undefined) => [
context?.referer ? `Referer: ${context.referer.replace(/[\r\n]/g, '')}` : '',
context?.media
? (context.headers || '')
.split(/\r?\n/)
.filter(line => {
const separator = line.indexOf(':');
return separator < 0 || line.slice(0, separator).trim().toLowerCase() !== 'cookie';
})
.join('\n')
: context?.headers
].filter(Boolean).join('\n');
export const AddDownloadsModal = () => {
const { t, i18n } = useTranslation();
const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl';
const { addToast } = useToast();
const {
isAddModalOpen,
pendingAddUrls,
pendingAddReferer,
pendingAddFilename,
pendingAddHeaders,
pendingAddCookies,
pendingAddMediaUrls,
pendingAddBatchName,
pendingAddRequestContexts,
pendingAddRequestVersion,
toggleAddModal,
addDownload,
queues
} = useDownloadStore();
const {
baseDownloadFolder,
rememberLastUsedDownloadDirectory,
lastUsedDownloadDirectory,
perServerConnections,
keychainAccessReady,
keychainPromptDismissed,
showKeychainModal
} = useSettingsStore();
const [urls, setUrls] = useState('');
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
const [parsedItems, setParsedItems] = useState<AddDownloadDraftRow[]>([]);
const metadataRequestsRef = useRef(new Set<string>());
const playlistRequestsRef = useRef(new Set<string>());
const latestPlaylistRequestRef = useRef(new Map<string, string>());
const [playlistExpansions, setPlaylistExpansions] = useState<Record<string, MediaPlaylistMetadata>>({});
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
const [showingDuplicates, setShowingDuplicates] = useState(false);
const [pendingAction, setPendingAction] = useState<AddDownloadAction>({ type: 'start-now' });
const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false);
const [pendingDestinationOverrides, setPendingDestinationOverrides] = useState<Record<number, string>>({});
const [resolvedLocation, setResolvedLocation] = useState('');
const [isQueueMenuOpen, setIsQueueMenuOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const isSubmittingRef = useRef(false);
const actionMenuRef = useRef<HTMLDivElement>(null);
// Right Form
const [saveLocation, setSaveLocation] = useState(baseDownloadFolder);
const [isSaveLocationManual, setIsSaveLocationManual] = useState(false);
const [saveInDedicatedFolder, setSaveInDedicatedFolder] = useState(false);
const [dedicatedFolderName, setDedicatedFolderName] = useState('');
const dedicatedFolderNameEditedRef = useRef(false);
const locationResolutionRequestRef = useRef(0);
const folderPickerRequestRef = useRef(0);
const pendingLastUsedDownloadDirectoryRef = useRef<string | null>(null);
const [connections, setConnections] = useState(perServerConnections);
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
const [speedLimit, setSpeedLimit] = useState('1024');
const [freeSpace, setFreeSpace] = useState('Unknown');
const freeSpaceRequestRef = useRef(0);
const [useAuth, setUseAuth] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [advancedExpanded, setAdvancedExpanded] = useState(false);
const [checksumEnabled, setChecksumEnabled] = useState(false);
const [checksumAlgo, setChecksumAlgo] = useState('SHA-256');
const [checksumValue, setChecksumValue] = useState('');
const [headers, setHeaders] = useState('');
const [cookies, setCookies] = useState('');
const headersManuallyEditedRef = useRef(false);
const cookiesManuallyEditedRef = useRef(false);
const modalSessionRef = useRef(false);
const observedRequestVersionRef = useRef(0);
const [mirrors, setMirrors] = useState('');
const requestContextForUrl = (url: string) =>
pendingAddRequestContexts[normalizeComparableUrl(url)];
const hasExtensionRequestContext = Object.keys(pendingAddRequestContexts).length > 0;
const headersForRow = (sourceUrl: string) => {
if (headersManuallyEditedRef.current) return headers.trim();
const context = requestContextForUrl(sourceUrl);
if (context) return extensionHeaders(context).trim();
return hasExtensionRequestContext ? '' : headers.trim();
};
const cookiesForRow = (sourceUrl: string, targetUrl = sourceUrl) => {
if (cookiesManuallyEditedRef.current) return cookies.trim();
const context = requestContextForUrl(sourceUrl);
const scopedCookies = cookieScopeForUrl(context, targetUrl);
if (scopedCookies) return scopedCookies;
if (context && urlsHaveDifferentOrigins(sourceUrl, targetUrl)) return '';
if (context) return context.cookies.trim();
return hasExtensionRequestContext ? '' : cookies.trim();
};
const shouldDeferCookiesForRow = (sourceUrl: string) =>
!cookiesManuallyEditedRef.current
&& Boolean(requestContextForUrl(sourceUrl))
&& !isGoogleAuthenticatedCaptureUrl(sourceUrl);
const suggestedFilenameForRow = (sourceUrl: string) => {
const context = requestContextForUrl(sourceUrl);
if (context?.filename) return context.filename;
return hasExtensionRequestContext ? '' : pendingAddFilename;
};
const requestContextUrlForRow = (row: AddDownloadDraftRow) =>
row.playlistSourceUrl || row.sourceUrl;
const closeModalFromDismissAction = useCallback(() => {
if (isSubmitting || isSubmittingRef.current || showKeychainModal) return;
const hasPendingInput = Boolean(
urls.trim() || pendingAddUrls.trim() || parsedItems.some(item => item.selected !== false) || headers.trim() || cookies.trim()
);
if (hasPendingInput && !window.confirm(t($ => $.addDownloads.discardSetup))) return;
toggleAddModal(false);
}, [cookies, headers, isSubmitting, parsedItems, pendingAddUrls, showKeychainModal, toggleAddModal, urls]);
useEffect(() => {
if (!isAddModalOpen) {
modalSessionRef.current = false;
++folderPickerRequestRef.current;
pendingLastUsedDownloadDirectoryRef.current = null;
setUrls('');
setPlaylistExpansions({});
playlistRequestsRef.current.clear();
latestPlaylistRequestRef.current.clear();
return;
}
if (modalSessionRef.current) return;
modalSessionRef.current = true;
const initialUrls = pendingAddUrls || '';
const initialUrlLines = initialUrls.split('\n').map(url => url.trim()).filter(Boolean);
observedRequestVersionRef.current = pendingAddRequestVersion;
const initialContext = initialUrlLines.length === 1
? requestContextForUrl(initialUrlLines[0])
: undefined;
const initialLocation = resolveInitialAddWindowLocation(
baseDownloadFolder,
rememberLastUsedDownloadDirectory,
lastUsedDownloadDirectory
);
setSaveLocation(initialLocation.path);
setIsSaveLocationManual(initialLocation.isManual);
setSaveInDedicatedFolder(false);
dedicatedFolderNameEditedRef.current = false;
setDedicatedFolderName(deriveBatchFolderName(pendingAddBatchName, pendingAddReferer));
setUrls(initialUrls);
setParsedItems([]);
setPlaylistExpansions({});
metadataRequestsRef.current.clear();
playlistRequestsRef.current.clear();
latestPlaylistRequestRef.current.clear();
setSelectedItemIndex(null);
setPendingUseSharedDestination(false);
setPendingDestinationOverrides({});
setConnections(perServerConnections);
setFreeSpace('Unknown');
setSpeedLimitEnabled(false);
setSpeedLimit('1024');
setUseAuth(false);
setUsername('');
setPassword('');
setAdvancedExpanded(false);
setChecksumEnabled(false);
setChecksumAlgo('SHA-256');
setChecksumValue('');
setHeaders(initialContext ? extensionHeaders(initialContext) : [
pendingAddReferer ? `Referer: ${pendingAddReferer.replace(/[\r\n]/g, '')}` : '',
pendingAddHeaders
].filter(Boolean).join('\n'));
headersManuallyEditedRef.current = false;
setCookies(initialContext?.cookies || pendingAddCookies);
cookiesManuallyEditedRef.current = false;
setMirrors('');
setIsQueueMenuOpen(false);
isSubmittingRef.current = false;
setIsSubmitting(false);
}, [
isAddModalOpen,
pendingAddUrls,
pendingAddReferer,
pendingAddHeaders,
pendingAddCookies,
pendingAddMediaUrls,
pendingAddBatchName,
baseDownloadFolder,
rememberLastUsedDownloadDirectory,
lastUsedDownloadDirectory,
perServerConnections
]);
useEffect(() => {
if (!isAddModalOpen || !modalSessionRef.current
|| observedRequestVersionRef.current === pendingAddRequestVersion) return;
const observedVersion = observedRequestVersionRef.current;
observedRequestVersionRef.current = pendingAddRequestVersion;
// Playlist membership and entry access can depend on the handoff's
// browser context. Re-discover playlists when a newer extension context
// arrives instead of reusing entries extracted under stale cookies.
setPlaylistExpansions({});
latestPlaylistRequestRef.current.clear();
setUrls(current => appendRequestUrlsAfterVersion(
current,
pendingAddRequestContexts,
observedVersion
));
}, [isAddModalOpen, pendingAddRequestContexts, pendingAddRequestVersion]);
useEffect(() => {
if (!isQueueMenuOpen) return;
const closeMenu = (event: PointerEvent) => {
if (!actionMenuRef.current?.contains(event.target as Node)) {
setIsQueueMenuOpen(false);
}
};
window.addEventListener('pointerdown', closeMenu);
return () => window.removeEventListener('pointerdown', closeMenu);
}, [isQueueMenuOpen]);
useEffect(() => {
if (!isAddModalOpen) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (showKeychainModal) return;
if (showingDuplicates) {
setShowingDuplicates(false);
} else if (isQueueMenuOpen) {
setIsQueueMenuOpen(false);
} else {
closeModalFromDismissAction();
}
};
window.addEventListener('keydown', closeOnEscape);
return () => window.removeEventListener('keydown', closeOnEscape);
}, [closeModalFromDismissAction, isAddModalOpen, isQueueMenuOpen, showKeychainModal, showingDuplicates]);
useEffect(() => {
const requestId = ++freeSpaceRequestRef.current;
if (!isAddModalOpen || !saveLocation) return;
invoke('get_free_space', { path: saveLocation })
.then(space => {
if (freeSpaceRequestRef.current === requestId) {
setFreeSpace(space);
}
})
.catch(() => {
if (freeSpaceRequestRef.current === requestId) {
setFreeSpace('Unknown');
}
});
}, [saveLocation, isAddModalOpen]);
useEffect(() => {
const activeUrls = new Set(
urls.split('\n').map(url => url.trim()).filter(Boolean).map(normalizeComparableUrl)
);
for (const sourceUrl of latestPlaylistRequestRef.current.keys()) {
if (!activeUrls.has(sourceUrl)) {
latestPlaylistRequestRef.current.delete(sourceUrl);
}
}
setPlaylistExpansions(current => {
const retained = Object.fromEntries(
Object.entries(current).filter(([sourceUrl]) => activeUrls.has(sourceUrl))
);
return Object.keys(retained).length === Object.keys(current).length ? current : retained;
});
const forcedMediaUrls = new Set(pendingAddMediaUrls.map(url => {
try {
return new URL(url).href;
} catch {
return url;
}
}));
const requestFilenames = Object.fromEntries(
Object.entries(pendingAddRequestContexts)
.filter(([, context]) => Boolean(context.filename))
.map(([url, context]) => [url, context.filename])
);
const requestContextVersions = Object.fromEntries(
Object.entries(pendingAddRequestContexts)
.map(([url, context]) => [url, context.version])
);
setParsedItems(current => {
const selectedBySourceUrl = Object.fromEntries(
current.map(row => [row.sourceUrl, row.selected !== false])
);
for (const row of current) {
if (row.playlistSourceUrl && !(row.playlistSourceUrl in selectedBySourceUrl)) {
selectedBySourceUrl[row.playlistSourceUrl] = row.selected !== false;
}
}
return reconcileDownloadRows(
urls,
current,
hasExtensionRequestContext ? undefined : pendingAddFilename || undefined,
forcedMediaUrls,
undefined,
requestFilenames,
requestContextVersions,
playlistExpansions,
selectedBySourceUrl
);
});
}, [
urls,
pendingAddFilename,
pendingAddMediaUrls,
pendingAddRequestContexts,
hasExtensionRequestContext,
playlistExpansions
]);
useEffect(() => {
const maxConcurrentMetadataRequests = 4;
for (const row of parsedItems) {
if (row.status !== 'loading' || row.selected === false) continue;
const requestKey = `${row.id}:${row.generation}`;
const requestSet = row.isPlaylist ? playlistRequestsRef.current : metadataRequestsRef.current;
if (requestSet.has(requestKey)) continue;
if (metadataRequestsRef.current.size + playlistRequestsRef.current.size >= maxConcurrentMetadataRequests) {
break;
}
requestSet.add(requestKey);
if (row.isPlaylist) {
// Invalidate stale playlist requests before any asynchronous settings,
// keychain, or network work can yield. Otherwise an old request can
// become the latest request again after the URL or browser context
// has already changed.
latestPlaylistRequestRef.current.set(row.sourceUrl, requestKey);
}
void (async () => {
try {
const settingsStore = useSettingsStore.getState();
const proxy = await getProxyArgs(settingsStore);
const login = getSiteLogin(row.sourceUrl, settingsStore);
const contextUrl = requestContextUrlForRow(row);
const requestContext = requestContextForUrl(contextUrl);
if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) {
settingsStore.setShowKeychainModal(true);
return;
}
if (row.isMedia) {
const { mediaCookieSource } = settingsStore;
const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null;
let keychainPassword = null;
if (login && !useAuth && keychainAccessReady) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password:", e);
}
}
const rowHeaders = headersForRow(contextUrl);
const rowCookies = cookiesForRow(contextUrl, row.sourceUrl);
const mediaMetadataArgs = {
url: row.sourceUrl,
cookieBrowser: browserArg,
userAgent: settingsStore.customUserAgent.trim() || null,
username: useAuth ? username.trim() || null : login?.username || null,
password: useAuth ? password || null : keychainPassword,
headers: rowHeaders || null,
cookies: rowCookies || null,
proxy
};
if (row.isPlaylist) {
if (playlistExpansions[row.sourceUrl]) return;
const playlistData = await fetchMediaPlaylistMetadataDeduped({
...mediaMetadataArgs,
url: contextUrl
});
if (latestPlaylistRequestRef.current.get(row.sourceUrl) !== requestKey) return;
if (!playlistData.entries.length) {
throw new Error(t($ => $.addDownloads.playlistNoEntries));
}
setPlaylistExpansions(current => ({
...current,
[row.sourceUrl]: playlistData
}));
return;
}
const mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs);
if (mediaData && mediaData.formats.length > 0) {
const mappedFormats = mediaData.formats.map(f => {
const quality = f.resolution || 'Video';
const container = f.ext.toUpperCase();
const exactBytes = f.filesize || 0;
const approxBytes = f.filesize_approx || 0;
const bytes = exactBytes || approxBytes;
const isApproximate = !exactBytes && approxBytes > 0;
return {
name: `${quality} ${container}`,
ext: f.ext,
bytes,
isApproximate,
formatLabel: f.format_label || f.ext.toUpperCase(),
detail: bytes ? `${isApproximate ? '~' : ''}${formatBytes(bytes)}` : t($ => $.addDownloads.unknownSize),
selector: f.format_id,
type: quality.toLowerCase().includes('audio') ? 'Audio' : 'Video'
};
});
setParsedItems(current => updateRowIfCurrent(
current,
row.id,
row.sourceUrl,
row.generation,
currentRow => ({
...currentRow,
downloadUrl: row.sourceUrl,
file: canonicalizeDownloadFileName(
`${playlistFilePrefix(row.playlistIndex, row.playlistCount)}${mediaData.title}.${mediaData.formats[0].ext}`
),
size: mappedFormats[0].bytes ? mappedFormats[0].detail : undefined,
sizeBytes: mappedFormats[0].bytes || undefined,
status: 'ready',
formats: mappedFormats,
selectedFormat: 0,
playlistError: undefined
})
));
} else {
throw new Error(t($ => $.addDownloads.invalidMediaMetadata));
}
} else {
let keychainPassword = null;
if (login && !useAuth && keychainAccessReady) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password:", e);
}
}
const meta = await invoke('fetch_metadata', {
url: row.sourceUrl,
userAgent: settingsStore.customUserAgent.trim() || null,
username: useAuth ? username.trim() || null : login?.username || null,
password: useAuth ? password || null : keychainPassword,
headers: headersForRow(contextUrl) || null,
cookies: cookiesForRow(contextUrl, row.sourceUrl) || null,
cookieScopes: requestContext?.cookieScopes || null,
proxy,
deferCookies: shouldDeferCookiesForRow(row.sourceUrl)
});
const nextDownloadUrl = meta.url || row.sourceUrl;
setParsedItems(current => updateRowIfCurrent(
current,
row.id,
row.sourceUrl,
row.generation,
currentRow => ({
...currentRow,
downloadUrl: nextDownloadUrl || currentRow.downloadUrl,
file: canonicalizeDownloadFileName(
current.length === 1 && suggestedFilenameForRow(contextUrl)
? suggestedFilenameForRow(contextUrl)
: meta.filename
),
size: meta.size_bytes ? meta.size : undefined,
sizeBytes: meta.size_bytes || undefined,
status: 'ready',
resumable: meta.resumable,
metadataBlockedReason: undefined
})
));
}
} catch (e) {
console.error("Meta fetch failed", e);
const errorMessage = e instanceof Error ? e.message : String(e);
const metadataBlockedReason = [
'SSRF blocked: Invalid URL',
'SSRF blocked: No host',
'SSRF blocked: DNS resolution failed',
'SSRF blocked: No DNS records',
'SSRF blocked: Private/local IP not allowed'
].some(prefix => errorMessage.startsWith(prefix))
? 'unsafe-url' as const
: undefined;
setParsedItems(current => updateRowIfCurrent(
current,
row.id,
row.sourceUrl,
row.generation,
currentRow => ({
...currentRow,
downloadUrl: currentRow.sourceUrl,
size: undefined,
sizeBytes: undefined,
status: 'metadata-error',
formats: undefined,
selectedFormat: undefined,
metadataBlockedReason,
playlistError: row.isPlaylist
? errorMessage
: undefined
})
));
} finally {
requestSet.delete(requestKey);
}
})();
}
}, [
keychainAccessReady,
keychainPromptDismissed,
parsedItems,
pendingAddFilename,
pendingAddMediaUrls,
playlistExpansions,
useAuth
]);
useEffect(() => {
if (!rememberLastUsedDownloadDirectory) {
pendingLastUsedDownloadDirectoryRef.current = null;
}
}, [rememberLastUsedDownloadDirectory]);
useEffect(() => {
const requestId = ++locationResolutionRequestRef.current;
if (parsedItems.length === 0) {
setSelectedItemIndex(null);
return;
}
setSelectedItemIndex(current =>
current === null || current >= parsedItems.length ? 0 : current
);
if (isSaveLocationManual) return;
if (parsedItems.length > 1) {
const baseFolder = useSettingsStore.getState().baseDownloadFolder || '~/Downloads';
void expandTilde(baseFolder).then(location => {
if (requestId === locationResolutionRequestRef.current) {
setSaveLocation(location);
}
});
return;
}
const first = parsedItems[0];
if (first.status !== 'ready' && first.status !== 'metadata-error') return;
void resolveCategoryDestination(
useSettingsStore.getState(),
categoryForFileName(first.file)
).then(location => {
if (requestId === locationResolutionRequestRef.current) {
setSaveLocation(location);
}
});
}, [isSaveLocationManual, parsedItems]);
useEffect(() => {
if (
!isAddModalOpen
|| parsedItems.length < 2
|| dedicatedFolderNameEditedRef.current
) {
return;
}
setDedicatedFolderName(deriveBatchFolderName(
pendingAddBatchName,
pendingAddReferer,
new Date(),
parsedItems.map(item => item.file)
));
}, [isAddModalOpen, parsedItems, pendingAddBatchName, pendingAddReferer]);
if (!isAddModalOpen) return null;
const handleBrowse = async () => {
try {
const requestId = ++folderPickerRequestRef.current;
const defaultPath = await expandTilde(saveLocation);
const selected = await open({
directory: true,
multiple: false,
defaultPath
});
if (requestId !== folderPickerRequestRef.current) return;
if (selected && typeof selected === 'string') {
++locationResolutionRequestRef.current;
const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected);
if (requestId !== folderPickerRequestRef.current) return;
setSaveLocation(approvedPath);
setIsSaveLocationManual(true);
const settings = useSettingsStore.getState();
if (settings.rememberLastUsedDownloadDirectory) {
pendingLastUsedDownloadDirectoryRef.current = approvedPath;
}
}
} catch (e) {
console.error("Failed to select folder:", e);
}
};
const categoryLocationForFile = (fileName: string) => {
const category = categoryForFileName(fileName);
return resolveCategoryDestination(useSettingsStore.getState(), category);
};
const commitDedicatedFolderName = () => {
const safeName = sanitizeBatchFolderName(dedicatedFolderName);
if (!safeName) {
addToast({
message: t($ => $.addDownloads.dedicatedFolderNameRequired),
variant: 'error',
isActionable: true
});
return;
}
dedicatedFolderNameEditedRef.current = true;
setDedicatedFolderName(safeName);
};
const destinationForFile = async (
fileName: string,
finalLocation: string,
useSharedDestination: boolean,
destinationOverride?: string
): Promise<string> => {
if (destinationOverride) return destinationOverride;
const root = useSharedDestination
? finalLocation
: await categoryLocationForFile(fileName);
return saveInDedicatedFolder
? resolveSubfolderDestination(root, dedicatedFolderName)
: root;
};
const handleAction = async (action: AddDownloadAction) => {
if (isSubmitting || isSubmittingRef.current || !canSubmitMetadataRows(parsedItems)) {
return;
}
if (speedLimitEnabled && (!Number.isFinite(Number(speedLimit)) || Number(speedLimit) <= 0)) {
addToast({ message: t($ => $.addDownloads.speedInvalid), variant: 'error', isActionable: true });
return;
}
if (saveInDedicatedFolder && !sanitizeBatchFolderName(dedicatedFolderName)) {
addToast({
message: t($ => $.addDownloads.dedicatedFolderNameRequired),
variant: 'error',
isActionable: true
});
return;
}
isSubmittingRef.current = true;
setIsSubmitting(true);
++folderPickerRequestRef.current;
let finalLocation = saveLocation;
let useSharedDestination = isSaveLocationManual;
const destinationOverrides: Record<number, string> = {};
const settings = useSettingsStore.getState();
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
for (const [index, item] of parsedItems.entries()) {
if (item.selected === false) continue;
try {
const suggestedLocation = await destinationForFile(
item.file,
finalLocation,
isSaveLocationManual
);
const selected = await open({
directory: true,
multiple: false,
title: `Choose a folder for ${item.file}`,
defaultPath: await expandTilde(suggestedLocation)
});
if (selected && typeof selected === 'string') {
const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected);
destinationOverrides[index] = approvedPath;
const currentSettings = useSettingsStore.getState();
if (currentSettings.rememberLastUsedDownloadDirectory) {
pendingLastUsedDownloadDirectoryRef.current = approvedPath;
}
} else {
pendingLastUsedDownloadDirectoryRef.current = null;
isSubmittingRef.current = false;
setIsSubmitting(false);
return;
}
} catch (e) {
console.error("Failed to select folder:", e);
pendingLastUsedDownloadDirectoryRef.current = null;
isSubmittingRef.current = false;
setIsSubmitting(false);
return;
}
}
}
setResolvedLocation(finalLocation);
const store = useDownloadStore.getState();
const newConflicts: DuplicateConflict[] = [];
const plannedTargets: Array<{ location: string; fileName: string }> = [];
const reservedFilenameMatchIds = new Set<string>();
for (let i = 0; i < parsedItems.length; i++) {
const item = parsedItems[i];
if (item.selected === false) continue;
let finalFile = item.isMedia
? mediaFileNameForSelectedFormat(item.file, item)
: canonicalizeDownloadFileName(item.file);
const itemLocation = await destinationForFile(
finalFile,
finalLocation,
useSharedDestination,
destinationOverrides[i]
);
const isUrlDupe = store.downloads.some(d => d.url === item.downloadUrl && d.status !== 'failed' && d.status !== 'completed');
const hasBatchConflict = plannedTargets.some(target =>
downloadLocationEquals(
target.location,
target.fileName,
itemLocation,
finalFile,
platform.os
)
);
if (isUrlDupe) {
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: t($ => $.addDownloads.urlAlreadyQueued) }, resolution: 'rename' });
} else if (hasBatchConflict) {
newConflicts.push({
id: i.toString(),
fileName: finalFile,
reason: { type: 'file', msg: t($ => $.addDownloads.destinationConflict) },
resolution: 'rename',
replaceAllowed: false
});
} else {
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);
if (
downloadLocationEquals(
destination,
download.fileName,
itemLocation,
finalFile,
platform.os
)
) {
existingDownload = download;
break;
}
}
let fileExistsOnDisk = false;
try {
fileExistsOnDisk = await invoke('check_file_exists', {
path: await resolveDownloadFilePath(itemLocation, finalFile)
});
} catch (e) {
console.error("Failed to check if file exists on disk:", e);
}
if (existingDownload || fileExistsOnDisk) {
newConflicts.push({
id: i.toString(),
fileName: finalFile,
reason: {
type: 'file',
msg: existingDownload
? t($ => $.addDownloads.existingDownloadDestination)
: t($ => $.addDownloads.fileExistsOnDisk)
},
resolution: 'rename',
replaceAllowed: Boolean(existingDownload),
existingDownloadId: existingDownload?.id
});
}
}
plannedTargets.push({ location: itemLocation, fileName: finalFile });
}
if (newConflicts.length > 0) {
setConflicts(newConflicts);
setPendingAction(action);
setPendingUseSharedDestination(useSharedDestination);
setPendingDestinationOverrides(destinationOverrides);
setShowingDuplicates(true);
isSubmittingRef.current = false;
setIsSubmitting(false);
return;
}
try {
await executeAddDownloads(action, finalLocation, useSharedDestination, undefined, destinationOverrides);
} finally {
isSubmittingRef.current = false;
setIsSubmitting(false);
}
};
const executeAddDownloads = async (
action: AddDownloadAction,
finalLocation: string,
useSharedDestination: boolean,
resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[],
destinationOverrides: Record<number, string> = {}
) => {
let itemsToAdd: Array<AddDownloadDraftRow | null> = parsedItems.map(item =>
item.selected === false ? null : item
);
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
let updatedCount = 0;
if (resolutions) {
for (const res of resolutions) {
const idx = parseInt(res.id);
const item = itemsToAdd[idx];
if (!item) continue;
const conflict = conflicts.find(c => c.id === res.id);
if (res.resolution === 'skip') {
itemsToAdd[idx] = null;
} else if (res.resolution === 'rename') {
let finalFile = item.isMedia
? mediaFileNameForSelectedFormat(item.file, item)
: canonicalizeDownloadFileName(item.file);
const itemLocation = await destinationForFile(
finalFile,
finalLocation,
useSharedDestination,
destinationOverrides[idx]
);
let count = 1;
const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
const ext = finalFile.includes('.') ? finalFile.substring(finalFile.lastIndexOf('.')) : '';
let newName = finalFile;
let exists = true;
const batchTargets: Array<{ location: string; fileName: string }> = [];
for (const [candidateIndex, candidate] of itemsToAdd.entries()) {
if (!candidate || candidateIndex === idx) continue;
const candidateFile = candidate.isMedia
? mediaFileNameForSelectedFormat(candidate.file, candidate)
: canonicalizeDownloadFileName(candidate.file);
const candidateLocation = await destinationForFile(
candidateFile,
finalLocation,
useSharedDestination,
destinationOverrides[candidateIndex]
);
batchTargets.push({ location: candidateLocation, fileName: candidateFile });
}
while (exists && count < 1000) {
newName = `${base} (${count})${ext}`;
let storeHas = false;
const currentSettings = useSettingsStore.getState();
for (const download of useDownloadStore.getState().downloads) {
const destination = download.destination ||
await resolveCategoryDestination(currentSettings, download.category);
if (
downloadLocationEquals(
destination,
download.fileName,
itemLocation,
newName,
platform.os
) &&
download.status !== 'failed'
) {
storeHas = true;
break;
}
}
let diskHas = false;
try {
diskHas = await invoke('check_file_exists', {
path: await resolveDownloadFilePath(itemLocation, newName)
});
} catch(e) {}
const batchHas = batchTargets.some(target => downloadLocationEquals(
target.location,
target.fileName,
itemLocation,
newName,
platform.os
));
exists = storeHas || diskHas || batchHas;
count++;
}
if (exists) {
throw new Error(t($ => $.addDownloads.noAvailableName, { file: finalFile }));
}
itemsToAdd[idx] = { ...item, file: newName };
} else if (res.resolution === 'replace') {
if (conflict?.reason.type !== 'file' || !conflict.replaceAllowed) {
itemsToAdd[idx] = null;
continue;
}
const finalFile = item.isMedia
? mediaFileNameForSelectedFormat(item.file, item)
: canonicalizeDownloadFileName(item.file);
const itemLocation = await destinationForFile(
finalFile,
finalLocation,
useSharedDestination,
destinationOverrides[idx]
);
const store = useDownloadStore.getState();
let existingItem = conflict?.existingDownloadId
? store.downloads.find(download => download.id === conflict.existingDownloadId)
: undefined;
const currentSettings = useSettingsStore.getState();
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;
}
}
}
if (existingItem && isTransferLocked(existingItem.status)) {
throw new Error(t($ => $.addDownloads.pauseBeforeReplace, { file: existingItem.fileName }));
}
if (!existingItem) {
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
}
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;
}
}
}
}
let addedCount = 0;
const failures: string[] = [];
for (const [itemIndex, item] of itemsToAdd.entries()) {
if (!item) continue;
try {
const id = crypto.randomUUID();
let finalFile = item.isMedia
? mediaFileNameForSelectedFormat(item.file, item)
: canonicalizeDownloadFileName(item.file);
let formatSelector = mediaFormatSelectorForRow(item);
const contextUrl = requestContextUrlForRow(item);
const category = categoryForFileName(finalFile);
const added = await addDownload({
id,
url: item.downloadUrl,
fileName: finalFile,
category,
dateAdded: new Date().toISOString(),
connections: Number(connections),
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
username: useAuth ? username.trim() : undefined,
password: useAuth ? password.trim() : undefined,
headers: headersForRow(contextUrl) || undefined,
checksum: checksumEnabled && checksumValue.trim()
? `${checksumAlgo}=${checksumValue.trim()}`
: undefined,
cookies: cookiesForRow(contextUrl, item.downloadUrl) || undefined,
mirrors: mirrors.trim() || undefined,
destination: useSharedDestination || saveInDedicatedFolder || destinationOverrides[itemIndex]
? await destinationForFile(
finalFile,
finalLocation,
useSharedDestination,
destinationOverrides[itemIndex]
)
: undefined,
isMedia: item.isMedia,
resumable: item.resumable,
mediaFormatSelector: formatSelector,
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
sizeBytes: item.sizeBytes
}, action);
if (!added) {
throw new Error(t($ => $.addDownloads.backendRejectedStart));
}
addedCount += 1;
} catch (e) {
console.error("Invalid URL or failed to add:", e);
failures.push(`${item.file}: ${e instanceof Error ? e.message : String(e)}`);
}
}
const currentSettings = useSettingsStore.getState();
if (
addedCount > 0
&& currentSettings.rememberLastUsedDownloadDirectory
&& pendingLastUsedDownloadDirectoryRef.current
) {
currentSettings.setLastUsedDownloadDirectory(
pendingLastUsedDownloadDirectoryRef.current
);
}
pendingLastUsedDownloadDirectoryRef.current = null;
toggleAddModal(false);
if (failures.length > 0) {
addToast({
message: t($ => $.addDownloads.addedWithFailures, { added: addedCount, failed: failures.length, detail: failures[0] }),
variant: 'error',
isActionable: true
});
} else if (addedCount > 0) {
addToast({
message: addedCount === 1
? t($ => $.addDownloads.addedOne)
: 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'
});
}
};
const SummaryBox = ({ title, value, icon: Icon, color }: {
title: string;
value: string | number;
icon: LucideIcon;
color: string;
}) => (
<div className="add-download-summary-card flex flex-col">
<div className="flex items-center gap-1.5 text-text-muted mb-1">
<Icon size={12} className={color} />
<span className="text-[10px] font-semibold uppercase tracking-wider">{title}</span>
</div>
<span className="text-sm font-semibold text-text-primary truncate">{value}</span>
</div>
);
const toggleRowSelection = (index: number) => {
setParsedItems(items => items.map((item, itemIndex) =>
itemIndex === index ? { ...item, selected: item.selected === false } : item
));
};
const toggleAllRows = () => {
setParsedItems(items => {
const shouldSelect = items.some(item => item.selected === false);
return items.map(item => ({ ...item, selected: shouldSelect }));
});
};
const selectMediaFormat = (index: number) => {
if (selectedItemIndex === null) return;
const selectedItem = parsedItems[selectedItemIndex];
const format = selectedItem?.formats?.[index];
if (!selectedItem || !format) return;
setParsedItems(items => items.map((item, itemIndex) =>
itemIndex === selectedItemIndex
? {
...item,
selectedFormat: index,
size: format.bytes ? format.detail : undefined,
sizeBytes: format.bytes || undefined,
file: mediaFileNameForSelectedFormat(item.file, {
formats: item.formats,
selectedFormat: index
})
}
: item
));
};
const selectedItems = parsedItems.filter(item => item.selected !== false);
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 =>
item.formats?.[item.selectedFormat ?? -1]?.isApproximate
);
const requiredStr = requiredBytes > 0
? `${hasApproximateSize ? '~' : ''}${requiredBytes < 1024 * 1024 ? `${(requiredBytes / 1024).toFixed(1)} KB`
: requiredBytes < 1024 * 1024 * 1024 ? `${(requiredBytes / 1024 / 1024).toFixed(1)} MB`
: `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`}`
: 'Unknown';
const canSubmit = canSubmitMetadataRows(parsedItems);
const failedMetadataCount = selectedItems.filter(item => item.status === 'metadata-error').length;
const failedMediaMetadataCount = selectedItems.filter(
item => item.status === 'metadata-error' && item.isMedia
).length;
const blockedMetadataCount = selectedItems.filter(
item => item.metadataBlockedReason === 'unsafe-url'
).length;
const fallbackMetadataCount = failedMetadataCount - failedMediaMetadataCount - blockedMetadataCount;
const activePlaylistUrls = new Set(
urls.split('\n').map(url => url.trim()).filter(Boolean).map(normalizeComparableUrl)
);
const playlistSummaries = Object.entries(playlistExpansions)
.filter(([sourceUrl]) => activePlaylistUrls.has(sourceUrl));
const pluralMessage = (
count: number,
one: () => string,
few: () => string,
many: () => string
): string => {
switch (localePluralVariant(i18n.language, count)) {
case 'one': return one();
case 'few': return few();
case 'many': return many();
}
};
const metadataSummary = (() => {
const summary = metadataSummaryState(parsedItems);
switch (summary.type) {
case 'empty': return t($ => $.addDownloads.pasteOneOrMore);
case 'none-selected': return t($ => $.addDownloads.selectAtLeastOne);
case 'invalid': return pluralMessage(
summary.count,
() => t($ => $.addDownloads.correctInvalidOne, { count: summary.count }),
() => t($ => $.addDownloads.correctInvalidFew, { count: summary.count }),
() => t($ => $.addDownloads.correctInvalidMany, { count: summary.count })
);
case 'loading': return pluralMessage(
summary.count,
() => t($ => $.addDownloads.waitingForMetadataOne, { count: summary.count }),
() => t($ => $.addDownloads.waitingForMetadataFew, { count: summary.count }),
() => t($ => $.addDownloads.waitingForMetadataMany, { count: summary.count })
);
case 'unsafe': return pluralMessage(
summary.count,
() => t($ => $.addDownloads.removeUnsafeOne, { count: summary.count }),
() => t($ => $.addDownloads.removeUnsafeFew, { count: summary.count }),
() => t($ => $.addDownloads.removeUnsafeMany, { count: summary.count })
);
case 'media-error': return pluralMessage(
summary.count,
() => t($ => $.addDownloads.mediaMetadataUnavailableSummaryOne, { count: summary.count }),
() => t($ => $.addDownloads.mediaMetadataUnavailableSummaryFew, { count: summary.count }),
() => t($ => $.addDownloads.mediaMetadataUnavailableSummaryMany, { count: summary.count })
);
case 'all-error': return t($ => $.addDownloads.metadataUnavailableFallback);
case 'fallback': return pluralMessage(
summary.ready,
() => t($ => $.addDownloads.fallbackReadyOne, { ready: summary.ready, failed: summary.failed }),
() => t($ => $.addDownloads.fallbackReadyFew, { ready: summary.ready, failed: summary.failed }),
() => t($ => $.addDownloads.fallbackReadyMany, { ready: summary.ready, failed: summary.failed })
);
case 'ready': return pluralMessage(
summary.count,
() => t($ => $.addDownloads.readyToAddOne, { count: summary.count }),
() => t($ => $.addDownloads.readyToAddFew, { count: summary.count }),
() => t($ => $.addDownloads.readyToAddMany, { count: summary.count })
);
}
})();
return (
<>
{showingDuplicates && (
<DuplicateResolutionModal
conflicts={conflicts}
onConfirm={(resolutions) => {
if (isSubmittingRef.current) return;
isSubmittingRef.current = true;
setShowingDuplicates(false);
setIsSubmitting(true);
void executeAddDownloads(
pendingAction,
resolvedLocation,
pendingUseSharedDestination,
resolutions,
pendingDestinationOverrides
)
.catch(error => {
addToast({
message: t($ => $.addDownloads.duplicateResolveFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
})
.finally(() => {
isSubmittingRef.current = false;
setIsSubmitting(false);
});
}}
onCancel={() => setShowingDuplicates(false)}
/>
)}
<div
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center"
onClick={(event) => {
if (event.target === event.currentTarget) closeModalFromDismissAction();
}}
role="dialog"
aria-modal="true"
>
<div className="app-modal add-download-modal flex flex-col overflow-hidden text-sm">
{/* Main Content Split */}
<div className="flex flex-1 min-h-0 overflow-hidden">
{/* Left Column: URLs and Preview */}
<div className="add-download-left w-[55%] flex flex-col">
<div className="add-download-pane p-5 flex-1 min-h-0 min-w-0 flex flex-col gap-5">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="add-download-section-title flex items-center gap-2">
<Link size={16} className="text-blue-500" />
{t($ => $.addDownloads.downloadLinks)}
</div>
</div>
<textarea
className={`add-download-control add-download-links-input w-full h-32 p-3 text-[13px] resize-none ${
isRtl ? 'add-download-links-input--rtl' : ''
}`}
placeholder={t($ => $.addDownloads.pastePlaceholder)}
value={urls}
onChange={(e) => setUrls(e.target.value)}
/>
{playlistSummaries.map(([sourceUrl, playlist]) => {
const total = playlist.entry_count || playlist.entries.length;
return (
<p key={sourceUrl} className="px-1 text-[11px] text-purple-500 dark:text-purple-400">
{t($ => $.addDownloads.playlistSummary, {
title: playlist.title,
loaded: playlist.entries.length,
total: total > playlist.entries.length ? ` of ${total}` : '',
truncated: playlist.truncated ? t($ => $.addDownloads.safeEntryLimit) : '',
skipped: playlist.skipped_entries > 0 ? `; ${playlist.skipped_entries} skipped, unavailable, duplicated, or outside the safe limit` : '',
})}.
</p>
);
})}
<div className="flex justify-between items-center px-1">
<span className="text-[11px] text-text-muted font-medium">
{t($ => $.addDownloads.selectedSummary, {
ready: selectedItems.filter(item => item.status === 'ready').length,
fallback: fallbackMetadataCount,
mediaRetry: failedMediaMetadataCount,
blocked: blockedMetadataCount,
})}
</span>
<button
type="button"
onClick={() => setParsedItems(refreshFailedMetadataRows)}
disabled={failedMetadataCount === 0}
className="add-download-link-button flex items-center gap-1.5 text-[11px] font-medium"
>
<RefreshCw size={12} /> {t($ => $.addDownloads.refreshMetadata)}
</button>
<button
type="button"
onClick={toggleAllRows}
disabled={parsedItems.length === 0}
className="add-download-link-button ms-3 text-[11px] font-medium"
>
{allRowsSelected ? t($ => $.addDownloads.clearSelection) : t($ => $.addDownloads.selectAll)}
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-3">
<SummaryBox title={t($ => $.addDownloads.files)} value={selectedItems.length === parsedItems.length ? parsedItems.length : `${selectedItems.length}/${parsedItems.length}`} icon={FileText} color="text-blue-500" />
<SummaryBox title={t($ => $.addDownloads.required)} value={requiredStr === 'Unknown' ? t($ => $.addDownloads.unknown) : requiredStr} icon={Database} color="text-orange-500" />
<SummaryBox title={t($ => $.addDownloads.free)} value={freeSpace === 'Unknown' ? t($ => $.addDownloads.unknown) : freeSpace} icon={HardDrive} color="text-green-500" />
<SummaryBox title={t($ => $.addDownloads.unknown)} value={selectedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
</div>
<div className="flex flex-col gap-2 flex-1 min-h-0 min-w-0 overflow-hidden">
<div className="add-download-section-title flex items-center gap-2">
<ArrowRight size={16} className="text-blue-500" />
{t($ => $.addDownloads.preview)}
</div>
<div className="add-download-preview flex-1 min-h-0 min-w-0 overflow-hidden flex flex-col">
<div className="add-download-preview-header px-3 py-2 flex text-[11px] font-semibold text-text-muted uppercase tracking-wider">
<div className="flex-[2]">{t($ => $.addDownloads.file)}</div>
<div className="flex-1">{t($ => $.addDownloads.size)}</div>
<div className="flex-[1.5]">{t($ => $.addDownloads.status)}</div>
</div>
<div className="flex-1 min-h-0 min-w-0 overflow-y-auto p-2 space-y-1" role="listbox" aria-label={t($ => $.addDownloads.downloadPreview)}>
{parsedItems.length === 0 ? (
<div className="add-download-empty h-full flex items-center justify-center text-text-muted text-xs">
{t($ => $.addDownloads.noLinks)}
</div>
) : (
parsedItems.map((item, i) => (
<div
key={item.id}
onClick={() => setSelectedItemIndex(i)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setSelectedItemIndex(i);
}
}}
role="option"
aria-selected={selectedItemIndex === i}
tabIndex={0}
className={`add-download-preview-row flex flex-col text-xs px-2 py-2 cursor-pointer rounded-md group ${
selectedItemIndex === i
? 'is-selected border'
: 'border border-transparent'
}`}
>
<div className="flex items-center w-full">
<input
type="checkbox"
checked={item.selected !== false}
onChange={() => toggleRowSelection(i)}
onClick={event => event.stopPropagation()}
aria-label={t($ => $.addDownloads.selectItem, { file: item.file })}
className="me-2 shrink-0 accent-purple-500"
/>
<div className="flex-[2] text-text-primary font-medium truncate pe-2" title={item.file}>{item.file}</div>
<div className={`flex-1 font-mono ${item.status === 'loading' ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || t($ => $.addDownloads.unknown)}</div>
<div className={`flex-[1.5] font-medium ${item.status === 'metadata-error' || item.status === 'invalid' ? 'text-red-500' : item.status === 'loading' ? 'text-orange-400' : 'text-blue-500'}`}>
{item.status === 'loading' ? (
<div className="flex items-center gap-1.5">
<RefreshCw size={12} className="animate-spin" /> {item.isPlaylist ? t($ => $.addDownloads.fetchingPlaylist) : t($ => $.addDownloads.fetching)}
</div>
) : (
item.status === 'metadata-error'
? item.metadataBlockedReason === 'unsafe-url' ? t($ => $.addDownloads.unsafeUrl) : item.isPlaylist ? t($ => $.addDownloads.playlistFailed) : item.isMedia ? t($ => $.addDownloads.metadataFailed) : t($ => $.addDownloads.fallback)
: item.status === 'invalid'
? t($ => $.addDownloads.invalid)
: t($ => $.addDownloads.ready)
)}
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</div>
{/* Right Column: Settings */}
<div className="add-download-settings w-[45%] flex flex-col overflow-y-auto">
<div className="p-6 space-y-5">
{/* Media Format (Dynamic) */}
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
<section className="add-download-section add-download-media-section relative overflow-hidden p-4">
<div className="absolute top-0 end-0 p-2 opacity-10">
<Video size={48} />
</div>
<div className="add-download-section-title flex items-center gap-2 mb-3 relative z-10">
<Video size={16} className="text-purple-500" /> {t($ => $.addDownloads.mediaFormat)}
{parsedItems[selectedItemIndex].playlistSourceUrl && (
<span className="text-[10px] font-normal text-text-muted">
{t($ => $.addDownloads.playlistItem, { index: parsedItems[selectedItemIndex].playlistIndex || '?' })}
</span>
)}
</div>
{parsedItems[selectedItemIndex].status === 'loading' ? (
<div className="flex flex-col items-center justify-center py-6 gap-3 relative z-10">
<RefreshCw size={24} className="animate-spin text-purple-500" />
<span className="text-xs text-text-muted font-medium animate-pulse">{t($ => $.addDownloads.fetchingMediaStreams)}</span>
</div>
) : parsedItems[selectedItemIndex].formats ? (
<div className="space-y-3 relative z-10">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] uppercase font-bold tracking-wider text-text-muted">{t($ => $.addDownloads.availableStreams)}</label>
<div className="flex flex-col gap-1 max-h-48 overflow-y-auto pe-1" role="radiogroup" aria-label={t($ => $.addDownloads.availableMediaStreams)}>
{parsedItems[selectedItemIndex].formats!.map((f, idx) => {
const isSelected = parsedItems[selectedItemIndex].selectedFormat === idx;
const Icon = f.type === 'Audio' ? Music : Film;
return (
<div
key={idx}
onClick={() => selectMediaFormat(idx)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
selectMediaFormat(idx);
}
}}
role="radio"
aria-checked={isSelected}
tabIndex={0}
className={`add-download-format-row flex items-center justify-between px-3 py-2 cursor-pointer text-xs border ${
isSelected ? 'is-selected text-purple-600 dark:text-purple-400 font-semibold' : 'text-text-secondary'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<Icon size={14} className={isSelected ? 'text-purple-500' : 'text-text-muted'} />
<div className="flex flex-col min-w-0">
<span className="truncate">{f.name}</span>
<span className="text-[10px] font-normal text-text-muted truncate">{f.formatLabel}</span>
</div>
</div>
<span className="font-mono text-[11px] opacity-80 shrink-0">{f.detail}</span>
</div>
);
})}
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-4 relative z-10">
<span className="text-xs text-red-400 font-medium">{t($ => $.addDownloads.metadataUnavailable)}</span>
</div>
)}
</section>
)}
{/* Save Location */}
<section className="add-download-section">
<div className="add-download-section-title flex items-center gap-2 mb-3">
<FolderPlus size={16} className="text-blue-500" /> {t($ => $.addDownloads.saveLocation)}
</div>
<div className="flex gap-2">
<input
type="text"
readOnly
value={saveLocation}
className="add-download-control flex-1 px-3 py-1.5 text-xs text-text-muted font-mono"
aria-label={t($ => $.addDownloads.saveLocation)}
/>
<button
type="button"
onClick={handleBrowse}
disabled={isSubmitting}
className="add-download-button add-download-button-secondary px-3 text-xs font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{t($ => $.addDownloads.browse)}
</button>
</div>
{parsedItems.length > 1 && (
<div className="mt-3">
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
<input
type="checkbox"
checked={saveInDedicatedFolder}
onChange={event => {
const enabled = event.target.checked;
if (enabled && !sanitizeBatchFolderName(dedicatedFolderName)) {
dedicatedFolderNameEditedRef.current = false;
setDedicatedFolderName(deriveBatchFolderName(
pendingAddBatchName,
pendingAddReferer,
new Date(),
parsedItems.map(item => item.file)
));
}
setSaveInDedicatedFolder(enabled);
}}
className="add-download-checkbox"
/>
{t($ => $.addDownloads.dedicatedFolder)}
</label>
{saveInDedicatedFolder && (
<>
<div className="flex gap-2 mt-2">
<input
type="text"
value={dedicatedFolderName}
onChange={event => {
dedicatedFolderNameEditedRef.current = true;
setDedicatedFolderName(event.target.value);
}}
placeholder={t($ => $.addDownloads.dedicatedFolderName)}
aria-label={t($ => $.addDownloads.dedicatedFolderName)}
className="add-download-control flex-1 px-3 py-1.5 text-xs"
disabled={isSubmitting}
/>
<button
type="button"
onClick={commitDedicatedFolderName}
disabled={isSubmitting}
className="add-download-button add-download-button-secondary px-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={t($ => $.addDownloads.saveFolderName)}
title={t($ => $.addDownloads.saveFolderName)}
>
<Save size={14} />
</button>
</div>
<p className="mt-2 text-[11px] text-text-muted">
{t(isSaveLocationManual
? $ => $.addDownloads.dedicatedFolderManualDescription
: $ => $.addDownloads.dedicatedFolderDescription)}
</p>
</>
)}
</div>
)}
{parsedItems.length > 1 && !isSaveLocationManual && (
<p className="mt-2 text-[11px] text-text-muted">
{t($ => $.addDownloads.categoryFolders)}
</p>
)}
{isSaveLocationManual && !saveInDedicatedFolder && (
<p className="mt-2 text-[11px] text-text-muted">
{t($ => $.addDownloads.sharedFolder)}
</p>
)}
</section>
{/* Transfer Settings */}
<section className="add-download-section">
<div className="add-download-section-title flex items-center gap-2 mb-3">
<Settings size={16} className="text-blue-500" /> {t($ => $.addDownloads.transferSettings)}
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<label className="text-xs text-text-secondary font-medium">{t($ => $.addDownloads.connectionsPerFile)}</label>
<div className="flex items-center gap-2">
<input type="range" min="1" max="16" value={connections} onChange={e=>setConnections(Number(e.target.value))} className="add-download-range w-24 accent-blue-500 cursor-pointer" aria-label={t($ => $.addDownloads.connectionsPerFileAria)} />
<span className="add-download-value text-xs text-text-primary font-mono w-6 text-center">{connections}</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
<input type="checkbox" checked={speedLimitEnabled} onChange={e=>setSpeedLimitEnabled(e.target.checked)} className="add-download-checkbox" />
{t($ => $.addDownloads.limitSpeedPerFile)}
</label>
{speedLimitEnabled && (
<div className="flex items-center gap-1.5">
<input type="number" value={speedLimit} onChange={e=>setSpeedLimit(e.target.value)} className="add-download-control w-16 px-2 py-1 text-xs font-mono" aria-label={t($ => $.addDownloads.speedLimitPerFileAria)} />
<span className="text-[10px] text-text-muted">KiB/s</span>
</div>
)}
</div>
</div>
</section>
{/* Authorization */}
<section className="add-download-section">
<div className="add-download-section-title flex items-center gap-2 mb-3">
<Shield size={16} className="text-blue-500" /> {t($ => $.addDownloads.authorization)}
</div>
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer mb-3">
<input type="checkbox" checked={useAuth} onChange={e=>setUseAuth(e.target.checked)} className="add-download-checkbox" />
{t($ => $.addDownloads.useAuthorization)}
</label>
{useAuth && (
<div className="add-download-nested-fields space-y-2.5">
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} placeholder={t($ => $.addDownloads.username)} className="add-download-control w-full px-3 py-1.5 text-xs" />
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} placeholder={t($ => $.addDownloads.password)} className="add-download-control w-full px-3 py-1.5 text-xs" />
</div>
)}
</section>
{/* Advanced */}
<section className="add-download-section add-download-advanced">
<button
onClick={() => setAdvancedExpanded(!advancedExpanded)}
className="add-download-advanced-toggle flex items-center gap-2 text-sm font-semibold text-text-primary w-full"
aria-expanded={advancedExpanded}
>
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
{t($ => $.addDownloads.advancedTransfer)}
</button>
{advancedExpanded && (
<div className="add-download-advanced-fields mt-4 space-y-4">
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
<input type="checkbox" checked={checksumEnabled} onChange={e=>setChecksumEnabled(e.target.checked)} className="add-download-checkbox" />
{t($ => $.addDownloads.verifyChecksum)}
</label>
{checksumEnabled && (
<div className="flex gap-2">
<select value={checksumAlgo} onChange={e=>setChecksumAlgo(e.target.value)} className="add-download-control add-download-select w-24 px-2 text-xs" aria-label={t($ => $.addDownloads.checksumAlgorithm)}>
<option>MD5</option><option>SHA-1</option><option>SHA-256</option>
</select>
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} placeholder={t($ => $.addDownloads.expectedDigest)} className="add-download-control flex-1 px-3 py-1.5 text-xs font-mono" />
</div>
)}
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">{t($ => $.addDownloads.headers)}</label>
<textarea
value={headers}
onChange={e => {
headersManuallyEditedRef.current = true;
setHeaders(e.target.value);
}}
className="add-download-control w-full h-12 px-3 py-1.5 text-xs font-mono resize-none"
aria-label={t($ => $.addDownloads.requestHeaders)}
/>
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">{t($ => $.addDownloads.cookies)}</label>
<input
type="password"
value={cookies}
onChange={e => {
cookiesManuallyEditedRef.current = true;
setCookies(e.target.value);
}}
placeholder={t($ => $.addDownloads.cookiePlaceholder)}
autoComplete="off"
className="add-download-control w-full px-3 py-1.5 text-xs font-mono"
aria-label={t($ => $.addDownloads.cookies)}
/>
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">{t($ => $.addDownloads.mirrors)}</label>
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} className="add-download-control w-full h-12 px-3 py-1.5 text-xs font-mono resize-none" aria-label={t($ => $.addDownloads.mirrors)} />
</div>
</div>
)}
</section>
</div>
</div>
</div>
{/* Footer */}
<div className="add-download-footer p-4 flex items-center shrink-0">
<div className="text-[11px] text-text-muted font-medium flex-1">
{metadataSummary}
</div>
<div className="flex gap-2.5">
<button onClick={closeModalFromDismissAction} disabled={isSubmitting || showKeychainModal} className="add-download-button add-download-button-secondary px-4 text-xs">
{t($ => $.addDownloads.cancel)}
</button>
<div ref={actionMenuRef} className="relative flex gap-2.5">
<button
onClick={() => handleAction({ type: 'start-now' })}
disabled={!canSubmit || isSubmitting}
className="add-download-button add-download-button-primary px-5 text-xs"
>
<Play size={12} fill="currentColor" /> {t($ => $.addDownloads.startDownloads)}
</button>
<div className="relative">
<button
type="button"
onClick={() => setIsQueueMenuOpen(open => !open)}
disabled={!canSubmit || isSubmitting}
className="add-download-button add-download-button-secondary px-4 text-xs"
aria-label={t($ => $.addDownloads.addToQueue)}
aria-haspopup="menu"
aria-expanded={isQueueMenuOpen}
>
{t($ => $.addDownloads.addToQueue)} <ChevronDown size={14} className="ms-1" />
</button>
{isQueueMenuOpen && (
<div
role="menu"
className="add-download-queue-menu app-modal absolute bottom-full z-[70] mb-2 min-w-[200px] overflow-visible py-1.5 text-xs"
>
{queues.map(queue => (
<button
key={queue.id}
type="button"
role="menuitem"
onClick={() => {
setIsQueueMenuOpen(false);
void handleAction({ type: 'add-to-queue', queueId: queue.id });
}}
className="flex w-full items-center gap-2 px-3 py-2 text-start hover:bg-item-hover"
>
<span className="truncate">{queue.name}</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
};