Compare commits

...

29 Commits

Author SHA1 Message Date
NimBold 2d427b96d3 feat(torrents): add global open-file limit 2026-08-02 10:50:30 +03:30
NimBold 48b4d984a2 feat(torrents): add tracker timing controls 2026-08-02 10:24:59 +03:30
NimBold 1d27b5b0bf feat(torrents): add encryption policy 2026-08-02 10:05:39 +03:30
NimBold b4da68655a feat(torrents): safely remove unselected files 2026-08-02 09:49:57 +03:30
NimBold 67023d3f0d feat(torrents): support piece priority 2026-08-02 09:22:15 +03:30
NimBold a46a64994d feat(torrents): support tracker exclusion 2026-08-02 01:19:18 +03:30
NimBold ab9f0507d0 feat(torrents): add peer diagnostics 2026-08-02 01:04:04 +03:30
NimBold 2474d2c1cb feat(torrents): add stall timeout control 2026-08-02 00:48:19 +03:30
NimBold d0541308c7 feat(torrents): support remote torrent metadata 2026-08-02 00:29:49 +03:30
NimBold 0f1f4e8003 feat(torrents): add tracker controls 2026-08-01 23:53:07 +03:30
NimBold e31a3fcc90 feat(torrents): add integrity verification policy 2026-08-01 22:53:37 +03:30
NimBold 87d682709e fix(torrents): preserve metadata retention on malformed records 2026-08-01 22:36:37 +03:30
NimBold ab6c3c372e feat(torrents): add live peer controls 2026-08-01 22:27:32 +03:30
NimBold 52082c1e1d feat(torrents): add peer discovery controls 2026-08-01 21:58:09 +03:30
NimBold 9247c5cf9c feat(torrents): add live upload limit control 2026-08-01 21:41:04 +03:30
NimBold dea6ad1974 feat(torrents): add seeding lifecycle and upload controls 2026-08-01 21:27:49 +03:30
NimBold bb64c4cd52 fix(ci): use Windows-compatible Torrent RPC integration test 2026-08-01 20:09:57 +03:30
NimBold 92092e9844 test(torrents): harden native smoke validation 2026-08-01 19:55:42 +03:30
NimBold 1a492fc4c8 fix(torrents): harden RPC probe shutdown and retry classification 2026-08-01 19:12:15 +03:30
NimBold a00d34edc8 feat(torrents): add HTTP-boundary Torrent probe harness and test script 2026-08-01 18:50:25 +03:30
NimBold 18b51ca86e feat(torrents): harden magnet metadata probe lifecycle 2026-07-31 22:07:50 +03:30
NimBold 1bf8b675e4 feat(torrents): add functions to remove orphaned probe directories and cached torrents 2026-07-31 21:34:32 +03:30
NimBold ebd11b26f6 chore: stop tracking local agent guidance 2026-07-31 21:20:28 +03:30
NimBold ae5199132d docs: document macOS dev app validation 2026-07-31 21:15:59 +03:30
NimBold 6336ff6f5d fix(torrents): harden smoke harness teardown 2026-07-31 21:06:18 +03:30
NimBold a8bd80d557 test(torrents): harden runtime smoke harness 2026-07-31 20:30:57 +03:30
NimBold 186e189277 feat(torrents): resolve magnet metadata before enqueue 2026-07-31 00:06:07 +03:30
NimBold 2c1bd9cdf1 fix(torrents): close late Aria2 cleanup races 2026-07-30 23:30:51 +03:30
NimBold 292447d417 feat(torrents): harden Aria2 torrent downloads 2026-07-30 22:51:46 +03:30
56 changed files with 11330 additions and 178 deletions
+2
View File
@@ -78,3 +78,5 @@ jobs:
run: | run: |
node scripts/stage-engines.js --target ${{ matrix.target }} node scripts/stage-engines.js --target ${{ matrix.target }}
node scripts/verify-binaries.js --staged --target ${{ matrix.target }} node scripts/verify-binaries.js --staged --target ${{ matrix.target }}
- name: Run Torrent process smoke
run: node scripts/smoke-torrent.js --binary src-tauri/engine-dist/${{ matrix.target }}/aria2c-${{ matrix.target }}${{ runner.os == 'Windows' && '.exe' || '' }} --failure-paths
+92
View File
@@ -0,0 +1,92 @@
# Firelink Torrent feature matrix
This is the current product-facing comparison for BitTorrent features exposed
by Firelink's bundled Aria2 engine. It intentionally excludes Aria2's generic
HTTP/FTP/Metalink options, shell hooks, and daemon-admin RPC methods that do not
belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://aria2.github.io/manual/en/html/aria2c.html).
## Implemented
- Local `.torrent` files, magnet links, and remote HTTP(S) `.torrent` metadata.
Remote metadata is bounded, SSRF-checked, redirect-checked, parsed, and
cached before it enters the normal `addTorrent` path.
- Bencode validation, canonical info-hash checks, safe output paths, managed
metadata retention, selected-file preview, `select-file`, and `index-out`.
- Firelink queue admission, per-queue/global permits, pause/resume, cancel,
retry/GID replacement, restart recovery, terminal reconciliation, and
output ownership for Torrent lifecycles.
- Optional seeding by time and/or ratio, upload progress, upload limits,
seeders telemetry, per-Torrent maximum peers, and the Aria2
`bt-request-peer-speed-limit` threshold.
- Bounded, read-only Torrent peer diagnostics through `aria2.getPeers`.
Firelink discards peer IPs, ports, IDs, and bitfields at the native boundary;
the selected-Torrent detail view exposes only operational speeds, seeder,
and choking flags, with a bounded display count.
- Global DHT, IPv6 DHT, PEX, and Local Peer Discovery toggles.
- Optional piece-integrity verification, including the explicit policy that
disables unverified seeding when verification is requested.
- Optional stall timeout through `bt-stop-timeout`, persisted with each
Torrent and re-applied when it starts or retries. A value of zero disables
the policy; Aria2 stops the Torrent after the configured consecutive
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.
- Optional `bt-prioritize-piece` preview policy for the head, tail, or both
ends of every selected file. The constrained policy is validated, persisted,
normalized, and reapplied when a Torrent starts or retries.
- One validated Torrent encryption policy mapped to Aria2's
`bt-force-encryption`, `bt-require-crypto`, and `bt-min-crypto-level`:
disabled, required obfuscated handshake, or forced ARC4 payload encryption.
The policy is persisted and reapplied when a Torrent starts or retries.
- Optional tracker timing controls through `bt-tracker-connect-timeout`,
`bt-tracker-timeout`, and `bt-tracker-interval`. Connect and request
timeouts are bounded to 1604800 seconds; interval 0 restores Aria2's
response/progress-driven scheduling. Timing is persisted and reapplied when
a Torrent starts or retries.
- Global `bt-max-open-files` control for multi-file Torrents, bounded to
14096 with Aria2's default of 100. The setting is persisted, applied at
daemon startup, and updateable through Aria2's global-option RPC; changes
affect newly added Torrents without restarting Aria2.
- Optional `bt-remove-unselected-file` cleanup after completion when a
selected-file subset is configured. Firelink requires explicit confirmation,
reserves the unselected paths against competing downloads, keeps those
paths separate from removable download ownership, and clears the reservation
after observing Aria2's completion cleanup (or on terminal failure,
cancellation, or reconfiguration).
- Deterministic local Aria2 smoke coverage for metadata resolution, selected
output, piece priority, encryption policy, tracker timing, pause/resume,
ownership, cancellation/removal, unavailable trackers, daemon failure, and
`bt-stop-timeout` terminal behavior; RPC-boundary coverage is separate.
## Priority tiers for remaining work
### Tier 0 — reliability and user-visible control
No remaining Tier 0 items.
### Tier 1 — transfer policy and storage behavior
1. **File priority beyond selection** — Aria2 exposes only the binary
`selected` file state through its Torrent file API and has no supported
per-file priority option. Firelink therefore does not pretend that
`select-file` is file priority; this remains pending an engine capability or
a safe product-level model.
### Tier 2 — advanced networking and daemon tuning
1. Configurable TCP/UDP listen ports, external IP, DHT entry points, IPv6 DHT
listen address, and LPD interface, with platform/firewall warnings.
2. Peer identity/agent controls, with explicit privacy and protocol-identity
warnings.
3. Aria2 `follow-torrent`/in-memory follow behavior for generic downloads only
if the resulting child-GID ownership model can be represented safely; the
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, bounded peer diagnostics,
persisted tracker exclusion, piece-preview priority, safe unselected-file
removal, the validated encryption policy, tracker timing controls, and the
global Torrent open-file limit.
+3
View File
@@ -33,6 +33,9 @@
"build": "tsc && vite build", "build": "tsc && vite build",
"check:i18n": "vitest run src/i18n/resources.test.ts", "check:i18n": "vitest run src/i18n/resources.test.ts",
"check:updates": "node scripts/check-updates.js", "check:updates": "node scripts/check-updates.js",
"smoke:torrent": "node scripts/smoke-torrent.js",
"smoke:torrent:failure-paths": "node scripts/smoke-torrent.js --failure-paths",
"test:torrent:rpc": "cd src-tauri && cargo test --test torrent_rpc -- --nocapture",
"verify:macos-signing": "node scripts/verify-macos-signing.js", "verify:macos-signing": "node scripts/verify-macos-signing.js",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
+916
View File
@@ -0,0 +1,916 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { execFileSync, spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
function argumentValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
if (process.argv.includes('--help')) {
console.log('Usage: node scripts/smoke-torrent.js [--binary PATH] [--failure-paths] [--keep-temp]');
process.exit(0);
}
const keepTemp = process.argv.includes('--keep-temp');
const runFailurePaths = process.argv.includes('--failure-paths');
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
const platform = {
darwin: 'apple-darwin',
linux: 'unknown-linux-gnu',
win32: 'pc-windows-msvc',
}[os.platform()];
if (!arch || !platform) {
throw new Error(`Unsupported host: ${os.arch()} / ${os.platform()}`);
}
const targetTriple = `${arch}-${platform}`;
const executableName = `aria2c-${targetTriple}${os.platform() === 'win32' ? '.exe' : ''}`;
const binaryPath = path.resolve(
argumentValue('--binary') || path.join(repoRoot, 'src-tauri', 'binaries', executableName),
);
const runtimeAbortController = new AbortController();
const sleep = (milliseconds, signal = runtimeAbortController.signal) => new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error('Torrent runtime smoke was interrupted'));
return;
}
let timer;
const onAbort = () => {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
reject(new Error('Torrent runtime smoke was interrupted'));
};
timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, milliseconds);
signal?.addEventListener('abort', onAbort, { once: true });
});
const activeDaemons = new Set();
let runtimeTempRoot;
let runtimeTracker;
let cleanupPromise;
let signalTerminationRequested = false;
class DaemonExitedError extends Error {}
function childExited(child) {
return child.exitCode !== null || child.signalCode !== null;
}
async function findAvailablePort() {
const server = net.createServer();
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen({ host: '127.0.0.1', port: 0 }, resolve);
});
const address = server.address();
const port = address && typeof address !== 'string' ? address.port : null;
await new Promise(resolve => server.close(resolve));
if (!port) throw new Error('Could not reserve a local port');
return port;
}
function bencode(value) {
if (Buffer.isBuffer(value)) {
return Buffer.concat([Buffer.from(`${value.length}:`), value]);
}
if (typeof value === 'string') return bencode(Buffer.from(value));
if (typeof value === 'number' && Number.isInteger(value)) {
return Buffer.from(`i${value}e`);
}
if (Array.isArray(value)) {
return Buffer.concat([Buffer.from('l'), ...value.map(bencode), Buffer.from('e')]);
}
if (value instanceof Map || (value && typeof value === 'object')) {
const entries = value instanceof Map ? [...value.entries()] : Object.entries(value);
entries.sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
return Buffer.concat([
Buffer.from('d'),
...entries.flatMap(([key, child]) => [bencode(String(key)), bencode(child)]),
Buffer.from('e'),
]);
}
throw new Error(`Unsupported bencode value: ${typeof value}`);
}
function decodeBencode(bytes) {
let position = 0;
const parse = () => {
const marker = bytes[position];
if (marker === 0x69) {
position += 1;
const end = bytes.indexOf(0x65, position);
if (end < 0) throw new Error('Malformed integer in saved torrent');
const value = Number(bytes.subarray(position, end).toString('ascii'));
position = end + 1;
return value;
}
if (marker === 0x6c) {
position += 1;
const values = [];
while (bytes[position] !== 0x65) values.push(parse());
position += 1;
return values;
}
if (marker === 0x64) {
position += 1;
const values = new Map();
while (bytes[position] !== 0x65) {
const key = parse();
if (!Buffer.isBuffer(key)) throw new Error('Malformed dictionary key in saved torrent');
values.set(key.toString('utf8'), parse());
}
position += 1;
return values;
}
if (marker >= 0x30 && marker <= 0x39) {
const separator = bytes.indexOf(0x3a, position);
if (separator < 0) throw new Error('Malformed byte string in saved torrent');
const length = Number(bytes.subarray(position, separator).toString('ascii'));
position = separator + 1;
const value = bytes.subarray(position, position + length);
if (value.length !== length) throw new Error('Truncated byte string in saved torrent');
position += length;
return value;
}
throw new Error(`Unexpected bencode marker ${String.fromCharCode(marker || 0)}`);
};
const value = parse();
if (position !== bytes.length) throw new Error('Saved torrent contains trailing data');
return value;
}
function sha1(value) {
return crypto.createHash('sha1').update(value).digest();
}
function createRuntimeTorrent(trackerUrl) {
const name = 'firelink-torrent-runtime';
const pieceLength = 16 * 1024;
const files = [
{
path: 'selected.bin',
data: Buffer.alloc(256 * 1024, 0x53),
},
{
path: 'skipped.bin',
data: Buffer.from('this file must not be selected\n'),
},
];
const piecesSource = Buffer.concat(files.map(file => file.data));
const pieces = [];
for (let offset = 0; offset < piecesSource.length; offset += pieceLength) {
pieces.push(sha1(piecesSource.subarray(offset, offset + pieceLength)));
}
const info = new Map([
['files', files.map(file => new Map([
['length', file.data.length],
['path', [file.path]],
]))],
['name', name],
['piece length', pieceLength],
['pieces', Buffer.concat(pieces)],
]);
const torrent = new Map([
['announce', trackerUrl],
['info', info],
]);
const bytes = bencode(torrent);
const infoHash = sha1(bencode(info)).toString('hex');
return { bytes, infoHash, name, files };
}
function startTracker(seederPort) {
let activeSeederPort = seederPort;
const server = http.createServer((request, response) => {
let pathname;
try {
pathname = request.url ? new URL(request.url, 'http://127.0.0.1').pathname : undefined;
} catch {
response.writeHead(400).end();
return;
}
if (pathname !== '/announce') {
response.writeHead(404).end();
return;
}
const peer = Buffer.alloc(6);
peer[0] = 127;
peer[1] = 0;
peer[2] = 0;
peer[3] = 1;
peer.writeUInt16BE(activeSeederPort, 4);
const body = bencode(new Map([
['complete', 1],
['incomplete', 0],
['interval', 2],
['min interval', 1],
['peers', peer],
]));
response.writeHead(200, {
'Content-Length': body.length,
'Content-Type': 'text/plain',
});
response.end(body);
});
return {
server,
setSeederPort(port) {
activeSeederPort = port;
},
};
}
async function listen(server) {
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen({ host: '127.0.0.1', port: 0 }, resolve);
});
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Could not determine local server port');
return address.port;
}
function daemonEnvironment() {
const libraries = path.join(path.dirname(binaryPath), 'aria2-libs');
return fs.existsSync(libraries)
? { ...process.env, OPENSSL_MODULES: libraries }
: process.env;
}
async function rpc(port, secret, method, params = [], { signal = runtimeAbortController.signal } = {}) {
const response = await fetch(`http://127.0.0.1:${port}/jsonrpc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: `firelink-torrent-${Date.now()}`,
method,
params: [`token:${secret}`, ...params],
}),
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(3000)]) : AbortSignal.timeout(3000),
});
const body = await response.json();
if (body.error) throw new Error(`${method}: ${JSON.stringify(body.error)}`);
if (!Object.hasOwn(body, 'result')) throw new Error(`${method}: response has no result`);
return body.result;
}
async function waitFor(description, check, timeoutMs = 15000) {
const deadline = Date.now() + timeoutMs;
let lastError;
while (Date.now() < deadline) {
try {
if (runtimeAbortController.signal.aborted) throw new Error('Torrent runtime smoke was interrupted');
const result = await check();
if (result) return result;
} catch (error) {
lastError = error;
if (runtimeAbortController.signal.aborted || error instanceof DaemonExitedError) throw error;
}
await sleep(100);
}
throw new Error(`Timed out waiting for ${description}${lastError ? `: ${lastError.message}` : ''}`);
}
async function startDaemon({ name, rpcPort, listenPort, directory, extraArgs = [] }) {
const maxAttempts = 3;
let lastError;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const selectedRpcPort = attempt === 0 ? rpcPort : await findAvailablePort();
const selectedListenPort = listenPort === undefined
? undefined
: attempt === 0 ? listenPort : await findAvailablePort();
const secret = `firelink-${name}-${crypto.randomUUID()}`;
const stderr = [];
const child = spawn(binaryPath, [
'--enable-rpc=true',
`--rpc-listen-port=${selectedRpcPort}`,
'--rpc-listen-all=false',
`--rpc-secret=${secret}`,
`--dir=${directory}`,
'--file-allocation=none',
'--enable-dht=false',
'--enable-peer-exchange=false',
'--bt-enable-lpd=false',
'--console-log-level=error',
'--quiet=true',
...(selectedListenPort ? [`--listen-port=${selectedListenPort}`] : []),
...extraArgs,
], {
env: daemonEnvironment(),
stdio: ['ignore', 'ignore', 'pipe'],
});
child.stderr.on('data', chunk => stderr.push(chunk.toString()));
let exit;
child.once('exit', (code, signal) => { exit = { code, signal }; });
child.once('error', error => { exit = { error }; });
const daemon = {
child,
secret,
stderr,
rpcPort: selectedRpcPort,
listenPort: selectedListenPort,
};
activeDaemons.add(daemon);
try {
await waitFor(`${name} Aria2 RPC`, async () => {
if (exit) throw new DaemonExitedError(`${name} exited: ${exit.error?.message || `${exit.code}/${exit.signal}`}`);
try {
await rpc(selectedRpcPort, secret, 'aria2.getVersion');
return true;
} catch {
return false;
}
}, 10000);
return daemon;
} catch (error) {
lastError = error;
const detail = `${error.message}\n${stderr.join('')}`;
await stopDaemon(daemon);
if (!/address already in use|failed to bind|could not bind|listen failed/i.test(detail)) {
throw error;
}
}
}
throw lastError || new Error(`Could not start ${name} Aria2 daemon`);
}
async function stopDaemon(daemon) {
const child = daemon?.child;
if (!child) return;
try {
if (!childExited(child)) {
try {
await rpc(daemon.rpcPort, daemon.secret, 'aria2.shutdown', [], { signal: null });
} catch {
// The process may already have exited during cleanup.
}
}
let exited = await waitForChildExit(child, 3000);
if (!exited) {
if (process.platform === 'win32') {
try {
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
timeout: 3000,
});
} catch {
// The process may have exited between the wait and taskkill.
}
} else {
child.kill('SIGTERM');
}
exited = await waitForChildExit(child, 3000);
}
if (!exited) {
if (process.platform !== 'win32') {
child.kill('SIGKILL');
exited = await waitForChildExit(child, 3000);
}
}
if (!exited) {
throw new Error(`Aria2 daemon process ${child.pid} did not exit after forced cleanup`);
}
} finally {
activeDaemons.delete(daemon);
}
}
async function closeServer(server) {
if (!server || !server.listening) return;
await new Promise(resolve => server.close(() => resolve()));
}
async function cleanupRuntime() {
if (cleanupPromise) return cleanupPromise;
cleanupPromise = (async () => {
let cleanupError;
for (const daemon of [...activeDaemons].reverse()) {
try {
await stopDaemon(daemon);
} catch (error) {
cleanupError ??= error;
console.error(`[FAIL] failed to stop Aria2 daemon: ${error.message}`);
}
}
if (runtimeTracker) {
const tracker = runtimeTracker;
runtimeTracker = undefined;
await closeServer(tracker.server);
}
if (runtimeTempRoot) {
const tempRoot = runtimeTempRoot;
runtimeTempRoot = undefined;
if (keepTemp || cleanupError) {
console.log(`[INFO] retained smoke-test directory: ${tempRoot}`);
} else {
try {
fs.rmSync(tempRoot, { recursive: true, force: true });
} catch (error) {
cleanupError ??= error;
console.error(`[FAIL] failed to remove smoke-test directory: ${error.message}`);
console.log(`[INFO] retained smoke-test directory: ${tempRoot}`);
}
}
}
if (cleanupError) throw cleanupError;
})();
return cleanupPromise;
}
async function tellStatus(daemon, gid) {
return rpc(daemon.rpcPort, daemon.secret, 'aria2.tellStatus', [gid, [
'status',
'errorCode',
'errorMessage',
'completedLength',
'totalLength',
'files',
]]);
}
function assertDaemonRunning(daemon) {
if (childExited(daemon.child)) {
throw new DaemonExitedError(
`Aria2 daemon exited: ${daemon.child.exitCode ?? 'null'}/${daemon.child.signalCode ?? 'null'}`,
);
}
}
async function waitForTerminal(daemon, gid, timeoutMs = 30000) {
let lastStatus;
try {
return await waitFor(`Aria2 transfer ${gid} to complete`, async () => {
assertDaemonRunning(daemon);
lastStatus = await tellStatus(daemon, gid);
if (lastStatus.status === 'complete') return lastStatus;
if (lastStatus.status === 'error' || lastStatus.status === 'removed') {
throw new Error(`transfer ended ${lastStatus.status}: ${lastStatus.errorCode || ''} ${lastStatus.errorMessage || ''}`);
}
return false;
}, timeoutMs);
} catch (error) {
const stderr = daemon.stderr?.join('').trim();
throw new Error(`${error.message}; last status ${JSON.stringify(lastStatus)}${stderr ? `; stderr ${stderr}` : ''}`);
}
}
async function waitForStatus(daemon, gid, expected, timeoutMs = 10000) {
return waitFor(`Aria2 transfer ${gid} to become ${expected}`, async () => {
assertDaemonRunning(daemon);
const status = await tellStatus(daemon, gid);
if (status.status === expected) return status;
if (status.status === 'error' || status.status === 'removed') {
throw new Error(`transfer ended ${status.status}: ${status.errorCode || ''} ${status.errorMessage || ''}`);
}
return false;
}, timeoutMs);
}
async function waitForDataComplete(daemon, gid, timeoutMs = 30000) {
let lastStatus;
try {
return await waitFor(`Aria2 transfer ${gid} data to complete`, async () => {
assertDaemonRunning(daemon);
lastStatus = await tellStatus(daemon, gid);
if (lastStatus.status === 'error' || lastStatus.status === 'removed') {
throw new Error(`transfer ended ${lastStatus.status}: ${lastStatus.errorCode || ''} ${lastStatus.errorMessage || ''}`);
}
return Number(lastStatus.completedLength) === Number(lastStatus.totalLength) ? lastStatus : false;
}, timeoutMs);
} catch (error) {
const stderr = daemon.stderr?.join('').trim();
throw new Error(`${error.message}; last status ${JSON.stringify(lastStatus)}${stderr ? `; stderr ${stderr}` : ''}`);
}
}
async function waitForRemoved(daemon, gid) {
let lastStatus;
try {
return await waitFor(`Aria2 transfer ${gid} to be removed`, async () => {
assertDaemonRunning(daemon);
try {
lastStatus = await tellStatus(daemon, gid);
return lastStatus.status === 'removed' ? lastStatus : false;
} catch (error) {
return /not found|no such download/i.test(error.message) ? true : false;
}
}, 10000);
} catch (error) {
throw new Error(`${error.message}; last status ${JSON.stringify(lastStatus)}`);
}
}
async function forceRemoveIfPresent(daemon, gid) {
try {
await rpc(daemon.rpcPort, daemon.secret, 'aria2.forceRemove', [gid]);
return true;
} catch (error) {
if (/not found|no such download|active download not found/i.test(error.message)) return false;
throw error;
}
}
async function waitForChildExit(child, timeoutMs = 5000) {
if (childExited(child)) return true;
return new Promise(resolve => {
let settled = false;
let timer;
let onExit;
const finish = result => {
if (settled) return;
settled = true;
clearTimeout(timer);
child.off('exit', onExit);
resolve(result);
};
timer = setTimeout(() => {
finish(false);
}, timeoutMs);
onExit = () => finish(true);
child.once('exit', onExit);
if (childExited(child)) finish(true);
});
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
async function runFailurePathChecks({ client, tempRoot }) {
const failureDir = path.join(tempRoot, 'failure');
fs.mkdirSync(failureDir, { recursive: true });
const unavailablePort = await findAvailablePort();
const unavailableInfoHash = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
const unavailableMagnet = `magnet:?xt=urn:btih:${unavailableInfoHash}&dn=unavailable-firelink-fixture&tr=${encodeURIComponent(`http://127.0.0.1:${unavailablePort}/announce`)}`;
const failureGid = await rpc(client.rpcPort, client.secret, 'aria2.addUri', [[unavailableMagnet], {
dir: failureDir,
'bt-metadata-only': 'true',
'bt-save-metadata': 'true',
'max-tries': '1',
'retry-wait': '1',
'connect-timeout': '1',
'bt-tracker-connect-timeout': '1',
timeout: '2',
'auto-file-renaming': 'false',
}]);
let lastStatus;
const deadline = Date.now() + 8000;
while (Date.now() < deadline) {
try {
lastStatus = await tellStatus(client, failureGid);
if (lastStatus.status === 'error' || lastStatus.status === 'complete') break;
} catch {
break;
}
await sleep(100);
}
const failureRemoved = await forceRemoveIfPresent(client, failureGid);
if (failureRemoved) await waitForRemoved(client, failureGid);
const savedFailureMetadata = fs.readdirSync(failureDir)
.filter(file => file.toLowerCase().endsWith('.torrent'));
assert(savedFailureMetadata.length === 0, 'unavailable tracker produced metadata unexpectedly');
assert(lastStatus?.status !== 'complete', 'unavailable tracker completed a metadata probe unexpectedly');
console.log(`[OK] unavailable tracker was cleaned up without metadata (${lastStatus?.status || 'gone'})`);
const exitDir = path.join(tempRoot, 'daemon-exit');
fs.mkdirSync(exitDir, { recursive: true });
const exitRpcPort = await findAvailablePort();
const exitDaemon = await startDaemon({
name: 'exit',
rpcPort: exitRpcPort,
listenPort: await findAvailablePort(),
directory: exitDir,
extraArgs: ['--seed-time=0'],
});
const exitGid = await rpc(exitDaemon.rpcPort, exitDaemon.secret, 'aria2.addUri', [[unavailableMagnet], {
dir: exitDir,
'bt-metadata-only': 'true',
'bt-save-metadata': 'true',
'max-tries': '1',
'connect-timeout': '1',
'bt-tracker-connect-timeout': '1',
timeout: '15',
}]);
assert(exitGid, 'daemon-exit probe did not return a GID');
exitDaemon.child.kill('SIGTERM');
assert(await waitForChildExit(exitDaemon.child), 'Aria2 daemon did not exit after termination');
assert(
!fs.readdirSync(exitDir).some(file => file.toLowerCase().endsWith('.torrent')),
'daemon exit left saved metadata in the probe directory',
);
console.log('[OK] daemon termination left no saved probe metadata');
}
async function main() {
if (!fs.existsSync(binaryPath)) {
throw new Error(`aria2c binary not found: ${binaryPath}`);
}
runtimeTempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-torrent-smoke-'));
try {
const tempRoot = runtimeTempRoot;
const seedParent = path.join(tempRoot, 'seed');
const seedRoot = path.join(seedParent, 'firelink-torrent-runtime');
const probeDir = path.join(tempRoot, 'probe');
const finalDir = path.join(tempRoot, 'final');
const integrityDir = path.join(tempRoot, 'integrity');
const encryptionDir = path.join(tempRoot, 'encryption');
const cancelDir = path.join(tempRoot, 'cancel');
const removeUnselectedDir = path.join(tempRoot, 'remove-unselected');
const stallDir = path.join(tempRoot, 'stall');
for (const directory of [seedRoot, probeDir, finalDir, integrityDir, encryptionDir, cancelDir, removeUnselectedDir, stallDir]) fs.mkdirSync(directory, { recursive: true });
const seederListenPort = await findAvailablePort();
const clientListenPort = await findAvailablePort();
runtimeTracker = startTracker(seederListenPort);
const trackerPort = await listen(runtimeTracker.server);
const torrent = createRuntimeTorrent(`http://127.0.0.1:${trackerPort}/announce`);
for (const file of torrent.files) fs.writeFileSync(path.join(seedRoot, file.path), file.data);
const magnet = `magnet:?xt=urn:btih:${torrent.infoHash}&dn=${encodeURIComponent(torrent.name)}&tr=${encodeURIComponent(`http://127.0.0.1:${trackerPort}/announce`)}`;
const indexOut = [
`1=${torrent.name}/selected.bin`,
`2=${torrent.name}/skipped.bin`,
];
const seederRpcPort = await findAvailablePort();
const seeder = await startDaemon({
name: 'seeder',
rpcPort: seederRpcPort,
listenPort: seederListenPort,
directory: seedParent,
extraArgs: ['--seed-time=60', '--seed-ratio=0'],
});
runtimeTracker.setSeederPort(seeder.listenPort);
const seedGid = await rpc(seeder.rpcPort, seeder.secret, 'aria2.addTorrent', [
torrent.bytes.toString('base64'),
[],
{
dir: seedParent,
'seed-time': '60',
'seed-ratio': '0',
'allow-overwrite': 'true',
'check-integrity': 'true',
},
]);
await waitForDataComplete(seeder, seedGid);
console.log(`[OK] local seeder completed ${seedGid}`);
const clientRpcPort = await findAvailablePort();
const client = await startDaemon({
name: 'client',
rpcPort: clientRpcPort,
listenPort: clientListenPort,
directory: probeDir,
extraArgs: ['--seed-time=0'],
});
const probeGid = await rpc(client.rpcPort, client.secret, 'aria2.addUri', [[magnet], {
dir: probeDir,
'bt-metadata-only': 'true',
'bt-save-metadata': 'true',
'max-tries': '3',
'retry-wait': '1',
'connect-timeout': '5',
timeout: '15',
'auto-file-renaming': 'false',
}]);
const probeStatus = await waitForTerminal(client, probeGid, 30000);
assert(probeStatus.status === 'complete', 'magnet metadata probe did not complete');
const savedTorrentPaths = fs.readdirSync(probeDir)
.filter(file => file.toLowerCase().endsWith('.torrent'))
.map(file => path.join(probeDir, file));
assert(savedTorrentPaths.length === 1, `expected one saved metadata file, found ${savedTorrentPaths.length}`);
const expectedMetadataPath = path.join(probeDir, `${torrent.infoHash}.torrent`);
assert(savedTorrentPaths[0] === expectedMetadataPath, `Aria2 saved metadata at an unexpected path: ${savedTorrentPaths[0]}`);
const savedTorrentBytes = fs.readFileSync(savedTorrentPaths[0]);
const savedTorrent = decodeBencode(savedTorrentBytes);
const savedInfo = savedTorrent instanceof Map ? savedTorrent.get('info') : undefined;
assert(savedInfo instanceof Map, 'saved metadata has no info dictionary');
assert(sha1(bencode(savedInfo)).toString('hex') === torrent.infoHash, 'saved metadata hash differs from magnet');
console.log(`[OK] magnet metadata resolved and hash matched ${torrent.infoHash}`);
const trackerlessTorrent = new Map(savedTorrent);
trackerlessTorrent.delete('announce');
trackerlessTorrent.delete('announce-list');
const trackerlessTorrentBytes = bencode(trackerlessTorrent);
await rpc(client.rpcPort, client.secret, 'aria2.changeGlobalOption', [
{ 'bt-max-open-files': '256' },
]);
const globalOptions = await rpc(client.rpcPort, client.secret, 'aria2.getGlobalOption', []);
assert(
globalOptions['bt-max-open-files'] === '256',
`Aria2 did not retain the global Torrent open-file limit: ${JSON.stringify(globalOptions['bt-max-open-files'])}`,
);
const probeRemoved = await forceRemoveIfPresent(client, probeGid);
if (probeRemoved) await waitForRemoved(client, probeGid);
fs.rmSync(probeDir, { recursive: true, force: true });
fs.mkdirSync(probeDir, { recursive: true });
console.log('[OK] metadata probe was removed after resolution');
const finalGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [
trackerlessTorrentBytes.toString('base64'),
[],
{
dir: finalDir,
'bt-tracker': `http://127.0.0.1:${trackerPort}/announce`,
'bt-tracker-connect-timeout': '5',
'bt-tracker-timeout': '7',
'bt-tracker-interval': '2',
'select-file': '1',
'bt-prioritize-piece': 'head=32K,tail=16K',
'index-out': indexOut,
'max-download-limit': '32K',
'seed-time': '0',
'auto-file-renaming': 'false',
},
]);
await waitForStatus(client, finalGid, 'active', 10000);
const finalOptions = await rpc(client.rpcPort, client.secret, 'aria2.getOption', [finalGid]);
assert(
finalOptions['bt-prioritize-piece'] === 'head=32K,tail=16K',
`Aria2 did not retain the piece-priority option: ${JSON.stringify(finalOptions['bt-prioritize-piece'])}`,
);
assert(finalOptions['bt-tracker-connect-timeout'] === '5', 'Aria2 did not retain tracker connect timeout');
assert(finalOptions['bt-tracker-timeout'] === '7', 'Aria2 did not retain tracker request timeout');
assert(finalOptions['bt-tracker-interval'] === '2', 'Aria2 did not retain tracker interval');
await rpc(client.rpcPort, client.secret, 'aria2.forcePause', [finalGid]);
await waitForStatus(client, finalGid, 'paused', 10000);
await rpc(client.rpcPort, client.secret, 'aria2.unpause', [finalGid]);
const finalStatus = await waitForTerminal(client, finalGid, 30000);
const selectedPath = path.join(finalDir, torrent.name, 'selected.bin');
const skippedPath = path.join(finalDir, torrent.name, 'skipped.bin');
assert(fs.existsSync(selectedPath), `selected torrent output is missing: ${selectedPath}`);
assert(fs.readFileSync(selectedPath).equals(torrent.files[0].data), 'selected torrent output content differs');
assert(!fs.existsSync(skippedPath), 'unselected torrent output was written');
const reportedFiles = await rpc(client.rpcPort, client.secret, 'aria2.getFiles', [finalGid]);
const reportedSelected = reportedFiles.find(file => file.path === selectedPath || file.path.endsWith('/selected.bin'));
assert(reportedSelected, `Aria2 ownership list did not report ${selectedPath}`);
assert(finalStatus.files?.some(file => file.path === selectedPath || file.path.endsWith('/selected.bin')), 'terminal status omitted selected output');
console.log('[OK] global open-file limit, tracker injection, piece priority, selected output, pause/resume, and Aria2 file ownership passed');
const integrityPath = path.join(integrityDir, torrent.name, 'selected.bin');
fs.mkdirSync(path.dirname(integrityPath), { recursive: true });
fs.writeFileSync(integrityPath, Buffer.alloc(torrent.files[0].data.length, 0x00));
const integrityGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [
savedTorrentBytes.toString('base64'),
[],
{
dir: integrityDir,
'select-file': '1',
'index-out': indexOut,
'check-integrity': 'true',
'bt-hash-check-seed': 'false',
'seed-time': '0',
'auto-file-renaming': 'false',
},
]);
const integrityStatus = await waitForTerminal(client, integrityGid, 30000);
assert(integrityStatus.status === 'complete', 'integrity-check torrent did not complete');
assert(fs.readFileSync(integrityPath).equals(torrent.files[0].data), 'integrity check did not replace corrupted torrent data');
console.log('[OK] check-integrity detected and repaired corrupted Torrent data');
const encryptionGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [
savedTorrentBytes.toString('base64'),
[],
{
dir: encryptionDir,
'select-file': '1',
'index-out': indexOut,
'bt-force-encryption': 'true',
'bt-require-crypto': 'true',
'bt-min-crypto-level': 'arc4',
'seed-time': '0',
'auto-file-renaming': 'false',
},
]);
const encryptionOptions = await rpc(client.rpcPort, client.secret, 'aria2.getOption', [encryptionGid]);
assert(encryptionOptions['bt-force-encryption'] === 'true', 'Aria2 did not retain force encryption');
assert(encryptionOptions['bt-require-crypto'] === 'true', 'Aria2 did not retain the crypto requirement');
assert(encryptionOptions['bt-min-crypto-level'] === 'arc4', 'Aria2 did not retain the ARC4 minimum crypto level');
await waitForTerminal(client, encryptionGid, 30000);
const encryptedPath = path.join(encryptionDir, torrent.name, 'selected.bin');
assert(fs.readFileSync(encryptedPath).equals(torrent.files[0].data), 'encrypted Torrent output content differs');
console.log('[OK] Torrent encryption policy retained all three Aria2 options and completed');
const removalSkippedPath = path.join(removeUnselectedDir, torrent.name, 'skipped.bin');
fs.mkdirSync(path.dirname(removalSkippedPath), { recursive: true });
fs.writeFileSync(removalSkippedPath, Buffer.from('pre-existing file owned outside the Torrent\n'));
const removalGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [
savedTorrentBytes.toString('base64'),
[],
{
dir: removeUnselectedDir,
'select-file': '1',
'index-out': indexOut,
'bt-remove-unselected-file': 'true',
'allow-overwrite': 'true',
'seed-time': '0',
'auto-file-renaming': 'false',
},
]);
const removalOptions = await rpc(client.rpcPort, client.secret, 'aria2.getOption', [removalGid]);
assert(
removalOptions['bt-remove-unselected-file'] === 'true',
`Aria2 did not retain unselected-file removal: ${JSON.stringify(removalOptions['bt-remove-unselected-file'])}`,
);
await waitForTerminal(client, removalGid, 30000);
const removalSelectedPath = path.join(removeUnselectedDir, torrent.name, 'selected.bin');
assert(fs.existsSync(removalSelectedPath), 'selected Torrent output was not retained with removal enabled');
assert(!fs.existsSync(removalSkippedPath), 'unselected Torrent file was not removed after completion');
console.log('[OK] bt-remove-unselected-file deleted only the unselected pre-existing output after completion');
const canceledSkippedPath = path.join(cancelDir, torrent.name, 'skipped.bin');
fs.mkdirSync(path.dirname(canceledSkippedPath), { recursive: true });
const canceledSentinel = Buffer.from('cancellation must preserve this file\n');
fs.writeFileSync(canceledSkippedPath, canceledSentinel);
const cancelGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [
savedTorrentBytes.toString('base64'),
[],
{
dir: cancelDir,
'select-file': '1',
'index-out': indexOut,
'bt-remove-unselected-file': 'true',
'allow-overwrite': 'true',
'max-download-limit': '8K',
'seed-time': '0',
'auto-file-renaming': 'false',
},
]);
await waitForStatus(client, cancelGid, 'active', 10000);
assert(await forceRemoveIfPresent(client, cancelGid), 'cancel/remove raced to a terminal state before forceRemove');
await waitForRemoved(client, cancelGid);
const canceledPath = path.join(cancelDir, torrent.name, 'selected.bin');
assert(
!fs.existsSync(canceledPath) || fs.statSync(canceledPath).size < torrent.files[0].data.length,
'canceled torrent produced a complete output',
);
assert(fs.existsSync(canceledSkippedPath), 'canceled Torrent removed an unselected file before completion');
assert(fs.readFileSync(canceledSkippedPath).equals(canceledSentinel), 'canceled Torrent changed the unselected file');
console.log('[OK] cancel/remove stopped the second torrent before completion without deleting an unselected file');
const stallGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [
trackerlessTorrentBytes.toString('base64'),
[],
{
dir: stallDir,
'bt-stop-timeout': '2',
'seed-time': '0',
'auto-file-renaming': 'false',
},
]);
let stallStatus;
await waitFor('stalled Torrent to stop', async () => {
assertDaemonRunning(client);
stallStatus = await tellStatus(client, stallGid);
if (stallStatus.status === 'complete') {
throw new Error('stalled Torrent completed without a peer');
}
return stallStatus.status === 'error';
}, 10000);
assert(stallStatus.errorCode === '7', `unexpected stall timeout error: ${JSON.stringify(stallStatus)}`);
console.log('[OK] bt-stop-timeout ended a stalled Torrent without a retry loop');
if (runFailurePaths) {
await runFailurePathChecks({ client, tempRoot });
}
} finally {
await cleanupRuntime();
}
}
function handleTerminationSignal(signal) {
if (signalTerminationRequested) return;
signalTerminationRequested = true;
runtimeAbortController.abort();
void cleanupRuntime().then(() => {
process.exit(signal === 'SIGINT' ? 130 : 143);
}, error => {
console.error(`[FAIL] Torrent runtime smoke cleanup: ${error.message}`);
process.exit(signal === 'SIGINT' ? 130 : 143);
});
}
const onSigint = () => handleTerminationSignal('SIGINT');
const onSigterm = () => handleTerminationSignal('SIGTERM');
process.once('SIGINT', onSigint);
process.once('SIGTERM', onSigterm);
main().catch(error => {
console.error(`[FAIL] Torrent runtime smoke test: ${error.message}`);
process.exitCode = 1;
}).finally(() => {
process.off('SIGINT', onSigint);
process.off('SIGTERM', onSigterm);
});
+14 -1
View File
@@ -1369,6 +1369,7 @@ dependencies = [
"apple-native-keyring-store", "apple-native-keyring-store",
"async-trait", "async-trait",
"axum", "axum",
"base64 0.22.1",
"chrono", "chrono",
"futures-util", "futures-util",
"hmac 0.13.0", "hmac 0.13.0",
@@ -1383,6 +1384,7 @@ dependencies = [
"semver", "semver",
"serde", "serde",
"serde_json", "serde_json",
"sha1 0.10.7",
"sha2 0.11.0", "sha2 0.11.0",
"sysinfo", "sysinfo",
"sysproxy", "sysproxy",
@@ -4281,6 +4283,17 @@ dependencies = [
"stable_deref_trait", "stable_deref_trait",
] ]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.11.0" version = "0.11.0"
@@ -5632,7 +5645,7 @@ dependencies = [
"httparse", "httparse",
"log 0.4.33", "log 0.4.33",
"rand 0.10.2", "rand 0.10.2",
"sha1", "sha1 0.11.0",
"thiserror 2.0.19", "thiserror 2.0.19",
] ]
+2
View File
@@ -40,6 +40,8 @@ tauri-plugin-clipboard-manager = "2.3.2"
sysinfo = "0.39.3" sysinfo = "0.39.3"
hmac = "0.13" hmac = "0.13"
sha2 = "0.11" sha2 = "0.11"
sha1 = "0.10"
base64 = "0.22"
tauri-plugin-deep-link = "2" tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] } tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
tempfile = "3" tempfile = "3"
+24 -4
View File
@@ -7,7 +7,7 @@ pub async fn reveal_in_file_manager(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
path: String, path: String,
) -> Result<(), String> { ) -> Result<(), String> {
let primary = authorize_download_path(&app_handle, &path)?; let primary = authorize_reveal_path(&app_handle, &path)?;
let path = existing_download_asset(&primary).ok_or_else(|| { let path = existing_download_asset(&primary).ok_or_else(|| {
format!( format!(
"Downloaded file or partial file is missing: {}", "Downloaded file or partial file is missing: {}",
@@ -56,18 +56,37 @@ fn authorize_download_path(
authorize_exact_path(Path::new(requested), &known_download_paths(app_handle)?) authorize_exact_path(Path::new(requested), &known_download_paths(app_handle)?)
} }
fn authorize_reveal_path(
app_handle: &tauri::AppHandle,
requested: &str,
) -> Result<PathBuf, String> {
authorize_exact_path_with_directory(
Path::new(requested),
&known_download_paths(app_handle)?,
true,
)
}
fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> { fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
crate::download_ownership::known_primary_paths(app_handle) crate::download_ownership::known_primary_paths(app_handle)
} }
fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<PathBuf, String> { fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<PathBuf, String> {
authorize_exact_path_with_directory(requested, allowed_paths, false)
}
fn authorize_exact_path_with_directory(
requested: &Path,
allowed_paths: &[PathBuf],
allow_directory: bool,
) -> Result<PathBuf, String> {
if crate::path_has_symlink_component(requested) { if crate::path_has_symlink_component(requested) {
return Err("Download path may not contain symlink components".to_string()); return Err("Download path may not contain symlink components".to_string());
} }
let requested = canonicalize_with_missing_leaf(requested)?; let requested = canonicalize_with_missing_leaf(requested)?;
if let Ok(metadata) = std::fs::metadata(&requested) { if let Ok(metadata) = std::fs::metadata(&requested) {
if !metadata.is_file() { if !metadata.is_file() && !(allow_directory && metadata.is_dir()) {
return Err("Download path is not a file".to_string()); return Err("Download path is not a file".to_string());
} }
} }
@@ -140,8 +159,9 @@ fn existing_download_asset(primary: &Path) -> Option<PathBuf> {
] ]
.into_iter() .into_iter()
.find(|candidate| { .find(|candidate| {
std::fs::symlink_metadata(candidate) std::fs::symlink_metadata(candidate).is_ok_and(|metadata| {
.is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink()) (metadata.is_file() || metadata.is_dir()) && !metadata.file_type().is_symlink()
})
}) })
} }
+467 -33
View File
@@ -7,7 +7,7 @@ use std::sync::Mutex;
const DATABASE_NAME: &str = "firelink.sqlite"; const DATABASE_NAME: &str = "firelink.sqlite";
const LEGACY_STORE_NAME: &str = "store.bin"; const LEGACY_STORE_NAME: &str = "store.bin";
const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app"; const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app";
const CURRENT_SCHEMA_VERSION: i64 = 1; const CURRENT_SCHEMA_VERSION: i64 = 3;
pub(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed"; pub(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed";
pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token"; pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token";
// Development builds are a different executable identity from the packaged // Development builds are a different executable identity from the packaged
@@ -127,6 +127,10 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(),
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
primary_path TEXT NOT NULL primary_path TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS download_owned_paths (
id TEXT PRIMARY KEY,
paths TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS migration_events ( CREATE TABLE IF NOT EXISTS migration_events (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
consumed INTEGER NOT NULL DEFAULT 0 consumed INTEGER NOT NULL DEFAULT 0
@@ -176,6 +180,32 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(),
} }
} }
if from_version < 2 {
transaction
.execute_batch(
"
CREATE TABLE IF NOT EXISTS download_owned_paths (
id TEXT PRIMARY KEY,
paths TEXT NOT NULL
);
",
)
.map_err(|error| format!("failed to migrate download ownership paths: {error}"))?;
}
if from_version < 3 {
transaction
.execute_batch(
"
CREATE TABLE IF NOT EXISTS download_removal_paths (
id TEXT PRIMARY KEY,
paths TEXT NOT NULL
);
",
)
.map_err(|error| format!("failed to migrate torrent removal paths: {error}"))?;
}
transaction transaction
.pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION) .pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION)
.map_err(|error| format!("failed to update database schema version: {error}"))?; .map_err(|error| format!("failed to update database schema version: {error}"))?;
@@ -934,23 +964,50 @@ 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 Some(url) = object.get("url").and_then(Value::as_str) {
if let Ok(mut parsed) = url::Url::parse(url) { if let Ok(mut parsed) = url::Url::parse(url) {
let had_userinfo = !parsed.username().is_empty() || parsed.password().is_some(); let had_userinfo = !parsed.username().is_empty() || parsed.password().is_some();
let had_query_or_fragment = parsed.query().is_some() || parsed.fragment().is_some(); if parsed.scheme() == "magnet" {
if had_userinfo || had_query_or_fragment { let mut serializer = url::form_urlencoded::Serializer::new(String::new());
let mut retained_info_hash = false;
let mut removed_query_context = false;
for (key, value) in parsed.query_pairs() {
if key == "xt" || key == "dn" {
retained_info_hash |= key == "xt";
serializer.append_pair(&key, &value);
} else {
removed_query_context = true;
}
}
let removed_fragment = parsed.fragment().is_some();
let safe_query = serializer.finish();
let _ = parsed.set_username(""); let _ = parsed.set_username("");
let _ = parsed.set_password(None); let _ = parsed.set_password(None);
parsed.set_query(None); parsed.set_query((!safe_query.is_empty()).then_some(safe_query.as_str()));
parsed.set_fragment(None); parsed.set_fragment(None);
object.insert("url".to_string(), Value::String(parsed.to_string())); object.insert("url".to_string(), Value::String(parsed.to_string()));
if had_userinfo || removed_query_context || removed_fragment || !retained_info_hash {
// A queued transfer whose URL depended on query/fragment
// credentials must not silently auto-resume with a truncated
// URL after a portable restart.
if had_userinfo || had_query_or_fragment {
mark_portable_download_unresumable(object); mark_portable_download_unresumable(object);
} }
} else {
let had_query_or_fragment = parsed.query().is_some() || parsed.fragment().is_some();
if had_userinfo || had_query_or_fragment {
let _ = parsed.set_username("");
let _ = parsed.set_password(None);
parsed.set_query(None);
parsed.set_fragment(None);
object.insert("url".to_string(), Value::String(parsed.to_string()));
// A queued transfer whose URL depended on query/fragment
// credentials must not silently auto-resume with a truncated
// URL after a portable restart.
if had_userinfo || had_query_or_fragment {
mark_portable_download_unresumable(object);
}
}
} }
} else { } else {
object.insert("url".to_string(), Value::String(String::new())); object.insert("url".to_string(), Value::String(String::new()));
@@ -967,6 +1024,82 @@ fn remove_persisted_transfer_secrets(value: &mut Value) {
} }
} }
fn sanitize_portable_torrent_tracker_field(
object: &mut serde_json::Map<String, Value>,
key: &str,
normalize: fn(Option<&str>) -> Result<Option<String>, String>,
) {
let Some(raw_value) = object.get(key).cloned() else {
return;
};
let Some(raw) = raw_value.as_str().map(str::to_string) else {
object.remove(key);
mark_portable_download_unresumable(object);
return;
};
let raw = raw.trim();
if raw.is_empty() {
object.remove(key);
return;
}
let Some(normalized) = normalize(Some(raw)).ok().flatten() else {
object.remove(key);
mark_portable_download_unresumable(object);
return;
};
let mut sanitized = Vec::new();
let mut removed_context = false;
for token in normalized.split(',') {
if token == "*" {
sanitized.push(token.to_string());
continue;
}
let Ok(mut parsed) = url::Url::parse(token) else {
object.remove(key);
mark_portable_download_unresumable(object);
return;
};
let had_context = !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some();
if had_context {
let _ = parsed.set_username("");
let _ = parsed.set_password(None);
parsed.set_query(None);
parsed.set_fragment(None);
removed_context = true;
}
sanitized.push(parsed.to_string());
}
if sanitized.is_empty() {
object.remove(key);
} else {
object.insert(key.to_string(), Value::String(sanitized.join(",")));
}
if removed_context {
mark_portable_download_unresumable(object);
}
}
fn sanitize_portable_torrent_trackers(object: &mut serde_json::Map<String, Value>) {
sanitize_portable_torrent_tracker_field(
object,
"torrentTrackers",
crate::queue::normalize_torrent_trackers,
);
}
fn sanitize_portable_torrent_exclude_trackers(object: &mut serde_json::Map<String, Value>) {
sanitize_portable_torrent_tracker_field(
object,
"torrentExcludeTrackers",
crate::queue::normalize_torrent_exclude_trackers,
);
}
fn value_is_empty(value: &Value) -> bool { fn value_is_empty(value: &Value) -> bool {
value.as_str().is_some_and(str::is_empty) value.as_str().is_some_and(str::is_empty)
|| value.as_array().is_some_and(Vec::is_empty) || value.as_array().is_some_and(Vec::is_empty)
@@ -1117,49 +1250,174 @@ fn required_string<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> {
.ok_or_else(|| format!("persisted item is missing '{key}'")) .ok_or_else(|| format!("persisted item is missing '{key}'"))
} }
pub fn load_ownership(connection: &Connection) -> Result<Vec<(String, String)>, String> { pub fn load_ownership(connection: &Connection) -> Result<Vec<(String, String, Vec<String>)>, String> {
let mut statement = connection let mut statement = connection
.prepare("SELECT id, primary_path FROM download_ownership") .prepare(
"SELECT ownership.id, ownership.primary_path, paths.paths
FROM download_ownership AS ownership
LEFT JOIN download_owned_paths AS paths ON paths.id = ownership.id",
)
.map_err(|error| format!("failed to prepare ownership query: {error}"))?; .map_err(|error| format!("failed to prepare ownership query: {error}"))?;
let rows = statement let rows = statement
.query_map([], |row| Ok((row.get(0)?, row.get(1)?))) .query_map([], |row| {
let primary_path: String = row.get(1)?;
let owned_paths = row
.get::<_, Option<String>>(2)?
.and_then(|paths| serde_json::from_str::<Vec<String>>(&paths).ok())
.filter(|paths| !paths.is_empty())
.unwrap_or_else(|| vec![primary_path.clone()]);
Ok((row.get(0)?, primary_path, owned_paths))
})
.map_err(|error| format!("failed to query ownership data: {error}"))?; .map_err(|error| format!("failed to query ownership data: {error}"))?;
rows.collect::<Result<Vec<_>, _>>() rows.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("failed to read ownership data: {error}")) .map_err(|error| format!("failed to read ownership data: {error}"))
} }
pub fn set_ownership(connection: &Connection, id: &str, path: &str) -> Result<(), String> { pub fn set_ownership_paths(
let existing_owner = connection connection: &Connection,
.query_row( id: &str,
"SELECT id FROM download_ownership primary_path: &str,
WHERE primary_path = ?1 AND id <> ?2 paths: &[String],
LIMIT 1", ) -> Result<(), String> {
params![path, id], set_ownership_paths_checked(connection, id, primary_path, paths, &[])
|row| row.get::<_, String>(0), }
fn set_ownership_paths_checked(
connection: &Connection,
id: &str,
primary_path: &str,
paths: &[String],
removal_paths: &[String],
) -> Result<(), String> {
let mut statement = connection
.prepare(
"SELECT ownership.id, ownership.primary_path, paths.paths, removal.paths
FROM download_ownership AS ownership
LEFT JOIN download_owned_paths AS paths ON paths.id = ownership.id
LEFT JOIN download_removal_paths AS removal ON removal.id = ownership.id
WHERE ownership.id <> ?1",
) )
.optional() .map_err(|error| format!("failed to prepare download ownership check: {error}"))?;
.map_err(|error| format!("failed to check download ownership path: {error}"))?; let existing = statement
if existing_owner.is_some() { .query_map(params![id], |row| {
return Err("Download destination is already owned by another Firelink download".to_string()); let primary: String = row.get(1)?;
let owned = row
.get::<_, Option<String>>(2)?
.and_then(|value| serde_json::from_str::<Vec<String>>(&value).ok())
.unwrap_or_else(|| vec![primary.clone()]);
let removal = row
.get::<_, Option<String>>(3)?
.and_then(|value| serde_json::from_str::<Vec<String>>(&value).ok())
.unwrap_or_default();
Ok((primary, owned, removal))
})
.map_err(|error| format!("failed to check download ownership paths: {error}"))?;
for row in existing {
let (existing_primary, owned, removal) =
row.map_err(|error| format!("failed to read download ownership paths: {error}"))?;
let new_paths = std::iter::once(primary_path)
.chain(paths.iter().map(String::as_str))
.chain(removal_paths.iter().map(String::as_str));
let existing_paths = std::iter::once(existing_primary.as_str())
.chain(owned.iter().map(String::as_str))
.chain(removal.iter().map(String::as_str));
if new_paths.clone().any(|new_path| {
existing_paths
.clone()
.any(|existing_path| crate::platform::paths_equal(Path::new(new_path), Path::new(existing_path)))
}) {
return Err("Download destination is already owned by another Firelink download".to_string());
}
} }
connection connection
.execute( .execute(
"INSERT INTO download_ownership (id, primary_path) VALUES (?1, ?2) "INSERT INTO download_ownership (id, primary_path) VALUES (?1, ?2)
ON CONFLICT(id) DO UPDATE SET primary_path = excluded.primary_path", ON CONFLICT(id) DO UPDATE SET primary_path = excluded.primary_path",
params![id, path], params![id, primary_path],
) )
.map_err(|error| format!("failed to save ownership data: {error}"))?; .map_err(|error| format!("failed to save ownership data: {error}"))?;
let encoded_paths = serde_json::to_string(paths)
.map_err(|error| format!("failed to encode download ownership paths: {error}"))?;
connection
.execute(
"INSERT INTO download_owned_paths (id, paths) VALUES (?1, ?2)
ON CONFLICT(id) DO UPDATE SET paths = excluded.paths",
params![id, encoded_paths],
)
.map_err(|error| format!("failed to save download ownership path list: {error}"))?;
Ok(())
}
pub fn set_ownership_and_removal_paths(
connection: &Connection,
id: &str,
primary_path: &str,
paths: &[String],
removal_paths: &[String],
) -> Result<(), String> {
set_ownership_paths_checked(connection, id, primary_path, paths, removal_paths)?;
if removal_paths.is_empty() {
connection
.execute(
"DELETE FROM download_removal_paths WHERE id = ?1",
params![id],
)
.map_err(|error| format!("failed to clear torrent removal paths: {error}"))?;
} else {
let encoded_paths = serde_json::to_string(removal_paths)
.map_err(|error| format!("failed to encode torrent removal paths: {error}"))?;
connection
.execute(
"INSERT INTO download_removal_paths (id, paths) VALUES (?1, ?2)
ON CONFLICT(id) DO UPDATE SET paths = excluded.paths",
params![id, encoded_paths],
)
.map_err(|error| format!("failed to save torrent removal paths: {error}"))?;
}
Ok(()) Ok(())
} }
pub fn remove_ownership(connection: &Connection, id: &str) -> Result<(), String> { pub fn remove_ownership(connection: &Connection, id: &str) -> Result<(), String> {
connection
.execute("DELETE FROM download_removal_paths WHERE id = ?1", params![id])
.map_err(|error| format!("failed to delete torrent removal paths: {error}"))?;
connection
.execute("DELETE FROM download_owned_paths WHERE id = ?1", params![id])
.map_err(|error| format!("failed to delete download ownership paths: {error}"))?;
connection connection
.execute("DELETE FROM download_ownership WHERE id = ?1", params![id]) .execute("DELETE FROM download_ownership WHERE id = ?1", params![id])
.map_err(|error| format!("failed to delete ownership data: {error}"))?; .map_err(|error| format!("failed to delete ownership data: {error}"))?;
Ok(()) Ok(())
} }
pub fn remove_torrent_removal_paths(connection: &Connection, id: &str) -> Result<(), String> {
connection
.execute("DELETE FROM download_removal_paths WHERE id = ?1", params![id])
.map_err(|error| format!("failed to clear torrent removal paths: {error}"))?;
Ok(())
}
pub fn load_torrent_removal_paths(
connection: &Connection,
id: &str,
) -> Result<Vec<String>, String> {
connection
.query_row(
"SELECT paths FROM download_removal_paths WHERE id = ?1",
params![id],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(|error| format!("failed to read torrent removal paths: {error}"))?
.map(|value| {
serde_json::from_str::<Vec<String>>(&value)
.map_err(|error| format!("failed to decode torrent removal paths: {error}"))
})
.transpose()
.map(|paths| paths.unwrap_or_default())
}
pub fn has_user_data(connection: &Connection) -> Result<bool, String> { pub fn has_user_data(connection: &Connection) -> Result<bool, String> {
connection connection
.query_row( .query_row(
@@ -1894,7 +2152,9 @@ mod tests {
"cookies": "session=secret", "cookies": "session=secret",
"headers": "Authorization: Bearer secret", "headers": "Authorization: Bearer secret",
"mirrors": "https://user:secret@example.com/mirror", "mirrors": "https://user:secret@example.com/mirror",
"proxy": "http://user:secret@example.com:8080" "proxy": "http://user:secret@example.com:8080",
"torrentTrackers": "https://tracker.example/announce?passkey=secret",
"torrentExcludeTrackers": "https://tracker.example/exclude?passkey=secret"
}]) }])
.to_string(); .to_string();
@@ -1908,6 +2168,77 @@ mod tests {
for key in ["password", "cookies", "headers", "mirrors", "proxy"] { for key in ["password", "cookies", "headers", "mirrors", "proxy"] {
assert!(saved.get(key).is_none(), "portable data retained {key}"); assert!(saved.get(key).is_none(), "portable data retained {key}");
} }
assert_eq!(saved["torrentTrackers"], "https://tracker.example/announce");
assert_eq!(saved["torrentExcludeTrackers"], "https://tracker.example/exclude");
}
#[test]
fn portable_download_persistence_drops_malformed_tracker_fields() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let mut connection = state.lock().unwrap();
let data = json!([{
"id": "download-malformed-trackers",
"status": "queued",
"queueId": "main",
"url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
"torrentTrackers": { "token": "secret" },
"torrentExcludeTrackers": { "token": "secret" }
}])
.to_string();
replace_downloads(&mut connection, &data, true).unwrap();
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert!(saved.get("torrentTrackers").is_none());
assert!(saved.get("torrentExcludeTrackers").is_none());
assert_eq!(saved["status"], "failed");
assert_eq!(saved["resumable"], false);
assert!(!saved.to_string().contains("secret"));
}
#[test]
fn portable_download_persistence_keeps_wildcard_tracker_exclusion() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let mut connection = state.lock().unwrap();
let data = json!([{
"id": "download-wildcard-exclusion",
"status": "queued",
"queueId": "main",
"url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
"torrentExcludeTrackers": "*"
}])
.to_string();
replace_downloads(&mut connection, &data, true).unwrap();
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert_eq!(saved["torrentExcludeTrackers"], "*");
}
#[test]
fn portable_download_persistence_drops_invalid_tracker_urls() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let mut connection = state.lock().unwrap();
let data = json!([{
"id": "download-invalid-trackers",
"status": "queued",
"queueId": "main",
"url": "https://example.com/file.bin",
"torrentTrackers": "ftp://tracker.example/announce",
"torrentExcludeTrackers": "ftp://tracker.example/announce"
}])
.to_string();
replace_downloads(&mut connection, &data, true).unwrap();
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert!(saved.get("torrentTrackers").is_none());
assert!(saved.get("torrentExcludeTrackers").is_none());
assert_eq!(saved["status"], "failed");
assert_eq!(saved["resumable"], false);
} }
#[test] #[test]
@@ -1937,6 +2268,29 @@ mod tests {
assert!(saved.get("headers").is_none()); assert!(saved.get("headers").is_none());
} }
#[test]
fn portable_magnet_persistence_keeps_identity_but_does_not_resume_after_tracker_removal() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let mut connection = state.lock().unwrap();
let data = json!([{
"id": "magnet-download",
"status": "queued",
"queueId": "main",
"url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example%20Torrent&tr=https%3A%2F%2Ftracker.invalid%2Fsecret"
}])
.to_string();
replace_downloads(&mut connection, &data, true).unwrap();
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert_eq!(saved["url"], "magnet:?xt=urn%3Abtih%3A0123456789abcdef0123456789abcdef01234567&dn=Example+Torrent");
assert_eq!(saved["status"], "failed");
assert_eq!(saved["resumable"], false);
assert!(!saved.to_string().contains("tracker.invalid"));
assert!(!saved.to_string().contains("secret"));
}
#[test] #[test]
fn portable_download_persistence_redacts_error_secrets_but_preserves_safe_errors_and_standard_details() { fn portable_download_persistence_redacts_error_secrets_but_preserves_safe_errors_and_standard_details() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
@@ -2142,15 +2496,95 @@ mod tests {
let state = init_at_path(temp.path()).unwrap(); let state = init_at_path(temp.path()).unwrap();
let connection = state.lock().unwrap(); let connection = state.lock().unwrap();
set_ownership(&connection, "first", "/downloads/file.bin").unwrap(); set_ownership_paths(
let error = set_ownership(&connection, "second", "/downloads/file.bin") &connection,
"first",
"/downloads/file.bin",
&["/downloads/file.bin".to_string()],
)
.unwrap();
let error = set_ownership_paths(
&connection,
"second",
"/downloads/file.bin",
&["/downloads/file.bin".to_string()],
)
.expect_err("a primary path must have one live owner"); .expect_err("a primary path must have one live owner");
assert!(error.contains("already owned")); assert!(error.contains("already owned"));
assert_eq!(load_ownership(&connection).unwrap(), vec![( assert_eq!(
"first".to_string(), load_ownership(&connection).unwrap(),
"/downloads/file.bin".to_string() vec![(
)]); "first".to_string(),
set_ownership(&connection, "first", "/downloads/renamed.bin").unwrap(); "/downloads/file.bin".to_string(),
vec!["/downloads/file.bin".to_string()]
)]
);
set_ownership_paths(
&connection,
"first",
"/downloads/renamed.bin",
&["/downloads/renamed.bin".to_string()],
)
.unwrap();
}
#[test]
fn rejects_overlap_with_any_owned_torrent_path() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let connection = state.lock().unwrap();
set_ownership_paths(
&connection,
"first",
"/downloads/root",
&[
"/downloads/root/a.bin".to_string(),
"/downloads/root/b.bin".to_string(),
],
)
.unwrap();
let error = set_ownership_paths(
&connection,
"second",
"/downloads/root",
&["/downloads/root/c.bin".to_string()],
)
.expect_err("a torrent root must not be reused");
assert!(error.contains("already owned"));
}
#[test]
fn removal_reservations_block_later_download_ownership_claims() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let connection = state.lock().unwrap();
set_ownership_and_removal_paths(
&connection,
"torrent",
"/downloads/selected.bin",
&["/downloads/selected.bin".to_string()],
&["/downloads/unselected.bin".to_string()],
)
.unwrap();
let error = set_ownership_paths(
&connection,
"later",
"/downloads/unselected.bin",
&["/downloads/unselected.bin".to_string()],
)
.expect_err("a planned Torrent deletion must reserve its path");
assert!(error.contains("already owned"));
remove_torrent_removal_paths(&connection, "torrent").unwrap();
set_ownership_paths(
&connection,
"later",
"/downloads/unselected.bin",
&["/downloads/unselected.bin".to_string()],
)
.unwrap();
} }
} }
+188 -15
View File
@@ -7,6 +7,7 @@ use tauri::Manager;
struct DownloadOwnershipRecord { struct DownloadOwnershipRecord {
id: String, id: String,
primary_path: String, primary_path: String,
owned_paths: Vec<String>,
} }
pub fn canonical_download_filename(filename: &str) -> String { pub fn canonical_download_filename(filename: &str) -> String {
@@ -90,8 +91,8 @@ fn truncate_utf8_to_bytes(value: &str, max_bytes: usize) -> String {
value[..end].to_string() value[..end].to_string()
} }
pub fn expected_primary_path( pub fn expected_primary_path<R: tauri::Runtime>(
app_handle: &tauri::AppHandle, app_handle: &tauri::AppHandle<R>,
destination: &str, destination: &str,
filename: &str, filename: &str,
) -> Result<PathBuf, String> { ) -> Result<PathBuf, String> {
@@ -109,8 +110,8 @@ pub fn expected_primary_path(
.ok_or_else(|| "Download path could not be canonicalized".to_string()) .ok_or_else(|| "Download path could not be canonicalized".to_string())
} }
pub fn register_expected( pub fn register_expected<R: tauri::Runtime>(
app_handle: &tauri::AppHandle, app_handle: &tauri::AppHandle<R>,
id: &str, id: &str,
destination: &str, destination: &str,
filename: &str, filename: &str,
@@ -119,11 +120,138 @@ pub fn register_expected(
set_primary_path(app_handle, id, &path) set_primary_path(app_handle, id, &path)
} }
pub fn set_primary_path( pub fn set_primary_path<R: tauri::Runtime>(
app_handle: &tauri::AppHandle, app_handle: &tauri::AppHandle<R>,
id: &str, id: &str,
path: &Path, path: &Path,
) -> Result<(), String> { ) -> Result<(), String> {
set_owned_paths(app_handle, id, &[path.to_path_buf()])
}
pub fn set_owned_paths<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
paths: &[PathBuf],
) -> Result<(), String> {
let primary = paths
.first()
.ok_or_else(|| "Download ownership requires at least one path".to_string())?;
set_owned_paths_with_primary(app_handle, id, primary, paths)
}
pub fn set_owned_paths_with_primary<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
primary: &Path,
paths: &[PathBuf],
) -> Result<(), String> {
if paths.is_empty() {
return Err("Download ownership requires at least one path".to_string());
}
let canonical_primary = canonical_owned_path(app_handle, primary)?;
let mut canonical_paths = Vec::with_capacity(paths.len());
for path in paths {
if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) {
return Err("Download ownership file path is a directory".to_string());
}
let canonical_path = canonical_owned_path(app_handle, path)?;
if !canonical_paths
.iter()
.any(|existing: &PathBuf| crate::platform::paths_equal(existing, &canonical_path))
{
canonical_paths.push(canonical_path);
}
}
let path_strings = canonical_paths
.iter()
.map(|path| path.to_string_lossy().to_string())
.collect::<Vec<_>>();
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
crate::db::set_ownership_paths(
&connection,
id,
&canonical_primary.to_string_lossy(),
&path_strings,
)
}
pub fn set_owned_paths_with_primary_and_removal<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
primary: &Path,
paths: &[PathBuf],
removal_paths: &[PathBuf],
) -> Result<(), String> {
if paths.is_empty() {
return Err("Download ownership requires at least one path".to_string());
}
let canonical_primary = canonical_owned_path(app_handle, primary)?;
let canonical_paths = canonical_file_paths(app_handle, paths)?;
let canonical_removal_paths = canonical_file_paths(app_handle, removal_paths)?;
let mut current_paths = owned_paths_for_id(app_handle, id)?;
if let Some(primary) = primary_path_for_id(app_handle, id)? {
current_paths.push(primary);
}
let known_paths = known_primary_paths(app_handle)?;
if canonical_removal_paths.iter().any(|candidate| {
known_paths.iter().any(|known| {
crate::platform::paths_equal(candidate, known)
&& !current_paths
.iter()
.any(|current| crate::platform::paths_equal(candidate, current))
})
}) {
return Err(
"Torrent removal would delete a file owned by another Firelink download".to_string(),
);
}
let path_strings = canonical_paths
.iter()
.map(|path| path.to_string_lossy().to_string())
.collect::<Vec<_>>();
let removal_strings = canonical_removal_paths
.iter()
.map(|path| path.to_string_lossy().to_string())
.collect::<Vec<_>>();
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
crate::db::set_ownership_and_removal_paths(
&connection,
id,
&canonical_primary.to_string_lossy(),
&path_strings,
&removal_strings,
)
}
fn canonical_file_paths<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
paths: &[PathBuf],
) -> Result<Vec<PathBuf>, String> {
let mut canonical_paths = Vec::with_capacity(paths.len());
for path in paths {
if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) {
return Err("Download ownership file path is a directory".to_string());
}
let canonical_path = canonical_owned_path(app_handle, path)?;
if !canonical_paths
.iter()
.any(|existing: &PathBuf| crate::platform::paths_equal(existing, &canonical_path))
{
canonical_paths.push(canonical_path);
}
}
Ok(canonical_paths)
}
fn canonical_owned_path<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
path: &Path,
) -> Result<PathBuf, String> {
if !path.is_absolute() { if !path.is_absolute() {
return Err("Download ownership path must be absolute".to_string()); return Err("Download ownership path must be absolute".to_string());
} }
@@ -140,18 +268,40 @@ pub fn set_primary_path(
} }
let canonical_path = crate::canonicalize_with_missing_components(path) let canonical_path = crate::canonicalize_with_missing_components(path)
.ok_or_else(|| "Download ownership path could not be canonicalized".to_string())?; .ok_or_else(|| "Download ownership path could not be canonicalized".to_string())?;
if !crate::is_safe_path(&canonical_path, app_handle) {
let database = app_handle.state::<crate::db::DbState>(); return Err("Download ownership path is outside an allowed download location".to_string());
let connection = database.lock()?; }
crate::db::set_ownership(&connection, id, &canonical_path.to_string_lossy()) Ok(canonical_path)
} }
pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> { pub fn remove<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
) -> Result<(), String> {
let database = app_handle.state::<crate::db::DbState>(); let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?; let connection = database.lock()?;
crate::db::remove_ownership(&connection, id) crate::db::remove_ownership(&connection, id)
} }
pub fn clear_torrent_removal_paths<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
) -> Result<(), String> {
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
crate::db::remove_torrent_removal_paths(&connection, id)
}
pub fn torrent_removal_paths_for_id<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
) -> Result<Vec<PathBuf>, String> {
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
crate::db::load_torrent_removal_paths(&connection, id)
.map(|paths| paths.into_iter().map(PathBuf::from).collect())
}
pub fn primary_path_for_id<R: tauri::Runtime>( pub fn primary_path_for_id<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>, app_handle: &tauri::AppHandle<R>,
id: &str, id: &str,
@@ -162,10 +312,27 @@ pub fn primary_path_for_id<R: tauri::Runtime>(
.map(|record| PathBuf::from(record.primary_path))) .map(|record| PathBuf::from(record.primary_path)))
} }
pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> { pub fn owned_paths_for_id<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
) -> Result<Vec<PathBuf>, String> {
Ok(load_records(app_handle)?
.into_iter()
.find(|record| record.id == id)
.map(|record| record.owned_paths.into_iter().map(PathBuf::from).collect())
.unwrap_or_default())
}
pub fn known_primary_paths<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
) -> Result<Vec<PathBuf>, String> {
let mut paths: Vec<PathBuf> = load_records(app_handle)? let mut paths: Vec<PathBuf> = load_records(app_handle)?
.into_iter() .into_iter()
.map(|record| PathBuf::from(record.primary_path)) .flat_map(|record| {
std::iter::once(PathBuf::from(record.primary_path)).chain(
record.owned_paths.into_iter().map(PathBuf::from),
)
})
.collect(); .collect();
// One-time compatibility for downloads created before the backend-owned // One-time compatibility for downloads created before the backend-owned
@@ -185,12 +352,18 @@ fn load_records<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) -> Result<V
crate::db::load_ownership(&connection).map(|records| { crate::db::load_ownership(&connection).map(|records| {
records records
.into_iter() .into_iter()
.map(|(id, primary_path)| DownloadOwnershipRecord { id, primary_path }) .map(|(id, primary_path, owned_paths)| DownloadOwnershipRecord {
id,
primary_path,
owned_paths,
})
.collect() .collect()
}) })
} }
fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> { fn legacy_download_queue_paths<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
) -> Result<Vec<PathBuf>, String> {
let settings = crate::settings::load_settings(app_handle).ok(); let settings = crate::settings::load_settings(app_handle).ok();
let downloads = { let downloads = {
+140
View File
@@ -14,6 +14,26 @@ fn default_sidebar_position() -> String {
"auto".to_string() "auto".to_string()
} }
fn default_torrent_enable_dht() -> bool {
true
}
fn default_torrent_enable_dht6() -> bool {
false
}
fn default_torrent_enable_pex() -> bool {
true
}
fn default_torrent_enable_lpd() -> bool {
false
}
fn default_torrent_max_open_files() -> u32 {
crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)] #[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")] #[ts(export, export_to = "../../src/bindings/")]
@@ -26,6 +46,9 @@ pub enum DownloadStatus {
/// Post-download media processing such as yt-dlp/ffmpeg merging or /// Post-download media processing such as yt-dlp/ffmpeg merging or
/// extraction. The queue permit is still held. /// extraction. The queue permit is still held.
Processing, Processing,
/// A BitTorrent download has all selected data and is still seeding.
/// The Aria2 GID and queue permit remain live until seeding ends.
Seeding,
Paused, Paused,
Completed, Completed,
Failed, Failed,
@@ -42,6 +65,7 @@ impl DownloadStatus {
Self::Staged => "staged", Self::Staged => "staged",
Self::Downloading => "downloading", Self::Downloading => "downloading",
Self::Processing => "processing", Self::Processing => "processing",
Self::Seeding => "seeding",
Self::Paused => "paused", Self::Paused => "paused",
Self::Completed => "completed", Self::Completed => "completed",
Self::Failed => "failed", Self::Failed => "failed",
@@ -144,6 +168,112 @@ pub struct DownloadItem {
pub last_error: Option<String>, pub last_error: Option<String>,
#[ts(optional)] #[ts(optional)]
pub last_try: Option<String>, pub last_try: Option<String>,
#[serde(default)]
#[ts(optional)]
pub is_torrent: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub torrent_path: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_file_indices: Option<Vec<u32>>,
#[serde(default)]
#[ts(optional)]
pub torrent_info_hash: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_seed_time: Option<f64>,
#[serde(default)]
#[ts(optional)]
pub torrent_seed_ratio: Option<f64>,
#[serde(default)]
#[ts(optional)]
pub torrent_upload_limit: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_max_peers: Option<u32>,
#[serde(default)]
#[ts(optional)]
pub torrent_peer_speed_limit: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_check_integrity: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub torrent_trackers: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_exclude_trackers: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_tracker_connect_timeout: Option<u32>,
#[serde(default)]
#[ts(optional)]
pub torrent_tracker_timeout: Option<u32>,
#[serde(default)]
#[ts(optional)]
pub torrent_tracker_interval: Option<u32>,
#[serde(default)]
#[ts(optional)]
pub torrent_stop_timeout: Option<u32>,
#[serde(default)]
#[ts(optional)]
pub torrent_prioritize_piece: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_remove_unselected_file: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub torrent_encryption_policy: Option<String>,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentPeer {
#[ts(type = "number")]
pub download_speed: u64,
#[ts(type = "number")]
pub upload_speed: u64,
pub seeder: bool,
pub am_choking: bool,
pub peer_choking: bool,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentPeerDiagnostics {
#[ts(type = "number")]
pub total_peers: u32,
#[ts(type = "number")]
pub total_seeders: u32,
pub peers: Vec<TorrentPeer>,
pub truncated: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentFile {
pub index: u32,
pub path: String,
#[ts(type = "number")]
pub length: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentMetadata {
pub name: String,
#[ts(type = "number")]
pub total_bytes: u64,
pub files: Vec<TorrentFile>,
pub info_hash: String,
#[serde(default)]
#[ts(optional)]
pub torrent_path: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, TS)] #[derive(Clone, Debug, Serialize, Deserialize, TS)]
@@ -359,6 +489,16 @@ pub struct PersistedSettings {
pub proxy_mode: ProxyMode, pub proxy_mode: ProxyMode,
pub proxy_host: String, pub proxy_host: String,
pub proxy_port: u16, pub proxy_port: u16,
#[serde(default = "default_torrent_enable_dht")]
pub torrent_enable_dht: bool,
#[serde(default = "default_torrent_enable_dht6")]
pub torrent_enable_dht6: bool,
#[serde(default = "default_torrent_enable_pex")]
pub torrent_enable_pex: bool,
#[serde(default = "default_torrent_enable_lpd")]
pub torrent_enable_lpd: bool,
#[serde(default = "default_torrent_max_open_files")]
pub torrent_max_open_files: u32,
pub custom_user_agent: String, pub custom_user_agent: String,
pub ask_where_to_save_each_file: bool, pub ask_where_to_save_each_file: bool,
pub remember_last_used_download_directory: bool, pub remember_last_used_download_directory: bool,
+1120 -23
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -123,12 +123,12 @@ pub fn path_is_within(path: &Path, root: &Path) -> bool {
} }
pub fn paths_equal(left: &Path, right: &Path) -> bool { pub fn paths_equal(left: &Path, right: &Path) -> bool {
#[cfg(target_os = "windows")] #[cfg(any(target_os = "windows", target_os = "macos"))]
{ {
left.to_string_lossy() left.to_string_lossy()
.eq_ignore_ascii_case(&right.to_string_lossy()) .eq_ignore_ascii_case(&right.to_string_lossy())
} }
#[cfg(not(target_os = "windows"))] #[cfg(not(any(target_os = "windows", target_os = "macos")))]
{ {
left == right left == right
} }
+2119 -18
View File
File diff suppressed because it is too large Load Diff
+17 -4
View File
@@ -64,7 +64,7 @@ pub fn backoff_for(strike: usize) -> Duration {
/// Classify an error string as a transient network condition worth retrying. /// Classify an error string as a transient network condition worth retrying.
/// ///
/// Returns `true` for socket drops, connect/read timeouts, connection resets, /// Returns `true` for socket drops, connect/read timeouts, connection resets,
/// and HTTP 408 / request-timeout conditions across both download paths: /// and transient HTTP status conditions across both download paths:
/// ///
/// - **yt-dlp**: stderr lines like `ERROR: unable to ... Connection timed out`, /// - **yt-dlp**: stderr lines like `ERROR: unable to ... Connection timed out`,
/// `HTTP Error 408`. /// `HTTP Error 408`.
@@ -164,9 +164,12 @@ pub fn is_transient_network_error(message: &str) -> bool {
"timeout.", "timeout.",
"invalid range header", "invalid range header",
]; ];
contains_http_status(&m, "408") const TRANSIENT_HTTP_STATUS: [&str; 11] = [
|| contains_http_status(&m, "429") "408", "429", "500", "502", "503", "504", "520", "521", "522", "523", "524",
|| contains_http_status(&m, "503") ];
TRANSIENT_HTTP_STATUS
.iter()
.any(|status| contains_http_status(&m, status))
|| TRANSIENT.iter().any(|t| m.contains(t)) || TRANSIENT.iter().any(|t| m.contains(t))
} }
@@ -292,6 +295,16 @@ mod tests {
assert!(is_transient_network_error("The response status is not successful. status=429")); assert!(is_transient_network_error("The response status is not successful. status=429"));
} }
#[test]
fn classifies_rpc_http_gateway_errors_as_transient() {
for status in [500, 502, 503, 504, 520, 521, 522, 523, 524] {
assert!(
is_transient_network_error(&format!("HTTP {status} gateway failure")),
"HTTP {status} should be retryable"
);
}
}
#[test] #[test]
fn classifies_ytdlp_and_aria2_phrasing_as_transient() { fn classifies_ytdlp_and_aria2_phrasing_as_transient() {
assert!(is_transient_network_error( assert!(is_transient_network_error(
+45
View File
@@ -188,6 +188,23 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
sanitize_integer_setting(state, "maxConcurrentDownloads", |value| value.as_u64().is_some()); sanitize_integer_setting(state, "maxConcurrentDownloads", |value| value.as_u64().is_some());
sanitize_integer_setting(state, "perServerConnections", |value| value.as_i64().is_some()); sanitize_integer_setting(state, "perServerConnections", |value| value.as_i64().is_some());
sanitize_integer_setting(state, "maxAutomaticRetries", |value| value.as_i64().is_some()); sanitize_integer_setting(state, "maxAutomaticRetries", |value| value.as_i64().is_some());
sanitize_integer_setting(state, "torrentMaxOpenFiles", |value| {
value
.as_u64()
.is_some_and(|value| {
(crate::queue::MIN_TORRENT_MAX_OPEN_FILES as u64..=
crate::queue::MAX_TORRENT_MAX_OPEN_FILES as u64)
.contains(&value)
})
});
for key in [
"torrentEnableDht",
"torrentEnableDht6",
"torrentEnablePex",
"torrentEnableLpd",
] {
sanitize_boolean_setting(state, key);
}
sanitize_allowed_string( sanitize_allowed_string(
state, state,
"theme", "theme",
@@ -273,6 +290,12 @@ fn sanitize_integer_setting(
} }
} }
fn sanitize_boolean_setting(state: &mut serde_json::Map<String, Value>, key: &str) {
if state.get(key).is_some_and(|value| !value.is_boolean()) {
state.remove(key);
}
}
fn sanitize_allowed_string( fn sanitize_allowed_string(
state: &mut serde_json::Map<String, Value>, state: &mut serde_json::Map<String, Value>,
key: &str, key: &str,
@@ -294,6 +317,10 @@ fn validate_settings(settings: &mut PersistedSettings) {
settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12); settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12);
settings.per_server_connections = settings.per_server_connections.clamp(1, 16); settings.per_server_connections = settings.per_server_connections.clamp(1, 16);
settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10); settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10);
settings.torrent_max_open_files = crate::queue::normalize_torrent_max_open_files(
settings.torrent_max_open_files,
)
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES);
if !matches!( if !matches!(
settings.last_custom_speed_limit_unit.as_str(), settings.last_custom_speed_limit_unit.as_str(),
"KB/s" | "MB/s" "KB/s" | "MB/s"
@@ -482,6 +509,11 @@ fn default_settings() -> PersistedSettings {
proxy_mode: ProxyMode::None, proxy_mode: ProxyMode::None,
proxy_host: String::new(), proxy_host: String::new(),
proxy_port: 8080, proxy_port: 8080,
torrent_enable_dht: true,
torrent_enable_dht6: false,
torrent_enable_pex: true,
torrent_enable_lpd: false,
torrent_max_open_files: crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES,
custom_user_agent: String::new(), custom_user_agent: String::new(),
ask_where_to_save_each_file: false, ask_where_to_save_each_file: false,
remember_last_used_download_directory: false, remember_last_used_download_directory: false,
@@ -770,6 +802,11 @@ mod tests {
"maxConcurrentDownloads": "not-a-number", "maxConcurrentDownloads": "not-a-number",
"perServerConnections": 5, "perServerConnections": 5,
"showNotifications": "yes", "showNotifications": "yes",
"torrentEnableDht": "yes",
"torrentEnableDht6": 1,
"torrentEnablePex": null,
"torrentEnableLpd": [],
"torrentMaxOpenFiles": 0,
"theme": "not-a-theme", "theme": "not-a-theme",
"calendarPreference": "lunar", "calendarPreference": "lunar",
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}] "siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
@@ -783,6 +820,14 @@ mod tests {
assert_eq!(settings.max_concurrent_downloads, 3); assert_eq!(settings.max_concurrent_downloads, 3);
assert_eq!(settings.per_server_connections, 5); assert_eq!(settings.per_server_connections, 5);
assert!(settings.show_notifications); assert!(settings.show_notifications);
assert!(settings.torrent_enable_dht);
assert!(!settings.torrent_enable_dht6);
assert!(settings.torrent_enable_pex);
assert!(!settings.torrent_enable_lpd);
assert_eq!(
settings.torrent_max_open_files,
crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES
);
assert!(matches!(settings.theme, crate::ipc::Theme::System)); assert!(matches!(settings.theme, crate::ipc::Theme::System));
assert!(matches!( assert!(matches!(
settings.calendar_preference, settings.calendar_preference,
+809
View File
@@ -0,0 +1,809 @@
use sha1::{Digest, Sha1};
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use tauri::Manager;
use crate::ipc::{TorrentFile, TorrentMetadata};
pub const MAX_TORRENT_BYTES: usize = 16 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct ParsedTorrent {
pub name: String,
pub total_bytes: u64,
pub files: Vec<TorrentFile>,
pub info_hash: String,
}
#[derive(Debug, Clone)]
enum BencodeValue {
Integer(i64),
Bytes(Vec<u8>),
List(Vec<BencodeValue>),
Dict(BTreeMap<Vec<u8>, BencodeValue>),
}
struct Parser<'a> {
input: &'a [u8],
position: usize,
depth: usize,
}
impl<'a> Parser<'a> {
fn new(input: &'a [u8]) -> Self {
Self { input, position: 0, depth: 0 }
}
fn parse(mut self) -> Result<BencodeValue, String> {
let value = self.parse_value()?;
if self.position != self.input.len() {
return Err("torrent metadata has trailing data".to_string());
}
Ok(value)
}
fn parse_value(&mut self) -> Result<BencodeValue, String> {
if self.depth >= 128 {
return Err("torrent metadata nesting is too deep".to_string());
}
let byte = *self
.input
.get(self.position)
.ok_or_else(|| "torrent metadata is truncated".to_string())?;
match byte {
b'i' => self.parse_integer(),
b'l' => self.parse_list(),
b'd' => self.parse_dict(),
b'0'..=b'9' => self.parse_bytes(),
_ => Err("torrent metadata contains an invalid bencode value".to_string()),
}
}
fn parse_integer(&mut self) -> Result<BencodeValue, String> {
self.position += 1;
let start = self.position;
while let Some(byte) = self.input.get(self.position) {
if *byte == b'e' {
break;
}
self.position += 1;
}
if self.input.get(self.position) != Some(&b'e') {
return Err("torrent integer is truncated".to_string());
}
let raw = std::str::from_utf8(&self.input[start..self.position])
.map_err(|_| "torrent integer is not valid UTF-8".to_string())?;
if raw.is_empty() || (raw.starts_with('0') && raw.len() > 1) || raw == "-0" {
return Err("torrent integer has invalid encoding".to_string());
}
let value = raw
.parse::<i64>()
.map_err(|_| "torrent integer is outside the supported range".to_string())?;
self.position += 1;
Ok(BencodeValue::Integer(value))
}
fn parse_bytes(&mut self) -> Result<BencodeValue, String> {
let start = self.position;
while let Some(byte) = self.input.get(self.position) {
if *byte == b':' {
break;
}
if !byte.is_ascii_digit() {
return Err("torrent byte string has an invalid length".to_string());
}
self.position += 1;
}
if self.input.get(self.position) != Some(&b':') {
return Err("torrent byte string is truncated".to_string());
}
let length = std::str::from_utf8(&self.input[start..self.position])
.map_err(|_| "torrent byte string length is invalid".to_string())?
.to_owned();
if (length.starts_with('0') && length.len() > 1) || length.is_empty() {
return Err("torrent byte string has invalid length encoding".to_string());
}
let length = length
.parse::<usize>()
.map_err(|_| "torrent byte string length is too large".to_string())?;
self.position += 1;
let end = self
.position
.checked_add(length)
.ok_or_else(|| "torrent byte string length overflowed".to_string())?;
if end > self.input.len() {
return Err("torrent byte string is truncated".to_string());
}
let value = self.input[self.position..end].to_vec();
self.position = end;
Ok(BencodeValue::Bytes(value))
}
fn parse_list(&mut self) -> Result<BencodeValue, String> {
self.position += 1;
self.depth += 1;
let mut values = Vec::new();
while self.input.get(self.position) != Some(&b'e') {
if self.input.get(self.position).is_none() {
self.depth -= 1;
return Err("torrent list is truncated".to_string());
}
values.push(self.parse_value()?);
}
self.position += 1;
self.depth -= 1;
Ok(BencodeValue::List(values))
}
fn parse_dict(&mut self) -> Result<BencodeValue, String> {
self.position += 1;
self.depth += 1;
let mut values = BTreeMap::new();
let mut previous_key: Option<Vec<u8>> = None;
while self.input.get(self.position) != Some(&b'e') {
if self.input.get(self.position).is_none() {
self.depth -= 1;
return Err("torrent dictionary is truncated".to_string());
}
let key = match self.parse_bytes()? {
BencodeValue::Bytes(key) => key,
_ => unreachable!(),
};
if previous_key.as_ref().is_some_and(|previous| previous >= &key) {
self.depth -= 1;
return Err("torrent dictionary keys are not sorted".to_string());
}
previous_key = Some(key.clone());
values.insert(key, self.parse_value()?);
}
self.position += 1;
self.depth -= 1;
Ok(BencodeValue::Dict(values))
}
}
fn encode(value: &BencodeValue, output: &mut Vec<u8>) {
match value {
BencodeValue::Integer(value) => output.extend_from_slice(format!("i{value}e").as_bytes()),
BencodeValue::Bytes(value) => {
output.extend_from_slice(value.len().to_string().as_bytes());
output.push(b':');
output.extend_from_slice(value);
}
BencodeValue::List(values) => {
output.push(b'l');
for value in values {
encode(value, output);
}
output.push(b'e');
}
BencodeValue::Dict(values) => {
output.push(b'd');
for (key, value) in values {
encode(&BencodeValue::Bytes(key.clone()), output);
encode(value, output);
}
output.push(b'e');
}
}
}
fn bytes_text(value: Option<&BencodeValue>, field: &str) -> Result<String, String> {
let bytes = match value {
Some(BencodeValue::Bytes(bytes)) => bytes,
_ => return Err(format!("torrent metadata is missing {field}")),
};
String::from_utf8(bytes.clone()).map_err(|_| format!("torrent {field} is not valid UTF-8"))
}
fn positive_length(value: Option<&BencodeValue>, field: &str) -> Result<u64, String> {
match value {
Some(BencodeValue::Integer(value)) if *value >= 0 => Ok(*value as u64),
_ => Err(format!("torrent {field} is invalid")),
}
}
fn safe_path_component(value: &str, field: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty() || value == "." || value == ".." || value.contains(['/', '\\', '\0']) {
return Err(format!("torrent {field} contains an unsafe path"));
}
Ok(crate::download_ownership::canonical_download_filename(value))
}
fn parse_info(info: &BencodeValue) -> Result<ParsedTorrent, String> {
let info_dict = match info {
BencodeValue::Dict(value) => value,
_ => return Err("torrent info dictionary is invalid".to_string()),
};
let name = info_dict
.get(b"name.utf-8".as_slice())
.or_else(|| info_dict.get(b"name".as_slice()))
.map(|value| bytes_text(Some(value), "name"))
.transpose()?
.ok_or_else(|| "torrent metadata is missing name".to_string())?;
let name = crate::download_ownership::canonical_download_filename(&safe_path_component(&name, "name")?);
let mut files = Vec::new();
let total_bytes = if let Some(files_value) = info_dict.get(b"files".as_slice()) {
let entries = match files_value {
BencodeValue::List(entries) => entries,
_ => return Err("torrent files field is invalid".to_string()),
};
let mut total = 0u64;
let mut paths = HashSet::new();
for (position, entry) in entries.iter().enumerate() {
let entry = match entry {
BencodeValue::Dict(value) => value,
_ => return Err(format!("torrent file entry {} is invalid", position + 1)),
};
let path_value = entry
.get(b"path.utf-8".as_slice())
.or_else(|| entry.get(b"path".as_slice()))
.ok_or_else(|| format!("torrent file entry {} has no path", position + 1))?;
let path_parts = match path_value {
BencodeValue::List(parts) => parts
.iter()
.map(|part| bytes_text(Some(part), "file path"))
.collect::<Result<Vec<_>, _>>()?,
_ => return Err(format!("torrent file entry {} path is invalid", position + 1)),
};
if path_parts.is_empty() {
return Err(format!("torrent file entry {} path is empty", position + 1));
}
let path = path_parts
.iter()
.map(|part| safe_path_component(part, "file path"))
.collect::<Result<Vec<_>, _>>()?
.join("/");
if !paths.insert(path.clone()) {
return Err("torrent contains duplicate output paths".to_string());
}
let length = positive_length(entry.get(b"length".as_slice()), "file length")?;
total = total
.checked_add(length)
.ok_or_else(|| "torrent total size is too large".to_string())?;
files.push(TorrentFile {
index: (position + 1) as u32,
path,
length,
});
}
if files.is_empty() {
return Err("torrent has no files".to_string());
}
total
} else {
let length = positive_length(info_dict.get(b"length".as_slice()), "length")?;
files.push(TorrentFile {
index: 1,
path: name.clone(),
length,
});
length
};
let mut encoded = Vec::new();
encode(info, &mut encoded);
let digest = Sha1::digest(encoded);
let info_hash = digest.iter().map(|byte| format!("{byte:02x}")).collect();
Ok(ParsedTorrent { name, total_bytes, files, info_hash })
}
fn canonical_btih(value: &str) -> Option<String> {
let normalized = value.trim().to_ascii_lowercase();
if normalized.len() == 40 && normalized.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Some(normalized);
}
if normalized.len() != 32 {
return None;
}
let mut decoded = Vec::with_capacity(20);
let mut accumulator = 0u64;
let mut bits = 0u8;
for byte in normalized.bytes() {
let value = match byte {
b'a'..=b'z' => byte - b'a',
b'2'..=b'7' => byte - b'2' + 26,
_ => return None,
} as u64;
accumulator = (accumulator << 5) | value;
bits += 5;
while bits >= 8 {
bits -= 8;
decoded.push(((accumulator >> bits) & 0xff) as u8);
}
if bits == 0 {
accumulator = 0;
} else {
accumulator &= (1u64 << bits) - 1;
}
}
if bits != 0 || decoded.len() != 20 {
return None;
}
Some(decoded.iter().map(|byte| format!("{byte:02x}")).collect())
}
pub fn validate_info_hash(expected: Option<&str>, actual: &str) -> Result<(), String> {
let Some(expected) = expected else {
return Ok(());
};
let expected = canonical_btih(expected)
.ok_or_else(|| "torrent metadata has an invalid expected info hash".to_string())?;
if expected != actual {
return Err("torrent metadata changed before it was queued".to_string());
}
Ok(())
}
pub fn parse_torrent_bytes(bytes: &[u8]) -> Result<ParsedTorrent, String> {
if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES {
return Err(format!("torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes"));
}
let root = Parser::new(bytes).parse()?;
let root = match root {
BencodeValue::Dict(value) => value,
_ => return Err("torrent root is not a dictionary".to_string()),
};
let info = root
.get(b"info".as_slice())
.ok_or_else(|| "torrent metadata is missing info".to_string())?;
parse_info(info)
}
fn magnet_metadata(source: &str) -> Result<ParsedTorrent, String> {
let parsed = url::Url::parse(source).map_err(|_| "invalid magnet URI".to_string())?;
if parsed.scheme() != "magnet" {
return Err("unsupported torrent source".to_string());
}
let info_hash = parsed
.query_pairs()
.find_map(|(key, value)| {
if key != "xt" {
return None;
}
let value = value.strip_prefix("urn:btih:")?;
canonical_btih(value)
})
.ok_or_else(|| "magnet URI has no valid BitTorrent info hash".to_string())?;
let name = parsed
.query_pairs()
.find_map(|(key, value)| (key == "dn").then_some(value.into_owned()))
.filter(|value| !value.trim().is_empty())
.map(|value| crate::download_ownership::canonical_download_filename(&value))
.unwrap_or_else(|| format!("torrent-{info_hash}"));
Ok(ParsedTorrent { name, total_bytes: 0, files: Vec::new(), info_hash })
}
fn local_torrent_path(source: &str) -> Result<PathBuf, String> {
let path = match url::Url::parse(source) {
Ok(parsed) if parsed.scheme() == "file" => parsed
.to_file_path()
.map_err(|_| "invalid local torrent path".to_string())?,
_ => PathBuf::from(source),
};
if !path.is_absolute() {
return Err("torrent source is not an absolute local file".to_string());
}
let path = std::fs::canonicalize(&path)
.map_err(|error| format!("could not resolve torrent file: {error}"))?;
if path.extension().and_then(|extension| extension.to_str()).map(|extension| extension.eq_ignore_ascii_case("torrent")) != Some(true) {
return Err("torrent files must use the .torrent extension".to_string());
}
Ok(path)
}
pub fn inspect_source(source: &str) -> Result<ParsedTorrent, String> {
if source.trim_start().to_ascii_lowercase().starts_with("magnet:") {
return magnet_metadata(source.trim());
}
let path = local_torrent_path(source)?;
let bytes = std::fs::read(&path).map_err(|error| format!("could not read torrent file: {error}"))?;
parse_torrent_bytes(&bytes)
}
/// Remote torrent metadata is fetched and cached before enqueue so it follows
/// the same validated `addTorrent`, ownership, retry, and restart path as
/// local metadata and magnets.
pub fn is_remote_torrent_url(source: &str) -> bool {
let Ok(parsed) = url::Url::parse(source.trim()) else {
return false;
};
matches!(parsed.scheme(), "http" | "https")
&& parsed
.path_segments()
.and_then(|segments| segments.last())
.is_some_and(|name| name.to_ascii_lowercase().ends_with(".torrent"))
}
pub fn to_metadata(parsed: ParsedTorrent, torrent_path: Option<String>) -> TorrentMetadata {
TorrentMetadata {
name: parsed.name,
total_bytes: parsed.total_bytes,
files: parsed.files,
info_hash: parsed.info_hash,
torrent_path,
}
}
/// Aria2's BitTorrent output is controlled by `index-out`, not `out`. Keep
/// these values derived from the validated, canonical paths so the daemon's
/// actual files stay aligned with Firelink's ownership registry.
pub fn aria2_index_outputs(parsed: &ParsedTorrent) -> Vec<String> {
parsed
.files
.iter()
.map(|file| {
let output = if parsed.files.len() == 1 {
file.path.clone()
} else {
format!("{}/{}", parsed.name, file.path)
};
format!("{}={output}", file.index)
})
.collect()
}
pub fn aria2_output_paths(parsed: &ParsedTorrent, selected: Option<&[u32]>) -> Vec<String> {
parsed
.files
.iter()
.filter(|file| selected.is_none_or(|indices| indices.contains(&file.index)))
.map(|file| {
if parsed.files.len() == 1 {
file.path.clone()
} else {
format!("{}/{}", parsed.name, file.path)
}
})
.collect()
}
pub fn managed_torrent_path<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
) -> Result<PathBuf, String> {
if id.is_empty() || !id.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') {
return Err("invalid torrent download id".to_string());
}
let root = managed_torrent_storage_root(app_handle)?;
Ok(root.join(format!("{id}.torrent")))
}
pub fn managed_torrent_storage_root<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
) -> Result<PathBuf, String> {
let root = app_handle
.path()
.app_data_dir()
.map_err(|error| format!("could not resolve torrent storage: {error}"))?
.join("torrents");
Ok(root)
}
pub fn remove_orphaned_probe_dirs<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
) -> Result<usize, String> {
let root = managed_torrent_storage_root(app_handle)?;
remove_orphaned_probe_dirs_at(&root)
}
fn remove_orphaned_probe_dirs_at(root: &Path) -> Result<usize, String> {
let entries = match std::fs::read_dir(&root) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(error) => return Err(format!("could not inspect torrent probe storage: {error}")),
};
let mut removed = 0;
for entry in entries {
let entry = entry.map_err(|error| format!("could not inspect torrent probe storage: {error}"))?;
let is_probe = entry
.file_name()
.to_str()
.is_some_and(|name| name.starts_with(".probe-"));
if !is_probe || !entry
.file_type()
.map_err(|error| format!("could not inspect torrent probe entry: {error}"))?
.is_dir()
{
continue;
}
match std::fs::remove_dir_all(entry.path()) {
Ok(()) => removed += 1,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("could not remove orphaned torrent probe: {error}")),
}
}
Ok(removed)
}
pub fn remove_orphaned_cached_torrents<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
retained_ids: &HashSet<String>,
) -> Result<usize, String> {
let root = managed_torrent_storage_root(app_handle)?;
remove_orphaned_cached_torrents_at(&root, retained_ids)
}
fn remove_orphaned_cached_torrents_at(
root: &Path,
retained_ids: &HashSet<String>,
) -> Result<usize, String> {
let entries = match std::fs::read_dir(&root) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(error) => return Err(format!("could not inspect torrent metadata storage: {error}")),
};
let mut removed = 0;
for entry in entries {
let entry = entry.map_err(|error| format!("could not inspect torrent metadata storage: {error}"))?;
let file_type = entry
.file_type()
.map_err(|error| format!("could not inspect torrent metadata entry: {error}"))?;
if !file_type.is_file() || entry.path().extension().and_then(|ext| ext.to_str()) != Some("torrent") {
continue;
}
let path = entry.path();
let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
if retained_ids.contains(id) {
continue;
}
match std::fs::remove_file(path) {
Ok(()) => removed += 1,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!("could not remove orphaned torrent metadata: {error}"));
}
}
}
Ok(removed)
}
pub fn validate_selected_indices(
selected: Option<&[u32]>,
file_count: usize,
) -> Result<Option<Vec<u32>>, String> {
let Some(selected) = selected else {
return Ok(None);
};
if selected.is_empty() || selected.iter().any(|index| *index == 0 || *index as usize > file_count) {
return Err("torrent file selection is invalid".to_string());
}
let mut normalized = selected.to_vec();
normalized.sort_unstable();
normalized.dedup();
Ok(Some(normalized))
}
pub async fn prepare_local_torrent<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
source: &str,
id: &str,
) -> Result<(ParsedTorrent, String), String> {
let source_path = local_torrent_path(source)?;
let file_metadata = tokio::fs::metadata(&source_path)
.await
.map_err(|error| format!("could not inspect torrent file: {error}"))?;
if file_metadata.len() == 0 || file_metadata.len() > MAX_TORRENT_BYTES as u64 {
return Err(format!(
"torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes"
));
}
let bytes = tokio::fs::read(&source_path)
.await
.map_err(|error| format!("could not read torrent file: {error}"))?;
let parsed = parse_torrent_bytes(&bytes)?;
let destination = cache_torrent_bytes(app_handle, id, &bytes).await?;
Ok((parsed, destination))
}
pub async fn cache_torrent_bytes<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
bytes: &[u8],
) -> Result<String, String> {
if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES {
return Err(format!(
"torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes"
));
}
let destination = managed_torrent_path(app_handle, id)?;
if let Some(parent) = destination.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|error| format!("could not create torrent storage: {error}"))?;
}
tokio::fs::write(&destination, bytes)
.await
.map_err(|error| format!("could not cache torrent metadata: {error}"))?;
Ok(destination.to_string_lossy().to_string())
}
pub fn validate_managed_torrent_path<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
path: &str,
) -> Result<PathBuf, String> {
let expected = managed_torrent_path(app_handle, id)?;
let candidate = std::fs::canonicalize(path)
.map_err(|error| format!("could not access cached torrent metadata: {error}"))?;
let expected_parent = expected
.parent()
.and_then(|parent| std::fs::canonicalize(parent).ok())
.ok_or_else(|| "cached torrent storage is unavailable".to_string())?;
if candidate.parent() != Some(expected_parent.as_path()) || candidate.file_name() != expected.file_name() {
return Err("cached torrent metadata path is invalid".to_string());
}
Ok(candidate)
}
pub async fn remove_managed_torrent<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
) {
if let Ok(path) = managed_torrent_path(app_handle, id) {
let _ = tokio::fs::remove_file(path).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_single_file_torrent_and_hashes_info_dictionary() {
let parsed = parse_torrent_bytes(b"d4:infod6:lengthi5e4:name4:testee")
.expect("single-file torrent should parse");
assert_eq!(parsed.name, "test");
assert_eq!(parsed.total_bytes, 5);
assert_eq!(parsed.files[0].index, 1);
assert_eq!(parsed.files[0].path, "test");
assert_eq!(parsed.info_hash.len(), 40);
}
#[test]
fn parses_multi_file_torrent_and_rejects_traversal() {
let parsed = parse_torrent_bytes(
b"d4:infod5:filesld6:lengthi2e4:pathl4:root5:a.txteed6:lengthi3e4:pathl4:root5:b.bineee4:name4:rootee",
)
.expect("multi-file torrent should parse");
assert_eq!(parsed.total_bytes, 5);
assert_eq!(parsed.files.len(), 2);
assert_eq!(parsed.files[1].path, "root/b.bin");
let error = parse_torrent_bytes(
b"d4:infod5:filesld6:lengthi1e4:pathl2:..4:evileee4:name4:rootee",
)
.expect_err("path traversal must be rejected");
assert!(error.contains("unsafe path"));
}
#[test]
fn parses_magnet_identity_without_logging_or_persisting_tracker_data() {
let parsed = inspect_source(
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example%20Torrent&tr=https%3A%2F%2Ftracker.invalid%2Fsecret",
)
.expect("magnet should parse");
assert_eq!(parsed.name, "Example Torrent");
assert_eq!(parsed.info_hash, "0123456789abcdef0123456789abcdef01234567");
assert!(parsed.files.is_empty());
}
#[test]
fn canonicalizes_base32_magnet_hashes_to_hex() {
let parsed = inspect_source(
"magnet:?xt=urn:btih:AERUKZ4JVPG66AJDIVTYTK6N54ASGRLH&dn=Base32",
)
.expect("a valid Base32 hash should parse");
assert_eq!(parsed.info_hash, "0123456789abcdef0123456789abcdef01234567");
}
#[test]
fn validates_expected_hashes_across_hex_and_base32_encodings() {
validate_info_hash(
Some("AERUKZ4JVPG66AJDIVTYTK6N54ASGRLH"),
"0123456789abcdef0123456789abcdef01234567",
)
.expect("equivalent Base32 and hexadecimal hashes should match");
assert!(validate_info_hash(
Some("0123456789abcdef0123456789abcdef01234567"),
"fedcba9876543210fedcba9876543210fedcba98",
)
.is_err());
validate_info_hash(None, "not-used").expect("missing legacy identity should remain compatible");
}
#[test]
fn rejects_malformed_base32_magnet_hashes() {
let error = inspect_source(
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcde!&dn=Invalid",
)
.expect_err("a 32-character hash with non-base32 characters must be rejected");
assert!(error.contains("valid BitTorrent info hash"));
let error = inspect_source(
"magnet:?xt=urn:btih:00000000000000000000000000000000&dn=Invalid",
)
.expect_err("characters outside RFC 4648 Base32 must be rejected");
assert!(error.contains("valid BitTorrent info hash"));
}
#[test]
fn normalizes_and_bounds_selected_file_indices() {
assert_eq!(
validate_selected_indices(Some(&[3, 1, 3]), 3).unwrap(),
Some(vec![1, 3])
);
assert!(validate_selected_indices(Some(&[0]), 3).is_err());
assert!(validate_selected_indices(Some(&[4]), 3).is_err());
}
#[test]
fn maps_validated_torrent_files_to_aria2_index_outputs() {
let parsed = parse_torrent_bytes(
b"d4:infod5:filesld6:lengthi2e4:pathl4:root5:a.txteed6:lengthi3e4:pathl4:root5:b.bineee4:name4:rootee",
)
.expect("multi-file torrent should parse");
assert_eq!(
aria2_index_outputs(&parsed),
vec!["1=root/root/a.txt".to_string(), "2=root/root/b.bin".to_string()]
);
}
#[test]
fn recognizes_only_http_torrent_metadata_urls() {
assert!(is_remote_torrent_url("https://example.com/files/sample.torrent"));
assert!(is_remote_torrent_url("http://example.com/sample.TORRENT?download=1"));
assert!(!is_remote_torrent_url("https://example.com/files/sample.zip"));
assert!(!is_remote_torrent_url("ftp://example.com/files/sample.torrent"));
assert!(!is_remote_torrent_url(
"magnet:?xt=urn:btih:0123456789012345678901234567890123456789"
));
}
#[test]
fn rejects_noncanonical_lengths_and_invalid_files_field() {
assert!(parse_torrent_bytes(b"d4:infod6:lengthi5e4:name04:testee").is_err());
assert!(parse_torrent_bytes(b"d4:infod5:filesi1e6:lengthi5e4:name4:testee").is_err());
}
#[test]
fn removes_only_orphaned_probe_directories() {
let temporary = tempfile::tempdir().expect("temporary torrent storage should exist");
let root = temporary.path();
std::fs::create_dir(root.join(".probe-stale")).expect("probe directory should exist");
std::fs::write(root.join(".probe-file"), b"not a directory")
.expect("probe marker file should exist");
std::fs::create_dir(root.join("retained-dir")).expect("unrelated directory should exist");
assert_eq!(remove_orphaned_probe_dirs_at(root).unwrap(), 1);
assert!(!root.join(".probe-stale").exists());
assert!(root.join(".probe-file").is_file());
assert!(root.join("retained-dir").is_dir());
}
#[test]
fn removes_unretained_torrent_files_but_preserves_retained_and_unrelated_entries() {
let temporary = tempfile::tempdir().expect("temporary torrent storage should exist");
let root = temporary.path();
std::fs::write(root.join("keep-id.torrent"), b"retained")
.expect("retained metadata should exist");
std::fs::write(root.join("orphan-id.torrent"), b"orphan")
.expect("orphan metadata should exist");
std::fs::write(root.join("notes.txt"), b"unrelated")
.expect("unrelated file should exist");
let retained = HashSet::from(["keep-id".to_string()]);
assert_eq!(remove_orphaned_cached_torrents_at(root, &retained).unwrap(), 1);
assert!(root.join("keep-id.torrent").is_file());
assert!(!root.join("orphan-id.torrent").exists());
assert!(root.join("notes.txt").is_file());
}
}
File diff suppressed because it is too large Load Diff
+32
View File
@@ -23,3 +23,35 @@ cd src-tauri
FIRELINK_LIVE_YOUTUBE_URL='https://www.youtube.com/watch?v=dQw4w9WgXcQ' \ FIRELINK_LIVE_YOUTUBE_URL='https://www.youtube.com/watch?v=dQw4w9WgXcQ' \
cargo test filters_live_youtube_metadata_from_env --lib -- --ignored --nocapture cargo test filters_live_youtube_metadata_from_env --lib -- --ignored --nocapture
``` ```
Run the real local Torrent runtime smoke test against the host bundled Aria2
binary. It starts a local tracker and two Aria2 daemons, then covers magnet
metadata resolution, saved-metadata hash validation, selected-file output,
pause/resume, ownership reporting, and cancel/remove:
```sh
npm run smoke:torrent
```
Use `node scripts/smoke-torrent.js --binary /path/to/aria2c` when validating a
packaged or target-specific Aria2 binary.
Run the HTTP-boundary Torrent RPC integration test. It drives the production
Aria2 RPC client through a local JSON-RPC server and verifies successful
requests plus HTTP gateway errors:
```sh
npm run test:torrent:rpc
```
Run the deterministic unavailable-tracker and Aria2-daemon-exit checks with:
```sh
npm run smoke:torrent:failure-paths
```
Native CI runs this failure-path smoke after staging the target-specific
bundled engines on macOS, Windows, and Linux. Windows runs this RPC
integration test through its target-qualified `cargo test --tests` step;
the general Rust job compiles the library tests without executing the
known-broken Tauri library harness.
+438 -1
View File
@@ -15,7 +15,18 @@ struct CountingSpawner {
media_calls: AtomicUsize, media_calls: AtomicUsize,
speed_limit_calls: AtomicUsize, speed_limit_calls: AtomicUsize,
last_speed_limit: std::sync::Mutex<Option<String>>, last_speed_limit: std::sync::Mutex<Option<String>>,
torrent_upload_limit_calls: AtomicUsize,
last_torrent_upload_limit: std::sync::Mutex<Option<String>>,
block_torrent_upload_limit: std::sync::atomic::AtomicBool,
torrent_upload_limit_started: tokio::sync::Notify,
torrent_upload_limit_release: tokio::sync::Notify,
torrent_peer_options_calls: AtomicUsize,
last_torrent_peer_options: std::sync::Mutex<Option<(u32, String)>>,
block_torrent_peer_options: std::sync::atomic::AtomicBool,
torrent_peer_options_started: tokio::sync::Notify,
torrent_peer_options_release: tokio::sync::Notify,
add_speed_limits: std::sync::Mutex<Vec<Option<String>>>, add_speed_limits: std::sync::Mutex<Vec<Option<String>>>,
add_peer_options: std::sync::Mutex<Vec<(Option<u32>, Option<String>)>>,
block_speed_limit: std::sync::atomic::AtomicBool, block_speed_limit: std::sync::atomic::AtomicBool,
speed_limit_started: tokio::sync::Notify, speed_limit_started: tokio::sync::Notify,
speed_limit_release: tokio::sync::Notify, speed_limit_release: tokio::sync::Notify,
@@ -188,7 +199,18 @@ impl CountingSpawner {
media_calls: AtomicUsize::new(0), media_calls: AtomicUsize::new(0),
speed_limit_calls: AtomicUsize::new(0), speed_limit_calls: AtomicUsize::new(0),
last_speed_limit: std::sync::Mutex::new(None), last_speed_limit: std::sync::Mutex::new(None),
torrent_upload_limit_calls: AtomicUsize::new(0),
last_torrent_upload_limit: std::sync::Mutex::new(None),
block_torrent_upload_limit: std::sync::atomic::AtomicBool::new(false),
torrent_upload_limit_started: tokio::sync::Notify::new(),
torrent_upload_limit_release: tokio::sync::Notify::new(),
torrent_peer_options_calls: AtomicUsize::new(0),
last_torrent_peer_options: std::sync::Mutex::new(None),
block_torrent_peer_options: std::sync::atomic::AtomicBool::new(false),
torrent_peer_options_started: tokio::sync::Notify::new(),
torrent_peer_options_release: tokio::sync::Notify::new(),
add_speed_limits: std::sync::Mutex::new(Vec::new()), add_speed_limits: std::sync::Mutex::new(Vec::new()),
add_peer_options: std::sync::Mutex::new(Vec::new()),
block_speed_limit: std::sync::atomic::AtomicBool::new(false), block_speed_limit: std::sync::atomic::AtomicBool::new(false),
speed_limit_started: tokio::sync::Notify::new(), speed_limit_started: tokio::sync::Notify::new(),
speed_limit_release: tokio::sync::Notify::new(), speed_limit_release: tokio::sync::Notify::new(),
@@ -265,6 +287,13 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
.lock() .lock()
.unwrap() .unwrap()
.push(payload.speed_limit.clone()); .push(payload.speed_limit.clone());
self.add_peer_options
.lock()
.unwrap()
.push((
payload.torrent_max_peers,
payload.torrent_peer_speed_limit.clone(),
));
Ok(format!("gid-{call}")) Ok(format!("gid-{call}"))
} }
async fn remove_uri(&self, _gid: &str) -> Result<(), String> { async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
@@ -286,6 +315,40 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
} }
Ok(()) Ok(())
} }
async fn set_torrent_upload_limit(
&self,
_gid: &str,
limit: Option<&str>,
) -> Result<(), String> {
self.torrent_upload_limit_calls.fetch_add(1, Ordering::SeqCst);
*self.last_torrent_upload_limit.lock().unwrap() = limit.map(str::to_string);
if self
.block_torrent_upload_limit
.load(std::sync::atomic::Ordering::SeqCst)
{
self.torrent_upload_limit_started.notify_one();
self.torrent_upload_limit_release.notified().await;
}
Ok(())
}
async fn set_torrent_peer_options(
&self,
_gid: &str,
max_peers: u32,
peer_speed_limit: &str,
) -> Result<(), String> {
self.torrent_peer_options_calls.fetch_add(1, Ordering::SeqCst);
*self.last_torrent_peer_options.lock().unwrap() =
Some((max_peers, peer_speed_limit.to_string()));
if self
.block_torrent_peer_options
.load(std::sync::atomic::Ordering::SeqCst)
{
self.torrent_peer_options_started.notify_one();
self.torrent_peer_options_release.notified().await;
}
Ok(())
}
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> { async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
self.media_calls.fetch_add(1, Ordering::SeqCst); self.media_calls.fetch_add(1, Ordering::SeqCst);
Ok(()) Ok(())
@@ -543,6 +606,304 @@ async fn live_aria2_speed_limit_does_not_update_payload_after_gid_replacement()
dispatcher.abort(); dispatcher.abort();
} }
#[tokio::test]
async fn live_torrent_upload_limit_updates_current_gid_and_payload() {
let (manager, spawner) = make_manager(1);
let manager = Arc::new(manager);
let mut task = aria2_task("torrent-upload-limit");
task.payload.is_torrent = true;
manager.push(task).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if manager.aria2_gid_for_download("torrent-upload-limit").is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("aria2 dispatch should register a Torrent gid");
manager
.set_aria2_torrent_upload_limit(
"torrent-upload-limit",
Some("512K".to_string()),
)
.await
.unwrap();
assert_eq!(
spawner.torrent_upload_limit_calls.load(Ordering::SeqCst),
1
);
assert_eq!(
spawner
.last_torrent_upload_limit
.lock()
.unwrap()
.as_deref(),
Some("512K")
);
manager
.set_aria2_torrent_upload_limit("torrent-upload-limit", None)
.await
.unwrap();
assert_eq!(
spawner.torrent_upload_limit_calls.load(Ordering::SeqCst),
2
);
assert!(spawner
.last_torrent_upload_limit
.lock()
.unwrap()
.is_none());
manager.clear_aria2_retry_state("torrent-upload-limit").await;
manager.forget_aria2_gid("torrent-upload-limit").await;
manager.release_permit("torrent-upload-limit").await;
manager.release_registered_id("torrent-upload-limit").await;
dispatcher.abort();
}
#[tokio::test]
async fn live_torrent_upload_limit_rejects_non_torrents_and_invalid_values() {
let (manager, spawner) = make_manager(1);
assert!(manager
.set_aria2_torrent_upload_limit("missing", Some("512K".to_string()))
.await
.is_err());
let manager = Arc::new(manager);
let task = aria2_task("ordinary-download");
manager.push(task).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if manager.aria2_gid_for_download("ordinary-download").is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("aria2 dispatch should register a gid");
assert!(manager
.set_aria2_torrent_upload_limit("ordinary-download", Some("not-a-rate".to_string()))
.await
.is_err());
assert!(manager
.set_aria2_torrent_upload_limit("ordinary-download", Some("512K".to_string()))
.await
.is_err());
assert_eq!(spawner.torrent_upload_limit_calls.load(Ordering::SeqCst), 0);
manager
.apply_completion(
"ordinary-download",
firelink_lib::queue::PendingOutcome::Complete,
)
.await;
dispatcher.abort();
}
#[tokio::test]
async fn live_torrent_upload_limit_does_not_update_after_gid_replacement() {
let (manager, spawner) = make_manager(1);
let manager = Arc::new(manager);
let mut task = aria2_task("torrent-upload-stale");
task.payload.is_torrent = true;
manager.push(task).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if manager.aria2_gid_for_download("torrent-upload-stale").is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("aria2 dispatch should register a Torrent gid");
spawner
.block_torrent_upload_limit
.store(true, std::sync::atomic::Ordering::SeqCst);
let started = spawner.torrent_upload_limit_started.notified();
let setter = {
let manager = Arc::clone(&manager);
tokio::spawn(async move {
manager
.set_aria2_torrent_upload_limit(
"torrent-upload-stale",
Some("512K".to_string()),
)
.await
})
};
timeout(Duration::from_secs(1), started)
.await
.expect("Torrent upload RPC should start");
manager
.remember_gid(
"torrent-upload-stale".to_string(),
"gid-replaced".to_string(),
)
.await;
spawner.torrent_upload_limit_release.notify_one();
assert!(setter.await.unwrap().is_err());
manager.clear_aria2_retry_state("torrent-upload-stale").await;
manager.forget_aria2_gid("torrent-upload-stale").await;
manager.release_permit("torrent-upload-stale").await;
manager.release_registered_id("torrent-upload-stale").await;
dispatcher.abort();
}
#[tokio::test]
async fn live_torrent_peer_options_update_current_gid_and_payload() {
let (manager, spawner) = make_manager(1);
let manager = Arc::new(manager);
let mut task = aria2_task("torrent-peer-options");
task.payload.is_torrent = true;
manager.push(task).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if manager.aria2_gid_for_download("torrent-peer-options").is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("aria2 dispatch should register a Torrent gid");
manager
.set_aria2_torrent_peer_options(
"torrent-peer-options",
Some(120),
Some("2M".to_string()),
)
.await
.unwrap();
assert_eq!(spawner.torrent_peer_options_calls.load(Ordering::SeqCst), 1);
assert_eq!(
spawner.last_torrent_peer_options.lock().unwrap().as_ref(),
Some(&(120, "2M".to_string()))
);
manager
.set_aria2_torrent_peer_options("torrent-peer-options", None, None)
.await
.unwrap();
assert_eq!(spawner.torrent_peer_options_calls.load(Ordering::SeqCst), 2);
assert_eq!(
spawner.last_torrent_peer_options.lock().unwrap().as_ref(),
Some(&(55, "50K".to_string()))
);
manager.clear_aria2_retry_state("torrent-peer-options").await;
manager.forget_aria2_gid("torrent-peer-options").await;
manager.release_permit("torrent-peer-options").await;
manager.release_registered_id("torrent-peer-options").await;
dispatcher.abort();
}
#[tokio::test]
async fn live_torrent_peer_options_reject_invalid_values_and_stale_gid() {
let (manager, spawner) = make_manager(1);
let manager = Arc::new(manager);
let mut task = aria2_task("torrent-peer-options-stale");
task.payload.is_torrent = true;
manager.push(task).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if manager
.aria2_gid_for_download("torrent-peer-options-stale")
.is_some()
{
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("aria2 dispatch should register a Torrent gid");
assert!(manager
.set_aria2_torrent_peer_options(
"torrent-peer-options-stale",
Some(1001),
Some("2M".to_string()),
)
.await
.is_err());
assert!(manager
.set_aria2_torrent_peer_options(
"torrent-peer-options-stale",
Some(100),
Some("not-a-rate".to_string()),
)
.await
.is_err());
assert_eq!(spawner.torrent_peer_options_calls.load(Ordering::SeqCst), 0);
spawner
.block_torrent_peer_options
.store(true, std::sync::atomic::Ordering::SeqCst);
let started = spawner.torrent_peer_options_started.notified();
let setter = {
let manager = Arc::clone(&manager);
tokio::spawn(async move {
manager
.set_aria2_torrent_peer_options(
"torrent-peer-options-stale",
Some(100),
Some("2M".to_string()),
)
.await
})
};
timeout(Duration::from_secs(1), started)
.await
.expect("Torrent peer options RPC should start");
manager
.remember_gid(
"torrent-peer-options-stale".to_string(),
"gid-replaced".to_string(),
)
.await;
spawner.torrent_peer_options_release.notify_one();
assert!(setter.await.unwrap().is_err());
manager.clear_aria2_retry_state("torrent-peer-options-stale").await;
manager.forget_aria2_gid("torrent-peer-options-stale").await;
manager.release_permit("torrent-peer-options-stale").await;
manager.release_registered_id("torrent-peer-options-stale").await;
dispatcher.abort();
}
#[tokio::test] #[tokio::test]
async fn retry_readds_aria2_with_the_latest_live_speed_limit() { async fn retry_readds_aria2_with_the_latest_live_speed_limit() {
use firelink_lib::queue::PendingOutcome; use firelink_lib::queue::PendingOutcome;
@@ -605,6 +966,80 @@ async fn retry_readds_aria2_with_the_latest_live_speed_limit() {
dispatcher.abort(); dispatcher.abort();
} }
#[tokio::test]
async fn retry_readds_torrent_with_the_latest_live_peer_options() {
use firelink_lib::queue::PendingOutcome;
let (mgr, spawner) = make_manager(1);
let manager = Arc::new(mgr);
let mut task = aria2_task("peer-options-retry");
task.payload.is_torrent = true;
task.payload.max_tries = Some(1);
task.payload.torrent_max_peers = Some(120);
task.payload.torrent_peer_speed_limit = Some("1M".to_string());
manager.push(task).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if spawner.add_uri_calls.load(Ordering::SeqCst) >= 1 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("initial Torrent add should run");
manager
.handle_aria2_event(
"gid-1",
PendingOutcome::Error(
"aria2 error code 1: Failed to receive data, cause: protocol error".to_string(),
),
)
.await;
manager
.set_aria2_torrent_peer_options(
"peer-options-retry",
Some(240),
Some("2M".to_string()),
)
.await
.unwrap();
timeout(Duration::from_secs(4), async {
loop {
if spawner.add_uri_calls.load(Ordering::SeqCst) >= 2 {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await
.expect("Torrent retry should re-add after backoff");
assert_eq!(
spawner.add_peer_options.lock().unwrap().as_slice(),
&[
(Some(120), Some("1M".to_string())),
(Some(240), Some("2M".to_string()))
]
);
manager
.clear_aria2_retry_state("peer-options-retry")
.await;
manager
.forget_aria2_gid("peer-options-retry")
.await;
manager.release_permit("peer-options-retry").await;
manager.release_registered_id("peer-options-retry").await;
dispatcher.abort();
}
#[tokio::test] #[tokio::test]
async fn stale_aria2_permit_candidate_cannot_replace_current_permit() { async fn stale_aria2_permit_candidate_cannot_replace_current_permit() {
let (mgr, _spawner) = make_manager(2); let (mgr, _spawner) = make_manager(2);
@@ -2043,7 +2478,9 @@ async fn late_aria2_gid_after_cancellation_is_removed_without_leaking_permit() {
manager.release_registered_id("late").await; manager.release_registered_id("late").await;
manager.release_permit("late").await; manager.release_permit("late").await;
tokio::time::sleep(Duration::from_millis(100)).await; timeout(Duration::from_secs(1), manager.wait_for_aria2_dispatch("late"))
.await
.expect("late dispatch must be fully removed before it is considered finished");
assert!(manager.aria2_gid_for_download("late").is_none()); assert!(manager.aria2_gid_for_download("late").is_none());
assert_eq!(manager.available_permits(), 1); assert_eq!(manager.available_permits(), 1);
+96
View File
@@ -0,0 +1,96 @@
use axum::{extract::Json, http::StatusCode, response::IntoResponse, routing::post, Router};
use firelink_lib::rpc_call;
use serde_json::{json, Value};
use std::net::SocketAddr;
use tokio::sync::oneshot;
async fn start_server(
app: Router,
) -> (SocketAddr, oneshot::Sender<()>, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("RPC test server should bind");
let address = listener
.local_addr()
.expect("RPC test server should have an address");
let (shutdown, shutdown_signal) = oneshot::channel();
let task = tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_signal.await;
})
.await
.expect("RPC test server should stop cleanly");
});
(address, shutdown, task)
}
struct TestResponse(StatusCode, Value);
impl IntoResponse for TestResponse {
fn into_response(self) -> axum::response::Response {
(self.0, Json(self.1)).into_response()
}
}
async fn successful_rpc(Json(request): Json<Value>) -> TestResponse {
assert_eq!(request.get("jsonrpc"), Some(&json!("2.0")));
assert_eq!(request.get("id"), Some(&json!("1")));
assert_eq!(request.get("method"), Some(&json!("aria2.getVersion")));
assert_eq!(
request.get("params"),
Some(&json!(["token:test-secret", {"include": "version"}]))
);
TestResponse(
StatusCode::OK,
json!({"jsonrpc": "2.0", "id": "1", "result": {"version": "test"}}),
)
}
async fn gateway_error(Json(_request): Json<Value>) -> TestResponse {
TestResponse(
StatusCode::BAD_GATEWAY,
json!({"jsonrpc": "2.0", "id": "1", "error": {"code": 1, "message": "backend unavailable"}}),
)
}
async fn stop_server(shutdown: oneshot::Sender<()>, task: tokio::task::JoinHandle<()>) {
let _ = shutdown.send(());
task.await.expect("RPC test server task should join");
}
#[tokio::test]
async fn production_rpc_client_sends_authenticated_json_rpc() {
let app = Router::new().route("/jsonrpc", post(successful_rpc));
let (address, shutdown, task) = start_server(app).await;
let result = rpc_call(
address.port(),
"test-secret",
"aria2.getVersion",
json!([{"include": "version"}]),
)
.await
.expect("successful RPC response should decode");
assert_eq!(result, json!({"version": "test"}));
stop_server(shutdown, task).await;
}
#[tokio::test]
async fn production_rpc_client_preserves_http_gateway_context() {
let app = Router::new().route("/jsonrpc", post(gateway_error));
let (address, shutdown, task) = start_server(app).await;
let error = rpc_call(address.port(), "test-secret", "aria2.getVersion", json!([]))
.await
.expect_err("gateway response should fail");
assert!(
error.contains("HTTP 502 Bad Gateway"),
"unexpected error: {error}"
);
assert!(
error.contains("backend unavailable"),
"unexpected error: {error}"
);
stop_server(shutdown, task).await;
}
+1 -1
View File
@@ -2,4 +2,4 @@
import type { DownloadCategory } from "./DownloadCategory"; import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus"; 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, }; 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, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: 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. // 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. // 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. // 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, 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, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, lifecycle_generation?: string, };
+1 -1
View File
@@ -11,4 +11,4 @@ import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme"; import type { Theme } from "./Theme";
import type { WindowControlStyle } from "./WindowControlStyle"; import type { WindowControlStyle } from "./WindowControlStyle";
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TorrentFile = { index: number, path: string, length: number, };
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { TorrentFile } from "./TorrentFile";
export type TorrentMetadata = { name: string, totalBytes: number, files: Array<TorrentFile>, infoHash: string, torrentPath?: string, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TorrentPeer = { downloadSpeed: number, uploadSpeed: number, seeder: boolean, amChoking: boolean, peerChoking: boolean, };
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { TorrentPeer } from "./TorrentPeer";
export type TorrentPeerDiagnostics = { totalPeers: number, totalSeeders: number, peers: Array<TorrentPeer>, truncated: boolean, };
+648 -11
View File
@@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { invokeCommand as invoke } from '../ipc'; import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch } from '../utils/downloads'; import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy } from '../utils/downloads';
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata'; import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
import { import {
expandTilde, expandTilde,
@@ -160,11 +160,47 @@ export const AddDownloadsModal = () => {
const [urls, setUrls] = useState(''); const [urls, setUrls] = useState('');
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null); const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
const [parsedItems, setParsedItems] = useState<AddDownloadDraftRow[]>([]); const [parsedItems, setParsedItems] = useState<AddDownloadDraftRow[]>([]);
const parsedItemsRef = useRef<AddDownloadDraftRow[]>([]);
const addModalOpenRef = useRef(isAddModalOpen);
const metadataRequestsRef = useRef(new Set<string>()); const metadataRequestsRef = useRef(new Set<string>());
const playlistRequestsRef = useRef(new Set<string>()); const playlistRequestsRef = useRef(new Set<string>());
const latestPlaylistRequestRef = useRef(new Map<string, string>()); const latestPlaylistRequestRef = useRef(new Map<string, string>());
const cachedTorrentDraftIdsRef = useRef(new Set<string>());
const [playlistExpansions, setPlaylistExpansions] = useState<Record<string, MediaPlaylistMetadata>>({}); const [playlistExpansions, setPlaylistExpansions] = useState<Record<string, MediaPlaylistMetadata>>({});
parsedItemsRef.current = parsedItems;
addModalOpenRef.current = isAddModalOpen;
const cleanupDraftTorrentCache = useCallback((ids?: Iterable<string>) => {
const idsToRemove = ids
? Array.from(ids)
: Array.from(cachedTorrentDraftIdsRef.current);
idsToRemove.forEach(id => cachedTorrentDraftIdsRef.current.delete(id));
idsToRemove.forEach(id => {
void invoke('remove_torrent_metadata', { id }).catch(error => {
console.warn('Failed to remove temporary torrent metadata:', error);
});
});
}, []);
useEffect(() => {
if (!isAddModalOpen) cleanupDraftTorrentCache();
}, [cleanupDraftTorrentCache, isAddModalOpen]);
useEffect(() => {
const activeDraftIds = new Set<string>();
for (const row of parsedItems) {
if (!row.isTorrent) continue;
activeDraftIds.add(row.torrentCacheId || row.id);
activeDraftIds.add(`${row.id}-${row.generation}`);
}
const staleDraftIds = Array.from(cachedTorrentDraftIdsRef.current)
.filter(id => !activeDraftIds.has(id));
if (staleDraftIds.length > 0) cleanupDraftTorrentCache(staleDraftIds);
}, [cleanupDraftTorrentCache, parsedItems]);
useEffect(() => cleanupDraftTorrentCache, [cleanupDraftTorrentCache]);
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]); const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
const [showingDuplicates, setShowingDuplicates] = useState(false); const [showingDuplicates, setShowingDuplicates] = useState(false);
const modalRef = useModalFocus(isAddModalOpen); const modalRef = useModalFocus(isAddModalOpen);
@@ -189,9 +225,48 @@ export const AddDownloadsModal = () => {
const [connections, setConnections] = useState(perServerConnections); const [connections, setConnections] = useState(perServerConnections);
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false); const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
const [speedLimit, setSpeedLimit] = useState('1024'); 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 [torrentMaxPeers, setTorrentMaxPeers] = useState('');
const [torrentPeerSpeedLimit, setTorrentPeerSpeedLimit] = useState('');
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
const [torrentRemoveUnselectedFile, setTorrentRemoveUnselectedFile] = useState(false);
const [torrentEncryptionPolicy, setTorrentEncryptionPolicy] = useState<TorrentEncryptionPolicy>(TORRENT_ENCRYPTION_POLICY_DISABLED);
const [torrentTrackers, setTorrentTrackers] = useState('');
const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState('');
const [torrentTrackerConnectTimeout, setTorrentTrackerConnectTimeout] = useState('');
const [torrentTrackerTimeout, setTorrentTrackerTimeout] = useState('');
const [torrentTrackerInterval, setTorrentTrackerInterval] = useState('0');
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
const [torrentPrioritizePiece, setTorrentPrioritizePiece] = useState('');
const [freeSpace, setFreeSpace] = useState('Unknown'); const [freeSpace, setFreeSpace] = useState('Unknown');
const freeSpaceRequestRef = useRef(0); const freeSpaceRequestRef = useRef(0);
const addTorrentFiles = async () => {
try {
const selected = await open({
multiple: true,
directory: false,
title: 'Choose torrent files',
filters: [{ name: 'Torrent', extensions: ['torrent'] }]
});
const paths = Array.isArray(selected)
? selected
: selected
? [selected]
: [];
if (paths.length === 0) return;
setUrls(current => [...current.split('\n').map(line => line.trim()).filter(Boolean), ...paths]
.filter((value, index, values) => values.indexOf(value) === index)
.join('\n'));
} catch (error) {
console.error('Failed to select torrent files:', error);
}
};
const [useAuth, setUseAuth] = useState(false); const [useAuth, setUseAuth] = useState(false);
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -300,6 +375,20 @@ export const AddDownloadsModal = () => {
setFreeSpace('Unknown'); setFreeSpace('Unknown');
setSpeedLimitEnabled(false); setSpeedLimitEnabled(false);
setSpeedLimit('1024'); setSpeedLimit('1024');
setTorrentSeedingEnabled(false);
setTorrentSeedTime('60');
setTorrentSeedRatio('1.0');
setTorrentUploadLimitEnabled(false);
setTorrentUploadLimit('1024');
setTorrentMaxPeers('');
setTorrentPeerSpeedLimit('');
setTorrentCheckIntegrity(false);
setTorrentTrackers('');
setTorrentExcludeTrackers('');
setTorrentTrackerConnectTimeout('');
setTorrentTrackerTimeout('');
setTorrentTrackerInterval('0');
setTorrentStopTimeout('0');
setUseAuth(false); setUseAuth(false);
setUsername(''); setUsername('');
setPassword(''); setPassword('');
@@ -493,10 +582,59 @@ export const AddDownloadsModal = () => {
void (async () => { void (async () => {
try { try {
const settingsStore = useSettingsStore.getState(); const settingsStore = useSettingsStore.getState();
const proxy = await getProxyArgs(settingsStore);
const login = getSiteLogin(row.sourceUrl, settingsStore); const login = getSiteLogin(row.sourceUrl, settingsStore);
const contextUrl = requestContextUrlForRow(row); const contextUrl = requestContextUrlForRow(row);
const requestContext = requestContextForUrl(contextUrl); const requestContext = requestContextForUrl(contextUrl);
if (row.isTorrent) {
const torrentCacheId = row.torrentCacheId || `${row.id}-${row.generation}`;
const proxy = await getProxyArgs(settingsStore);
const torrentData = await invoke('inspect_torrent', {
source: row.sourceUrl,
id: torrentCacheId,
cache: true,
proxy: proxy ?? undefined
});
const isCurrentTorrentDraft = addModalOpenRef.current
&& parsedItemsRef.current.some(currentRow =>
currentRow.id === row.id
&& currentRow.sourceUrl === row.sourceUrl
&& currentRow.generation === row.generation
&& (currentRow.torrentCacheId || `${currentRow.id}-${currentRow.generation}`) === torrentCacheId
);
if (torrentData.torrentPath && isCurrentTorrentDraft) {
cachedTorrentDraftIdsRef.current.add(torrentCacheId);
} else if (torrentData.torrentPath) {
void invoke('remove_torrent_metadata', { id: torrentCacheId }).catch(error => {
console.warn('Failed to remove stale torrent metadata:', error);
});
}
const totalBytes = torrentData.totalBytes || undefined;
setParsedItems(current => updateRowIfCurrent(
current,
row.id,
row.sourceUrl,
row.generation,
currentRow => ({
...currentRow,
downloadUrl: !row.sourceUrl.trim().toLowerCase().startsWith('magnet:')
? 'torrent:' + torrentData.infoHash
: row.sourceUrl,
file: canonicalizeDownloadFileName(torrentData.name),
size: totalBytes ? formatBytes(totalBytes) : undefined,
sizeBytes: totalBytes,
status: 'ready',
isTorrent: true,
torrentPath: torrentData.torrentPath,
torrentCacheId,
torrentInfoHash: torrentData.infoHash,
torrentFiles: torrentData.files,
selectedTorrentFileIndices: currentRow.selectedTorrentFileIndices
?.filter(index => torrentData.files.some(file => file.index === index))
})
));
return;
}
const proxy = await getProxyArgs(settingsStore);
if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) { if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) {
settingsStore.setShowKeychainModal(true); settingsStore.setShowKeychainModal(true);
return; return;
@@ -822,6 +960,78 @@ export const AddDownloadsModal = () => {
addToast({ message: t($ => $.addDownloads.speedInvalid), variant: 'error', isActionable: true }); addToast({ message: t($ => $.addDownloads.speedInvalid), variant: 'error', isActionable: true });
return; 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 (
hasSelectedTorrent
&& torrentMaxPeers.trim()
&& (!Number.isInteger(Number(torrentMaxPeers)) || Number(torrentMaxPeers) < 0 || Number(torrentMaxPeers) > 1000)
) {
addToast({ message: t($ => $.addDownloads.torrentMaxPeersInvalid), variant: 'error', isActionable: true });
return;
}
if (hasSelectedTorrent && torrentPeerSpeedLimit.trim() && !normalizeSpeedLimitForBackend(torrentPeerSpeedLimit)) {
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 (hasSelectedTorrent && !isValidTorrentExcludeTrackerList(torrentExcludeTrackers)) {
addToast({ message: t($ => $.addDownloads.torrentExcludeTrackersInvalid), variant: 'error', isActionable: true });
return;
}
if (hasSelectedTorrent && torrentTrackerConnectTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerConnectTimeout)) {
addToast({ message: t($ => $.addDownloads.torrentTrackerTimeoutInvalid), variant: 'error', isActionable: true });
return;
}
if (hasSelectedTorrent && torrentTrackerTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerTimeout)) {
addToast({ message: t($ => $.addDownloads.torrentTrackerTimeoutInvalid), variant: 'error', isActionable: true });
return;
}
if (hasSelectedTorrent && torrentTrackerInterval.trim() && normalizeTorrentTrackerInterval(torrentTrackerInterval) === undefined) {
addToast({ message: t($ => $.addDownloads.torrentTrackerIntervalInvalid), variant: 'error', isActionable: true });
return;
}
if (hasSelectedTorrent && torrentPrioritizePiece.trim() && !normalizeTorrentPrioritizePiece(torrentPrioritizePiece)) {
addToast({ message: t($ => $.addDownloads.torrentPrioritizePieceInvalid), variant: 'error', isActionable: true });
return;
}
if (
hasSelectedTorrent
&& torrentStopTimeout.trim()
&& (!Number.isInteger(Number(torrentStopTimeout)) || Number(torrentStopTimeout) < 0 || Number(torrentStopTimeout) > MAX_TORRENT_STOP_TIMEOUT)
) {
addToast({ message: t($ => $.addDownloads.torrentStopTimeoutInvalid), variant: 'error', isActionable: true });
return;
}
const removableTorrentFileCount = parsedItems.reduce((total, item) => {
if (item.selected === false || !item.isTorrent || !item.torrentFiles?.length) return total;
const selected = item.selectedTorrentFileIndices;
if (!selected || selected.length === 0 || selected.length >= item.torrentFiles.length) return total;
return total + item.torrentFiles.length - selected.length;
}, 0);
if (
torrentRemoveUnselectedFile
&& removableTorrentFileCount > 0
&& !window.confirm(t($ => $.addDownloads.torrentRemoveUnselectedFileConfirm, {
count: removableTorrentFileCount
}))
) {
return;
}
if (saveInDedicatedFolder && !sanitizeBatchFolderName(dedicatedFolderName)) { if (saveInDedicatedFolder && !sanitizeBatchFolderName(dedicatedFolderName)) {
addToast({ addToast({
message: t($ => $.addDownloads.dedicatedFolderNameRequired), message: t($ => $.addDownloads.dedicatedFolderNameRequired),
@@ -1184,15 +1394,19 @@ export const AddDownloadsModal = () => {
if (!existingItem) { if (!existingItem) {
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile })); throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
} }
const incomingMediaFormat = mediaFormatSelectorForRow(item); const incomingMediaFormat = mediaFormatSelectorForRow(item);
const mediaFormatChanged = item.isMedia const mediaFormatChanged = item.isMedia
&& existingItem.mediaFormatSelector !== incomingMediaFormat; && existingItem.mediaFormatSelector !== incomingMediaFormat;
if (existingItem.status === 'completed' || mediaFormatChanged) { const torrentReplacement = Boolean(item.isTorrent) || Boolean(existingItem.isTorrent);
// Completed replacements must remove the old file so the if (existingItem.status === 'completed' || mediaFormatChanged || torrentReplacement) {
// new transfer cannot be treated as an already-complete // Completed replacements must remove the old file so the
// aria2 target. Unfinished rows use the in-place path to // new transfer cannot be treated as an already-complete
// preserve their resumable assets and progress. // aria2 target. A torrent replacement also needs a fresh
await store.removeDownload(existingItem.id, true, false); // identity because its cached metadata is keyed by the
// new row ID and its output contract differs from a normal
// file transfer. Unfinished ordinary rows use the in-place
// path to preserve their resumable assets and progress.
await store.removeDownload(existingItem.id, true, false);
} else { } else {
const contextUrl = requestContextUrlForRow(item); const contextUrl = requestContextUrlForRow(item);
const replaced = await store.replaceDownload(existingItem.id, { const replaced = await store.replaceDownload(existingItem.id, {
@@ -1223,8 +1437,33 @@ export const AddDownloadsModal = () => {
for (const [itemIndex, item] of itemsToAdd.entries()) { for (const [itemIndex, item] of itemsToAdd.entries()) {
if (!item) continue; if (!item) continue;
let allocatedId: string | null = null;
try { try {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
allocatedId = id;
let torrentPath = item.torrentPath;
if (item.isTorrent) {
if (item.torrentPath) {
torrentPath = await invoke('rekey_torrent_metadata', {
sourceId: item.torrentCacheId || item.id,
targetId: id
});
cachedTorrentDraftIdsRef.current.delete(item.torrentCacheId || item.id);
} else {
// Keep a safe fallback for rows restored from an older draft
// shape that did not retain the preview cache identity.
const proxy = item.sourceUrl.trim().toLowerCase().startsWith('magnet:')
? await getProxyArgs(useSettingsStore.getState())
: undefined;
const torrentData = await invoke('inspect_torrent', {
source: item.sourceUrl,
id,
cache: true,
proxy: proxy ?? undefined
});
torrentPath = torrentData.torrentPath;
}
}
let finalFile = item.isMedia let finalFile = item.isMedia
? mediaFileNameForSelectedFormat(item.file, item) ? mediaFileNameForSelectedFormat(item.file, item)
: canonicalizeDownloadFileName(item.file); : canonicalizeDownloadFileName(item.file);
@@ -1260,6 +1499,37 @@ export const AddDownloadsModal = () => {
resumable: item.resumable, resumable: item.resumable,
mediaFormatSelector: formatSelector, mediaFormatSelector: formatSelector,
mediaQuality: mediaQualityForRow(item), mediaQuality: mediaQualityForRow(item),
isTorrent: item.isTorrent,
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,
torrentMaxPeers: item.isTorrent && torrentMaxPeers.trim() ? Number(torrentMaxPeers) : undefined,
torrentPeerSpeedLimit: item.isTorrent
? normalizeSpeedLimitForBackend(torrentPeerSpeedLimit) || undefined
: undefined,
torrentCheckIntegrity: item.isTorrent ? torrentCheckIntegrity : undefined,
torrentRemoveUnselectedFile: item.isTorrent && torrentRemoveUnselectedFile && hasPartialTorrentSelection(item)
? true
: undefined,
torrentEncryptionPolicy: item.isTorrent && torrentEncryptionPolicy !== TORRENT_ENCRYPTION_POLICY_DISABLED
? torrentEncryptionPolicy
: undefined,
torrentTrackers: item.isTorrent ? torrentTrackers.trim() || undefined : undefined,
torrentExcludeTrackers: item.isTorrent ? torrentExcludeTrackers.trim() || undefined : undefined,
torrentTrackerConnectTimeout: item.isTorrent && torrentTrackerConnectTimeout.trim()
? Number(torrentTrackerConnectTimeout)
: undefined,
torrentTrackerTimeout: item.isTorrent && torrentTrackerTimeout.trim()
? Number(torrentTrackerTimeout)
: undefined,
torrentTrackerInterval: item.isTorrent && torrentTrackerInterval.trim()
? Number(torrentTrackerInterval)
: undefined,
torrentStopTimeout: item.isTorrent && torrentStopTimeout.trim() ? Number(torrentStopTimeout) : undefined,
torrentPrioritizePiece: item.isTorrent ? normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined : undefined,
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined), size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
sizeBytes: item.sizeBytes sizeBytes: item.sizeBytes
}, action); }, action);
@@ -1268,6 +1538,11 @@ export const AddDownloadsModal = () => {
} }
addedCount += 1; addedCount += 1;
} catch (e) { } catch (e) {
if (item.isTorrent && allocatedId) {
await invoke('remove_torrent_metadata', { id: allocatedId }).catch(error => {
console.warn('Failed to remove cached torrent metadata after add failure:', error);
});
}
console.error("Invalid URL or failed to add:", e); console.error("Invalid URL or failed to add:", e);
failures.push(`${item.file}: ${e instanceof Error ? e.message : String(e)}`); failures.push(`${item.file}: ${e instanceof Error ? e.message : String(e)}`);
} }
@@ -1369,7 +1644,29 @@ export const AddDownloadsModal = () => {
)); ));
}; };
const toggleTorrentFile = (index: number) => {
if (selectedItemIndex === null) return;
setParsedItems(items => items.map((item, itemIndex) => {
if (itemIndex !== selectedItemIndex || !item.torrentFiles?.length) return item;
const allIndices = item.torrentFiles.map(file => file.index);
const selectedIndices = item.selectedTorrentFileIndices ?? allIndices;
if (selectedIndices.length === 1 && selectedIndices[0] === index) return item;
const next = selectedIndices.includes(index)
? selectedIndices.filter(value => value !== index)
: [...selectedIndices, index].sort((left, right) => left - right);
return {
...item,
selectedTorrentFileIndices: next.length === allIndices.length ? undefined : next
};
}));
};
const selectedItems = parsedItems.filter(item => item.selected !== false); const selectedItems = parsedItems.filter(item => item.selected !== false);
const hasPartialTorrentSelection = (item: AddDownloadDraftRow): boolean => {
if (!item.isTorrent || !item.torrentFiles?.length) return false;
const selected = item.selectedTorrentFileIndices;
return Boolean(selected && selected.length > 0 && selected.length < item.torrentFiles.length);
};
const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex]; const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex];
const selectedPlaylistSourceUrl = selectedItem?.playlistSourceUrl; const selectedPlaylistSourceUrl = selectedItem?.playlistSourceUrl;
const selectedPlaylistRows = selectedPlaylistSourceUrl const selectedPlaylistRows = selectedPlaylistSourceUrl
@@ -1635,6 +1932,15 @@ export const AddDownloadsModal = () => {
value={urls} value={urls}
onChange={(e) => setUrls(e.target.value)} onChange={(e) => setUrls(e.target.value)}
/> />
<div className="flex justify-end">
<button
type="button"
onClick={() => void addTorrentFiles()}
className="add-download-link-button flex items-center gap-1.5 text-[11px] font-medium"
>
<FolderPlus size={12} /> {t($ => $.addDownloads.chooseTorrentFiles)}
</button>
</div>
{playlistSummaries.map(([sourceUrl, playlist]) => { {playlistSummaries.map(([sourceUrl, playlist]) => {
const total = playlist.entry_count || playlist.entries.length; const total = playlist.entry_count || playlist.entries.length;
return ( return (
@@ -1766,6 +2072,337 @@ export const AddDownloadsModal = () => {
<div className="add-download-settings w-[45%] flex flex-col overflow-y-auto"> <div className="add-download-settings w-[45%] flex flex-col overflow-y-auto">
<div className="p-6 space-y-5"> <div className="p-6 space-y-5">
{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">
<FileText size={16} className="text-blue-500" /> {t($ => $.addDownloads.torrentFiles)}
</div>
{parsedItems[selectedItemIndex].torrentFiles?.length ? (
<div
className="flex flex-col gap-1 max-h-64 overflow-y-auto pe-1"
role="group"
aria-label={t($ => $.addDownloads.torrentFiles)}
>
{parsedItems[selectedItemIndex].torrentFiles!.map(file => {
const selectedIndices = parsedItems[selectedItemIndex!].selectedTorrentFileIndices;
const checked = !selectedIndices || selectedIndices.includes(file.index);
return (
<label
key={file.index}
className="flex items-center gap-2 px-2 py-1.5 text-xs text-text-secondary hover:bg-surface-hover rounded"
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleTorrentFile(file.index)}
aria-label={file.path}
className="accent-blue-500"
/>
<span className="truncate flex-1" title={file.path}>{file.path}</span>
<span className="font-mono text-text-muted shrink-0">{formatBytes(file.length)}</span>
</label>
);
})}
</div>
) : (
<p className="text-xs text-text-muted">
{t($ => $.addDownloads.torrentMetadataPending)}
</p>
)}
</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}
<label className="flex items-start gap-2 text-text-primary pt-2 border-t border-border-modal/50">
<input
type="checkbox"
checked={torrentCheckIntegrity}
onChange={event => setTorrentCheckIntegrity(event.target.checked)}
className="accent-blue-500 mt-0.5"
/>
<span>
<span className="block">{t($ => $.addDownloads.torrentVerifyIntegrity)}</span>
<span className="block text-[10px] text-text-muted">
{t($ => $.addDownloads.torrentVerifyIntegrityHint)}
</span>
</span>
</label>
<div className="grid grid-cols-[1fr_auto] gap-2 items-center pt-2 border-t border-border-modal/50">
<label htmlFor="torrent-encryption-policy" className="text-text-muted">
{t($ => $.addDownloads.torrentEncryptionPolicy)}
</label>
<select
id="torrent-encryption-policy"
value={torrentEncryptionPolicy}
onChange={event => setTorrentEncryptionPolicy(event.currentTarget.value as TorrentEncryptionPolicy)}
aria-describedby="torrent-encryption-policy-hint"
className="app-control max-w-56 px-2 py-1 text-xs"
>
<option value={TORRENT_ENCRYPTION_POLICY_DISABLED}>
{t($ => $.addDownloads.torrentEncryptionDisabled)}
</option>
<option value={TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO}>
{t($ => $.addDownloads.torrentEncryptionRequireCrypto)}
</option>
<option value={TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION}>
{t($ => $.addDownloads.torrentEncryptionForceEncryption)}
</option>
</select>
<p id="torrent-encryption-policy-hint" className="col-span-2 text-[10px] text-text-muted">
{t($ => $.addDownloads.torrentEncryptionPolicyHint)}
</p>
</div>
<label className="flex items-start gap-2 text-text-primary pt-2 border-t border-border-modal/50">
<input
type="checkbox"
checked={torrentRemoveUnselectedFile}
onChange={event => setTorrentRemoveUnselectedFile(event.target.checked)}
disabled={parsedItems.every(item => !hasPartialTorrentSelection(item))}
className="accent-red-500 mt-0.5 disabled:opacity-50"
/>
<span>
<span className="block">{t($ => $.addDownloads.torrentRemoveUnselectedFile)}</span>
<span className="block text-[10px] text-text-muted">
{t($ => $.addDownloads.torrentRemoveUnselectedFileHint)}
</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="pt-2 border-t border-border-modal/50">
<label htmlFor="torrent-exclude-trackers" className="block text-text-muted">
{t($ => $.addDownloads.torrentExcludeTrackers)}
</label>
<textarea
id="torrent-exclude-trackers"
rows={3}
value={torrentExcludeTrackers}
onChange={event => setTorrentExcludeTrackers(event.currentTarget.value)}
placeholder="https://tracker.example/announce or *"
aria-describedby="torrent-exclude-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-exclude-trackers-hint" className="mt-1 text-[10px] text-text-muted">
{t($ => $.addDownloads.torrentExcludeTrackersHint)}
</p>
</div>
<div className="grid grid-cols-[1fr_auto] gap-2 items-center pt-2 border-t border-border-modal/50">
<label htmlFor="torrent-tracker-connect-timeout" className="text-text-muted">
{t($ => $.addDownloads.torrentTrackerConnectTimeout)}
</label>
<div className="flex items-center gap-1">
<input
id="torrent-tracker-connect-timeout"
type="number"
min={1}
max={MAX_TORRENT_TRACKER_TIMEOUT}
step={1}
value={torrentTrackerConnectTimeout}
onChange={event => setTorrentTrackerConnectTimeout(event.currentTarget.value)}
placeholder="60"
className="app-control w-24 px-2 py-1 text-end font-mono"
aria-describedby="torrent-tracker-timing-hint"
/>
<span className="text-[10px] text-text-muted">{t($ => $.addDownloads.seconds)}</span>
</div>
<label htmlFor="torrent-tracker-timeout" className="text-text-muted">
{t($ => $.addDownloads.torrentTrackerTimeout)}
</label>
<div className="flex items-center gap-1">
<input
id="torrent-tracker-timeout"
type="number"
min={1}
max={MAX_TORRENT_TRACKER_TIMEOUT}
step={1}
value={torrentTrackerTimeout}
onChange={event => setTorrentTrackerTimeout(event.currentTarget.value)}
placeholder="60"
className="app-control w-24 px-2 py-1 text-end font-mono"
aria-describedby="torrent-tracker-timing-hint"
/>
<span className="text-[10px] text-text-muted">{t($ => $.addDownloads.seconds)}</span>
</div>
<label htmlFor="torrent-tracker-interval" className="text-text-muted">
{t($ => $.addDownloads.torrentTrackerInterval)}
</label>
<div className="flex items-center gap-1">
<input
id="torrent-tracker-interval"
type="number"
min={0}
max={MAX_TORRENT_TRACKER_INTERVAL}
step={1}
value={torrentTrackerInterval}
onChange={event => setTorrentTrackerInterval(event.currentTarget.value)}
className="app-control w-24 px-2 py-1 text-end font-mono"
aria-describedby="torrent-tracker-timing-hint"
/>
<span className="text-[10px] text-text-muted">{t($ => $.addDownloads.seconds)}</span>
</div>
<p id="torrent-tracker-timing-hint" className="col-span-2 text-[10px] text-text-muted">
{t($ => $.addDownloads.torrentTrackerTimingHint)}
</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)}
</label>
<input
id="torrent-max-peers"
type="number"
min={0}
max={1000}
step={1}
value={torrentMaxPeers}
onChange={event => setTorrentMaxPeers(event.target.value)}
placeholder="55"
className="app-control w-24 px-2 py-1 text-end font-mono"
aria-describedby="torrent-peer-options-hint"
/>
<label htmlFor="torrent-peer-speed-limit" className="text-text-muted">
{t($ => $.addDownloads.torrentPeerSpeedLimit)}
</label>
<input
id="torrent-peer-speed-limit"
type="text"
inputMode="decimal"
value={torrentPeerSpeedLimit}
onChange={event => setTorrentPeerSpeedLimit(event.target.value)}
placeholder="50K"
className="app-control w-24 px-2 py-1 text-end font-mono"
aria-describedby="torrent-peer-options-hint"
/>
<p id="torrent-peer-options-hint" className="col-span-2 text-[10px] text-text-muted">
{t($ => $.addDownloads.torrentPeerOptionsHint)}
</p>
</div>
<div className="grid grid-cols-[1fr_auto] gap-2 items-center pt-2 border-t border-border-modal/50">
<label htmlFor="torrent-stop-timeout" className="text-text-muted">
{t($ => $.addDownloads.torrentStopTimeout)}
</label>
<div className="flex items-center gap-1">
<input
id="torrent-stop-timeout"
type="number"
min={0}
max={MAX_TORRENT_STOP_TIMEOUT}
step={1}
value={torrentStopTimeout}
onChange={event => setTorrentStopTimeout(event.currentTarget.value)}
className="app-control w-24 px-2 py-1 text-end font-mono"
aria-describedby="torrent-stop-timeout-hint"
/>
<span className="text-[10px] text-text-muted">{t($ => $.addDownloads.seconds)}</span>
</div>
<p id="torrent-stop-timeout-hint" className="col-span-2 text-[10px] text-text-muted">
{t($ => $.addDownloads.torrentStopTimeoutHint)}
</p>
</div>
<div className="pt-2 border-t border-border-modal/50">
<label htmlFor="torrent-prioritize-piece" className="block text-text-muted">
{t($ => $.addDownloads.torrentPrioritizePiece)}
</label>
<input
id="torrent-prioritize-piece"
type="text"
value={torrentPrioritizePiece}
onChange={event => setTorrentPrioritizePiece(event.currentTarget.value)}
placeholder="head=1M,tail=1M"
aria-describedby="torrent-prioritize-piece-hint"
className="app-control mt-1 w-full px-2.5 py-1.5 text-xs font-mono"
/>
<p id="torrent-prioritize-piece-hint" className="mt-1 text-[10px] text-text-muted">
{t($ => $.addDownloads.torrentPrioritizePieceHint)}
</p>
</div>
</div>
</section>
)}
{/* Media Format (Dynamic) */} {/* Media Format (Dynamic) */}
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && ( {selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
<section className="add-download-section add-download-media-section relative overflow-hidden p-4"> <section className="add-download-section add-download-media-section relative overflow-hidden p-4">
+16 -3
View File
@@ -178,16 +178,20 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
}; };
}, [isActionVisible, updateActionPosition]); }, [isActionVisible, updateActionPosition]);
const displayFraction = download.status === 'downloading' const displayFraction = download.status === 'downloading' || download.status === 'seeding'
? liveProgress?.fraction ?? download.fraction ?? 0 ? liveProgress?.fraction ?? download.fraction ?? 0
: download.fraction ?? 0; : download.fraction ?? 0;
const displayPercent = `${(displayFraction * 100).toFixed(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 ? liveProgress?.speed ?? download.speed
: download.status === 'processing' : download.status === 'processing'
? t($ => $.downloads.values.processing) ? t($ => $.downloads.values.processing)
: '-'; : '-';
const displayEta = download.status === 'downloading' const displayEta = download.status === 'seeding'
? '-'
: download.status === 'downloading'
? liveProgress?.eta ?? download.eta ? liveProgress?.eta ?? download.eta
: download.status === 'processing' : download.status === 'processing'
? t($ => $.downloads.values.muxing) ? t($ => $.downloads.values.muxing)
@@ -241,6 +245,11 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
{mediaQualityLabel} {mediaQualityLabel}
</span> </span>
) : null} ) : null}
{download.isTorrent ? (
<span className="download-quality-chip shrink-0" title={t($ => $.addDownloads.torrentFiles)}>
{t($ => $.addDownloads.torrentFiles)}
</span>
) : null}
</div> </div>
</div> </div>
), ),
@@ -286,6 +295,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<div <div
className={`download-progress-fill ${ className={`download-progress-fill ${
download.status === 'paused' ? 'paused' : download.status === 'paused' ? 'paused' :
download.status === 'seeding' ? 'seeding' :
download.status === 'processing' ? 'processing' : download.status === 'processing' ? 'processing' :
download.status === 'queued' || download.status === 'staged' ? 'queued' : download.status === 'queued' || download.status === 'staged' ? 'queued' :
download.status === 'retrying' ? 'retrying' : '' download.status === 'retrying' ? 'retrying' : ''
@@ -307,6 +317,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
} }
className={`download-status flex items-center gap-1.5 ${ className={`download-status flex items-center gap-1.5 ${
download.status === 'paused' ? 'download-status-paused' : download.status === 'paused' ? 'download-status-paused' :
download.status === 'seeding' ? 'download-status-seeding' :
download.status === 'failed' ? 'download-status-failed' : download.status === 'failed' ? 'download-status-failed' :
download.status === 'processing' ? 'download-status-processing' : download.status === 'processing' ? 'download-status-processing' :
download.status === 'downloading' ? 'download-status-downloading' : download.status === 'downloading' ? 'download-status-downloading' :
@@ -323,6 +334,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
</> </>
) : download.status === 'downloading' ? ( ) : download.status === 'downloading' ? (
displayPercent displayPercent
) : download.status === 'seeding' ? (
displayPercent
) : download.status === 'processing' ? ( ) : download.status === 'processing' ? (
downloadStatusLabel downloadStatusLabel
) : ( ) : (
+30 -17
View File
@@ -1365,6 +1365,14 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
}, []); }, []);
const getDownloadPath = useCallback(async (item: DownloadItem) => { const getDownloadPath = useCallback(async (item: DownloadItem) => {
if (item.isTorrent) {
try {
const ownedPath = await invoke('get_download_primary_path', { id: item.id });
if (ownedPath) return ownedPath;
} catch (error) {
console.error("Failed to resolve torrent output path:", error);
}
}
const fileName = item.fileName?.trim(); const fileName = item.fileName?.trim();
if (!fileName) return null; if (!fileName) return null;
const settings = useSettingsStore.getState(); const settings = useSettingsStore.getState();
@@ -1377,12 +1385,33 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
useDownloadStore.getState().setSelectedPropertiesDownloadId(id); useDownloadStore.getState().setSelectedPropertiesDownloadId(id);
}, []); }, []);
const revealDownloadFile = useCallback(async (item: DownloadItem) => {
const pathToReveal = await getDownloadPath(item);
if (!pathToReveal) {
openProperties(item.id);
return;
}
try {
await invoke('reveal_in_file_manager', { path: pathToReveal });
} catch (error) {
console.error("Failed to show in Finder:", error);
showInteractionError(t($ => $.downloadTable.revealFileFailed), error);
}
}, [getDownloadPath, openProperties, showInteractionError]);
const openDownloadFile = useCallback(async (item: DownloadItem) => { const openDownloadFile = useCallback(async (item: DownloadItem) => {
if (item.status !== 'completed') { if (item.status !== 'completed') {
openProperties(item.id); openProperties(item.id);
return; return;
} }
if (item.isTorrent) {
await revealDownloadFile(item);
return;
}
const fullPath = await getDownloadPath(item); const fullPath = await getDownloadPath(item);
if (!fullPath) { if (!fullPath) {
openProperties(item.id); openProperties(item.id);
@@ -1395,23 +1424,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
console.error("Failed to open file:", error); console.error("Failed to open file:", error);
showInteractionError(t($ => $.downloadTable.openFileFailed), error); showInteractionError(t($ => $.downloadTable.openFileFailed), error);
} }
}, [getDownloadPath, openProperties, showInteractionError]); }, [getDownloadPath, openProperties, revealDownloadFile, showInteractionError]);
const revealDownloadFile = async (item: DownloadItem) => {
const pathToReveal = await getDownloadPath(item);
if (!pathToReveal) {
openProperties(item.id);
return;
}
try {
await invoke('reveal_in_file_manager', { path: pathToReveal });
} catch (error) {
console.error("Failed to show in Finder:", error);
showInteractionError(t($ => $.downloadTable.revealFileFailed), error);
}
};
const handleDownloadDoubleClick = useCallback((item: DownloadItem) => { const handleDownloadDoubleClick = useCallback((item: DownloadItem) => {
if (item.status === 'completed') { if (item.status === 'completed') {
+669 -2
View File
@@ -3,6 +3,8 @@ import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
import { useDownloadProgressStore } from '../store/downloadProgressStore'; import { useDownloadProgressStore } from '../store/downloadProgressStore';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useSettingsStore } from '../store/useSettingsStore'; import { useSettingsStore } from '../store/useSettingsStore';
import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics';
import { invokeCommand as invoke } from '../ipc';
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react'; import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { resolveCategoryDestination } from '../utils/downloadLocations'; import { resolveCategoryDestination } from '../utils/downloadLocations';
@@ -13,10 +15,11 @@ import {
} from '../utils/downloadActions'; } from '../utils/downloadActions';
import { import {
downloadProgressColorClass, downloadProgressColorClass,
formatDownloadBytes,
formatDownloadTotal, formatDownloadTotal,
resolveDownloadSizeDisplay resolveDownloadSizeDisplay
} from '../utils/downloadProgress'; } from '../utils/downloadProgress';
import { resolveDownloadConnections } from '../utils/downloads'; import { isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, resolveDownloadConnections, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy } from '../utils/downloads';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { formatDateTime, type CalendarPreference } from '../utils/dateTime'; import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus'; import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
@@ -36,6 +39,12 @@ const formatLastTry = (
}); });
}; };
const isPeerDiagnosticsStatus = (status: string): boolean =>
['downloading', 'seeding', 'retrying'].includes(status);
const formatPeerSpeed = (bytesPerSecond: number): string =>
`${formatDownloadBytes(bytesPerSecond)}/s`;
export const PropertiesModal = () => { export const PropertiesModal = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const categoryLabel = (category: string) => { const categoryLabel = (category: string) => {
@@ -74,7 +83,25 @@ export const PropertiesModal = () => {
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false); const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
const [liveSpeedLimitValue, setLiveSpeedLimitValue] = useState(''); const [liveSpeedLimitValue, setLiveSpeedLimitValue] = useState('');
const [liveTorrentUploadLimitValue, setLiveTorrentUploadLimitValue] = useState('');
const [liveTorrentMaxPeersValue, setLiveTorrentMaxPeersValue] = useState('');
const [liveTorrentPeerSpeedLimitValue, setLiveTorrentPeerSpeedLimitValue] = useState('');
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
const [torrentRemoveUnselectedFile, setTorrentRemoveUnselectedFile] = useState(false);
const [torrentEncryptionPolicy, setTorrentEncryptionPolicy] = useState<TorrentEncryptionPolicy>(TORRENT_ENCRYPTION_POLICY_DISABLED);
const [torrentTrackers, setTorrentTrackers] = useState('');
const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState('');
const [torrentTrackerConnectTimeout, setTorrentTrackerConnectTimeout] = useState('');
const [torrentTrackerTimeout, setTorrentTrackerTimeout] = useState('');
const [torrentTrackerInterval, setTorrentTrackerInterval] = useState('0');
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
const [torrentPrioritizePiece, setTorrentPrioritizePiece] = useState('');
const [torrentPeerDiagnostics, setTorrentPeerDiagnostics] = useState<TorrentPeerDiagnostics | null>(null);
const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false);
const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false);
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false); const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false);
const [loginMode, setLoginMode] = useState<LoginMode>('matching'); const [loginMode, setLoginMode] = useState<LoginMode>('matching');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@@ -91,12 +118,20 @@ export const PropertiesModal = () => {
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [isPauseResumePending, setIsPauseResumePending] = useState(false); const [isPauseResumePending, setIsPauseResumePending] = useState(false);
const actionRequestRef = useRef(0); const actionRequestRef = useRef(0);
const peerDiagnosticsRequestRef = useRef(0);
const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item)); const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item));
useEffect(() => { useEffect(() => {
// Invalidate native pickers and transfer-control results when the modal // Invalidate native pickers and transfer-control results when the modal
// switches items, closes, or reopens for the same download. // switches items, closes, or reopens for the same download.
actionRequestRef.current += 1; actionRequestRef.current += 1;
setIsLiveSpeedLimitPending(false);
setIsLiveTorrentUploadLimitPending(false);
setIsLiveTorrentPeerOptionsPending(false);
peerDiagnosticsRequestRef.current += 1;
setTorrentPeerDiagnostics(null);
setTorrentPeerDiagnosticsError(false);
setIsTorrentPeerDiagnosticsPending(false);
}, [selectedPropertiesDownloadId]); }, [selectedPropertiesDownloadId]);
useEffect(() => { useEffect(() => {
@@ -156,6 +191,20 @@ export const PropertiesModal = () => {
} }
setCookies(activeItem.cookies || ''); setCookies(activeItem.cookies || '');
setMirrors(activeItem.mirrors || ''); setMirrors(activeItem.mirrors || '');
setLiveTorrentMaxPeersValue(
activeItem.torrentMaxPeers === undefined ? '' : String(activeItem.torrentMaxPeers)
);
setLiveTorrentPeerSpeedLimitValue(activeItem.torrentPeerSpeedLimit || '');
setTorrentCheckIntegrity(activeItem.torrentCheckIntegrity === true);
setTorrentRemoveUnselectedFile(activeItem.torrentRemoveUnselectedFile === true);
setTorrentEncryptionPolicy(normalizeTorrentEncryptionPolicy(activeItem.torrentEncryptionPolicy) || TORRENT_ENCRYPTION_POLICY_DISABLED);
setTorrentTrackers(activeItem.torrentTrackers || '');
setTorrentExcludeTrackers(activeItem.torrentExcludeTrackers || '');
setTorrentTrackerConnectTimeout(activeItem.torrentTrackerConnectTimeout === undefined ? '' : String(activeItem.torrentTrackerConnectTimeout));
setTorrentTrackerTimeout(activeItem.torrentTrackerTimeout === undefined ? '' : String(activeItem.torrentTrackerTimeout));
setTorrentTrackerInterval(activeItem.torrentTrackerInterval === undefined ? '0' : String(activeItem.torrentTrackerInterval));
setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout));
setTorrentPrioritizePiece(activeItem.torrentPrioritizePiece || '');
setErrorMessage(''); setErrorMessage('');
} else { } else {
setSelectedPropertiesDownloadId(null); setSelectedPropertiesDownloadId(null);
@@ -168,6 +217,25 @@ export const PropertiesModal = () => {
setLiveSpeedLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : ''); setLiveSpeedLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : '');
}, [item?.speedLimit, selectedPropertiesDownloadId]); }, [item?.speedLimit, selectedPropertiesDownloadId]);
useEffect(() => {
const activeLimit = item?.torrentUploadLimit?.trim();
setLiveTorrentUploadLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : '');
}, [item?.torrentUploadLimit, selectedPropertiesDownloadId]);
useEffect(() => {
peerDiagnosticsRequestRef.current += 1;
setTorrentPeerDiagnostics(null);
setTorrentPeerDiagnosticsError(false);
setIsTorrentPeerDiagnosticsPending(false);
}, [item?.id, item?.isTorrent, item?.lastTry, item?.status]);
useEffect(() => {
setLiveTorrentMaxPeersValue(
item?.torrentMaxPeers === undefined ? '' : String(item.torrentMaxPeers)
);
setLiveTorrentPeerSpeedLimitValue(item?.torrentPeerSpeedLimit || '');
}, [item?.torrentMaxPeers, item?.torrentPeerSpeedLimit, selectedPropertiesDownloadId]);
useEffect(() => { useEffect(() => {
if (!selectedPropertiesDownloadId || connectionsDirty) return; if (!selectedPropertiesDownloadId || connectionsDirty) return;
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId); const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
@@ -213,6 +281,46 @@ export const PropertiesModal = () => {
} }
}; };
const handleRefreshTorrentPeers = async () => {
if (
isTorrentPeerDiagnosticsPending
|| !item.isTorrent
|| !isPeerDiagnosticsStatus(item.status)
) return;
const requestId = ++peerDiagnosticsRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentPeerDiagnosticsPending(true);
setTorrentPeerDiagnosticsError(false);
try {
const diagnostics = await invoke('get_torrent_peers', { id: propertiesDownloadId });
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === peerDiagnosticsRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isPeerDiagnosticsStatus(currentItem.status)
) {
setTorrentPeerDiagnostics(diagnostics);
}
} catch {
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === peerDiagnosticsRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isPeerDiagnosticsStatus(currentItem.status)
) {
setTorrentPeerDiagnosticsError(true);
setTorrentPeerDiagnostics(null);
}
} finally {
if (requestId === peerDiagnosticsRequestRef.current) {
setIsTorrentPeerDiagnosticsPending(false);
}
}
};
const handleSave = async () => { const handleSave = async () => {
if (!url.trim()) { if (!url.trim()) {
setErrorMessage(t($ => $.properties.enterValidUrl)); setErrorMessage(t($ => $.properties.enterValidUrl));
@@ -223,6 +331,74 @@ export const PropertiesModal = () => {
return; return;
} }
const normalizedMaxPeers = liveTorrentMaxPeersValue.trim()
? Number(liveTorrentMaxPeersValue)
: undefined;
if (
item.isTorrent
&& normalizedMaxPeers !== undefined
&& (!Number.isInteger(normalizedMaxPeers) || normalizedMaxPeers < 0 || normalizedMaxPeers > 1000)
) {
setErrorMessage(t($ => $.properties.torrentMaxPeersInvalid));
return;
}
const normalizedPeerSpeedLimit = normalizeSpeedLimitForBackend(liveTorrentPeerSpeedLimitValue);
if (item.isTorrent && liveTorrentPeerSpeedLimitValue.trim() && !normalizedPeerSpeedLimit) {
setErrorMessage(t($ => $.properties.torrentPeerSpeedLimitInvalid));
return;
}
if (item.isTorrent && !isValidTorrentTrackerList(torrentTrackers)) {
setErrorMessage(t($ => $.properties.torrentTrackersInvalid));
return;
}
if (item.isTorrent && !isValidTorrentExcludeTrackerList(torrentExcludeTrackers)) {
setErrorMessage(t($ => $.properties.torrentExcludeTrackersInvalid));
return;
}
if (item.isTorrent && torrentTrackerConnectTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerConnectTimeout)) {
setErrorMessage(t($ => $.properties.torrentTrackerTimeoutInvalid));
return;
}
if (item.isTorrent && torrentTrackerTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerTimeout)) {
setErrorMessage(t($ => $.properties.torrentTrackerTimeoutInvalid));
return;
}
if (item.isTorrent && torrentTrackerInterval.trim() && normalizeTorrentTrackerInterval(torrentTrackerInterval) === undefined) {
setErrorMessage(t($ => $.properties.torrentTrackerIntervalInvalid));
return;
}
if (item.isTorrent && torrentPrioritizePiece.trim() && !normalizeTorrentPrioritizePiece(torrentPrioritizePiece)) {
setErrorMessage(t($ => $.properties.torrentPrioritizePieceInvalid));
return;
}
const normalizedStopTimeout = torrentStopTimeout.trim()
? Number(torrentStopTimeout)
: undefined;
if (
item.isTorrent
&& normalizedStopTimeout !== undefined
&& (!Number.isInteger(normalizedStopTimeout) || normalizedStopTimeout < 0 || normalizedStopTimeout > MAX_TORRENT_STOP_TIMEOUT)
) {
setErrorMessage(t($ => $.properties.torrentStopTimeoutInvalid));
return;
}
if (item.isTorrent && torrentRemoveUnselectedFile && !item.torrentFileIndices?.length) {
setErrorMessage(t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired));
return;
}
if (item.isTorrent && !normalizeTorrentEncryptionPolicy(torrentEncryptionPolicy)) {
setErrorMessage(t($ => $.properties.torrentEncryptionPolicyInvalid));
return;
}
if (
item.isTorrent
&& torrentRemoveUnselectedFile
&& !item.torrentRemoveUnselectedFile
&& !window.confirm(t($ => $.properties.torrentRemoveUnselectedFileConfirm))
) {
return;
}
const updates: Partial<DownloadItem> = { const updates: Partial<DownloadItem> = {
url, url,
fileName, fileName,
@@ -234,6 +410,32 @@ export const PropertiesModal = () => {
checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : undefined, checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : undefined,
cookies: cookies.trim() || undefined, cookies: cookies.trim() || undefined,
mirrors: mirrors.trim() || undefined, mirrors: mirrors.trim() || undefined,
...(item.isTorrent
? {
torrentMaxPeers: normalizedMaxPeers,
torrentPeerSpeedLimit: normalizedPeerSpeedLimit || undefined,
torrentCheckIntegrity,
torrentTrackers: torrentTrackers.trim() || undefined,
torrentExcludeTrackers: torrentExcludeTrackers.trim() || undefined,
torrentTrackerConnectTimeout: torrentTrackerConnectTimeout.trim()
? Number(torrentTrackerConnectTimeout)
: undefined,
torrentTrackerTimeout: torrentTrackerTimeout.trim()
? Number(torrentTrackerTimeout)
: undefined,
torrentTrackerInterval: torrentTrackerInterval.trim()
? Number(torrentTrackerInterval)
: undefined,
torrentStopTimeout: normalizedStopTimeout,
torrentPrioritizePiece: normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined,
torrentRemoveUnselectedFile: item.torrentFileIndices !== undefined
? torrentRemoveUnselectedFile
: undefined,
torrentEncryptionPolicy: torrentEncryptionPolicy !== TORRENT_ENCRYPTION_POLICY_DISABLED
? torrentEncryptionPolicy
: undefined,
}
: {}),
...(connectionsDirty ...(connectionsDirty
? { connections: resolveDownloadConnections(connections, perServerConnections) } ? { connections: resolveDownloadConnections(connections, perServerConnections) }
: {}), : {}),
@@ -321,10 +523,74 @@ 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 handleLiveTorrentPeerOptions = async () => {
if (
isLiveTorrentPeerOptionsPending
|| !item.isTorrent
|| !['downloading', 'seeding', 'retrying'].includes(item.status)
) return;
setErrorMessage('');
const requestId = ++actionRequestRef.current;
setIsLiveTorrentPeerOptionsPending(true);
try {
await useDownloadStore.getState().setTorrentPeerOptions(
item.id,
liveTorrentMaxPeersValue,
liveTorrentPeerSpeedLimitValue
);
} catch (error) {
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
setErrorMessage(t($ => $.properties.liveTorrentPeerOptionsFailed, {
detail: error instanceof Error ? error.message : String(error)
}));
}
} finally {
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
setIsLiveTorrentPeerOptionsPending(false);
}
}
};
const identityLocked = getIdentityLocked(item.status); const identityLocked = getIdentityLocked(item.status);
const transferLocked = getTransferLocked(item.status); const transferLocked = getTransferLocked(item.status);
const liveSpeedLimitAvailable = !item.isMedia && ['downloading', 'retrying'].includes(item.status); const liveSpeedLimitAvailable = !item.isMedia && ['downloading', 'retrying'].includes(item.status);
const liveSpeedLimitUnavailable = item.isMedia && ['downloading', 'processing', '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 liveTorrentPeerOptionsAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status);
const torrentPeerDiagnosticsAvailable = item.isTorrent && isPeerDiagnosticsStatus(item.status);
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections); const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
const observedConnectionTotal = Math.max( const observedConnectionTotal = Math.max(
1, 1,
@@ -333,6 +599,7 @@ export const PropertiesModal = () => {
const observedActiveConnections = liveProgress?.active_connections; const observedActiveConnections = liveProgress?.active_connections;
const connectionTelemetryActive = item.status === 'downloading' || const connectionTelemetryActive = item.status === 'downloading' ||
item.status === 'processing' || item.status === 'processing' ||
item.status === 'seeding' ||
item.status === 'retrying'; item.status === 'retrying';
const connectionStatus = (() => { const connectionStatus = (() => {
if (!connectionTelemetryActive) return String(configuredConnections); if (!connectionTelemetryActive) return String(configuredConnections);
@@ -363,9 +630,13 @@ export const PropertiesModal = () => {
: liveProgress?.fraction ?? item.fraction ?? 0; : liveProgress?.fraction ?? item.fraction ?? 0;
const displayedSpeed = item.status === 'completed' const displayedSpeed = item.status === 'completed'
? '-' ? '-'
: item.status === 'seeding'
? liveProgress?.upload_speed ?? '-'
: liveProgress?.speed ?? item.speed ?? '-'; : liveProgress?.speed ?? item.speed ?? '-';
const displayedEta = item.status === 'completed' const displayedEta = item.status === 'completed'
? '-' ? '-'
: item.status === 'seeding'
? '-'
: liveProgress?.eta ?? item.eta ?? '-'; : liveProgress?.eta ?? item.eta ?? '-';
const sizeDisplay = resolveDownloadSizeDisplay({ const sizeDisplay = resolveDownloadSizeDisplay({
downloadedBytes: liveProgress?.downloaded_bytes ?? item.downloadedBytes, downloadedBytes: liveProgress?.downloaded_bytes ?? item.downloadedBytes,
@@ -400,7 +671,7 @@ export const PropertiesModal = () => {
let statusColor = 'text-text-secondary'; let statusColor = 'text-text-secondary';
let StatusIcon = Info; let StatusIcon = Info;
if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; } 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 === 'processing') { statusColor = 'text-sky-500'; StatusIcon = Play; }
else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; } else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; }
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; } else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
@@ -559,6 +830,313 @@ export const PropertiesModal = () => {
<div className="col-start-2 text-[11px] text-text-muted"> <div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.savedPerDownload)} {t($ => $.properties.savedPerDownload)}
</div> </div>
{item.isTorrent && (
<>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.torrentMaxPeers)}</label>
<input
type="number"
min={0}
max={1000}
step={1}
value={liveTorrentMaxPeersValue}
onChange={event => setLiveTorrentMaxPeersValue(event.currentTarget.value)}
placeholder="55"
disabled={transferLocked}
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.torrentPeerSpeedLimit)}</label>
<input
type="text"
inputMode="decimal"
value={liveTorrentPeerSpeedLimitValue}
onChange={event => setLiveTorrentPeerSpeedLimitValue(event.currentTarget.value)}
placeholder="50K"
disabled={transferLocked}
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
<div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerOptionsSavedHint)}
</div>
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-text-primary">
{t($ => $.properties.torrentPeerDiagnostics)}
</div>
<button
type="button"
onClick={() => void handleRefreshTorrentPeers()}
disabled={!torrentPeerDiagnosticsAvailable || isTorrentPeerDiagnosticsPending}
className="app-button px-3 text-xs disabled:opacity-50"
>
{isTorrentPeerDiagnosticsPending
? t($ => $.properties.torrentPeerDiagnosticsLoading)
: t($ => $.properties.torrentPeerDiagnosticsRefresh)}
</button>
</div>
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerDiagnosticsHint)}
</p>
{!torrentPeerDiagnosticsAvailable && (
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerDiagnosticsUnavailable)}
</p>
)}
{torrentPeerDiagnosticsError && (
<p className="text-[11px] text-red-400">
{t($ => $.properties.torrentPeerDiagnosticsFailed)}
</p>
)}
{torrentPeerDiagnostics && (
<>
<div className="text-[11px] font-medium text-text-primary">
{t($ => $.properties.torrentPeerCount, {
total: torrentPeerDiagnostics.totalPeers,
seeders: torrentPeerDiagnostics.totalSeeders
})}
</div>
<div className="max-h-48 overflow-auto rounded border border-border-modal/60">
<table className="w-full text-[10px]">
<thead className="sticky top-0 bg-bg-input text-text-muted">
<tr>
<th className="px-2 py-1 text-start">#</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentPeerDownload)}</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentPeerUpload)}</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentPeerSeeder)}</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentPeerAmChoking)}</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentPeerChoking)}</th>
</tr>
</thead>
<tbody>
{torrentPeerDiagnostics.peers.map((peer, index) => (
<tr key={`${index}-${peer.downloadSpeed}-${peer.uploadSpeed}`} className="border-t border-border-modal/40 text-text-primary">
<td className="px-2 py-1 font-mono">{index + 1}</td>
<td className="px-2 py-1 font-mono">{formatPeerSpeed(peer.downloadSpeed)}</td>
<td className="px-2 py-1 font-mono">{formatPeerSpeed(peer.uploadSpeed)}</td>
<td className="px-2 py-1">{peer.seeder ? '✓' : '—'}</td>
<td className="px-2 py-1">{peer.amChoking ? '✓' : '—'}</td>
<td className="px-2 py-1">{peer.peerChoking ? '✓' : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
{torrentPeerDiagnostics.truncated && (
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerShowing, {
shown: torrentPeerDiagnostics.peers.length,
total: torrentPeerDiagnostics.totalPeers
})}
</p>
)}
</>
)}
</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-exclude-trackers-properties">
{t($ => $.properties.torrentExcludeTrackers)}
</label>
<div>
<textarea
id="torrent-exclude-trackers-properties"
rows={3}
value={torrentExcludeTrackers}
onChange={event => setTorrentExcludeTrackers(event.currentTarget.value)}
placeholder="https://tracker.example/announce or *"
disabled={transferLocked}
aria-describedby="torrent-exclude-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-exclude-trackers-properties-hint" className="mt-1 text-[11px] text-text-muted">
{t($ => $.properties.torrentExcludeTrackersHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-tracker-connect-timeout-properties">
{t($ => $.properties.torrentTrackerConnectTimeout)}
</label>
<div>
<div className="flex items-center gap-2">
<input
id="torrent-tracker-connect-timeout-properties"
type="number"
min={1}
max={MAX_TORRENT_TRACKER_TIMEOUT}
step={1}
value={torrentTrackerConnectTimeout}
onChange={event => setTorrentTrackerConnectTimeout(event.currentTarget.value)}
placeholder="60"
disabled={transferLocked}
aria-describedby="torrent-tracker-timing-properties-hint"
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
<span className="text-[11px] text-text-muted">{t($ => $.properties.seconds)}</span>
</div>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-tracker-timeout-properties">
{t($ => $.properties.torrentTrackerTimeout)}
</label>
<div>
<div className="flex items-center gap-2">
<input
id="torrent-tracker-timeout-properties"
type="number"
min={1}
max={MAX_TORRENT_TRACKER_TIMEOUT}
step={1}
value={torrentTrackerTimeout}
onChange={event => setTorrentTrackerTimeout(event.currentTarget.value)}
placeholder="60"
disabled={transferLocked}
aria-describedby="torrent-tracker-timing-properties-hint"
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
<span className="text-[11px] text-text-muted">{t($ => $.properties.seconds)}</span>
</div>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-tracker-interval-properties">
{t($ => $.properties.torrentTrackerInterval)}
</label>
<div>
<div className="flex items-center gap-2">
<input
id="torrent-tracker-interval-properties"
type="number"
min={0}
max={MAX_TORRENT_TRACKER_INTERVAL}
step={1}
value={torrentTrackerInterval}
onChange={event => setTorrentTrackerInterval(event.currentTarget.value)}
disabled={transferLocked}
aria-describedby="torrent-tracker-timing-properties-hint"
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
<span className="text-[11px] text-text-muted">{t($ => $.properties.seconds)}</span>
</div>
<p id="torrent-tracker-timing-properties-hint" className="mt-1 text-[11px] text-text-muted">
{t($ => $.properties.torrentTrackerTimingHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-stop-timeout-properties">
{t($ => $.properties.torrentStopTimeout)}
</label>
<div>
<div className="flex items-center gap-2">
<input
id="torrent-stop-timeout-properties"
type="number"
min={0}
max={MAX_TORRENT_STOP_TIMEOUT}
step={1}
value={torrentStopTimeout}
onChange={event => setTorrentStopTimeout(event.currentTarget.value)}
disabled={transferLocked}
aria-describedby="torrent-stop-timeout-properties-hint"
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
<span className="text-[11px] text-text-muted">{t($ => $.properties.seconds)}</span>
</div>
<p id="torrent-stop-timeout-properties-hint" className="mt-1 text-[11px] text-text-muted">
{t($ => $.properties.torrentStopTimeoutHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-prioritize-piece-properties">
{t($ => $.properties.torrentPrioritizePiece)}
</label>
<div>
<input
id="torrent-prioritize-piece-properties"
type="text"
value={torrentPrioritizePiece}
onChange={event => setTorrentPrioritizePiece(event.currentTarget.value)}
placeholder="head=1M,tail=1M"
disabled={transferLocked}
aria-describedby="torrent-prioritize-piece-properties-hint"
className="app-control w-full px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
/>
<p id="torrent-prioritize-piece-properties-hint" className="mt-1 text-[11px] text-text-muted">
{t($ => $.properties.torrentPrioritizePieceHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-encryption-policy-properties">
{t($ => $.properties.torrentEncryptionPolicy)}
</label>
<div>
<select
id="torrent-encryption-policy-properties"
value={torrentEncryptionPolicy}
onChange={event => setTorrentEncryptionPolicy(event.currentTarget.value as TorrentEncryptionPolicy)}
disabled={transferLocked}
aria-describedby="torrent-encryption-policy-properties-hint"
className="app-control max-w-56 px-2.5 py-1.5 text-xs disabled:opacity-50"
>
<option value={TORRENT_ENCRYPTION_POLICY_DISABLED}>
{t($ => $.properties.torrentEncryptionDisabled)}
</option>
<option value={TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO}>
{t($ => $.properties.torrentEncryptionRequireCrypto)}
</option>
<option value={TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION}>
{t($ => $.properties.torrentEncryptionForceEncryption)}
</option>
</select>
<p id="torrent-encryption-policy-properties-hint" className="mt-1 text-[11px] text-text-muted">
{t($ => $.properties.torrentEncryptionPolicyHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-check-integrity">
{t($ => $.properties.torrentVerifyIntegrity)}
</label>
<label className="flex items-start gap-2 text-xs text-text-primary">
<input
id="torrent-check-integrity"
type="checkbox"
checked={torrentCheckIntegrity}
onChange={event => setTorrentCheckIntegrity(event.currentTarget.checked)}
disabled={transferLocked}
className="accent-accent mt-0.5 disabled:opacity-50"
aria-describedby="torrent-check-integrity-hint"
/>
<span id="torrent-check-integrity-hint" className="text-[11px] text-text-muted">
{t($ => $.properties.torrentVerifyIntegrityHint)}
</span>
</label>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-remove-unselected-file">
{t($ => $.properties.torrentRemoveUnselectedFile)}
</label>
<label className="flex items-start gap-2 text-xs text-text-primary">
<input
id="torrent-remove-unselected-file"
type="checkbox"
checked={torrentRemoveUnselectedFile}
onChange={event => setTorrentRemoveUnselectedFile(event.currentTarget.checked)}
disabled={transferLocked || !item.torrentFileIndices?.length}
className="accent-red-500 mt-0.5 disabled:opacity-50"
aria-describedby="torrent-remove-unselected-file-hint"
/>
<span id="torrent-remove-unselected-file-hint" className="text-[11px] text-text-muted">
{item.torrentFileIndices?.length
? t($ => $.properties.torrentRemoveUnselectedFileHint)
: t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired)}
</span>
</label>
</>
)}
{(liveSpeedLimitAvailable || liveSpeedLimitUnavailable) && ( {(liveSpeedLimitAvailable || liveSpeedLimitUnavailable) && (
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2"> <div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
{liveSpeedLimitAvailable ? ( {liveSpeedLimitAvailable ? (
@@ -606,6 +1184,95 @@ export const PropertiesModal = () => {
)} )}
</div> </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>
)}
{liveTorrentPeerOptionsAvailable && (
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="text-xs font-semibold text-text-primary">
{t($ => $.properties.liveTorrentPeerOptions)}
</div>
<div className="grid grid-cols-[1fr_auto] items-center gap-2">
<label htmlFor="live-torrent-max-peers" className="text-[11px] text-text-muted">
{t($ => $.properties.torrentMaxPeers)}
</label>
<input
id="live-torrent-max-peers"
type="number"
min={0}
max={1000}
step={1}
value={liveTorrentMaxPeersValue}
onChange={event => setLiveTorrentMaxPeersValue(event.currentTarget.value)}
placeholder="55"
disabled={isLiveTorrentPeerOptionsPending}
aria-describedby="live-torrent-peer-options-hint"
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
<label htmlFor="live-torrent-peer-speed-limit" className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerSpeedLimit)}
</label>
<input
id="live-torrent-peer-speed-limit"
type="text"
inputMode="decimal"
value={liveTorrentPeerSpeedLimitValue}
onChange={event => setLiveTorrentPeerSpeedLimitValue(event.currentTarget.value)}
placeholder="50K"
disabled={isLiveTorrentPeerOptionsPending}
aria-describedby="live-torrent-peer-options-hint"
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
</div>
<button
type="button"
onClick={() => void handleLiveTorrentPeerOptions()}
disabled={isLiveTorrentPeerOptionsPending}
className="app-button app-button-primary px-3 text-xs disabled:opacity-50"
>
{t($ => $.properties.liveTorrentPeerOptionsApply)}
</button>
<p id="live-torrent-peer-options-hint" className="text-[11px] text-text-muted">
{t($ => $.properties.liveTorrentPeerOptionsHint)}
</p>
</div>
)}
</div> </div>
</section> </section>
+105
View File
@@ -34,6 +34,11 @@ import {
import { usePlatformInfo } from '../utils/platform'; import { usePlatformInfo } from '../utils/platform';
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls'; import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
import { normalizeCustomProxy } from '../store/useDownloadStore'; import { normalizeCustomProxy } from '../store/useDownloadStore';
import {
MAX_TORRENT_MAX_OPEN_FILES,
MIN_TORRENT_MAX_OPEN_FILES,
normalizeTorrentMaxOpenFiles
} from '../utils/downloads';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { localeDirection, resolveAppLocale } from '../i18n'; import { localeDirection, resolveAppLocale } from '../i18n';
@@ -318,6 +323,10 @@ const engineRunId = useRef(0);
() => String(settings.maxConcurrentDownloads) () => String(settings.maxConcurrentDownloads)
); );
const [proxyPortInput, setProxyPortInput] = useState(() => String(settings.proxyPort)); const [proxyPortInput, setProxyPortInput] = useState(() => String(settings.proxyPort));
const [torrentMaxOpenFilesInput, setTorrentMaxOpenFilesInput] = useState(
() => String(settings.torrentMaxOpenFiles)
);
const torrentMaxOpenFilesCommitRef = useRef(0);
useEffect(() => { useEffect(() => {
setPerServerConnectionsInput(String(settings.perServerConnections)); setPerServerConnectionsInput(String(settings.perServerConnections));
@@ -331,6 +340,10 @@ const engineRunId = useRef(0);
setProxyPortInput(String(settings.proxyPort)); setProxyPortInput(String(settings.proxyPort));
}, [settings.proxyPort]); }, [settings.proxyPort]);
useEffect(() => {
setTorrentMaxOpenFilesInput(String(settings.torrentMaxOpenFiles));
}, [settings.torrentMaxOpenFiles]);
// Local state for adding site login // Local state for adding site login
const [loginPattern, setLoginPattern] = useState(''); const [loginPattern, setLoginPattern] = useState('');
const [loginUser, setLoginUser] = useState(''); const [loginUser, setLoginUser] = useState('');
@@ -346,6 +359,22 @@ const engineRunId = useRef(0);
// Toast notifications // Toast notifications
const { addToast } = useToast(); const { addToast } = useToast();
const commitTorrentMaxOpenFiles = (raw: string) => {
const next = normalizeTorrentMaxOpenFiles(raw) ?? settings.torrentMaxOpenFiles;
const requestId = ++torrentMaxOpenFilesCommitRef.current;
setTorrentMaxOpenFilesInput(String(next));
void settings.setTorrentMaxOpenFiles(next).catch(error => {
if (requestId !== torrentMaxOpenFilesCommitRef.current) return;
setTorrentMaxOpenFilesInput(String(settings.torrentMaxOpenFiles));
addToast({
message: t($ => $.settings.network.torrentMaxOpenFilesUpdateFailed, {
detail: error instanceof Error ? error.message : String(error)
}),
variant: 'error',
isActionable: true
});
});
};
const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false); const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false);
const [manualUpdateStatus, setManualUpdateStatus] = useState<ManualUpdateStatus>({ type: 'idle' }); const [manualUpdateStatus, setManualUpdateStatus] = useState<ManualUpdateStatus>({ type: 'idle' });
@@ -1157,6 +1186,82 @@ runEngineChecks(false);
)} )}
</div> </div>
<h2 className="settings-section-title">{t($ => $.settings.network.torrentPeerDiscovery)}</h2>
<div className="mac-settings-group">
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentDht)}</span>
<small>{t($ => $.settings.network.torrentDhtDescription)}</small>
</div>
<input
type="checkbox"
checked={settings.torrentEnableDht}
onChange={(event) => settings.setTorrentEnableDht(event.target.checked)}
className="mac-switch"
/>
</label>
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentDht6)}</span>
<small>{t($ => $.settings.network.torrentDht6Description)}</small>
</div>
<input
type="checkbox"
checked={settings.torrentEnableDht6}
onChange={(event) => settings.setTorrentEnableDht6(event.target.checked)}
className="mac-switch"
/>
</label>
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentPex)}</span>
<small>{t($ => $.settings.network.torrentPexDescription)}</small>
</div>
<input
type="checkbox"
checked={settings.torrentEnablePex}
onChange={(event) => settings.setTorrentEnablePex(event.target.checked)}
className="mac-switch"
/>
</label>
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentLpd)}</span>
<small>{t($ => $.settings.network.torrentLpdDescription)}</small>
</div>
<input
type="checkbox"
checked={settings.torrentEnableLpd}
onChange={(event) => settings.setTorrentEnableLpd(event.target.checked)}
className="mac-switch"
/>
</label>
<p className="settings-group-footer">
{t($ => $.settings.network.torrentPeerDiscoveryRestartNote)}
</p>
</div>
<h2 className="settings-section-title">{t($ => $.settings.network.torrentResourceLimits)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentMaxOpenFiles)}</span>
<small>{t($ => $.settings.network.torrentMaxOpenFilesDescription)}</small>
</div>
<input
type="number"
min={MIN_TORRENT_MAX_OPEN_FILES}
max={MAX_TORRENT_MAX_OPEN_FILES}
step={1}
value={torrentMaxOpenFilesInput}
onChange={(event) => setTorrentMaxOpenFilesInput(event.target.value)}
onBlur={(event) => commitTorrentMaxOpenFiles(event.target.value)}
className="app-control settings-port-input text-center"
aria-label={t($ => $.settings.network.torrentMaxOpenFiles)}
/>
</div>
</div>
<h2 className="settings-section-title">{t($ => $.settings.network.identity)}</h2> <h2 className="settings-section-title">{t($ => $.settings.network.identity)}</h2>
<div className="mac-settings-group settings-popup-group"> <div className="mac-settings-group settings-popup-group">
<div className="mac-settings-row settings-network-row"> <div className="mac-settings-row settings-network-row">
+124 -1
View File
@@ -89,6 +89,7 @@ const common = {
queued: 'Queued', queued: 'Queued',
downloading: 'Downloading', downloading: 'Downloading',
processing: 'Processing', processing: 'Processing',
seeding: 'Seeding',
paused: 'Paused', paused: 'Paused',
completed: 'Completed', completed: 'Completed',
failed: 'Failed', failed: 'Failed',
@@ -227,6 +228,63 @@ const common = {
liveSpeedLimitClear: 'Clear', liveSpeedLimitClear: 'Clear',
liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}', liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}',
liveSpeedLimitUnavailable: 'Live speed control is unavailable for media downloads while running.', 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}}',
liveTorrentPeerOptions: 'Live Torrent peer controls',
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.',
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 *.',
torrentTrackerConnectTimeout: 'Tracker connect timeout',
torrentTrackerTimeout: 'Tracker request timeout',
torrentTrackerInterval: 'Tracker interval',
torrentTrackerTimingHint: 'Connect timeout covers establishing a tracker connection; request timeout covers the response afterward. Blank values keep Aria2s 60-second defaults, and interval 0 follows tracker response and download progress.',
torrentTrackerTimeoutInvalid: 'Tracker timeout must be a whole number from 1 to 604800 seconds',
torrentTrackerIntervalInvalid: 'Tracker interval must be a whole number from 0 to 604800 seconds',
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',
torrentPeerSpeedLimit: 'Peer speed threshold',
torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000',
torrentPeerSpeedLimitInvalid: 'Torrent peer speed threshold must be greater than zero',
torrentPeerDiagnostics: 'Torrent peer diagnostics',
torrentPeerDiagnosticsRefresh: 'Refresh',
torrentPeerDiagnosticsLoading: 'Loading peer diagnostics…',
torrentPeerDiagnosticsUnavailable: 'Peer diagnostics are available while this Torrent is active.',
torrentPeerDiagnosticsFailed: 'Could not read Torrent peer diagnostics.',
torrentPeerDiagnosticsHint: 'Speeds and connection flags only are shown; peer IPs, ports, IDs, and bitfields are not retained.',
torrentPeerCount: '{{total}} peers · {{seeders}} seeders',
torrentPeerDownload: 'Download',
torrentPeerUpload: 'Upload',
torrentPeerSeeder: 'Seeder',
torrentPeerAmChoking: 'Firelink choking',
torrentPeerChoking: 'Peer choking',
torrentPeerShowing: 'Showing {{shown}} of {{total}} peers.',
seconds: 'seconds',
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',
torrentEncryptionPolicy: 'Torrent encryption policy',
torrentEncryptionPolicyHint: 'Applied when this Torrent starts or retries. Choose one policy so the handshake and payload encryption settings stay consistent.',
torrentEncryptionDisabled: 'Disabled',
torrentEncryptionRequireCrypto: 'Require obfuscated handshake',
torrentEncryptionForceEncryption: 'Force encrypted payload (ARC4)',
torrentEncryptionPolicyInvalid: 'Choose a valid Torrent encryption policy',
torrentRemoveUnselectedFile: 'Delete unselected Torrent files after completion',
torrentRemoveUnselectedFileHint: 'Only applies when a subset of files is selected. Aria2 permanently deletes the other files after the Torrent completes.',
torrentRemoveUnselectedFileConfirm: 'Delete {{count}} unselected Torrent files after completion? This cannot be undone.',
torrentRemoveUnselectedFileSelectionRequired: 'Select a subset of Torrent files before enabling unselected-file removal.',
liveTorrentPeerOptionsFailed: 'Could not update live Torrent peer controls: {{detail}}',
category: 'Category', category: 'Category',
lastTry: 'Last try', lastTry: 'Last try',
dateAdded: 'Date added', dateAdded: 'Date added',
@@ -446,7 +504,7 @@ const common = {
pauseBeforeReplace: 'Pause {{file}} before replacing it.', pauseBeforeReplace: 'Pause {{file}} before replacing it.',
cannotReplace: 'Cannot replace {{file}}: file is not owned by a Firelink download.', cannotReplace: 'Cannot replace {{file}}: file is not owned by a Firelink download.',
downloadLinks: 'Download Links', downloadLinks: 'Download Links',
pastePlaceholder: 'Paste HTTP, HTTPS, FTP, or SFTP URLs here...\n\nFor media downloads, paste links from YouTube, X, TikTok, Instagram, Reddit, etc.', pastePlaceholder: 'Paste HTTP, HTTPS, FTP, SFTP, or magnet URLs here... You can also choose .torrent files below.\n\nFor media downloads, paste links from YouTube, X, TikTok, Instagram, Reddit, etc.',
playlistSummary: 'Playlist “{{title}}”: {{loaded}}{{total}} entries loaded{{truncated}}{{skipped}}', playlistSummary: 'Playlist “{{title}}”: {{loaded}}{{total}} entries loaded{{truncated}}{{skipped}}',
safeEntryLimit: ' (safe entry limit reached)', safeEntryLimit: ' (safe entry limit reached)',
selectedSummary: '{{ready}} selected ready, {{fallback}} fallback, {{mediaRetry}} media retry, {{blocked}} blocked', selectedSummary: '{{ready}} selected ready, {{fallback}} fallback, {{mediaRetry}} media retry, {{blocked}} blocked',
@@ -454,6 +512,56 @@ const common = {
selectAll: 'Select all', selectAll: 'Select all',
refreshMetadata: 'Refresh Metadata', refreshMetadata: 'Refresh Metadata',
files: 'Files', files: 'Files',
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',
seconds: 'seconds',
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',
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 *.',
torrentTrackerConnectTimeout: 'Tracker connect timeout',
torrentTrackerTimeout: 'Tracker request timeout',
torrentTrackerInterval: 'Tracker interval',
torrentTrackerTimingHint: 'Saved with this Torrent and applied on its next start or retry. Connect timeout covers establishing the connection; request timeout covers the response afterward. Blank values keep Aria2s 60-second defaults, and interval 0 follows tracker response and download progress.',
torrentTrackerTimeoutInvalid: 'Tracker timeout must be a whole number from 1 to 604800 seconds',
torrentTrackerIntervalInvalid: 'Tracker interval must be a whole number from 0 to 604800 seconds',
torrentVerifyIntegrity: 'Verify Torrent integrity',
torrentVerifyIntegrityHint: 'Recheck piece hashes when starting or retrying; damaged pieces may be downloaded again.',
torrentMaxPeers: 'Maximum Torrent peers',
torrentPeerSpeedLimit: 'Peer speed threshold',
torrentPeerOptionsHint: 'Leave blank for Aria2 defaults (55 peers and 50K). 0 peers means unlimited.',
torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000',
torrentPeerSpeedLimitInvalid: 'Torrent peer speed threshold must be greater than zero',
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',
torrentEncryptionPolicy: 'Torrent encryption policy',
torrentEncryptionPolicyHint: 'Saved with this Torrent and applied on its next start or retry. The selected policy keeps Aria2 encryption settings consistent.',
torrentEncryptionDisabled: 'Disabled',
torrentEncryptionRequireCrypto: 'Require obfuscated handshake',
torrentEncryptionForceEncryption: 'Force encrypted payload (ARC4)',
torrentEncryptionPolicyInvalid: 'Choose a valid Torrent encryption policy',
torrentRemoveUnselectedFile: 'Delete unselected Torrent files after completion',
torrentRemoveUnselectedFileHint: 'Only applies when a selected subset is configured. The unselected files are not Firelink-owned and are permanently removed when the Torrent completes.',
torrentRemoveUnselectedFileConfirm: 'Enable permanent deletion of unselected Torrent files after completion? This cannot be undone.',
torrentRemoveUnselectedFileSelectionRequired: 'Select a subset of Torrent files before enabling unselected-file removal.',
required: 'Required', required: 'Required',
free: 'Free', free: 'Free',
preview: 'Preview', preview: 'Preview',
@@ -681,6 +789,20 @@ const common = {
detectedSystemProxy: 'A system proxy was detected. Normal file downloads require an HTTP or HTTPS endpoint; media downloads can use SOCKS.', detectedSystemProxy: 'A system proxy was detected. Normal file downloads require an HTTP or HTTPS endpoint; media downloads can use SOCKS.',
noSystemProxy: 'No usable system proxy was detected. Downloads will use no proxy.', noSystemProxy: 'No usable system proxy was detected. Downloads will use no proxy.',
systemProxyReadFailed: 'System proxy configuration could not be read. Choose No Proxy or try again when it is available.', systemProxyReadFailed: 'System proxy configuration could not be read. Choose No Proxy or try again when it is available.',
torrentPeerDiscovery: 'BitTorrent peer discovery',
torrentDht: 'IPv4 DHT and UDP trackers',
torrentDhtDescription: 'Find peers without relying only on trackers. Disabling this also disables UDP tracker support.',
torrentDht6: 'IPv6 DHT',
torrentDht6Description: 'Use IPv6 for distributed peer discovery when the network provides a usable IPv6 path.',
torrentPex: 'Peer Exchange (PEX)',
torrentPexDescription: 'Allow connected peers to share additional peer addresses.',
torrentLpd: 'Local Peer Discovery (LPD)',
torrentLpdDescription: 'Discover compatible peers on the local network. This increases local network visibility.',
torrentPeerDiscoveryRestartNote: 'These options are global to Aria2 and take effect after Firelink restarts. Aria2 still disables peer discovery for private torrents.',
torrentResourceLimits: 'BitTorrent resource limits',
torrentMaxOpenFiles: 'Maximum open Torrent files',
torrentMaxOpenFilesDescription: 'Global Aria2 limit for files open at once in multi-file Torrents. Lower values reduce file-descriptor use; the default is 100. Changes apply to new Torrents without restarting Aria2, and this does not raise your operating system limit.',
torrentMaxOpenFilesUpdateFailed: 'Could not apply the Torrent open-file limit: {{detail}}',
identity: 'Identity', identity: 'Identity',
customUserAgent: 'Custom User-Agent', customUserAgent: 'Custom User-Agent',
userAgentDescription: 'Applied to metadata fetches and download engines.', userAgentDescription: 'Applied to metadata fetches and download engines.',
@@ -814,6 +936,7 @@ const common = {
active: '{{count}} active', active: '{{count}} active',
queued: '{{count}} queued', queued: '{{count}} queued',
done: '{{count}} done', done: '{{count}} done',
seeding: 'Seeding',
}, },
} as const; } as const;
+124 -1
View File
@@ -89,6 +89,7 @@ const fa = {
queued: 'در صف', queued: 'در صف',
downloading: 'در حال دانلود', downloading: 'در حال دانلود',
processing: 'در حال پردازش', processing: 'در حال پردازش',
seeding: 'در حال اشتراک‌گذاری',
paused: 'متوقف‌شده', paused: 'متوقف‌شده',
completed: 'تکمیل‌شده', completed: 'تکمیل‌شده',
failed: 'ناموفق', failed: 'ناموفق',
@@ -227,6 +228,63 @@ const fa = {
liveSpeedLimitClear: 'پاک کردن', liveSpeedLimitClear: 'پاک کردن',
liveSpeedLimitFailed: 'به‌روزرسانی سقف سرعت زنده ممکن نیست: {{detail}}', liveSpeedLimitFailed: 'به‌روزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانه‌ای هنگام اجرا در دسترس نیست.', liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانه‌ای هنگام اجرا در دسترس نیست.',
liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت',
liveTorrentUploadLimitHint: 'برای تورنت‌های فعال و در حال سید اعمال می‌شود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
liveTorrentUploadLimitFailed: 'به‌روزرسانی محدودیت زنده آپلود تورنت ممکن نیست: {{detail}}',
liveTorrentPeerOptions: 'کنترل زنده همتاهای تورنت',
liveTorrentPeerOptionsApply: 'اعمال کنترل همتا',
liveTorrentPeerOptionsHint: 'بدون جایگزینی تورنت فعال اعمال می‌شود. برای استفاده از پیش‌فرض آریا۲ خالی بگذارید.',
torrentPeerOptionsSavedHint: 'برای هر تورنت ذخیره می‌شود. صفر یعنی نامحدود؛ خالی یعنی پیش‌فرض آریا۲.',
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 بدون اطلاعات ورود، یا * استفاده کنید.',
torrentTrackerConnectTimeout: 'مهلت اتصال به Tracker',
torrentTrackerTimeout: 'مهلت درخواست Tracker',
torrentTrackerInterval: 'فاصله درخواست Tracker',
torrentTrackerTimingHint: 'مهلت اتصال برای برقراری اتصال به Tracker و مهلت درخواست برای پاسخ پس از آن است. مقدار خالی پیش‌فرض ۶۰ ثانیه‌ای آریا۲ را نگه می‌دارد و فاصله ۰ از پاسخ Tracker و پیشرفت دانلود پیروی می‌کند.',
torrentTrackerTimeoutInvalid: 'مهلت Tracker باید عددی صحیح بین ۱ تا ۶۰۴۸۰۰ ثانیه باشد',
torrentTrackerIntervalInvalid: 'فاصله Tracker باید عددی صحیح بین ۰ تا ۶۰۴۸۰۰ ثانیه باشد',
torrentVerifyIntegrity: 'بررسی صحت تورنت',
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد این تورنت اعمال می‌شود. ممکن است قطعه‌ها دوباره بررسی و داده‌های خراب دوباره دانلود شوند؛ در انتقال فعال قابل تغییر نیست.',
torrentMaxPeers: 'حداکثر همتاهای تورنت',
torrentPeerSpeedLimit: 'آستانه سرعت همتا',
torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد',
torrentPeerSpeedLimitInvalid: 'آستانه سرعت همتای تورنت باید بیشتر از صفر باشد',
torrentPeerDiagnostics: 'اطلاعات همتاهای تورنت',
torrentPeerDiagnosticsRefresh: 'تازه‌سازی',
torrentPeerDiagnosticsLoading: 'در حال دریافت اطلاعات همتاها…',
torrentPeerDiagnosticsUnavailable: 'اطلاعات همتاها هنگام فعال بودن تورنت در دسترس است.',
torrentPeerDiagnosticsFailed: 'خواندن اطلاعات همتاهای تورنت ممکن نیست.',
torrentPeerDiagnosticsHint: 'فقط سرعت و وضعیت اتصال نمایش داده می‌شود؛ IP، پورت، شناسه و بیت‌فیلد همتاها ذخیره نمی‌شود.',
torrentPeerCount: '{{total}} همتا · {{seeders}} سید',
torrentPeerDownload: 'دریافت',
torrentPeerUpload: 'آپلود',
torrentPeerSeeder: 'سید',
torrentPeerAmChoking: 'محدودسازی از طرف Firelink',
torrentPeerChoking: 'محدودسازی از طرف همتا',
torrentPeerShowing: 'نمایش {{shown}} همتا از {{total}} همتا.',
seconds: 'ثانیه',
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف می‌کند. ۰ این سیاست را غیرفعال می‌کند؛ تغییرات هنگام شروع یا تلاش مجدد اعمال می‌شوند.',
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
torrentPrioritizePiece: 'اولویت‌بندی قطعه‌های تورنت',
torrentPrioritizePieceHint: 'سیاست اختیاری پیش‌نمایش آریا۲: ابتدا، انتها یا هر دو؛ برای هرکدام می‌توان اندازه‌ای مثل 1M نوشت. تغییرات هنگام شروع یا تلاش مجدد اعمال می‌شوند.',
torrentPrioritizePieceInvalid: 'اولویت قطعه‌های تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
torrentEncryptionPolicy: 'سیاست رمزنگاری تورنت',
torrentEncryptionPolicyHint: 'هنگام شروع یا تلاش مجدد اعمال می‌شود. یک سیاست واحد انتخاب کنید تا تنظیمات handshake و رمزنگاری payload آریا۲ سازگار بمانند.',
torrentEncryptionDisabled: 'غیرفعال',
torrentEncryptionRequireCrypto: 'الزام handshake مبهم‌سازی‌شده',
torrentEncryptionForceEncryption: 'الزام payload رمزنگاری‌شده (ARC4)',
torrentEncryptionPolicyInvalid: 'یک سیاست معتبر برای رمزنگاری تورنت انتخاب کنید',
torrentRemoveUnselectedFile: 'حذف فایل‌های انتخاب‌نشده تورنت پس از تکمیل',
torrentRemoveUnselectedFileHint: 'فقط وقتی اعمال می‌شود که زیرمجموعه‌ای از فایل‌ها انتخاب شده باشد. آریا۲ فایل‌های دیگر را پس از تکمیل تورنت برای همیشه حذف می‌کند.',
torrentRemoveUnselectedFileConfirm: '{{count}} فایل انتخاب‌نشده تورنت پس از تکمیل حذف شوند؟ این کار قابل بازگشت نیست.',
torrentRemoveUnselectedFileSelectionRequired: 'پیش از فعال‌کردن حذف فایل‌های انتخاب‌نشده، زیرمجموعه‌ای از فایل‌های تورنت را انتخاب کنید.',
liveTorrentPeerOptionsFailed: 'کنترل زنده همتاهای تورنت به‌روزرسانی نشد: {{detail}}',
category: 'دسته', category: 'دسته',
lastTry: 'آخرین تلاش', lastTry: 'آخرین تلاش',
dateAdded: 'تاریخ افزودن', dateAdded: 'تاریخ افزودن',
@@ -446,7 +504,7 @@ const fa = {
pauseBeforeReplace: 'قبل از جایگزینی {{file}}، آن را متوقف کنید.', pauseBeforeReplace: 'قبل از جایگزینی {{file}}، آن را متوقف کنید.',
cannotReplace: 'نمی‌توان {{file}} را جایگزین کرد: فایل متعلق به یک دانلود Firelink نیست.', cannotReplace: 'نمی‌توان {{file}} را جایگزین کرد: فایل متعلق به یک دانلود Firelink نیست.',
downloadLinks: 'پیوندهای دانلود', downloadLinks: 'پیوندهای دانلود',
pastePlaceholder: '\u2066URL\u2069های \u2066HTTP\u2069، \u2066HTTPS\u2069، \u2066FTP\u2069 یا \u2066SFTP\u2069 را در اینجا جای‌گذاری کنید…\n\nبرای دانلود رسانه، پیوندهایی از \u2066YouTube\u2069، \u2066X\u2069، \u2066TikTok\u2069، \u2066Instagram\u2069، \u2066Reddit\u2069 و غیره جای‌گذاری کنید.', pastePlaceholder: 'URLهای HTTP، HTTPS، FTP، SFTP یا magnet را اینجا جای‌گذاری کنید… همچنین می‌توانید فایل‌های .torrent را از پایین انتخاب کنید.\n\nبرای دانلود رسانه، پیوندهایی از \u2066YouTube\u2069، \u2066X\u2069، \u2066TikTok\u2069، \u2066Instagram\u2069، \u2066Reddit\u2069 و غیره جای‌گذاری کنید.',
playlistSummary: 'لیست پخش "{{title}}": {{loaded}} از {{total}} ورودی بارگیری شد{{truncated}}{{skipped}}', playlistSummary: 'لیست پخش "{{title}}": {{loaded}} از {{total}} ورودی بارگیری شد{{truncated}}{{skipped}}',
safeEntryLimit: ' (به حد مجاز ایمن ورودی‌ها رسیدیم)', safeEntryLimit: ' (به حد مجاز ایمن ورودی‌ها رسیدیم)',
selectedSummary: '{{ready}} انتخاب‌شده آماده، {{fallback}} اطلاعات جایگزین، {{mediaRetry}} تلاش مجدد رسانه، {{blocked}} مسدودشده', selectedSummary: '{{ready}} انتخاب‌شده آماده، {{fallback}} اطلاعات جایگزین، {{mediaRetry}} تلاش مجدد رسانه، {{blocked}} مسدودشده',
@@ -454,6 +512,56 @@ const fa = {
selectAll: 'انتخاب همه', selectAll: 'انتخاب همه',
refreshMetadata: 'تازه‌سازی متادیتا', refreshMetadata: 'تازه‌سازی متادیتا',
files: 'فایل‌ها', files: 'فایل‌ها',
torrentFiles: 'فایل‌های تورنت',
chooseTorrentFiles: 'افزودن فایل‌های .torrent',
torrentMetadataPending: 'آریا۲ هنگام شروع انتقال، متادیتای مگنت را دریافت می‌کند.',
torrentSeeding: 'اشتراک‌گذاری تورنت',
seedAfterDownload: 'پس از پایان دانلود سید شود',
seedTime: 'مدت سید',
minutes: 'دقیقه',
seconds: 'ثانیه',
seedRatio: 'نسبت سید',
seedRatioHint: '۰ یعنی فقط مدت زمان تعیین‌شده ملاک است؛ در غیر این صورت با رسیدن به اولین حد متوقف می‌شود.',
limitTorrentUpload: 'محدود کردن آپلود تورنت',
torrentUploadLimit: 'محدودیت آپلود تورنت',
torrentSeedTimeInvalid: 'مدت سید تورنت باید بیشتر از صفر باشد',
torrentSeedRatioInvalid: 'نسبت سید تورنت نمی‌تواند منفی باشد',
torrentUploadLimitInvalid: 'محدودیت آپلود تورنت باید بیشتر از صفر باشد',
torrentTrackers: 'Trackerهای اضافی تورنت',
torrentTrackersHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال می‌شود.',
torrentTrackersInvalid: 'فهرست Trackerهای تورنت نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود استفاده کنید.',
torrentExcludeTrackers: 'Trackerهای مستثناشده تورنت',
torrentExcludeTrackersHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال می‌شود. * همه آدرس‌های announce را حذف می‌کند؛ تنظیمات DHT و PEX تغییر نمی‌کند.',
torrentExcludeTrackersInvalid: 'فهرست Trackerهای مستثناشده نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود، یا * استفاده کنید.',
torrentTrackerConnectTimeout: 'مهلت اتصال به Tracker',
torrentTrackerTimeout: 'مهلت درخواست Tracker',
torrentTrackerInterval: 'فاصله درخواست Tracker',
torrentTrackerTimingHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال می‌شود. مهلت اتصال برای برقراری اتصال و مهلت درخواست برای پاسخ پس از آن است؛ مقدار خالی پیش‌فرض ۶۰ ثانیه‌ای آریا۲ را نگه می‌دارد و فاصله ۰ از پاسخ Tracker و پیشرفت دانلود پیروی می‌کند.',
torrentTrackerTimeoutInvalid: 'مهلت Tracker باید عددی صحیح بین ۱ تا ۶۰۴۸۰۰ ثانیه باشد',
torrentTrackerIntervalInvalid: 'فاصله Tracker باید عددی صحیح بین ۰ تا ۶۰۴۸۰۰ ثانیه باشد',
torrentVerifyIntegrity: 'بررسی صحت تورنت',
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد، هش قطعه‌ها را بررسی می‌کند؛ قطعه‌های خراب ممکن است دوباره دانلود شوند.',
torrentMaxPeers: 'حداکثر همتاهای تورنت',
torrentPeerSpeedLimit: 'آستانه سرعت همتا',
torrentPeerOptionsHint: 'برای استفاده از پیش‌فرض‌های آریا۲ خالی بگذارید (۵۵ همتا و 50K). صفر یعنی نامحدود.',
torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد',
torrentPeerSpeedLimitInvalid: 'آستانه سرعت همتای تورنت باید بیشتر از صفر باشد',
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف می‌کند. ۰ این سیاست را غیرفعال می‌کند.',
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
torrentPrioritizePiece: 'اولویت‌بندی قطعه‌های تورنت',
torrentPrioritizePieceHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال می‌شود. ابتدا، انتها یا هر دو را با اندازه اختیاری K یا M وارد کنید.',
torrentPrioritizePieceInvalid: 'اولویت قطعه‌های تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
torrentEncryptionPolicy: 'سیاست رمزنگاری تورنت',
torrentEncryptionPolicyHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال می‌شود. سیاست انتخابی تنظیمات رمزنگاری آریا۲ را سازگار نگه می‌دارد.',
torrentEncryptionDisabled: 'غیرفعال',
torrentEncryptionRequireCrypto: 'الزام handshake مبهم‌سازی‌شده',
torrentEncryptionForceEncryption: 'الزام payload رمزنگاری‌شده (ARC4)',
torrentEncryptionPolicyInvalid: 'یک سیاست معتبر برای رمزنگاری تورنت انتخاب کنید',
torrentRemoveUnselectedFile: 'حذف فایل‌های انتخاب‌نشده تورنت پس از تکمیل',
torrentRemoveUnselectedFileHint: 'فقط برای زیرمجموعه انتخاب‌شده اعمال می‌شود. فایل‌های انتخاب‌نشده متعلق به Firelink نیستند و هنگام تکمیل تورنت برای همیشه حذف می‌شوند.',
torrentRemoveUnselectedFileConfirm: 'حذف دائمی فایل‌های انتخاب‌نشده تورنت پس از تکمیل فعال شود؟ این کار قابل بازگشت نیست.',
torrentRemoveUnselectedFileSelectionRequired: 'پیش از فعال‌کردن حذف فایل‌های انتخاب‌نشده، زیرمجموعه‌ای از فایل‌های تورنت را انتخاب کنید.',
required: 'الزامی', required: 'الزامی',
free: 'فضای آزاد', free: 'فضای آزاد',
preview: 'پیش‌نمایش', preview: 'پیش‌نمایش',
@@ -681,6 +789,20 @@ const fa = {
detectedSystemProxy: 'یک پروکسی سیستم شناسایی شد. دانلودهای فایل عادی به یک نقطه پایانی HTTP یا HTTPS نیاز دارند؛ دانلودهای رسانه می‌توانند از SOCKS استفاده کنند.', detectedSystemProxy: 'یک پروکسی سیستم شناسایی شد. دانلودهای فایل عادی به یک نقطه پایانی HTTP یا HTTPS نیاز دارند؛ دانلودهای رسانه می‌توانند از SOCKS استفاده کنند.',
noSystemProxy: 'هیچ پروکسی سیستم قابل استفاده‌ای شناسایی نشد. دانلودها از هیچ پروکسی‌ای استفاده نخواهند کرد.', noSystemProxy: 'هیچ پروکسی سیستم قابل استفاده‌ای شناسایی نشد. دانلودها از هیچ پروکسی‌ای استفاده نخواهند کرد.',
systemProxyReadFailed: 'پیکربندی پروکسی سیستم قابل خواندن نیست. بدون پروکسی را انتخاب کنید یا هنگامی که در دسترس است دوباره امتحان کنید.', systemProxyReadFailed: 'پیکربندی پروکسی سیستم قابل خواندن نیست. بدون پروکسی را انتخاب کنید یا هنگامی که در دسترس است دوباره امتحان کنید.',
torrentPeerDiscovery: 'کشف همتا در بیت‌تورنت',
torrentDht: 'DHT نسخه IPv4 و ترکرهای UDP',
torrentDhtDescription: 'همتاها را بدون تکیه صرف بر ترکرها پیدا می‌کند. خاموش کردن آن پشتیبانی از ترکرهای UDP را هم خاموش می‌کند.',
torrentDht6: 'DHT نسخه IPv6',
torrentDht6Description: 'وقتی مسیر IPv6 قابل استفاده باشد، از آن برای کشف توزیع‌شده همتاها استفاده می‌کند.',
torrentPex: 'تبادل همتا (PEX)',
torrentPexDescription: 'به همتاهای متصل اجازه می‌دهد آدرس همتاهای بیشتری را به اشتراک بگذارند.',
torrentLpd: 'کشف همتای محلی (LPD)',
torrentLpdDescription: 'همتاهای سازگار در شبکه محلی را پیدا می‌کند و دیده‌شدن ترافیک در شبکه محلی را افزایش می‌دهد.',
torrentPeerDiscoveryRestartNote: 'این گزینه‌ها سراسری و مربوط به Aria2 هستند و پس از راه‌اندازی مجدد Firelink اعمال می‌شوند. Aria2 همچنان کشف همتا را برای تورنت‌های خصوصی خاموش می‌کند.',
torrentResourceLimits: 'محدودیت منابع بیت‌تورنت',
torrentMaxOpenFiles: 'حداکثر فایل‌های باز تورنت',
torrentMaxOpenFilesDescription: 'حداکثر سراسری Aria2 برای تعداد فایل‌های هم‌زمان باز در تورنت‌های چندفایلی. مقدار کمتر مصرف file descriptor را کم می‌کند؛ پیش‌فرض ۱۰۰ است. تغییرات برای تورنت‌های جدید و بدون راه‌اندازی مجدد Aria2 اعمال می‌شوند و محدودیت سیستم‌عامل را افزایش نمی‌دهند.',
torrentMaxOpenFilesUpdateFailed: 'اعمال محدودیت فایل‌های باز تورنت ممکن نشد: {{detail}}',
identity: 'هویت', identity: 'هویت',
customUserAgent: 'User-Agent سفارشی', customUserAgent: 'User-Agent سفارشی',
userAgentDescription: 'در دریافت‌های متادیتا و موتورهای دانلود اعمال می‌شود.', userAgentDescription: 'در دریافت‌های متادیتا و موتورهای دانلود اعمال می‌شود.',
@@ -814,6 +936,7 @@ const fa = {
active: '{{count}} فعال', active: '{{count}} فعال',
queued: '{{count}} در صف', queued: '{{count}} در صف',
done: '{{count}} تکمیل‌شده', done: '{{count}} تکمیل‌شده',
seeding: 'در حال اشتراک‌گذاری',
}, },
} as const; } as const;
+124 -1
View File
@@ -89,6 +89,7 @@ const he = {
queued: 'בתור', queued: 'בתור',
downloading: 'מוריד', downloading: 'מוריד',
processing: 'מעבד', processing: 'מעבד',
seeding: 'משתף',
paused: 'מושהה', paused: 'מושהה',
completed: 'הושלם', completed: 'הושלם',
failed: 'נכשל', failed: 'נכשל',
@@ -227,6 +228,63 @@ const he = {
liveSpeedLimitClear: 'נקה', liveSpeedLimitClear: 'נקה',
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}', liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.', liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת',
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
liveTorrentUploadLimitFailed: 'לא ניתן לעדכן את הגבלת העלאת הטורנט בזמן אמת: {{detail}}',
liveTorrentPeerOptions: 'בקרות עמיתי טורנט בזמן אמת',
liveTorrentPeerOptionsApply: 'החל בקרות עמיתים',
liveTorrentPeerOptionsHint: 'השינוי חל בלי להחליף את הטורנט הפעיל. השאר ריק כדי להשתמש בברירות המחדל של Aria2.',
torrentPeerOptionsSavedHint: 'נשמר לכל טורנט. אפס עמיתים פירושו ללא הגבלה; ריק משתמש בברירות המחדל של Aria2.',
torrentTrackers: 'עוקבי טורנט נוספים',
torrentTrackersHint: 'עוקב HTTP, HTTPS או UDP אחד בכל שורה. פרטי התחברות אינם מותרים.',
torrentTrackersInvalid: 'רשימת עוקבי הטורנט אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות.',
torrentExcludeTrackers: 'עוקבי טורנט להחרגה',
torrentExcludeTrackersHint: 'עוקב HTTP, HTTPS או UDP אחד בכל שורה, או * כדי להחריג את כל כתובות ההכרזה. פרטי התחברות אינם מותרים; הגדרות DHT ו-PEX לא ישתנו.',
torrentExcludeTrackersInvalid: 'רשימת עוקבי הטורנט להחרגה אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות, או ב-*.',
torrentTrackerConnectTimeout: 'זמן קצוב לחיבור ל-Tracker',
torrentTrackerTimeout: 'זמן קצוב לבקשת Tracker',
torrentTrackerInterval: 'מרווח בין בקשות Tracker',
torrentTrackerTimingHint: 'זמן קצוב לחיבור חל על יצירת החיבור ל-Tracker, וזמן קצוב לבקשה חל על התגובה שלאחר מכן. ערכים ריקים שומרים על ברירת המחדל של Aria2, 60 שניות; מרווח 0 עוקב אחר תגובת ה-Tracker והתקדמות ההורדה.',
torrentTrackerTimeoutInvalid: 'זמן הקצוב ל-Tracker חייב להיות מספר שלם בין 1 ל-604800 שניות',
torrentTrackerIntervalInvalid: 'מרווח ה-Tracker חייב להיות מספר שלם בין 0 ל-604800 שניות',
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
torrentVerifyIntegrityHint: 'מוחל כשהטורנט מתחיל או מנסה שוב. ייתכן שהחלקים ייבדקו מחדש ונתונים פגומים יורדו שוב; אי אפשר לשנות זאת בהעברה פעילה.',
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
torrentPeerSpeedLimit: 'סף מהירות עמיתים',
torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000',
torrentPeerSpeedLimitInvalid: 'סף מהירות העמיתים חייב להיות גדול מאפס',
torrentPeerDiagnostics: 'אבחון עמיתי טורנט',
torrentPeerDiagnosticsRefresh: 'רענון',
torrentPeerDiagnosticsLoading: 'טוען אבחון עמיתים…',
torrentPeerDiagnosticsUnavailable: 'אבחון עמיתים זמין כשהטורנט פעיל.',
torrentPeerDiagnosticsFailed: 'לא ניתן לקרוא את אבחון עמיתי הטורנט.',
torrentPeerDiagnosticsHint: 'מוצגים רק מהירויות ודגלי חיבור; כתובות IP, יציאות, מזהים ושדות ביטים אינם נשמרים.',
torrentPeerCount: '{{total}} עמיתים · {{seeders}} משתפים',
torrentPeerDownload: 'הורדה',
torrentPeerUpload: 'העלאה',
torrentPeerSeeder: 'משתף',
torrentPeerAmChoking: 'Firelink מגביל',
torrentPeerChoking: 'העמית מגביל',
torrentPeerShowing: 'מוצגים {{shown}} מתוך {{total}} עמיתים.',
seconds: 'שניות',
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות; השינוי חל כשהטורנט מתחיל או מנסה שוב.',
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
torrentPrioritizePiece: 'תעדוף חלקי טורנט',
torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית של Aria2: התחלה, סוף או שניהם; לכל אחד אפשר לציין גודל כמו 1M. השינוי חל בהפעלה או בניסיון חוזר.',
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
torrentEncryptionPolicy: 'מדיניות הצפנת Torrent',
torrentEncryptionPolicyHint: 'מוחלת כשה-Torrent מתחיל או מנסה שוב. בחרו מדיניות אחת כדי לשמור על הגדרות handshake והצפנת payload עקביות.',
torrentEncryptionDisabled: 'מושבתת',
torrentEncryptionRequireCrypto: 'דרישת handshake מוסווה',
torrentEncryptionForceEncryption: 'כפיית payload מוצפן (ARC4)',
torrentEncryptionPolicyInvalid: 'בחרו מדיניות הצפנה תקפה ל-Torrent',
torrentRemoveUnselectedFile: 'מחיקת קבצי Torrent שלא נבחרו לאחר השלמה',
torrentRemoveUnselectedFileHint: 'חל רק כאשר נבחרה קבוצת קבצים חלקית. Aria2 מוחק לצמיתות את שאר הקבצים לאחר השלמת ה-Torrent.',
torrentRemoveUnselectedFileConfirm: 'למחוק {{count}} קבצי Torrent שלא נבחרו לאחר השלמה? אי אפשר לבטל פעולה זו.',
torrentRemoveUnselectedFileSelectionRequired: 'בחרו קבוצת קבצים חלקית לפני הפעלת מחיקת הקבצים שלא נבחרו.',
liveTorrentPeerOptionsFailed: 'לא ניתן לעדכן את בקרות עמיתי הטורנט בזמן אמת: {{detail}}',
category: 'קטגוריה', category: 'קטגוריה',
lastTry: 'ניסיון אחרון', lastTry: 'ניסיון אחרון',
dateAdded: 'תאריך הוספה', dateAdded: 'תאריך הוספה',
@@ -446,7 +504,7 @@ const he = {
pauseBeforeReplace: 'השהה את {{file}} לפני החלפתו.', pauseBeforeReplace: 'השהה את {{file}} לפני החלפתו.',
cannotReplace: 'לא ניתן להחליף את {{file}}: הקובץ אינו שייך להורדת Firelink.', cannotReplace: 'לא ניתן להחליף את {{file}}: הקובץ אינו שייך להורדת Firelink.',
downloadLinks: 'קישורי הורדה', downloadLinks: 'קישורי הורדה',
pastePlaceholder: 'הדבק כתובות \u2066HTTP\u2069, \u2066HTTPS\u2069, \u2066FTP\u2069 או \u2066SFTP\u2069 כאן…\n\nעבור הורדות מדיה, הדבק קישורים מ-\u2066YouTube\u2069, \u2066X\u2069, \u2066TikTok\u2069, \u2066Instagram\u2069, \u2066Reddit\u2069 וכו\'.', pastePlaceholder: 'הדבק כאן כתובות \u2066HTTP\u2069, \u2066HTTPS\u2069, \u2066FTP\u2069, \u2066SFTP\u2069 או magnet… אפשר גם לבחור קובצי ‎.torrent‎ למטה.\n\nעבור הורדות מדיה, הדבק קישורים מ-\u2066YouTube\u2069, \u2066X\u2069, \u2066TikTok\u2069, \u2066Instagram\u2069, \u2066Reddit\u2069 וכו\'.',
playlistSummary: 'רשימת השמעה "{{title}}": {{loaded}} מתוך {{total}} פריטים נטענו{{truncated}}{{skipped}}', playlistSummary: 'רשימת השמעה "{{title}}": {{loaded}} מתוך {{total}} פריטים נטענו{{truncated}}{{skipped}}',
safeEntryLimit: ' (הושגה מגבלת הפריטים הבטוחה)', safeEntryLimit: ' (הושגה מגבלת הפריטים הבטוחה)',
selectedSummary: '{{ready}} נבחרו ומוכנים, {{fallback}} לגיבוי, {{mediaRetry}} מדיה לניסיון חוזר, {{blocked}} חסומים', selectedSummary: '{{ready}} נבחרו ומוכנים, {{fallback}} לגיבוי, {{mediaRetry}} מדיה לניסיון חוזר, {{blocked}} חסומים',
@@ -454,6 +512,56 @@ const he = {
selectAll: 'בחירת הכל', selectAll: 'בחירת הכל',
refreshMetadata: 'רענון מטא נתונים', refreshMetadata: 'רענון מטא נתונים',
files: 'קבצים', files: 'קבצים',
torrentFiles: 'קובצי טורנט',
chooseTorrentFiles: 'הוספת קובצי .torrent',
torrentMetadataPending: 'Aria2 יאתר את נתוני המגנט כשההעברה תתחיל.',
torrentSeeding: 'שיתוף טורנט',
seedAfterDownload: 'לשתף לאחר סיום ההורדה',
seedTime: 'זמן שיתוף',
minutes: 'דקות',
seconds: 'שניות',
seedRatio: 'יחס שיתוף',
seedRatioHint: '0 פירושו שיתוף לפי זמן בלבד; אחרת השיתוף ייפסק בהגעה למגבלה הראשונה.',
limitTorrentUpload: 'הגבלת העלאת טורנט',
torrentUploadLimit: 'מגבלת העלאת טורנט',
torrentSeedTimeInvalid: 'זמן שיתוף הטורנט חייב להיות גדול מאפס',
torrentSeedRatioInvalid: 'יחס שיתוף הטורנט חייב להיות אפס או יותר',
torrentUploadLimitInvalid: 'מגבלת העלאת הטורנט חייבת להיות גדולה מאפס',
torrentTrackers: 'עוקבי טורנט נוספים',
torrentTrackersHint: 'נשמרים עם הטורנט ומוחלים בהפעלה או בניסיון החוזר הבא.',
torrentTrackersInvalid: 'רשימת עוקבי הטורנט אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות.',
torrentExcludeTrackers: 'עוקבי טורנט להחרגה',
torrentExcludeTrackersHint: 'נשמרים עם הטורנט ומוחלים בהפעלה או בניסיון החוזר הבא. * מחריג את כל כתובות ההכרזה; הגדרות DHT ו-PEX לא ישתנו.',
torrentExcludeTrackersInvalid: 'רשימת עוקבי הטורנט להחרגה אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות, או ב-*.',
torrentTrackerConnectTimeout: 'זמן קצוב לחיבור ל-Tracker',
torrentTrackerTimeout: 'זמן קצוב לבקשת Tracker',
torrentTrackerInterval: 'מרווח בין בקשות Tracker',
torrentTrackerTimingHint: 'נשמר עם ה-Torrent ומוחל בהפעלה או בניסיון חוזר. זמן קצוב לחיבור חל על יצירת החיבור, וזמן קצוב לבקשה חל על התגובה שלאחר מכן; ערכים ריקים שומרים על ברירת המחדל של Aria2, 60 שניות, ומרווח 0 עוקב אחר תגובת ה-Tracker והתקדמות ההורדה.',
torrentTrackerTimeoutInvalid: 'זמן הקצוב ל-Tracker חייב להיות מספר שלם בין 1 ל-604800 שניות',
torrentTrackerIntervalInvalid: 'מרווח ה-Tracker חייב להיות מספר שלם בין 0 ל-604800 שניות',
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
torrentVerifyIntegrityHint: 'בדיקת גיבובי החלקים בעת התחלה או ניסיון חוזר; חלקים פגומים עשויים להיות מורדים מחדש.',
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
torrentPeerSpeedLimit: 'סף מהירות עמיתים',
torrentPeerOptionsHint: 'השאר ריק כדי להשתמש בברירות המחדל של Aria2 (55 עמיתים ו-50K). אפס עמיתים פירושו ללא הגבלה.',
torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000',
torrentPeerSpeedLimitInvalid: 'סף מהירות העמיתים חייב להיות גדול מאפס',
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות.',
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
torrentPrioritizePiece: 'תעדוף חלקי טורנט',
torrentPrioritizePieceHint: 'נשמר עם הטורנט ומוחל בהפעלה או בניסיון חוזר. יש להזין התחלה, סוף או שניהם עם גודל K או M אופציונלי.',
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
torrentEncryptionPolicy: 'מדיניות הצפנת Torrent',
torrentEncryptionPolicyHint: 'נשמרת עם ה-Torrent ומוחלת בהפעלה או בניסיון חוזר. המדיניות שומרת על הגדרות ההצפנה של Aria2 עקביות.',
torrentEncryptionDisabled: 'מושבתת',
torrentEncryptionRequireCrypto: 'דרישת handshake מוסווה',
torrentEncryptionForceEncryption: 'כפיית payload מוצפן (ARC4)',
torrentEncryptionPolicyInvalid: 'בחרו מדיניות הצפנה תקפה ל-Torrent',
torrentRemoveUnselectedFile: 'מחיקת קבצי Torrent שלא נבחרו לאחר השלמה',
torrentRemoveUnselectedFileHint: 'חל רק כאשר מוגדרת קבוצת קבצים חלקית. הקבצים שלא נבחרו אינם בבעלות Firelink ונמחקים לצמיתות כשה-Torrent מסתיים.',
torrentRemoveUnselectedFileConfirm: 'להפעיל מחיקה לצמיתות של קבצי Torrent שלא נבחרו לאחר השלמה? אי אפשר לבטל פעולה זו.',
torrentRemoveUnselectedFileSelectionRequired: 'בחרו קבוצת קבצים חלקית לפני הפעלת מחיקת הקבצים שלא נבחרו.',
required: 'נדרש', required: 'נדרש',
free: 'פנוי', free: 'פנוי',
preview: 'תצוגה מקדימה', preview: 'תצוגה מקדימה',
@@ -681,6 +789,20 @@ const he = {
detectedSystemProxy: 'זוהה פרוקסי מערכת. הורדות קבצים רגילות דורשות נקודת קצה של HTTP או HTTPS; הורדות מדיה יכולות להשתמש ב-SOCKS.', detectedSystemProxy: 'זוהה פרוקסי מערכת. הורדות קבצים רגילות דורשות נקודת קצה של HTTP או HTTPS; הורדות מדיה יכולות להשתמש ב-SOCKS.',
noSystemProxy: 'לא זוהה פרוקסי מערכת שימושי. ההורדות לא ישתמשו בפרוקסי.', noSystemProxy: 'לא זוהה פרוקסי מערכת שימושי. ההורדות לא ישתמשו בפרוקסי.',
systemProxyReadFailed: 'לא ניתן לקרוא את תצורת פרוקסי המערכת. בחר "ללא פרוקסי" או נסה שוב כשהיא תהיה זמינה.', systemProxyReadFailed: 'לא ניתן לקרוא את תצורת פרוקסי המערכת. בחר "ללא פרוקסי" או נסה שוב כשהיא תהיה זמינה.',
torrentPeerDiscovery: 'גילוי עמיתים ב-BitTorrent',
torrentDht: 'DHT של IPv4 ומעקבי UDP',
torrentDhtDescription: 'מאתר עמיתים בלי להסתמך רק על מעקבים. השבתה מכבה גם תמיכה במעקבי UDP.',
torrentDht6: 'DHT של IPv6',
torrentDht6Description: 'משתמש ב-IPv6 לגילוי מבוזר של עמיתים כשיש נתיב IPv6 זמין.',
torrentPex: 'החלפת עמיתים (PEX)',
torrentPexDescription: 'מאפשר לעמיתים מחוברים לשתף כתובות של עמיתים נוספים.',
torrentLpd: 'גילוי עמיתים מקומיים (LPD)',
torrentLpdDescription: 'מאתר עמיתים תואמים ברשת המקומית ומגדיל את החשיפה המקומית של התעבורה.',
torrentPeerDiscoveryRestartNote: 'האפשרויות האלה הן כלליות ל-Aria2 ונכנסות לתוקף לאחר הפעלה מחדש של Firelink. Aria2 עדיין משבית גילוי עמיתים בטורנטים פרטיים.',
torrentResourceLimits: 'מגבלות משאבי BitTorrent',
torrentMaxOpenFiles: 'מספר קובצי Torrent פתוחים מרבי',
torrentMaxOpenFilesDescription: 'מגבלה כללית של Aria2 על מספר הקבצים הפתוחים בו-זמנית בטורנטים מרובי קבצים. ערך נמוך יותר מפחית שימוש ב-file descriptors; ברירת המחדל היא 100. השינויים חלים על טורנטים חדשים ללא הפעלה מחדש של Aria2, ואינם מגדילים את מגבלת מערכת ההפעלה.',
torrentMaxOpenFilesUpdateFailed: 'לא ניתן להחיל את מגבלת הקבצים הפתוחים של Torrent: {{detail}}',
identity: 'זהות', identity: 'זהות',
customUserAgent: 'User-Agent מותאם אישית', customUserAgent: 'User-Agent מותאם אישית',
userAgentDescription: 'מוחל על משיכות מטא נתונים ומנועי הורדה.', userAgentDescription: 'מוחל על משיכות מטא נתונים ומנועי הורדה.',
@@ -814,6 +936,7 @@ const he = {
active: '{{count}} פעילים', active: '{{count}} פעילים',
queued: '{{count}} בתור', queued: '{{count}} בתור',
done: '{{count}} הושלמו', done: '{{count}} הושלמו',
seeding: 'משתף',
}, },
} as const; } as const;
+124 -1
View File
@@ -89,6 +89,7 @@ const ru = {
queued: 'В очереди', queued: 'В очереди',
downloading: 'Загрузка', downloading: 'Загрузка',
processing: 'Обработка', processing: 'Обработка',
seeding: 'Раздача',
paused: 'Приостановлено', paused: 'Приостановлено',
completed: 'Завершено', completed: 'Завершено',
failed: 'Ошибка', failed: 'Ошибка',
@@ -227,6 +228,63 @@ const ru = {
liveSpeedLimitClear: 'Очистить', liveSpeedLimitClear: 'Очистить',
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}', liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.', liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
liveTorrentUploadLimit: 'Текущий лимит отдачи торрента',
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
liveTorrentUploadLimitFailed: 'Не удалось обновить текущий лимит отдачи торрента: {{detail}}',
liveTorrentPeerOptions: 'Текущие настройки пиров торрента',
liveTorrentPeerOptionsApply: 'Применить настройки пиров',
liveTorrentPeerOptionsHint: 'Применяется без замены активного торрента. Оставьте пустым для параметров Aria2 по умолчанию.',
torrentPeerOptionsSavedHint: 'Сохраняется для этого торрента. 0 пиров означает без ограничений; пустое поле использует настройки Aria2 по умолчанию.',
torrentTrackers: 'Дополнительные трекеры торрента',
torrentTrackersHint: 'По одному HTTP-, HTTPS- или UDP-трекеру в строке. Данные для входа не допускаются.',
torrentTrackersInvalid: 'Список трекеров торрента недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа.',
torrentExcludeTrackers: 'Исключаемые трекеры торрента',
torrentExcludeTrackersHint: 'По одному HTTP-, HTTPS- или UDP-трекеру в строке или * для исключения всех announce-адресов. Данные для входа не допускаются; настройки DHT и PEX не изменяются.',
torrentExcludeTrackersInvalid: 'Список исключаемых трекеров недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа либо *.',
torrentTrackerConnectTimeout: 'Тайм-аут подключения к трекеру',
torrentTrackerTimeout: 'Тайм-аут запроса к трекеру',
torrentTrackerInterval: 'Интервал запросов к трекеру',
torrentTrackerTimingHint: 'Тайм-аут подключения действует при установлении соединения с трекером, а тайм-аут запроса — после этого для ответа. Пустые значения сохраняют стандартные 60 секунд Aria2; интервал 0 следует ответу трекера и прогрессу загрузки.',
torrentTrackerTimeoutInvalid: 'Тайм-аут трекера должен быть целым числом от 1 до 604800 секунд',
torrentTrackerIntervalInvalid: 'Интервал трекера должен быть целым числом от 0 до 604800 секунд',
torrentVerifyIntegrity: 'Проверять целостность торрента',
torrentVerifyIntegrityHint: 'Применяется при запуске или повторной попытке. Может повторно проверить части и скачать повреждённые данные; во время активной передачи изменить нельзя.',
torrentMaxPeers: 'Максимум пиров торрента',
torrentPeerSpeedLimit: 'Порог скорости пиров',
torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000',
torrentPeerSpeedLimitInvalid: 'Порог скорости пиров должен быть больше нуля',
torrentPeerDiagnostics: 'Диагностика пиров торрента',
torrentPeerDiagnosticsRefresh: 'Обновить',
torrentPeerDiagnosticsLoading: 'Загрузка диагностики пиров…',
torrentPeerDiagnosticsUnavailable: 'Диагностика пиров доступна, пока торрент активен.',
torrentPeerDiagnosticsFailed: 'Не удалось получить диагностику пиров торрента.',
torrentPeerDiagnosticsHint: 'Показываются только скорости и флаги соединения; IP-адреса, порты, идентификаторы и битовые поля не сохраняются.',
torrentPeerCount: '{{total}} пиров · {{seeders}} сидеров',
torrentPeerDownload: 'Загрузка',
torrentPeerUpload: 'Отдача',
torrentPeerSeeder: 'Сидер',
torrentPeerAmChoking: 'Firelink ограничивает',
torrentPeerChoking: 'Пир ограничивает',
torrentPeerShowing: 'Показано {{shown}} из {{total}} пиров.',
seconds: 'секунд',
torrentStopTimeout: 'Останавливать неактивный торрент через',
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило; изменения применяются при запуске или повторной попытке.',
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
torrentPrioritizePiece: 'Приоритет частей торрента',
torrentPrioritizePieceHint: 'Необязательная политика предпросмотра Aria2: начало, конец или оба варианта; для каждого можно указать размер, например 1M. Применяется при запуске или повторной попытке.',
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
torrentEncryptionPolicy: 'Политика шифрования Torrent',
torrentEncryptionPolicyHint: 'Применяется при запуске или повторной попытке Torrent. Выберите одну политику, чтобы параметры handshake и шифрования payload оставались согласованными.',
torrentEncryptionDisabled: 'Отключено',
torrentEncryptionRequireCrypto: 'Требовать зашифрованное рукопожатие',
torrentEncryptionForceEncryption: 'Принудительно шифровать payload (ARC4)',
torrentEncryptionPolicyInvalid: 'Выберите допустимую политику шифрования Torrent',
torrentRemoveUnselectedFile: 'Удалять невыбранные файлы Torrent после завершения',
torrentRemoveUnselectedFileHint: 'Применяется только при выборе части файлов. Aria2 навсегда удалит остальные файлы после завершения Torrent.',
torrentRemoveUnselectedFileConfirm: 'Удалить {{count}} невыбранных файлов Torrent после завершения? Это действие нельзя отменить.',
torrentRemoveUnselectedFileSelectionRequired: 'Выберите часть файлов Torrent перед включением удаления невыбранных файлов.',
liveTorrentPeerOptionsFailed: 'Не удалось обновить текущие настройки пиров торрента: {{detail}}',
category: 'Категория', category: 'Категория',
lastTry: 'Последняя попытка', lastTry: 'Последняя попытка',
dateAdded: 'Дата добавления', dateAdded: 'Дата добавления',
@@ -446,7 +504,7 @@ const ru = {
pauseBeforeReplace: 'Приостановите {{file}} перед заменой.', pauseBeforeReplace: 'Приостановите {{file}} перед заменой.',
cannotReplace: 'Невозможно заменить {{file}}: файл не принадлежит загрузке Firelink.', cannotReplace: 'Невозможно заменить {{file}}: файл не принадлежит загрузке Firelink.',
downloadLinks: 'Ссылки для скачивания', downloadLinks: 'Ссылки для скачивания',
pastePlaceholder: 'Вставьте сюда URL-адреса HTTP, HTTPS, FTP или SFTP…\n\nДля загрузки медиа вставляйте ссылки с YouTube, X, TikTok, Instagram, Reddit и т. д.', pastePlaceholder: 'Вставьте сюда URL-адреса HTTP, HTTPS, FTP, SFTP или magnet… Также можно выбрать файлы .torrent ниже.\n\nДля загрузки медиа вставляйте ссылки с YouTube, X, TikTok, Instagram, Reddit и т. д.',
playlistSummary: 'Плейлист «{{title}}»: загружено {{loaded}} из {{total}} элементов{{truncated}}{{skipped}}', playlistSummary: 'Плейлист «{{title}}»: загружено {{loaded}} из {{total}} элементов{{truncated}}{{skipped}}',
safeEntryLimit: ' (достигнут безопасный лимит элементов)', safeEntryLimit: ' (достигнут безопасный лимит элементов)',
selectedSummary: 'Выбрано: {{ready}} готовых, {{fallback}} с резервными данными, {{mediaRetry}} повторов медиа, {{blocked}} заблокировано', selectedSummary: 'Выбрано: {{ready}} готовых, {{fallback}} с резервными данными, {{mediaRetry}} повторов медиа, {{blocked}} заблокировано',
@@ -454,6 +512,56 @@ const ru = {
selectAll: 'Выбрать все', selectAll: 'Выбрать все',
refreshMetadata: 'Обновить метаданные', refreshMetadata: 'Обновить метаданные',
files: 'Файлы', files: 'Файлы',
torrentFiles: 'Торрент-файлы',
chooseTorrentFiles: 'Добавить файлы .torrent',
torrentMetadataPending: 'Aria2 получит метаданные магнита при запуске передачи.',
torrentSeeding: 'Раздача торрента',
seedAfterDownload: 'Раздавать после завершения загрузки',
seedTime: 'Время раздачи',
minutes: 'минут',
seconds: 'секунд',
seedRatio: 'Коэффициент раздачи',
seedRatioHint: '0 означает раздачу только по времени; иначе раздача остановится при достижении первого ограничения.',
limitTorrentUpload: 'Ограничить отдачу торрента',
torrentUploadLimit: 'Лимит отдачи торрента',
torrentSeedTimeInvalid: 'Время раздачи торрента должно быть больше нуля',
torrentSeedRatioInvalid: 'Коэффициент раздачи торрента не может быть отрицательным',
torrentUploadLimitInvalid: 'Лимит отдачи торрента должен быть больше нуля',
torrentTrackers: 'Дополнительные трекеры торрента',
torrentTrackersHint: 'Сохраняется вместе с торрентом и применяется при следующем запуске или повторной попытке.',
torrentTrackersInvalid: 'Список трекеров торрента недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа.',
torrentExcludeTrackers: 'Исключаемые трекеры торрента',
torrentExcludeTrackersHint: 'Сохраняется вместе с торрентом и применяется при следующем запуске или повторной попытке. * исключает все announce-адреса; настройки DHT и PEX не изменяются.',
torrentExcludeTrackersInvalid: 'Список исключаемых трекеров недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа либо *.',
torrentTrackerConnectTimeout: 'Тайм-аут подключения к трекеру',
torrentTrackerTimeout: 'Тайм-аут запроса к трекеру',
torrentTrackerInterval: 'Интервал запросов к трекеру',
torrentTrackerTimingHint: 'Сохраняется вместе с Torrent и применяется при следующем запуске или повторной попытке. Тайм-аут подключения действует при установлении соединения, а тайм-аут запроса — для ответа после этого; пустые значения сохраняют стандартные 60 секунд Aria2, а интервал 0 следует ответу трекера и прогрессу загрузки.',
torrentTrackerTimeoutInvalid: 'Тайм-аут трекера должен быть целым числом от 1 до 604800 секунд',
torrentTrackerIntervalInvalid: 'Интервал трекера должен быть целым числом от 0 до 604800 секунд',
torrentVerifyIntegrity: 'Проверять целостность торрента',
torrentVerifyIntegrityHint: 'Проверка хешей частей при запуске или повторной попытке; повреждённые части могут быть загружены заново.',
torrentMaxPeers: 'Максимум пиров торрента',
torrentPeerSpeedLimit: 'Порог скорости пиров',
torrentPeerOptionsHint: 'Оставьте пустым для параметров Aria2 по умолчанию (55 пиров и 50K). 0 пиров означает без ограничений.',
torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000',
torrentPeerSpeedLimitInvalid: 'Порог скорости пиров должен быть больше нуля',
torrentStopTimeout: 'Останавливать неактивный торрент через',
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило.',
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
torrentPrioritizePiece: 'Приоритет частей торрента',
torrentPrioritizePieceHint: 'Сохраняется с торрентом и применяется при следующем запуске или повторной попытке. Укажите начало, конец или оба варианта с размером K или M.',
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
torrentEncryptionPolicy: 'Политика шифрования Torrent',
torrentEncryptionPolicyHint: 'Сохраняется вместе с Torrent и применяется при следующем запуске или повторной попытке. Выбранная политика согласует параметры шифрования Aria2.',
torrentEncryptionDisabled: 'Отключено',
torrentEncryptionRequireCrypto: 'Требовать зашифрованное рукопожатие',
torrentEncryptionForceEncryption: 'Принудительно шифровать payload (ARC4)',
torrentEncryptionPolicyInvalid: 'Выберите допустимую политику шифрования Torrent',
torrentRemoveUnselectedFile: 'Удалять невыбранные файлы Torrent после завершения',
torrentRemoveUnselectedFileHint: 'Применяется при настроенном выборе части файлов. Невыбранные файлы не принадлежат Firelink и навсегда удаляются после завершения Torrent.',
torrentRemoveUnselectedFileConfirm: 'Включить безвозвратное удаление невыбранных файлов Torrent после завершения? Это действие нельзя отменить.',
torrentRemoveUnselectedFileSelectionRequired: 'Выберите часть файлов Torrent перед включением удаления невыбранных файлов.',
required: 'Требуется', required: 'Требуется',
free: 'Свободно', free: 'Свободно',
preview: 'Предпросмотр', preview: 'Предпросмотр',
@@ -681,6 +789,20 @@ const ru = {
detectedSystemProxy: 'Обнаружен системный прокси. Для обычных загрузок файлов требуется HTTP- или HTTPS-прокси; для загрузки медиа можно использовать SOCKS.', detectedSystemProxy: 'Обнаружен системный прокси. Для обычных загрузок файлов требуется HTTP- или HTTPS-прокси; для загрузки медиа можно использовать SOCKS.',
noSystemProxy: 'Подходящий системный прокси не обнаружен. Загрузки будут выполняться без прокси.', noSystemProxy: 'Подходящий системный прокси не обнаружен. Загрузки будут выполняться без прокси.',
systemProxyReadFailed: 'Не удалось прочитать конфигурацию системного прокси. Выберите «Без прокси» или повторите попытку, когда она станет доступна.', systemProxyReadFailed: 'Не удалось прочитать конфигурацию системного прокси. Выберите «Без прокси» или повторите попытку, когда она станет доступна.',
torrentPeerDiscovery: 'Обнаружение пиров BitTorrent',
torrentDht: 'IPv4 DHT и UDP-трекеры',
torrentDhtDescription: 'Ищет пиры не только через трекеры. Отключение также отключает поддержку UDP-трекеров.',
torrentDht6: 'DHT по IPv6',
torrentDht6Description: 'Использует IPv6 для распределённого поиска пиров, если доступен рабочий IPv6-маршрут.',
torrentPex: 'Обмен пирами (PEX)',
torrentPexDescription: 'Позволяет подключённым пирам передавать адреса дополнительных пиров.',
torrentLpd: 'Локальное обнаружение пиров (LPD)',
torrentLpdDescription: 'Ищет подходящие пиры в локальной сети и увеличивает видимость трафика в ней.',
torrentPeerDiscoveryRestartNote: 'Эти параметры являются глобальными для Aria2 и применяются после перезапуска Firelink. Aria2 по-прежнему отключает обнаружение пиров для приватных торрентов.',
torrentResourceLimits: 'Ограничения ресурсов BitTorrent',
torrentMaxOpenFiles: 'Максимум открытых файлов Torrent',
torrentMaxOpenFilesDescription: 'Глобальный лимит Aria2 на одновременно открытые файлы в многофайловых торрентах. Меньшие значения снижают расход дескрипторов; по умолчанию 100. Изменения применяются к новым торрентам без перезапуска Aria2 и не повышают лимит операционной системы.',
torrentMaxOpenFilesUpdateFailed: 'Не удалось применить лимит открытых файлов Torrent: {{detail}}',
identity: 'Идентификация', identity: 'Идентификация',
customUserAgent: 'Собственный User-Agent', customUserAgent: 'Собственный User-Agent',
userAgentDescription: 'Применяется при получении метаданных и работе движков загрузки.', userAgentDescription: 'Применяется при получении метаданных и работе движков загрузки.',
@@ -814,6 +936,7 @@ const ru = {
active: '{{count}} активных', active: '{{count}} активных',
queued: '{{count}} в очереди', queued: '{{count}} в очереди',
done: '{{count}} завершено', done: '{{count}} завершено',
seeding: 'Раздача',
}, },
} as const; } as const;
+124 -1
View File
@@ -89,6 +89,7 @@ const uk = {
queued: 'У черзі', queued: 'У черзі',
downloading: 'Завантаження', downloading: 'Завантаження',
processing: 'Обробка', processing: 'Обробка',
seeding: 'Роздача',
paused: 'Призупинено', paused: 'Призупинено',
completed: 'Завершено', completed: 'Завершено',
failed: 'Помилка', failed: 'Помилка',
@@ -227,6 +228,63 @@ const uk = {
liveSpeedLimitClear: 'Очистити', liveSpeedLimitClear: 'Очистити',
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}', liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.', liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента',
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
liveTorrentUploadLimitFailed: 'Не вдалося оновити поточний ліміт віддачі торрента: {{detail}}',
liveTorrentPeerOptions: 'Поточні налаштування пірів торрента',
liveTorrentPeerOptionsApply: 'Застосувати налаштування пірів',
liveTorrentPeerOptionsHint: 'Застосовується без заміни активного торрента. Залиште порожнім для стандартних параметрів Aria2.',
torrentPeerOptionsSavedHint: 'Зберігається для цього торрента. 0 пірів означає без обмежень; порожнє поле використовує стандартні параметри Aria2.',
torrentTrackers: 'Додаткові трекери торрента',
torrentTrackersHint: 'Один HTTP-, HTTPS- або UDP-трекер у рядку. Дані для входу не дозволені.',
torrentTrackersInvalid: 'Список трекерів торрента недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу.',
torrentExcludeTrackers: 'Виключені трекери торрента',
torrentExcludeTrackersHint: 'Один HTTP-, HTTPS- або UDP-трекер у рядку або * для виключення всіх announce-адрес. Дані для входу не дозволені; налаштування DHT і PEX не змінюються.',
torrentExcludeTrackersInvalid: 'Список виключених трекерів недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу або *.',
torrentTrackerConnectTimeout: 'Час очікування підключення до трекера',
torrentTrackerTimeout: 'Час очікування запиту до трекера',
torrentTrackerInterval: 'Інтервал запитів до трекера',
torrentTrackerTimingHint: 'Час очікування підключення діє під час встановлення з’єднання з трекером, а час очікування запиту — для відповіді після цього. Порожні значення зберігають стандартні 60 секунд Aria2; інтервал 0 використовує відповідь трекера та прогрес завантаження.',
torrentTrackerTimeoutInvalid: 'Час очікування трекера має бути цілим числом від 1 до 604800 секунд',
torrentTrackerIntervalInvalid: 'Інтервал трекера має бути цілим числом від 0 до 604800 секунд',
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
torrentVerifyIntegrityHint: 'Застосовується під час запуску або повторної спроби. Частини можуть перевірятися повторно, а пошкоджені дані — завантажуватися знову; під час активної передачі змінити не можна.',
torrentMaxPeers: 'Максимум пірів торрента',
torrentPeerSpeedLimit: 'Поріг швидкості пірів',
torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000',
torrentPeerSpeedLimitInvalid: 'Поріг швидкості пірів має бути більшим за нуль',
torrentPeerDiagnostics: 'Діагностика пірів торрента',
torrentPeerDiagnosticsRefresh: 'Оновити',
torrentPeerDiagnosticsLoading: 'Завантаження діагностики пірів…',
torrentPeerDiagnosticsUnavailable: 'Діагностика пірів доступна, поки торрент активний.',
torrentPeerDiagnosticsFailed: 'Не вдалося отримати діагностику пірів торрента.',
torrentPeerDiagnosticsHint: 'Показуються лише швидкості та прапорці з’єднання; IP-адреси, порти, ідентифікатори й бітові поля не зберігаються.',
torrentPeerCount: '{{total}} пірів · {{seeders}} сідів',
torrentPeerDownload: 'Завантаження',
torrentPeerUpload: 'Віддача',
torrentPeerSeeder: 'Сідер',
torrentPeerAmChoking: 'Firelink обмежує',
torrentPeerChoking: 'Пір обмежує',
torrentPeerShowing: 'Показано {{shown}} із {{total}} пірів.',
seconds: 'секунд',
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило; зміни застосовуються під час запуску або повторної спроби.',
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
torrentPrioritizePiece: 'Пріоритет частин торрента',
torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду Aria2: початок, кінець або обидва варіанти; для кожного можна вказати розмір, наприклад 1M. Застосовується під час запуску або повторної спроби.',
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
torrentEncryptionPolicy: 'Політика шифрування Torrent',
torrentEncryptionPolicyHint: 'Застосовується під час запуску або повторної спроби Torrent. Виберіть одну політику, щоб параметри handshake і шифрування payload залишалися узгодженими.',
torrentEncryptionDisabled: 'Вимкнено',
torrentEncryptionRequireCrypto: 'Вимагати зашифроване рукостискання',
torrentEncryptionForceEncryption: 'Примусово шифрувати payload (ARC4)',
torrentEncryptionPolicyInvalid: 'Виберіть допустиму політику шифрування Torrent',
torrentRemoveUnselectedFile: 'Видаляти невибрані файли Torrent після завершення',
torrentRemoveUnselectedFileHint: 'Застосовується лише після вибору частини файлів. Aria2 назавжди видалить решту файлів після завершення Torrent.',
torrentRemoveUnselectedFileConfirm: 'Видалити {{count}} невибраних файлів Torrent після завершення? Цю дію не можна скасувати.',
torrentRemoveUnselectedFileSelectionRequired: 'Виберіть частину файлів Torrent перед увімкненням видалення невибраних файлів.',
liveTorrentPeerOptionsFailed: 'Не вдалося оновити поточні налаштування пірів торрента: {{detail}}',
category: 'Категорія', category: 'Категорія',
lastTry: 'Остання спроба', lastTry: 'Остання спроба',
dateAdded: 'Дата додавання', dateAdded: 'Дата додавання',
@@ -446,7 +504,7 @@ const uk = {
pauseBeforeReplace: 'Призупиніть {{file}} перед заміною.', pauseBeforeReplace: 'Призупиніть {{file}} перед заміною.',
cannotReplace: 'Неможливо замінити {{file}}: файл не належить до завантажень Firelink.', cannotReplace: 'Неможливо замінити {{file}}: файл не належить до завантажень Firelink.',
downloadLinks: 'Посилання для завантаження', downloadLinks: 'Посилання для завантаження',
pastePlaceholder: 'Вставте URL-адреси HTTP, HTTPS, FTP або SFTP сюди…\n\nДля медіазавантажень вставте посилання з YouTube, X, TikTok, Instagram, Reddit тощо.', pastePlaceholder: 'Вставте сюди URL-адреси HTTP, HTTPS, FTP, SFTP або magnet… Також можна вибрати файли .torrent нижче.\n\nДля медіазавантажень вставте посилання з YouTube, X, TikTok, Instagram, Reddit тощо.',
playlistSummary: 'Плейлист “{{title}}”: {{loaded}} з {{total}} елементів завантажено{{truncated}}{{skipped}}', playlistSummary: 'Плейлист “{{title}}”: {{loaded}} з {{total}} елементів завантажено{{truncated}}{{skipped}}',
safeEntryLimit: ' (досягнуто безпечного ліміту елементів)', safeEntryLimit: ' (досягнуто безпечного ліміту елементів)',
selectedSummary: '{{ready}} вибрано готових, {{fallback}} резервних, {{mediaRetry}} повторних медіа, {{blocked}} заблоковано', selectedSummary: '{{ready}} вибрано готових, {{fallback}} резервних, {{mediaRetry}} повторних медіа, {{blocked}} заблоковано',
@@ -454,6 +512,56 @@ const uk = {
selectAll: 'Вибрати всі', selectAll: 'Вибрати всі',
refreshMetadata: 'Оновити метадані', refreshMetadata: 'Оновити метадані',
files: 'Файли', files: 'Файли',
torrentFiles: 'Торрент-файли',
chooseTorrentFiles: 'Додати файли .torrent',
torrentMetadataPending: 'Aria2 отримає метадані магнітного посилання після початку передачі.',
torrentSeeding: 'Роздача торрента',
seedAfterDownload: 'Роздавати після завершення завантаження',
seedTime: 'Час роздачі',
minutes: 'хвилин',
seconds: 'секунд',
seedRatio: 'Коефіцієнт роздачі',
seedRatioHint: '0 означає роздачу лише за часом; інакше роздача зупиниться після досягнення першого обмеження.',
limitTorrentUpload: 'Обмежити віддачу торрента',
torrentUploadLimit: 'Ліміт віддачі торрента',
torrentSeedTimeInvalid: 'Час роздачі торрента має бути більшим за нуль',
torrentSeedRatioInvalid: 'Коефіцієнт роздачі торрента не може бути від’ємним',
torrentUploadLimitInvalid: 'Ліміт віддачі торрента має бути більшим за нуль',
torrentTrackers: 'Додаткові трекери торрента',
torrentTrackersHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби.',
torrentTrackersInvalid: 'Список трекерів торрента недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу.',
torrentExcludeTrackers: 'Виключені трекери торрента',
torrentExcludeTrackersHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби. * виключає всі announce-адреси; налаштування DHT і PEX не змінюються.',
torrentExcludeTrackersInvalid: 'Список виключених трекерів недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу або *.',
torrentTrackerConnectTimeout: 'Час очікування підключення до трекера',
torrentTrackerTimeout: 'Час очікування запиту до трекера',
torrentTrackerInterval: 'Інтервал запитів до трекера',
torrentTrackerTimingHint: 'Зберігається разом із Torrent і застосовується під час наступного запуску або повторної спроби. Час очікування підключення діє під час встановлення з’єднання, а час очікування запиту — для відповіді після цього; порожні значення зберігають стандартні 60 секунд Aria2, а інтервал 0 використовує відповідь трекера та прогрес завантаження.',
torrentTrackerTimeoutInvalid: 'Час очікування трекера має бути цілим числом від 1 до 604800 секунд',
torrentTrackerIntervalInvalid: 'Інтервал трекера має бути цілим числом від 0 до 604800 секунд',
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
torrentVerifyIntegrityHint: 'Перевіряє хеші частин під час запуску або повторної спроби; пошкоджені частини можуть завантажуватися знову.',
torrentMaxPeers: 'Максимум пірів торрента',
torrentPeerSpeedLimit: 'Поріг швидкості пірів',
torrentPeerOptionsHint: 'Залиште порожнім для стандартних параметрів Aria2 (55 пірів і 50K). 0 пірів означає без обмежень.',
torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000',
torrentPeerSpeedLimitInvalid: 'Поріг швидкості пірів має бути більшим за нуль',
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило.',
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
torrentPrioritizePiece: 'Пріоритет частин торрента',
torrentPrioritizePieceHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби. Укажіть початок, кінець або обидва варіанти з розміром K чи M.',
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
torrentEncryptionPolicy: 'Політика шифрування Torrent',
torrentEncryptionPolicyHint: 'Зберігається разом із Torrent і застосовується під час наступного запуску або повторної спроби. Вибрана політика узгоджує параметри шифрування Aria2.',
torrentEncryptionDisabled: 'Вимкнено',
torrentEncryptionRequireCrypto: 'Вимагати зашифроване рукостискання',
torrentEncryptionForceEncryption: 'Примусово шифрувати payload (ARC4)',
torrentEncryptionPolicyInvalid: 'Виберіть допустиму політику шифрування Torrent',
torrentRemoveUnselectedFile: 'Видаляти невибрані файли Torrent після завершення',
torrentRemoveUnselectedFileHint: 'Застосовується для налаштованого вибору частини файлів. Невибрані файли не належать Firelink і назавжди видаляються після завершення Torrent.',
torrentRemoveUnselectedFileConfirm: 'Увімкнути незворотне видалення невибраних файлів Torrent після завершення? Цю дію не можна скасувати.',
torrentRemoveUnselectedFileSelectionRequired: 'Виберіть частину файлів Torrent перед увімкненням видалення невибраних файлів.',
required: 'Обов\'язково', required: 'Обов\'язково',
free: 'Вільно', free: 'Вільно',
preview: 'Попередній перегляд', preview: 'Попередній перегляд',
@@ -681,6 +789,20 @@ const uk = {
detectedSystemProxy: 'Виявлено системний проксі. Звичайні завантаження файлів вимагають HTTP або HTTPS проксі; медіазавантаження можуть використовувати SOCKS.', detectedSystemProxy: 'Виявлено системний проксі. Звичайні завантаження файлів вимагають HTTP або HTTPS проксі; медіазавантаження можуть використовувати SOCKS.',
noSystemProxy: 'Не виявлено придатного системного проксі. Завантаження використовуватимуть пряме підключення (без проксі).', noSystemProxy: 'Не виявлено придатного системного проксі. Завантаження використовуватимуть пряме підключення (без проксі).',
systemProxyReadFailed: 'Не вдалося прочитати конфігурацію системного проксі. Виберіть "Без проксі" або спробуйте ще раз, коли він стане доступним.', systemProxyReadFailed: 'Не вдалося прочитати конфігурацію системного проксі. Виберіть "Без проксі" або спробуйте ще раз, коли він стане доступним.',
torrentPeerDiscovery: 'Пошук пірів BitTorrent',
torrentDht: 'IPv4 DHT і UDP-трекери',
torrentDhtDescription: 'Шукає пірів не лише через трекери. Вимкнення також вимикає підтримку UDP-трекерів.',
torrentDht6: 'DHT через IPv6',
torrentDht6Description: 'Використовує IPv6 для розподіленого пошуку пірів, якщо доступний робочий маршрут IPv6.',
torrentPex: 'Обмін пірами (PEX)',
torrentPexDescription: 'Дозволяє підключеним пірам передавати адреси додаткових пірів.',
torrentLpd: 'Локальний пошук пірів (LPD)',
torrentLpdDescription: 'Шукає сумісних пірів у локальній мережі та збільшує видимість трафіку в ній.',
torrentPeerDiscoveryRestartNote: 'Ці параметри є глобальними для Aria2 і застосовуються після перезапуску Firelink. Aria2 і надалі вимикає пошук пірів для приватних торрентів.',
torrentResourceLimits: 'Обмеження ресурсів BitTorrent',
torrentMaxOpenFiles: 'Максимум відкритих файлів Torrent',
torrentMaxOpenFilesDescription: 'Глобальне обмеження Aria2 на одночасно відкриті файли в багатофайлових торрентах. Менші значення зменшують використання дескрипторів; типове значення — 100. Зміни застосовуються до нових торрентів без перезапуску Aria2 і не підвищують обмеження операційної системи.',
torrentMaxOpenFilesUpdateFailed: 'Не вдалося застосувати обмеження відкритих файлів Torrent: {{detail}}',
identity: 'Ідентифікація', identity: 'Ідентифікація',
customUserAgent: 'Власний User-Agent', customUserAgent: 'Власний User-Agent',
userAgentDescription: 'Застосовується до запитів метаданих та рушіїв завантаження.', userAgentDescription: 'Застосовується до запитів метаданих та рушіїв завантаження.',
@@ -814,6 +936,7 @@ const uk = {
active: '{{count}} активних', active: '{{count}} активних',
queued: '{{count}} в черзі', queued: '{{count}} в черзі',
done: '{{count}} завершено', done: '{{count}} завершено',
seeding: 'Роздача',
}, },
} as const; } as const;
+124 -1
View File
@@ -89,6 +89,7 @@ const zhCN = {
queued: '已排队', queued: '已排队',
downloading: '下载中', downloading: '下载中',
processing: '处理中', processing: '处理中',
seeding: '做种中',
paused: '已暂停', paused: '已暂停',
completed: '已完成', completed: '已完成',
failed: '失败', failed: '失败',
@@ -227,6 +228,63 @@ const zhCN = {
liveSpeedLimitClear: '清除', liveSpeedLimitClear: '清除',
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}', liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。', liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
liveTorrentUploadLimit: '实时种子上传限速',
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
liveTorrentUploadLimitPlaceholder: '例如 1024K',
liveTorrentUploadLimitFailed: '无法更新实时种子上传限速:{{detail}}',
liveTorrentPeerOptions: 'Torrent 实时对等节点控制',
liveTorrentPeerOptionsApply: '应用节点控制',
liveTorrentPeerOptionsHint: '无需替换活动 Torrent 即可应用。留空以使用 Aria2 默认值。',
torrentPeerOptionsSavedHint: '按 Torrent 保存。0 个节点表示不限制;留空使用 Aria2 默认值。',
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 地址,或使用 *。',
torrentTrackerConnectTimeout: 'Tracker 连接超时',
torrentTrackerTimeout: 'Tracker 请求超时',
torrentTrackerInterval: 'Tracker 请求间隔',
torrentTrackerTimingHint: '连接超时用于建立 Tracker 连接,请求超时用于连接后的响应。留空会保留 Aria2 的 60 秒默认值;间隔 0 会遵循 Tracker 响应和下载进度。',
torrentTrackerTimeoutInvalid: 'Tracker 超时必须是 1 到 604800 秒之间的整数',
torrentTrackerIntervalInvalid: 'Tracker 间隔必须是 0 到 604800 秒之间的整数',
torrentVerifyIntegrity: '验证 Torrent 完整性',
torrentVerifyIntegrityHint: '在 Torrent 启动或重试时应用。可能会重新检查分片并重新下载损坏的数据;活动传输期间无法更改。',
torrentMaxPeers: 'Torrent 最大对等节点数',
torrentPeerSpeedLimit: '对等节点速度阈值',
torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数',
torrentPeerSpeedLimitInvalid: '对等节点速度阈值必须大于零',
torrentPeerDiagnostics: 'Torrent 对等节点诊断',
torrentPeerDiagnosticsRefresh: '刷新',
torrentPeerDiagnosticsLoading: '正在加载对等节点诊断…',
torrentPeerDiagnosticsUnavailable: 'Torrent 活跃时可查看对等节点诊断。',
torrentPeerDiagnosticsFailed: '无法读取 Torrent 对等节点诊断。',
torrentPeerDiagnosticsHint: '仅显示速度和连接状态;不会保留对等节点 IP、端口、ID 或位域。',
torrentPeerCount: '{{total}} 个节点 · {{seeders}} 个做种节点',
torrentPeerDownload: '下载',
torrentPeerUpload: '上传',
torrentPeerSeeder: '做种',
torrentPeerAmChoking: 'Firelink 限制中',
torrentPeerChoking: '对等节点限制中',
torrentPeerShowing: '显示 {{total}} 个节点中的 {{shown}} 个。',
seconds: '秒',
torrentStopTimeout: '在此时间后停止无速度 Torrent',
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用;更改会在 Torrent 启动或重试时应用。',
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
torrentPrioritizePiece: '优先下载 Torrent 片段',
torrentPrioritizePieceHint: '可选的 Aria2 预览策略:开头、结尾或两者;每项可使用 1M 等大小。Torrent 启动或重试时应用。',
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
torrentEncryptionPolicy: 'Torrent 加密策略',
torrentEncryptionPolicyHint: '在 Torrent 启动或重试时应用。选择单一策略,确保握手和 payload 加密设置保持一致。',
torrentEncryptionDisabled: '已禁用',
torrentEncryptionRequireCrypto: '要求加密握手',
torrentEncryptionForceEncryption: '强制加密 payloadARC4',
torrentEncryptionPolicyInvalid: '请选择有效的 Torrent 加密策略',
torrentRemoveUnselectedFile: '完成后删除未选中的 Torrent 文件',
torrentRemoveUnselectedFileHint: '仅在选择了部分文件时生效。Torrent 完成后,Aria2 会永久删除其余文件。',
torrentRemoveUnselectedFileConfirm: '完成后删除 {{count}} 个未选中的 Torrent 文件?此操作无法撤销。',
torrentRemoveUnselectedFileSelectionRequired: '请先选择部分 Torrent 文件,再启用未选中文件删除功能。',
liveTorrentPeerOptionsFailed: '无法更新 Torrent 实时对等节点控制:{{detail}}',
category: '类别', category: '类别',
lastTry: '上次尝试', lastTry: '上次尝试',
dateAdded: '添加日期', dateAdded: '添加日期',
@@ -446,7 +504,7 @@ const zhCN = {
pauseBeforeReplace: '请在替换 {{file}} 前暂停它。', pauseBeforeReplace: '请在替换 {{file}} 前暂停它。',
cannotReplace: '无法替换 {{file}}:文件不属于 Firelink 下载。', cannotReplace: '无法替换 {{file}}:文件不属于 Firelink 下载。',
downloadLinks: '下载链接', downloadLinks: '下载链接',
pastePlaceholder: '在此粘贴 HTTP、HTTPS、FTPSFTP URL…\n\n对于媒体下载,请粘贴来自 YouTube、X、TikTok、Instagram、Reddit 等的链接。', pastePlaceholder: '在此粘贴 HTTP、HTTPS、FTPSFTP 或 magnet URL…也可以在下方选择 .torrent 文件。\n\n对于媒体下载,请粘贴来自 YouTube、X、TikTok、Instagram、Reddit 等的链接。',
playlistSummary: '播放列表“{{title}}”:已加载 {{loaded}} / {{total}} 个条目{{truncated}}{{skipped}}', playlistSummary: '播放列表“{{title}}”:已加载 {{loaded}} / {{total}} 个条目{{truncated}}{{skipped}}',
safeEntryLimit: ' (达到安全条目限制)', safeEntryLimit: ' (达到安全条目限制)',
selectedSummary: '准备就绪 {{ready}} 个,后备项 {{fallback}} 个,媒体重试 {{mediaRetry}} 个,已屏蔽 {{blocked}} 个', selectedSummary: '准备就绪 {{ready}} 个,后备项 {{fallback}} 个,媒体重试 {{mediaRetry}} 个,已屏蔽 {{blocked}} 个',
@@ -454,6 +512,56 @@ const zhCN = {
selectAll: '全选', selectAll: '全选',
refreshMetadata: '刷新元数据', refreshMetadata: '刷新元数据',
files: '文件', files: '文件',
torrentFiles: '种子文件',
chooseTorrentFiles: '添加 .torrent 文件',
torrentMetadataPending: '传输开始时,Aria2 将解析磁力链接元数据。',
torrentSeeding: 'BT 做种',
seedAfterDownload: '下载完成后继续做种',
seedTime: '做种时间',
minutes: '分钟',
seconds: '秒',
seedRatio: '做种比率',
seedRatioHint: '0 表示仅按时间做种;否则达到第一个限制时停止做种。',
limitTorrentUpload: '限制种子上传',
torrentUploadLimit: '种子上传限速',
torrentSeedTimeInvalid: '做种时间必须大于零',
torrentSeedRatioInvalid: '做种比率不能小于零',
torrentUploadLimitInvalid: '种子上传限速必须大于零',
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 地址,或使用 *。',
torrentTrackerConnectTimeout: 'Tracker 连接超时',
torrentTrackerTimeout: 'Tracker 请求超时',
torrentTrackerInterval: 'Tracker 请求间隔',
torrentTrackerTimingHint: '随 Torrent 保存,并在下次启动或重试时应用。连接超时用于建立连接,请求超时用于之后的响应;留空会保留 Aria2 的 60 秒默认值,间隔 0 会遵循 Tracker 响应和下载进度。',
torrentTrackerTimeoutInvalid: 'Tracker 超时必须是 1 到 604800 秒之间的整数',
torrentTrackerIntervalInvalid: 'Tracker 间隔必须是 0 到 604800 秒之间的整数',
torrentVerifyIntegrity: '验证 Torrent 完整性',
torrentVerifyIntegrityHint: '启动或重试时重新检查分片哈希;损坏的分片可能会再次下载。',
torrentMaxPeers: 'Torrent 最大对等节点数',
torrentPeerSpeedLimit: '对等节点速度阈值',
torrentPeerOptionsHint: '留空以使用 Aria2 默认值(55 个节点和 50K)。0 个节点表示不限制。',
torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数',
torrentPeerSpeedLimitInvalid: '对等节点速度阈值必须大于零',
torrentStopTimeout: '在此时间后停止无速度 Torrent',
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用。',
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
torrentPrioritizePiece: '优先下载 Torrent 片段',
torrentPrioritizePieceHint: '随 Torrent 保存,并在下次启动或重试时应用。可使用开头、结尾或两者,并可选 K 或 M 大小。',
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
torrentEncryptionPolicy: 'Torrent 加密策略',
torrentEncryptionPolicyHint: '随 Torrent 保存,并在下次启动或重试时应用。所选策略会保持 Aria2 加密设置一致。',
torrentEncryptionDisabled: '已禁用',
torrentEncryptionRequireCrypto: '要求加密握手',
torrentEncryptionForceEncryption: '强制加密 payloadARC4',
torrentEncryptionPolicyInvalid: '请选择有效的 Torrent 加密策略',
torrentRemoveUnselectedFile: '完成后删除未选中的 Torrent 文件',
torrentRemoveUnselectedFileHint: '仅适用于配置了部分文件选择的 Torrent。未选中的文件不属于 Firelink,并会在 Torrent 完成后永久删除。',
torrentRemoveUnselectedFileConfirm: '启用完成后永久删除未选中的 Torrent 文件?此操作无法撤销。',
torrentRemoveUnselectedFileSelectionRequired: '请先选择部分 Torrent 文件,再启用未选中文件删除功能。',
required: '必需', required: '必需',
free: '可用空间', free: '可用空间',
preview: '预览', preview: '预览',
@@ -681,6 +789,20 @@ const zhCN = {
detectedSystemProxy: '检测到系统代理。普通文件下载需要 HTTP 或 HTTPS 端点;媒体下载可以使用 SOCKS。', detectedSystemProxy: '检测到系统代理。普通文件下载需要 HTTP 或 HTTPS 端点;媒体下载可以使用 SOCKS。',
noSystemProxy: '未检测到可用的系统代理。下载将不使用代理。', noSystemProxy: '未检测到可用的系统代理。下载将不使用代理。',
systemProxyReadFailed: '无法读取系统代理配置。请选择“无代理”,或者在其可用时重试。', systemProxyReadFailed: '无法读取系统代理配置。请选择“无代理”,或者在其可用时重试。',
torrentPeerDiscovery: 'BitTorrent 对等节点发现',
torrentDht: 'IPv4 DHT 与 UDP Tracker',
torrentDhtDescription: '不只依赖 Tracker 查找节点。关闭后也会禁用 UDP Tracker 支持。',
torrentDht6: 'IPv6 分布式哈希表',
torrentDht6Description: '当网络提供可用的 IPv6 路径时,使用 IPv6 进行分布式节点发现。',
torrentPex: '节点交换(PEX',
torrentPexDescription: '允许已连接的节点共享其他节点的地址。',
torrentLpd: '本地节点发现(LPD',
torrentLpdDescription: '在本地网络中发现兼容节点,这会增加本地网络中的流量可见性。',
torrentPeerDiscoveryRestartNote: '这些选项是 Aria2 的全局设置,需要重启 Firelink 后生效。Aria2 仍会对私有 Torrent 禁用节点发现。',
torrentResourceLimits: 'BitTorrent 资源限制',
torrentMaxOpenFiles: 'Torrent 最大打开文件数',
torrentMaxOpenFilesDescription: 'Aria2 对多文件 Torrent 同时打开文件数的全局限制。较低的值可减少文件描述符占用;默认值为 100。修改会在不重启 Aria2 的情况下应用于新 Torrent,且不会提高操作系统的限制。',
torrentMaxOpenFilesUpdateFailed: '无法应用 Torrent 打开文件数限制:{{detail}}',
identity: '身份', identity: '身份',
customUserAgent: '自定义 User-Agent', customUserAgent: '自定义 User-Agent',
userAgentDescription: '应用于元数据获取和下载引擎。', userAgentDescription: '应用于元数据获取和下载引擎。',
@@ -814,6 +936,7 @@ const zhCN = {
active: '{{count}} 个进行中', active: '{{count}} 个进行中',
queued: '{{count}} 个排队', queued: '{{count}} 个排队',
done: '{{count}} 个完成', done: '{{count}} 个完成',
seeding: '做种',
}, },
} as const; } as const;
+9
View File
@@ -3239,6 +3239,10 @@ html[dir="rtl"] .download-context-menu-chevron {
background: hsl(199 89% 48%); background: hsl(199 89% 48%);
} }
.download-progress-fill.seeding {
background: hsl(262 83% 58%);
}
.download-progress-fill.queued { .download-progress-fill.queued {
background: hsl(var(--status-queued)); background: hsl(var(--status-queued));
} }
@@ -3281,6 +3285,11 @@ html[dir="rtl"] .download-context-menu-chevron {
font-weight: 600; font-weight: 600;
} }
.download-status-seeding {
color: hsl(262 83% 58%);
font-weight: 600;
}
.download-status-queued { .download-status-queued {
color: hsl(var(--status-queued)); color: hsl(var(--status-queued));
font-weight: 600; font-weight: 600;
+22
View File
@@ -18,6 +18,8 @@ import type { EnqueueItem } from './bindings/EnqueueItem';
import type { EnqueueAccepted } from './bindings/EnqueueAccepted'; import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
import type { PlatformInfo } from './bindings/PlatformInfo'; import type { PlatformInfo } from './bindings/PlatformInfo';
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig'; import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
import type { TorrentMetadata } from './bindings/TorrentMetadata';
import type { TorrentPeerDiagnostics } from './bindings/TorrentPeerDiagnostics';
type CommandMap = { type CommandMap = {
fetch_metadata: { fetch_metadata: {
@@ -32,6 +34,18 @@ type CommandMap = {
args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null }; args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null };
result: MediaPlaylistMetadata; result: MediaPlaylistMetadata;
}; };
inspect_torrent: {
args: { source: string; id: string; cache?: boolean; proxy?: string };
result: TorrentMetadata;
};
rekey_torrent_metadata: {
args: { sourceId: string; targetId: string };
result: string;
};
remove_torrent_metadata: {
args: { id: string };
result: void;
};
get_aria2_engine_status: { args: undefined; result: EngineStatusItem }; get_aria2_engine_status: { args: undefined; result: EngineStatusItem };
get_ytdlp_engine_status: { args: undefined; result: EngineStatusItem }; get_ytdlp_engine_status: { args: undefined; result: EngineStatusItem };
get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem }; get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem };
@@ -41,6 +55,7 @@ type CommandMap = {
pause_download: { args: { id: string }; result: void }; pause_download: { args: { id: string }; result: void };
resume_download: { args: { id: string; queueId: string }; result: boolean }; resume_download: { args: { id: string; queueId: string }; result: boolean };
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void }; remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
get_download_primary_path: { args: { id: string }; result: string | null };
detach_download_for_reconfigure: { args: { id: string }; result: void }; detach_download_for_reconfigure: { args: { id: string }; result: void };
begin_dock_badge_session: { args: undefined; result: number }; begin_dock_badge_session: { args: undefined; result: number };
update_dock_badge: { args: { count: number; generation: number; session: number }; result: void }; update_dock_badge: { args: { count: number; generation: number; session: number }; result: void };
@@ -56,6 +71,13 @@ type CommandMap = {
set_concurrent_limit: { args: { limit: number }; result: void }; set_concurrent_limit: { args: { limit: number }; result: void };
set_queue_concurrency_limits: { args: { limits: QueueConcurrencyConfig[] }; result: void }; set_queue_concurrency_limits: { args: { limits: QueueConcurrencyConfig[] }; result: void };
set_download_speed_limit: { args: { id: string; limit: string | null }; 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_torrent_peer_options: {
args: { id: string; max_peers: number | null; peer_speed_limit: string | null };
result: void;
};
get_torrent_peers: { args: { id: string }; result: TorrentPeerDiagnostics };
set_torrent_max_open_files: { args: { max_open_files: number }; result: void };
set_global_speed_limit: { args: { limit: string | null }; result: void }; set_global_speed_limit: { args: { limit: string | null }; result: void };
request_automation_permission: { args: undefined; result: void }; request_automation_permission: { args: undefined; result: void };
check_automation_permission: { args: undefined; result: void }; check_automation_permission: { args: undefined; result: void };
+78
View File
@@ -125,6 +125,84 @@ describe('useDownloadProgressStore', () => {
release(); 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 () => { it('clears progress when events arrive after a download row was removed', async () => {
const handlers: Record<string, (event: any) => void> = {}; const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => { 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, // A sidecar can flush one last progress chunk after a pause, failure,
// completion, or lifecycle reset. Do not let that stale chunk repopulate // completion, or lifecycle reset. Do not let that stale chunk repopulate
// the live progress map or overwrite a later lifecycle's first frame. // 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); useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
return; return;
} }
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload); useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final)); const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
const updates: Partial<DownloadItem> = {}; 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.fraction = payload.fraction;
updates.speed = payload.speed; updates.speed = current.status === 'seeding'
updates.eta = payload.eta; ? payload.upload_speed ?? '-'
: payload.speed;
updates.eta = current.status === 'seeding' ? '-' : payload.eta;
} }
if (shouldUpdateSize && current.size !== payload.size) { if (shouldUpdateSize && current.size !== payload.size) {
updates.size = payload.size!; updates.size = payload.size!;
@@ -119,7 +121,7 @@ const startDownloadListeners = async () => {
return; return;
} }
if (status === 'downloading' || status === 'processing' || if (status === 'downloading' || status === 'processing' ||
status === 'completed' || status === 'failed') { status === 'seeding' || status === 'completed' || status === 'failed') {
clearDownloadControlIntent(payload.id, 'resume'); clearDownloadControlIntent(payload.id, 'resume');
} }
if (status === 'paused') { if (status === 'paused') {
@@ -142,6 +144,13 @@ const startDownloadListeners = async () => {
status !== 'failed') { status !== 'failed') {
return; return;
} }
if (current.status === 'seeding' &&
status !== 'seeding' &&
status !== 'paused' &&
status !== 'completed' &&
status !== 'failed') {
return;
}
const progress = useDownloadProgressStore.getState().progressMap[payload.id]; const progress = useDownloadProgressStore.getState().progressMap[payload.id];
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) { if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
@@ -179,7 +188,7 @@ const startDownloadListeners = async () => {
} }
mainStore.updateDownload(payload.id, updates); mainStore.updateDownload(payload.id, updates);
if (status === 'completed' || status === 'failed' || status === 'paused') { if (status === 'completed' || status === 'failed' || status === 'paused' || status === 'seeding') {
useDownloadStore.setState(state => ({ useDownloadStore.setState(state => ({
pendingOrder: state.pendingOrder.filter(id => id !== payload.id) pendingOrder: state.pendingOrder.filter(id => id !== payload.id)
})); }));
@@ -189,7 +198,7 @@ const startDownloadListeners = async () => {
: { pendingOrder: [...state.pendingOrder, payload.id] }); : { 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]); mainStore.registerBackendIds([payload.id]);
} else if (status === 'completed' || status === 'failed') { } else if (status === 'completed' || status === 'failed') {
mainStore.unregisterBackendIds([payload.id]); mainStore.unregisterBackendIds([payload.id]);
+241 -2
View File
@@ -396,6 +396,156 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().downloads[0].speedLimit).toBeUndefined(); 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('updates active Torrent peer options and clears them to Aria2 defaults', async () => {
useDownloadStore.setState({
downloads: [{
id: 'live-torrent-peers',
status: 'seeding',
isMedia: false,
isTorrent: true,
torrentMaxPeers: 120,
torrentPeerSpeedLimit: '512K'
}] as any[]
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await useDownloadStore.getState().setTorrentPeerOptions('live-torrent-peers', '240', '2M');
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_torrent_peer_options', {
id: 'live-torrent-peers',
max_peers: 240,
peer_speed_limit: '2M'
});
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
torrentMaxPeers: 240,
torrentPeerSpeedLimit: '2M'
});
await useDownloadStore.getState().setTorrentPeerOptions('live-torrent-peers', null, null);
const peerOptionCalls = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'set_torrent_peer_options');
expect(peerOptionCalls[peerOptionCalls.length - 1]).toEqual(['set_torrent_peer_options', {
id: 'live-torrent-peers',
max_peers: null,
peer_speed_limit: null
}]);
expect(useDownloadStore.getState().downloads[0].torrentMaxPeers).toBeUndefined();
expect(useDownloadStore.getState().downloads[0].torrentPeerSpeedLimit).toBeUndefined();
});
it('rejects invalid or inactive live Torrent peer options', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'ordinary-peers', status: 'downloading', isMedia: false, isTorrent: false },
{ id: 'paused-peers', status: 'paused', isMedia: false, isTorrent: true }
] as any[]
});
await expect(useDownloadStore.getState().setTorrentPeerOptions('ordinary-peers', '100', '2M'))
.rejects.toThrow('only for Torrent');
await expect(useDownloadStore.getState().setTorrentPeerOptions('paused-peers', '100', '2M'))
.rejects.toThrow('active Torrent');
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('set_torrent_peer_options', expect.anything());
useDownloadStore.setState({
downloads: [{ id: 'invalid-peers', status: 'downloading', isMedia: false, isTorrent: true }] as any[]
});
await expect(useDownloadStore.getState().setTorrentPeerOptions('invalid-peers', '1001', '2M'))
.rejects.toThrow('between 0 and 1000');
await expect(useDownloadStore.getState().setTorrentPeerOptions('invalid-peers', '100', 'not-a-rate'))
.rejects.toThrow('valid Torrent peer speed');
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('set_torrent_peer_options', expect.anything());
});
it('keeps prior Torrent peer options when the backend rejects the update', async () => {
useDownloadStore.setState({
downloads: [{
id: 'live-torrent-peers-failure',
status: 'downloading',
isMedia: false,
isTorrent: true,
torrentMaxPeers: 120,
torrentPeerSpeedLimit: '512K'
}] as any[]
});
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
if (command === 'set_torrent_peer_options') throw new Error('aria2 unavailable');
return undefined;
});
await expect(useDownloadStore.getState().setTorrentPeerOptions('live-torrent-peers-failure', '240', '2M'))
.rejects.toThrow('aria2 unavailable');
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
torrentMaxPeers: 120,
torrentPeerSpeedLimit: '512K'
});
});
it('rejects live speed changes for media and inactive downloads', async () => { it('rejects live speed changes for media and inactive downloads', async () => {
useDownloadStore.setState({ useDownloadStore.setState({
downloads: [ downloads: [
@@ -545,6 +695,33 @@ describe('useDownloadStore', () => {
.toEqual(['00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001']); .toEqual(['00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001']);
}); });
it('skips malformed persisted download records without blocking startup', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') return [];
if (cmd === 'db_get_all_downloads') {
return [
'{not-json',
JSON.stringify(null),
JSON.stringify([]),
JSON.stringify({
id: 'valid-after-corruption',
url: 'https://example.com/valid.bin',
fileName: 'valid.bin',
status: 'ready',
category: 'Other',
dateAdded: ''
})
];
}
return undefined;
});
await useDownloadStore.getState().initDB();
expect(useDownloadStore.getState().downloads.map(download => download.id))
.toEqual(['valid-after-corruption']);
});
it('moves persisted paused rows behind runnable rows and assigns contiguous positions', async () => { it('moves persisted paused rows behind runnable rows and assigns contiguous positions', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => { vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') { if (cmd === 'db_get_all_queues') {
@@ -665,6 +842,43 @@ describe('useDownloadStore', () => {
})).toBe(true); })).toBe(true);
}); });
it('clears malformed persisted Torrent peer options', () => {
const normalized = normalizePersistedDownloadProgress({
id: 'malformed-torrent-options',
url: 'magnet:?xt=urn:btih:bad',
fileName: 'payload',
status: 'queued',
category: 'Other',
dateAdded: '',
isTorrent: true,
torrentMaxPeers: 'not-a-number' as unknown as number,
torrentPeerSpeedLimit: 0 as unknown as string,
torrentCheckIntegrity: 'yes' as unknown as boolean,
torrentTrackers: 123 as unknown as string,
torrentExcludeTrackers: 123 as unknown as string,
torrentTrackerConnectTimeout: 0,
torrentTrackerTimeout: 604801,
torrentTrackerInterval: -1,
torrentStopTimeout: 604801,
torrentPrioritizePiece: 'head=1G',
torrentRemoveUnselectedFile: 'yes' as unknown as boolean,
torrentEncryptionPolicy: 'arc4'
});
expect(normalized.torrentMaxPeers).toBeUndefined();
expect(normalized.torrentPeerSpeedLimit).toBeUndefined();
expect(normalized.torrentCheckIntegrity).toBeUndefined();
expect(normalized.torrentTrackers).toBeUndefined();
expect(normalized.torrentExcludeTrackers).toBeUndefined();
expect(normalized.torrentTrackerConnectTimeout).toBeUndefined();
expect(normalized.torrentTrackerTimeout).toBeUndefined();
expect(normalized.torrentTrackerInterval).toBeUndefined();
expect(normalized.torrentStopTimeout).toBeUndefined();
expect(normalized.torrentPrioritizePiece).toBeUndefined();
expect(normalized.torrentRemoveUnselectedFile).toBeUndefined();
expect(normalized.torrentEncryptionPolicy).toBeUndefined();
});
it('normalizes proxy settings for download dispatch', async () => { it('normalizes proxy settings for download dispatch', async () => {
expect(normalizeCustomProxy('127.0.0.1', 8080)).toBe('http://127.0.0.1:8080'); expect(normalizeCustomProxy('127.0.0.1', 8080)).toBe('http://127.0.0.1:8080');
expect(normalizeCustomProxy('http://proxy.local:9000', 8080)).toBe('http://proxy.local:9000'); expect(normalizeCustomProxy('http://proxy.local:9000', 8080)).toBe('http://proxy.local:9000');
@@ -1311,7 +1525,19 @@ describe('useDownloadStore', () => {
url: 'https://example.com/start.bin', url: 'https://example.com/start.bin',
fileName: 'start.bin', fileName: 'start.bin',
category: 'Other', category: 'Other',
dateAdded: '' dateAdded: '',
isTorrent: true,
torrentCheckIntegrity: true,
torrentTrackers: 'https://tracker.example/announce',
torrentExcludeTrackers: '*',
torrentTrackerConnectTimeout: 11,
torrentTrackerTimeout: 22,
torrentTrackerInterval: 33,
torrentStopTimeout: 300,
torrentPrioritizePiece: 'head=1M,tail=1M',
torrentEncryptionPolicy: 'force-encryption',
torrentFileIndices: [1],
torrentRemoveUnselectedFile: true
}, { type: 'start-now' }); }, { type: 'start-now' });
const item = useDownloadStore.getState().downloads[0]; const item = useDownloadStore.getState().downloads[0];
@@ -1320,7 +1546,20 @@ describe('useDownloadStore', () => {
expect(ipc.invokeCommand).toHaveBeenCalledWith( expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download', 'enqueue_download',
expect.objectContaining({ expect.objectContaining({
item: expect.objectContaining({ id: 'start-1' }) item: expect.objectContaining({
id: 'start-1',
torrent_check_integrity: true,
torrent_trackers: 'https://tracker.example/announce',
torrent_exclude_trackers: '*',
torrent_tracker_connect_timeout: 11,
torrent_tracker_timeout: 22,
torrent_tracker_interval: 33,
torrent_stop_timeout: 300,
torrent_prioritize_piece: 'head=1M,tail=1M',
torrent_encryption_policy: 'force-encryption',
torrent_file_indices: [1],
torrent_remove_unselected_file: true
})
}) })
); );
}); });
+216 -10
View File
@@ -9,7 +9,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
import type { Queue } from '../bindings/Queue'; import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore'; import { useSettingsStore } from './useSettingsStore';
import { useDownloadProgressStore } from './downloadProgressStore'; import { useDownloadProgressStore } from './downloadProgressStore';
import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import { import {
resolveCategoryDestination resolveCategoryDestination
} from '../utils/downloadLocations'; } from '../utils/downloadLocations';
@@ -341,6 +341,25 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
format_selector: item.mediaFormatSelector || null, format_selector: item.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null, cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: item.isMedia || false, is_media: item.isMedia || false,
is_torrent: item.isTorrent || false,
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,
torrent_max_peers: item.torrentMaxPeers,
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
torrent_check_integrity: item.torrentCheckIntegrity,
torrent_trackers: item.torrentTrackers || undefined,
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
torrent_tracker_connect_timeout: item.torrentTrackerConnectTimeout,
torrent_tracker_timeout: item.torrentTrackerTimeout,
torrent_tracker_interval: item.torrentTrackerInterval,
torrent_stop_timeout: item.torrentStopTimeout,
torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
torrent_remove_unselected_file: item.torrentRemoveUnselectedFile,
torrent_encryption_policy: item.torrentEncryptionPolicy || undefined,
lifecycle_generation: lifecycleGeneration.toString(), lifecycle_generation: lifecycleGeneration.toString(),
}; };
@@ -605,10 +624,85 @@ export const hasStaleTemporaryMediaEstimate = (
return hasImpossibleNumericEstimate || hasImpossibleVisibleEstimate; return hasImpossibleNumericEstimate || hasImpossibleVisibleEstimate;
}; };
export const normalizePersistedDownloadProgress = (download: DownloadItem): DownloadItem => export const normalizePersistedDownloadProgress = (download: DownloadItem): DownloadItem => {
hasStaleTemporaryMediaEstimate(download) const rawMaxPeers = download.torrentMaxPeers as unknown;
const normalizedMaxPeers = typeof rawMaxPeers === 'number' &&
Number.isInteger(rawMaxPeers) &&
rawMaxPeers >= 0 &&
rawMaxPeers <= 1000
? rawMaxPeers
: undefined;
const rawPeerSpeedLimit = download.torrentPeerSpeedLimit as unknown;
const normalizedPeerSpeedLimit = typeof rawPeerSpeedLimit === 'string'
? normalizeSpeedLimitForBackend(rawPeerSpeedLimit) || undefined
: undefined;
const rawCheckIntegrity = download.torrentCheckIntegrity as unknown;
const normalizedCheckIntegrity = typeof rawCheckIntegrity === 'boolean'
? rawCheckIntegrity
: undefined;
const rawTrackers = download.torrentTrackers as unknown;
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 rawTrackerConnectTimeout = download.torrentTrackerConnectTimeout as unknown;
const normalizedTrackerConnectTimeout = normalizeTorrentTrackerTimeout(rawTrackerConnectTimeout);
const rawTrackerTimeout = download.torrentTrackerTimeout as unknown;
const normalizedTrackerTimeout = normalizeTorrentTrackerTimeout(rawTrackerTimeout);
const rawTrackerInterval = download.torrentTrackerInterval as unknown;
const normalizedTrackerInterval = normalizeTorrentTrackerInterval(rawTrackerInterval);
const rawStopTimeout = download.torrentStopTimeout as unknown;
const normalizedStopTimeout = typeof rawStopTimeout === 'number' &&
Number.isInteger(rawStopTimeout) &&
rawStopTimeout >= 0 &&
rawStopTimeout <= MAX_TORRENT_STOP_TIMEOUT
? rawStopTimeout
: undefined;
const rawPrioritizePiece = download.torrentPrioritizePiece as unknown;
const normalizedPrioritizePiece = typeof rawPrioritizePiece === 'string'
? normalizeTorrentPrioritizePiece(rawPrioritizePiece) || undefined
: undefined;
const rawRemoveUnselectedFile = download.torrentRemoveUnselectedFile as unknown;
const normalizedRemoveUnselectedFile = typeof rawRemoveUnselectedFile === 'boolean'
? rawRemoveUnselectedFile
: undefined;
const rawEncryptionPolicy = download.torrentEncryptionPolicy as unknown;
const normalizedEncryptionPolicy = normalizeTorrentEncryptionPolicy(rawEncryptionPolicy);
const normalizedOptions = rawMaxPeers !== normalizedMaxPeers ||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
rawCheckIntegrity !== normalizedCheckIntegrity ||
rawTrackers !== normalizedTrackers ||
rawExcludeTrackers !== normalizedExcludeTrackers ||
rawTrackerConnectTimeout !== normalizedTrackerConnectTimeout ||
rawTrackerTimeout !== normalizedTrackerTimeout ||
rawTrackerInterval !== normalizedTrackerInterval ||
rawStopTimeout !== normalizedStopTimeout ||
rawPrioritizePiece !== normalizedPrioritizePiece ||
rawRemoveUnselectedFile !== normalizedRemoveUnselectedFile ||
rawEncryptionPolicy !== normalizedEncryptionPolicy
? { ? {
...download, ...download,
torrentMaxPeers: normalizedMaxPeers,
torrentPeerSpeedLimit: normalizedPeerSpeedLimit,
torrentCheckIntegrity: normalizedCheckIntegrity,
torrentTrackers: normalizedTrackers,
torrentExcludeTrackers: normalizedExcludeTrackers,
torrentTrackerConnectTimeout: normalizedTrackerConnectTimeout,
torrentTrackerTimeout: normalizedTrackerTimeout,
torrentTrackerInterval: normalizedTrackerInterval,
torrentStopTimeout: normalizedStopTimeout,
torrentPrioritizePiece: normalizedPrioritizePiece,
torrentRemoveUnselectedFile: normalizedRemoveUnselectedFile,
torrentEncryptionPolicy: normalizedEncryptionPolicy
}
: download;
return hasStaleTemporaryMediaEstimate(normalizedOptions)
? {
...normalizedOptions,
// The old lifecycle could persist yt-dlp's temporary HLS estimate as // The old lifecycle could persist yt-dlp's temporary HLS estimate as
// both the numeric denominator and the visible size. Neither value is // both the numeric denominator and the visible size. Neither value is
// recoverable after the fact, so remove the false claim on startup. // recoverable after the fact, so remove the false claim on startup.
@@ -616,7 +710,8 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
totalBytes: undefined, totalBytes: undefined,
totalIsEstimate: undefined totalIsEstimate: undefined
} }
: download; : normalizedOptions;
};
export type { DownloadStatus }; export type { DownloadStatus };
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001'; export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
@@ -793,6 +888,12 @@ interface DownloadState {
pauseAll: () => Promise<number>; pauseAll: () => Promise<number>;
assignToQueue: (ids: string[], queueId: string) => Promise<void>; assignToQueue: (ids: string[], queueId: string) => Promise<void>;
setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>; setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>;
setTorrentUploadLimit: (id: string, limit: string | null) => Promise<void>;
setTorrentPeerOptions: (
id: string,
maxPeers: string | null,
peerSpeedLimit: string | null
) => Promise<void>;
setQueueConcurrency: (id: string, maxConcurrent: number | null) => Promise<void>; setQueueConcurrency: (id: string, maxConcurrent: number | null) => Promise<void>;
addQueue: (name: string) => boolean; addQueue: (name: string) => boolean;
renameQueue: (id: string, name: string) => boolean; renameQueue: (id: string, name: string) => boolean;
@@ -813,7 +914,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
? updates ? updates
: { ...updates, fileName: canonicalizeDownloadFileName(updates.fileName) }; : { ...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)); throw new Error(i18n.t($ => $.downloadTable.transferActive));
} }
@@ -1407,8 +1508,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
if (updates.status && ['completed', 'failed', 'paused'].includes(updates.status)) { if (updates.status && ['completed', 'failed', 'paused'].includes(updates.status)) {
info(`Download ${id} status changed to ${updates.status}`); info(`Download ${id} status changed to ${updates.status}`);
syncSystemIntegrations(); syncSystemIntegrations();
} else if (updates.status === 'downloading') { } else if (updates.status === 'downloading' || updates.status === 'seeding') {
info(`Download ${id} status changed to downloading`); info(`Download ${id} status changed to ${updates.status}`);
syncSystemIntegrations(); syncSystemIntegrations();
} }
}, },
@@ -1879,6 +1980,83 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
true, true,
preemptDispatch 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
),
setTorrentPeerOptions: (id, maxPeers, peerSpeedLimit) => runDownloadLifecycleOperation(
id,
'torrent-peer-options',
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 peer control is available only for Torrent downloads.');
}
if (!['downloading', 'seeding', 'retrying'].includes(item.status)) {
throw new Error('Live peer control requires an active Torrent.');
}
const trimmedMaxPeers = maxPeers?.trim() || '';
const parsedMaxPeers = trimmedMaxPeers ? Number(trimmedMaxPeers) : null;
if (
parsedMaxPeers !== null
&& (!Number.isInteger(parsedMaxPeers) || parsedMaxPeers < 0 || parsedMaxPeers > 1000)
) {
throw new Error('Torrent maximum peers must be an integer between 0 and 1000.');
}
const normalizedPeerSpeedLimit = peerSpeedLimit?.trim()
? normalizeSpeedLimitForBackend(peerSpeedLimit)
: null;
if (peerSpeedLimit?.trim() && normalizedPeerSpeedLimit === null) {
throw new Error('Enter a valid Torrent peer speed limit.');
}
await invoke('set_torrent_peer_options', {
id,
max_peers: parsedMaxPeers,
peer_speed_limit: normalizedPeerSpeedLimit
});
if (get().downloads.some(download => download.id === id)) {
get().updateDownload(id, {
torrentMaxPeers: parsedMaxPeers === null ? undefined : parsedMaxPeers,
torrentPeerSpeedLimit: normalizedPeerSpeedLimit ?? undefined
});
}
},
true,
preemptDispatch
),
setQueueConcurrency: (id, maxConcurrent) => { setQueueConcurrency: (id, maxConcurrent) => {
const operation = queueConfigurationQueue.then(async () => { const operation = queueConfigurationQueue.then(async () => {
if ( if (
@@ -2040,6 +2218,25 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
format_selector: item.mediaFormatSelector || null, format_selector: item.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null, cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: item.isMedia || false, is_media: item.isMedia || false,
is_torrent: item.isTorrent || false,
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,
torrent_max_peers: item.torrentMaxPeers,
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
torrent_check_integrity: item.torrentCheckIntegrity,
torrent_trackers: item.torrentTrackers || undefined,
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
torrent_tracker_connect_timeout: item.torrentTrackerConnectTimeout,
torrent_tracker_timeout: item.torrentTrackerTimeout,
torrent_tracker_interval: item.torrentTrackerInterval,
torrent_stop_timeout: item.torrentStopTimeout,
torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
torrent_remove_unselected_file: item.torrentRemoveUnselectedFile,
torrent_encryption_policy: item.torrentEncryptionPolicy || undefined,
lifecycle_generation: currentDownloadLifecycle(item.id).toString(), lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
}); });
} }
@@ -2164,9 +2361,18 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const normalizedQueueState = normalizePersistedQueueState(persistedQueues); const normalizedQueueState = normalizePersistedQueueState(persistedQueues);
const queues = normalizedQueueState.queues; const queues = normalizedQueueState.queues;
const knownQueueIds = new Set(queues.map(queue => queue.id)); const knownQueueIds = new Set(queues.map(queue => queue.id));
const downloads = (await invoke('db_get_all_downloads')).map( const downloads = (await invoke('db_get_all_downloads')).flatMap(value => {
value => JSON.parse(value) as DownloadItem try {
).map(download => { const parsed: unknown = JSON.parse(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('persisted download is not an object');
}
return [parsed as DownloadItem];
} catch {
console.warn('Skipping malformed persisted download record during startup');
return [];
}
}).map(download => {
const persistedQueueId = download.queueId || MAIN_QUEUE_ID; const persistedQueueId = download.queueId || MAIN_QUEUE_ID;
const queueId = normalizedQueueState.queueIdRemap.get(persistedQueueId) const queueId = normalizedQueueState.queueIdRemap.get(persistedQueueId)
|| (knownQueueIds.has(persistedQueueId) ? persistedQueueId : MAIN_QUEUE_ID); || (knownQueueIds.has(persistedQueueId) ? persistedQueueId : MAIN_QUEUE_ID);
+100
View File
@@ -6,6 +6,10 @@ import {
} from './useSettingsStore'; } from './useSettingsStore';
import * as ipc from '../ipc'; import * as ipc from '../ipc';
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration'; import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
import {
DEFAULT_TORRENT_MAX_OPEN_FILES,
MAX_TORRENT_MAX_OPEN_FILES
} from '../utils/downloads';
vi.mock('../ipc', () => ({ vi.mock('../ipc', () => ({
invokeCommand: vi.fn() invokeCommand: vi.fn()
@@ -21,6 +25,102 @@ describe('last used download directory preference', () => {
}); });
}); });
describe('Torrent peer discovery preferences', () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({
torrentEnableDht: true,
torrentEnableDht6: false,
torrentEnablePex: true,
torrentEnableLpd: false
});
});
it('matches Aria2 defaults and persists explicit changes', async () => {
expect(useSettingsStore.getState().torrentEnableDht).toBe(true);
expect(useSettingsStore.getState().torrentEnableDht6).toBe(false);
expect(useSettingsStore.getState().torrentEnablePex).toBe(true);
expect(useSettingsStore.getState().torrentEnableLpd).toBe(false);
useSettingsStore.getState().setTorrentEnableDht(false);
useSettingsStore.getState().setTorrentEnableDht6(true);
useSettingsStore.getState().setTorrentEnablePex(false);
useSettingsStore.getState().setTorrentEnableLpd(true);
await vi.waitFor(() => {
const save = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'db_save_settings')
.slice(-1)[0];
expect(save).toBeDefined();
expect(JSON.parse((save?.[1] as { data: string }).data).state).toMatchObject({
torrentEnableDht: false,
torrentEnableDht6: true,
torrentEnablePex: false,
torrentEnableLpd: true
});
});
});
});
describe('Torrent open-file limit preference', () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({ torrentMaxOpenFiles: DEFAULT_TORRENT_MAX_OPEN_FILES });
});
it('applies a bounded global limit before persisting it', async () => {
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined);
await useSettingsStore.getState().setTorrentMaxOpenFiles(256);
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_torrent_max_open_files', {
max_open_files: 256
});
expect(useSettingsStore.getState().torrentMaxOpenFiles).toBe(256);
});
it('rejects unsafe values without changing the saved limit', async () => {
await expect(useSettingsStore.getState().setTorrentMaxOpenFiles(0)).rejects.toThrow();
await expect(
useSettingsStore.getState().setTorrentMaxOpenFiles(MAX_TORRENT_MAX_OPEN_FILES + 1)
).rejects.toThrow();
expect(ipc.invokeCommand).not.toHaveBeenCalledWith(
'set_torrent_max_open_files',
expect.anything()
);
expect(useSettingsStore.getState().torrentMaxOpenFiles)
.toBe(DEFAULT_TORRENT_MAX_OPEN_FILES);
});
it('serializes rapid updates so the native global option cannot reorder', async () => {
let releaseFirst!: () => void;
const firstUpdate = new Promise<void>(resolve => {
releaseFirst = resolve;
});
const events: string[] = [];
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string, args?: unknown) => {
if (command !== 'set_torrent_max_open_files') return undefined;
const value = (args as { max_open_files: number }).max_open_files;
events.push(`start:${value}`);
if (value === 256) await firstUpdate;
events.push(`finish:${value}`);
return undefined;
});
const first = useSettingsStore.getState().setTorrentMaxOpenFiles(256);
const second = useSettingsStore.getState().setTorrentMaxOpenFiles(512);
await vi.waitFor(() => expect(events).toEqual(['start:256']));
expect(useSettingsStore.getState().torrentMaxOpenFiles)
.toBe(DEFAULT_TORRENT_MAX_OPEN_FILES);
releaseFirst();
await Promise.all([first, second]);
expect(events).toEqual(['start:256', 'finish:256', 'start:512', 'finish:512']);
expect(useSettingsStore.getState().torrentMaxOpenFiles).toBe(512);
});
});
describe('calendar preference', () => { describe('calendar preference', () => {
it('keeps Gregorian as the default and persists explicit calendar choices', async () => { it('keeps Gregorian as the default and persists explicit calendar choices', async () => {
vi.clearAllMocks(); vi.clearAllMocks();
+54 -1
View File
@@ -20,7 +20,13 @@ import {
DEFAULT_CATEGORY_SUBFOLDERS, DEFAULT_CATEGORY_SUBFOLDERS,
normalizeDownloadLocationSettings normalizeDownloadLocationSettings
} from '../utils/downloadLocations'; } from '../utils/downloadLocations';
import { normalizeSpeedLimitForBackend } from '../utils/downloads'; import {
DEFAULT_TORRENT_MAX_OPEN_FILES,
MAX_TORRENT_MAX_OPEN_FILES,
MIN_TORRENT_MAX_OPEN_FILES,
normalizeSpeedLimitForBackend,
normalizeTorrentMaxOpenFiles
} from '../utils/downloads';
import i18n from '../i18n'; import i18n from '../i18n';
import { isAppLocalePreference, type AppLocalePreference } from '../i18n/locales'; import { isAppLocalePreference, type AppLocalePreference } from '../i18n/locales';
import { import {
@@ -30,6 +36,7 @@ import {
} from '../utils/dateTime'; } from '../utils/dateTime';
let settingsQueue: Promise<void> = Promise.resolve(); let settingsQueue: Promise<void> = Promise.resolve();
let torrentMaxOpenFilesQueue: Promise<void> = Promise.resolve();
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null; let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
const settingsPersistenceErrorListeners = new Set<() => void>(); const settingsPersistenceErrorListeners = new Set<() => void>();
let settingsPersistenceFailed = false; let settingsPersistenceFailed = false;
@@ -234,6 +241,11 @@ export interface SettingsState {
proxyMode: ProxyMode; proxyMode: ProxyMode;
proxyHost: string; proxyHost: string;
proxyPort: number; proxyPort: number;
torrentEnableDht: boolean;
torrentEnableDht6: boolean;
torrentEnablePex: boolean;
torrentEnableLpd: boolean;
torrentMaxOpenFiles: number;
customUserAgent: string; customUserAgent: string;
askWhereToSaveEachFile: boolean; askWhereToSaveEachFile: boolean;
preventsSleepWhileDownloading: boolean; preventsSleepWhileDownloading: boolean;
@@ -284,6 +296,11 @@ export interface SettingsState {
setProxyMode: (mode: ProxyMode) => void; setProxyMode: (mode: ProxyMode) => void;
setProxyHost: (host: string) => void; setProxyHost: (host: string) => void;
setProxyPort: (port: number) => void; setProxyPort: (port: number) => void;
setTorrentEnableDht: (enabled: boolean) => void;
setTorrentEnableDht6: (enabled: boolean) => void;
setTorrentEnablePex: (enabled: boolean) => void;
setTorrentEnableLpd: (enabled: boolean) => void;
setTorrentMaxOpenFiles: (value: number) => Promise<void>;
setCustomUserAgent: (userAgent: string) => void; setCustomUserAgent: (userAgent: string) => void;
setAskWhereToSaveEachFile: (ask: boolean) => void; setAskWhereToSaveEachFile: (ask: boolean) => void;
setPreventsSleepWhileDownloading: (prevent: boolean) => void; setPreventsSleepWhileDownloading: (prevent: boolean) => void;
@@ -360,6 +377,11 @@ export const useSettingsStore = create<SettingsState>()(
proxyMode: 'none', proxyMode: 'none',
proxyHost: '', proxyHost: '',
proxyPort: 8080, proxyPort: 8080,
torrentEnableDht: true,
torrentEnableDht6: false,
torrentEnablePex: true,
torrentEnableLpd: false,
torrentMaxOpenFiles: DEFAULT_TORRENT_MAX_OPEN_FILES,
customUserAgent: '', customUserAgent: '',
askWhereToSaveEachFile: false, askWhereToSaveEachFile: false,
preventsSleepWhileDownloading: true, preventsSleepWhileDownloading: true,
@@ -454,6 +476,26 @@ export const useSettingsStore = create<SettingsState>()(
? Math.min(65535, Math.max(1, Math.trunc(proxyPort))) ? Math.min(65535, Math.max(1, Math.trunc(proxyPort)))
: 8080 : 8080
}), }),
setTorrentEnableDht: (torrentEnableDht) => set({ torrentEnableDht }),
setTorrentEnableDht6: (torrentEnableDht6) => set({ torrentEnableDht6 }),
setTorrentEnablePex: (torrentEnablePex) => set({ torrentEnablePex }),
setTorrentEnableLpd: (torrentEnableLpd) => set({ torrentEnableLpd }),
setTorrentMaxOpenFiles: (value) => {
const normalized = normalizeTorrentMaxOpenFiles(value);
if (normalized === undefined) {
return Promise.reject(new Error(
`Torrent maximum open files must be between ${MIN_TORRENT_MAX_OPEN_FILES} and ${MAX_TORRENT_MAX_OPEN_FILES}`
));
}
const apply = async () => {
await invoke('set_torrent_max_open_files', { max_open_files: normalized });
info('Settings updated: torrentMaxOpenFiles');
set({ torrentMaxOpenFiles: normalized });
};
const result = torrentMaxOpenFilesQueue.then(apply, apply);
torrentMaxOpenFilesQueue = result.then(() => undefined, () => undefined);
return result;
},
setCustomUserAgent: (customUserAgent) => set({ customUserAgent }), setCustomUserAgent: (customUserAgent) => set({ customUserAgent }),
setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }), setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }),
setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => { setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => {
@@ -635,6 +677,11 @@ export const useSettingsStore = create<SettingsState>()(
proxyMode: state.proxyMode, proxyMode: state.proxyMode,
proxyHost: state.proxyHost, proxyHost: state.proxyHost,
proxyPort: state.proxyPort, proxyPort: state.proxyPort,
torrentEnableDht: state.torrentEnableDht,
torrentEnableDht6: state.torrentEnableDht6,
torrentEnablePex: state.torrentEnablePex,
torrentEnableLpd: state.torrentEnableLpd,
torrentMaxOpenFiles: state.torrentMaxOpenFiles,
customUserAgent: state.customUserAgent, customUserAgent: state.customUserAgent,
askWhereToSaveEachFile: state.askWhereToSaveEachFile, askWhereToSaveEachFile: state.askWhereToSaveEachFile,
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading, preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
@@ -680,6 +727,12 @@ export const useSettingsStore = create<SettingsState>()(
listRowDensity: isAllowedSetting(LIST_ROW_DENSITY_VALUES, persisted.listRowDensity) listRowDensity: isAllowedSetting(LIST_ROW_DENSITY_VALUES, persisted.listRowDensity)
? persisted.listRowDensity ? persisted.listRowDensity
: currentState.listRowDensity, : currentState.listRowDensity,
torrentEnableDht: persistedBoolean(persisted.torrentEnableDht, currentState.torrentEnableDht),
torrentEnableDht6: persistedBoolean(persisted.torrentEnableDht6, currentState.torrentEnableDht6),
torrentEnablePex: persistedBoolean(persisted.torrentEnablePex, currentState.torrentEnablePex),
torrentEnableLpd: persistedBoolean(persisted.torrentEnableLpd, currentState.torrentEnableLpd),
torrentMaxOpenFiles: normalizeTorrentMaxOpenFiles(persisted.torrentMaxOpenFiles)
?? currentState.torrentMaxOpenFiles,
sidebarPosition: isAllowedSetting(SIDEBAR_POSITION_VALUES, persisted.sidebarPosition) sidebarPosition: isAllowedSetting(SIDEBAR_POSITION_VALUES, persisted.sidebarPosition)
? persisted.sidebarPosition ? persisted.sidebarPosition
: currentState.sidebarPosition, : currentState.sidebarPosition,
+68
View File
@@ -12,6 +12,7 @@ import {
mediaTypeForFormat, mediaTypeForFormat,
metadataSummaryMessage, metadataSummaryMessage,
isYouTubePlaylistUrl, isYouTubePlaylistUrl,
isRemoteTorrentUrl,
playlistFilePrefix, playlistFilePrefix,
reconcileDownloadRows, reconcileDownloadRows,
refreshFailedMetadataRows, refreshFailedMetadataRows,
@@ -84,6 +85,73 @@ describe('add download metadata workflow', () => {
expect(isYouTubePlaylistUrl('https://example.com/playlist?list=PL123')).toBe(false); expect(isYouTubePlaylistUrl('https://example.com/playlist?list=PL123')).toBe(false);
}); });
it('admits magnets and local torrent files through the Add window metadata path', () => {
const rows = reconcileDownloadRows(
'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example\nfile:///tmp/Example.torrent',
[]
);
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({
isTorrent: true,
isMedia: false,
status: 'loading'
});
expect(rows[1]).toMatchObject({
isTorrent: true,
isMedia: false,
sourceUrl: 'file:///tmp/Example.torrent',
status: 'loading'
});
expect(rows[0].torrentCacheId).toBe(`${rows[0].id}-1`);
expect(rows[1].torrentCacheId).toBe(`${rows[1].id}-1`);
});
it('admits remote .torrent URLs through the Torrent metadata path', () => {
expect(isRemoteTorrentUrl('https://example.com/files/sample.torrent?download=1')).toBe(true);
expect(isRemoteTorrentUrl('https://example.com/files/sample.zip')).toBe(false);
const rows = reconcileDownloadRows('https://example.com/files/sample.torrent?download=1', []);
expect(rows[0]).toMatchObject({
isTorrent: true,
isMedia: false,
status: 'loading'
});
});
it('gives refreshed torrent metadata a new cache identity', () => {
const existing = row({
id: 'torrent-row',
sourceUrl: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
downloadUrl: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
isTorrent: true,
torrentCacheId: 'torrent-row-1',
torrentPath: '/managed/torrent-row-1.torrent',
torrentInfoHash: '0123456789abcdef0123456789abcdef01234567',
generation: 1,
requestContextVersion: 1
});
const refreshed = reconcileDownloadRows(
existing.sourceUrl,
[existing],
undefined,
new Set(),
undefined,
{},
{ [existing.sourceUrl]: 2 }
);
expect(refreshed[0]).toMatchObject({
generation: 2,
torrentCacheId: 'torrent-row-2',
torrentPath: undefined,
torrentInfoHash: undefined,
torrentFiles: undefined
});
});
it('keeps a playlist as one loading row until discovery succeeds', () => { it('keeps a playlist as one loading row until discovery succeeds', () => {
const rows = reconcileDownloadRows( const rows = reconcileDownloadRows(
'https://www.youtube.com/playlist?list=PL123', 'https://www.youtube.com/playlist?list=PL123',
+68 -8
View File
@@ -4,6 +4,7 @@ import {
isMediaUrl isMediaUrl
} from './downloads'; } from './downloads';
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata'; import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
import type { TorrentFile } from '../bindings/TorrentFile';
import i18n from '../i18n'; import i18n from '../i18n';
import { localePluralVariant } from '../i18n/locales'; import { localePluralVariant } from '../i18n/locales';
@@ -52,6 +53,20 @@ export interface AddDownloadDraftRow {
playlistError?: string; playlistError?: string;
metadataBlockedReason?: 'unsafe-url'; metadataBlockedReason?: 'unsafe-url';
selected?: boolean; selected?: boolean;
isTorrent?: boolean;
torrentPath?: string;
torrentCacheId?: string;
torrentInfoHash?: string;
torrentFiles?: TorrentFile[];
selectedTorrentFileIndices?: number[];
torrentSeedTime?: number;
torrentSeedRatio?: number;
torrentUploadLimit?: string;
torrentMaxPeers?: number;
torrentPeerSpeedLimit?: string;
torrentCheckIntegrity?: boolean;
torrentTrackers?: string;
torrentExcludeTrackers?: string;
} }
/** /**
@@ -61,12 +76,37 @@ export interface AddDownloadDraftRow {
*/ */
export const durableDownloadUrl = (sourceUrl: string): string => sourceUrl.trim(); export const durableDownloadUrl = (sourceUrl: string): string => sourceUrl.trim();
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:']); const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:', 'magnet:']);
const isLocalTorrentPath = (value: string): boolean => {
try {
const parsed = new URL(value);
if (parsed.protocol === 'file:') {
return parsed.pathname.toLowerCase().endsWith('.torrent');
}
} catch {
// A native Windows path is not a URL, even though URL parsing may treat
// its drive letter as a scheme.
}
return value.toLowerCase().endsWith('.torrent')
&& (value.startsWith('/') || /^[a-z]:[\\/]/i.test(value));
};
export const isRemoteTorrentUrl = (value: string): boolean => {
try {
const parsed = new URL(value);
return (parsed.protocol === 'http:' || parsed.protocol === 'https:')
&& parsed.pathname.toLowerCase().endsWith('.torrent');
} catch {
return false;
}
};
type ParsedInput = { type ParsedInput = {
identity: string; identity: string;
sourceUrl: string; sourceUrl: string;
valid: boolean; valid: boolean;
isTorrent?: boolean;
isPlaylist?: boolean; isPlaylist?: boolean;
playlistSourceUrl?: string; playlistSourceUrl?: string;
playlistTitle?: string; playlistTitle?: string;
@@ -115,10 +155,18 @@ const parseInputLines = (
let sourceUrl = line; let sourceUrl = line;
let valid = false; let valid = false;
let isTorrent = false;
if (isLocalTorrentPath(line)) {
valid = true;
isTorrent = true;
}
try { try {
const url = new URL(line); if (!isTorrent) {
valid = ALLOWED_SCHEMES.has(url.protocol); const url = new URL(line);
if (valid) sourceUrl = url.href; valid = ALLOWED_SCHEMES.has(url.protocol);
isTorrent = valid && (url.protocol === 'magnet:' || isRemoteTorrentUrl(sourceUrl));
if (valid) sourceUrl = url.href;
}
} catch { } catch {
valid = false; valid = false;
} }
@@ -166,6 +214,7 @@ const parseInputLines = (
identity, identity,
sourceUrl, sourceUrl,
valid, valid,
isTorrent,
isPlaylist: valid && isYouTubePlaylistUrl(sourceUrl), isPlaylist: valid && isYouTubePlaylistUrl(sourceUrl),
requestContextVersion: valid ? requestContextVersions[sourceUrl] : undefined, requestContextVersion: valid ? requestContextVersions[sourceUrl] : undefined,
selected: selectedBySourceUrl[sourceUrl] !== false selected: selectedBySourceUrl[sourceUrl] !== false
@@ -207,6 +256,7 @@ export const reconcileDownloadRows = (
|| preserved.playlistCount !== input.playlistCount || preserved.playlistCount !== input.playlistCount
|| preserved.playlistEntryTitle !== input.playlistEntryTitle; || preserved.playlistEntryTitle !== input.playlistEntryTitle;
if ((forcedMedia && !preserved.isMedia) || contextChanged || playlistContextChanged) { if ((forcedMedia && !preserved.isMedia) || contextChanged || playlistContextChanged) {
const nextGeneration = preserved.generation + 1;
const requestedFilename = input.playlistSourceUrl const requestedFilename = input.playlistSourceUrl
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}` ? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
: requestFilenames[input.sourceUrl]; : requestFilenames[input.sourceUrl];
@@ -216,9 +266,10 @@ export const reconcileDownloadRows = (
? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl)) ? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl))
: preserved.file, : preserved.file,
status: 'loading', status: 'loading',
generation: preserved.generation + 1, generation: nextGeneration,
requestContextVersion, requestContextVersion,
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl), isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
isTorrent: input.isTorrent,
size: undefined, size: undefined,
sizeBytes: undefined, sizeBytes: undefined,
resumable: undefined, resumable: undefined,
@@ -235,7 +286,12 @@ export const reconcileDownloadRows = (
playlistCount: input.playlistCount, playlistCount: input.playlistCount,
playlistEntryTitle: input.playlistEntryTitle, playlistEntryTitle: input.playlistEntryTitle,
playlistError: undefined, playlistError: undefined,
metadataBlockedReason: undefined metadataBlockedReason: undefined,
torrentPath: undefined,
torrentCacheId: input.isTorrent ? `${preserved.id}-${nextGeneration}` : undefined,
torrentInfoHash: undefined,
torrentFiles: undefined,
selectedTorrentFileIndices: undefined
}; };
} }
return preserved; return preserved;
@@ -249,13 +305,15 @@ export const reconcileDownloadRows = (
requestedFilename || fileNameFromUrl(input.sourceUrl) requestedFilename || fileNameFromUrl(input.sourceUrl)
); );
const id = createId();
const generation = input.valid ? 1 : 0;
return { return {
id: createId(), id,
sourceUrl: input.sourceUrl, sourceUrl: input.sourceUrl,
downloadUrl: input.sourceUrl, downloadUrl: input.sourceUrl,
file: fallback, file: fallback,
status: input.valid ? 'loading' : 'invalid', status: input.valid ? 'loading' : 'invalid',
generation: input.valid ? 1 : 0, generation,
requestContextVersion: input.requestContextVersion, requestContextVersion: input.requestContextVersion,
isMedia: input.valid && ( isMedia: input.valid && (
Boolean(input.isPlaylist) Boolean(input.isPlaylist)
@@ -263,6 +321,7 @@ export const reconcileDownloadRows = (
|| forceMediaUrls.has(input.sourceUrl) || forceMediaUrls.has(input.sourceUrl)
|| isMediaUrl(input.sourceUrl) || isMediaUrl(input.sourceUrl)
), ),
isTorrent: input.valid && Boolean(input.isTorrent),
isPlaylist: input.isPlaylist, isPlaylist: input.isPlaylist,
playlistSourceUrl: input.playlistSourceUrl, playlistSourceUrl: input.playlistSourceUrl,
playlistTitle: input.playlistTitle, playlistTitle: input.playlistTitle,
@@ -270,6 +329,7 @@ export const reconcileDownloadRows = (
playlistCount: input.playlistCount, playlistCount: input.playlistCount,
playlistEntryTitle: input.playlistEntryTitle, playlistEntryTitle: input.playlistEntryTitle,
metadataBlockedReason: undefined, metadataBlockedReason: undefined,
torrentCacheId: input.valid && input.isTorrent ? `${id}-${generation}` : undefined,
selected: input.selected !== false selected: input.selected !== false
}; };
}); });
+4 -1
View File
@@ -20,7 +20,7 @@ describe('download action policy', () => {
expect(canStartDownload(status)).toBe(true); expect(canStartDownload(status)).toBe(true);
expect(canPauseDownload(status)).toBe(false); 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); expect(canPauseDownload(status)).toBe(true);
} }
for (const status of ['queued', 'downloading', 'processing', 'retrying'] as const) { 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('failed')).toBe(true);
expect(canRedownload('paused')).toBe(true); expect(canRedownload('paused')).toBe(true);
expect(canRedownload('downloading')).toBe(false); expect(canRedownload('downloading')).toBe(false);
expect(canRedownload('seeding')).toBe(false);
}); });
it('only exposes pause or resume for the details-view toggle', () => { it('only exposes pause or resume for the details-view toggle', () => {
expect(getPauseResumeAction('queued')).toBe('pause'); expect(getPauseResumeAction('queued')).toBe('pause');
expect(getPauseResumeAction('downloading')).toBe('pause'); expect(getPauseResumeAction('downloading')).toBe('pause');
expect(getPauseResumeAction('processing')).toBe('pause'); expect(getPauseResumeAction('processing')).toBe('pause');
expect(getPauseResumeAction('seeding')).toBe('pause');
expect(getPauseResumeAction('retrying')).toBe('pause'); expect(getPauseResumeAction('retrying')).toBe('pause');
expect(getPauseResumeAction('paused')).toBe('resume'); expect(getPauseResumeAction('paused')).toBe('resume');
@@ -53,6 +55,7 @@ describe('download action policy', () => {
expect(startActionLabel('failed')).toBe('Start'); expect(startActionLabel('failed')).toBe('Start');
expect(startActionLabel('paused')).toBe('Resume'); expect(startActionLabel('paused')).toBe('Resume');
expect(isTransferLocked('processing')).toBe(true); expect(isTransferLocked('processing')).toBe(true);
expect(isTransferLocked('seeding')).toBe(true);
expect(isIdentityLocked('completed')).toBe(true); expect(isIdentityLocked('completed')).toBe(true);
expect(isTransferLocked('completed')).toBe(false); expect(isTransferLocked('completed')).toBe(false);
}); });
+2 -1
View File
@@ -11,6 +11,7 @@ const PAUSABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'staged', 'staged',
'queued', 'queued',
'downloading', 'downloading',
'seeding',
'processing', 'processing',
'retrying', 'retrying',
]); ]);
@@ -63,7 +64,7 @@ export const startActionLabel = (status: DownloadStatus): 'Start' | 'Resume' =>
status === 'ready' || status === 'staged' || status === 'failed' ? 'Start' : 'Resume'; status === 'ready' || status === 'staged' || status === 'failed' ? 'Start' : 'Resume';
export const isTransferLocked = (status: DownloadStatus): boolean => 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 => export const isIdentityLocked = (status: DownloadStatus): boolean =>
isTransferLocked(status) || status === 'completed'; isTransferLocked(status) || status === 'completed';
+2
View File
@@ -73,6 +73,8 @@ export const downloadProgressColorClass = (status: string): string => {
return 'download-status-failed'; return 'download-status-failed';
case 'processing': case 'processing':
return 'download-status-processing'; return 'download-status-processing';
case 'seeding':
return 'download-status-seeding';
case 'queued': case 'queued':
case 'staged': case 'staged':
return 'download-status-queued'; return 'download-status-queued';
+1
View File
@@ -20,6 +20,7 @@ const isFreshDownloadStatus = (status: DownloadItem['status']): boolean =>
status === 'staged' || status === 'staged' ||
status === 'queued' || status === 'queued' ||
status === 'downloading' || status === 'downloading' ||
status === 'seeding' ||
status === 'processing' || status === 'processing' ||
status === 'retrying'; status === 'retrying';
+90
View File
@@ -6,6 +6,13 @@ import {
downloadMediaKindsMatch, downloadMediaKindsMatch,
MAX_DOWNLOAD_FILENAME_BYTES, MAX_DOWNLOAD_FILENAME_BYTES,
canonicalizeDownloadFileName, canonicalizeDownloadFileName,
isValidTorrentExcludeTrackerList,
isValidTorrentTrackerList,
normalizeTorrentEncryptionPolicy,
normalizeTorrentMaxOpenFiles,
normalizeTorrentPrioritizePiece,
normalizeTorrentTrackerInterval,
normalizeTorrentTrackerTimeout,
redactDownloadForPersistence, redactDownloadForPersistence,
resolveDownloadConnections resolveDownloadConnections
} from './downloads'; } from './downloads';
@@ -51,6 +58,88 @@ 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);
});
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('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('Torrent encryption policy validation', () => {
it('accepts only the canonical policy states', () => {
expect(normalizeTorrentEncryptionPolicy('disabled')).toBe('disabled');
expect(normalizeTorrentEncryptionPolicy('require-crypto')).toBe('require-crypto');
expect(normalizeTorrentEncryptionPolicy('force-encryption')).toBe('force-encryption');
});
it('clears unknown or malformed persisted values', () => {
expect(normalizeTorrentEncryptionPolicy(undefined)).toBeUndefined();
expect(normalizeTorrentEncryptionPolicy('arc4')).toBeUndefined();
expect(normalizeTorrentEncryptionPolicy(true)).toBeUndefined();
});
});
describe('Torrent tracker timing validation', () => {
it('accepts bounded timeout values and an automatic interval', () => {
expect(normalizeTorrentTrackerTimeout('1')).toBe(1);
expect(normalizeTorrentTrackerTimeout(604800)).toBe(604800);
expect(normalizeTorrentTrackerInterval('0')).toBe(0);
expect(normalizeTorrentTrackerInterval(604800)).toBe(604800);
});
it('rejects zero timeouts and out-of-range timing values', () => {
expect(normalizeTorrentTrackerTimeout('0')).toBeUndefined();
expect(normalizeTorrentTrackerTimeout(604801)).toBeUndefined();
expect(normalizeTorrentTrackerInterval(-1)).toBeUndefined();
expect(normalizeTorrentTrackerInterval(604801)).toBeUndefined();
expect(normalizeTorrentTrackerTimeout('1.5')).toBeUndefined();
});
});
describe('Torrent open-file limit validation', () => {
it('accepts bounded integer limits', () => {
expect(normalizeTorrentMaxOpenFiles(1)).toBe(1);
expect(normalizeTorrentMaxOpenFiles(4096)).toBe(4096);
});
it('rejects zero, fractional, and oversized limits', () => {
expect(normalizeTorrentMaxOpenFiles(0)).toBeUndefined();
expect(normalizeTorrentMaxOpenFiles('1.5')).toBeUndefined();
expect(normalizeTorrentMaxOpenFiles(4097)).toBeUndefined();
});
});
describe('download connection resolution', () => { describe('download connection resolution', () => {
it('uses a clamped fallback for legacy rows without a saved value', () => { it('uses a clamped fallback for legacy rows without a saved value', () => {
expect(resolveDownloadConnections(undefined, 8)).toBe(8); expect(resolveDownloadConnections(undefined, 8)).toBe(8);
@@ -68,6 +157,7 @@ describe('download connection resolution', () => {
describe('download filename matching', () => { describe('download filename matching', () => {
it('matches frontend filenames to the backend Windows device-name canonicalization', () => { it('matches frontend filenames to the backend Windows device-name canonicalization', () => {
expect(canonicalizeDownloadFileName('CON.txt')).toBe('CON-.txt'); expect(canonicalizeDownloadFileName('CON.txt')).toBe('CON-.txt');
expect(canonicalizeDownloadFileName('CON .txt')).toBe('CON -.txt');
expect(canonicalizeDownloadFileName('com1.archive.zip')).toBe('com1.archive-.zip'); expect(canonicalizeDownloadFileName('com1.archive.zip')).toBe('com1.archive-.zip');
expect(downloadFileNamesMatch('CON-.txt', 'CON.txt')).toBe(true); expect(downloadFileNamesMatch('CON-.txt', 'CON.txt')).toBe(true);
expect(downloadFileNamesMatch('console.txt', 'CON.txt')).toBe(false); expect(downloadFileNamesMatch('console.txt', 'CON.txt')).toBe(false);
+179 -3
View File
@@ -30,6 +30,7 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'queued', 'queued',
'downloading', 'downloading',
'processing', 'processing',
'seeding',
'retrying', 'retrying',
]); ]);
@@ -38,11 +39,72 @@ export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
/** Transfer states that consume a worker/permit. Queued is intentionally excluded. */ /** Transfer states that consume a worker/permit. Queued is intentionally excluded. */
export const isTransferActiveStatus = (status: DownloadStatus): boolean => 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_MIN = 1;
export const DOWNLOAD_CONNECTIONS_MAX = 16; export const DOWNLOAD_CONNECTIONS_MAX = 16;
export const TORRENT_ENCRYPTION_POLICY_DISABLED = 'disabled' as const;
export const TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO = 'require-crypto' as const;
export const TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION = 'force-encryption' as const;
export type TorrentEncryptionPolicy =
| typeof TORRENT_ENCRYPTION_POLICY_DISABLED
| typeof TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO
| typeof TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION;
export const normalizeTorrentEncryptionPolicy = (
value: unknown
): TorrentEncryptionPolicy | undefined => {
if (
value === TORRENT_ENCRYPTION_POLICY_DISABLED ||
value === TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO ||
value === TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION
) {
return value;
}
return undefined;
};
export const MAX_TORRENT_TRACKER_TIMEOUT = 604800;
export const MAX_TORRENT_TRACKER_INTERVAL = 604800;
export const DEFAULT_TORRENT_MAX_OPEN_FILES = 100;
export const MIN_TORRENT_MAX_OPEN_FILES = 1;
export const MAX_TORRENT_MAX_OPEN_FILES = 4096;
const parseIntegerOption = (value: unknown): number | undefined => {
if (typeof value === 'number') {
return Number.isInteger(value) ? value : undefined;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : undefined;
}
return undefined;
};
export const normalizeTorrentTrackerTimeout = (value: unknown): number | undefined => {
const parsed = parseIntegerOption(value);
return parsed !== undefined && parsed >= 1 && parsed <= MAX_TORRENT_TRACKER_TIMEOUT
? parsed
: undefined;
};
export const normalizeTorrentTrackerInterval = (value: unknown): number | undefined => {
const parsed = parseIntegerOption(value);
return parsed !== undefined && parsed >= 0 && parsed <= MAX_TORRENT_TRACKER_INTERVAL
? parsed
: undefined;
};
export const normalizeTorrentMaxOpenFiles = (value: unknown): number | undefined => {
const parsed = parseIntegerOption(value);
return parsed !== undefined
&& parsed >= MIN_TORRENT_MAX_OPEN_FILES
&& parsed <= MAX_TORRENT_MAX_OPEN_FILES
? parsed
: undefined;
};
// Keep every filename component within the common cross-platform filesystem // Keep every filename component within the common cross-platform filesystem
// limit. Count UTF-8 bytes because POSIX filesystems enforce bytes, while this // limit. Count UTF-8 bytes because POSIX filesystems enforce bytes, while this
// bound is also conservative for Windows filename components. // bound is also conservative for Windows filename components.
@@ -117,6 +179,119 @@ export const normalizeSpeedLimitForBackend = (value?: string | null): string | n
return unit ? `${amount}${unit}` : `${amount}K`; return unit ? `${amount}${unit}` : `${amount}K`;
}; };
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.
* The Rust validator remains authoritative because persisted data can bypass
* this helper and the browser URL parser is not the native URL parser.
*/
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<string>();
let wildcard = false;
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;
}
if (allowWildcard && token === '*') {
if (normalized.size > 0) return false;
wildcard = true;
continue;
}
if (wildcard) 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 || wildcard;
};
export const isValidTorrentTrackerList = (value: string): boolean =>
isValidTorrentTrackerListInternal(value, false);
export const isValidTorrentExcludeTrackerList = (value: string): boolean =>
isValidTorrentTrackerListInternal(value, true);
export const initMediaDomains = async () => { export const initMediaDomains = async () => {
try { try {
const domains = await invoke('get_supported_media_domains'); const domains = await invoke('get_supported_media_domains');
@@ -162,7 +337,7 @@ export const canonicalizeDownloadFileName = (fileName: string): string => {
.trim() .trim()
.replace(/[. ]+$/g, ''); .replace(/[. ]+$/g, '');
let canonical = sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download'; let canonical = sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
const reservedStem = canonical.split('.')[0]?.toUpperCase(); const reservedStem = canonical.split('.')[0]?.trimEnd().toUpperCase();
if (reservedStem && WINDOWS_RESERVED_FILENAME_STEMS.has(reservedStem)) { if (reservedStem && WINDOWS_RESERVED_FILENAME_STEMS.has(reservedStem)) {
const extensionStart = canonical.lastIndexOf('.'); const extensionStart = canonical.lastIndexOf('.');
const base = extensionStart > 0 ? canonical.slice(0, extensionStart) : canonical; const base = extensionStart > 0 ? canonical.slice(0, extensionStart) : canonical;
@@ -255,7 +430,8 @@ export const isMediaUrl = (rawUrl: string): boolean => {
*/ */
const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const; const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
const VOLATILE_PROGRESS_STATUSES = new Set([ const VOLATILE_PROGRESS_STATUSES = new Set([
'downloading' 'downloading',
'seeding'
]); ]);
/** /**