feat(downloads): add dedicated Torrent category

This commit is contained in:
NimBold
2026-08-10 14:20:50 +03:30
parent 23991c3dea
commit 472da5a681
22 changed files with 150 additions and 25 deletions
+1 -1
View File
@@ -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 DownloadCategory = "Musics" | "Movies" | "Compressed" | "Documents" | "Pictures" | "Applications" | "Other";
export type DownloadCategory = "Musics" | "Movies" | "Compressed" | "Documents" | "Pictures" | "Applications" | "Torrents" | "Other";
+20 -12
View File
@@ -888,7 +888,7 @@ export const AddDownloadsModal = () => {
if (first.status !== 'ready' && first.status !== 'metadata-error') return;
void resolveCategoryDestination(
useSettingsStore.getState(),
categoryForFileName(first.file)
categoryForFileName(first.file, first.isTorrent === true)
).then(location => {
if (requestId === locationResolutionRequestRef.current) {
setSaveLocation(location);
@@ -940,8 +940,8 @@ export const AddDownloadsModal = () => {
}
};
const categoryLocationForFile = (fileName: string) => {
const category = categoryForFileName(fileName);
const categoryLocationForFile = (fileName: string, isTorrent = false) => {
const category = categoryForFileName(fileName, isTorrent);
return resolveCategoryDestination(useSettingsStore.getState(), category);
};
@@ -963,12 +963,13 @@ export const AddDownloadsModal = () => {
fileName: string,
finalLocation: string,
useSharedDestination: boolean,
destinationOverride?: string
destinationOverride?: string,
isTorrent = false
): Promise<string> => {
if (destinationOverride) return destinationOverride;
const root = useSharedDestination
? finalLocation
: await categoryLocationForFile(fileName);
: await categoryLocationForFile(fileName, isTorrent);
return saveInDedicatedFolder
? resolveSubfolderDestination(root, dedicatedFolderName)
: root;
@@ -1113,7 +1114,9 @@ export const AddDownloadsModal = () => {
const suggestedLocation = await destinationForFile(
item.file,
finalLocation,
isSaveLocationManual
isSaveLocationManual,
undefined,
item.isTorrent === true
);
const selected = await open({
directory: true,
@@ -1160,7 +1163,8 @@ export const AddDownloadsModal = () => {
finalFile,
finalLocation,
useSharedDestination,
destinationOverrides[i]
destinationOverrides[i],
item.isTorrent === true
);
const urlMatch = store.downloads.find(d =>
@@ -1342,7 +1346,8 @@ export const AddDownloadsModal = () => {
finalFile,
finalLocation,
useSharedDestination,
destinationOverrides[idx]
destinationOverrides[idx],
item.isTorrent === true
);
let count = 1;
@@ -1358,7 +1363,8 @@ export const AddDownloadsModal = () => {
candidateFile,
finalLocation,
useSharedDestination,
destinationOverrides[candidateIndex]
destinationOverrides[candidateIndex],
candidate.isTorrent === true
);
batchTargets.push({ location: candidateLocation, fileName: candidateFile });
}
@@ -1419,7 +1425,8 @@ export const AddDownloadsModal = () => {
finalFile,
finalLocation,
useSharedDestination,
destinationOverrides[idx]
destinationOverrides[idx],
item.isTorrent === true
);
const store = useDownloadStore.getState();
let existingItem = conflict?.existingDownloadId
@@ -1531,7 +1538,7 @@ export const AddDownloadsModal = () => {
? mediaFileNameForSelectedFormat(item.file, item)
: canonicalizeDownloadFileName(item.file);
let formatSelector = mediaFormatSelectorForRow(item);
const category = categoryForFileName(finalFile);
const category = categoryForFileName(finalFile, item.isTorrent === true);
const added = await addDownload({
id,
url: item.downloadUrl,
@@ -1559,7 +1566,8 @@ export const AddDownloadsModal = () => {
finalFile,
finalLocation,
useSharedDestination,
destinationOverrides[itemIndex]
destinationOverrides[itemIndex],
item.isTorrent === true
)
: undefined,
isMedia: item.isMedia,
+2 -2
View File
@@ -273,8 +273,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
</span>
) : null}
{download.isTorrent ? (
<span className="download-quality-chip shrink-0" title={t($ => $.addDownloads.torrentFiles)}>
{t($ => $.addDownloads.torrentFiles)}
<span className="download-quality-chip shrink-0" title={t($ => $.addDownloads.torrent)}>
{t($ => $.addDownloads.torrent)}
</span>
) : null}
</div>
+3 -1
View File
@@ -6,7 +6,7 @@ import { useToast } from '../contexts/ToastContext';
import { useSettingsStore } from '../store/useSettingsStore';
import { SidebarFilter } from './Sidebar';
import {
Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion,
Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, Magnet,
ArrowDownCircle, ArrowUp, ArrowDown, Command, ChevronUp, ChevronDown, MoreHorizontal,
AlignLeft, AlignCenter, AlignRight, GripVertical
} from 'lucide-react';
@@ -1850,6 +1850,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
case 'Documents': return t($ => $.navigation.categories.documents);
case 'Pictures': return t($ => $.navigation.categories.pictures);
case 'Applications': return t($ => $.navigation.categories.applications);
case 'Torrents': return t($ => $.navigation.categories.torrents);
case 'Other': return t($ => $.navigation.categories.other);
default: return filter;
}
@@ -2008,6 +2009,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
case 'Applications': return <Box size={16} className="text-indigo-400" />;
case 'Pictures': return <ImageIcon size={16} className="text-purple-400" />;
case 'Compressed': return <Archive size={16} className="text-amber-600" />;
case 'Torrents': return <Magnet size={16} className="text-violet-400" />;
case 'Other': return <FileQuestion size={16} className="text-gray-400" />;
default: return <FileQuestion size={16} className="text-gray-400" />;
}
+1
View File
@@ -813,6 +813,7 @@ runEngineChecks(false);
case 'Documents': return t($ => $.navigation.categories.documents);
case 'Pictures': return t($ => $.navigation.categories.pictures);
case 'Applications': return t($ => $.navigation.categories.applications);
case 'Torrents': return t($ => $.navigation.categories.torrents);
default: return t($ => $.navigation.categories.other);
}
};
+2 -1
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useLayoutEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import {
Inbox, Zap, CheckCircle2, CircleDashed,
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion,
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion, Magnet,
List, CalendarClock, Gauge, Bug, Settings, Plus, Play, Pause, Edit2, Trash2, PanelLeft,
ChevronDown,
type LucideIcon
@@ -403,6 +403,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<NavItem icon={FileText} label={t($ => $.navigation.categories.documents)} filter="Documents" />
<NavItem icon={ImageIcon} label={t($ => $.navigation.categories.pictures)} filter="Pictures" />
<NavItem icon={Box} label={t($ => $.navigation.categories.applications)} filter="Applications" />
<NavItem icon={Magnet} label={t($ => $.navigation.categories.torrents)} filter="Torrents" />
<NavItem icon={FileQuestion} label={t($ => $.navigation.categories.other)} filter="Other" />
</div>
</div>
+2
View File
@@ -14,6 +14,7 @@ const common = {
documents: 'Documents',
pictures: 'Pictures',
applications: 'Applications',
torrents: 'Torrents',
other: 'Other',
},
folders: 'Folders',
@@ -667,6 +668,7 @@ const common = {
refreshMetadata: 'Refresh Metadata',
files: 'Files',
torrentFiles: 'Torrent files',
torrent: 'Torrent',
chooseTorrentFiles: 'Add .torrent files',
torrentMetadataPending: 'Aria2 will resolve the magnet metadata when the transfer starts.',
torrentSeeding: 'Torrent seeding',
+2
View File
@@ -14,6 +14,7 @@ const fa = {
documents: 'اسناد',
pictures: 'تصاویر',
applications: 'برنامه‌ها',
torrents: 'تورنت‌ها',
other: 'سایر',
},
folders: 'پوشه‌ها',
@@ -667,6 +668,7 @@ const fa = {
refreshMetadata: 'تازه‌سازی متادیتا',
files: 'فایل‌ها',
torrentFiles: 'فایل‌های تورنت',
torrent: 'تورنت',
chooseTorrentFiles: 'افزودن فایل‌های .torrent',
torrentMetadataPending: 'آریا۲ هنگام شروع انتقال، متادیتای مگنت را دریافت می‌کند.',
torrentSeeding: 'اشتراک‌گذاری تورنت',
+2
View File
@@ -14,6 +14,7 @@ const he = {
documents: 'מסמכים',
pictures: 'תמונות',
applications: 'יישומים',
torrents: 'טורנטים',
other: 'אחר',
},
folders: 'תיקיות',
@@ -667,6 +668,7 @@ const he = {
refreshMetadata: 'רענון מטא נתונים',
files: 'קבצים',
torrentFiles: 'קובצי טורנט',
torrent: 'טורנט',
chooseTorrentFiles: 'הוספת קובצי .torrent',
torrentMetadataPending: 'Aria2 יאתר את נתוני המגנט כשההעברה תתחיל.',
torrentSeeding: 'שיתוף טורנט',
+2
View File
@@ -14,6 +14,7 @@ const ru = {
documents: 'Документы',
pictures: 'Изображения',
applications: 'Программы',
torrents: 'Торренты',
other: 'Другое',
},
folders: 'Папки',
@@ -667,6 +668,7 @@ const ru = {
refreshMetadata: 'Обновить метаданные',
files: 'Файлы',
torrentFiles: 'Торрент-файлы',
torrent: 'Торрент',
chooseTorrentFiles: 'Добавить файлы .torrent',
torrentMetadataPending: 'Aria2 получит метаданные магнита при запуске передачи.',
torrentSeeding: 'Раздача торрента',
+2
View File
@@ -14,6 +14,7 @@ const uk = {
documents: 'Документи',
pictures: 'Зображення',
applications: 'Програми',
torrents: 'Торенти',
other: 'Інше',
},
folders: 'Папки',
@@ -667,6 +668,7 @@ const uk = {
refreshMetadata: 'Оновити метадані',
files: 'Файли',
torrentFiles: 'Торрент-файли',
torrent: 'Торрент',
chooseTorrentFiles: 'Додати файли .torrent',
torrentMetadataPending: 'Aria2 отримає метадані магнітного посилання після початку передачі.',
torrentSeeding: 'Роздача торрента',
+2
View File
@@ -14,6 +14,7 @@ const zhCN = {
documents: '文档',
pictures: '图片',
applications: '应用程序',
torrents: '种子',
other: '其他',
},
folders: '文件夹',
@@ -667,6 +668,7 @@ const zhCN = {
refreshMetadata: '刷新元数据',
files: '文件',
torrentFiles: '种子文件',
torrent: '种子',
chooseTorrentFiles: '添加 .torrent 文件',
torrentMetadataPending: '传输开始时,Aria2 将解析磁力链接元数据。',
torrentSeeding: 'BT 做种',
+6 -2
View File
@@ -2,7 +2,7 @@ import type { UnlistenFn } from '@tauri-apps/api/event';
import type { DownloadStatus } from '../bindings/DownloadStatus';
import { listenEvent as listen } from '../ipc';
import type { DownloadItem } from '../bindings/DownloadItem';
import { categoryForFileName } from '../utils/downloads';
import { categoryForDownload } from '../utils/downloads';
import { useDownloadProgressStore } from './downloadProgressStore';
import {
@@ -221,7 +221,11 @@ const startDownloadListeners = async () => {
}
if (payload.fileName && payload.fileName !== current.fileName) {
updates.fileName = payload.fileName;
updates.category = categoryForFileName(payload.fileName);
updates.category = categoryForDownload(
payload.fileName,
current.isTorrent === true,
current.category
);
}
if (status !== 'downloading' && status !== 'verifying') {
updates.speed = '-';
+17
View File
@@ -1607,6 +1607,23 @@ describe('useDownloadStore', () => {
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
});
it('normalizes new Torrent rows before resolving their default destination', async () => {
await useDownloadStore.getState().addDownload({
id: 'torrent-default',
url: 'magnet:?xt=urn:btih:default',
fileName: 'metadata',
category: 'Other',
dateAdded: '',
isTorrent: true
}, { type: 'add-to-queue', queueId: 'queue-torrents' });
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
category: 'Torrents',
destination: '/Users/test/Downloads/Torrents',
status: 'staged'
});
});
it('inserts a newly staged queue item before paused rows', async () => {
useDownloadStore.setState({
downloads: [{
+13 -4
View File
@@ -9,7 +9,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { useDownloadProgressStore } from './downloadProgressStore';
import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import {
resolveCategoryDestination
} from '../utils/downloadLocations';
@@ -418,7 +418,11 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
if (acceptedFilename !== item.fileName) {
useDownloadStore.getState().updateDownload(id, {
fileName: acceptedFilename,
category: categoryForFileName(acceptedFilename)
category: categoryForDownload(
acceptedFilename,
item.isTorrent === true,
item.category
)
});
}
const order = await invoke('get_pending_order', { queueId: item.queueId || MAIN_QUEUE_ID });
@@ -1614,7 +1618,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const settings = useSettingsStore.getState();
const normalizedItem = {
...item,
fileName: canonicalizeDownloadFileName(item.fileName)
fileName: canonicalizeDownloadFileName(item.fileName),
category: categoryForFileName(item.fileName, item.isTorrent === true)
};
const destPath = await effectiveDestinationForItem(normalizedItem, settings);
const queueId = action.type === 'add-to-queue' ? action.queueId : MAIN_QUEUE_ID;
@@ -2541,7 +2546,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
...(acceptedFilenames.has(download.id)
? {
fileName: acceptedFilenames.get(download.id),
category: categoryForFileName(acceptedFilenames.get(download.id)!)
category: categoryForDownload(
acceptedFilenames.get(download.id)!,
download.isTorrent === true,
download.category
)
}
: {}),
hasBeenDispatched: true,
+18
View File
@@ -130,6 +130,24 @@ describe('download locations', () => {
expect(await resolveCategoryDestination(automatic, 'Movies')).toBe('/Volumes/Media');
});
it('defaults Torrent downloads to the Torrents folder and respects overrides', async () => {
const settings = normalizeDownloadLocationSettings({
baseDownloadFolder: '/Users/test/Downloads'
});
expect(settings.categorySubfolders.Torrents).toBe('Torrents');
expect(await resolveCategoryDestination(settings, 'Torrents'))
.toBe('/Users/test/Downloads/Torrents');
settings.categoryDirectoryOverrides.Torrents = '/Volumes/Archive/Torrents';
expect(await resolveCategoryDestination(settings, 'Torrents'))
.toBe('/Volumes/Archive/Torrents');
settings.categorySubfoldersEnabled = false;
expect(await resolveCategoryDestination(settings, 'Torrents'))
.toBe('/Users/test/Downloads');
});
it('defaults category subfolders on and sends every category to the base folder when disabled', async () => {
const automatic = normalizeDownloadLocationSettings({
baseDownloadFolder: '/Users/test/Downloads'
+3
View File
@@ -19,6 +19,7 @@ export const DOWNLOAD_CATEGORIES: DownloadCategory[] = [
'Documents',
'Pictures',
'Applications',
'Torrents',
'Other'
];
@@ -29,6 +30,7 @@ export const DEFAULT_CATEGORY_SUBFOLDERS: Record<DownloadCategory, string> = {
Documents: 'Documents',
Pictures: 'Pictures',
Applications: 'Applications',
Torrents: 'Torrents',
Other: 'Other'
};
@@ -245,6 +247,7 @@ export const normalizeDownloadLocationSettings = (
Documents: 'Documents',
Pictures: 'Images',
Applications: 'Apps',
Torrents: 'Torrents',
Other: 'Other'
};
+13
View File
@@ -6,6 +6,8 @@ import {
downloadMediaKindsMatch,
MAX_DOWNLOAD_FILENAME_BYTES,
canonicalizeDownloadFileName,
categoryForDownload,
categoryForFileName,
isValidTorrentExcludeTrackerList,
isValidTorrentTrackerList,
normalizeTorrentEncryptionPolicy,
@@ -33,6 +35,17 @@ const item = (status: DownloadItem['status']): DownloadItem => ({
totalIsEstimate: false
});
describe('download category detection', () => {
it('classifies torrent files and explicit Torrent rows separately from filename types', () => {
expect(categoryForFileName('Example.torrent')).toBe('Torrents');
expect(categoryForFileName('Example', true)).toBe('Torrents');
expect(categoryForFileName('Example.mkv', true)).toBe('Torrents');
expect(categoryForFileName('Example.mkv')).toBe('Movies');
expect(categoryForDownload('Renamed', true, 'Other')).toBe('Other');
expect(categoryForDownload('Renamed', true, 'Torrents')).toBe('Torrents');
});
});
describe('download persistence progress snapshots', () => {
it('does not write active byte counters on every progress event', () => {
const persisted = redactDownloadForPersistence(item('downloading'));
+14 -1
View File
@@ -417,7 +417,11 @@ export const initMediaDomains = async () => {
}
};
export const categoryForFileName = (fileName: string): DownloadCategory => {
export const categoryForFileName = (
fileName: string,
isTorrent = false
): DownloadCategory => {
if (isTorrent || fileName.trim().toLowerCase().endsWith('.torrent')) return 'Torrents';
const ext = fileName.split('.').pop()?.toLowerCase() || '';
if (['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'mpeg', 'mpg', '3gp', 'ts', 'vob'].includes(ext)) return 'Movies';
if (['mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'wma', 'alac', 'ape', 'mid', 'midi'].includes(ext)) return 'Musics';
@@ -428,6 +432,15 @@ export const categoryForFileName = (fileName: string): DownloadCategory => {
return 'Other';
};
export const categoryForDownload = (
fileName: string,
isTorrent: boolean,
existingCategory?: DownloadCategory
): DownloadCategory => {
if (isTorrent && existingCategory === 'Other') return existingCategory;
return categoryForFileName(fileName, isTorrent);
};
export const fileNameFromUrl = (rawUrl: string): string => {
try {
const url = new URL(rawUrl);