mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-10 11:37:21 +00:00
feat(torrents): harden Aria2 torrent downloads
This commit is contained in:
@@ -2,4 +2,4 @@
|
||||
import type { DownloadCategory } from "./DownloadCategory";
|
||||
import type { DownloadStatus } from "./DownloadStatus";
|
||||
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, };
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, lifecycle_generation?: string, };
|
||||
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, lifecycle_generation?: string, };
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type TorrentFile = { index: number, path: string, length: number, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { TorrentFile } from "./TorrentFile";
|
||||
|
||||
export type TorrentMetadata = { name: string, totalBytes: number, files: Array<TorrentFile>, infoHash: string, torrentPath?: string, };
|
||||
@@ -192,6 +192,28 @@ export const AddDownloadsModal = () => {
|
||||
const [freeSpace, setFreeSpace] = useState('Unknown');
|
||||
const freeSpaceRequestRef = useRef(0);
|
||||
|
||||
const addTorrentFiles = async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: true,
|
||||
directory: false,
|
||||
title: 'Choose torrent files',
|
||||
filters: [{ name: 'Torrent', extensions: ['torrent'] }]
|
||||
});
|
||||
const paths = Array.isArray(selected)
|
||||
? selected
|
||||
: selected
|
||||
? [selected]
|
||||
: [];
|
||||
if (paths.length === 0) return;
|
||||
setUrls(current => [...current.split('\n').map(line => line.trim()).filter(Boolean), ...paths]
|
||||
.filter((value, index, values) => values.indexOf(value) === index)
|
||||
.join('\n'));
|
||||
} catch (error) {
|
||||
console.error('Failed to select torrent files:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const [useAuth, setUseAuth] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -493,10 +515,41 @@ export const AddDownloadsModal = () => {
|
||||
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 (row.isTorrent) {
|
||||
const torrentData = await invoke('inspect_torrent', {
|
||||
source: row.sourceUrl,
|
||||
id: row.id,
|
||||
cache: false
|
||||
});
|
||||
const totalBytes = torrentData.totalBytes || undefined;
|
||||
setParsedItems(current => updateRowIfCurrent(
|
||||
current,
|
||||
row.id,
|
||||
row.sourceUrl,
|
||||
row.generation,
|
||||
currentRow => ({
|
||||
...currentRow,
|
||||
downloadUrl: !row.sourceUrl.trim().toLowerCase().startsWith('magnet:')
|
||||
? 'torrent:' + torrentData.infoHash
|
||||
: row.sourceUrl,
|
||||
file: canonicalizeDownloadFileName(torrentData.name),
|
||||
size: totalBytes ? formatBytes(totalBytes) : undefined,
|
||||
sizeBytes: totalBytes,
|
||||
status: 'ready',
|
||||
isTorrent: true,
|
||||
torrentPath: torrentData.torrentPath,
|
||||
torrentInfoHash: torrentData.infoHash,
|
||||
torrentFiles: torrentData.files,
|
||||
selectedTorrentFileIndices: currentRow.selectedTorrentFileIndices
|
||||
?.filter(index => torrentData.files.some(file => file.index === index))
|
||||
})
|
||||
));
|
||||
return;
|
||||
}
|
||||
const proxy = await getProxyArgs(settingsStore);
|
||||
if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) {
|
||||
settingsStore.setShowKeychainModal(true);
|
||||
return;
|
||||
@@ -1184,15 +1237,19 @@ export const AddDownloadsModal = () => {
|
||||
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);
|
||||
const incomingMediaFormat = mediaFormatSelectorForRow(item);
|
||||
const mediaFormatChanged = item.isMedia
|
||||
&& existingItem.mediaFormatSelector !== incomingMediaFormat;
|
||||
const torrentReplacement = Boolean(item.isTorrent) || Boolean(existingItem.isTorrent);
|
||||
if (existingItem.status === 'completed' || mediaFormatChanged || torrentReplacement) {
|
||||
// Completed replacements must remove the old file so the
|
||||
// new transfer cannot be treated as an already-complete
|
||||
// aria2 target. A torrent replacement also needs a fresh
|
||||
// identity because its cached metadata is keyed by the
|
||||
// new row ID and its output contract differs from a normal
|
||||
// file transfer. Unfinished ordinary 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, {
|
||||
@@ -1225,6 +1282,19 @@ export const AddDownloadsModal = () => {
|
||||
if (!item) continue;
|
||||
try {
|
||||
const id = crypto.randomUUID();
|
||||
let torrentPath = item.torrentPath;
|
||||
if (item.isTorrent && !item.sourceUrl.trim().toLowerCase().startsWith('magnet:')) {
|
||||
// Cached torrent metadata is deliberately keyed by the download
|
||||
// identity. The metadata row ID is temporary, so re-key the
|
||||
// cache after the final download ID is allocated (including
|
||||
// replacement flows).
|
||||
const torrentData = await invoke('inspect_torrent', {
|
||||
source: item.sourceUrl,
|
||||
id,
|
||||
cache: true
|
||||
});
|
||||
torrentPath = torrentData.torrentPath;
|
||||
}
|
||||
let finalFile = item.isMedia
|
||||
? mediaFileNameForSelectedFormat(item.file, item)
|
||||
: canonicalizeDownloadFileName(item.file);
|
||||
@@ -1260,6 +1330,10 @@ export const AddDownloadsModal = () => {
|
||||
resumable: item.resumable,
|
||||
mediaFormatSelector: formatSelector,
|
||||
mediaQuality: mediaQualityForRow(item),
|
||||
isTorrent: item.isTorrent,
|
||||
torrentPath,
|
||||
torrentInfoHash: item.torrentInfoHash,
|
||||
torrentFileIndices: item.selectedTorrentFileIndices,
|
||||
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
|
||||
sizeBytes: item.sizeBytes
|
||||
}, action);
|
||||
@@ -1369,6 +1443,23 @@ export const AddDownloadsModal = () => {
|
||||
));
|
||||
};
|
||||
|
||||
const toggleTorrentFile = (index: number) => {
|
||||
if (selectedItemIndex === null) return;
|
||||
setParsedItems(items => items.map((item, itemIndex) => {
|
||||
if (itemIndex !== selectedItemIndex || !item.torrentFiles?.length) return item;
|
||||
const allIndices = item.torrentFiles.map(file => file.index);
|
||||
const selectedIndices = item.selectedTorrentFileIndices ?? allIndices;
|
||||
if (selectedIndices.length === 1 && selectedIndices[0] === index) return item;
|
||||
const next = selectedIndices.includes(index)
|
||||
? selectedIndices.filter(value => value !== index)
|
||||
: [...selectedIndices, index].sort((left, right) => left - right);
|
||||
return {
|
||||
...item,
|
||||
selectedTorrentFileIndices: next.length === allIndices.length ? undefined : next
|
||||
};
|
||||
}));
|
||||
};
|
||||
|
||||
const selectedItems = parsedItems.filter(item => item.selected !== false);
|
||||
const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex];
|
||||
const selectedPlaylistSourceUrl = selectedItem?.playlistSourceUrl;
|
||||
@@ -1635,6 +1726,15 @@ export const AddDownloadsModal = () => {
|
||||
value={urls}
|
||||
onChange={(e) => setUrls(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void addTorrentFiles()}
|
||||
className="add-download-link-button flex items-center gap-1.5 text-[11px] font-medium"
|
||||
>
|
||||
<FolderPlus size={12} /> {t($ => $.addDownloads.chooseTorrentFiles)}
|
||||
</button>
|
||||
</div>
|
||||
{playlistSummaries.map(([sourceUrl, playlist]) => {
|
||||
const total = playlist.entry_count || playlist.entries.length;
|
||||
return (
|
||||
@@ -1766,6 +1866,46 @@ export const AddDownloadsModal = () => {
|
||||
<div className="add-download-settings w-[45%] flex flex-col overflow-y-auto">
|
||||
<div className="p-6 space-y-5">
|
||||
|
||||
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && (
|
||||
<section className="add-download-section relative overflow-hidden p-4">
|
||||
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
||||
<FileText size={16} className="text-blue-500" /> {t($ => $.addDownloads.torrentFiles)}
|
||||
</div>
|
||||
{parsedItems[selectedItemIndex].torrentFiles?.length ? (
|
||||
<div
|
||||
className="flex flex-col gap-1 max-h-64 overflow-y-auto pe-1"
|
||||
role="group"
|
||||
aria-label={t($ => $.addDownloads.torrentFiles)}
|
||||
>
|
||||
{parsedItems[selectedItemIndex].torrentFiles!.map(file => {
|
||||
const selectedIndices = parsedItems[selectedItemIndex!].selectedTorrentFileIndices;
|
||||
const checked = !selectedIndices || selectedIndices.includes(file.index);
|
||||
return (
|
||||
<label
|
||||
key={file.index}
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-xs text-text-secondary hover:bg-surface-hover rounded"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleTorrentFile(file.index)}
|
||||
aria-label={file.path}
|
||||
className="accent-blue-500"
|
||||
/>
|
||||
<span className="truncate flex-1" title={file.path}>{file.path}</span>
|
||||
<span className="font-mono text-text-muted shrink-0">{formatBytes(file.length)}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-text-muted">
|
||||
{t($ => $.addDownloads.torrentMetadataPending)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Media Format (Dynamic) */}
|
||||
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
|
||||
<section className="add-download-section add-download-media-section relative overflow-hidden p-4">
|
||||
|
||||
@@ -241,6 +241,11 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
{mediaQualityLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{download.isTorrent ? (
|
||||
<span className="download-quality-chip shrink-0" title={t($ => $.addDownloads.torrentFiles)}>
|
||||
{t($ => $.addDownloads.torrentFiles)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -1365,6 +1365,14 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
}, []);
|
||||
|
||||
const getDownloadPath = useCallback(async (item: DownloadItem) => {
|
||||
if (item.isTorrent) {
|
||||
try {
|
||||
const ownedPath = await invoke('get_download_primary_path', { id: item.id });
|
||||
if (ownedPath) return ownedPath;
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve torrent output path:", error);
|
||||
}
|
||||
}
|
||||
const fileName = item.fileName?.trim();
|
||||
if (!fileName) return null;
|
||||
const settings = useSettingsStore.getState();
|
||||
@@ -1377,12 +1385,33 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
useDownloadStore.getState().setSelectedPropertiesDownloadId(id);
|
||||
}, []);
|
||||
|
||||
const revealDownloadFile = useCallback(async (item: DownloadItem) => {
|
||||
const pathToReveal = await getDownloadPath(item);
|
||||
|
||||
if (!pathToReveal) {
|
||||
openProperties(item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('reveal_in_file_manager', { path: pathToReveal });
|
||||
} catch (error) {
|
||||
console.error("Failed to show in Finder:", error);
|
||||
showInteractionError(t($ => $.downloadTable.revealFileFailed), error);
|
||||
}
|
||||
}, [getDownloadPath, openProperties, showInteractionError]);
|
||||
|
||||
const openDownloadFile = useCallback(async (item: DownloadItem) => {
|
||||
if (item.status !== 'completed') {
|
||||
openProperties(item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.isTorrent) {
|
||||
await revealDownloadFile(item);
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = await getDownloadPath(item);
|
||||
if (!fullPath) {
|
||||
openProperties(item.id);
|
||||
@@ -1395,23 +1424,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
console.error("Failed to open file:", error);
|
||||
showInteractionError(t($ => $.downloadTable.openFileFailed), error);
|
||||
}
|
||||
}, [getDownloadPath, openProperties, showInteractionError]);
|
||||
|
||||
const revealDownloadFile = async (item: DownloadItem) => {
|
||||
const pathToReveal = await getDownloadPath(item);
|
||||
|
||||
if (!pathToReveal) {
|
||||
openProperties(item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('reveal_in_file_manager', { path: pathToReveal });
|
||||
} catch (error) {
|
||||
console.error("Failed to show in Finder:", error);
|
||||
showInteractionError(t($ => $.downloadTable.revealFileFailed), error);
|
||||
}
|
||||
};
|
||||
}, [getDownloadPath, openProperties, revealDownloadFile, showInteractionError]);
|
||||
|
||||
const handleDownloadDoubleClick = useCallback((item: DownloadItem) => {
|
||||
if (item.status === 'completed') {
|
||||
|
||||
@@ -446,7 +446,7 @@ const common = {
|
||||
pauseBeforeReplace: 'Pause {{file}} before replacing it.',
|
||||
cannotReplace: 'Cannot replace {{file}}: file is not owned by a Firelink download.',
|
||||
downloadLinks: 'Download Links',
|
||||
pastePlaceholder: 'Paste HTTP, HTTPS, FTP, or SFTP URLs here...\n\nFor media downloads, paste links from YouTube, X, TikTok, Instagram, Reddit, etc.',
|
||||
pastePlaceholder: 'Paste HTTP, HTTPS, FTP, SFTP, or magnet URLs here... You can also choose .torrent files below.\n\nFor media downloads, paste links from YouTube, X, TikTok, Instagram, Reddit, etc.',
|
||||
playlistSummary: 'Playlist “{{title}}”: {{loaded}}{{total}} entries loaded{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (safe entry limit reached)',
|
||||
selectedSummary: '{{ready}} selected ready, {{fallback}} fallback, {{mediaRetry}} media retry, {{blocked}} blocked',
|
||||
@@ -454,6 +454,9 @@ const common = {
|
||||
selectAll: 'Select all',
|
||||
refreshMetadata: 'Refresh Metadata',
|
||||
files: 'Files',
|
||||
torrentFiles: 'Torrent files',
|
||||
chooseTorrentFiles: 'Add .torrent files',
|
||||
torrentMetadataPending: 'Aria2 will resolve the magnet metadata when the transfer starts.',
|
||||
required: 'Required',
|
||||
free: 'Free',
|
||||
preview: 'Preview',
|
||||
|
||||
@@ -446,7 +446,7 @@ const fa = {
|
||||
pauseBeforeReplace: 'قبل از جایگزینی {{file}}، آن را متوقف کنید.',
|
||||
cannotReplace: 'نمیتوان {{file}} را جایگزین کرد: فایل متعلق به یک دانلود Firelink نیست.',
|
||||
downloadLinks: 'پیوندهای دانلود',
|
||||
pastePlaceholder: '\u2066URL\u2069های \u2066HTTP\u2069، \u2066HTTPS\u2069، \u2066FTP\u2069 یا \u2066SFTP\u2069 را در اینجا جایگذاری کنید…\n\nبرای دانلود رسانه، پیوندهایی از \u2066YouTube\u2069، \u2066X\u2069، \u2066TikTok\u2069، \u2066Instagram\u2069، \u2066Reddit\u2069 و غیره جایگذاری کنید.',
|
||||
pastePlaceholder: 'URLهای HTTP، HTTPS، FTP، SFTP یا magnet را اینجا جایگذاری کنید… همچنین میتوانید فایلهای .torrent را از پایین انتخاب کنید.\n\nبرای دانلود رسانه، پیوندهایی از \u2066YouTube\u2069، \u2066X\u2069، \u2066TikTok\u2069، \u2066Instagram\u2069، \u2066Reddit\u2069 و غیره جایگذاری کنید.',
|
||||
playlistSummary: 'لیست پخش "{{title}}": {{loaded}} از {{total}} ورودی بارگیری شد{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (به حد مجاز ایمن ورودیها رسیدیم)',
|
||||
selectedSummary: '{{ready}} انتخابشده آماده، {{fallback}} اطلاعات جایگزین، {{mediaRetry}} تلاش مجدد رسانه، {{blocked}} مسدودشده',
|
||||
@@ -454,6 +454,9 @@ const fa = {
|
||||
selectAll: 'انتخاب همه',
|
||||
refreshMetadata: 'تازهسازی متادیتا',
|
||||
files: 'فایلها',
|
||||
torrentFiles: 'فایلهای تورنت',
|
||||
chooseTorrentFiles: 'افزودن فایلهای .torrent',
|
||||
torrentMetadataPending: 'آریا۲ هنگام شروع انتقال، متادیتای مگنت را دریافت میکند.',
|
||||
required: 'الزامی',
|
||||
free: 'فضای آزاد',
|
||||
preview: 'پیشنمایش',
|
||||
|
||||
@@ -446,7 +446,7 @@ const he = {
|
||||
pauseBeforeReplace: 'השהה את {{file}} לפני החלפתו.',
|
||||
cannotReplace: 'לא ניתן להחליף את {{file}}: הקובץ אינו שייך להורדת Firelink.',
|
||||
downloadLinks: 'קישורי הורדה',
|
||||
pastePlaceholder: 'הדבק כתובות \u2066HTTP\u2069, \u2066HTTPS\u2069, \u2066FTP\u2069 או \u2066SFTP\u2069 כאן…\n\nעבור הורדות מדיה, הדבק קישורים מ-\u2066YouTube\u2069, \u2066X\u2069, \u2066TikTok\u2069, \u2066Instagram\u2069, \u2066Reddit\u2069 וכו\'.',
|
||||
pastePlaceholder: 'הדבק כאן כתובות \u2066HTTP\u2069, \u2066HTTPS\u2069, \u2066FTP\u2069, \u2066SFTP\u2069 או magnet… אפשר גם לבחור קובצי .torrent למטה.\n\nעבור הורדות מדיה, הדבק קישורים מ-\u2066YouTube\u2069, \u2066X\u2069, \u2066TikTok\u2069, \u2066Instagram\u2069, \u2066Reddit\u2069 וכו\'.',
|
||||
playlistSummary: 'רשימת השמעה "{{title}}": {{loaded}} מתוך {{total}} פריטים נטענו{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (הושגה מגבלת הפריטים הבטוחה)',
|
||||
selectedSummary: '{{ready}} נבחרו ומוכנים, {{fallback}} לגיבוי, {{mediaRetry}} מדיה לניסיון חוזר, {{blocked}} חסומים',
|
||||
@@ -454,6 +454,9 @@ const he = {
|
||||
selectAll: 'בחירת הכל',
|
||||
refreshMetadata: 'רענון מטא נתונים',
|
||||
files: 'קבצים',
|
||||
torrentFiles: 'קובצי טורנט',
|
||||
chooseTorrentFiles: 'הוספת קובצי .torrent',
|
||||
torrentMetadataPending: 'Aria2 יאתר את נתוני המגנט כשההעברה תתחיל.',
|
||||
required: 'נדרש',
|
||||
free: 'פנוי',
|
||||
preview: 'תצוגה מקדימה',
|
||||
|
||||
@@ -446,7 +446,7 @@ const ru = {
|
||||
pauseBeforeReplace: 'Приостановите {{file}} перед заменой.',
|
||||
cannotReplace: 'Невозможно заменить {{file}}: файл не принадлежит загрузке Firelink.',
|
||||
downloadLinks: 'Ссылки для скачивания',
|
||||
pastePlaceholder: 'Вставьте сюда URL-адреса HTTP, HTTPS, FTP или SFTP…\n\nДля загрузки медиа вставляйте ссылки с YouTube, X, TikTok, Instagram, Reddit и т. д.',
|
||||
pastePlaceholder: 'Вставьте сюда URL-адреса HTTP, HTTPS, FTP, SFTP или magnet… Также можно выбрать файлы .torrent ниже.\n\nДля загрузки медиа вставляйте ссылки с YouTube, X, TikTok, Instagram, Reddit и т. д.',
|
||||
playlistSummary: 'Плейлист «{{title}}»: загружено {{loaded}} из {{total}} элементов{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (достигнут безопасный лимит элементов)',
|
||||
selectedSummary: 'Выбрано: {{ready}} готовых, {{fallback}} с резервными данными, {{mediaRetry}} повторов медиа, {{blocked}} заблокировано',
|
||||
@@ -454,6 +454,9 @@ const ru = {
|
||||
selectAll: 'Выбрать все',
|
||||
refreshMetadata: 'Обновить метаданные',
|
||||
files: 'Файлы',
|
||||
torrentFiles: 'Торрент-файлы',
|
||||
chooseTorrentFiles: 'Добавить файлы .torrent',
|
||||
torrentMetadataPending: 'Aria2 получит метаданные магнита при запуске передачи.',
|
||||
required: 'Требуется',
|
||||
free: 'Свободно',
|
||||
preview: 'Предпросмотр',
|
||||
|
||||
@@ -446,7 +446,7 @@ const uk = {
|
||||
pauseBeforeReplace: 'Призупиніть {{file}} перед заміною.',
|
||||
cannotReplace: 'Неможливо замінити {{file}}: файл не належить до завантажень Firelink.',
|
||||
downloadLinks: 'Посилання для завантаження',
|
||||
pastePlaceholder: 'Вставте URL-адреси HTTP, HTTPS, FTP або SFTP сюди…\n\nДля медіазавантажень вставте посилання з YouTube, X, TikTok, Instagram, Reddit тощо.',
|
||||
pastePlaceholder: 'Вставте сюди URL-адреси HTTP, HTTPS, FTP, SFTP або magnet… Також можна вибрати файли .torrent нижче.\n\nДля медіазавантажень вставте посилання з YouTube, X, TikTok, Instagram, Reddit тощо.',
|
||||
playlistSummary: 'Плейлист “{{title}}”: {{loaded}} з {{total}} елементів завантажено{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (досягнуто безпечного ліміту елементів)',
|
||||
selectedSummary: '{{ready}} вибрано готових, {{fallback}} резервних, {{mediaRetry}} повторних медіа, {{blocked}} заблоковано',
|
||||
@@ -454,6 +454,9 @@ const uk = {
|
||||
selectAll: 'Вибрати всі',
|
||||
refreshMetadata: 'Оновити метадані',
|
||||
files: 'Файли',
|
||||
torrentFiles: 'Торрент-файли',
|
||||
chooseTorrentFiles: 'Додати файли .torrent',
|
||||
torrentMetadataPending: 'Aria2 отримає метадані магнітного посилання після початку передачі.',
|
||||
required: 'Обов\'язково',
|
||||
free: 'Вільно',
|
||||
preview: 'Попередній перегляд',
|
||||
|
||||
@@ -446,7 +446,7 @@ const zhCN = {
|
||||
pauseBeforeReplace: '请在替换 {{file}} 前暂停它。',
|
||||
cannotReplace: '无法替换 {{file}}:文件不属于 Firelink 下载。',
|
||||
downloadLinks: '下载链接',
|
||||
pastePlaceholder: '在此粘贴 HTTP、HTTPS、FTP 或 SFTP URL…\n\n对于媒体下载,请粘贴来自 YouTube、X、TikTok、Instagram、Reddit 等的链接。',
|
||||
pastePlaceholder: '在此粘贴 HTTP、HTTPS、FTP、SFTP 或 magnet URL…也可以在下方选择 .torrent 文件。\n\n对于媒体下载,请粘贴来自 YouTube、X、TikTok、Instagram、Reddit 等的链接。',
|
||||
playlistSummary: '播放列表“{{title}}”:已加载 {{loaded}} / {{total}} 个条目{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (达到安全条目限制)',
|
||||
selectedSummary: '准备就绪 {{ready}} 个,后备项 {{fallback}} 个,媒体重试 {{mediaRetry}} 个,已屏蔽 {{blocked}} 个',
|
||||
@@ -454,6 +454,9 @@ const zhCN = {
|
||||
selectAll: '全选',
|
||||
refreshMetadata: '刷新元数据',
|
||||
files: '文件',
|
||||
torrentFiles: '种子文件',
|
||||
chooseTorrentFiles: '添加 .torrent 文件',
|
||||
torrentMetadataPending: '传输开始时,Aria2 将解析磁力链接元数据。',
|
||||
required: '必需',
|
||||
free: '可用空间',
|
||||
preview: '预览',
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { EnqueueItem } from './bindings/EnqueueItem';
|
||||
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
|
||||
import type { PlatformInfo } from './bindings/PlatformInfo';
|
||||
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
|
||||
import type { TorrentMetadata } from './bindings/TorrentMetadata';
|
||||
|
||||
type CommandMap = {
|
||||
fetch_metadata: {
|
||||
@@ -32,6 +33,10 @@ type CommandMap = {
|
||||
args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null };
|
||||
result: MediaPlaylistMetadata;
|
||||
};
|
||||
inspect_torrent: {
|
||||
args: { source: string; id: string; cache?: boolean };
|
||||
result: TorrentMetadata;
|
||||
};
|
||||
get_aria2_engine_status: { args: undefined; result: EngineStatusItem };
|
||||
get_ytdlp_engine_status: { args: undefined; result: EngineStatusItem };
|
||||
get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem };
|
||||
@@ -41,6 +46,7 @@ type CommandMap = {
|
||||
pause_download: { args: { id: string }; result: void };
|
||||
resume_download: { args: { id: string; queueId: string }; result: boolean };
|
||||
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
|
||||
get_download_primary_path: { args: { id: string }; result: string | null };
|
||||
detach_download_for_reconfigure: { args: { id: string }; result: void };
|
||||
begin_dock_badge_session: { args: undefined; result: number };
|
||||
update_dock_badge: { args: { count: number; generation: number; session: number }; result: void };
|
||||
|
||||
@@ -341,6 +341,10 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
||||
format_selector: item.mediaFormatSelector || null,
|
||||
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
|
||||
is_media: item.isMedia || false,
|
||||
is_torrent: item.isTorrent || false,
|
||||
torrent_path: item.torrentPath || undefined,
|
||||
torrent_file_indices: item.torrentFileIndices || undefined,
|
||||
torrent_info_hash: item.torrentInfoHash || undefined,
|
||||
lifecycle_generation: lifecycleGeneration.toString(),
|
||||
};
|
||||
|
||||
@@ -2040,6 +2044,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
format_selector: item.mediaFormatSelector || null,
|
||||
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
|
||||
is_media: item.isMedia || false,
|
||||
is_torrent: item.isTorrent || false,
|
||||
torrent_path: item.torrentPath || undefined,
|
||||
torrent_file_indices: item.torrentFileIndices || undefined,
|
||||
torrent_info_hash: item.torrentInfoHash || undefined,
|
||||
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,6 +84,26 @@ describe('add download metadata workflow', () => {
|
||||
expect(isYouTubePlaylistUrl('https://example.com/playlist?list=PL123')).toBe(false);
|
||||
});
|
||||
|
||||
it('admits magnets and local torrent files through the Add window metadata path', () => {
|
||||
const rows = reconcileDownloadRows(
|
||||
'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example\nfile:///tmp/Example.torrent',
|
||||
[]
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({
|
||||
isTorrent: true,
|
||||
isMedia: false,
|
||||
status: 'loading'
|
||||
});
|
||||
expect(rows[1]).toMatchObject({
|
||||
isTorrent: true,
|
||||
isMedia: false,
|
||||
sourceUrl: 'file:///tmp/Example.torrent',
|
||||
status: 'loading'
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a playlist as one loading row until discovery succeeds', () => {
|
||||
const rows = reconcileDownloadRows(
|
||||
'https://www.youtube.com/playlist?list=PL123',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isMediaUrl
|
||||
} from './downloads';
|
||||
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
||||
import type { TorrentFile } from '../bindings/TorrentFile';
|
||||
import i18n from '../i18n';
|
||||
import { localePluralVariant } from '../i18n/locales';
|
||||
|
||||
@@ -52,6 +53,11 @@ export interface AddDownloadDraftRow {
|
||||
playlistError?: string;
|
||||
metadataBlockedReason?: 'unsafe-url';
|
||||
selected?: boolean;
|
||||
isTorrent?: boolean;
|
||||
torrentPath?: string;
|
||||
torrentInfoHash?: string;
|
||||
torrentFiles?: TorrentFile[];
|
||||
selectedTorrentFileIndices?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,12 +67,27 @@ export interface AddDownloadDraftRow {
|
||||
*/
|
||||
export const durableDownloadUrl = (sourceUrl: string): string => sourceUrl.trim();
|
||||
|
||||
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:']);
|
||||
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:', 'magnet:']);
|
||||
|
||||
const isLocalTorrentPath = (value: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol === 'file:') {
|
||||
return parsed.pathname.toLowerCase().endsWith('.torrent');
|
||||
}
|
||||
} catch {
|
||||
// A native Windows path is not a URL, even though URL parsing may treat
|
||||
// its drive letter as a scheme.
|
||||
}
|
||||
return value.toLowerCase().endsWith('.torrent')
|
||||
&& (value.startsWith('/') || /^[a-z]:[\\/]/i.test(value));
|
||||
};
|
||||
|
||||
type ParsedInput = {
|
||||
identity: string;
|
||||
sourceUrl: string;
|
||||
valid: boolean;
|
||||
isTorrent?: boolean;
|
||||
isPlaylist?: boolean;
|
||||
playlistSourceUrl?: string;
|
||||
playlistTitle?: string;
|
||||
@@ -115,10 +136,18 @@ const parseInputLines = (
|
||||
|
||||
let sourceUrl = line;
|
||||
let valid = false;
|
||||
let isTorrent = false;
|
||||
if (isLocalTorrentPath(line)) {
|
||||
valid = true;
|
||||
isTorrent = true;
|
||||
}
|
||||
try {
|
||||
const url = new URL(line);
|
||||
valid = ALLOWED_SCHEMES.has(url.protocol);
|
||||
if (valid) sourceUrl = url.href;
|
||||
if (!isTorrent) {
|
||||
const url = new URL(line);
|
||||
valid = ALLOWED_SCHEMES.has(url.protocol);
|
||||
isTorrent = valid && url.protocol === 'magnet:';
|
||||
if (valid) sourceUrl = url.href;
|
||||
}
|
||||
} catch {
|
||||
valid = false;
|
||||
}
|
||||
@@ -166,6 +195,7 @@ const parseInputLines = (
|
||||
identity,
|
||||
sourceUrl,
|
||||
valid,
|
||||
isTorrent,
|
||||
isPlaylist: valid && isYouTubePlaylistUrl(sourceUrl),
|
||||
requestContextVersion: valid ? requestContextVersions[sourceUrl] : undefined,
|
||||
selected: selectedBySourceUrl[sourceUrl] !== false
|
||||
@@ -219,6 +249,7 @@ export const reconcileDownloadRows = (
|
||||
generation: preserved.generation + 1,
|
||||
requestContextVersion,
|
||||
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
|
||||
isTorrent: input.isTorrent,
|
||||
size: undefined,
|
||||
sizeBytes: undefined,
|
||||
resumable: undefined,
|
||||
@@ -235,7 +266,11 @@ export const reconcileDownloadRows = (
|
||||
playlistCount: input.playlistCount,
|
||||
playlistEntryTitle: input.playlistEntryTitle,
|
||||
playlistError: undefined,
|
||||
metadataBlockedReason: undefined
|
||||
metadataBlockedReason: undefined,
|
||||
torrentPath: input.isTorrent ? preserved.torrentPath : undefined,
|
||||
torrentInfoHash: input.isTorrent ? preserved.torrentInfoHash : undefined,
|
||||
torrentFiles: input.isTorrent ? preserved.torrentFiles : undefined,
|
||||
selectedTorrentFileIndices: input.isTorrent ? preserved.selectedTorrentFileIndices : undefined
|
||||
};
|
||||
}
|
||||
return preserved;
|
||||
@@ -263,6 +298,7 @@ export const reconcileDownloadRows = (
|
||||
|| forceMediaUrls.has(input.sourceUrl)
|
||||
|| isMediaUrl(input.sourceUrl)
|
||||
),
|
||||
isTorrent: input.valid && Boolean(input.isTorrent),
|
||||
isPlaylist: input.isPlaylist,
|
||||
playlistSourceUrl: input.playlistSourceUrl,
|
||||
playlistTitle: input.playlistTitle,
|
||||
|
||||
@@ -68,6 +68,7 @@ describe('download connection resolution', () => {
|
||||
describe('download filename matching', () => {
|
||||
it('matches frontend filenames to the backend Windows device-name canonicalization', () => {
|
||||
expect(canonicalizeDownloadFileName('CON.txt')).toBe('CON-.txt');
|
||||
expect(canonicalizeDownloadFileName('CON .txt')).toBe('CON -.txt');
|
||||
expect(canonicalizeDownloadFileName('com1.archive.zip')).toBe('com1.archive-.zip');
|
||||
expect(downloadFileNamesMatch('CON-.txt', 'CON.txt')).toBe(true);
|
||||
expect(downloadFileNamesMatch('console.txt', 'CON.txt')).toBe(false);
|
||||
|
||||
@@ -162,7 +162,7 @@ export const canonicalizeDownloadFileName = (fileName: string): string => {
|
||||
.trim()
|
||||
.replace(/[. ]+$/g, '');
|
||||
let canonical = sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
|
||||
const reservedStem = canonical.split('.')[0]?.toUpperCase();
|
||||
const reservedStem = canonical.split('.')[0]?.trimEnd().toUpperCase();
|
||||
if (reservedStem && WINDOWS_RESERVED_FILENAME_STEMS.has(reservedStem)) {
|
||||
const extensionStart = canonical.lastIndexOf('.');
|
||||
const base = extensionStart > 0 ? canonical.slice(0, extensionStart) : canonical;
|
||||
|
||||
Reference in New Issue
Block a user