mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-08 10:23:29 +00:00
feat(torrents): add live upload limit control
This commit is contained in:
@@ -74,7 +74,9 @@ export const PropertiesModal = () => {
|
||||
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
|
||||
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
|
||||
const [liveSpeedLimitValue, setLiveSpeedLimitValue] = useState('');
|
||||
const [liveTorrentUploadLimitValue, setLiveTorrentUploadLimitValue] = useState('');
|
||||
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
|
||||
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
|
||||
|
||||
const [loginMode, setLoginMode] = useState<LoginMode>('matching');
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -97,6 +99,8 @@ export const PropertiesModal = () => {
|
||||
// Invalidate native pickers and transfer-control results when the modal
|
||||
// switches items, closes, or reopens for the same download.
|
||||
actionRequestRef.current += 1;
|
||||
setIsLiveSpeedLimitPending(false);
|
||||
setIsLiveTorrentUploadLimitPending(false);
|
||||
}, [selectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -168,6 +172,11 @@ export const PropertiesModal = () => {
|
||||
setLiveSpeedLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : '');
|
||||
}, [item?.speedLimit, selectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeLimit = item?.torrentUploadLimit?.trim();
|
||||
setLiveTorrentUploadLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : '');
|
||||
}, [item?.torrentUploadLimit, selectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPropertiesDownloadId || connectionsDirty) return;
|
||||
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
|
||||
@@ -321,10 +330,43 @@ export const PropertiesModal = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLiveTorrentUploadLimit = async (limit: string | null) => {
|
||||
if (
|
||||
isLiveTorrentUploadLimitPending
|
||||
|| !item.isTorrent
|
||||
|| !['downloading', 'seeding', 'retrying'].includes(item.status)
|
||||
) return;
|
||||
|
||||
setErrorMessage('');
|
||||
const requestId = ++actionRequestRef.current;
|
||||
setIsLiveTorrentUploadLimitPending(true);
|
||||
try {
|
||||
await useDownloadStore.getState().setTorrentUploadLimit(item.id, limit);
|
||||
if (
|
||||
limit === null
|
||||
&& requestId === actionRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === item.id
|
||||
) {
|
||||
setLiveTorrentUploadLimitValue('');
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
|
||||
setErrorMessage(t($ => $.properties.liveTorrentUploadLimitFailed, {
|
||||
detail: error instanceof Error ? error.message : String(error)
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
|
||||
setIsLiveTorrentUploadLimitPending(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const identityLocked = getIdentityLocked(item.status);
|
||||
const transferLocked = getTransferLocked(item.status);
|
||||
const liveSpeedLimitAvailable = !item.isMedia && ['downloading', 'retrying'].includes(item.status);
|
||||
const liveSpeedLimitUnavailable = item.isMedia && ['downloading', 'processing', 'retrying'].includes(item.status);
|
||||
const liveTorrentUploadLimitAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status);
|
||||
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
|
||||
const observedConnectionTotal = Math.max(
|
||||
1,
|
||||
@@ -611,6 +653,45 @@ export const PropertiesModal = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{liveTorrentUploadLimitAvailable && (
|
||||
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
|
||||
<label htmlFor="live-torrent-upload-limit" className="block text-xs font-semibold text-text-primary">
|
||||
{t($ => $.properties.liveTorrentUploadLimit)}
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
id="live-torrent-upload-limit"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={liveTorrentUploadLimitValue}
|
||||
onChange={event => setLiveTorrentUploadLimitValue(event.currentTarget.value)}
|
||||
placeholder={t($ => $.properties.liveTorrentUploadLimitPlaceholder)}
|
||||
disabled={isLiveTorrentUploadLimitPending}
|
||||
aria-describedby="live-torrent-upload-limit-hint"
|
||||
className="app-control w-32 px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLiveTorrentUploadLimit(liveTorrentUploadLimitValue)}
|
||||
disabled={isLiveTorrentUploadLimitPending}
|
||||
className="app-button app-button-primary px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
{t($ => $.properties.liveSpeedLimitApply)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLiveTorrentUploadLimit(null)}
|
||||
disabled={isLiveTorrentUploadLimitPending || !liveTorrentUploadLimitValue}
|
||||
className="app-button px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
{t($ => $.properties.liveSpeedLimitClear)}
|
||||
</button>
|
||||
</div>
|
||||
<p id="live-torrent-upload-limit-hint" className="text-[11px] text-text-muted">
|
||||
{t($ => $.properties.liveTorrentUploadLimitHint)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -228,6 +228,10 @@ const common = {
|
||||
liveSpeedLimitClear: 'Clear',
|
||||
liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'Live speed control is unavailable for media downloads while running.',
|
||||
liveTorrentUploadLimit: 'Live Torrent upload limit',
|
||||
liveTorrentUploadLimitHint: 'Applies to active Torrent downloads and seeding. Clear it to remove the per-Torrent upload cap.',
|
||||
liveTorrentUploadLimitPlaceholder: 'e.g. 1024K',
|
||||
liveTorrentUploadLimitFailed: 'Could not update the live Torrent upload limit: {{detail}}',
|
||||
category: 'Category',
|
||||
lastTry: 'Last try',
|
||||
dateAdded: 'Date added',
|
||||
|
||||
@@ -228,6 +228,10 @@ const fa = {
|
||||
liveSpeedLimitClear: 'پاک کردن',
|
||||
liveSpeedLimitFailed: 'بهروزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانهای هنگام اجرا در دسترس نیست.',
|
||||
liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت',
|
||||
liveTorrentUploadLimitHint: 'برای تورنتهای فعال و در حال سید اعمال میشود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
|
||||
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
|
||||
liveTorrentUploadLimitFailed: 'بهروزرسانی محدودیت زنده آپلود تورنت ممکن نیست: {{detail}}',
|
||||
category: 'دسته',
|
||||
lastTry: 'آخرین تلاش',
|
||||
dateAdded: 'تاریخ افزودن',
|
||||
|
||||
@@ -228,6 +228,10 @@ const he = {
|
||||
liveSpeedLimitClear: 'נקה',
|
||||
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
|
||||
liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת',
|
||||
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
|
||||
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
|
||||
liveTorrentUploadLimitFailed: 'לא ניתן לעדכן את הגבלת העלאת הטורנט בזמן אמת: {{detail}}',
|
||||
category: 'קטגוריה',
|
||||
lastTry: 'ניסיון אחרון',
|
||||
dateAdded: 'תאריך הוספה',
|
||||
|
||||
@@ -228,6 +228,10 @@ const ru = {
|
||||
liveSpeedLimitClear: 'Очистить',
|
||||
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
|
||||
liveTorrentUploadLimit: 'Текущий лимит отдачи торрента',
|
||||
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
|
||||
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
|
||||
liveTorrentUploadLimitFailed: 'Не удалось обновить текущий лимит отдачи торрента: {{detail}}',
|
||||
category: 'Категория',
|
||||
lastTry: 'Последняя попытка',
|
||||
dateAdded: 'Дата добавления',
|
||||
|
||||
@@ -228,6 +228,10 @@ const uk = {
|
||||
liveSpeedLimitClear: 'Очистити',
|
||||
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
|
||||
liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента',
|
||||
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
|
||||
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
|
||||
liveTorrentUploadLimitFailed: 'Не вдалося оновити поточний ліміт віддачі торрента: {{detail}}',
|
||||
category: 'Категорія',
|
||||
lastTry: 'Остання спроба',
|
||||
dateAdded: 'Дата додавання',
|
||||
|
||||
@@ -228,6 +228,10 @@ const zhCN = {
|
||||
liveSpeedLimitClear: '清除',
|
||||
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
|
||||
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
|
||||
liveTorrentUploadLimit: '实时种子上传限速',
|
||||
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
|
||||
liveTorrentUploadLimitPlaceholder: '例如 1024K',
|
||||
liveTorrentUploadLimitFailed: '无法更新实时种子上传限速:{{detail}}',
|
||||
category: '类别',
|
||||
lastTry: '上次尝试',
|
||||
dateAdded: '添加日期',
|
||||
|
||||
@@ -70,6 +70,7 @@ type CommandMap = {
|
||||
set_concurrent_limit: { args: { limit: number }; result: void };
|
||||
set_queue_concurrency_limits: { args: { limits: QueueConcurrencyConfig[] }; result: void };
|
||||
set_download_speed_limit: { args: { id: string; limit: string | null }; result: void };
|
||||
set_torrent_upload_limit: { args: { id: string; limit: string | null }; result: void };
|
||||
set_global_speed_limit: { args: { limit: string | null }; result: void };
|
||||
request_automation_permission: { args: undefined; result: void };
|
||||
check_automation_permission: { args: undefined; result: void };
|
||||
|
||||
@@ -396,6 +396,71 @@ describe('useDownloadStore', () => {
|
||||
expect(useDownloadStore.getState().downloads[0].speedLimit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updates an active Torrent upload limit while seeding and clears it', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'live-torrent-upload',
|
||||
status: 'seeding',
|
||||
isMedia: false,
|
||||
isTorrent: true,
|
||||
torrentUploadLimit: '512K'
|
||||
}] as any[]
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
|
||||
|
||||
await useDownloadStore.getState().setTorrentUploadLimit('live-torrent-upload', '2M');
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_torrent_upload_limit', {
|
||||
id: 'live-torrent-upload',
|
||||
limit: '2M'
|
||||
});
|
||||
expect(useDownloadStore.getState().downloads[0].torrentUploadLimit).toBe('2M');
|
||||
|
||||
await useDownloadStore.getState().setTorrentUploadLimit('live-torrent-upload', null);
|
||||
const uploadLimitCalls = vi.mocked(ipc.invokeCommand).mock.calls
|
||||
.filter(([command]) => command === 'set_torrent_upload_limit');
|
||||
expect(uploadLimitCalls[uploadLimitCalls.length - 1]).toEqual(['set_torrent_upload_limit', {
|
||||
id: 'live-torrent-upload',
|
||||
limit: null
|
||||
}]);
|
||||
expect(useDownloadStore.getState().downloads[0].torrentUploadLimit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects live Torrent upload control for ordinary or inactive downloads', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'ordinary-upload', status: 'downloading', isMedia: false, isTorrent: false },
|
||||
{ id: 'paused-upload', status: 'paused', isMedia: false, isTorrent: true }
|
||||
] as any[]
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().setTorrentUploadLimit('ordinary-upload', '2M'))
|
||||
.rejects.toThrow('only for Torrent');
|
||||
await expect(useDownloadStore.getState().setTorrentUploadLimit('paused-upload', '2M'))
|
||||
.rejects.toThrow('active Torrent');
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('set_torrent_upload_limit', expect.anything());
|
||||
});
|
||||
|
||||
it('keeps the prior Torrent upload limit when the backend rejects the update', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'live-torrent-upload-failure',
|
||||
status: 'downloading',
|
||||
isMedia: false,
|
||||
isTorrent: true,
|
||||
torrentUploadLimit: '512K'
|
||||
}] as any[]
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||
if (command === 'set_torrent_upload_limit') throw new Error('aria2 unavailable');
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().setTorrentUploadLimit('live-torrent-upload-failure', '2M'))
|
||||
.rejects.toThrow('aria2 unavailable');
|
||||
expect(useDownloadStore.getState().downloads[0].torrentUploadLimit).toBe('512K');
|
||||
});
|
||||
|
||||
it('rejects live speed changes for media and inactive downloads', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
|
||||
@@ -800,6 +800,7 @@ interface DownloadState {
|
||||
pauseAll: () => Promise<number>;
|
||||
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
|
||||
setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>;
|
||||
setTorrentUploadLimit: (id: string, limit: string | null) => Promise<void>;
|
||||
setQueueConcurrency: (id: string, maxConcurrent: number | null) => Promise<void>;
|
||||
addQueue: (name: string) => boolean;
|
||||
renameQueue: (id: string, name: string) => boolean;
|
||||
@@ -1886,6 +1887,39 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
true,
|
||||
preemptDispatch
|
||||
),
|
||||
setTorrentUploadLimit: (id, limit) => runDownloadLifecycleOperation(
|
||||
id,
|
||||
'torrent-upload-limit',
|
||||
async () => {
|
||||
await waitForPendingStartupResume();
|
||||
const item = get().downloads.find(download => download.id === id);
|
||||
if (!item) throw new Error('Download no longer exists.');
|
||||
if (!item.isTorrent) {
|
||||
throw new Error('Live upload control is available only for Torrent downloads.');
|
||||
}
|
||||
if (!['downloading', 'seeding', 'retrying'].includes(item.status)) {
|
||||
throw new Error('Live upload control requires an active Torrent.');
|
||||
}
|
||||
|
||||
const trimmed = limit?.trim() || '';
|
||||
const normalizedLimit = trimmed
|
||||
? normalizeSpeedLimitForBackend(trimmed)
|
||||
: null;
|
||||
if (trimmed && normalizedLimit === null) {
|
||||
throw new Error('Enter a valid Torrent upload limit.');
|
||||
}
|
||||
|
||||
await invoke('set_torrent_upload_limit', {
|
||||
id,
|
||||
limit: normalizedLimit
|
||||
});
|
||||
if (get().downloads.some(download => download.id === id)) {
|
||||
get().updateDownload(id, { torrentUploadLimit: normalizedLimit ?? undefined });
|
||||
}
|
||||
},
|
||||
true,
|
||||
preemptDispatch
|
||||
),
|
||||
setQueueConcurrency: (id, maxConcurrent) => {
|
||||
const operation = queueConfigurationQueue.then(async () => {
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user