feat(torrents): add seeding lifecycle and upload controls

This commit is contained in:
NimBold
2026-08-01 21:27:49 +03:30
parent bb64c4cd52
commit dea6ad1974
26 changed files with 663 additions and 25 deletions
+1 -1
View File
@@ -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, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: 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, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, };
+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 DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, };
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, uploaded_bytes?: number, upload_speed?: string, num_seeders?: number, };
+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 DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "paused" | "completed" | "failed" | "queued" | "retrying";
export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "paused" | "completed" | "failed" | "queued" | "retrying";
+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 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, };
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, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, lifecycle_generation?: string, };
+99
View File
@@ -225,6 +225,11 @@ export const AddDownloadsModal = () => {
const [connections, setConnections] = useState(perServerConnections);
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
const [speedLimit, setSpeedLimit] = useState('1024');
const [torrentSeedingEnabled, setTorrentSeedingEnabled] = useState(false);
const [torrentSeedTime, setTorrentSeedTime] = useState('60');
const [torrentSeedRatio, setTorrentSeedRatio] = useState('1.0');
const [torrentUploadLimitEnabled, setTorrentUploadLimitEnabled] = useState(false);
const [torrentUploadLimit, setTorrentUploadLimit] = useState('1024');
const [freeSpace, setFreeSpace] = useState('Unknown');
const freeSpaceRequestRef = useRef(0);
@@ -358,6 +363,11 @@ export const AddDownloadsModal = () => {
setFreeSpace('Unknown');
setSpeedLimitEnabled(false);
setSpeedLimit('1024');
setTorrentSeedingEnabled(false);
setTorrentSeedTime('60');
setTorrentSeedRatio('1.0');
setTorrentUploadLimitEnabled(false);
setTorrentUploadLimit('1024');
setUseAuth(false);
setUsername('');
setPassword('');
@@ -931,6 +941,19 @@ export const AddDownloadsModal = () => {
addToast({ message: t($ => $.addDownloads.speedInvalid), variant: 'error', isActionable: true });
return;
}
const hasSelectedTorrent = parsedItems.some(item => item.selected !== false && item.isTorrent);
if (hasSelectedTorrent && torrentSeedingEnabled && (!Number.isFinite(Number(torrentSeedTime)) || Number(torrentSeedTime) <= 0)) {
addToast({ message: t($ => $.addDownloads.torrentSeedTimeInvalid), variant: 'error', isActionable: true });
return;
}
if (hasSelectedTorrent && torrentSeedingEnabled && (!Number.isFinite(Number(torrentSeedRatio)) || Number(torrentSeedRatio) < 0)) {
addToast({ message: t($ => $.addDownloads.torrentSeedRatioInvalid), variant: 'error', isActionable: true });
return;
}
if (hasSelectedTorrent && torrentUploadLimitEnabled && (!Number.isFinite(Number(torrentUploadLimit)) || Number(torrentUploadLimit) <= 0)) {
addToast({ message: t($ => $.addDownloads.torrentUploadLimitInvalid), variant: 'error', isActionable: true });
return;
}
if (saveInDedicatedFolder && !sanitizeBatchFolderName(dedicatedFolderName)) {
addToast({
message: t($ => $.addDownloads.dedicatedFolderNameRequired),
@@ -1402,6 +1425,9 @@ export const AddDownloadsModal = () => {
torrentPath,
torrentInfoHash: item.torrentInfoHash,
torrentFileIndices: item.selectedTorrentFileIndices,
torrentSeedTime: item.isTorrent && torrentSeedingEnabled ? Number(torrentSeedTime) : undefined,
torrentSeedRatio: item.isTorrent && torrentSeedingEnabled ? Number(torrentSeedRatio) : undefined,
torrentUploadLimit: item.isTorrent && torrentUploadLimitEnabled ? `${torrentUploadLimit}K` : undefined,
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
sizeBytes: item.sizeBytes
}, action);
@@ -1979,6 +2005,79 @@ export const AddDownloadsModal = () => {
</section>
)}
{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">
<HardDrive size={16} className="text-blue-500" /> {t($ => $.addDownloads.torrentSeeding)}
</div>
<div className="space-y-3 text-xs">
<label className="flex items-center gap-2 text-text-primary">
<input
type="checkbox"
checked={torrentSeedingEnabled}
onChange={event => setTorrentSeedingEnabled(event.target.checked)}
className="accent-blue-500"
/>
{t($ => $.addDownloads.seedAfterDownload)}
</label>
{torrentSeedingEnabled ? (
<div className="grid grid-cols-[1fr_auto] gap-2 items-center">
<label htmlFor="torrent-seed-time" className="text-text-muted">{t($ => $.addDownloads.seedTime)}</label>
<div className="flex items-center gap-1.5">
<input
id="torrent-seed-time"
type="number"
min={1}
step={1}
value={torrentSeedTime}
onChange={event => setTorrentSeedTime(event.target.value)}
className="app-control w-20 px-2 py-1 text-end font-mono"
/>
<span className="text-text-muted">{t($ => $.addDownloads.minutes)}</span>
</div>
<label htmlFor="torrent-seed-ratio" className="text-text-muted">{t($ => $.addDownloads.seedRatio)}</label>
<input
id="torrent-seed-ratio"
type="number"
min={0}
step={0.1}
value={torrentSeedRatio}
onChange={event => setTorrentSeedRatio(event.target.value)}
className="app-control w-20 px-2 py-1 text-end font-mono"
aria-describedby="torrent-seed-ratio-hint"
/>
<span id="torrent-seed-ratio-hint" className="col-span-2 text-[10px] text-text-muted">
{t($ => $.addDownloads.seedRatioHint)}
</span>
</div>
) : null}
<label className="flex items-center gap-2 text-text-primary">
<input
type="checkbox"
checked={torrentUploadLimitEnabled}
onChange={event => setTorrentUploadLimitEnabled(event.target.checked)}
className="accent-blue-500"
/>
{t($ => $.addDownloads.limitTorrentUpload)}
</label>
{torrentUploadLimitEnabled ? (
<div className="flex items-center gap-2">
<input
type="number"
min={1}
step={128}
value={torrentUploadLimit}
onChange={event => setTorrentUploadLimit(event.target.value)}
className="app-control w-24 px-2 py-1 text-end font-mono"
aria-label={t($ => $.addDownloads.torrentUploadLimit)}
/>
<span className="text-text-muted">KiB/s</span>
</div>
) : null}
</div>
</section>
)}
{/* Media Format (Dynamic) */}
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
<section className="add-download-section add-download-media-section relative overflow-hidden p-4">
+11 -3
View File
@@ -178,16 +178,20 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
};
}, [isActionVisible, updateActionPosition]);
const displayFraction = download.status === 'downloading'
const displayFraction = download.status === 'downloading' || download.status === 'seeding'
? liveProgress?.fraction ?? download.fraction ?? 0
: download.fraction ?? 0;
const displayPercent = `${(displayFraction * 100).toFixed(0)}%`;
const displaySpeed = download.status === 'downloading'
const displaySpeed = download.status === 'seeding'
? liveProgress?.upload_speed ?? '-'
: download.status === 'downloading'
? liveProgress?.speed ?? download.speed
: download.status === 'processing'
? t($ => $.downloads.values.processing)
: '-';
const displayEta = download.status === 'downloading'
const displayEta = download.status === 'seeding'
? '-'
: download.status === 'downloading'
? liveProgress?.eta ?? download.eta
: download.status === 'processing'
? t($ => $.downloads.values.muxing)
@@ -291,6 +295,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<div
className={`download-progress-fill ${
download.status === 'paused' ? 'paused' :
download.status === 'seeding' ? 'seeding' :
download.status === 'processing' ? 'processing' :
download.status === 'queued' || download.status === 'staged' ? 'queued' :
download.status === 'retrying' ? 'retrying' : ''
@@ -312,6 +317,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
}
className={`download-status flex items-center gap-1.5 ${
download.status === 'paused' ? 'download-status-paused' :
download.status === 'seeding' ? 'download-status-seeding' :
download.status === 'failed' ? 'download-status-failed' :
download.status === 'processing' ? 'download-status-processing' :
download.status === 'downloading' ? 'download-status-downloading' :
@@ -328,6 +334,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
</>
) : download.status === 'downloading' ? (
displayPercent
) : download.status === 'seeding' ? (
displayPercent
) : download.status === 'processing' ? (
downloadStatusLabel
) : (
+6 -1
View File
@@ -333,6 +333,7 @@ export const PropertiesModal = () => {
const observedActiveConnections = liveProgress?.active_connections;
const connectionTelemetryActive = item.status === 'downloading' ||
item.status === 'processing' ||
item.status === 'seeding' ||
item.status === 'retrying';
const connectionStatus = (() => {
if (!connectionTelemetryActive) return String(configuredConnections);
@@ -363,9 +364,13 @@ export const PropertiesModal = () => {
: liveProgress?.fraction ?? item.fraction ?? 0;
const displayedSpeed = item.status === 'completed'
? '-'
: item.status === 'seeding'
? liveProgress?.upload_speed ?? '-'
: liveProgress?.speed ?? item.speed ?? '-';
const displayedEta = item.status === 'completed'
? '-'
: item.status === 'seeding'
? '-'
: liveProgress?.eta ?? item.eta ?? '-';
const sizeDisplay = resolveDownloadSizeDisplay({
downloadedBytes: liveProgress?.downloaded_bytes ?? item.downloadedBytes,
@@ -400,7 +405,7 @@ export const PropertiesModal = () => {
let statusColor = 'text-text-secondary';
let StatusIcon = Info;
if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; }
else if (item.status === 'downloading' || item.status === 'retrying') { statusColor = 'text-blue-500'; StatusIcon = Play; }
else if (item.status === 'downloading' || item.status === 'seeding' || item.status === 'retrying') { statusColor = 'text-blue-500'; StatusIcon = Play; }
else if (item.status === 'processing') { statusColor = 'text-sky-500'; StatusIcon = Play; }
else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; }
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
+13
View File
@@ -89,6 +89,7 @@ const common = {
queued: 'Queued',
downloading: 'Downloading',
processing: 'Processing',
seeding: 'Seeding',
paused: 'Paused',
completed: 'Completed',
failed: 'Failed',
@@ -457,6 +458,17 @@ const common = {
torrentFiles: 'Torrent files',
chooseTorrentFiles: 'Add .torrent files',
torrentMetadataPending: 'Aria2 will resolve the magnet metadata when the transfer starts.',
torrentSeeding: 'Torrent seeding',
seedAfterDownload: 'Seed after download completes',
seedTime: 'Seed time',
minutes: 'minutes',
seedRatio: 'Seed ratio',
seedRatioHint: '0 means time-only seeding; otherwise seeding stops at the first limit reached.',
limitTorrentUpload: 'Limit torrent upload',
torrentUploadLimit: 'Torrent upload limit',
torrentSeedTimeInvalid: 'Torrent seed time must be greater than zero',
torrentSeedRatioInvalid: 'Torrent seed ratio must be zero or greater',
torrentUploadLimitInvalid: 'Torrent upload limit must be greater than zero',
required: 'Required',
free: 'Free',
preview: 'Preview',
@@ -817,6 +829,7 @@ const common = {
active: '{{count}} active',
queued: '{{count}} queued',
done: '{{count}} done',
seeding: 'Seeding',
},
} as const;
+13
View File
@@ -89,6 +89,7 @@ const fa = {
queued: 'در صف',
downloading: 'در حال دانلود',
processing: 'در حال پردازش',
seeding: 'در حال اشتراک‌گذاری',
paused: 'متوقف‌شده',
completed: 'تکمیل‌شده',
failed: 'ناموفق',
@@ -457,6 +458,17 @@ const fa = {
torrentFiles: 'فایل‌های تورنت',
chooseTorrentFiles: 'افزودن فایل‌های .torrent',
torrentMetadataPending: 'آریا۲ هنگام شروع انتقال، متادیتای مگنت را دریافت می‌کند.',
torrentSeeding: 'اشتراک‌گذاری تورنت',
seedAfterDownload: 'پس از پایان دانلود سید شود',
seedTime: 'مدت سید',
minutes: 'دقیقه',
seedRatio: 'نسبت سید',
seedRatioHint: '۰ یعنی فقط مدت زمان تعیین‌شده ملاک است؛ در غیر این صورت با رسیدن به اولین حد متوقف می‌شود.',
limitTorrentUpload: 'محدود کردن آپلود تورنت',
torrentUploadLimit: 'محدودیت آپلود تورنت',
torrentSeedTimeInvalid: 'مدت سید تورنت باید بیشتر از صفر باشد',
torrentSeedRatioInvalid: 'نسبت سید تورنت نمی‌تواند منفی باشد',
torrentUploadLimitInvalid: 'محدودیت آپلود تورنت باید بیشتر از صفر باشد',
required: 'الزامی',
free: 'فضای آزاد',
preview: 'پیش‌نمایش',
@@ -817,6 +829,7 @@ const fa = {
active: '{{count}} فعال',
queued: '{{count}} در صف',
done: '{{count}} تکمیل‌شده',
seeding: 'در حال اشتراک‌گذاری',
},
} as const;
+13
View File
@@ -89,6 +89,7 @@ const he = {
queued: 'בתור',
downloading: 'מוריד',
processing: 'מעבד',
seeding: 'משתף',
paused: 'מושהה',
completed: 'הושלם',
failed: 'נכשל',
@@ -457,6 +458,17 @@ const he = {
torrentFiles: 'קובצי טורנט',
chooseTorrentFiles: 'הוספת קובצי .torrent',
torrentMetadataPending: 'Aria2 יאתר את נתוני המגנט כשההעברה תתחיל.',
torrentSeeding: 'שיתוף טורנט',
seedAfterDownload: 'לשתף לאחר סיום ההורדה',
seedTime: 'זמן שיתוף',
minutes: 'דקות',
seedRatio: 'יחס שיתוף',
seedRatioHint: '0 פירושו שיתוף לפי זמן בלבד; אחרת השיתוף ייפסק בהגעה למגבלה הראשונה.',
limitTorrentUpload: 'הגבלת העלאת טורנט',
torrentUploadLimit: 'מגבלת העלאת טורנט',
torrentSeedTimeInvalid: 'זמן שיתוף הטורנט חייב להיות גדול מאפס',
torrentSeedRatioInvalid: 'יחס שיתוף הטורנט חייב להיות אפס או יותר',
torrentUploadLimitInvalid: 'מגבלת העלאת הטורנט חייבת להיות גדולה מאפס',
required: 'נדרש',
free: 'פנוי',
preview: 'תצוגה מקדימה',
@@ -817,6 +829,7 @@ const he = {
active: '{{count}} פעילים',
queued: '{{count}} בתור',
done: '{{count}} הושלמו',
seeding: 'משתף',
},
} as const;
+13
View File
@@ -89,6 +89,7 @@ const ru = {
queued: 'В очереди',
downloading: 'Загрузка',
processing: 'Обработка',
seeding: 'Раздача',
paused: 'Приостановлено',
completed: 'Завершено',
failed: 'Ошибка',
@@ -457,6 +458,17 @@ const ru = {
torrentFiles: 'Торрент-файлы',
chooseTorrentFiles: 'Добавить файлы .torrent',
torrentMetadataPending: 'Aria2 получит метаданные магнита при запуске передачи.',
torrentSeeding: 'Раздача торрента',
seedAfterDownload: 'Раздавать после завершения загрузки',
seedTime: 'Время раздачи',
minutes: 'минут',
seedRatio: 'Коэффициент раздачи',
seedRatioHint: '0 означает раздачу только по времени; иначе раздача остановится при достижении первого ограничения.',
limitTorrentUpload: 'Ограничить отдачу торрента',
torrentUploadLimit: 'Лимит отдачи торрента',
torrentSeedTimeInvalid: 'Время раздачи торрента должно быть больше нуля',
torrentSeedRatioInvalid: 'Коэффициент раздачи торрента не может быть отрицательным',
torrentUploadLimitInvalid: 'Лимит отдачи торрента должен быть больше нуля',
required: 'Требуется',
free: 'Свободно',
preview: 'Предпросмотр',
@@ -817,6 +829,7 @@ const ru = {
active: '{{count}} активных',
queued: '{{count}} в очереди',
done: '{{count}} завершено',
seeding: 'Раздача',
},
} as const;
+13
View File
@@ -89,6 +89,7 @@ const uk = {
queued: 'У черзі',
downloading: 'Завантаження',
processing: 'Обробка',
seeding: 'Роздача',
paused: 'Призупинено',
completed: 'Завершено',
failed: 'Помилка',
@@ -457,6 +458,17 @@ const uk = {
torrentFiles: 'Торрент-файли',
chooseTorrentFiles: 'Додати файли .torrent',
torrentMetadataPending: 'Aria2 отримає метадані магнітного посилання після початку передачі.',
torrentSeeding: 'Роздача торрента',
seedAfterDownload: 'Роздавати після завершення завантаження',
seedTime: 'Час роздачі',
minutes: 'хвилин',
seedRatio: 'Коефіцієнт роздачі',
seedRatioHint: '0 означає роздачу лише за часом; інакше роздача зупиниться після досягнення першого обмеження.',
limitTorrentUpload: 'Обмежити віддачу торрента',
torrentUploadLimit: 'Ліміт віддачі торрента',
torrentSeedTimeInvalid: 'Час роздачі торрента має бути більшим за нуль',
torrentSeedRatioInvalid: 'Коефіцієнт роздачі торрента не може бути від’ємним',
torrentUploadLimitInvalid: 'Ліміт віддачі торрента має бути більшим за нуль',
required: 'Обов\'язково',
free: 'Вільно',
preview: 'Попередній перегляд',
@@ -817,6 +829,7 @@ const uk = {
active: '{{count}} активних',
queued: '{{count}} в черзі',
done: '{{count}} завершено',
seeding: 'Роздача',
},
} as const;
+13
View File
@@ -89,6 +89,7 @@ const zhCN = {
queued: '已排队',
downloading: '下载中',
processing: '处理中',
seeding: '做种中',
paused: '已暂停',
completed: '已完成',
failed: '失败',
@@ -457,6 +458,17 @@ const zhCN = {
torrentFiles: '种子文件',
chooseTorrentFiles: '添加 .torrent 文件',
torrentMetadataPending: '传输开始时,Aria2 将解析磁力链接元数据。',
torrentSeeding: 'BT 做种',
seedAfterDownload: '下载完成后继续做种',
seedTime: '做种时间',
minutes: '分钟',
seedRatio: '做种比率',
seedRatioHint: '0 表示仅按时间做种;否则达到第一个限制时停止做种。',
limitTorrentUpload: '限制种子上传',
torrentUploadLimit: '种子上传限速',
torrentSeedTimeInvalid: '做种时间必须大于零',
torrentSeedRatioInvalid: '做种比率不能小于零',
torrentUploadLimitInvalid: '种子上传限速必须大于零',
required: '必需',
free: '可用空间',
preview: '预览',
@@ -817,6 +829,7 @@ const zhCN = {
active: '{{count}} 个进行中',
queued: '{{count}} 个排队',
done: '{{count}} 个完成',
seeding: '做种',
},
} as const;
+9
View File
@@ -3239,6 +3239,10 @@ html[dir="rtl"] .download-context-menu-chevron {
background: hsl(199 89% 48%);
}
.download-progress-fill.seeding {
background: hsl(262 83% 58%);
}
.download-progress-fill.queued {
background: hsl(var(--status-queued));
}
@@ -3281,6 +3285,11 @@ html[dir="rtl"] .download-context-menu-chevron {
font-weight: 600;
}
.download-status-seeding {
color: hsl(262 83% 58%);
font-weight: 600;
}
.download-status-queued {
color: hsl(var(--status-queued));
font-weight: 600;
+78
View File
@@ -125,6 +125,84 @@ describe('useDownloadProgressStore', () => {
release();
});
it('projects torrent seeding state and upload telemetry', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'torrent-seeding',
url: 'magnet:?xt=urn:btih:test',
fileName: 'ubuntu.iso',
status: 'downloading',
category: 'Other',
dateAdded: ''
}]
});
const release = await initDownloadListener();
handlers['download-state']({ payload: {
id: 'torrent-seeding',
status: 'seeding'
} });
handlers['download-progress']({ payload: {
id: 'torrent-seeding',
fraction: 1,
speed: '0 B/s',
eta: '-',
size: '2 GB',
size_is_final: false,
uploaded_bytes: 1048576,
upload_speed: '512 KiB/s',
num_seeders: 4,
active_connections: 6,
requested_connections: 8
} });
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'seeding',
fraction: 1,
speed: '512 KiB/s',
eta: '-'
});
expect(useDownloadProgressStore.getState().progressMap['torrent-seeding'])
.toMatchObject({ uploaded_bytes: 1048576, upload_speed: '512 KiB/s', num_seeders: 4 });
release();
});
it('does not regress a seeding row from a delayed active state event', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'torrent-seeding-race',
url: 'magnet:?xt=urn:btih:test',
fileName: 'ubuntu.iso',
status: 'seeding',
category: 'Other',
dateAdded: ''
}]
});
const release = await initDownloadListener();
handlers['download-state']({ payload: {
id: 'torrent-seeding-race',
status: 'downloading'
} });
handlers['download-state']({ payload: {
id: 'torrent-seeding-race',
status: 'queued'
} });
expect(useDownloadStore.getState().downloads[0].status).toBe('seeding');
release();
});
it('clears progress when events arrive after a download row was removed', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
+16 -7
View File
@@ -45,17 +45,19 @@ const startDownloadListeners = async () => {
// A sidecar can flush one last progress chunk after a pause, failure,
// completion, or lifecycle reset. Do not let that stale chunk repopulate
// the live progress map or overwrite a later lifecycle's first frame.
if (!['downloading', 'processing'].includes(current.status)) {
if (!['downloading', 'processing', 'seeding'].includes(current.status)) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
return;
}
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
const updates: Partial<DownloadItem> = {};
if (current.status === 'downloading' || current.status === 'processing') {
if (current.status === 'downloading' || current.status === 'processing' || current.status === 'seeding') {
updates.fraction = payload.fraction;
updates.speed = payload.speed;
updates.eta = payload.eta;
updates.speed = current.status === 'seeding'
? payload.upload_speed ?? '-'
: payload.speed;
updates.eta = current.status === 'seeding' ? '-' : payload.eta;
}
if (shouldUpdateSize && current.size !== payload.size) {
updates.size = payload.size!;
@@ -119,7 +121,7 @@ const startDownloadListeners = async () => {
return;
}
if (status === 'downloading' || status === 'processing' ||
status === 'completed' || status === 'failed') {
status === 'seeding' || status === 'completed' || status === 'failed') {
clearDownloadControlIntent(payload.id, 'resume');
}
if (status === 'paused') {
@@ -142,6 +144,13 @@ const startDownloadListeners = async () => {
status !== 'failed') {
return;
}
if (current.status === 'seeding' &&
status !== 'seeding' &&
status !== 'paused' &&
status !== 'completed' &&
status !== 'failed') {
return;
}
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
@@ -179,7 +188,7 @@ const startDownloadListeners = async () => {
}
mainStore.updateDownload(payload.id, updates);
if (status === 'completed' || status === 'failed' || status === 'paused') {
if (status === 'completed' || status === 'failed' || status === 'paused' || status === 'seeding') {
useDownloadStore.setState(state => ({
pendingOrder: state.pendingOrder.filter(id => id !== payload.id)
}));
@@ -189,7 +198,7 @@ const startDownloadListeners = async () => {
: { pendingOrder: [...state.pendingOrder, payload.id] });
}
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'retrying') {
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'retrying') {
mainStore.registerBackendIds([payload.id]);
} else if (status === 'completed' || status === 'failed') {
mainStore.unregisterBackendIds([payload.id]);
+9 -3
View File
@@ -345,6 +345,9 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
torrent_path: item.torrentPath || undefined,
torrent_file_indices: item.torrentFileIndices || undefined,
torrent_info_hash: item.torrentInfoHash || undefined,
torrent_seed_time: item.torrentSeedTime,
torrent_seed_ratio: item.torrentSeedRatio,
torrent_upload_limit: item.torrentUploadLimit || undefined,
lifecycle_generation: lifecycleGeneration.toString(),
};
@@ -817,7 +820,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
? updates
: { ...updates, fileName: canonicalizeDownloadFileName(updates.fileName) };
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') {
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'seeding' || item.status === 'retrying') {
throw new Error(i18n.t($ => $.downloadTable.transferActive));
}
@@ -1411,8 +1414,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
if (updates.status && ['completed', 'failed', 'paused'].includes(updates.status)) {
info(`Download ${id} status changed to ${updates.status}`);
syncSystemIntegrations();
} else if (updates.status === 'downloading') {
info(`Download ${id} status changed to downloading`);
} else if (updates.status === 'downloading' || updates.status === 'seeding') {
info(`Download ${id} status changed to ${updates.status}`);
syncSystemIntegrations();
}
},
@@ -2048,6 +2051,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
torrent_path: item.torrentPath || undefined,
torrent_file_indices: item.torrentFileIndices || undefined,
torrent_info_hash: item.torrentInfoHash || undefined,
torrent_seed_time: item.torrentSeedTime,
torrent_seed_ratio: item.torrentSeedRatio,
torrent_upload_limit: item.torrentUploadLimit || undefined,
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
});
}
+3
View File
@@ -59,6 +59,9 @@ export interface AddDownloadDraftRow {
torrentInfoHash?: string;
torrentFiles?: TorrentFile[];
selectedTorrentFileIndices?: number[];
torrentSeedTime?: number;
torrentSeedRatio?: number;
torrentUploadLimit?: string;
}
/**
+4 -1
View File
@@ -20,7 +20,7 @@ describe('download action policy', () => {
expect(canStartDownload(status)).toBe(true);
expect(canPauseDownload(status)).toBe(false);
}
for (const status of ['staged', 'queued', 'downloading', 'processing', 'retrying'] as const) {
for (const status of ['staged', 'queued', 'downloading', 'seeding', 'processing', 'retrying'] as const) {
expect(canPauseDownload(status)).toBe(true);
}
for (const status of ['queued', 'downloading', 'processing', 'retrying'] as const) {
@@ -33,12 +33,14 @@ describe('download action policy', () => {
expect(canRedownload('failed')).toBe(true);
expect(canRedownload('paused')).toBe(true);
expect(canRedownload('downloading')).toBe(false);
expect(canRedownload('seeding')).toBe(false);
});
it('only exposes pause or resume for the details-view toggle', () => {
expect(getPauseResumeAction('queued')).toBe('pause');
expect(getPauseResumeAction('downloading')).toBe('pause');
expect(getPauseResumeAction('processing')).toBe('pause');
expect(getPauseResumeAction('seeding')).toBe('pause');
expect(getPauseResumeAction('retrying')).toBe('pause');
expect(getPauseResumeAction('paused')).toBe('resume');
@@ -53,6 +55,7 @@ describe('download action policy', () => {
expect(startActionLabel('failed')).toBe('Start');
expect(startActionLabel('paused')).toBe('Resume');
expect(isTransferLocked('processing')).toBe(true);
expect(isTransferLocked('seeding')).toBe(true);
expect(isIdentityLocked('completed')).toBe(true);
expect(isTransferLocked('completed')).toBe(false);
});
+2 -1
View File
@@ -11,6 +11,7 @@ const PAUSABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'staged',
'queued',
'downloading',
'seeding',
'processing',
'retrying',
]);
@@ -63,7 +64,7 @@ export const startActionLabel = (status: DownloadStatus): 'Start' | 'Resume' =>
status === 'ready' || status === 'staged' || status === 'failed' ? 'Start' : 'Resume';
export const isTransferLocked = (status: DownloadStatus): boolean =>
status === 'downloading' || status === 'processing' || status === 'retrying';
status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'retrying';
export const isIdentityLocked = (status: DownloadStatus): boolean =>
isTransferLocked(status) || status === 'completed';
+2
View File
@@ -73,6 +73,8 @@ export const downloadProgressColorClass = (status: string): string => {
return 'download-status-failed';
case 'processing':
return 'download-status-processing';
case 'seeding':
return 'download-status-seeding';
case 'queued':
case 'staged':
return 'download-status-queued';
+1
View File
@@ -20,6 +20,7 @@ const isFreshDownloadStatus = (status: DownloadItem['status']): boolean =>
status === 'staged' ||
status === 'queued' ||
status === 'downloading' ||
status === 'seeding' ||
status === 'processing' ||
status === 'retrying';
+4 -2
View File
@@ -30,6 +30,7 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'queued',
'downloading',
'processing',
'seeding',
'retrying',
]);
@@ -38,7 +39,7 @@ export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
/** Transfer states that consume a worker/permit. Queued is intentionally excluded. */
export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
status === 'downloading' || status === 'processing' || status === 'retrying';
status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'retrying';
export const DOWNLOAD_CONNECTIONS_MIN = 1;
export const DOWNLOAD_CONNECTIONS_MAX = 16;
@@ -255,7 +256,8 @@ export const isMediaUrl = (rawUrl: string): boolean => {
*/
const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
const VOLATILE_PROGRESS_STATUSES = new Set([
'downloading'
'downloading',
'seeding'
]);
/**