diff --git a/TORRENT_FEATURES.md b/TORRENT_FEATURES.md
index eaedaa5..c96fe55 100644
--- a/TORRENT_FEATURES.md
+++ b/TORRENT_FEATURES.md
@@ -31,6 +31,9 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar
zero-download-speed interval.
- Additional per-Torrent tracker URLs through `bt-tracker`, with bounded and
credential-free HTTP/HTTPS/UDP validation.
+- Per-Torrent tracker exclusion through `bt-exclude-tracker`, including Aria2's
+ explicit `*` value for excluding all announce URLs. Exclusions are persisted,
+ normalized, reapplied on retries, and do not change DHT or PEX settings.
- Deterministic local Aria2 smoke coverage for metadata resolution, selected
output, pause/resume, ownership, cancellation/removal, unavailable trackers,
daemon failure, and `bt-stop-timeout` terminal behavior; RPC-boundary
@@ -40,10 +43,7 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar
### Tier 0 — reliability and user-visible control
-1. **Tracker exclusion** — add `bt-exclude-tracker` alongside the existing
- additional-tracker list. It must be persisted, re-normalized, and re-applied
- on every retry/GID replacement. Document that it filters announce URLs only;
- it does not disable DHT or PEX.
+No remaining Tier 0 items.
### Tier 1 — transfer policy and storage behavior
@@ -71,5 +71,5 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar
current explicit metadata path intentionally avoids unmapped child jobs.
The first implementation in this task was remote `.torrent` metadata intake;
-follow-up implementations add stall-timeout control and bounded peer
-diagnostics.
+follow-up implementations add stall-timeout control, bounded peer diagnostics,
+and persisted tracker exclusion.
diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs
index 97c0a38..31b176c 100644
--- a/src-tauri/src/db.rs
+++ b/src-tauri/src/db.rs
@@ -952,6 +952,7 @@ fn remove_persisted_transfer_secrets(value: &mut Value) {
}
sanitize_portable_torrent_trackers(object);
+ sanitize_portable_torrent_exclude_trackers(object);
if let Some(url) = object.get("url").and_then(Value::as_str) {
if let Ok(mut parsed) = url::Url::parse(url) {
@@ -1010,22 +1011,26 @@ fn remove_persisted_transfer_secrets(value: &mut Value) {
}
}
-fn sanitize_portable_torrent_trackers(object: &mut serde_json::Map) {
- let Some(raw_value) = object.get("torrentTrackers").cloned() else {
+fn sanitize_portable_torrent_tracker_field(
+ object: &mut serde_json::Map,
+ key: &str,
+ normalize: fn(Option<&str>) -> Result
+
+
+
+
+
+
diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts
index e416b8e..eb692ee 100644
--- a/src/i18n/catalogs/en.ts
+++ b/src/i18n/catalogs/en.ts
@@ -239,6 +239,9 @@ const common = {
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.',
+ torrentExcludeTrackers: 'Excluded Torrent trackers',
+ torrentExcludeTrackersHint: 'One HTTP, HTTPS, or UDP tracker per line, or * to exclude all announce URLs. Credentials are not allowed; DHT and PEX settings are unchanged.',
+ torrentExcludeTrackersInvalid: 'Excluded Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials, or *.',
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',
@@ -508,6 +511,9 @@ const common = {
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.',
+ torrentExcludeTrackers: 'Excluded Torrent trackers',
+ torrentExcludeTrackersHint: 'Saved with this Torrent and applied on its next start or retry. * excludes all announce URLs; DHT and PEX settings are unchanged.',
+ torrentExcludeTrackersInvalid: 'Excluded Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials, or *.',
torrentVerifyIntegrity: 'Verify Torrent integrity',
torrentVerifyIntegrityHint: 'Recheck piece hashes when starting or retrying; damaged pieces may be downloaded again.',
torrentMaxPeers: 'Maximum Torrent peers',
diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts
index f8358be..ffbacfe 100644
--- a/src/i18n/catalogs/fa.ts
+++ b/src/i18n/catalogs/fa.ts
@@ -239,6 +239,9 @@ const fa = {
torrentTrackers: 'Trackerهای اضافی تورنت',
torrentTrackersHint: 'هر Tracker را در یک خط بنویسید. HTTP، HTTPS یا UDP؛ اطلاعات ورود مجاز نیست.',
torrentTrackersInvalid: 'فهرست Trackerهای تورنت نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود استفاده کنید.',
+ torrentExcludeTrackers: 'Trackerهای مستثناشده تورنت',
+ torrentExcludeTrackersHint: 'در هر خط یک Tracker از نوع HTTP، HTTPS یا UDP، یا * برای حذف همه آدرسهای announce وارد کنید. اطلاعات ورود مجاز نیست؛ تنظیمات DHT و PEX تغییر نمیکند.',
+ torrentExcludeTrackersInvalid: 'فهرست Trackerهای مستثناشده نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود، یا * استفاده کنید.',
torrentVerifyIntegrity: 'بررسی صحت تورنت',
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد این تورنت اعمال میشود. ممکن است قطعهها دوباره بررسی و دادههای خراب دوباره دانلود شوند؛ در انتقال فعال قابل تغییر نیست.',
torrentMaxPeers: 'حداکثر همتاهای تورنت',
@@ -508,6 +511,9 @@ const fa = {
torrentTrackers: 'Trackerهای اضافی تورنت',
torrentTrackersHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود.',
torrentTrackersInvalid: 'فهرست Trackerهای تورنت نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود استفاده کنید.',
+ torrentExcludeTrackers: 'Trackerهای مستثناشده تورنت',
+ torrentExcludeTrackersHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود. * همه آدرسهای announce را حذف میکند؛ تنظیمات DHT و PEX تغییر نمیکند.',
+ torrentExcludeTrackersInvalid: 'فهرست Trackerهای مستثناشده نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود، یا * استفاده کنید.',
torrentVerifyIntegrity: 'بررسی صحت تورنت',
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد، هش قطعهها را بررسی میکند؛ قطعههای خراب ممکن است دوباره دانلود شوند.',
torrentMaxPeers: 'حداکثر همتاهای تورنت',
diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts
index 80e569c..0a9db5c 100644
--- a/src/i18n/catalogs/he.ts
+++ b/src/i18n/catalogs/he.ts
@@ -239,6 +239,9 @@ const he = {
torrentTrackers: 'עוקבי טורנט נוספים',
torrentTrackersHint: 'עוקב HTTP, HTTPS או UDP אחד בכל שורה. פרטי התחברות אינם מותרים.',
torrentTrackersInvalid: 'רשימת עוקבי הטורנט אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות.',
+ torrentExcludeTrackers: 'עוקבי טורנט להחרגה',
+ torrentExcludeTrackersHint: 'עוקב HTTP, HTTPS או UDP אחד בכל שורה, או * כדי להחריג את כל כתובות ההכרזה. פרטי התחברות אינם מותרים; הגדרות DHT ו-PEX לא ישתנו.',
+ torrentExcludeTrackersInvalid: 'רשימת עוקבי הטורנט להחרגה אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות, או ב-*.',
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
torrentVerifyIntegrityHint: 'מוחל כשהטורנט מתחיל או מנסה שוב. ייתכן שהחלקים ייבדקו מחדש ונתונים פגומים יורדו שוב; אי אפשר לשנות זאת בהעברה פעילה.',
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
@@ -508,6 +511,9 @@ const he = {
torrentTrackers: 'עוקבי טורנט נוספים',
torrentTrackersHint: 'נשמרים עם הטורנט ומוחלים בהפעלה או בניסיון החוזר הבא.',
torrentTrackersInvalid: 'רשימת עוקבי הטורנט אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות.',
+ torrentExcludeTrackers: 'עוקבי טורנט להחרגה',
+ torrentExcludeTrackersHint: 'נשמרים עם הטורנט ומוחלים בהפעלה או בניסיון החוזר הבא. * מחריג את כל כתובות ההכרזה; הגדרות DHT ו-PEX לא ישתנו.',
+ torrentExcludeTrackersInvalid: 'רשימת עוקבי הטורנט להחרגה אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות, או ב-*.',
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
torrentVerifyIntegrityHint: 'בדיקת גיבובי החלקים בעת התחלה או ניסיון חוזר; חלקים פגומים עשויים להיות מורדים מחדש.',
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts
index 6a886a9..7afd595 100644
--- a/src/i18n/catalogs/ru.ts
+++ b/src/i18n/catalogs/ru.ts
@@ -239,6 +239,9 @@ const ru = {
torrentTrackers: 'Дополнительные трекеры торрента',
torrentTrackersHint: 'По одному HTTP-, HTTPS- или UDP-трекеру в строке. Данные для входа не допускаются.',
torrentTrackersInvalid: 'Список трекеров торрента недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа.',
+ torrentExcludeTrackers: 'Исключаемые трекеры торрента',
+ torrentExcludeTrackersHint: 'По одному HTTP-, HTTPS- или UDP-трекеру в строке или * для исключения всех announce-адресов. Данные для входа не допускаются; настройки DHT и PEX не изменяются.',
+ torrentExcludeTrackersInvalid: 'Список исключаемых трекеров недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа либо *.',
torrentVerifyIntegrity: 'Проверять целостность торрента',
torrentVerifyIntegrityHint: 'Применяется при запуске или повторной попытке. Может повторно проверить части и скачать повреждённые данные; во время активной передачи изменить нельзя.',
torrentMaxPeers: 'Максимум пиров торрента',
@@ -508,6 +511,9 @@ const ru = {
torrentTrackers: 'Дополнительные трекеры торрента',
torrentTrackersHint: 'Сохраняется вместе с торрентом и применяется при следующем запуске или повторной попытке.',
torrentTrackersInvalid: 'Список трекеров торрента недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа.',
+ torrentExcludeTrackers: 'Исключаемые трекеры торрента',
+ torrentExcludeTrackersHint: 'Сохраняется вместе с торрентом и применяется при следующем запуске или повторной попытке. * исключает все announce-адреса; настройки DHT и PEX не изменяются.',
+ torrentExcludeTrackersInvalid: 'Список исключаемых трекеров недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа либо *.',
torrentVerifyIntegrity: 'Проверять целостность торрента',
torrentVerifyIntegrityHint: 'Проверка хешей частей при запуске или повторной попытке; повреждённые части могут быть загружены заново.',
torrentMaxPeers: 'Максимум пиров торрента',
diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts
index d738a9e..321acf7 100644
--- a/src/i18n/catalogs/uk.ts
+++ b/src/i18n/catalogs/uk.ts
@@ -239,6 +239,9 @@ const uk = {
torrentTrackers: 'Додаткові трекери торрента',
torrentTrackersHint: 'Один HTTP-, HTTPS- або UDP-трекер у рядку. Дані для входу не дозволені.',
torrentTrackersInvalid: 'Список трекерів торрента недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу.',
+ torrentExcludeTrackers: 'Виключені трекери торрента',
+ torrentExcludeTrackersHint: 'Один HTTP-, HTTPS- або UDP-трекер у рядку або * для виключення всіх announce-адрес. Дані для входу не дозволені; налаштування DHT і PEX не змінюються.',
+ torrentExcludeTrackersInvalid: 'Список виключених трекерів недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу або *.',
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
torrentVerifyIntegrityHint: 'Застосовується під час запуску або повторної спроби. Частини можуть перевірятися повторно, а пошкоджені дані — завантажуватися знову; під час активної передачі змінити не можна.',
torrentMaxPeers: 'Максимум пірів торрента',
@@ -508,6 +511,9 @@ const uk = {
torrentTrackers: 'Додаткові трекери торрента',
torrentTrackersHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби.',
torrentTrackersInvalid: 'Список трекерів торрента недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу.',
+ torrentExcludeTrackers: 'Виключені трекери торрента',
+ torrentExcludeTrackersHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби. * виключає всі announce-адреси; налаштування DHT і PEX не змінюються.',
+ torrentExcludeTrackersInvalid: 'Список виключених трекерів недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу або *.',
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
torrentVerifyIntegrityHint: 'Перевіряє хеші частин під час запуску або повторної спроби; пошкоджені частини можуть завантажуватися знову.',
torrentMaxPeers: 'Максимум пірів торрента',
diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts
index 697a080..e185426 100644
--- a/src/i18n/catalogs/zh-CN.ts
+++ b/src/i18n/catalogs/zh-CN.ts
@@ -239,6 +239,9 @@ const zhCN = {
torrentTrackers: '其他 Torrent Tracker',
torrentTrackersHint: '每行一个 HTTP、HTTPS 或 UDP Tracker。不允许填写凭据。',
torrentTrackersInvalid: 'Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址。',
+ torrentExcludeTrackers: '排除的 Torrent Tracker',
+ torrentExcludeTrackersHint: '每行一个 HTTP、HTTPS 或 UDP Tracker,或使用 * 排除所有 announce 地址。不允许填写凭据;不会更改 DHT 和 PEX 设置。',
+ torrentExcludeTrackersInvalid: '排除的 Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址,或使用 *。',
torrentVerifyIntegrity: '验证 Torrent 完整性',
torrentVerifyIntegrityHint: '在 Torrent 启动或重试时应用。可能会重新检查分片并重新下载损坏的数据;活动传输期间无法更改。',
torrentMaxPeers: 'Torrent 最大对等节点数',
@@ -508,6 +511,9 @@ const zhCN = {
torrentTrackers: '其他 Torrent Tracker',
torrentTrackersHint: '随该 Torrent 保存,并在下次启动或重试时应用。',
torrentTrackersInvalid: 'Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址。',
+ torrentExcludeTrackers: '排除的 Torrent Tracker',
+ torrentExcludeTrackersHint: '随该 Torrent 保存,并在下次启动或重试时应用。* 会排除所有 announce 地址;不会更改 DHT 和 PEX 设置。',
+ torrentExcludeTrackersInvalid: '排除的 Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址,或使用 *。',
torrentVerifyIntegrity: '验证 Torrent 完整性',
torrentVerifyIntegrityHint: '启动或重试时重新检查分片哈希;损坏的分片可能会再次下载。',
torrentMaxPeers: 'Torrent 最大对等节点数',
diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts
index 9fd6414..6d5e94a 100644
--- a/src/store/useDownloadStore.test.ts
+++ b/src/store/useDownloadStore.test.ts
@@ -855,6 +855,7 @@ describe('useDownloadStore', () => {
torrentPeerSpeedLimit: 0 as unknown as string,
torrentCheckIntegrity: 'yes' as unknown as boolean,
torrentTrackers: 123 as unknown as string,
+ torrentExcludeTrackers: 123 as unknown as string,
torrentStopTimeout: 604801
});
@@ -862,6 +863,7 @@ describe('useDownloadStore', () => {
expect(normalized.torrentPeerSpeedLimit).toBeUndefined();
expect(normalized.torrentCheckIntegrity).toBeUndefined();
expect(normalized.torrentTrackers).toBeUndefined();
+ expect(normalized.torrentExcludeTrackers).toBeUndefined();
expect(normalized.torrentStopTimeout).toBeUndefined();
});
@@ -1515,6 +1517,7 @@ describe('useDownloadStore', () => {
isTorrent: true,
torrentCheckIntegrity: true,
torrentTrackers: 'https://tracker.example/announce',
+ torrentExcludeTrackers: '*',
torrentStopTimeout: 300
}, { type: 'start-now' });
@@ -1528,6 +1531,7 @@ describe('useDownloadStore', () => {
id: 'start-1',
torrent_check_integrity: true,
torrent_trackers: 'https://tracker.example/announce',
+ torrent_exclude_trackers: '*',
torrent_stop_timeout: 300
})
})
diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts
index 4e33e9f..03831cf 100644
--- a/src/store/useDownloadStore.ts
+++ b/src/store/useDownloadStore.ts
@@ -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, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
+import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import {
resolveCategoryDestination
} from '../utils/downloadLocations';
@@ -352,6 +352,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
torrent_check_integrity: item.torrentCheckIntegrity,
torrent_trackers: item.torrentTrackers || undefined,
+ torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
torrent_stop_timeout: item.torrentStopTimeout,
lifecycle_generation: lifecycleGeneration.toString(),
};
@@ -634,9 +635,13 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
? rawCheckIntegrity
: undefined;
const rawTrackers = download.torrentTrackers as unknown;
- const normalizedTrackers = typeof rawTrackers === 'string' && rawTrackers.trim()
+ const normalizedTrackers = typeof rawTrackers === 'string' && rawTrackers.trim() && isValidTorrentTrackerList(rawTrackers)
? rawTrackers.trim()
: undefined;
+ const rawExcludeTrackers = download.torrentExcludeTrackers as unknown;
+ const normalizedExcludeTrackers = typeof rawExcludeTrackers === 'string' && rawExcludeTrackers.trim() && isValidTorrentExcludeTrackerList(rawExcludeTrackers)
+ ? rawExcludeTrackers.trim()
+ : undefined;
const rawStopTimeout = download.torrentStopTimeout as unknown;
const normalizedStopTimeout = typeof rawStopTimeout === 'number' &&
Number.isInteger(rawStopTimeout) &&
@@ -648,6 +653,7 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
rawCheckIntegrity !== normalizedCheckIntegrity ||
rawTrackers !== normalizedTrackers ||
+ rawExcludeTrackers !== normalizedExcludeTrackers ||
rawStopTimeout !== normalizedStopTimeout
? {
...download,
@@ -655,6 +661,7 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
torrentPeerSpeedLimit: normalizedPeerSpeedLimit,
torrentCheckIntegrity: normalizedCheckIntegrity,
torrentTrackers: normalizedTrackers,
+ torrentExcludeTrackers: normalizedExcludeTrackers,
torrentStopTimeout: normalizedStopTimeout
}
: download;
@@ -2188,6 +2195,7 @@ export const useDownloadStore = create((set, get) => {
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
torrent_check_integrity: item.torrentCheckIntegrity,
torrent_trackers: item.torrentTrackers || undefined,
+ torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
torrent_stop_timeout: item.torrentStopTimeout,
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
});
diff --git a/src/utils/addDownloadMetadata.ts b/src/utils/addDownloadMetadata.ts
index 819d30c..a0f2aa6 100644
--- a/src/utils/addDownloadMetadata.ts
+++ b/src/utils/addDownloadMetadata.ts
@@ -66,6 +66,7 @@ export interface AddDownloadDraftRow {
torrentPeerSpeedLimit?: string;
torrentCheckIntegrity?: boolean;
torrentTrackers?: string;
+ torrentExcludeTrackers?: string;
}
/**
diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts
index c5581ae..ee14c81 100644
--- a/src/utils/downloads.test.ts
+++ b/src/utils/downloads.test.ts
@@ -6,6 +6,7 @@ import {
downloadMediaKindsMatch,
MAX_DOWNLOAD_FILENAME_BYTES,
canonicalizeDownloadFileName,
+ isValidTorrentExcludeTrackerList,
isValidTorrentTrackerList,
redactDownloadForPersistence,
resolveDownloadConnections
@@ -66,6 +67,14 @@ describe('Torrent tracker input validation', () => {
expect(isValidTorrentTrackerList('https://tracker.example/announce,')).toBe(false);
expect(isValidTorrentTrackerList(Array.from({ length: 65 }, (_, index) => `https://tracker${index}.example/announce`).join('\n'))).toBe(false);
});
+
+ it('accepts wildcard exclusions but rejects ambiguous mixtures', () => {
+ expect(isValidTorrentExcludeTrackerList('*')).toBe(true);
+ expect(isValidTorrentExcludeTrackerList('https://tracker.example/announce\nudp://tracker.example:6969/announce')).toBe(true);
+ expect(isValidTorrentExcludeTrackerList('*,https://tracker.example/announce')).toBe(false);
+ expect(isValidTorrentExcludeTrackerList('https://tracker.example/announce,*')).toBe(false);
+ expect(isValidTorrentExcludeTrackerList('https://user:pass@tracker.example/announce')).toBe(false);
+ });
});
describe('download connection resolution', () => {
diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts
index bb4d50d..165b416 100644
--- a/src/utils/downloads.ts
+++ b/src/utils/downloads.ts
@@ -127,12 +127,13 @@ export const MAX_TORRENT_STOP_TIMEOUT = 7 * 24 * 60 * 60;
* 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 isValidTorrentTrackerListInternal = (value: string, allowWildcard: boolean): boolean => {
const raw = value.trim();
if (!raw) return true;
if (utf8ByteLength(raw) > MAX_TORRENT_TRACKER_BYTES) return false;
const normalized = new Set();
+ let wildcard = false;
let serializedBytes = 0;
for (const line of raw.split(/[\r\n]/)) {
const trimmedLine = line.trim();
@@ -142,6 +143,12 @@ export const isValidTorrentTrackerList = (value: string): boolean => {
if (!token || [...token].some(character => character.charCodeAt(0) < 0x20 || character.charCodeAt(0) === 0x7f)) {
return false;
}
+ if (allowWildcard && token === '*') {
+ if (normalized.size > 0) return false;
+ wildcard = true;
+ continue;
+ }
+ if (wildcard) return false;
let parsed: URL;
try {
parsed = new URL(token);
@@ -162,9 +169,15 @@ export const isValidTorrentTrackerList = (value: string): boolean => {
if (serializedBytes > MAX_TORRENT_TRACKER_BYTES) return false;
}
}
- return normalized.size > 0;
+ return normalized.size > 0 || wildcard;
};
+export const isValidTorrentTrackerList = (value: string): boolean =>
+ isValidTorrentTrackerListInternal(value, false);
+
+export const isValidTorrentExcludeTrackerList = (value: string): boolean =>
+ isValidTorrentTrackerListInternal(value, true);
+
export const initMediaDomains = async () => {
try {
const domains = await invoke('get_supported_media_domains');