mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-07 09:53:17 +00:00
feat(torrents): harden lifecycle and web-seed management
- Enforce generation-safe seed admission and budget tracking. - Make web-seed RPC, persistence, rollback, and startup attachment lifecycle-safe. - Keep Torrent progress, DHT, seed-capacity, and web-seed validation covered. - Ignore local TORRENT_FEATURES.md roadmap notes.
This commit is contained in:
@@ -11,6 +11,8 @@ skills-lock.json
|
||||
# Local agent and planning notes
|
||||
AGENT.md
|
||||
AGENTS.md
|
||||
TORRENT_FEATURES.md
|
||||
torrent_features.md
|
||||
CLAUDE.md
|
||||
GEMINI.md
|
||||
implementation_plan.md
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
# Firelink Torrent feature matrix
|
||||
|
||||
This document is the source of truth for Firelink's BitTorrent scope, current
|
||||
implementation status, and next work. It compares Firelink with the
|
||||
BitTorrent-specific surface of the bundled Aria2 1.37.0 engine. Aria2's
|
||||
generic HTTP/FTP/SFTP/Metalink options, arbitrary shell hooks, and daemon
|
||||
administration RPCs are intentionally separate unless they affect Torrent
|
||||
ownership or safety.
|
||||
|
||||
Reference: [Aria2 1.37.0 manual](https://aria2.github.io/manual/en/html/aria2c.html).
|
||||
|
||||
## Audit basis
|
||||
|
||||
- Audited on 2026-08-03 at Firelink `32034e9` (`main`) plus the current working
|
||||
tree, with the cumulative
|
||||
Torrent work reviewed from `edc76a7`.
|
||||
- Source of truth: `src-tauri/src/torrent.rs`, `torrent_probe.rs`, `queue.rs`,
|
||||
`lib.rs`, `settings.rs`, `download_ownership.rs`, `db.rs`, the IPC bindings,
|
||||
frontend stores/components, and `scripts/smoke-torrent.js`.
|
||||
- Reliability claims require a source postcondition or a test/harness
|
||||
assertion. A passing local macOS check does not prove Windows/Linux native
|
||||
behavior, public tracker/DHT reachability, or packaged-app behavior.
|
||||
- The requested Agy and OpenCode review was bounded to the cumulative Torrent
|
||||
diff and relevant paths. Their advice was used only after this source audit
|
||||
and was verified against the live tree.
|
||||
|
||||
## Implemented
|
||||
|
||||
### Intake, metadata, and file selection
|
||||
|
||||
- Local `.torrent` files, magnet links, and remote HTTP(S) `.torrent` metadata.
|
||||
Remote metadata is bounded, redirect/SSRF checked, credential-free, parsed,
|
||||
and cached before enqueue.
|
||||
- Strict bencode parsing, sorted-key validation, size/depth bounds, UTF-8
|
||||
validation, canonical info-hash verification, safe output components, and
|
||||
managed metadata retention/rekeying.
|
||||
- Selected-file preview and validated `select-file` handling. Firelink derives
|
||||
the Torrent output contract with Aria2 `index-out`; it does not use the
|
||||
generic `out` option for Torrent files.
|
||||
- Torrent metadata probing uses Aria2 `bt-metadata-only` and `bt-save-metadata`
|
||||
internally, validates the returned hash, and conservatively cleans probe
|
||||
directories. It is not exposed as a separate metadata-only download mode.
|
||||
- Validated metadata is also stored under a canonical lowercase hexadecimal
|
||||
info-hash key. Plain magnets containing only `xt` and optional `dn` reuse
|
||||
that cache before probing when the cached file has no tracker, web-seed, or
|
||||
other source-specific outer metadata; tracker, web-seed, source, and unknown
|
||||
query parameters conservatively force a fresh probe. Cache hits are
|
||||
revalidated against bencode and the exact hash, copied into the current
|
||||
draft ID, and therefore remain compatible with Add-window rekeying.
|
||||
- Canonical metadata writes use a same-directory temporary file and rename;
|
||||
invalid entries and abandoned canonical temporary files are removed safely.
|
||||
Canonical files use a separate `.info-<hash>.torrent` namespace from
|
||||
draft/final IDs, and reads are bounded before parsing. Startup retention
|
||||
keeps canonical files referenced by persisted Torrent records'
|
||||
`torrentInfoHash`, as well as draft/final ID-keyed files.
|
||||
- `addTorrent` passes validated web-seed/mirror URIs when supplied through the
|
||||
existing download input. There is no separate Torrent web-seed manager.
|
||||
|
||||
### Queue and lifecycle ownership
|
||||
|
||||
- Torrents use the existing Firelink queue admission, global/per-queue permits,
|
||||
pause/resume, cancellation, retry/GID replacement, restart recovery, and
|
||||
terminal reconciliation.
|
||||
- A Torrent's Aria2 GID is paired with the Firelink download ID and lifecycle
|
||||
epoch. Late RPC results and stale terminal events cannot revive a removed or
|
||||
newer lifecycle.
|
||||
- Exactly one queue permit remains parked for the complete Aria2 lifecycle,
|
||||
including seeding, and release is idempotent.
|
||||
- Aria2 `getFiles` reconciliation establishes output ownership for Torrent
|
||||
files. Ownership and optional unselected-file removal reservations are
|
||||
canonicalized, persisted, collision-checked, and kept separate.
|
||||
- Generic `addUri` explicitly sets both `follow-torrent=false` and
|
||||
`follow-metalink=false`. This prevents an HTTP download from creating an
|
||||
unmanaged child GID outside Firelink's queue, ownership, cancellation, retry,
|
||||
and restart model.
|
||||
|
||||
### Transfer, seeding, and integrity controls
|
||||
|
||||
- Optional `seed-time` and/or `seed-ratio` policies, including ratio-only and
|
||||
unlimited-ratio semantics; upload progress and seeding status are reflected
|
||||
in the UI.
|
||||
- Per-Torrent upload limit through Aria2 `max-upload-limit`, with a live,
|
||||
lifecycle-fenced update path.
|
||||
- Global Aria2 aggregate upload limit through
|
||||
`max-overall-upload-limit`. It is persisted, validated, applied at daemon
|
||||
startup, and changeable through `aria2.changeGlobalOption`; in Firelink it
|
||||
primarily controls Torrent seeding traffic, and blank means Aria2's
|
||||
unlimited value (`0`).
|
||||
- Per-Torrent maximum peers (`bt-max-peers`) and low-speed peer expansion
|
||||
threshold (`bt-request-peer-speed-limit`), including live updates.
|
||||
- Optional piece-integrity verification through `check-integrity` and a safe
|
||||
`bt-hash-check-seed`/`bt-seed-unverified=false` policy. Firelink does not
|
||||
silently seed unverified data when the user requests verification.
|
||||
- Optional `bt-stop-timeout` stall policy, persisted per Torrent and reapplied
|
||||
on start/retry.
|
||||
- Optional `bt-prioritize-piece` head/tail preview policy, normalized and
|
||||
reapplied on start/retry.
|
||||
- Validated encryption policies mapped consistently to
|
||||
`bt-force-encryption`, `bt-require-crypto`, and `bt-min-crypto-level`.
|
||||
- Optional `bt-remove-unselected-file` cleanup after successful completion,
|
||||
only with an explicit partial selection and confirmation. The selected-file
|
||||
ownership and unselected-file reservation are committed atomically; Firelink
|
||||
clears the reservation only after Aria2's reserved paths are absent,
|
||||
including when a transfer fails. Restart recovery preserves queued/paused
|
||||
and orphaned reservations while reclaiming only observed failed or completed
|
||||
cleanup. Disabling the option after a detach clears the reservation before
|
||||
the edited item is persisted.
|
||||
|
||||
### Trackers, peers, and network identity
|
||||
|
||||
- Additional `bt-tracker` URLs and `bt-exclude-tracker`, including the explicit
|
||||
`*` wildcard. URLs are bounded, normalized, credential-free, and limited to
|
||||
HTTP(S)/UDP schemes.
|
||||
- `bt-tracker-connect-timeout`, `bt-tracker-timeout`, and
|
||||
`bt-tracker-interval`, persisted per Torrent and reapplied on start/retry.
|
||||
- Bounded read-only `aria2.getPeers` diagnostics. Firelink discards peer IPs,
|
||||
ports, IDs, and bitfields at the native boundary and exposes only bounded
|
||||
operational speeds and choking/seeder flags.
|
||||
- Global DHT, IPv6 DHT, PEX, and LPD toggles. Private-Torrent behavior remains
|
||||
Aria2-controlled.
|
||||
- Launch-scoped TCP/UDP listen-port ranges, external BitTorrent IP, IPv4/IPv6
|
||||
DHT entry points, IPv6 DHT listen address, and LPD interface. Settings are
|
||||
validated, persisted, and applied only after Firelink restart.
|
||||
- Optional bounded peer-ID prefix and peer-agent overrides. They are disabled
|
||||
by default and carry identity/privacy/compatibility warnings.
|
||||
- Global `bt-max-open-files`, bounded to 1–4096, applied at startup and
|
||||
updateable for newly added Torrents through `aria2.changeGlobalOption`.
|
||||
|
||||
### Evidence already present in the tree
|
||||
|
||||
- Rust unit coverage for bencode/hash/path validation, option normalization,
|
||||
queue ownership, lifecycle fencing, persistence sanitization, atomic Torrent
|
||||
removal reservations, conservative restart recovery, host case-insensitive
|
||||
path identity, and native startup argument construction.
|
||||
- `src-tauri/tests/torrent_rpc.rs` covers the production authenticated JSON-RPC
|
||||
HTTP boundary in a Windows-compatible integration-test target.
|
||||
- `npm run smoke:torrent` and `npm run smoke:torrent:failure-paths` cover
|
||||
deterministic local seeding, magnet metadata resolution, selected output,
|
||||
pause/resume, ownership, cancellation/removal, unavailable trackers,
|
||||
daemon failure, integrity, encryption, tracker/piece policies, open-file and
|
||||
aggregate-upload limits, and stall-timeout behavior.
|
||||
|
||||
## Aria2 comparison: available but not exposed or only partially represented
|
||||
|
||||
| Aria2 capability | Firelink status | Reason / next step |
|
||||
| --- | --- | --- |
|
||||
| `bt-load-saved-metadata` | App-equivalent implemented | Firelink owns a validated, atomic, info-hash-keyed metadata cache for plain magnets, limited to metadata without source-specific outer tracker/web-seed fields, while preserving the current draft-ID/rekey contract. Source-specific magnet parameters intentionally bypass reuse; Aria2's daemon option is not exposed directly. |
|
||||
| `dht-message-timeout` | Not exposed | Global DHT/UDP timeout tuning is not yet represented in settings. Add only with bounded validation and a runtime/startup contract. |
|
||||
| `dht-file-path`, `dht-file-path6` | Not explicitly controlled | Aria2 can persist DHT routing tables, but Firelink does not choose app-managed paths or report their health. Decide whether portable-mode and privacy behavior justify exposing this. |
|
||||
| `bt-detach-seed-only` | Not used | Aria2's concurrent-download accounting does not replace Firelink's permit ownership. Enabling it blindly would create two competing concurrency models. Revisit only with an explicit seed-slot policy. |
|
||||
| `follow-torrent=true/mem` | Intentionally disabled for generic URLs | The child GID has no durable Firelink identity, permit, output ownership, or restart recovery record. Implement only after a parent/child lifecycle model exists and remote metadata validation is preserved. |
|
||||
| `bt-metadata-only` / `bt-save-metadata` as user actions | Internal probe only | The Add window resolves metadata before enqueue; a separate user-visible metadata-only job is not currently a product need. |
|
||||
| `on-bt-download-complete` and other hooks | Out of scope | Aria2 executes arbitrary commands. Firelink does not expose a shell-command injection surface; any future automation should be a bounded, app-owned event system. |
|
||||
| `bt-enable-hook-after-hash-check` | Out of scope with hooks | It has no useful standalone meaning while arbitrary hooks are excluded. |
|
||||
| `rpc-save-upload-metadata`, `save-session`, and other daemon-admin RPC policy | Out of scope / replaced | Firelink owns metadata retention and durable download state; enabling Aria2's uploaded-metadata persistence would create a second storage contract. |
|
||||
| Aria2 CLI-only `show-files` / `torrent-file` controls | Product-equivalent path exists | Firelink provides a validated Add-window preview and managed `addTorrent` path rather than exposing CLI flags. |
|
||||
|
||||
The comparison intentionally does not treat Aria2 defaults as Firelink
|
||||
features. For example, Aria2 defaults `follow-torrent` to true, but Firelink
|
||||
must override it to false on every generic `addUri` path until child ownership
|
||||
is durable.
|
||||
|
||||
## Priority tiers for future work
|
||||
|
||||
### Tier 0 — correctness and safety gates
|
||||
|
||||
No unstarted Tier 0 feature is approved. The global aggregate upload ceiling
|
||||
was the highest-impact missing control and is now implemented as a persisted,
|
||||
startup, and live-RPC contract.
|
||||
|
||||
Before any new Torrent feature is promoted, keep these gates mandatory:
|
||||
|
||||
1. Every Aria2 GID must remain attached to one Firelink identity, lifecycle
|
||||
epoch, permit, and owned-path contract.
|
||||
2. Every awaited RPC must re-check lifecycle ownership before mutating UI,
|
||||
persistence, or queue state.
|
||||
3. Any cleanup that can delete files must prove ownership and remain
|
||||
conservative after cancellation, daemon loss, restart, and missed events.
|
||||
4. Generic followed child GIDs remain disabled until their full lifecycle is
|
||||
modeled and tested.
|
||||
|
||||
### Tier 1 — high-value user behavior
|
||||
|
||||
1. **DHT routing-table persistence policy.** Decide and implement app-managed
|
||||
`dht-file-path`/`dht-file-path6` behavior, especially for portable mode,
|
||||
permissions, reset, and privacy. This should be opt-in if it expands data
|
||||
retention beyond the current download metadata contract.
|
||||
|
||||
### Tier 2 — advanced tuning and ownership expansion
|
||||
|
||||
1. Expose bounded `dht-message-timeout` if real tracker/DHT diagnostics show a
|
||||
user-visible need; validate it at startup and document that it affects DHT
|
||||
and UDP tracker waits, not HTTP metadata fetches.
|
||||
2. Add an explicit seed-slot policy only if Firelink wants seeding to stop
|
||||
consuming a queue permit. Aria2 `bt-detach-seed-only` alone is insufficient;
|
||||
Firelink's queue and power-management semantics must agree first.
|
||||
3. Model generic followed Torrent children (`true` or `mem`) with durable
|
||||
parent/child IDs, admission accounting, output ownership, cancellation,
|
||||
retry/GID replacement, restart discovery, and bounded metadata validation.
|
||||
This remains a substantial architecture change, not a one-line option.
|
||||
|
||||
## Deliberately not planned
|
||||
|
||||
- Arbitrary shell hooks from Aria2.
|
||||
- Direct daemon-admin/session-management controls that duplicate Firelink's
|
||||
persistence and ownership system.
|
||||
- Claims of public tracker/DHT readiness from local deterministic fixtures.
|
||||
- A second Torrent engine. Firelink's existing Aria2 queue, permit, GID, and
|
||||
recovery contracts are the intended transfer architecture.
|
||||
|
||||
## Validation commands
|
||||
|
||||
Run focused checks first, then the relevant broader gates:
|
||||
|
||||
```sh
|
||||
npm test -- --run
|
||||
npm run check:i18n
|
||||
npm run bindings
|
||||
cd src-tauri
|
||||
cargo test --test torrent_rpc -- --nocapture
|
||||
cargo test --all-targets
|
||||
cd ..
|
||||
npm run smoke:torrent
|
||||
npm run smoke:torrent:failure-paths
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Native Windows/Linux behavior, packaged-app startup, public magnets, and
|
||||
router/firewall port forwarding remain separate evidence slices and must not be
|
||||
implied by these local checks.
|
||||
@@ -632,7 +632,37 @@ async function main() {
|
||||
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 dhtStateDir = path.join(tempRoot, 'aria2-state');
|
||||
for (const directory of [seedRoot, probeDir, finalDir, integrityDir, encryptionDir, cancelDir, removeUnselectedDir, stallDir, dhtStateDir]) fs.mkdirSync(directory, { recursive: true });
|
||||
|
||||
const dhtPath = path.join(dhtStateDir, 'dht.dat');
|
||||
const dht6Path = path.join(dhtStateDir, 'dht6.dat');
|
||||
const dhtFirst = await startDaemon({
|
||||
name: 'dht-paths-first',
|
||||
rpcPort: await findAvailablePort(),
|
||||
listenPort: await findAvailablePort(),
|
||||
directory: dhtStateDir,
|
||||
extraArgs: [`--dht-file-path=${dhtPath}`, `--dht-file-path6=${dht6Path}`, '--enable-dht=true', '--enable-dht6=true', '--dht-message-timeout=42'],
|
||||
});
|
||||
const firstDhtOptions = await rpc(dhtFirst.rpcPort, dhtFirst.secret, 'aria2.getGlobalOption');
|
||||
assert(firstDhtOptions['dht-file-path'] === dhtPath, `first daemon did not retain dht-file-path: ${JSON.stringify(firstDhtOptions['dht-file-path'])}`);
|
||||
assert(firstDhtOptions['dht-file-path6'] === dht6Path, `first daemon did not retain dht-file-path6: ${JSON.stringify(firstDhtOptions['dht-file-path6'])}`);
|
||||
assert(firstDhtOptions['dht-message-timeout'] === '42', `first daemon did not retain dht-message-timeout: ${JSON.stringify(firstDhtOptions['dht-message-timeout'])}`);
|
||||
await stopDaemon(dhtFirst);
|
||||
|
||||
const dhtSecond = await startDaemon({
|
||||
name: 'dht-paths-second',
|
||||
rpcPort: await findAvailablePort(),
|
||||
listenPort: await findAvailablePort(),
|
||||
directory: dhtStateDir,
|
||||
extraArgs: [`--dht-file-path=${dhtPath}`, `--dht-file-path6=${dht6Path}`, '--enable-dht=true', '--enable-dht6=true', '--dht-message-timeout=42'],
|
||||
});
|
||||
const secondDhtOptions = await rpc(dhtSecond.rpcPort, dhtSecond.secret, 'aria2.getGlobalOption');
|
||||
assert(secondDhtOptions['dht-file-path'] === dhtPath, `second daemon did not retain dht-file-path: ${JSON.stringify(secondDhtOptions['dht-file-path'])}`);
|
||||
assert(secondDhtOptions['dht-file-path6'] === dht6Path, `second daemon did not retain dht-file-path6: ${JSON.stringify(secondDhtOptions['dht-file-path6'])}`);
|
||||
assert(secondDhtOptions['dht-message-timeout'] === '42', `second daemon did not retain dht-message-timeout: ${JSON.stringify(secondDhtOptions['dht-message-timeout'])}`);
|
||||
await stopDaemon(dhtSecond);
|
||||
console.log('[OK] Aria2 DHT routing-table paths and message timeout remained explicit across two launches');
|
||||
|
||||
const seederListenPort = await findAvailablePort();
|
||||
const clientListenPort = await findAvailablePort();
|
||||
|
||||
@@ -34,6 +34,18 @@ fn default_torrent_max_open_files() -> u32 {
|
||||
crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES
|
||||
}
|
||||
|
||||
fn default_torrent_dht_message_timeout() -> u32 {
|
||||
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
}
|
||||
|
||||
fn default_torrent_separate_seed_slots() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_torrent_max_concurrent_seeds() -> u32 {
|
||||
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -49,6 +61,10 @@ pub enum DownloadStatus {
|
||||
/// A BitTorrent download has all selected data and is still seeding.
|
||||
/// The Aria2 GID and queue permit remain live until seeding ends.
|
||||
Seeding,
|
||||
/// A BitTorrent download is complete but paused while waiting for a
|
||||
/// Firelink-owned seeding slot.
|
||||
#[serde(rename = "waitingToSeed")]
|
||||
WaitingToSeed,
|
||||
Paused,
|
||||
Completed,
|
||||
Failed,
|
||||
@@ -66,6 +82,7 @@ impl DownloadStatus {
|
||||
Self::Downloading => "downloading",
|
||||
Self::Processing => "processing",
|
||||
Self::Seeding => "seeding",
|
||||
Self::WaitingToSeed => "waitingToSeed",
|
||||
Self::Paused => "paused",
|
||||
Self::Completed => "completed",
|
||||
Self::Failed => "failed",
|
||||
@@ -188,6 +205,12 @@ pub struct DownloadItem {
|
||||
pub torrent_seed_ratio: Option<f64>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_seed_remaining: Option<f64>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_web_seeds: Option<Vec<TorrentWebSeed>>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_upload_limit: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
@@ -252,6 +275,48 @@ pub struct TorrentPeerDiagnostics {
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentFileProgress {
|
||||
pub index: u32,
|
||||
pub relative_path: String,
|
||||
#[ts(type = "number")]
|
||||
pub length: u64,
|
||||
#[ts(type = "number")]
|
||||
pub completed_length: u64,
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentFileProgressSnapshot {
|
||||
pub files: Vec<TorrentFileProgress>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentPieceProgressSnapshot {
|
||||
#[ts(type = "number")]
|
||||
pub piece_length: u64,
|
||||
#[ts(type = "number")]
|
||||
pub num_pieces: u64,
|
||||
#[ts(type = "number")]
|
||||
pub completed_pieces: u64,
|
||||
pub buckets: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentWebSeed {
|
||||
#[ts(type = "number")]
|
||||
pub file_index: u32,
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -501,6 +566,12 @@ pub struct PersistedSettings {
|
||||
pub torrent_enable_lpd: bool,
|
||||
#[serde(default = "default_torrent_max_open_files")]
|
||||
pub torrent_max_open_files: u32,
|
||||
#[serde(default = "default_torrent_dht_message_timeout")]
|
||||
pub torrent_dht_message_timeout: u32,
|
||||
#[serde(default = "default_torrent_separate_seed_slots")]
|
||||
pub torrent_separate_seed_slots: bool,
|
||||
#[serde(default = "default_torrent_max_concurrent_seeds")]
|
||||
pub torrent_max_concurrent_seeds: u32,
|
||||
#[serde(default)]
|
||||
pub torrent_listen_port: String,
|
||||
#[serde(default)]
|
||||
@@ -559,6 +630,8 @@ pub struct DownloadStateEvent {
|
||||
pub error: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub file_name: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub torrent_seed_remaining: Option<f64>,
|
||||
}
|
||||
|
||||
impl DownloadStateEvent {
|
||||
@@ -568,6 +641,7 @@ impl DownloadStateEvent {
|
||||
status: status.as_str().to_string(),
|
||||
error: None,
|
||||
file_name: None,
|
||||
torrent_seed_remaining: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,6 +651,7 @@ impl DownloadStateEvent {
|
||||
status: DownloadStatus::Failed.as_str().to_string(),
|
||||
error: Some(error.into()),
|
||||
file_name: None,
|
||||
torrent_seed_remaining: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,6 +661,17 @@ impl DownloadStateEvent {
|
||||
status: DownloadStatus::Paused.as_str().to_string(),
|
||||
error: Some(error.into()),
|
||||
file_name: None,
|
||||
torrent_seed_remaining: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paused_with_seed_remaining(id: impl Into<String>, remaining: Option<f64>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::Paused.as_str().to_string(),
|
||||
error: None,
|
||||
file_name: None,
|
||||
torrent_seed_remaining: remaining,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,6 +681,7 @@ impl DownloadStateEvent {
|
||||
status: DownloadStatus::Completed.as_str().to_string(),
|
||||
error: None,
|
||||
file_name: Some(file_name.into()),
|
||||
torrent_seed_remaining: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,6 +693,17 @@ impl DownloadStateEvent {
|
||||
status: DownloadStatus::Retrying.as_str().to_string(),
|
||||
error: Some(reason.into()),
|
||||
file_name: None,
|
||||
torrent_seed_remaining: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn waiting_to_seed(id: impl Into<String>, remaining: Option<f64>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::WaitingToSeed.as_str().to_string(),
|
||||
error: None,
|
||||
file_name: None,
|
||||
torrent_seed_remaining: remaining,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+405
-4
@@ -4633,11 +4633,17 @@ async fn pause_download(
|
||||
}
|
||||
}
|
||||
|
||||
let seed_remaining = if state.queue_manager.is_seed_owner(&id) {
|
||||
state.queue_manager.capture_seed_remaining(&id).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
state.queue_manager.release_seed_tracking(&id);
|
||||
state.queue_manager.release_permit(&id).await;
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Paused),
|
||||
crate::ipc::DownloadStateEvent::paused_with_seed_remaining(id, seed_remaining),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -4677,6 +4683,12 @@ async fn pause_download(
|
||||
.await;
|
||||
}
|
||||
|
||||
let seed_remaining = if state.queue_manager.is_seed_owner(&id) {
|
||||
state.queue_manager.capture_seed_remaining(&id).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
state.queue_manager.release_seed_tracking(&id);
|
||||
state.queue_manager.release_permit(&id).await;
|
||||
if registered_lifecycle_generation.is_some()
|
||||
|| removed_pending
|
||||
@@ -4694,7 +4706,7 @@ async fn pause_download(
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Paused),
|
||||
crate::ipc::DownloadStateEvent::paused_with_seed_remaining(id, seed_remaining),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -4723,6 +4735,14 @@ async fn resume_download(
|
||||
state.queue_manager.release_registered_id(&id).await;
|
||||
return Ok(false);
|
||||
};
|
||||
if state.queue_manager.is_waiting_to_seed(&id) {
|
||||
// WaitingToSeed is an intentional paused GID with no download
|
||||
// permit. A resume request only wakes the Firelink seed scheduler;
|
||||
// it must not bypass the seed-slot admission gate.
|
||||
state.queue_manager.wake_seed_waiters();
|
||||
drop(control_guard);
|
||||
return Ok(true);
|
||||
}
|
||||
let status = aria2_download_status(
|
||||
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||
&state.aria2_secret,
|
||||
@@ -6489,6 +6509,297 @@ async fn get_torrent_peers(
|
||||
state.queue_manager.get_aria2_torrent_peers(&id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_torrent_file_progress(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<crate::ipc::TorrentFileProgressSnapshot, String> {
|
||||
state
|
||||
.queue_manager
|
||||
.get_aria2_torrent_file_progress(&id)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_torrent_piece_progress(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<crate::ipc::TorrentPieceProgressSnapshot, String> {
|
||||
state
|
||||
.queue_manager
|
||||
.get_aria2_torrent_piece_progress(&id)
|
||||
.await
|
||||
}
|
||||
|
||||
fn replace_persisted_torrent_web_seeds(
|
||||
database: &crate::db::DbState,
|
||||
id: &str,
|
||||
seeds: &[crate::ipc::TorrentWebSeed],
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
let mut connection = database.lock()?;
|
||||
let records = crate::db::load_downloads(&connection)?;
|
||||
let next_seeds = serde_json::to_value(seeds)
|
||||
.map_err(|error| format!("failed to encode Torrent web seeds: {error}"))?;
|
||||
let mut previous_seeds = None;
|
||||
let mut changed = false;
|
||||
let mut next = Vec::with_capacity(records.len());
|
||||
for record in records {
|
||||
let mut value: serde_json::Value = match serde_json::from_str(&record) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
// Preserve unrelated legacy/corrupt rows byte-for-byte. A
|
||||
// web-seed update must not fail its own transaction merely
|
||||
// because another download cannot be decoded.
|
||||
next.push(record);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if value
|
||||
.get("id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(id)
|
||||
{
|
||||
let object = value
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "persisted download is not an object".to_string())?;
|
||||
previous_seeds = object.get("torrentWebSeeds").cloned();
|
||||
object.insert("torrentWebSeeds".to_string(), next_seeds.clone());
|
||||
changed = true;
|
||||
}
|
||||
next.push(
|
||||
serde_json::to_string(&value)
|
||||
.map_err(|error| format!("failed to encode persisted download: {error}"))?,
|
||||
);
|
||||
}
|
||||
if !changed {
|
||||
return Err("download is not persisted".to_string());
|
||||
}
|
||||
let next_data = serde_json::to_string(&next)
|
||||
.map_err(|error| format!("failed to encode persisted downloads: {error}"))?;
|
||||
crate::db::replace_downloads(&mut connection, &next_data, database.is_portable())?;
|
||||
Ok(previous_seeds)
|
||||
}
|
||||
|
||||
fn restore_persisted_torrent_web_seeds(
|
||||
database: &crate::db::DbState,
|
||||
id: &str,
|
||||
expected_seeds: &[crate::ipc::TorrentWebSeed],
|
||||
previous_seeds: Option<serde_json::Value>,
|
||||
) -> Result<(), String> {
|
||||
let mut connection = database.lock()?;
|
||||
let records = crate::db::load_downloads(&connection)?;
|
||||
let expected_value = serde_json::to_value(expected_seeds)
|
||||
.map_err(|error| format!("failed to encode expected Torrent web seeds: {error}"))?;
|
||||
let mut found = false;
|
||||
let mut changed = false;
|
||||
let mut next = Vec::with_capacity(records.len());
|
||||
for record in records {
|
||||
let mut value: serde_json::Value = match serde_json::from_str(&record) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
next.push(record);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if value
|
||||
.get("id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(id)
|
||||
{
|
||||
found = true;
|
||||
let object = value
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "persisted download is not an object".to_string())?;
|
||||
if object.get("torrentWebSeeds") == Some(&expected_value) {
|
||||
match previous_seeds.clone() {
|
||||
Some(previous) => {
|
||||
object.insert("torrentWebSeeds".to_string(), previous);
|
||||
}
|
||||
None => {
|
||||
object.remove("torrentWebSeeds");
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
} else {
|
||||
log::warn!(
|
||||
"Torrent web-seed rollback [{}] skipped because persisted state changed concurrently",
|
||||
id
|
||||
);
|
||||
}
|
||||
}
|
||||
next.push(
|
||||
serde_json::to_string(&value)
|
||||
.map_err(|error| format!("failed to encode persisted download: {error}"))?,
|
||||
);
|
||||
}
|
||||
if !found {
|
||||
return Err("download is no longer persisted".to_string());
|
||||
}
|
||||
if changed {
|
||||
let next_data = serde_json::to_string(&next)
|
||||
.map_err(|error| format!("failed to encode persisted downloads: {error}"))?;
|
||||
crate::db::replace_downloads(&mut connection, &next_data, database.is_portable())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn normalize_persisted_torrent_web_seeds(
|
||||
database: &crate::db::DbState,
|
||||
app_handle: &tauri::AppHandle,
|
||||
id: &str,
|
||||
seeds: &[crate::ipc::TorrentWebSeed],
|
||||
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
|
||||
let record = {
|
||||
let connection = database.lock()?;
|
||||
crate::db::load_downloads(&connection)?
|
||||
.into_iter()
|
||||
.find_map(|record| {
|
||||
serde_json::from_str::<crate::ipc::DownloadItem>(&record)
|
||||
.ok()
|
||||
.filter(|item| item.id == id)
|
||||
})
|
||||
.ok_or_else(|| "download is not persisted".to_string())?
|
||||
};
|
||||
let path = record
|
||||
.torrent_path
|
||||
.as_deref()
|
||||
.ok_or_else(|| "Torrent metadata is unavailable for web-seed management".to_string())?;
|
||||
let path = crate::torrent::validate_managed_torrent_path(app_handle, id, path)?;
|
||||
let bytes = tokio::fs::read(path)
|
||||
.await
|
||||
.map_err(|error| format!("could not read cached Torrent metadata: {error}"))?;
|
||||
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
|
||||
crate::queue::normalize_torrent_web_seeds(Some(seeds), &metadata.files)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_torrent_web_seeds(
|
||||
database: tauri::State<'_, crate::db::DbState>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
|
||||
if state.queue_manager.is_registered(&id).await
|
||||
&& matches!(state.queue_manager.active_kind(&id).await, Some(crate::queue::TaskKind::Aria2))
|
||||
{
|
||||
return state.queue_manager.get_aria2_torrent_web_seeds(&id).await;
|
||||
}
|
||||
let persisted_seeds = {
|
||||
let connection = database.lock()?;
|
||||
crate::db::load_downloads(&connection)?
|
||||
.into_iter()
|
||||
.find_map(|record| {
|
||||
serde_json::from_str::<crate::ipc::DownloadItem>(&record)
|
||||
.ok()
|
||||
.filter(|item| item.id == id)
|
||||
.map(|item| item.torrent_web_seeds)
|
||||
})
|
||||
.ok_or_else(|| "download is not persisted".to_string())?
|
||||
};
|
||||
let Some(seeds) = persisted_seeds else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if seeds.is_empty() {
|
||||
return Ok(seeds);
|
||||
}
|
||||
normalize_persisted_torrent_web_seeds(
|
||||
database.inner(),
|
||||
&state.queue_manager.app_handle(),
|
||||
&id,
|
||||
&seeds,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_torrent_web_seeds(
|
||||
database: tauri::State<'_, crate::db::DbState>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
seeds: Vec<crate::ipc::TorrentWebSeed>,
|
||||
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
|
||||
let active = state.queue_manager.is_registered(&id).await
|
||||
&& matches!(state.queue_manager.active_kind(&id).await, Some(crate::queue::TaskKind::Aria2));
|
||||
let normalized = if active {
|
||||
state
|
||||
.queue_manager
|
||||
.normalize_aria2_torrent_web_seeds(&id, &seeds)
|
||||
.await?
|
||||
} else {
|
||||
normalize_persisted_torrent_web_seeds(
|
||||
database.inner(),
|
||||
&state.queue_manager.app_handle(),
|
||||
&id,
|
||||
&seeds,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
let previous_seeds =
|
||||
replace_persisted_torrent_web_seeds(database.inner(), &id, &normalized)?;
|
||||
// The download can cross the queued/active boundary while metadata is
|
||||
// being normalized and persistence is updated. Recheck before returning
|
||||
// so a newly active Torrent receives the live Aria2 change instead of
|
||||
// waiting for a restart to apply its persisted value.
|
||||
let active_now = state.queue_manager.is_registered(&id).await
|
||||
&& matches!(state.queue_manager.active_kind(&id).await, Some(crate::queue::TaskKind::Aria2));
|
||||
if !active_now {
|
||||
return Ok(normalized);
|
||||
}
|
||||
match state
|
||||
.queue_manager
|
||||
.set_aria2_torrent_web_seeds(&id, normalized.clone())
|
||||
.await
|
||||
{
|
||||
Ok((result, previous_live_seeds)) => {
|
||||
if let Err(persist_error) =
|
||||
replace_persisted_torrent_web_seeds(database.inner(), &id, &result)
|
||||
{
|
||||
// The live operation succeeded, but the durable value must
|
||||
// remain transactional. Try to restore the exact value that
|
||||
// was persisted before this command before reporting the
|
||||
// persistence failure to the caller.
|
||||
if let Err(rollback_error) = state
|
||||
.queue_manager
|
||||
.set_aria2_torrent_web_seeds(&id, previous_live_seeds)
|
||||
.await
|
||||
{
|
||||
log::error!(
|
||||
"Torrent web-seed live rollback [{}] failed after persistence error: {}",
|
||||
id,
|
||||
rollback_error
|
||||
);
|
||||
}
|
||||
if let Err(restore_error) = restore_persisted_torrent_web_seeds(
|
||||
database.inner(),
|
||||
&id,
|
||||
&normalized,
|
||||
previous_seeds.clone(),
|
||||
) {
|
||||
log::error!(
|
||||
"Torrent web-seed persistence rollback [{}] failed: {}",
|
||||
id,
|
||||
restore_error
|
||||
);
|
||||
}
|
||||
return Err(format!(
|
||||
"Torrent web seeds changed live but could not be persisted: {persist_error}"
|
||||
));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
Err(error) => {
|
||||
if let Err(restore_error) = restore_persisted_torrent_web_seeds(
|
||||
database.inner(),
|
||||
&id,
|
||||
&normalized,
|
||||
previous_seeds,
|
||||
) {
|
||||
log::error!("Torrent web-seed rollback [{}] failed: {}", id, restore_error);
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_speed_limit_for_aria2(limit: &str) -> Option<String> {
|
||||
let trimmed = limit.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -6568,6 +6879,25 @@ fn apply_aria2_torrent_network_options(
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_aria2_torrent_dht_paths(
|
||||
command: &mut std::process::Command,
|
||||
dht_path: &std::path::Path,
|
||||
dht6_path: &std::path::Path,
|
||||
) {
|
||||
command
|
||||
.arg(format!("--dht-file-path={}", dht_path.display()))
|
||||
.arg(format!("--dht-file-path6={}", dht6_path.display()));
|
||||
}
|
||||
|
||||
fn apply_aria2_torrent_dht_options(
|
||||
command: &mut std::process::Command,
|
||||
message_timeout: u32,
|
||||
) {
|
||||
let timeout = queue::normalize_torrent_dht_message_timeout(message_timeout)
|
||||
.unwrap_or(queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT);
|
||||
command.arg(format!("--dht-message-timeout={timeout}"));
|
||||
}
|
||||
|
||||
fn apply_aria2_torrent_peer_identity_options(
|
||||
command: &mut std::process::Command,
|
||||
peer_id_prefix: &str,
|
||||
@@ -7255,8 +7585,12 @@ fn db_save_settings(
|
||||
let prevent_system_sleep = decoded.prevents_sleep_while_downloading;
|
||||
let prevent_display_sleep = decoded.prevents_display_sleep_while_downloading;
|
||||
if let Ok(mut cached) = app_state.scheduler_settings.write() {
|
||||
*cached = Some(decoded);
|
||||
*cached = Some(decoded.clone());
|
||||
}
|
||||
app_state.queue_manager.configure_seed_capacity(
|
||||
decoded.torrent_separate_seed_slots,
|
||||
decoded.torrent_max_concurrent_seeds,
|
||||
);
|
||||
if let Err(error) = app_state
|
||||
.power_manager
|
||||
.set_preferences(prevent_system_sleep, prevent_display_sleep)
|
||||
@@ -7782,6 +8116,8 @@ mod tests {
|
||||
apply_aria2_torrent_network_options,
|
||||
apply_aria2_torrent_peer_identity_options,
|
||||
apply_aria2_torrent_peer_discovery_options,
|
||||
apply_aria2_torrent_dht_paths,
|
||||
apply_aria2_torrent_dht_options,
|
||||
aria2_rpc_port_is_occupied,
|
||||
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
|
||||
redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value,
|
||||
@@ -7834,6 +8170,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_torrent_dht_paths_are_explicit_and_owned_by_firelink() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dht_path = root.path().join("aria2/dht.dat");
|
||||
let dht6_path = root.path().join("aria2/dht6.dat");
|
||||
let mut command = std::process::Command::new("aria2c");
|
||||
|
||||
apply_aria2_torrent_dht_paths(&mut command, &dht_path, &dht6_path);
|
||||
|
||||
assert_eq!(
|
||||
command
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
format!("--dht-file-path={}", dht_path.display()),
|
||||
format!("--dht-file-path6={}", dht6_path.display()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_torrent_dht_message_timeout_is_bounded_and_launch_scoped() {
|
||||
let mut command = std::process::Command::new("aria2c");
|
||||
apply_aria2_torrent_dht_options(&mut command, 42);
|
||||
assert_eq!(
|
||||
command
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["--dht-message-timeout=42"]
|
||||
);
|
||||
|
||||
let mut command = std::process::Command::new("aria2c");
|
||||
apply_aria2_torrent_dht_options(&mut command, 0);
|
||||
assert_eq!(
|
||||
command
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["--dht-message-timeout=10"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_torrent_global_options_are_bounded_and_explicit() {
|
||||
let mut command = std::process::Command::new("aria2c");
|
||||
@@ -10466,6 +10846,12 @@ pub fn run() {
|
||||
|
||||
let database = crate::db::init(&storage_layout)
|
||||
.map_err(|error| format!("failed to initialize persistence: {error}"))?;
|
||||
// Establish Firelink-owned Aria2 routing-table paths after the
|
||||
// existing data-root initializer has created the selected storage
|
||||
// directory, but before the daemon launcher is scheduled. A
|
||||
// conflict must fail startup; silently allowing Aria2 to fall back
|
||||
// to a user-global dht.dat would escape the storage boundary.
|
||||
let aria2_dht_paths = storage_layout.prepare_aria2_dht_paths()?;
|
||||
if let Err(error) = crate::torrent::remove_orphaned_probe_dirs(app.handle()) {
|
||||
log::warn!("could not remove orphaned torrent probes: {error}");
|
||||
}
|
||||
@@ -10564,6 +10950,12 @@ pub fn run() {
|
||||
let scheduler_settings = Arc::new(RwLock::new(persisted_settings.clone()));
|
||||
|
||||
let queue_manager = Arc::new(queue::QueueManager::new(app.handle().clone(), max_concurrent));
|
||||
if let Some(settings) = persisted_settings.as_ref() {
|
||||
queue_manager.configure_seed_capacity(
|
||||
settings.torrent_separate_seed_slots,
|
||||
settings.torrent_max_concurrent_seeds,
|
||||
);
|
||||
}
|
||||
let power_manager = queue_manager.power_manager();
|
||||
if let Some(settings) = persisted_settings.as_ref() {
|
||||
let _ = power_manager.set_preferences(
|
||||
@@ -10723,6 +11115,15 @@ pub fn run() {
|
||||
torrent_max_open_files,
|
||||
Some(&torrent_overall_upload_limit),
|
||||
);
|
||||
apply_aria2_torrent_dht_paths(
|
||||
&mut cmd,
|
||||
&aria2_dht_paths.0,
|
||||
&aria2_dht_paths.1,
|
||||
);
|
||||
apply_aria2_torrent_dht_options(
|
||||
&mut cmd,
|
||||
torrent_startup_settings.dht_message_timeout,
|
||||
);
|
||||
|
||||
apply_aria2_torrent_peer_discovery_options(
|
||||
&mut cmd,
|
||||
@@ -11332,7 +11733,7 @@ pub fn run() {
|
||||
authorize_keychain_access,
|
||||
acknowledge_pairing_token_change,
|
||||
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
||||
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, get_torrent_peers, set_torrent_max_open_files, set_torrent_overall_upload_limit, set_global_speed_limit, remove_download, get_download_primary_path,
|
||||
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, get_torrent_peers, get_torrent_file_progress, get_torrent_piece_progress, get_torrent_web_seeds, set_torrent_web_seeds, set_torrent_max_open_files, set_torrent_overall_upload_limit, set_global_speed_limit, remove_download, get_download_primary_path,
|
||||
detach_download_for_reconfigure,
|
||||
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
|
||||
commands::reveal_in_file_manager, commands::open_downloaded_file,
|
||||
|
||||
+1840
-10
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ pub struct TorrentStartupSettings {
|
||||
pub lpd_interface: String,
|
||||
pub peer_id_prefix: String,
|
||||
pub peer_agent: String,
|
||||
pub dht_message_timeout: u32,
|
||||
}
|
||||
|
||||
fn normalize_torrent_startup_value(
|
||||
@@ -85,6 +86,10 @@ pub fn torrent_startup_settings(settings: Option<&PersistedSettings>) -> Torrent
|
||||
&settings.torrent_peer_agent,
|
||||
crate::queue::normalize_torrent_peer_agent,
|
||||
),
|
||||
dht_message_timeout: crate::queue::normalize_torrent_dht_message_timeout(
|
||||
settings.torrent_dht_message_timeout,
|
||||
)
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +150,32 @@ pub fn canonicalize_torrent_network_settings(stored: &str) -> Result<String, Str
|
||||
canonicalize_torrent_network_value(state, "torrentLpdInterface", crate::queue::normalize_torrent_lpd_interface);
|
||||
canonicalize_torrent_network_value(state, "torrentPeerIdPrefix", crate::queue::normalize_torrent_peer_id_prefix);
|
||||
canonicalize_torrent_network_value(state, "torrentPeerAgent", crate::queue::normalize_torrent_peer_agent);
|
||||
let dht_message_timeout = state
|
||||
.get("torrentDhtMessageTimeout")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.and_then(|value| crate::queue::normalize_torrent_dht_message_timeout(value).ok())
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT);
|
||||
state.insert(
|
||||
"torrentDhtMessageTimeout".to_string(),
|
||||
Value::Number(serde_json::Number::from(dht_message_timeout)),
|
||||
);
|
||||
let max_concurrent_seeds = state
|
||||
.get("torrentMaxConcurrentSeeds")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.and_then(|value| crate::queue::normalize_torrent_max_concurrent_seeds(value).ok())
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS);
|
||||
state.insert(
|
||||
"torrentMaxConcurrentSeeds".to_string(),
|
||||
Value::Number(serde_json::Number::from(max_concurrent_seeds)),
|
||||
);
|
||||
if !state
|
||||
.get("torrentSeparateSeedSlots")
|
||||
.is_some_and(Value::is_boolean)
|
||||
{
|
||||
state.insert("torrentSeparateSeedSlots".to_string(), Value::Bool(false));
|
||||
}
|
||||
serde_json::to_string(&document)
|
||||
.map_err(|error| format!("failed to encode canonical settings: {error}"))
|
||||
}
|
||||
@@ -314,11 +345,26 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
|
||||
.contains(&value)
|
||||
})
|
||||
});
|
||||
sanitize_integer_setting(state, "torrentDhtMessageTimeout", |value| {
|
||||
value.as_u64().and_then(|value| u32::try_from(value).ok()).is_some_and(|value| {
|
||||
(crate::queue::MIN_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
..=crate::queue::MAX_TORRENT_DHT_MESSAGE_TIMEOUT)
|
||||
.contains(&value)
|
||||
})
|
||||
});
|
||||
sanitize_integer_setting(state, "torrentMaxConcurrentSeeds", |value| {
|
||||
value.as_u64().and_then(|value| u32::try_from(value).ok()).is_some_and(|value| {
|
||||
(crate::queue::MIN_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
..=crate::queue::MAX_TORRENT_MAX_CONCURRENT_SEEDS)
|
||||
.contains(&value)
|
||||
})
|
||||
});
|
||||
for key in [
|
||||
"torrentEnableDht",
|
||||
"torrentEnableDht6",
|
||||
"torrentEnablePex",
|
||||
"torrentEnableLpd",
|
||||
"torrentSeparateSeedSlots",
|
||||
] {
|
||||
sanitize_boolean_setting(state, key);
|
||||
}
|
||||
@@ -483,6 +529,14 @@ fn validate_settings(settings: &mut PersistedSettings) {
|
||||
settings.torrent_max_open_files,
|
||||
)
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES);
|
||||
settings.torrent_dht_message_timeout = crate::queue::normalize_torrent_dht_message_timeout(
|
||||
settings.torrent_dht_message_timeout,
|
||||
)
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT);
|
||||
settings.torrent_max_concurrent_seeds = crate::queue::normalize_torrent_max_concurrent_seeds(
|
||||
settings.torrent_max_concurrent_seeds,
|
||||
)
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS);
|
||||
settings.torrent_listen_port = crate::queue::normalize_torrent_port_spec(
|
||||
Some(&settings.torrent_listen_port),
|
||||
"TCP listen ports",
|
||||
@@ -735,6 +789,9 @@ fn default_settings() -> PersistedSettings {
|
||||
torrent_enable_pex: true,
|
||||
torrent_enable_lpd: false,
|
||||
torrent_max_open_files: crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES,
|
||||
torrent_dht_message_timeout: crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
|
||||
torrent_separate_seed_slots: false,
|
||||
torrent_max_concurrent_seeds: crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
|
||||
torrent_listen_port: String::new(),
|
||||
torrent_dht_listen_port: String::new(),
|
||||
torrent_external_ip: String::new(),
|
||||
@@ -1085,6 +1142,10 @@ mod tests {
|
||||
settings.torrent_max_open_files,
|
||||
crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES
|
||||
);
|
||||
assert_eq!(
|
||||
settings.torrent_dht_message_timeout,
|
||||
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
);
|
||||
assert!(settings.torrent_listen_port.is_empty());
|
||||
assert!(settings.torrent_dht_listen_port.is_empty());
|
||||
assert!(settings.torrent_external_ip.is_empty());
|
||||
@@ -1139,7 +1200,10 @@ mod tests {
|
||||
"torrentListenPort": " 6881-6999 ",
|
||||
"torrentExternalIp": "not-an-ip",
|
||||
"torrentPeerIdPrefix": "123456789012345678901",
|
||||
"torrentPeerAgent": " Firelink/1.3.1 "
|
||||
"torrentPeerAgent": " Firelink/1.3.1 ",
|
||||
"torrentDhtMessageTimeout": 601,
|
||||
"torrentMaxConcurrentSeeds": 65,
|
||||
"torrentSeparateSeedSlots": "yes"
|
||||
},
|
||||
"version": 6
|
||||
});
|
||||
@@ -1150,6 +1214,15 @@ mod tests {
|
||||
assert_eq!(canonical["state"]["torrentExternalIp"], "");
|
||||
assert_eq!(canonical["state"]["torrentPeerIdPrefix"], "");
|
||||
assert_eq!(canonical["state"]["torrentPeerAgent"], "Firelink/1.3.1");
|
||||
assert_eq!(
|
||||
canonical["state"]["torrentDhtMessageTimeout"],
|
||||
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
);
|
||||
assert_eq!(
|
||||
canonical["state"]["torrentMaxConcurrentSeeds"],
|
||||
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
);
|
||||
assert_eq!(canonical["state"]["torrentSeparateSeedSlots"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1166,12 +1239,21 @@ mod tests {
|
||||
assert!(startup.listen_port.is_empty());
|
||||
assert!(startup.peer_id_prefix.is_empty());
|
||||
assert_eq!(startup.peer_agent, "Firelink/1.3.1");
|
||||
assert_eq!(
|
||||
startup.dht_message_timeout,
|
||||
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opt_in_defaults_match_the_frontend_defaults() {
|
||||
assert!(!default_settings().play_completion_sound);
|
||||
assert!(!default_settings().auto_add_clipboard_links);
|
||||
assert!(!default_settings().torrent_separate_seed_slots);
|
||||
assert_eq!(
|
||||
default_settings().torrent_max_concurrent_seeds,
|
||||
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+113
-1
@@ -5,6 +5,9 @@ pub const PORTABLE_MARKER: &str = "portable.flag";
|
||||
const PORTABLE_DATA_DIR: &str = "data";
|
||||
const PORTABLE_LOG_DIR: &str = "logs";
|
||||
const PORTABLE_WEBVIEW_DIR: &str = "webview";
|
||||
const ARIA2_DATA_DIR: &str = "aria2";
|
||||
const ARIA2_DHT_FILE: &str = "dht.dat";
|
||||
const ARIA2_DHT6_FILE: &str = "dht6.dat";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StorageMode {
|
||||
@@ -104,6 +107,59 @@ impl StorageLayout {
|
||||
pub fn webview_dir(&self) -> &Path {
|
||||
&self.webview_dir
|
||||
}
|
||||
|
||||
pub fn aria2_dht_paths(&self) -> (PathBuf, PathBuf) {
|
||||
let directory = self.data_dir.join(ARIA2_DATA_DIR);
|
||||
(
|
||||
directory.join(ARIA2_DHT_FILE),
|
||||
directory.join(ARIA2_DHT6_FILE),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create and validate only Firelink's Aria2 state directory. Aria2 owns
|
||||
/// the table contents; Firelink owns this exact location and must never
|
||||
/// fall back to a user-global default when it cannot establish it.
|
||||
pub fn prepare_aria2_dht_paths(&self) -> Result<(PathBuf, PathBuf), String> {
|
||||
let directory = self.data_dir.join(ARIA2_DATA_DIR);
|
||||
if crate::path_has_symlink_component(&directory) {
|
||||
return Err(format!(
|
||||
"Aria2 state directory contains a symlink: '{}'",
|
||||
directory.display()
|
||||
));
|
||||
}
|
||||
|
||||
match std::fs::symlink_metadata(&directory) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err(format!(
|
||||
"Aria2 state directory is a symlink: '{}'",
|
||||
directory.display()
|
||||
));
|
||||
}
|
||||
Ok(metadata) if !metadata.is_dir() => {
|
||||
return Err(format!(
|
||||
"Aria2 state path is not a directory: '{}'",
|
||||
directory.display()
|
||||
));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
std::fs::create_dir(&directory).map_err(|error| {
|
||||
format!(
|
||||
"failed to create Aria2 state directory '{}': {error}",
|
||||
directory.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"failed to inspect Aria2 state directory '{}': {error}",
|
||||
directory.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(self.aria2_dht_paths())
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
|
||||
@@ -154,7 +210,7 @@ fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{canonicalize_storage_path, StorageMode, PORTABLE_MARKER};
|
||||
use super::{canonicalize_storage_path, StorageLayout, StorageMode, PORTABLE_MARKER};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
@@ -182,6 +238,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn test_layout(data_dir: &Path) -> StorageLayout {
|
||||
let data_dir = fs::canonicalize(data_dir).unwrap();
|
||||
StorageLayout {
|
||||
mode: StorageMode::Standard,
|
||||
data_dir: data_dir.clone(),
|
||||
log_dir: data_dir.join("logs"),
|
||||
webview_dir: data_dir.join("webview"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_dht_paths_are_owned_by_the_selected_data_directory() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let layout = test_layout(root.path());
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
layout.aria2_dht_paths(),
|
||||
(
|
||||
root_path.join("aria2/dht.dat"),
|
||||
root_path.join("aria2/dht6.dat")
|
||||
)
|
||||
);
|
||||
let prepared = layout.prepare_aria2_dht_paths().unwrap();
|
||||
assert_eq!(prepared, layout.aria2_dht_paths());
|
||||
assert!(root_path.join("aria2").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_dht_preparation_rejects_a_file_at_the_directory_boundary() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
fs::write(root_path.join("aria2"), b"not a directory").unwrap();
|
||||
|
||||
let error = test_layout(root.path())
|
||||
.prepare_aria2_dht_paths()
|
||||
.unwrap_err();
|
||||
assert!(error.contains("not a directory"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn aria2_dht_preparation_rejects_a_symlinked_directory() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
let target = TempDir::new().unwrap();
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
symlink(target.path(), root_path.join("aria2")).unwrap();
|
||||
|
||||
let error = test_layout(root.path())
|
||||
.prepare_aria2_dht_paths()
|
||||
.unwrap_err();
|
||||
assert!(error.contains("symlink"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_symlinked_storage_directories() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DownloadCategory } from "./DownloadCategory";
|
||||
import type { DownloadStatus } from "./DownloadStatus";
|
||||
import type { TorrentWebSeed } from "./TorrentWebSeed";
|
||||
|
||||
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, };
|
||||
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, torrentSeedRemaining?: number, torrentWebSeeds?: Array<TorrentWebSeed>, 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.
|
||||
|
||||
export type DownloadStateEvent = { id: string, status: string, error: string | null, fileName?: string, };
|
||||
export type DownloadStateEvent = { id: string, status: string, error: string | null, fileName?: string, torrentSeedRemaining?: 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.
|
||||
|
||||
export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "paused" | "completed" | "failed" | "queued" | "retrying";
|
||||
export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "waitingToSeed" | "paused" | "completed" | "failed" | "queued" | "retrying";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { TorrentWebSeed } from "./TorrentWebSeed";
|
||||
|
||||
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, };
|
||||
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_seed_remaining?: number, torrent_web_seeds?: Array<TorrentWebSeed>, 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 { 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, torrentOverallUploadLimit: 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, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, 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, torrentOverallUploadLimit: 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, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, 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 TorrentFileProgress = { index: number, relativePath: string, length: number, completedLength: number, selected: 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 { TorrentFileProgress } from "./TorrentFileProgress";
|
||||
|
||||
export type TorrentFileProgressSnapshot = { files: Array<TorrentFileProgress>, };
|
||||
@@ -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 TorrentPieceProgressSnapshot = { pieceLength: number, numPieces: number, completedPieces: number, buckets: Array<number>, };
|
||||
@@ -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 TorrentWebSeed = { fileIndex: number, uri: string, };
|
||||
@@ -4,6 +4,9 @@ import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics';
|
||||
import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot';
|
||||
import type { TorrentPieceProgressSnapshot } from '../bindings/TorrentPieceProgressSnapshot';
|
||||
import type { TorrentWebSeed } from '../bindings/TorrentWebSeed';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
@@ -42,6 +45,9 @@ const formatLastTry = (
|
||||
const isPeerDiagnosticsStatus = (status: string): boolean =>
|
||||
['downloading', 'seeding', 'retrying'].includes(status);
|
||||
|
||||
const isTorrentFileProgressStatus = (status: string): boolean =>
|
||||
['downloading', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status);
|
||||
|
||||
const formatPeerSpeed = (bytesPerSecond: number): string =>
|
||||
`${formatDownloadBytes(bytesPerSecond)}/s`;
|
||||
|
||||
@@ -99,9 +105,18 @@ export const PropertiesModal = () => {
|
||||
const [torrentPeerDiagnostics, setTorrentPeerDiagnostics] = useState<TorrentPeerDiagnostics | null>(null);
|
||||
const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false);
|
||||
const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false);
|
||||
const [torrentFileProgress, setTorrentFileProgress] = useState<TorrentFileProgressSnapshot | null>(null);
|
||||
const [torrentFileProgressError, setTorrentFileProgressError] = useState(false);
|
||||
const [isTorrentFileProgressPending, setIsTorrentFileProgressPending] = useState(false);
|
||||
const [torrentPieceProgress, setTorrentPieceProgress] = useState<TorrentPieceProgressSnapshot | null>(null);
|
||||
const [torrentPieceProgressError, setTorrentPieceProgressError] = useState(false);
|
||||
const [isTorrentPieceProgressPending, setIsTorrentPieceProgressPending] = useState(false);
|
||||
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
|
||||
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
|
||||
const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false);
|
||||
const [torrentWebSeedsText, setTorrentWebSeedsText] = useState('');
|
||||
const [torrentWebSeedsError, setTorrentWebSeedsError] = useState(false);
|
||||
const [isTorrentWebSeedsPending, setIsTorrentWebSeedsPending] = useState(false);
|
||||
|
||||
const [loginMode, setLoginMode] = useState<LoginMode>('matching');
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -119,6 +134,9 @@ export const PropertiesModal = () => {
|
||||
const [isPauseResumePending, setIsPauseResumePending] = useState(false);
|
||||
const actionRequestRef = useRef(0);
|
||||
const peerDiagnosticsRequestRef = useRef(0);
|
||||
const torrentFileProgressRequestRef = useRef(0);
|
||||
const torrentPieceProgressRequestRef = useRef(0);
|
||||
const torrentWebSeedsRequestRef = useRef(0);
|
||||
const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -132,6 +150,17 @@ export const PropertiesModal = () => {
|
||||
setTorrentPeerDiagnostics(null);
|
||||
setTorrentPeerDiagnosticsError(false);
|
||||
setIsTorrentPeerDiagnosticsPending(false);
|
||||
torrentFileProgressRequestRef.current += 1;
|
||||
setTorrentFileProgress(null);
|
||||
setTorrentFileProgressError(false);
|
||||
setIsTorrentFileProgressPending(false);
|
||||
torrentPieceProgressRequestRef.current += 1;
|
||||
setTorrentPieceProgress(null);
|
||||
setTorrentPieceProgressError(false);
|
||||
setIsTorrentPieceProgressPending(false);
|
||||
torrentWebSeedsRequestRef.current += 1;
|
||||
setTorrentWebSeedsError(false);
|
||||
setIsTorrentWebSeedsPending(false);
|
||||
}, [selectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -205,6 +234,9 @@ export const PropertiesModal = () => {
|
||||
setTorrentTrackerInterval(activeItem.torrentTrackerInterval === undefined ? '0' : String(activeItem.torrentTrackerInterval));
|
||||
setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout));
|
||||
setTorrentPrioritizePiece(activeItem.torrentPrioritizePiece || '');
|
||||
setTorrentWebSeedsText((activeItem.torrentWebSeeds || [])
|
||||
.map(seed => `${seed.fileIndex}|${seed.uri}`)
|
||||
.join('\n'));
|
||||
setErrorMessage('');
|
||||
} else {
|
||||
setSelectedPropertiesDownloadId(null);
|
||||
@@ -229,6 +261,120 @@ export const PropertiesModal = () => {
|
||||
setIsTorrentPeerDiagnosticsPending(false);
|
||||
}, [item?.id, item?.isTorrent, item?.lastTry, item?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
torrentFileProgressRequestRef.current += 1;
|
||||
setTorrentFileProgress(null);
|
||||
setTorrentFileProgressError(false);
|
||||
setIsTorrentFileProgressPending(false);
|
||||
if (
|
||||
!selectedPropertiesDownloadId
|
||||
|| !item?.isTorrent
|
||||
|| !isTorrentFileProgressStatus(item.status)
|
||||
) return;
|
||||
|
||||
const requestId = torrentFileProgressRequestRef.current;
|
||||
const propertiesDownloadId = item.id;
|
||||
setIsTorrentFileProgressPending(true);
|
||||
void invoke('get_torrent_file_progress', { id: propertiesDownloadId })
|
||||
.then(snapshot => {
|
||||
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
|
||||
if (
|
||||
requestId === torrentFileProgressRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
||||
&& currentItem?.isTorrent
|
||||
&& isTorrentFileProgressStatus(currentItem.status)
|
||||
) {
|
||||
setTorrentFileProgress(snapshot);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
|
||||
if (
|
||||
requestId === torrentFileProgressRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
||||
&& currentItem?.isTorrent
|
||||
&& isTorrentFileProgressStatus(currentItem.status)
|
||||
) {
|
||||
setTorrentFileProgressError(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId === torrentFileProgressRequestRef.current) {
|
||||
setIsTorrentFileProgressPending(false);
|
||||
}
|
||||
});
|
||||
}, [item?.id, item?.isTorrent, item?.lastTry, item?.status, selectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
torrentWebSeedsRequestRef.current += 1;
|
||||
setTorrentWebSeedsError(false);
|
||||
setIsTorrentWebSeedsPending(false);
|
||||
if (!selectedPropertiesDownloadId || !item?.isTorrent || !isTorrentFileProgressStatus(item.status)) return;
|
||||
const requestId = torrentWebSeedsRequestRef.current;
|
||||
const propertiesDownloadId = item.id;
|
||||
setIsTorrentWebSeedsPending(true);
|
||||
void invoke('get_torrent_web_seeds', { id: propertiesDownloadId })
|
||||
.then(seeds => {
|
||||
if (
|
||||
requestId === torrentWebSeedsRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
||||
) {
|
||||
setTorrentWebSeedsText(seeds.map(seed => `${seed.fileIndex}|${seed.uri}`).join('\n'));
|
||||
useDownloadStore.getState().updateDownload(propertiesDownloadId, { torrentWebSeeds: seeds });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestId === torrentWebSeedsRequestRef.current) setTorrentWebSeedsError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId === torrentWebSeedsRequestRef.current) setIsTorrentWebSeedsPending(false);
|
||||
});
|
||||
}, [item?.id, item?.isTorrent, item?.status, selectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
torrentPieceProgressRequestRef.current += 1;
|
||||
setTorrentPieceProgress(null);
|
||||
setTorrentPieceProgressError(false);
|
||||
setIsTorrentPieceProgressPending(false);
|
||||
if (
|
||||
!selectedPropertiesDownloadId
|
||||
|| !item?.isTorrent
|
||||
|| !isTorrentFileProgressStatus(item.status)
|
||||
) return;
|
||||
|
||||
const requestId = torrentPieceProgressRequestRef.current;
|
||||
const propertiesDownloadId = item.id;
|
||||
setIsTorrentPieceProgressPending(true);
|
||||
void invoke('get_torrent_piece_progress', { id: propertiesDownloadId })
|
||||
.then(snapshot => {
|
||||
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
|
||||
if (
|
||||
requestId === torrentPieceProgressRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
||||
&& currentItem?.isTorrent
|
||||
&& isTorrentFileProgressStatus(currentItem.status)
|
||||
) {
|
||||
setTorrentPieceProgress(snapshot);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
|
||||
if (
|
||||
requestId === torrentPieceProgressRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
||||
&& currentItem?.isTorrent
|
||||
&& isTorrentFileProgressStatus(currentItem.status)
|
||||
) {
|
||||
setTorrentPieceProgressError(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId === torrentPieceProgressRequestRef.current) {
|
||||
setIsTorrentPieceProgressPending(false);
|
||||
}
|
||||
});
|
||||
}, [item?.id, item?.isTorrent, item?.lastTry, item?.status, selectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
setLiveTorrentMaxPeersValue(
|
||||
item?.torrentMaxPeers === undefined ? '' : String(item.torrentMaxPeers)
|
||||
@@ -321,6 +467,108 @@ export const PropertiesModal = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshTorrentFileProgress = async () => {
|
||||
if (
|
||||
isTorrentFileProgressPending
|
||||
|| !item.isTorrent
|
||||
|| !isTorrentFileProgressStatus(item.status)
|
||||
) return;
|
||||
|
||||
const requestId = ++torrentFileProgressRequestRef.current;
|
||||
const propertiesDownloadId = item.id;
|
||||
setIsTorrentFileProgressPending(true);
|
||||
setTorrentFileProgressError(false);
|
||||
try {
|
||||
const snapshot = await invoke('get_torrent_file_progress', { id: propertiesDownloadId });
|
||||
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
|
||||
if (
|
||||
requestId === torrentFileProgressRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
||||
&& currentItem?.isTorrent
|
||||
&& isTorrentFileProgressStatus(currentItem.status)
|
||||
) {
|
||||
setTorrentFileProgress(snapshot);
|
||||
}
|
||||
} catch {
|
||||
if (
|
||||
requestId === torrentFileProgressRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
||||
) {
|
||||
setTorrentFileProgressError(true);
|
||||
setTorrentFileProgress(null);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === torrentFileProgressRequestRef.current) {
|
||||
setIsTorrentFileProgressPending(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshTorrentPieceProgress = async () => {
|
||||
if (
|
||||
isTorrentPieceProgressPending
|
||||
|| !item.isTorrent
|
||||
|| !isTorrentFileProgressStatus(item.status)
|
||||
) return;
|
||||
|
||||
const requestId = ++torrentPieceProgressRequestRef.current;
|
||||
const propertiesDownloadId = item.id;
|
||||
setIsTorrentPieceProgressPending(true);
|
||||
setTorrentPieceProgressError(false);
|
||||
try {
|
||||
const snapshot = await invoke('get_torrent_piece_progress', { id: propertiesDownloadId });
|
||||
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
|
||||
if (
|
||||
requestId === torrentPieceProgressRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
||||
&& currentItem?.isTorrent
|
||||
&& isTorrentFileProgressStatus(currentItem.status)
|
||||
) {
|
||||
setTorrentPieceProgress(snapshot);
|
||||
}
|
||||
} catch {
|
||||
if (
|
||||
requestId === torrentPieceProgressRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
||||
) {
|
||||
setTorrentPieceProgressError(true);
|
||||
setTorrentPieceProgress(null);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === torrentPieceProgressRequestRef.current) {
|
||||
setIsTorrentPieceProgressPending(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTorrentWebSeedsSave = async () => {
|
||||
if (!item?.isTorrent || isTorrentWebSeedsPending) return;
|
||||
const seeds: TorrentWebSeed[] = [];
|
||||
for (const line of torrentWebSeedsText.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const separator = trimmed.indexOf('|');
|
||||
const fileIndex = Number(separator >= 0 ? trimmed.slice(0, separator).trim() : '');
|
||||
const uri = separator >= 0 ? trimmed.slice(separator + 1).trim() : '';
|
||||
if (!Number.isInteger(fileIndex) || fileIndex < 0 || !uri) {
|
||||
setTorrentWebSeedsError(true);
|
||||
return;
|
||||
}
|
||||
seeds.push({ fileIndex, uri });
|
||||
}
|
||||
setIsTorrentWebSeedsPending(true);
|
||||
setTorrentWebSeedsError(false);
|
||||
try {
|
||||
const normalized = await invoke('set_torrent_web_seeds', { id: item.id, seeds });
|
||||
setTorrentWebSeedsText(normalized.map(seed => `${seed.fileIndex}|${seed.uri}`).join('\n'));
|
||||
useDownloadStore.getState().updateDownload(item.id, { torrentWebSeeds: normalized });
|
||||
} catch {
|
||||
setTorrentWebSeedsError(true);
|
||||
} finally {
|
||||
setIsTorrentWebSeedsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!url.trim()) {
|
||||
setErrorMessage(t($ => $.properties.enterValidUrl));
|
||||
@@ -857,6 +1105,127 @@ export const PropertiesModal = () => {
|
||||
<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.torrentPieceProgress)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRefreshTorrentPieceProgress()}
|
||||
disabled={!isTorrentFileProgressStatus(item.status) || isTorrentPieceProgressPending}
|
||||
className="app-button px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
{isTorrentPieceProgressPending
|
||||
? t($ => $.properties.torrentPieceProgressLoading)
|
||||
: t($ => $.properties.torrentPieceProgressRefresh)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{t($ => $.properties.torrentPieceProgressHint)}
|
||||
</p>
|
||||
{!isTorrentFileProgressStatus(item.status) && (
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{t($ => $.properties.torrentPieceProgressUnavailable)}
|
||||
</p>
|
||||
)}
|
||||
{torrentPieceProgressError && (
|
||||
<p className="text-[11px] text-red-400">
|
||||
{t($ => $.properties.torrentPieceProgressFailed)}
|
||||
</p>
|
||||
)}
|
||||
{torrentPieceProgress && (
|
||||
<>
|
||||
<div className="text-[11px] text-text-secondary">
|
||||
{t($ => $.properties.torrentPieceProgressSummary, {
|
||||
completed: torrentPieceProgress.completedPieces,
|
||||
total: torrentPieceProgress.numPieces,
|
||||
size: formatDownloadBytes(torrentPieceProgress.pieceLength),
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className="grid gap-0.5 rounded border border-border-modal/60 bg-bg-input p-1"
|
||||
style={{ gridTemplateColumns: `repeat(${Math.min(16, Math.max(1, torrentPieceProgress.buckets.length))}, minmax(0, 1fr))` }}
|
||||
role="img"
|
||||
aria-label={t($ => $.properties.torrentPieceProgressMap)}
|
||||
>
|
||||
{torrentPieceProgress.buckets.map((percentage, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="aspect-square min-w-1 rounded-sm bg-blue-500 motion-safe:transition-opacity motion-reduce:transition-none"
|
||||
style={{ opacity: Math.max(0.15, percentage / 100) }}
|
||||
title={`${percentage}%`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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.torrentFileProgress)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRefreshTorrentFileProgress()}
|
||||
disabled={!isTorrentFileProgressStatus(item.status) || isTorrentFileProgressPending}
|
||||
className="app-button px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
{isTorrentFileProgressPending
|
||||
? t($ => $.properties.torrentFileProgressLoading)
|
||||
: t($ => $.properties.torrentFileProgressRefresh)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{t($ => $.properties.torrentFileProgressHint)}
|
||||
</p>
|
||||
{!isTorrentFileProgressStatus(item.status) && (
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{t($ => $.properties.torrentFileProgressUnavailable)}
|
||||
</p>
|
||||
)}
|
||||
{torrentFileProgressError && (
|
||||
<p className="text-[11px] text-red-400">
|
||||
{t($ => $.properties.torrentFileProgressFailed)}
|
||||
</p>
|
||||
)}
|
||||
{torrentFileProgress && (
|
||||
<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.torrentFileProgressPath)}</th>
|
||||
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentFileProgressCompleted)}</th>
|
||||
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentFileProgressSelected)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{torrentFileProgress.files.map(file => {
|
||||
const percentage = file.length === 0
|
||||
? 100
|
||||
: Math.round((file.completedLength / file.length) * 100);
|
||||
return (
|
||||
<tr key={file.index} className="border-t border-border-modal/40 text-text-primary">
|
||||
<td className="px-2 py-1 font-mono">{file.index}</td>
|
||||
<td className="px-2 py-1 max-w-[220px] truncate" title={file.relativePath} dir="auto">{file.relativePath}</td>
|
||||
<td className="px-2 py-1 font-mono whitespace-nowrap">
|
||||
{formatDownloadBytes(file.completedLength)} / {formatDownloadBytes(file.length)} ({percentage}%)
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
{file.selected
|
||||
? t($ => $.properties.torrentFileProgressSelected)
|
||||
: t($ => $.properties.torrentFileProgressUnselected)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
@@ -1313,6 +1682,36 @@ export const PropertiesModal = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{item.isTorrent && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
|
||||
{t($ => $.properties.torrentWebSeeds)}
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted mb-2">{t($ => $.properties.torrentWebSeedsHint)}</p>
|
||||
<textarea
|
||||
value={torrentWebSeedsText}
|
||||
onChange={event => setTorrentWebSeedsText(event.target.value)}
|
||||
placeholder={t($ => $.properties.torrentWebSeedsPlaceholder)}
|
||||
disabled={isTorrentWebSeedsPending}
|
||||
aria-label={t($ => $.properties.torrentWebSeeds)}
|
||||
className="w-full h-20 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2 gap-2">
|
||||
<span className="text-xs text-red-500">
|
||||
{torrentWebSeedsError ? t($ => $.properties.torrentWebSeedsFailed) : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleTorrentWebSeedsSave()}
|
||||
disabled={isTorrentWebSeedsPending}
|
||||
className="app-button px-3 text-xs"
|
||||
>
|
||||
{isTorrentWebSeedsPending ? t($ => $.properties.torrentWebSeedsLoading) : t($ => $.properties.torrentWebSeedsApply)}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Advanced Transfer Section */}
|
||||
<section>
|
||||
<button
|
||||
|
||||
@@ -35,9 +35,17 @@ import { usePlatformInfo } from '../utils/platform';
|
||||
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
|
||||
import { normalizeCustomProxy } from '../store/useDownloadStore';
|
||||
import {
|
||||
DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
|
||||
MAX_TORRENT_DHT_MESSAGE_TIMEOUT,
|
||||
MIN_TORRENT_DHT_MESSAGE_TIMEOUT,
|
||||
DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
|
||||
MAX_TORRENT_MAX_CONCURRENT_SEEDS,
|
||||
MIN_TORRENT_MAX_CONCURRENT_SEEDS,
|
||||
MAX_TORRENT_MAX_OPEN_FILES,
|
||||
MIN_TORRENT_MAX_OPEN_FILES,
|
||||
normalizeSpeedLimitForBackend,
|
||||
normalizeTorrentDhtMessageTimeout,
|
||||
normalizeTorrentMaxConcurrentSeeds,
|
||||
normalizeTorrentMaxOpenFiles
|
||||
} from '../utils/downloads';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -327,6 +335,12 @@ const engineRunId = useRef(0);
|
||||
const [torrentMaxOpenFilesInput, setTorrentMaxOpenFilesInput] = useState(
|
||||
() => String(settings.torrentMaxOpenFiles)
|
||||
);
|
||||
const [torrentDhtMessageTimeoutInput, setTorrentDhtMessageTimeoutInput] = useState(
|
||||
() => String(settings.torrentDhtMessageTimeout)
|
||||
);
|
||||
const [torrentMaxConcurrentSeedsInput, setTorrentMaxConcurrentSeedsInput] = useState(
|
||||
() => String(settings.torrentMaxConcurrentSeeds)
|
||||
);
|
||||
const [torrentOverallUploadLimitInput, setTorrentOverallUploadLimitInput] = useState(
|
||||
() => settings.torrentOverallUploadLimit
|
||||
);
|
||||
@@ -349,6 +363,14 @@ const engineRunId = useRef(0);
|
||||
setTorrentMaxOpenFilesInput(String(settings.torrentMaxOpenFiles));
|
||||
}, [settings.torrentMaxOpenFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
setTorrentDhtMessageTimeoutInput(String(settings.torrentDhtMessageTimeout));
|
||||
}, [settings.torrentDhtMessageTimeout]);
|
||||
|
||||
useEffect(() => {
|
||||
setTorrentMaxConcurrentSeedsInput(String(settings.torrentMaxConcurrentSeeds));
|
||||
}, [settings.torrentMaxConcurrentSeeds]);
|
||||
|
||||
useEffect(() => {
|
||||
setTorrentOverallUploadLimitInput(settings.torrentOverallUploadLimit);
|
||||
}, [settings.torrentOverallUploadLimit]);
|
||||
@@ -384,6 +406,20 @@ const engineRunId = useRef(0);
|
||||
});
|
||||
});
|
||||
};
|
||||
const commitTorrentDhtMessageTimeout = (raw: string) => {
|
||||
const next = normalizeTorrentDhtMessageTimeout(raw)
|
||||
?? settings.torrentDhtMessageTimeout
|
||||
?? DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT;
|
||||
setTorrentDhtMessageTimeoutInput(String(next));
|
||||
settings.setTorrentDhtMessageTimeout(next);
|
||||
};
|
||||
const commitTorrentMaxConcurrentSeeds = (raw: string) => {
|
||||
const next = normalizeTorrentMaxConcurrentSeeds(raw)
|
||||
?? settings.torrentMaxConcurrentSeeds
|
||||
?? DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS;
|
||||
setTorrentMaxConcurrentSeedsInput(String(next));
|
||||
settings.setTorrentMaxConcurrentSeeds(next);
|
||||
};
|
||||
const commitTorrentOverallUploadLimit = (raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
const normalized = trimmed ? (normalizeSpeedLimitForBackend(trimmed) ?? '') : '';
|
||||
@@ -1406,6 +1442,59 @@ runEngineChecks(false);
|
||||
aria-label={t($ => $.settings.network.torrentPeerAgent)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mac-settings-row settings-network-row">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.network.torrentSeparateSeedSlots)}</span>
|
||||
<small>{t($ => $.settings.network.torrentSeparateSeedSlotsDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.torrentSeparateSeedSlots}
|
||||
onChange={(event) => settings.setTorrentSeparateSeedSlots(event.target.checked)}
|
||||
aria-label={t($ => $.settings.network.torrentSeparateSeedSlots)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mac-settings-row settings-network-row">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.network.torrentMaxConcurrentSeeds)}</span>
|
||||
<small>{t($ => $.settings.network.torrentMaxConcurrentSeedsDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={MIN_TORRENT_MAX_CONCURRENT_SEEDS}
|
||||
max={MAX_TORRENT_MAX_CONCURRENT_SEEDS}
|
||||
step={1}
|
||||
value={torrentMaxConcurrentSeedsInput}
|
||||
onChange={(event) => setTorrentMaxConcurrentSeedsInput(event.target.value)}
|
||||
onBlur={(event) => commitTorrentMaxConcurrentSeeds(event.target.value)}
|
||||
className="app-control settings-port-input text-center"
|
||||
aria-label={t($ => $.settings.network.torrentMaxConcurrentSeeds)}
|
||||
/>
|
||||
</div>
|
||||
<p className="settings-group-footer">
|
||||
{t($ => $.settings.network.torrentNetworkRestartNote)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 className="settings-section-title">{t($ => $.settings.network.torrentAdvanced)}</h2>
|
||||
<div className="mac-settings-group">
|
||||
<div className="mac-settings-row settings-network-row">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.network.torrentDhtMessageTimeout)}</span>
|
||||
<small>{t($ => $.settings.network.torrentDhtMessageTimeoutDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={MIN_TORRENT_DHT_MESSAGE_TIMEOUT}
|
||||
max={MAX_TORRENT_DHT_MESSAGE_TIMEOUT}
|
||||
step={1}
|
||||
value={torrentDhtMessageTimeoutInput}
|
||||
onChange={(event) => setTorrentDhtMessageTimeoutInput(event.target.value)}
|
||||
onBlur={(event) => commitTorrentDhtMessageTimeout(event.target.value)}
|
||||
className="app-control settings-port-input text-center"
|
||||
aria-label={t($ => $.settings.network.torrentDhtMessageTimeout)}
|
||||
/>
|
||||
</div>
|
||||
<p className="settings-group-footer">
|
||||
{t($ => $.settings.network.torrentNetworkRestartNote)}
|
||||
</p>
|
||||
|
||||
@@ -90,6 +90,7 @@ const common = {
|
||||
downloading: 'Downloading',
|
||||
processing: 'Processing',
|
||||
seeding: 'Seeding',
|
||||
waitingToSeed: 'Waiting to seed',
|
||||
paused: 'Paused',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
@@ -260,6 +261,30 @@ const common = {
|
||||
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.',
|
||||
torrentFileProgress: 'Torrent file progress',
|
||||
torrentFileProgressRefresh: 'Refresh',
|
||||
torrentFileProgressLoading: 'Loading file progress…',
|
||||
torrentFileProgressUnavailable: 'File progress is available while this Torrent is active or paused.',
|
||||
torrentFileProgressFailed: 'Could not read Torrent file progress.',
|
||||
torrentFileProgressHint: 'Validated relative paths and completed bytes are shown; daemon paths and URIs are not exposed.',
|
||||
torrentFileProgressPath: 'File',
|
||||
torrentFileProgressCompleted: 'Completed',
|
||||
torrentFileProgressSelected: 'Selected',
|
||||
torrentFileProgressUnselected: 'Not selected',
|
||||
torrentPieceProgress: 'Torrent piece progress',
|
||||
torrentPieceProgressRefresh: 'Refresh',
|
||||
torrentPieceProgressLoading: 'Loading piece progress…',
|
||||
torrentPieceProgressUnavailable: 'Piece progress is available while this Torrent is active or paused.',
|
||||
torrentPieceProgressFailed: 'Could not read Torrent piece progress.',
|
||||
torrentPieceProgressHint: 'Each cell summarizes adjacent pieces. Raw bitfields are never exposed.',
|
||||
torrentPieceProgressSummary: '{{completed}} of {{total}} pieces complete · {{size}} each',
|
||||
torrentPieceProgressMap: 'Torrent piece completion map',
|
||||
torrentWebSeeds: 'Torrent web seeds',
|
||||
torrentWebSeedsHint: 'One line per seed in the form file index|HTTP(S) URI. Firelink expands multi-file paths natively.',
|
||||
torrentWebSeedsPlaceholder: '0|https://mirror.example/torrent/',
|
||||
torrentWebSeedsApply: 'Apply web seeds',
|
||||
torrentWebSeedsLoading: 'Applying…',
|
||||
torrentWebSeedsFailed: 'Could not validate or apply the Torrent web seeds.',
|
||||
torrentPeerCount: '{{total}} peers · {{seeders}} seeders',
|
||||
torrentPeerDownload: 'Download',
|
||||
torrentPeerUpload: 'Upload',
|
||||
@@ -800,6 +825,13 @@ const common = {
|
||||
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.',
|
||||
torrentNetwork: 'BitTorrent network binding',
|
||||
torrentAdvanced: 'Advanced Torrent network',
|
||||
torrentDhtMessageTimeout: 'DHT message timeout',
|
||||
torrentDhtMessageTimeoutDescription: 'Whole seconds for DHT and UDP message waits. This does not affect remote .torrent HTTP fetches or HTTP tracker requests. Applies after Firelink restarts.',
|
||||
torrentSeparateSeedSlots: 'Separate seeding capacity',
|
||||
torrentSeparateSeedSlotsDescription: 'Keep seeding outside the download limit and cap it with a Firelink-managed pool.',
|
||||
torrentMaxConcurrentSeeds: 'Maximum concurrent seeds',
|
||||
torrentMaxConcurrentSeedsDescription: 'Maximum number of Torrents Firelink lets seed at once when separate capacity is enabled.',
|
||||
torrentListenPort: 'TCP peer ports',
|
||||
torrentListenPortDescription: 'TCP ports for incoming BitTorrent peer connections. Leave blank for Aria2’s default range.',
|
||||
torrentDhtListenPort: 'UDP/DHT ports',
|
||||
|
||||
@@ -90,6 +90,7 @@ const fa = {
|
||||
downloading: 'در حال دانلود',
|
||||
processing: 'در حال پردازش',
|
||||
seeding: 'در حال اشتراکگذاری',
|
||||
waitingToSeed: 'در انتظار اشتراکگذاری',
|
||||
paused: 'متوقفشده',
|
||||
completed: 'تکمیلشده',
|
||||
failed: 'ناموفق',
|
||||
@@ -260,6 +261,30 @@ const fa = {
|
||||
torrentPeerDiagnosticsUnavailable: 'اطلاعات همتاها هنگام فعال بودن تورنت در دسترس است.',
|
||||
torrentPeerDiagnosticsFailed: 'خواندن اطلاعات همتاهای تورنت ممکن نیست.',
|
||||
torrentPeerDiagnosticsHint: 'فقط سرعت و وضعیت اتصال نمایش داده میشود؛ IP، پورت، شناسه و بیتفیلد همتاها ذخیره نمیشود.',
|
||||
torrentFileProgress: 'پیشرفت فایلهای تورنت',
|
||||
torrentFileProgressRefresh: 'تازهسازی',
|
||||
torrentFileProgressLoading: 'در حال دریافت پیشرفت فایلها…',
|
||||
torrentFileProgressUnavailable: 'پیشرفت فایل هنگام فعال یا متوقفبودن تورنت در دسترس است.',
|
||||
torrentFileProgressFailed: 'خواندن پیشرفت فایلهای تورنت ممکن نیست.',
|
||||
torrentFileProgressHint: 'مسیرهای نسبی معتبر و حجم تکمیلشده نمایش داده میشود؛ مسیرهای داخلی و URIهای daemon نمایش داده نمیشوند.',
|
||||
torrentFileProgressPath: 'فایل',
|
||||
torrentFileProgressCompleted: 'تکمیلشده',
|
||||
torrentFileProgressSelected: 'انتخابشده',
|
||||
torrentFileProgressUnselected: 'انتخابنشده',
|
||||
torrentPieceProgress: 'پیشرفت قطعههای تورنت',
|
||||
torrentPieceProgressRefresh: 'تازهسازی',
|
||||
torrentPieceProgressLoading: 'در حال دریافت پیشرفت قطعهها…',
|
||||
torrentPieceProgressUnavailable: 'پیشرفت قطعهها هنگام فعال یا متوقفبودن تورنت در دسترس است.',
|
||||
torrentPieceProgressFailed: 'خواندن پیشرفت قطعههای تورنت ممکن نیست.',
|
||||
torrentPieceProgressHint: 'هر خانه خلاصهای از قطعههای مجاور است؛ bitfield خام نمایش داده نمیشود.',
|
||||
torrentPieceProgressSummary: '{{completed}} از {{total}} قطعه کامل شده · هرکدام {{size}}',
|
||||
torrentPieceProgressMap: 'نقشه تکمیل قطعههای تورنت',
|
||||
torrentWebSeeds: 'وبسیدهای تورنت',
|
||||
torrentWebSeedsHint: 'هر خط بهشکل شماره فایل|نشانی HTTP(S). مسیر فایلهای چندفایلی را Firelink در بخش native میسازد.',
|
||||
torrentWebSeedsPlaceholder: '۰|https://mirror.example/torrent/',
|
||||
torrentWebSeedsApply: 'اعمال وبسیدها',
|
||||
torrentWebSeedsLoading: 'در حال اعمال…',
|
||||
torrentWebSeedsFailed: 'اعتبارسنجی یا اعمال وبسیدهای تورنت انجام نشد.',
|
||||
torrentPeerCount: '{{total}} همتا · {{seeders}} سید',
|
||||
torrentPeerDownload: 'دریافت',
|
||||
torrentPeerUpload: 'آپلود',
|
||||
@@ -800,6 +825,13 @@ const fa = {
|
||||
torrentLpdDescription: 'همتاهای سازگار در شبکه محلی را پیدا میکند و دیدهشدن ترافیک در شبکه محلی را افزایش میدهد.',
|
||||
torrentPeerDiscoveryRestartNote: 'این گزینهها سراسری و مربوط به Aria2 هستند و پس از راهاندازی مجدد Firelink اعمال میشوند. Aria2 همچنان کشف همتا را برای تورنتهای خصوصی خاموش میکند.',
|
||||
torrentNetwork: 'اتصال شبکه بیتتورنت',
|
||||
torrentAdvanced: 'شبکه پیشرفته تورنت',
|
||||
torrentDhtMessageTimeout: 'مهلت پیام DHT',
|
||||
torrentDhtMessageTimeoutDescription: 'مدت انتظار پیامهای DHT و UDP برحسب ثانیه. روی دریافت HTTP فایل .torrent یا درخواستهای tracker از نوع HTTP اثر ندارد و پس از راهاندازی دوباره اعمال میشود.',
|
||||
torrentSeparateSeedSlots: 'ظرفیت جداگانهٔ سید کردن',
|
||||
torrentSeparateSeedSlotsDescription: 'سید کردن را از سقف دانلود جدا میکند و آن را با ظرفیت مدیریتشدهٔ Firelink محدود میکند.',
|
||||
torrentMaxConcurrentSeeds: 'حداکثر سید همزمان',
|
||||
torrentMaxConcurrentSeedsDescription: 'وقتی ظرفیت جداگانه فعال است، حداکثر تعداد تورنتهایی که Firelink همزمان سید میکند.',
|
||||
torrentListenPort: 'پورتهای همتای TCP',
|
||||
torrentListenPortDescription: 'پورتهای TCP برای اتصالهای ورودی همتاهای بیتتورنت. برای محدوده پیشفرض Aria2 خالی بگذارید.',
|
||||
torrentDhtListenPort: 'پورتهای UDP/DHT',
|
||||
|
||||
@@ -90,6 +90,7 @@ const he = {
|
||||
downloading: 'מוריד',
|
||||
processing: 'מעבד',
|
||||
seeding: 'משתף',
|
||||
waitingToSeed: 'ממתין לשיתוף',
|
||||
paused: 'מושהה',
|
||||
completed: 'הושלם',
|
||||
failed: 'נכשל',
|
||||
@@ -260,6 +261,30 @@ const he = {
|
||||
torrentPeerDiagnosticsUnavailable: 'אבחון עמיתים זמין כשהטורנט פעיל.',
|
||||
torrentPeerDiagnosticsFailed: 'לא ניתן לקרוא את אבחון עמיתי הטורנט.',
|
||||
torrentPeerDiagnosticsHint: 'מוצגים רק מהירויות ודגלי חיבור; כתובות IP, יציאות, מזהים ושדות ביטים אינם נשמרים.',
|
||||
torrentFileProgress: 'התקדמות קובצי הטורנט',
|
||||
torrentFileProgressRefresh: 'רענון',
|
||||
torrentFileProgressLoading: 'טוען את התקדמות הקבצים…',
|
||||
torrentFileProgressUnavailable: 'התקדמות הקבצים זמינה כשהטורנט פעיל או מושהה.',
|
||||
torrentFileProgressFailed: 'לא ניתן לקרוא את התקדמות קובצי הטורנט.',
|
||||
torrentFileProgressHint: 'מוצגים נתיבים יחסיים מאומתים ובייטים שהושלמו; נתיבי daemon וכתובות URI אינם נחשפים.',
|
||||
torrentFileProgressPath: 'קובץ',
|
||||
torrentFileProgressCompleted: 'הושלם',
|
||||
torrentFileProgressSelected: 'נבחר',
|
||||
torrentFileProgressUnselected: 'לא נבחר',
|
||||
torrentPieceProgress: 'התקדמות חלקי הטורנט',
|
||||
torrentPieceProgressRefresh: 'רענון',
|
||||
torrentPieceProgressLoading: 'טוען את התקדמות החלקים…',
|
||||
torrentPieceProgressUnavailable: 'התקדמות החלקים זמינה כשהטורנט פעיל או מושהה.',
|
||||
torrentPieceProgressFailed: 'לא ניתן לקרוא את התקדמות חלקי הטורנט.',
|
||||
torrentPieceProgressHint: 'כל תא מסכם חלקים סמוכים; מפת הסיביות הגולמית אינה נחשפת.',
|
||||
torrentPieceProgressSummary: '{{completed}} מתוך {{total}} חלקים הושלמו · {{size}} לכל חלק',
|
||||
torrentPieceProgressMap: 'מפת השלמת חלקי הטורנט',
|
||||
torrentWebSeeds: 'זריעות Web של טורנט',
|
||||
torrentWebSeedsHint: 'שורה אחת לכל זרע בפורמט file index|כתובת HTTP(S). Firelink מרחיב נתיבי קבצים מרובי-קבצים באופן מקורי.',
|
||||
torrentWebSeedsPlaceholder: '0|https://מראה.example/torrent/',
|
||||
torrentWebSeedsApply: 'החל זריעות Web',
|
||||
torrentWebSeedsLoading: 'מיישם…',
|
||||
torrentWebSeedsFailed: 'לא ניתן לאמת או להחיל את זריעות ה-Web של הטורנט.',
|
||||
torrentPeerCount: '{{total}} עמיתים · {{seeders}} משתפים',
|
||||
torrentPeerDownload: 'הורדה',
|
||||
torrentPeerUpload: 'העלאה',
|
||||
@@ -800,6 +825,13 @@ const he = {
|
||||
torrentLpdDescription: 'מאתר עמיתים תואמים ברשת המקומית ומגדיל את החשיפה המקומית של התעבורה.',
|
||||
torrentPeerDiscoveryRestartNote: 'האפשרויות האלה הן כלליות ל-Aria2 ונכנסות לתוקף לאחר הפעלה מחדש של Firelink. Aria2 עדיין משבית גילוי עמיתים בטורנטים פרטיים.',
|
||||
torrentNetwork: 'קישור רשת BitTorrent',
|
||||
torrentAdvanced: 'רשת Torrent מתקדמת',
|
||||
torrentDhtMessageTimeout: 'זמן קצוב להודעות DHT',
|
||||
torrentDhtMessageTimeoutDescription: 'משך ההמתנה להודעות DHT ו-UDP בשניות. אינו משפיע על הורדת קובצי .torrent ב-HTTP או על בקשות HTTP למעקבים. חל לאחר הפעלה מחדש של Firelink.',
|
||||
torrentSeparateSeedSlots: 'קיבולת זריעה נפרדת',
|
||||
torrentSeparateSeedSlotsDescription: 'הפרד זריעה ממגבלת ההורדות והגבל אותה למאגר בניהול Firelink.',
|
||||
torrentMaxConcurrentSeeds: 'מקסימום זריעות במקביל',
|
||||
torrentMaxConcurrentSeedsDescription: 'מספר הטורנטים המרבי ש-Firelink יזריע בו-זמנית כשהקיבולת הנפרדת פעילה.',
|
||||
torrentListenPort: 'יציאות עמיתי TCP',
|
||||
torrentListenPortDescription: 'יציאות TCP לחיבורי עמיתים נכנסים של BitTorrent. השאר ריק כדי להשתמש בטווח ברירת המחדל של Aria2.',
|
||||
torrentDhtListenPort: 'יציאות UDP/DHT',
|
||||
|
||||
@@ -90,6 +90,7 @@ const ru = {
|
||||
downloading: 'Загрузка',
|
||||
processing: 'Обработка',
|
||||
seeding: 'Раздача',
|
||||
waitingToSeed: 'Ожидание раздачи',
|
||||
paused: 'Приостановлено',
|
||||
completed: 'Завершено',
|
||||
failed: 'Ошибка',
|
||||
@@ -260,6 +261,30 @@ const ru = {
|
||||
torrentPeerDiagnosticsUnavailable: 'Диагностика пиров доступна, пока торрент активен.',
|
||||
torrentPeerDiagnosticsFailed: 'Не удалось получить диагностику пиров торрента.',
|
||||
torrentPeerDiagnosticsHint: 'Показываются только скорости и флаги соединения; IP-адреса, порты, идентификаторы и битовые поля не сохраняются.',
|
||||
torrentFileProgress: 'Прогресс файлов торрента',
|
||||
torrentFileProgressRefresh: 'Обновить',
|
||||
torrentFileProgressLoading: 'Загрузка прогресса файлов…',
|
||||
torrentFileProgressUnavailable: 'Прогресс файлов доступен, пока торрент активен или приостановлен.',
|
||||
torrentFileProgressFailed: 'Не удалось получить прогресс файлов торрента.',
|
||||
torrentFileProgressHint: 'Показываются проверенные относительные пути и загруженные байты; пути демона и URI не раскрываются.',
|
||||
torrentFileProgressPath: 'Файл',
|
||||
torrentFileProgressCompleted: 'Завершено',
|
||||
torrentFileProgressSelected: 'Выбран',
|
||||
torrentFileProgressUnselected: 'Не выбран',
|
||||
torrentPieceProgress: 'Прогресс частей торрента',
|
||||
torrentPieceProgressRefresh: 'Обновить',
|
||||
torrentPieceProgressLoading: 'Загрузка прогресса частей…',
|
||||
torrentPieceProgressUnavailable: 'Прогресс частей доступен, пока торрент активен или приостановлен.',
|
||||
torrentPieceProgressFailed: 'Не удалось получить прогресс частей торрента.',
|
||||
torrentPieceProgressHint: 'Каждая ячейка объединяет соседние части; исходная битовая карта не раскрывается.',
|
||||
torrentPieceProgressSummary: '{{completed}} из {{total}} частей завершено · по {{size}} на часть',
|
||||
torrentPieceProgressMap: 'Карта завершения частей торрента',
|
||||
torrentWebSeeds: 'Веб-сиды торрента',
|
||||
torrentWebSeedsHint: 'Одна строка на сид в формате индекс файла|HTTP(S)-URI. Firelink сам расширяет пути многофайловых торрентов.',
|
||||
torrentWebSeedsPlaceholder: '0|https://зеркало.example/torrent/',
|
||||
torrentWebSeedsApply: 'Применить веб-сиды',
|
||||
torrentWebSeedsLoading: 'Применение…',
|
||||
torrentWebSeedsFailed: 'Не удалось проверить или применить веб-сиды торрента.',
|
||||
torrentPeerCount: '{{total}} пиров · {{seeders}} сидеров',
|
||||
torrentPeerDownload: 'Загрузка',
|
||||
torrentPeerUpload: 'Отдача',
|
||||
@@ -800,6 +825,13 @@ const ru = {
|
||||
torrentLpdDescription: 'Ищет подходящие пиры в локальной сети и увеличивает видимость трафика в ней.',
|
||||
torrentPeerDiscoveryRestartNote: 'Эти параметры являются глобальными для Aria2 и применяются после перезапуска Firelink. Aria2 по-прежнему отключает обнаружение пиров для приватных торрентов.',
|
||||
torrentNetwork: 'Сетевые параметры BitTorrent',
|
||||
torrentAdvanced: 'Расширенные параметры Torrent',
|
||||
torrentDhtMessageTimeout: 'Тайм-аут сообщений DHT',
|
||||
torrentDhtMessageTimeoutDescription: 'Время ожидания сообщений DHT и UDP в секундах. Не влияет на загрузку .torrent по HTTP или HTTP-запросы к трекерам. Применяется после перезапуска Firelink.',
|
||||
torrentSeparateSeedSlots: 'Отдельная ёмкость раздачи',
|
||||
torrentSeparateSeedSlotsDescription: 'Вынести раздачу за пределы лимита загрузок и ограничить её пулом Firelink.',
|
||||
torrentMaxConcurrentSeeds: 'Максимум одновременных раздач',
|
||||
torrentMaxConcurrentSeedsDescription: 'Максимальное число торрентов, которые Firelink раздаёт одновременно при включённой отдельной ёмкости.',
|
||||
torrentListenPort: 'TCP-порты пиров',
|
||||
torrentListenPortDescription: 'TCP-порты для входящих соединений BitTorrent. Оставьте пустым, чтобы использовать диапазон Aria2 по умолчанию.',
|
||||
torrentDhtListenPort: 'Порты UDP/DHT',
|
||||
|
||||
@@ -90,6 +90,7 @@ const uk = {
|
||||
downloading: 'Завантаження',
|
||||
processing: 'Обробка',
|
||||
seeding: 'Роздача',
|
||||
waitingToSeed: 'Очікування роздачі',
|
||||
paused: 'Призупинено',
|
||||
completed: 'Завершено',
|
||||
failed: 'Помилка',
|
||||
@@ -260,6 +261,30 @@ const uk = {
|
||||
torrentPeerDiagnosticsUnavailable: 'Діагностика пірів доступна, поки торрент активний.',
|
||||
torrentPeerDiagnosticsFailed: 'Не вдалося отримати діагностику пірів торрента.',
|
||||
torrentPeerDiagnosticsHint: 'Показуються лише швидкості та прапорці з’єднання; IP-адреси, порти, ідентифікатори й бітові поля не зберігаються.',
|
||||
torrentFileProgress: 'Прогрес файлів торрента',
|
||||
torrentFileProgressRefresh: 'Оновити',
|
||||
torrentFileProgressLoading: 'Завантаження прогресу файлів…',
|
||||
torrentFileProgressUnavailable: 'Прогрес файлів доступний, коли торрент активний або призупинений.',
|
||||
torrentFileProgressFailed: 'Не вдалося отримати прогрес файлів торрента.',
|
||||
torrentFileProgressHint: 'Показуються перевірені відносні шляхи та завантажені байти; шляхи демона й URI не розкриваються.',
|
||||
torrentFileProgressPath: 'Файл',
|
||||
torrentFileProgressCompleted: 'Завершено',
|
||||
torrentFileProgressSelected: 'Вибрано',
|
||||
torrentFileProgressUnselected: 'Не вибрано',
|
||||
torrentPieceProgress: 'Прогрес частин торрента',
|
||||
torrentPieceProgressRefresh: 'Оновити',
|
||||
torrentPieceProgressLoading: 'Завантаження прогресу частин…',
|
||||
torrentPieceProgressUnavailable: 'Прогрес частин доступний, коли торрент активний або призупинений.',
|
||||
torrentPieceProgressFailed: 'Не вдалося отримати прогрес частин торрента.',
|
||||
torrentPieceProgressHint: 'Кожна клітинка узагальнює сусідні частини; необроблена бітова карта не розкривається.',
|
||||
torrentPieceProgressSummary: '{{completed}} із {{total}} частин завершено · по {{size}} на частину',
|
||||
torrentPieceProgressMap: 'Карта завершення частин торрента',
|
||||
torrentWebSeeds: 'Вебсіди торента',
|
||||
torrentWebSeedsHint: 'Один рядок на сід у форматі індекс файлу|HTTP(S)-URI. Firelink сам розгортає шляхи багатофайлових торентів.',
|
||||
torrentWebSeedsPlaceholder: '0|https://дзеркало.example/torrent/',
|
||||
torrentWebSeedsApply: 'Застосувати вебсіди',
|
||||
torrentWebSeedsLoading: 'Застосування…',
|
||||
torrentWebSeedsFailed: 'Не вдалося перевірити або застосувати вебсіди торента.',
|
||||
torrentPeerCount: '{{total}} пірів · {{seeders}} сідів',
|
||||
torrentPeerDownload: 'Завантаження',
|
||||
torrentPeerUpload: 'Віддача',
|
||||
@@ -800,6 +825,13 @@ const uk = {
|
||||
torrentLpdDescription: 'Шукає сумісних пірів у локальній мережі та збільшує видимість трафіку в ній.',
|
||||
torrentPeerDiscoveryRestartNote: 'Ці параметри є глобальними для Aria2 і застосовуються після перезапуску Firelink. Aria2 і надалі вимикає пошук пірів для приватних торрентів.',
|
||||
torrentNetwork: 'Мережеві параметри BitTorrent',
|
||||
torrentAdvanced: 'Розширені параметри Torrent',
|
||||
torrentDhtMessageTimeout: 'Час очікування повідомлень DHT',
|
||||
torrentDhtMessageTimeoutDescription: 'Час очікування повідомлень DHT і UDP у секундах. Не впливає на завантаження .torrent через HTTP або HTTP-запити до трекерів. Застосовується після перезапуску Firelink.',
|
||||
torrentSeparateSeedSlots: 'Окрема місткість роздачі',
|
||||
torrentSeparateSeedSlotsDescription: 'Винести роздачу за межі ліміту завантажень і обмежити її пулом Firelink.',
|
||||
torrentMaxConcurrentSeeds: 'Максимум одночасних роздач',
|
||||
torrentMaxConcurrentSeedsDescription: 'Максимальна кількість торентів, які Firelink роздає одночасно за ввімкненої окремої місткості.',
|
||||
torrentListenPort: 'TCP-порти пірів',
|
||||
torrentListenPortDescription: 'TCP-порти для вхідних з’єднань BitTorrent. Залиште порожнім, щоб використати типовий діапазон Aria2.',
|
||||
torrentDhtListenPort: 'Порти UDP/DHT',
|
||||
|
||||
@@ -90,6 +90,7 @@ const zhCN = {
|
||||
downloading: '下载中',
|
||||
processing: '处理中',
|
||||
seeding: '做种中',
|
||||
waitingToSeed: '等待做种',
|
||||
paused: '已暂停',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
@@ -260,6 +261,30 @@ const zhCN = {
|
||||
torrentPeerDiagnosticsUnavailable: 'Torrent 活跃时可查看对等节点诊断。',
|
||||
torrentPeerDiagnosticsFailed: '无法读取 Torrent 对等节点诊断。',
|
||||
torrentPeerDiagnosticsHint: '仅显示速度和连接状态;不会保留对等节点 IP、端口、ID 或位域。',
|
||||
torrentFileProgress: 'Torrent 文件进度',
|
||||
torrentFileProgressRefresh: '刷新',
|
||||
torrentFileProgressLoading: '正在加载文件进度…',
|
||||
torrentFileProgressUnavailable: 'Torrent 活跃或暂停时可查看文件进度。',
|
||||
torrentFileProgressFailed: '无法读取 Torrent 文件进度。',
|
||||
torrentFileProgressHint: '仅显示已验证的相对路径和已完成字节数;不会暴露守护进程路径或 URI。',
|
||||
torrentFileProgressPath: '文件',
|
||||
torrentFileProgressCompleted: '已完成',
|
||||
torrentFileProgressSelected: '已选择',
|
||||
torrentFileProgressUnselected: '未选择',
|
||||
torrentPieceProgress: 'Torrent 分片进度',
|
||||
torrentPieceProgressRefresh: '刷新',
|
||||
torrentPieceProgressLoading: '正在加载分片进度…',
|
||||
torrentPieceProgressUnavailable: 'Torrent 活跃或暂停时可查看分片进度。',
|
||||
torrentPieceProgressFailed: '无法读取 Torrent 分片进度。',
|
||||
torrentPieceProgressHint: '每个单元格汇总相邻分片;不会暴露原始位图。',
|
||||
torrentPieceProgressSummary: '{{completed}}/{{total}} 个分片已完成 · 每片 {{size}}',
|
||||
torrentPieceProgressMap: 'Torrent 分片完成度地图',
|
||||
torrentWebSeeds: 'Torrent Web 做种',
|
||||
torrentWebSeedsHint: '每行一个做种,格式为文件索引|HTTP(S) URI。多文件路径由 Firelink 原生展开。',
|
||||
torrentWebSeedsPlaceholder: '0|https://镜像.example/torrent/',
|
||||
torrentWebSeedsApply: '应用 Web 做种',
|
||||
torrentWebSeedsLoading: '正在应用…',
|
||||
torrentWebSeedsFailed: '无法验证或应用 Torrent Web 做种。',
|
||||
torrentPeerCount: '{{total}} 个节点 · {{seeders}} 个做种节点',
|
||||
torrentPeerDownload: '下载',
|
||||
torrentPeerUpload: '上传',
|
||||
@@ -800,6 +825,13 @@ const zhCN = {
|
||||
torrentLpdDescription: '在本地网络中发现兼容节点,这会增加本地网络中的流量可见性。',
|
||||
torrentPeerDiscoveryRestartNote: '这些选项是 Aria2 的全局设置,需要重启 Firelink 后生效。Aria2 仍会对私有 Torrent 禁用节点发现。',
|
||||
torrentNetwork: 'BitTorrent 网络绑定',
|
||||
torrentAdvanced: '高级 Torrent 网络设置',
|
||||
torrentDhtMessageTimeout: 'DHT 消息超时',
|
||||
torrentDhtMessageTimeoutDescription: 'DHT 和 UDP 消息的等待时间(秒)。不影响通过 HTTP 获取 .torrent 文件,也不影响 HTTP tracker 请求。Firelink 重启后生效。',
|
||||
torrentSeparateSeedSlots: '独立做种容量',
|
||||
torrentSeparateSeedSlotsDescription: '将做种从下载上限中分离,并使用 Firelink 管理的容量池限制做种。',
|
||||
torrentMaxConcurrentSeeds: '最大同时做种数',
|
||||
torrentMaxConcurrentSeedsDescription: '启用独立容量后,Firelink 同时做种的 Torrent 数量上限。',
|
||||
torrentListenPort: 'TCP 节点端口',
|
||||
torrentListenPortDescription: '用于传入 BitTorrent 节点连接的 TCP 端口。留空以使用 Aria2 的默认范围。',
|
||||
torrentDhtListenPort: 'UDP/DHT 端口',
|
||||
|
||||
@@ -20,6 +20,9 @@ import type { PlatformInfo } from './bindings/PlatformInfo';
|
||||
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
|
||||
import type { TorrentMetadata } from './bindings/TorrentMetadata';
|
||||
import type { TorrentPeerDiagnostics } from './bindings/TorrentPeerDiagnostics';
|
||||
import type { TorrentFileProgressSnapshot } from './bindings/TorrentFileProgressSnapshot';
|
||||
import type { TorrentPieceProgressSnapshot } from './bindings/TorrentPieceProgressSnapshot';
|
||||
import type { TorrentWebSeed } from './bindings/TorrentWebSeed';
|
||||
|
||||
type CommandMap = {
|
||||
fetch_metadata: {
|
||||
@@ -79,6 +82,10 @@ type CommandMap = {
|
||||
result: void;
|
||||
};
|
||||
get_torrent_peers: { args: { id: string }; result: TorrentPeerDiagnostics };
|
||||
get_torrent_file_progress: { args: { id: string }; result: TorrentFileProgressSnapshot };
|
||||
get_torrent_piece_progress: { args: { id: string }; result: TorrentPieceProgressSnapshot };
|
||||
get_torrent_web_seeds: { args: { id: string }; result: TorrentWebSeed[] };
|
||||
set_torrent_web_seeds: { args: { id: string; seeds: TorrentWebSeed[] }; result: TorrentWebSeed[] };
|
||||
set_torrent_max_open_files: { args: { max_open_files: number }; result: void };
|
||||
set_torrent_overall_upload_limit: { args: { limit: string | null }; result: void };
|
||||
set_global_speed_limit: { args: { limit: string | null }; result: void };
|
||||
|
||||
@@ -121,7 +121,8 @@ const startDownloadListeners = async () => {
|
||||
return;
|
||||
}
|
||||
if (status === 'downloading' || status === 'processing' ||
|
||||
status === 'seeding' || status === 'completed' || status === 'failed') {
|
||||
status === 'seeding' || status === 'waitingToSeed' ||
|
||||
status === 'completed' || status === 'failed') {
|
||||
clearDownloadControlIntent(payload.id, 'resume');
|
||||
}
|
||||
if (status === 'paused') {
|
||||
@@ -146,6 +147,7 @@ const startDownloadListeners = async () => {
|
||||
}
|
||||
if (current.status === 'seeding' &&
|
||||
status !== 'seeding' &&
|
||||
status !== 'waitingToSeed' &&
|
||||
status !== 'paused' &&
|
||||
status !== 'completed' &&
|
||||
status !== 'failed') {
|
||||
@@ -153,7 +155,7 @@ const startDownloadListeners = async () => {
|
||||
}
|
||||
|
||||
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
|
||||
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
|
||||
if (['queued', 'retrying', 'completed', 'failed', 'paused', 'waitingToSeed'].includes(status)) {
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
}
|
||||
const updates: Partial<DownloadItem> = {
|
||||
@@ -175,6 +177,11 @@ const startDownloadListeners = async () => {
|
||||
? { lastTry: new Date().toISOString() }
|
||||
: {})
|
||||
};
|
||||
if (payload.torrentSeedRemaining != null) {
|
||||
updates.torrentSeedRemaining = payload.torrentSeedRemaining;
|
||||
} else if (status === 'seeding' || status === 'completed' || status === 'failed') {
|
||||
updates.torrentSeedRemaining = undefined;
|
||||
}
|
||||
if (!payload.error && status !== 'failed' && status !== 'retrying') {
|
||||
updates.lastError = undefined;
|
||||
}
|
||||
@@ -188,7 +195,7 @@ const startDownloadListeners = async () => {
|
||||
}
|
||||
mainStore.updateDownload(payload.id, updates);
|
||||
|
||||
if (status === 'completed' || status === 'failed' || status === 'paused' || status === 'seeding') {
|
||||
if (status === 'completed' || status === 'failed' || status === 'paused' || status === 'seeding' || status === 'waitingToSeed') {
|
||||
useDownloadStore.setState(state => ({
|
||||
pendingOrder: state.pendingOrder.filter(id => id !== payload.id)
|
||||
}));
|
||||
@@ -198,7 +205,7 @@ const startDownloadListeners = async () => {
|
||||
: { pendingOrder: [...state.pendingOrder, payload.id] });
|
||||
}
|
||||
|
||||
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'retrying') {
|
||||
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying') {
|
||||
mainStore.registerBackendIds([payload.id]);
|
||||
} else if (status === 'completed' || status === 'failed') {
|
||||
mainStore.unregisterBackendIds([payload.id]);
|
||||
|
||||
@@ -347,6 +347,8 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
||||
torrent_info_hash: item.torrentInfoHash || undefined,
|
||||
torrent_seed_time: item.torrentSeedTime,
|
||||
torrent_seed_ratio: item.torrentSeedRatio,
|
||||
torrent_seed_remaining: item.torrentSeedRemaining,
|
||||
torrent_web_seeds: item.torrentWebSeeds,
|
||||
torrent_upload_limit: item.torrentUploadLimit || undefined,
|
||||
torrent_max_peers: item.torrentMaxPeers,
|
||||
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
|
||||
@@ -625,6 +627,12 @@ export const hasStaleTemporaryMediaEstimate = (
|
||||
};
|
||||
|
||||
export const normalizePersistedDownloadProgress = (download: DownloadItem): DownloadItem => {
|
||||
const rawSeedRemaining = download.torrentSeedRemaining as unknown;
|
||||
const normalizedSeedRemaining = typeof rawSeedRemaining === 'number' &&
|
||||
Number.isFinite(rawSeedRemaining) &&
|
||||
rawSeedRemaining >= 0
|
||||
? rawSeedRemaining
|
||||
: undefined;
|
||||
const rawMaxPeers = download.torrentMaxPeers as unknown;
|
||||
const normalizedMaxPeers = typeof rawMaxPeers === 'number' &&
|
||||
Number.isInteger(rawMaxPeers) &&
|
||||
@@ -632,6 +640,17 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
rawMaxPeers <= 1000
|
||||
? rawMaxPeers
|
||||
: undefined;
|
||||
const rawWebSeeds = download.torrentWebSeeds as unknown;
|
||||
const normalizedWebSeeds = Array.isArray(rawWebSeeds)
|
||||
? rawWebSeeds.filter((seed): seed is { fileIndex: number; uri: string } =>
|
||||
!!seed && typeof seed === 'object' &&
|
||||
typeof (seed as { fileIndex?: unknown }).fileIndex === 'number' &&
|
||||
Number.isInteger((seed as { fileIndex: number }).fileIndex) &&
|
||||
(seed as { fileIndex: number }).fileIndex >= 0 &&
|
||||
typeof (seed as { uri?: unknown }).uri === 'string' &&
|
||||
(seed as { uri: string }).uri.length <= 2048
|
||||
).slice(0, 256)
|
||||
: undefined;
|
||||
const rawPeerSpeedLimit = download.torrentPeerSpeedLimit as unknown;
|
||||
const normalizedPeerSpeedLimit = typeof rawPeerSpeedLimit === 'string'
|
||||
? normalizeSpeedLimitForBackend(rawPeerSpeedLimit) || undefined
|
||||
@@ -671,7 +690,9 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
: undefined;
|
||||
const rawEncryptionPolicy = download.torrentEncryptionPolicy as unknown;
|
||||
const normalizedEncryptionPolicy = normalizeTorrentEncryptionPolicy(rawEncryptionPolicy);
|
||||
const normalizedOptions = rawMaxPeers !== normalizedMaxPeers ||
|
||||
const normalizedOptions = rawSeedRemaining !== normalizedSeedRemaining ||
|
||||
rawWebSeeds !== normalizedWebSeeds ||
|
||||
rawMaxPeers !== normalizedMaxPeers ||
|
||||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
|
||||
rawCheckIntegrity !== normalizedCheckIntegrity ||
|
||||
rawTrackers !== normalizedTrackers ||
|
||||
@@ -685,6 +706,8 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
rawEncryptionPolicy !== normalizedEncryptionPolicy
|
||||
? {
|
||||
...download,
|
||||
torrentSeedRemaining: normalizedSeedRemaining,
|
||||
torrentWebSeeds: normalizedWebSeeds,
|
||||
torrentMaxPeers: normalizedMaxPeers,
|
||||
torrentPeerSpeedLimit: normalizedPeerSpeedLimit,
|
||||
torrentCheckIntegrity: normalizedCheckIntegrity,
|
||||
@@ -2171,6 +2194,20 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
if (pendingStartupResume) return pendingStartupResume;
|
||||
|
||||
const operation = (async () => {
|
||||
// WaitingToSeed is a paused Aria2 GID owned by the previous process;
|
||||
// that GID cannot survive an app restart. Reconstruct the row as a
|
||||
// queued Torrent using its persisted remaining seed budget, then let
|
||||
// the normal backend admission path assign a fresh lifecycle/GID.
|
||||
const waitingToSeedIds = get().downloads
|
||||
.filter(download => download.status === 'waitingToSeed')
|
||||
.map(download => download.id);
|
||||
if (waitingToSeedIds.length > 0) {
|
||||
set(state => ({
|
||||
downloads: state.downloads.map(download => waitingToSeedIds.includes(download.id)
|
||||
? { ...download, status: 'queued' }
|
||||
: download)
|
||||
}));
|
||||
}
|
||||
const active = get().downloads
|
||||
.filter(d => d.status === 'queued')
|
||||
.sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0));
|
||||
@@ -2236,6 +2273,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
torrent_info_hash: item.torrentInfoHash || undefined,
|
||||
torrent_seed_time: item.torrentSeedTime,
|
||||
torrent_seed_ratio: item.torrentSeedRatio,
|
||||
torrent_seed_remaining: item.torrentSeedRemaining,
|
||||
torrent_web_seeds: item.torrentWebSeeds,
|
||||
torrent_upload_limit: item.torrentUploadLimit || undefined,
|
||||
torrent_max_peers: item.torrentMaxPeers,
|
||||
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
DEFAULT_TORRENT_MAX_OPEN_FILES,
|
||||
MAX_TORRENT_MAX_OPEN_FILES,
|
||||
MIN_TORRENT_MAX_OPEN_FILES,
|
||||
DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
|
||||
DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
|
||||
normalizeSpeedLimitForBackend,
|
||||
normalizeTorrentDhtMessageTimeout,
|
||||
normalizeTorrentMaxOpenFiles
|
||||
} from '../utils/downloads';
|
||||
import i18n from '../i18n';
|
||||
@@ -248,6 +251,9 @@ export interface SettingsState {
|
||||
torrentEnablePex: boolean;
|
||||
torrentEnableLpd: boolean;
|
||||
torrentMaxOpenFiles: number;
|
||||
torrentDhtMessageTimeout: number;
|
||||
torrentSeparateSeedSlots: boolean;
|
||||
torrentMaxConcurrentSeeds: number;
|
||||
torrentListenPort: string;
|
||||
torrentDhtListenPort: string;
|
||||
torrentExternalIp: string;
|
||||
@@ -313,6 +319,9 @@ export interface SettingsState {
|
||||
setTorrentEnablePex: (enabled: boolean) => void;
|
||||
setTorrentEnableLpd: (enabled: boolean) => void;
|
||||
setTorrentMaxOpenFiles: (value: number) => Promise<void>;
|
||||
setTorrentDhtMessageTimeout: (value: number) => void;
|
||||
setTorrentSeparateSeedSlots: (enabled: boolean) => void;
|
||||
setTorrentMaxConcurrentSeeds: (value: number) => void;
|
||||
setTorrentListenPort: (value: string) => void;
|
||||
setTorrentDhtListenPort: (value: string) => void;
|
||||
setTorrentExternalIp: (value: string) => void;
|
||||
@@ -404,6 +413,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
torrentEnablePex: true,
|
||||
torrentEnableLpd: false,
|
||||
torrentMaxOpenFiles: DEFAULT_TORRENT_MAX_OPEN_FILES,
|
||||
torrentDhtMessageTimeout: DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
|
||||
torrentSeparateSeedSlots: false,
|
||||
torrentMaxConcurrentSeeds: DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
|
||||
torrentListenPort: '',
|
||||
torrentDhtListenPort: '',
|
||||
torrentExternalIp: '',
|
||||
@@ -553,6 +565,19 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
torrentMaxOpenFilesQueue = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
},
|
||||
setTorrentDhtMessageTimeout: (value) => {
|
||||
const normalized = normalizeTorrentDhtMessageTimeout(value);
|
||||
set({
|
||||
torrentDhtMessageTimeout: normalized
|
||||
?? DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
});
|
||||
},
|
||||
setTorrentSeparateSeedSlots: (torrentSeparateSeedSlots) => set({ torrentSeparateSeedSlots }),
|
||||
setTorrentMaxConcurrentSeeds: (value) => set({
|
||||
torrentMaxConcurrentSeeds: Number.isInteger(value) && value >= 1 && value <= 64
|
||||
? value
|
||||
: DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
}),
|
||||
setCustomUserAgent: (customUserAgent) => set({ customUserAgent }),
|
||||
setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }),
|
||||
setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => {
|
||||
@@ -740,6 +765,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
torrentEnablePex: state.torrentEnablePex,
|
||||
torrentEnableLpd: state.torrentEnableLpd,
|
||||
torrentMaxOpenFiles: state.torrentMaxOpenFiles,
|
||||
torrentDhtMessageTimeout: state.torrentDhtMessageTimeout,
|
||||
torrentSeparateSeedSlots: state.torrentSeparateSeedSlots,
|
||||
torrentMaxConcurrentSeeds: state.torrentMaxConcurrentSeeds,
|
||||
torrentListenPort: state.torrentListenPort,
|
||||
torrentDhtListenPort: state.torrentDhtListenPort,
|
||||
torrentExternalIp: state.torrentExternalIp,
|
||||
@@ -800,6 +828,18 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
torrentEnableLpd: persistedBoolean(persisted.torrentEnableLpd, currentState.torrentEnableLpd),
|
||||
torrentMaxOpenFiles: normalizeTorrentMaxOpenFiles(persisted.torrentMaxOpenFiles)
|
||||
?? currentState.torrentMaxOpenFiles,
|
||||
torrentDhtMessageTimeout: normalizeTorrentDhtMessageTimeout(persisted.torrentDhtMessageTimeout)
|
||||
?? currentState.torrentDhtMessageTimeout,
|
||||
torrentSeparateSeedSlots: persistedBoolean(
|
||||
persisted.torrentSeparateSeedSlots,
|
||||
currentState.torrentSeparateSeedSlots
|
||||
),
|
||||
torrentMaxConcurrentSeeds: typeof persisted.torrentMaxConcurrentSeeds === 'number'
|
||||
&& Number.isInteger(persisted.torrentMaxConcurrentSeeds)
|
||||
&& persisted.torrentMaxConcurrentSeeds >= 1
|
||||
&& persisted.torrentMaxConcurrentSeeds <= 64
|
||||
? persisted.torrentMaxConcurrentSeeds
|
||||
: currentState.torrentMaxConcurrentSeeds,
|
||||
torrentListenPort: typeof persisted.torrentListenPort === 'string'
|
||||
? persisted.torrentListenPort
|
||||
: currentState.torrentListenPort,
|
||||
|
||||
@@ -4,6 +4,7 @@ const STARTABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
|
||||
'ready',
|
||||
'staged',
|
||||
'paused',
|
||||
'waitingToSeed',
|
||||
'failed',
|
||||
]);
|
||||
|
||||
@@ -12,6 +13,7 @@ const PAUSABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
|
||||
'queued',
|
||||
'downloading',
|
||||
'seeding',
|
||||
'waitingToSeed',
|
||||
'processing',
|
||||
'retrying',
|
||||
]);
|
||||
@@ -64,7 +66,7 @@ export const startActionLabel = (status: DownloadStatus): 'Start' | 'Resume' =>
|
||||
status === 'ready' || status === 'staged' || status === 'failed' ? 'Start' : 'Resume';
|
||||
|
||||
export const isTransferLocked = (status: DownloadStatus): boolean =>
|
||||
status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'retrying';
|
||||
status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying';
|
||||
|
||||
export const isIdentityLocked = (status: DownloadStatus): boolean =>
|
||||
isTransferLocked(status) || status === 'completed';
|
||||
|
||||
@@ -31,6 +31,7 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
|
||||
'downloading',
|
||||
'processing',
|
||||
'seeding',
|
||||
'waitingToSeed',
|
||||
'retrying',
|
||||
]);
|
||||
|
||||
@@ -70,6 +71,12 @@ 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;
|
||||
export const DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT = 10;
|
||||
export const MIN_TORRENT_DHT_MESSAGE_TIMEOUT = 1;
|
||||
export const MAX_TORRENT_DHT_MESSAGE_TIMEOUT = 600;
|
||||
export const DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS = 2;
|
||||
export const MIN_TORRENT_MAX_CONCURRENT_SEEDS = 1;
|
||||
export const MAX_TORRENT_MAX_CONCURRENT_SEEDS = 64;
|
||||
|
||||
const parseIntegerOption = (value: unknown): number | undefined => {
|
||||
if (typeof value === 'number') {
|
||||
@@ -105,6 +112,24 @@ export const normalizeTorrentMaxOpenFiles = (value: unknown): number | undefined
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export const normalizeTorrentDhtMessageTimeout = (value: unknown): number | undefined => {
|
||||
const parsed = parseIntegerOption(value);
|
||||
return parsed !== undefined
|
||||
&& parsed >= MIN_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
&& parsed <= MAX_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
? parsed
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export const normalizeTorrentMaxConcurrentSeeds = (value: unknown): number | undefined => {
|
||||
const parsed = parseIntegerOption(value);
|
||||
return parsed !== undefined
|
||||
&& parsed >= MIN_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
&& parsed <= MAX_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
? parsed
|
||||
: undefined;
|
||||
};
|
||||
|
||||
// Keep every filename component within the common cross-platform filesystem
|
||||
// limit. Count UTF-8 bytes because POSIX filesystems enforce bytes, while this
|
||||
// bound is also conservative for Windows filename components.
|
||||
|
||||
Reference in New Issue
Block a user