diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts
index eb692ee..ce889b2 100644
--- a/src/i18n/catalogs/en.ts
+++ b/src/i18n/catalogs/en.ts
@@ -265,6 +265,9 @@ const common = {
torrentStopTimeout: 'Stop stalled Torrent after',
torrentStopTimeoutHint: 'Aria2 stops this Torrent after this many consecutive seconds at 0 B/s. 0 disables the policy; changes apply when the Torrent starts or retries.',
torrentStopTimeoutInvalid: 'Torrent stall timeout must be a whole number from 0 to 604800 seconds',
+ torrentPrioritizePiece: 'Prioritize Torrent pieces',
+ torrentPrioritizePieceHint: 'Optional Aria2 preview policy: head, tail, or both; each may use a size such as 1M. Changes apply when the Torrent starts or retries.',
+ torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M',
liveTorrentPeerOptionsFailed: 'Could not update live Torrent peer controls: {{detail}}',
category: 'Category',
lastTry: 'Last try',
@@ -524,6 +527,9 @@ const common = {
torrentStopTimeout: 'Stop stalled Torrent after',
torrentStopTimeoutHint: 'Aria2 stops this Torrent after this many consecutive seconds at 0 B/s. 0 disables the policy.',
torrentStopTimeoutInvalid: 'Torrent stall timeout must be a whole number from 0 to 604800 seconds',
+ torrentPrioritizePiece: 'Prioritize Torrent pieces',
+ torrentPrioritizePieceHint: 'Saved with this Torrent and applied on its next start or retry. Use head, tail, or both with optional K or M sizes.',
+ torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M',
required: 'Required',
free: 'Free',
preview: 'Preview',
diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts
index ffbacfe..802c7f1 100644
--- a/src/i18n/catalogs/fa.ts
+++ b/src/i18n/catalogs/fa.ts
@@ -265,6 +265,9 @@ const fa = {
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف میکند. ۰ این سیاست را غیرفعال میکند؛ تغییرات هنگام شروع یا تلاش مجدد اعمال میشوند.',
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
+ torrentPrioritizePiece: 'اولویتبندی قطعههای تورنت',
+ torrentPrioritizePieceHint: 'سیاست اختیاری پیشنمایش آریا۲: ابتدا، انتها یا هر دو؛ برای هرکدام میتوان اندازهای مثل 1M نوشت. تغییرات هنگام شروع یا تلاش مجدد اعمال میشوند.',
+ torrentPrioritizePieceInvalid: 'اولویت قطعههای تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
liveTorrentPeerOptionsFailed: 'کنترل زنده همتاهای تورنت بهروزرسانی نشد: {{detail}}',
category: 'دسته',
lastTry: 'آخرین تلاش',
@@ -524,6 +527,9 @@ const fa = {
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف میکند. ۰ این سیاست را غیرفعال میکند.',
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
+ torrentPrioritizePiece: 'اولویتبندی قطعههای تورنت',
+ torrentPrioritizePieceHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود. ابتدا، انتها یا هر دو را با اندازه اختیاری K یا M وارد کنید.',
+ torrentPrioritizePieceInvalid: 'اولویت قطعههای تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
required: 'الزامی',
free: 'فضای آزاد',
preview: 'پیشنمایش',
diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts
index 0a9db5c..8ee1b2f 100644
--- a/src/i18n/catalogs/he.ts
+++ b/src/i18n/catalogs/he.ts
@@ -265,6 +265,9 @@ const he = {
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות; השינוי חל כשהטורנט מתחיל או מנסה שוב.',
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
+ torrentPrioritizePiece: 'תעדוף חלקי טורנט',
+ torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית של Aria2: התחלה, סוף או שניהם; לכל אחד אפשר לציין גודל כמו 1M. השינוי חל בהפעלה או בניסיון חוזר.',
+ torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
liveTorrentPeerOptionsFailed: 'לא ניתן לעדכן את בקרות עמיתי הטורנט בזמן אמת: {{detail}}',
category: 'קטגוריה',
lastTry: 'ניסיון אחרון',
@@ -524,6 +527,9 @@ const he = {
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות.',
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
+ torrentPrioritizePiece: 'תעדוף חלקי טורנט',
+ torrentPrioritizePieceHint: 'נשמר עם הטורנט ומוחל בהפעלה או בניסיון חוזר. יש להזין התחלה, סוף או שניהם עם גודל K או M אופציונלי.',
+ torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
required: 'נדרש',
free: 'פנוי',
preview: 'תצוגה מקדימה',
diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts
index 7afd595..1dea1c6 100644
--- a/src/i18n/catalogs/ru.ts
+++ b/src/i18n/catalogs/ru.ts
@@ -265,6 +265,9 @@ const ru = {
torrentStopTimeout: 'Останавливать неактивный торрент через',
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило; изменения применяются при запуске или повторной попытке.',
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
+ torrentPrioritizePiece: 'Приоритет частей торрента',
+ torrentPrioritizePieceHint: 'Необязательная политика предпросмотра Aria2: начало, конец или оба варианта; для каждого можно указать размер, например 1M. Применяется при запуске или повторной попытке.',
+ torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
liveTorrentPeerOptionsFailed: 'Не удалось обновить текущие настройки пиров торрента: {{detail}}',
category: 'Категория',
lastTry: 'Последняя попытка',
@@ -524,6 +527,9 @@ const ru = {
torrentStopTimeout: 'Останавливать неактивный торрент через',
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило.',
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
+ torrentPrioritizePiece: 'Приоритет частей торрента',
+ torrentPrioritizePieceHint: 'Сохраняется с торрентом и применяется при следующем запуске или повторной попытке. Укажите начало, конец или оба варианта с размером K или M.',
+ torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
required: 'Требуется',
free: 'Свободно',
preview: 'Предпросмотр',
diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts
index 321acf7..cd08d93 100644
--- a/src/i18n/catalogs/uk.ts
+++ b/src/i18n/catalogs/uk.ts
@@ -265,6 +265,9 @@ const uk = {
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило; зміни застосовуються під час запуску або повторної спроби.',
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
+ torrentPrioritizePiece: 'Пріоритет частин торрента',
+ torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду Aria2: початок, кінець або обидва варіанти; для кожного можна вказати розмір, наприклад 1M. Застосовується під час запуску або повторної спроби.',
+ torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
liveTorrentPeerOptionsFailed: 'Не вдалося оновити поточні налаштування пірів торрента: {{detail}}',
category: 'Категорія',
lastTry: 'Остання спроба',
@@ -524,6 +527,9 @@ const uk = {
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило.',
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
+ torrentPrioritizePiece: 'Пріоритет частин торрента',
+ torrentPrioritizePieceHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби. Укажіть початок, кінець або обидва варіанти з розміром K чи M.',
+ torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
required: 'Обов\'язково',
free: 'Вільно',
preview: 'Попередній перегляд',
diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts
index e185426..bf1d8d8 100644
--- a/src/i18n/catalogs/zh-CN.ts
+++ b/src/i18n/catalogs/zh-CN.ts
@@ -265,6 +265,9 @@ const zhCN = {
torrentStopTimeout: '在此时间后停止无速度 Torrent',
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用;更改会在 Torrent 启动或重试时应用。',
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
+ torrentPrioritizePiece: '优先下载 Torrent 片段',
+ torrentPrioritizePieceHint: '可选的 Aria2 预览策略:开头、结尾或两者;每项可使用 1M 等大小。Torrent 启动或重试时应用。',
+ torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
liveTorrentPeerOptionsFailed: '无法更新 Torrent 实时对等节点控制:{{detail}}',
category: '类别',
lastTry: '上次尝试',
@@ -524,6 +527,9 @@ const zhCN = {
torrentStopTimeout: '在此时间后停止无速度 Torrent',
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用。',
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
+ torrentPrioritizePiece: '优先下载 Torrent 片段',
+ torrentPrioritizePieceHint: '随 Torrent 保存,并在下次启动或重试时应用。可使用开头、结尾或两者,并可选 K 或 M 大小。',
+ torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
required: '必需',
free: '可用空间',
preview: '预览',
diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts
index 6d5e94a..d0063fc 100644
--- a/src/store/useDownloadStore.test.ts
+++ b/src/store/useDownloadStore.test.ts
@@ -856,7 +856,8 @@ describe('useDownloadStore', () => {
torrentCheckIntegrity: 'yes' as unknown as boolean,
torrentTrackers: 123 as unknown as string,
torrentExcludeTrackers: 123 as unknown as string,
- torrentStopTimeout: 604801
+ torrentStopTimeout: 604801,
+ torrentPrioritizePiece: 'head=1G'
});
expect(normalized.torrentMaxPeers).toBeUndefined();
@@ -865,6 +866,7 @@ describe('useDownloadStore', () => {
expect(normalized.torrentTrackers).toBeUndefined();
expect(normalized.torrentExcludeTrackers).toBeUndefined();
expect(normalized.torrentStopTimeout).toBeUndefined();
+ expect(normalized.torrentPrioritizePiece).toBeUndefined();
});
it('normalizes proxy settings for download dispatch', async () => {
@@ -1518,7 +1520,8 @@ describe('useDownloadStore', () => {
torrentCheckIntegrity: true,
torrentTrackers: 'https://tracker.example/announce',
torrentExcludeTrackers: '*',
- torrentStopTimeout: 300
+ torrentStopTimeout: 300,
+ torrentPrioritizePiece: 'head=1M,tail=1M'
}, { type: 'start-now' });
const item = useDownloadStore.getState().downloads[0];
@@ -1532,7 +1535,8 @@ describe('useDownloadStore', () => {
torrent_check_integrity: true,
torrent_trackers: 'https://tracker.example/announce',
torrent_exclude_trackers: '*',
- torrent_stop_timeout: 300
+ torrent_stop_timeout: 300,
+ torrent_prioritize_piece: 'head=1M,tail=1M'
})
})
);
diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts
index 03831cf..c493423 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, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
+import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentPrioritizePiece, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import {
resolveCategoryDestination
} from '../utils/downloadLocations';
@@ -354,6 +354,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
torrent_trackers: item.torrentTrackers || undefined,
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
torrent_stop_timeout: item.torrentStopTimeout,
+ torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
lifecycle_generation: lifecycleGeneration.toString(),
};
@@ -649,12 +650,17 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
rawStopTimeout <= MAX_TORRENT_STOP_TIMEOUT
? rawStopTimeout
: undefined;
+ const rawPrioritizePiece = download.torrentPrioritizePiece as unknown;
+ const normalizedPrioritizePiece = typeof rawPrioritizePiece === 'string'
+ ? normalizeTorrentPrioritizePiece(rawPrioritizePiece) || undefined
+ : undefined;
const normalizedOptions = rawMaxPeers !== normalizedMaxPeers ||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
rawCheckIntegrity !== normalizedCheckIntegrity ||
rawTrackers !== normalizedTrackers ||
rawExcludeTrackers !== normalizedExcludeTrackers ||
- rawStopTimeout !== normalizedStopTimeout
+ rawStopTimeout !== normalizedStopTimeout ||
+ rawPrioritizePiece !== normalizedPrioritizePiece
? {
...download,
torrentMaxPeers: normalizedMaxPeers,
@@ -662,7 +668,8 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
torrentCheckIntegrity: normalizedCheckIntegrity,
torrentTrackers: normalizedTrackers,
torrentExcludeTrackers: normalizedExcludeTrackers,
- torrentStopTimeout: normalizedStopTimeout
+ torrentStopTimeout: normalizedStopTimeout,
+ torrentPrioritizePiece: normalizedPrioritizePiece
}
: download;
@@ -2197,6 +2204,7 @@ export const useDownloadStore = create((set, get) => {
torrent_trackers: item.torrentTrackers || undefined,
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
torrent_stop_timeout: item.torrentStopTimeout,
+ torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
});
}
diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts
index ee14c81..5c30056 100644
--- a/src/utils/downloads.test.ts
+++ b/src/utils/downloads.test.ts
@@ -8,6 +8,7 @@ import {
canonicalizeDownloadFileName,
isValidTorrentExcludeTrackerList,
isValidTorrentTrackerList,
+ normalizeTorrentPrioritizePiece,
redactDownloadForPersistence,
resolveDownloadConnections
} from './downloads';
@@ -77,6 +78,20 @@ describe('Torrent tracker input validation', () => {
});
});
+describe('Torrent piece priority validation', () => {
+ it('normalizes head and tail preview policies', () => {
+ expect(normalizeTorrentPrioritizePiece(' tail = 64k, HEAD ')).toBe('head,tail=64K');
+ expect(normalizeTorrentPrioritizePiece('head=1m,tail=1024M')).toBe('head=1M,tail=1024M');
+ expect(normalizeTorrentPrioritizePiece('')).toBeNull();
+ });
+
+ it('rejects duplicate, unsupported, malformed, and oversized policies', () => {
+ for (const value of ['head,head', 'middle', 'head=0K', 'tail=1G', 'head=1K,', 'head=1025M']) {
+ expect(normalizeTorrentPrioritizePiece(value)).toBeNull();
+ }
+ });
+});
+
describe('download connection resolution', () => {
it('uses a clamped fallback for legacy rows without a saved value', () => {
expect(resolveDownloadConnections(undefined, 8)).toBe(8);
diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts
index 165b416..98cdadd 100644
--- a/src/utils/downloads.ts
+++ b/src/utils/downloads.ts
@@ -121,6 +121,59 @@ export const normalizeSpeedLimitForBackend = (value?: string | null): string | n
const MAX_TORRENT_TRACKERS = 64;
const MAX_TORRENT_TRACKER_BYTES = 16 * 1024;
export const MAX_TORRENT_STOP_TIMEOUT = 7 * 24 * 60 * 60;
+const MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB = 1024;
+
+const normalizeTorrentPiecePrioritySize = (value: string): string | null => {
+ const match = value.trim().match(/^(\d+)\s*([km])$/i);
+ if (!match) return null;
+ const amount = Number(match[1]);
+ const unit = match[2].toUpperCase();
+ const maximum = unit === 'M'
+ ? MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB
+ : MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB * 1024;
+ if (!Number.isSafeInteger(amount) || amount <= 0 || amount > maximum) return null;
+ return `${amount}${unit}`;
+};
+
+/**
+ * Normalize the constrained subset of Aria2's bt-prioritize-piece syntax
+ * that Firelink persists. The native validator remains authoritative because
+ * persisted state and older clients can bypass this helper.
+ */
+export const normalizeTorrentPrioritizePiece = (value?: string | null): string | null => {
+ const raw = value?.trim();
+ if (!raw) return null;
+ if (utf8ByteLength(raw) > 64) return null;
+
+ let head: string | undefined;
+ let tail: string | undefined;
+ for (const rawToken of raw.split(',')) {
+ const token = rawToken.trim();
+ if (!token) return null;
+ const separator = token.indexOf('=');
+ const keyword = (separator === -1 ? token : token.slice(0, separator)).trim().toLowerCase();
+ const size = separator === -1 ? undefined : token.slice(separator + 1);
+ if (!['head', 'tail'].includes(keyword) || (separator !== -1 && token.indexOf('=', separator + 1) !== -1)) {
+ return null;
+ }
+ const normalized = size === undefined
+ ? keyword
+ : (() => {
+ const normalizedSize = normalizeTorrentPiecePrioritySize(size);
+ return normalizedSize ? `${keyword}=${normalizedSize}` : null;
+ })();
+ if (!normalized) return null;
+ if (keyword === 'head') {
+ if (head) return null;
+ head = normalized;
+ } else {
+ if (tail) return null;
+ tail = normalized;
+ }
+ }
+
+ return [head, tail].filter((part): part is string => Boolean(part)).join(',') || null;
+};
/**
* Performs the same user-facing safety checks as the native tracker boundary.