mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-02 23:50:00 +00:00
feat(torrents): add tracker controls
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, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, };
|
||||
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, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: 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, 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, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, 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, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, lifecycle_generation?: string, };
|
||||
|
||||
@@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
||||
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, normalizeSpeedLimitForBackend } from '../utils/downloads';
|
||||
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentTrackerList, normalizeSpeedLimitForBackend } from '../utils/downloads';
|
||||
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
|
||||
import {
|
||||
expandTilde,
|
||||
@@ -233,6 +233,7 @@ export const AddDownloadsModal = () => {
|
||||
const [torrentMaxPeers, setTorrentMaxPeers] = useState('');
|
||||
const [torrentPeerSpeedLimit, setTorrentPeerSpeedLimit] = useState('');
|
||||
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
|
||||
const [torrentTrackers, setTorrentTrackers] = useState('');
|
||||
const [freeSpace, setFreeSpace] = useState('Unknown');
|
||||
const freeSpaceRequestRef = useRef(0);
|
||||
|
||||
@@ -374,6 +375,7 @@ export const AddDownloadsModal = () => {
|
||||
setTorrentMaxPeers('');
|
||||
setTorrentPeerSpeedLimit('');
|
||||
setTorrentCheckIntegrity(false);
|
||||
setTorrentTrackers('');
|
||||
setUseAuth(false);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
@@ -972,6 +974,10 @@ export const AddDownloadsModal = () => {
|
||||
addToast({ message: t($ => $.addDownloads.torrentPeerSpeedLimitInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (hasSelectedTorrent && !isValidTorrentTrackerList(torrentTrackers)) {
|
||||
addToast({ message: t($ => $.addDownloads.torrentTrackersInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (saveInDedicatedFolder && !sanitizeBatchFolderName(dedicatedFolderName)) {
|
||||
addToast({
|
||||
message: t($ => $.addDownloads.dedicatedFolderNameRequired),
|
||||
@@ -1451,6 +1457,7 @@ export const AddDownloadsModal = () => {
|
||||
? normalizeSpeedLimitForBackend(torrentPeerSpeedLimit) || undefined
|
||||
: undefined,
|
||||
torrentCheckIntegrity: item.isTorrent ? torrentCheckIntegrity : undefined,
|
||||
torrentTrackers: item.isTorrent ? torrentTrackers.trim() || undefined : undefined,
|
||||
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
|
||||
sizeBytes: item.sizeBytes
|
||||
}, action);
|
||||
@@ -2111,6 +2118,23 @@ export const AddDownloadsModal = () => {
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="pt-2 border-t border-border-modal/50">
|
||||
<label htmlFor="torrent-trackers" className="block text-text-muted">
|
||||
{t($ => $.addDownloads.torrentTrackers)}
|
||||
</label>
|
||||
<textarea
|
||||
id="torrent-trackers"
|
||||
rows={3}
|
||||
value={torrentTrackers}
|
||||
onChange={event => setTorrentTrackers(event.currentTarget.value)}
|
||||
placeholder="https://tracker.example/announce"
|
||||
aria-describedby="torrent-trackers-hint"
|
||||
className="app-control mt-1 min-h-20 w-full resize-y px-2.5 py-1.5 text-xs font-mono"
|
||||
/>
|
||||
<p id="torrent-trackers-hint" className="mt-1 text-[10px] text-text-muted">
|
||||
{t($ => $.addDownloads.torrentTrackersHint)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2 items-center pt-2 border-t border-border-modal/50">
|
||||
<label htmlFor="torrent-max-peers" className="text-text-muted">
|
||||
{t($ => $.addDownloads.torrentMaxPeers)}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
formatDownloadTotal,
|
||||
resolveDownloadSizeDisplay
|
||||
} from '../utils/downloadProgress';
|
||||
import { normalizeSpeedLimitForBackend, resolveDownloadConnections } from '../utils/downloads';
|
||||
import { isValidTorrentTrackerList, normalizeSpeedLimitForBackend, resolveDownloadConnections } from '../utils/downloads';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
|
||||
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
|
||||
@@ -78,6 +78,7 @@ export const PropertiesModal = () => {
|
||||
const [liveTorrentMaxPeersValue, setLiveTorrentMaxPeersValue] = useState('');
|
||||
const [liveTorrentPeerSpeedLimitValue, setLiveTorrentPeerSpeedLimitValue] = useState('');
|
||||
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
|
||||
const [torrentTrackers, setTorrentTrackers] = useState('');
|
||||
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
|
||||
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
|
||||
const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false);
|
||||
@@ -170,6 +171,7 @@ export const PropertiesModal = () => {
|
||||
);
|
||||
setLiveTorrentPeerSpeedLimitValue(activeItem.torrentPeerSpeedLimit || '');
|
||||
setTorrentCheckIntegrity(activeItem.torrentCheckIntegrity === true);
|
||||
setTorrentTrackers(activeItem.torrentTrackers || '');
|
||||
setErrorMessage('');
|
||||
} else {
|
||||
setSelectedPropertiesDownloadId(null);
|
||||
@@ -265,6 +267,10 @@ export const PropertiesModal = () => {
|
||||
setErrorMessage(t($ => $.properties.torrentPeerSpeedLimitInvalid));
|
||||
return;
|
||||
}
|
||||
if (item.isTorrent && !isValidTorrentTrackerList(torrentTrackers)) {
|
||||
setErrorMessage(t($ => $.properties.torrentTrackersInvalid));
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Partial<DownloadItem> = {
|
||||
url,
|
||||
@@ -282,6 +288,7 @@ export const PropertiesModal = () => {
|
||||
torrentMaxPeers: normalizedMaxPeers,
|
||||
torrentPeerSpeedLimit: normalizedPeerSpeedLimit || undefined,
|
||||
torrentCheckIntegrity,
|
||||
torrentTrackers: torrentTrackers.trim() || undefined,
|
||||
}
|
||||
: {}),
|
||||
...(connectionsDirty
|
||||
@@ -704,6 +711,24 @@ export const PropertiesModal = () => {
|
||||
<div className="col-start-2 text-[11px] text-text-muted">
|
||||
{t($ => $.properties.torrentPeerOptionsSavedHint)}
|
||||
</div>
|
||||
<label className="text-xs text-text-muted text-right" htmlFor="torrent-trackers-properties">
|
||||
{t($ => $.properties.torrentTrackers)}
|
||||
</label>
|
||||
<div>
|
||||
<textarea
|
||||
id="torrent-trackers-properties"
|
||||
rows={3}
|
||||
value={torrentTrackers}
|
||||
onChange={event => setTorrentTrackers(event.currentTarget.value)}
|
||||
placeholder="https://tracker.example/announce"
|
||||
disabled={transferLocked}
|
||||
aria-describedby="torrent-trackers-properties-hint"
|
||||
className="app-control min-h-20 w-full resize-y px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
|
||||
/>
|
||||
<p id="torrent-trackers-properties-hint" className="mt-1 text-[11px] text-text-muted">
|
||||
{t($ => $.properties.torrentTrackersHint)}
|
||||
</p>
|
||||
</div>
|
||||
<label className="text-xs text-text-muted text-right" htmlFor="torrent-check-integrity">
|
||||
{t($ => $.properties.torrentVerifyIntegrity)}
|
||||
</label>
|
||||
|
||||
@@ -236,6 +236,9 @@ const common = {
|
||||
liveTorrentPeerOptionsApply: 'Apply peer controls',
|
||||
liveTorrentPeerOptionsHint: 'Changes apply without replacing the active Torrent. Leave blank to use Aria2 defaults.',
|
||||
torrentPeerOptionsSavedHint: 'Saved per Torrent. 0 peers means unlimited; blank uses Aria2 defaults.',
|
||||
torrentTrackers: 'Additional Torrent trackers',
|
||||
torrentTrackersHint: 'One HTTP, HTTPS, or UDP tracker per line. Optional comma-separated entries are also accepted; credentials are not allowed.',
|
||||
torrentTrackersInvalid: 'Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials.',
|
||||
torrentVerifyIntegrity: 'Verify Torrent integrity',
|
||||
torrentVerifyIntegrityHint: 'Applied when this Torrent starts or retries. It may recheck pieces and download damaged data; active transfers cannot change it.',
|
||||
torrentMaxPeers: 'Maximum Torrent peers',
|
||||
@@ -484,6 +487,9 @@ const common = {
|
||||
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',
|
||||
torrentTrackers: 'Additional Torrent trackers',
|
||||
torrentTrackersHint: 'Saved with this Torrent and applied on its next start or retry.',
|
||||
torrentTrackersInvalid: 'Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials.',
|
||||
torrentVerifyIntegrity: 'Verify Torrent integrity',
|
||||
torrentVerifyIntegrityHint: 'Recheck piece hashes when starting or retrying; damaged pieces may be downloaded again.',
|
||||
torrentMaxPeers: 'Maximum Torrent peers',
|
||||
|
||||
@@ -236,6 +236,9 @@ const fa = {
|
||||
liveTorrentPeerOptionsApply: 'اعمال کنترل همتا',
|
||||
liveTorrentPeerOptionsHint: 'بدون جایگزینی تورنت فعال اعمال میشود. برای استفاده از پیشفرض آریا۲ خالی بگذارید.',
|
||||
torrentPeerOptionsSavedHint: 'برای هر تورنت ذخیره میشود. صفر یعنی نامحدود؛ خالی یعنی پیشفرض آریا۲.',
|
||||
torrentTrackers: 'Trackerهای اضافی تورنت',
|
||||
torrentTrackersHint: 'هر Tracker را در یک خط بنویسید. HTTP، HTTPS یا UDP؛ اطلاعات ورود مجاز نیست.',
|
||||
torrentTrackersInvalid: 'فهرست Trackerهای تورنت نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود استفاده کنید.',
|
||||
torrentVerifyIntegrity: 'بررسی صحت تورنت',
|
||||
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد این تورنت اعمال میشود. ممکن است قطعهها دوباره بررسی و دادههای خراب دوباره دانلود شوند؛ در انتقال فعال قابل تغییر نیست.',
|
||||
torrentMaxPeers: 'حداکثر همتاهای تورنت',
|
||||
@@ -484,6 +487,9 @@ const fa = {
|
||||
torrentSeedTimeInvalid: 'مدت سید تورنت باید بیشتر از صفر باشد',
|
||||
torrentSeedRatioInvalid: 'نسبت سید تورنت نمیتواند منفی باشد',
|
||||
torrentUploadLimitInvalid: 'محدودیت آپلود تورنت باید بیشتر از صفر باشد',
|
||||
torrentTrackers: 'Trackerهای اضافی تورنت',
|
||||
torrentTrackersHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود.',
|
||||
torrentTrackersInvalid: 'فهرست Trackerهای تورنت نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود استفاده کنید.',
|
||||
torrentVerifyIntegrity: 'بررسی صحت تورنت',
|
||||
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد، هش قطعهها را بررسی میکند؛ قطعههای خراب ممکن است دوباره دانلود شوند.',
|
||||
torrentMaxPeers: 'حداکثر همتاهای تورنت',
|
||||
|
||||
@@ -236,6 +236,9 @@ const he = {
|
||||
liveTorrentPeerOptionsApply: 'החל בקרות עמיתים',
|
||||
liveTorrentPeerOptionsHint: 'השינוי חל בלי להחליף את הטורנט הפעיל. השאר ריק כדי להשתמש בברירות המחדל של Aria2.',
|
||||
torrentPeerOptionsSavedHint: 'נשמר לכל טורנט. אפס עמיתים פירושו ללא הגבלה; ריק משתמש בברירות המחדל של Aria2.',
|
||||
torrentTrackers: 'עוקבי טורנט נוספים',
|
||||
torrentTrackersHint: 'עוקב HTTP, HTTPS או UDP אחד בכל שורה. פרטי התחברות אינם מותרים.',
|
||||
torrentTrackersInvalid: 'רשימת עוקבי הטורנט אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות.',
|
||||
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
|
||||
torrentVerifyIntegrityHint: 'מוחל כשהטורנט מתחיל או מנסה שוב. ייתכן שהחלקים ייבדקו מחדש ונתונים פגומים יורדו שוב; אי אפשר לשנות זאת בהעברה פעילה.',
|
||||
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
|
||||
@@ -484,6 +487,9 @@ const he = {
|
||||
torrentSeedTimeInvalid: 'זמן שיתוף הטורנט חייב להיות גדול מאפס',
|
||||
torrentSeedRatioInvalid: 'יחס שיתוף הטורנט חייב להיות אפס או יותר',
|
||||
torrentUploadLimitInvalid: 'מגבלת העלאת הטורנט חייבת להיות גדולה מאפס',
|
||||
torrentTrackers: 'עוקבי טורנט נוספים',
|
||||
torrentTrackersHint: 'נשמרים עם הטורנט ומוחלים בהפעלה או בניסיון החוזר הבא.',
|
||||
torrentTrackersInvalid: 'רשימת עוקבי הטורנט אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות.',
|
||||
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
|
||||
torrentVerifyIntegrityHint: 'בדיקת גיבובי החלקים בעת התחלה או ניסיון חוזר; חלקים פגומים עשויים להיות מורדים מחדש.',
|
||||
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
|
||||
|
||||
@@ -236,6 +236,9 @@ const ru = {
|
||||
liveTorrentPeerOptionsApply: 'Применить настройки пиров',
|
||||
liveTorrentPeerOptionsHint: 'Применяется без замены активного торрента. Оставьте пустым для параметров Aria2 по умолчанию.',
|
||||
torrentPeerOptionsSavedHint: 'Сохраняется для этого торрента. 0 пиров означает без ограничений; пустое поле использует настройки Aria2 по умолчанию.',
|
||||
torrentTrackers: 'Дополнительные трекеры торрента',
|
||||
torrentTrackersHint: 'По одному HTTP-, HTTPS- или UDP-трекеру в строке. Данные для входа не допускаются.',
|
||||
torrentTrackersInvalid: 'Список трекеров торрента недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа.',
|
||||
torrentVerifyIntegrity: 'Проверять целостность торрента',
|
||||
torrentVerifyIntegrityHint: 'Применяется при запуске или повторной попытке. Может повторно проверить части и скачать повреждённые данные; во время активной передачи изменить нельзя.',
|
||||
torrentMaxPeers: 'Максимум пиров торрента',
|
||||
@@ -484,6 +487,9 @@ const ru = {
|
||||
torrentSeedTimeInvalid: 'Время раздачи торрента должно быть больше нуля',
|
||||
torrentSeedRatioInvalid: 'Коэффициент раздачи торрента не может быть отрицательным',
|
||||
torrentUploadLimitInvalid: 'Лимит отдачи торрента должен быть больше нуля',
|
||||
torrentTrackers: 'Дополнительные трекеры торрента',
|
||||
torrentTrackersHint: 'Сохраняется вместе с торрентом и применяется при следующем запуске или повторной попытке.',
|
||||
torrentTrackersInvalid: 'Список трекеров торрента недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа.',
|
||||
torrentVerifyIntegrity: 'Проверять целостность торрента',
|
||||
torrentVerifyIntegrityHint: 'Проверка хешей частей при запуске или повторной попытке; повреждённые части могут быть загружены заново.',
|
||||
torrentMaxPeers: 'Максимум пиров торрента',
|
||||
|
||||
@@ -236,6 +236,9 @@ const uk = {
|
||||
liveTorrentPeerOptionsApply: 'Застосувати налаштування пірів',
|
||||
liveTorrentPeerOptionsHint: 'Застосовується без заміни активного торрента. Залиште порожнім для стандартних параметрів Aria2.',
|
||||
torrentPeerOptionsSavedHint: 'Зберігається для цього торрента. 0 пірів означає без обмежень; порожнє поле використовує стандартні параметри Aria2.',
|
||||
torrentTrackers: 'Додаткові трекери торрента',
|
||||
torrentTrackersHint: 'Один HTTP-, HTTPS- або UDP-трекер у рядку. Дані для входу не дозволені.',
|
||||
torrentTrackersInvalid: 'Список трекерів торрента недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу.',
|
||||
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
|
||||
torrentVerifyIntegrityHint: 'Застосовується під час запуску або повторної спроби. Частини можуть перевірятися повторно, а пошкоджені дані — завантажуватися знову; під час активної передачі змінити не можна.',
|
||||
torrentMaxPeers: 'Максимум пірів торрента',
|
||||
@@ -484,6 +487,9 @@ const uk = {
|
||||
torrentSeedTimeInvalid: 'Час роздачі торрента має бути більшим за нуль',
|
||||
torrentSeedRatioInvalid: 'Коефіцієнт роздачі торрента не може бути від’ємним',
|
||||
torrentUploadLimitInvalid: 'Ліміт віддачі торрента має бути більшим за нуль',
|
||||
torrentTrackers: 'Додаткові трекери торрента',
|
||||
torrentTrackersHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби.',
|
||||
torrentTrackersInvalid: 'Список трекерів торрента недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу.',
|
||||
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
|
||||
torrentVerifyIntegrityHint: 'Перевіряє хеші частин під час запуску або повторної спроби; пошкоджені частини можуть завантажуватися знову.',
|
||||
torrentMaxPeers: 'Максимум пірів торрента',
|
||||
|
||||
@@ -236,6 +236,9 @@ const zhCN = {
|
||||
liveTorrentPeerOptionsApply: '应用节点控制',
|
||||
liveTorrentPeerOptionsHint: '无需替换活动 Torrent 即可应用。留空以使用 Aria2 默认值。',
|
||||
torrentPeerOptionsSavedHint: '按 Torrent 保存。0 个节点表示不限制;留空使用 Aria2 默认值。',
|
||||
torrentTrackers: '其他 Torrent Tracker',
|
||||
torrentTrackersHint: '每行一个 HTTP、HTTPS 或 UDP Tracker。不允许填写凭据。',
|
||||
torrentTrackersInvalid: 'Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址。',
|
||||
torrentVerifyIntegrity: '验证 Torrent 完整性',
|
||||
torrentVerifyIntegrityHint: '在 Torrent 启动或重试时应用。可能会重新检查分片并重新下载损坏的数据;活动传输期间无法更改。',
|
||||
torrentMaxPeers: 'Torrent 最大对等节点数',
|
||||
@@ -484,6 +487,9 @@ const zhCN = {
|
||||
torrentSeedTimeInvalid: '做种时间必须大于零',
|
||||
torrentSeedRatioInvalid: '做种比率不能小于零',
|
||||
torrentUploadLimitInvalid: '种子上传限速必须大于零',
|
||||
torrentTrackers: '其他 Torrent Tracker',
|
||||
torrentTrackersHint: '随该 Torrent 保存,并在下次启动或重试时应用。',
|
||||
torrentTrackersInvalid: 'Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址。',
|
||||
torrentVerifyIntegrity: '验证 Torrent 完整性',
|
||||
torrentVerifyIntegrityHint: '启动或重试时重新检查分片哈希;损坏的分片可能会再次下载。',
|
||||
torrentMaxPeers: 'Torrent 最大对等节点数',
|
||||
|
||||
@@ -853,12 +853,14 @@ describe('useDownloadStore', () => {
|
||||
isTorrent: true,
|
||||
torrentMaxPeers: 'not-a-number' as unknown as number,
|
||||
torrentPeerSpeedLimit: 0 as unknown as string,
|
||||
torrentCheckIntegrity: 'yes' as unknown as boolean
|
||||
torrentCheckIntegrity: 'yes' as unknown as boolean,
|
||||
torrentTrackers: 123 as unknown as string
|
||||
});
|
||||
|
||||
expect(normalized.torrentMaxPeers).toBeUndefined();
|
||||
expect(normalized.torrentPeerSpeedLimit).toBeUndefined();
|
||||
expect(normalized.torrentCheckIntegrity).toBeUndefined();
|
||||
expect(normalized.torrentTrackers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizes proxy settings for download dispatch', async () => {
|
||||
@@ -1509,7 +1511,8 @@ describe('useDownloadStore', () => {
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
isTorrent: true,
|
||||
torrentCheckIntegrity: true
|
||||
torrentCheckIntegrity: true,
|
||||
torrentTrackers: 'https://tracker.example/announce'
|
||||
}, { type: 'start-now' });
|
||||
|
||||
const item = useDownloadStore.getState().downloads[0];
|
||||
@@ -1520,7 +1523,8 @@ describe('useDownloadStore', () => {
|
||||
expect.objectContaining({
|
||||
item: expect.objectContaining({
|
||||
id: 'start-1',
|
||||
torrent_check_integrity: true
|
||||
torrent_check_integrity: true,
|
||||
torrent_trackers: 'https://tracker.example/announce'
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
@@ -351,6 +351,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
||||
torrent_max_peers: item.torrentMaxPeers,
|
||||
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
|
||||
torrent_check_integrity: item.torrentCheckIntegrity,
|
||||
torrent_trackers: item.torrentTrackers || undefined,
|
||||
lifecycle_generation: lifecycleGeneration.toString(),
|
||||
};
|
||||
|
||||
@@ -631,14 +632,20 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
const normalizedCheckIntegrity = typeof rawCheckIntegrity === 'boolean'
|
||||
? rawCheckIntegrity
|
||||
: undefined;
|
||||
const rawTrackers = download.torrentTrackers as unknown;
|
||||
const normalizedTrackers = typeof rawTrackers === 'string' && rawTrackers.trim()
|
||||
? rawTrackers.trim()
|
||||
: undefined;
|
||||
const normalizedOptions = rawMaxPeers !== normalizedMaxPeers ||
|
||||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
|
||||
rawCheckIntegrity !== normalizedCheckIntegrity
|
||||
rawCheckIntegrity !== normalizedCheckIntegrity ||
|
||||
rawTrackers !== normalizedTrackers
|
||||
? {
|
||||
...download,
|
||||
torrentMaxPeers: normalizedMaxPeers,
|
||||
torrentPeerSpeedLimit: normalizedPeerSpeedLimit,
|
||||
torrentCheckIntegrity: normalizedCheckIntegrity
|
||||
torrentCheckIntegrity: normalizedCheckIntegrity,
|
||||
torrentTrackers: normalizedTrackers
|
||||
}
|
||||
: download;
|
||||
|
||||
@@ -2170,6 +2177,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
torrent_max_peers: item.torrentMaxPeers,
|
||||
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
|
||||
torrent_check_integrity: item.torrentCheckIntegrity,
|
||||
torrent_trackers: item.torrentTrackers || undefined,
|
||||
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface AddDownloadDraftRow {
|
||||
torrentMaxPeers?: number;
|
||||
torrentPeerSpeedLimit?: string;
|
||||
torrentCheckIntegrity?: boolean;
|
||||
torrentTrackers?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
downloadMediaKindsMatch,
|
||||
MAX_DOWNLOAD_FILENAME_BYTES,
|
||||
canonicalizeDownloadFileName,
|
||||
isValidTorrentTrackerList,
|
||||
redactDownloadForPersistence,
|
||||
resolveDownloadConnections
|
||||
} from './downloads';
|
||||
@@ -51,6 +52,22 @@ describe('download persistence progress snapshots', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('Torrent tracker input validation', () => {
|
||||
it('accepts supported trackers separated by lines or commas', () => {
|
||||
expect(isValidTorrentTrackerList(
|
||||
' https://tracker.example/announce\nudp://tracker.example:6969/announce '
|
||||
)).toBe(true);
|
||||
expect(isValidTorrentTrackerList('https://tracker.example/announce,https://tracker.example/announce')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unsupported, credential-bearing, empty, and oversized entries', () => {
|
||||
expect(isValidTorrentTrackerList('ftp://tracker.example/announce')).toBe(false);
|
||||
expect(isValidTorrentTrackerList('https://user:pass@tracker.example/announce')).toBe(false);
|
||||
expect(isValidTorrentTrackerList('https://tracker.example/announce,')).toBe(false);
|
||||
expect(isValidTorrentTrackerList(Array.from({ length: 65 }, (_, index) => `https://tracker${index}.example/announce`).join('\n'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('download connection resolution', () => {
|
||||
it('uses a clamped fallback for legacy rows without a saved value', () => {
|
||||
expect(resolveDownloadConnections(undefined, 8)).toBe(8);
|
||||
|
||||
@@ -118,6 +118,52 @@ export const normalizeSpeedLimitForBackend = (value?: string | null): string | n
|
||||
return unit ? `${amount}${unit}` : `${amount}K`;
|
||||
};
|
||||
|
||||
const MAX_TORRENT_TRACKERS = 64;
|
||||
const MAX_TORRENT_TRACKER_BYTES = 16 * 1024;
|
||||
|
||||
/**
|
||||
* Performs the same user-facing safety checks as the native tracker boundary.
|
||||
* The Rust validator remains authoritative because persisted data can bypass
|
||||
* this helper and the browser URL parser is not the native URL parser.
|
||||
*/
|
||||
export const isValidTorrentTrackerList = (value: string): boolean => {
|
||||
const raw = value.trim();
|
||||
if (!raw) return true;
|
||||
if (utf8ByteLength(raw) > MAX_TORRENT_TRACKER_BYTES) return false;
|
||||
|
||||
const normalized = new Set<string>();
|
||||
let serializedBytes = 0;
|
||||
for (const line of raw.split(/[\r\n]/)) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) continue;
|
||||
for (const part of trimmedLine.split(',')) {
|
||||
const token = part.trim();
|
||||
if (!token || [...token].some(character => character.charCodeAt(0) < 0x20 || character.charCodeAt(0) === 0x7f)) {
|
||||
return false;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(token);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!['http:', 'https:', 'udp:'].includes(parsed.protocol) || !parsed.hostname) {
|
||||
return false;
|
||||
}
|
||||
if (parsed.username || parsed.password || parsed.hash) {
|
||||
return false;
|
||||
}
|
||||
const canonical = parsed.toString();
|
||||
if (normalized.has(canonical)) continue;
|
||||
normalized.add(canonical);
|
||||
if (normalized.size > MAX_TORRENT_TRACKERS) return false;
|
||||
serializedBytes += utf8ByteLength(canonical) + (normalized.size > 1 ? 1 : 0);
|
||||
if (serializedBytes > MAX_TORRENT_TRACKER_BYTES) return false;
|
||||
}
|
||||
}
|
||||
return normalized.size > 0;
|
||||
};
|
||||
|
||||
export const initMediaDomains = async () => {
|
||||
try {
|
||||
const domains = await invoke('get_supported_media_domains');
|
||||
|
||||
Reference in New Issue
Block a user