mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-02 07:30:18 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d427b96d3 | |||
| 48b4d984a2 | |||
| 1d27b5b0bf | |||
| b4da68655a | |||
| 67023d3f0d | |||
| a46a64994d | |||
| ab9f0507d0 | |||
| 2474d2c1cb | |||
| d0541308c7 | |||
| 0f1f4e8003 | |||
| e31a3fcc90 | |||
| 87d682709e | |||
| ab6c3c372e | |||
| 52082c1e1d | |||
| 9247c5cf9c | |||
| dea6ad1974 | |||
| bb64c4cd52 | |||
| 92092e9844 | |||
| 1a492fc4c8 | |||
| a00d34edc8 | |||
| 18b51ca86e | |||
| 1bf8b675e4 | |||
| ebd11b26f6 | |||
| ae5199132d | |||
| 6336ff6f5d | |||
| a8bd80d557 | |||
| 186e189277 | |||
| 2c1bd9cdf1 | |||
| 292447d417 |
@@ -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
|
||||||
|
|||||||
@@ -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 1–604800 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
|
||||||
|
1–4096 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.
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Generated
+14
-1
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+17
-4
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,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,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,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, };
|
||||||
|
|||||||
@@ -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, };
|
||||||
|
|||||||
@@ -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, };
|
||||||
@@ -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, };
|
||||||
@@ -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, };
|
||||||
@@ -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, };
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 Aria2’s 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 Aria2’s 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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: '强制加密 payload(ARC4)',
|
||||||
|
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、FTP 或 SFTP URL…\n\n对于媒体下载,请粘贴来自 YouTube、X、TikTok、Instagram、Reddit 等的链接。',
|
pastePlaceholder: '在此粘贴 HTTP、HTTPS、FTP、SFTP 或 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: '强制加密 payload(ARC4)',
|
||||||
|
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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user