Skip to main content

irontide_session/
types.rs

1use std::collections::HashMap;
2use std::net::SocketAddr;
3use std::path::PathBuf;
4
5use bitflags::bitflags;
6use bytes::Bytes;
7use serde::{Deserialize, Serialize};
8use tokio::sync::oneshot;
9
10use irontide_storage::Bitfield;
11use irontide_wire::ExtHandshake;
12
13use crate::choker::{ChokingAlgorithm, SeedChokingAlgorithm};
14
15/// Configurable parameters for a torrent session.
16#[derive(Debug, Clone)]
17pub struct TorrentConfig {
18    /// TCP listen port for incoming peer connections.
19    pub listen_port: u16,
20    /// Maximum number of peer connections per torrent.
21    pub max_peers: usize,
22    /// Number of outstanding piece requests to maintain per peer.
23    pub target_request_queue: usize,
24    /// Directory where downloaded files are stored.
25    pub download_dir: PathBuf,
26    /// Enable DHT for peer discovery.
27    pub enable_dht: bool,
28    /// Enable Peer Exchange (BEP 11) for peer discovery.
29    pub enable_pex: bool,
30    /// Enable Fast Extension (BEP 6) for reject/suggest/allowed-fast messages.
31    pub enable_fast: bool,
32    /// Stop seeding after reaching this upload/download ratio (None = unlimited).
33    pub seed_ratio_limit: Option<f64>,
34    /// M171: Stop seeding after this many cumulative seeding seconds
35    /// (None = no limit). Mirrors qBt's "Maximum seeding time" preference.
36    pub seed_time_limit_secs: Option<u64>,
37    /// M171: Stop seeding after this many seconds of inactivity while in
38    /// the Seeding state (None = no limit). Mirrors qBt's "Maximum inactive
39    /// seeding time" preference.
40    pub inactive_seed_time_limit_secs: Option<u64>,
41    /// In end game mode, cancel duplicate requests when a piece completes.
42    pub strict_end_game: bool,
43    /// Upload rate limit in bytes/sec (0 = unlimited).
44    pub upload_rate_limit: u64,
45    /// Download rate limit in bytes/sec (0 = unlimited).
46    pub download_rate_limit: u64,
47    /// M224: Maximum unchoked upload slots per torrent (`-1` = unlimited).
48    /// Mirrors `Settings.max_uploads_per_torrent`; caps the choker's regular
49    /// unchoke set when `n >= 1`. `-1` falls back to the historical default
50    /// of 4 regular slots.
51    pub max_uploads_per_torrent: i32,
52    /// Connection encryption mode (MSE/PE).
53    pub encryption_mode: irontide_wire::mse::EncryptionMode,
54    /// Enable uTP (micro Transport Protocol) for peer connections.
55    pub enable_utp: bool,
56    /// Enable HTTP/web seeding (BEP 19, BEP 17).
57    pub enable_web_seed: bool,
58    /// Enable BEP 55 holepunch extension for NAT traversal.
59    pub enable_holepunch: bool,
60    /// Enable BEP 40 canonical peer priority for connection eviction.
61    pub enable_bep40_eviction: bool,
62    /// Maximum concurrent web seed connections.
63    pub max_web_seeds: usize,
64    /// M186: Base delay (seconds) for web seed exponential backoff.
65    pub web_seed_retry_base_secs: u64,
66    /// M186: Multiplier for web seed exponential backoff.
67    pub web_seed_retry_factor: u64,
68    /// M186: Maximum backoff (seconds) for web seed retry.
69    pub web_seed_retry_cap_secs: u64,
70    /// M186: Consecutive failures before permanently banning a web seed.
71    pub web_seed_max_failures: u32,
72    /// BEP 16: super seeding mode — reveal pieces one-per-peer for maximum diversity.
73    pub super_seeding: bool,
74    /// BEP 21: advertise upload-only status via extension handshake when seeding.
75    pub upload_only_announce: bool,
76    /// Number of concurrent piece verifications during torrent checking.
77    pub hashing_threads: usize,
78    /// Enable sequential (in-order) piece downloading.
79    pub sequential_download: bool,
80    /// Completed piece count below which the picker uses random selection to promote diversity.
81    pub initial_picker_threshold: u32,
82    /// Seconds below which a fast peer downloads a whole piece; if under this, picker grants
83    /// exclusive assignment (no block splitting).
84    pub whole_pieces_threshold: u32,
85    /// Seconds without data from a peer before marking it as snubbed.
86    pub snub_timeout_secs: u32,
87    /// Number of pieces ahead of the streaming cursor to prioritize.
88    pub readahead_pieces: u32,
89    /// When true, escalate streaming piece requests that exceed the mean RTT.
90    pub streaming_timeout_escalation: bool,
91    /// Maximum concurrent file stream readers per torrent.
92    pub max_concurrent_stream_reads: usize,
93    /// Proxy configuration for outbound peer connections.
94    pub proxy: crate::proxy::ProxyConfig,
95    /// Anonymous mode: suppress client identity in peer handshakes.
96    pub anonymous_mode: bool,
97    /// Share mode: relay pieces in memory without writing to disk.
98    /// Requires `enable_fast` for `RejectRequest` when evicting pieces.
99    pub share_mode: bool,
100    /// Whether this torrent should use I2P for peer connections.
101    pub enable_i2p: bool,
102    /// Whether to allow mixing I2P and clearnet peers.
103    pub allow_i2p_mixed: bool,
104    /// SSL listen port for SSL torrent connections (0 = disabled).
105    pub ssl_listen_port: u16,
106    /// Algorithm for ranking peers during seed-mode choking.
107    pub seed_choking_algorithm: SeedChokingAlgorithm,
108    /// Algorithm for determining the number of unchoke slots.
109    pub choking_algorithm: ChokingAlgorithm,
110    /// Prefer grouping piece requests within the same 4 MiB disk extent.
111    pub piece_extent_affinity: bool,
112    /// Enable sending `SuggestPiece` messages for cached pieces.
113    pub suggest_mode: bool,
114    /// Maximum number of pieces to suggest per peer.
115    pub max_suggest_pieces: usize,
116    /// Delay (ms) before announcing Have for a piece still being written to disk (0 = disabled).
117    pub predictive_piece_announce_ms: u64,
118    /// Mixed-mode TCP/uTP bandwidth allocation algorithm.
119    pub mixed_mode_algorithm: crate::rate_limiter::MixedModeAlgorithm,
120    /// Enable automatic sequential mode switching on partial-piece explosion.
121    pub auto_sequential: bool,
122    /// Storage allocation mode for disk I/O.
123    pub storage_mode: irontide_core::StorageMode,
124    /// Override pre-allocation strategy. `None` = derive from `storage_mode`.
125    pub preallocate_mode: Option<irontide_storage::PreallocateMode>,
126    /// Block request timeout in seconds before re-issuing (0 = disabled).
127    pub block_request_timeout_secs: u32,
128    /// Enable Local Service Discovery (BEP 14) for this torrent.
129    pub enable_lsd: bool,
130    /// Force all connections through the configured proxy.
131    pub force_proxy: bool,
132    /// Steal blocks from peers this many times slower than the requesting peer (0.0 = disabled).
133    pub steal_threshold_ratio: f64,
134    /// M149: Steal threshold multiplier when >90% complete.
135    pub steal_threshold_endgame: f64,
136    /// M133: Seconds without any wire message before disconnecting a peer (0 = disabled).
137    pub peer_read_timeout_secs: u64,
138    /// M133: Seconds before a stalled outgoing write disconnects a peer (0 = disabled).
139    pub peer_write_timeout_secs: u64,
140    /// M137: Seconds without Piece data before disconnecting a peer (0 = disabled).
141    pub data_contribution_timeout_secs: u64,
142    /// v0.187.3 / OV2 / 12A: post-handshake grace before Pass 0 eviction
143    /// (zero-throughput) can fire (0 = disable grace, legacy v0.187.2 behaviour).
144    pub pass0_grace_secs: u64,
145    /// v0.187.3 / 3A: sliding-window cap on proactive evictions per torrent
146    /// in any rolling 60s window.
147    pub proactive_evictions_per_minute_limit: u32,
148    /// v0.187.3: how long Pass 0 victims stay banned from reconnection.
149    pub eviction_ban_duration_secs: u64,
150    /// v0.187.3 / OV4: FIFO cap on the banned-peer set.
151    pub eviction_ban_set_cap: usize,
152    /// M138: Maximum peers to evict per choke rotation tick (0 = disabled).
153    pub choke_rotation_max_evictions: u32,
154    /// M138: Maximum concurrent outbound peer connections.
155    pub max_concurrent_connects: u16,
156    /// M147: Seconds without TCP SYN-ACK before soft reap disconnects.
157    pub connect_soft_timeout: u64,
158    /// M182: dispatch-channel reader-side spill cap (default 8).
159    pub dispatch_backlog_cap: usize,
160    /// M182: event-channel reader-side spill cap (default 32).
161    pub event_backlog_cap: usize,
162    /// M187 A/B: use actor-centralised dispatch (true) or per-peer CAS dispatch (false).
163    pub use_actor_dispatch: bool,
164    /// v0.186.1: when `true`, snapshot publishes that overflow the
165    /// dispatch channel disconnect the peer fatally (regression mode).
166    /// M178: Per-URL minimum interval between `PeerEvent::WebSeedProgress`
167    /// emissions. `0` disables the throttle (every chunk emits).
168    pub web_seed_progress_throttle_ms: u64,
169    /// URL security configuration for SSRF mitigation and IDNA checking.
170    pub url_security: crate::url_guard::UrlSecurityConfig,
171    /// Timeout in seconds for outbound TCP peer connections (0 = OS default).
172    pub peer_connect_timeout: u64,
173    /// DSCP (Differentiated Services Code Point) value for peer traffic sockets.
174    pub peer_dscp: u8,
175    /// Fixed per-peer request queue depth for the lifetime of the connection.
176    pub initial_queue_depth: usize,
177    /// Maximum per-peer request queue depth.
178    pub max_request_queue_depth: usize,
179    /// Deprecated — unused in the fixed-depth pipeline model. Retained for API
180    /// compatibility; was formerly used to scale BDP-based queue depth.
181    pub request_queue_time: f64,
182    /// Maximum BEP 9 metadata size in bytes accepted from peers.
183    pub max_metadata_size: u64,
184    /// Maximum wire protocol message size in bytes for the codec.
185    pub max_message_size: usize,
186    /// Maximum accepted piece length when adding a torrent.
187    pub max_piece_length: u64,
188    /// Maximum outstanding incoming requests per peer.
189    pub max_outstanding_requests: usize,
190    /// Maximum number of pieces simultaneously in-flight (downloaded but not
191    /// yet verified).
192    pub max_in_flight_pieces: usize,
193    /// M103: Enable block-level stealing for partially-downloaded pieces.
194    pub use_block_stealing: bool,
195    /// M132: Seconds between steal-queue population scans (0 = disabled).
196    pub steal_stale_piece_secs: u64,
197    /// M104: Fixed per-peer pipeline depth (concurrent requests per peer).
198    pub fixed_pipeline_depth: usize,
199    /// M120: Lock timing warning threshold in milliseconds (0 = disabled).
200    pub lock_warn_threshold_ms: u64,
201    /// M127: Enable direct I/O for filesystem storage (`O_DIRECT` / `F_NOCACHE`).
202    pub filesystem_direct_io: bool,
203    /// M170: Per-torrent category label (qBt-compat). `None` = uncategorised.
204    /// Resolved at add-time from the category registry; stored here so the
205    /// `TorrentActor` can surface it through `TorrentStats`.
206    pub category: Option<String>,
207    /// M171: Per-torrent tags (qBt-compat). Multi-valued, free-form. Stored
208    /// here so that `TorrentStats.tags` and resume-data persistence both read
209    /// from the same source of truth.
210    pub tags: Vec<String>,
211}
212
213impl Default for TorrentConfig {
214    fn default() -> Self {
215        Self {
216            listen_port: 6881,
217            max_peers: 128,
218            target_request_queue: 5,
219            download_dir: PathBuf::from("."),
220            enable_dht: true,
221            enable_pex: true,
222            enable_fast: false,
223            seed_ratio_limit: None,
224            seed_time_limit_secs: None,
225            inactive_seed_time_limit_secs: None,
226            strict_end_game: true,
227            upload_rate_limit: 0,
228            download_rate_limit: 0,
229            max_uploads_per_torrent: -1,
230            encryption_mode: irontide_wire::mse::EncryptionMode::Disabled,
231            enable_utp: true,
232            enable_web_seed: true,
233            enable_holepunch: true,
234            enable_bep40_eviction: true,
235            max_web_seeds: 4,
236            web_seed_retry_base_secs: 10,
237            web_seed_retry_factor: 6,
238            web_seed_retry_cap_secs: 3600,
239            web_seed_max_failures: 10,
240            super_seeding: false,
241            upload_only_announce: true,
242            hashing_threads: {
243                let cores = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
244                (cores / 4).clamp(2, 8)
245            },
246            sequential_download: false,
247            initial_picker_threshold: 4,
248            whole_pieces_threshold: 20,
249            snub_timeout_secs: 15,
250            readahead_pieces: 8,
251            streaming_timeout_escalation: true,
252            max_concurrent_stream_reads: 8,
253            proxy: crate::proxy::ProxyConfig::default(),
254            anonymous_mode: false,
255            share_mode: false,
256            enable_i2p: false,
257            allow_i2p_mixed: false,
258            ssl_listen_port: 0,
259            seed_choking_algorithm: SeedChokingAlgorithm::FastestUpload,
260            choking_algorithm: ChokingAlgorithm::FixedSlots,
261            piece_extent_affinity: true,
262            suggest_mode: false,
263            max_suggest_pieces: 10,
264            predictive_piece_announce_ms: 0,
265            mixed_mode_algorithm: crate::rate_limiter::MixedModeAlgorithm::PeerProportional,
266            auto_sequential: true,
267            storage_mode: irontide_core::StorageMode::Auto,
268            preallocate_mode: None,
269            block_request_timeout_secs: 60,
270            enable_lsd: true,
271            force_proxy: false,
272            steal_threshold_ratio: 10.0,
273            steal_threshold_endgame: 3.0,
274            peer_read_timeout_secs: 10,
275            peer_write_timeout_secs: 10,
276            data_contribution_timeout_secs: 0,
277            // v0.187.3 eviction defaults — mirror settings.rs values.
278            pass0_grace_secs: 60,
279            proactive_evictions_per_minute_limit: 30,
280            eviction_ban_duration_secs: 600,
281            eviction_ban_set_cap: 1024,
282            choke_rotation_max_evictions: 0,
283            max_concurrent_connects: 128,
284            connect_soft_timeout: 3,
285            dispatch_backlog_cap: 8,
286            event_backlog_cap: 32,
287            use_actor_dispatch: true,
288            web_seed_progress_throttle_ms: 250,
289            url_security: crate::url_guard::UrlSecurityConfig::default(),
290            peer_connect_timeout: 10,
291            peer_dscp: 0x08,
292            initial_queue_depth: 128,
293            max_request_queue_depth: 250,
294            request_queue_time: 3.0,
295            max_metadata_size: 4 * 1024 * 1024,
296            max_message_size: 16 * 1024 * 1024,
297            max_piece_length: 32 * 1024 * 1024,
298            max_outstanding_requests: 500,
299            max_in_flight_pieces: 512,
300            use_block_stealing: true,
301            steal_stale_piece_secs: 2,
302            fixed_pipeline_depth: 128,
303            lock_warn_threshold_ms: 50,
304            filesystem_direct_io: false,
305            category: None,
306            tags: Vec::new(),
307        }
308    }
309}
310
311impl From<&crate::settings::Settings> for TorrentConfig {
312    fn from(s: &crate::settings::Settings) -> Self {
313        Self {
314            listen_port: 0, // Each torrent gets a random port (matches make_torrent_config)
315            max_peers: s.max_peers_per_torrent,
316            target_request_queue: 5,
317            download_dir: s.download_dir.clone(),
318            enable_dht: s.enable_dht,
319            enable_pex: s.enable_pex,
320            enable_fast: s.enable_fast_extension,
321            seed_ratio_limit: s.seed_ratio_limit,
322            seed_time_limit_secs: s.seed_time_limit_secs,
323            inactive_seed_time_limit_secs: s.inactive_seed_time_limit_secs,
324            strict_end_game: s.strict_end_game,
325            upload_rate_limit: s.upload_rate_limit,
326            download_rate_limit: s.download_rate_limit,
327            max_uploads_per_torrent: s.max_uploads_per_torrent,
328            encryption_mode: s.encryption_mode,
329            enable_utp: s.enable_utp,
330            enable_web_seed: s.enable_web_seed,
331            enable_holepunch: s.enable_holepunch,
332            enable_bep40_eviction: s.enable_bep40_eviction,
333            max_web_seeds: s.max_web_seeds,
334            web_seed_retry_base_secs: s.web_seed_retry_base_secs,
335            web_seed_retry_factor: s.web_seed_retry_factor,
336            web_seed_retry_cap_secs: s.web_seed_retry_cap_secs,
337            web_seed_max_failures: s.web_seed_max_failures,
338            super_seeding: s.default_super_seeding,
339            upload_only_announce: s.upload_only_announce,
340            hashing_threads: s.hashing_threads,
341            sequential_download: false,
342            initial_picker_threshold: s.initial_picker_threshold,
343            whole_pieces_threshold: s.whole_pieces_threshold,
344            snub_timeout_secs: s.snub_timeout_secs,
345            readahead_pieces: s.readahead_pieces,
346            streaming_timeout_escalation: s.streaming_timeout_escalation,
347            max_concurrent_stream_reads: s.max_concurrent_stream_reads,
348            proxy: s.proxy.clone(),
349            anonymous_mode: s.anonymous_mode,
350            share_mode: s.default_share_mode,
351            enable_i2p: s.enable_i2p,
352            allow_i2p_mixed: s.allow_i2p_mixed,
353            ssl_listen_port: s.ssl_listen_port,
354            seed_choking_algorithm: s.seed_choking_algorithm,
355            choking_algorithm: s.choking_algorithm,
356            piece_extent_affinity: s.piece_extent_affinity,
357            suggest_mode: s.suggest_mode,
358            max_suggest_pieces: s.max_suggest_pieces,
359            predictive_piece_announce_ms: s.predictive_piece_announce_ms,
360            mixed_mode_algorithm: s.mixed_mode_algorithm,
361            auto_sequential: s.auto_sequential,
362            storage_mode: s.storage_mode,
363            preallocate_mode: s.preallocate_mode,
364            block_request_timeout_secs: s.block_request_timeout_secs,
365            enable_lsd: s.enable_lsd,
366            force_proxy: s.force_proxy,
367            steal_threshold_ratio: s.steal_threshold_ratio,
368            steal_threshold_endgame: s.steal_threshold_endgame,
369            peer_read_timeout_secs: s.peer_read_timeout_secs,
370            peer_write_timeout_secs: s.peer_write_timeout_secs,
371            data_contribution_timeout_secs: s.data_contribution_timeout_secs,
372            pass0_grace_secs: s.pass0_grace_secs,
373            proactive_evictions_per_minute_limit: s.proactive_evictions_per_minute_limit,
374            eviction_ban_duration_secs: s.eviction_ban_duration_secs,
375            eviction_ban_set_cap: s.eviction_ban_set_cap,
376            choke_rotation_max_evictions: s.choke_rotation_max_evictions,
377            max_concurrent_connects: s.max_concurrent_connects,
378            connect_soft_timeout: s.connect_soft_timeout,
379            dispatch_backlog_cap: s.dispatch_backlog_cap,
380            event_backlog_cap: s.event_backlog_cap,
381            use_actor_dispatch: s.use_actor_dispatch,
382            web_seed_progress_throttle_ms: s.web_seed_progress_throttle_ms,
383            url_security: crate::url_guard::UrlSecurityConfig::from(s),
384            peer_connect_timeout: s.peer_connect_timeout,
385            peer_dscp: s.peer_dscp,
386            initial_queue_depth: s.initial_queue_depth,
387            max_request_queue_depth: s.max_request_queue_depth,
388            request_queue_time: s.request_queue_time,
389            max_metadata_size: s.max_metadata_size,
390            max_message_size: s.max_message_size,
391            max_piece_length: s.max_piece_length,
392            max_outstanding_requests: s.max_outstanding_requests,
393            max_in_flight_pieces: s.max_in_flight_pieces,
394            use_block_stealing: s.use_block_stealing,
395            steal_stale_piece_secs: s.steal_stale_piece_secs,
396            fixed_pipeline_depth: s.fixed_pipeline_depth,
397            lock_warn_threshold_ms: s.lock_warn_threshold_ms,
398            filesystem_direct_io: s.filesystem_direct_io,
399            // M170: category is not in Settings (it lives in the per-torrent
400            // registry); the session actor overrides it on add if the caller
401            // specified one.
402            category: None,
403            // M171: tags are per-torrent, not session-wide. Populated by the
404            // session actor when adding via AddTorrentParams::with_tags().
405            tags: Vec::new(),
406        }
407    }
408}
409
410/// Current state of a torrent.
411#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
412pub enum TorrentState {
413    /// Waiting for peers to send torrent metadata via BEP 9.
414    FetchingMetadata,
415    /// Verifying existing data on disk against piece hashes.
416    Checking,
417    /// Actively downloading pieces from peers.
418    Downloading,
419    /// All pieces downloaded, awaiting transition to seeding.
420    Complete,
421    /// Upload-only: all pieces verified, serving to other peers.
422    Seeding,
423    /// Manually paused by the user. No peer connections maintained.
424    Paused,
425    /// Queued by auto-manage. Peers disconnected; queue evaluator will resume when a slot opens.
426    Queued,
427    /// Removed from the session. Terminal state.
428    Stopped,
429    /// Share mode: relay pieces in memory without writing to disk.
430    Sharing,
431}
432
433/// Aggregate statistics for a torrent.
434#[derive(Debug, Clone, Serialize)]
435pub struct TorrentStats {
436    // ── Original fields (unchanged) ──
437    /// Current torrent state.
438    pub state: TorrentState,
439    /// Total bytes downloaded (payload only).
440    pub downloaded: u64,
441    /// Total bytes uploaded (payload only).
442    pub uploaded: u64,
443    /// Number of pieces that have been verified.
444    pub pieces_have: u32,
445    /// Total number of pieces in the torrent.
446    pub pieces_total: u32,
447    /// Number of currently connected peers.
448    pub peers_connected: usize,
449    /// Number of known peers (connected + available).
450    pub peers_available: usize,
451    /// Progress of piece checking (0.0–1.0), meaningful when state is `Checking`.
452    pub checking_progress: f32,
453    /// Number of connected peers broken down by discovery source.
454    pub peers_by_source: HashMap<crate::peer_state::PeerSource, usize>,
455
456    // ── Identity ──
457    /// Info hashes (v1 SHA-1 and/or v2 SHA-256) for this torrent.
458    pub info_hashes: irontide_core::InfoHashes,
459    /// Display name from the torrent metadata.
460    pub name: String,
461
462    // ── State flags ──
463    /// Whether metadata has been received (always true for .torrent adds).
464    pub has_metadata: bool,
465    /// Whether we have all pieces and are seeding.
466    pub is_seeding: bool,
467    /// Whether all wanted pieces are downloaded (may differ from `is_seeding` with file priorities).
468    pub is_finished: bool,
469    /// Whether the torrent is paused.
470    pub is_paused: bool,
471    /// Whether the torrent is queued by auto-manage.
472    pub is_queued: bool,
473    /// Whether the torrent is auto-managed by the session queuing system.
474    pub auto_managed: bool,
475    /// Whether sequential piece downloading is enabled.
476    pub sequential_download: bool,
477    /// Whether BEP 16 super seeding mode is active.
478    pub super_seeding: bool,
479    /// Whether the user explicitly toggled seed-only mode (M159).
480    ///
481    /// Distinct from `is_seeding` which reflects download completion.
482    /// When `true`, the engine stops scheduling new block requests but
483    /// continues to serve uploads to interested peers.
484    #[serde(default)]
485    pub user_seed_mode: bool,
486    /// Whether the user force-started this torrent (bypassing queue limits).
487    #[serde(default)]
488    pub user_forced: bool,
489    /// Per-torrent seed ratio limit override (`None` = use session default).
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub seed_ratio_override: Option<f64>,
492    /// Whether we have accepted any incoming peer connections.
493    pub has_incoming: bool,
494    /// Whether resume data needs to be saved.
495    pub need_save_resume: bool,
496    /// Whether a storage move operation is in progress.
497    pub moving_storage: bool,
498
499    // ── Progress ──
500    /// Download progress as a fraction (0.0–1.0).
501    pub progress: f32,
502    /// Download progress in parts per million (`0–1_000_000`).
503    pub progress_ppm: u32,
504    /// Total bytes of verified (downloaded and hash-checked) data.
505    pub total_done: u64,
506    /// Total size of the torrent in bytes.
507    pub total: u64,
508    /// Total bytes of wanted data that have been verified.
509    pub total_wanted_done: u64,
510    /// Total bytes of wanted data (respecting file priorities).
511    pub total_wanted: u64,
512    /// Block (sub-piece request) size in bytes.
513    pub block_size: u32,
514
515    // ── Transfer (session counters) ──
516    /// Total bytes downloaded this session (including protocol overhead).
517    pub total_download: u64,
518    /// Total bytes uploaded this session (including protocol overhead).
519    pub total_upload: u64,
520    /// Total payload bytes downloaded this session.
521    pub total_payload_download: u64,
522    /// Total payload bytes uploaded this session.
523    pub total_payload_upload: u64,
524    /// Total bytes of data that failed hash check.
525    pub total_failed_bytes: u64,
526    /// Total bytes of redundant (duplicate) data received.
527    pub total_redundant_bytes: u64,
528
529    // ── Transfer (all-time, persisted) ──
530    /// All-time total bytes downloaded (persisted across sessions via resume data).
531    pub all_time_download: u64,
532    /// All-time total bytes uploaded (persisted across sessions via resume data).
533    pub all_time_upload: u64,
534
535    // ── Rates ──
536    /// Current download rate in bytes/sec (including protocol overhead).
537    pub download_rate: u64,
538    /// Current upload rate in bytes/sec (including protocol overhead).
539    pub upload_rate: u64,
540    /// Current payload download rate in bytes/sec.
541    pub download_payload_rate: u64,
542    /// Current payload upload rate in bytes/sec.
543    pub upload_payload_rate: u64,
544
545    // ── Connection details ──
546    /// Number of peers connected (including half-open).
547    pub num_peers: usize,
548    /// Number of connected peers that are seeds.
549    pub num_seeds: usize,
550    /// Number of complete copies known from tracker scrape (-1 = unknown).
551    pub num_complete: i32,
552    /// Number of incomplete copies known from tracker scrape (-1 = unknown).
553    pub num_incomplete: i32,
554    /// Total number of seeds across all trackers.
555    pub list_seeds: usize,
556    /// Total number of peers across all trackers.
557    pub list_peers: usize,
558    /// Number of peers available to connect to (not yet connected).
559    pub connect_candidates: usize,
560    /// Number of active peer connections (TCP + uTP).
561    pub num_connections: usize,
562    /// Number of unchoked peers we are uploading to.
563    pub num_uploads: usize,
564    /// M133: Total unique peer connection attempts during this session.
565    pub unique_peers_attempted: u64,
566    /// M137: Peer pipeline lifecycle snapshot.
567    #[serde(skip_serializing_if = "Option::is_none")]
568    pub pipeline: Option<crate::peer_states::PeerPipelineSnapshot>,
569    /// M138: Total peers evicted by proactive choke rotation.
570    pub choke_rotations: u64,
571    /// M149: Total number of piece-level steals performed.
572    pub piece_steals: u64,
573    /// M190: Total holepunch rendezvous requests relayed to other peers.
574    #[serde(default)]
575    pub holepunch_relayed: u64,
576    /// M187: Pieces queued for dispatch in `PieceTracker` (0 when no tracker).
577    #[serde(default)]
578    pub dispatch_pieces_queued: u32,
579    /// M187: Pieces currently in-flight in `PieceTracker` (0 when no tracker).
580    #[serde(default)]
581    pub dispatch_pieces_inflight: u32,
582
583    // ── Limits ──
584    /// Maximum number of connections for this torrent.
585    pub connections_limit: usize,
586    /// Maximum number of unchoke slots for this torrent.
587    pub uploads_limit: usize,
588
589    // ── Distributed copies ──
590    /// Number of full distributed copies available in the swarm.
591    pub distributed_full_copies: u32,
592    /// Fractional part of distributed copies (0–999).
593    pub distributed_fraction: u32,
594    /// Distributed copies as a float (full + fraction/1000).
595    pub distributed_copies: f32,
596
597    // ── Tracker ──
598    /// URL of the tracker we most recently announced to.
599    pub current_tracker: String,
600    /// Whether we are currently announcing to any tracker.
601    pub announcing_to_trackers: bool,
602    /// Whether we are currently announcing to LSD (Local Service Discovery).
603    pub announcing_to_lsd: bool,
604    /// Whether we are currently announcing to DHT.
605    pub announcing_to_dht: bool,
606
607    // ── Timestamps (POSIX seconds) ──
608    /// Time when the torrent was added to the session.
609    pub added_time: i64,
610    /// Time when the torrent completed downloading (0 = not completed).
611    pub completed_time: i64,
612    /// Last time a complete copy was seen in the swarm (0 = never).
613    pub last_seen_complete: i64,
614    /// Time of last upload activity (0 = never).
615    pub last_upload: i64,
616    /// Time of last download activity (0 = never).
617    pub last_download: i64,
618
619    // ── Durations (cumulative seconds) ──
620    /// Total seconds the torrent has been active (downloading or seeding).
621    pub active_duration: i64,
622    /// Total seconds the torrent has been in finished state.
623    pub finished_duration: i64,
624    /// Total seconds the torrent has been seeding.
625    pub seeding_duration: i64,
626
627    // ── Storage ──
628    /// Current save path for the torrent data.
629    pub save_path: String,
630
631    // ── Queue ──
632    /// Position in the session queue (-1 = not queued).
633    pub queue_position: i32,
634
635    // ── Error ──
636    /// Human-readable error message (empty = no error).
637    pub error: String,
638    /// Index of the file that caused the error (-1 = not file-specific).
639    pub error_file: i32,
640
641    // ── M170: qBt v2 *arr-minimal surface ──
642    /// User-assigned category label (qBt-compat). `None` = uncategorised.
643    #[serde(default)]
644    pub category: Option<String>,
645    /// Torrent creator string (`created by` field of the .torrent). `None`
646    /// for magnet-added torrents whose metadata has not yet resolved.
647    #[serde(default)]
648    pub created_by: Option<String>,
649    /// UNIX timestamp (seconds) from the torrent's `creation date` field.
650    /// `None` if absent from the .torrent or if metadata has not resolved.
651    #[serde(default)]
652    pub creation_date: Option<i64>,
653    /// Piece length in bytes (from the info dict). `0` if metadata has not
654    /// yet resolved for a magnet-added torrent.
655    #[serde(default)]
656    pub piece_size: u64,
657
658    // ── M171: qBt v2 parity ──
659    /// User-assigned tags (qBt-compat). Multi-valued. Empty = no tags.
660    #[serde(default)]
661    pub tags: Vec<String>,
662}
663
664impl Default for TorrentStats {
665    fn default() -> Self {
666        Self {
667            // Original fields
668            state: TorrentState::Paused,
669            downloaded: 0,
670            uploaded: 0,
671            pieces_have: 0,
672            pieces_total: 0,
673            peers_connected: 0,
674            peers_available: 0,
675            checking_progress: 0.0,
676            peers_by_source: HashMap::new(),
677
678            // Identity
679            info_hashes: irontide_core::InfoHashes::v1_only(irontide_core::Id20::from([0u8; 20])),
680            name: String::new(),
681
682            // State flags
683            has_metadata: false,
684            is_seeding: false,
685            is_finished: false,
686            is_paused: false,
687            is_queued: false,
688            auto_managed: false,
689            sequential_download: false,
690            super_seeding: false,
691            user_seed_mode: false,
692            user_forced: false,
693            seed_ratio_override: None,
694            has_incoming: false,
695            need_save_resume: false,
696            moving_storage: false,
697
698            // Progress
699            progress: 0.0,
700            progress_ppm: 0,
701            total_done: 0,
702            total: 0,
703            total_wanted_done: 0,
704            total_wanted: 0,
705            block_size: 16384,
706
707            // Transfer (session counters)
708            total_download: 0,
709            total_upload: 0,
710            total_payload_download: 0,
711            total_payload_upload: 0,
712            total_failed_bytes: 0,
713            total_redundant_bytes: 0,
714
715            // Transfer (all-time)
716            all_time_download: 0,
717            all_time_upload: 0,
718
719            // Rates
720            download_rate: 0,
721            upload_rate: 0,
722            download_payload_rate: 0,
723            upload_payload_rate: 0,
724
725            // Connection details
726            num_peers: 0,
727            num_seeds: 0,
728            num_complete: -1,
729            num_incomplete: -1,
730            list_seeds: 0,
731            list_peers: 0,
732            connect_candidates: 0,
733            num_connections: 0,
734            num_uploads: 0,
735            unique_peers_attempted: 0,
736            pipeline: None,
737            choke_rotations: 0,
738            piece_steals: 0,
739            holepunch_relayed: 0,
740            dispatch_pieces_queued: 0,
741            dispatch_pieces_inflight: 0,
742
743            // Limits
744            connections_limit: 0,
745            uploads_limit: 0,
746
747            // Distributed copies
748            distributed_full_copies: 0,
749            distributed_fraction: 0,
750            distributed_copies: 0.0,
751
752            // Tracker
753            current_tracker: String::new(),
754            announcing_to_trackers: false,
755            announcing_to_lsd: false,
756            announcing_to_dht: false,
757
758            // Timestamps
759            added_time: 0,
760            completed_time: 0,
761            last_seen_complete: 0,
762            last_upload: 0,
763            last_download: 0,
764
765            // Durations
766            active_duration: 0,
767            finished_duration: 0,
768            seeding_duration: 0,
769
770            // Storage
771            save_path: String::new(),
772
773            // Queue
774            queue_position: -1,
775
776            // Error
777            error: String::new(),
778            error_file: -1,
779
780            // M170
781            category: None,
782            created_by: None,
783            creation_date: None,
784            piece_size: 0,
785
786            // M171
787            tags: Vec::new(),
788        }
789    }
790}
791
792/// Lightweight summary of a torrent for the HTTP API.
793///
794/// A reduced view of [`TorrentStats`] with only the fields needed for list views.
795/// Constructed via `From<&TorrentStats>`.
796#[derive(Debug, Clone, Serialize)]
797pub struct TorrentSummary {
798    /// Hex-encoded v1 info hash (empty string for v2-only torrents).
799    pub info_hash: String,
800    /// Display name from the torrent metadata.
801    pub name: String,
802    /// Current torrent state.
803    pub state: TorrentState,
804    /// Download progress as a fraction (0.0–1.0).
805    pub progress: f64,
806    /// Current download rate in bytes/sec.
807    pub download_rate: u64,
808    /// Current upload rate in bytes/sec.
809    pub upload_rate: u64,
810    /// Total size of the torrent in bytes.
811    pub total_size: u64,
812    /// Number of connected peers.
813    pub num_peers: usize,
814    /// Number of connected seeders.
815    pub num_seeds: usize,
816    /// Total bytes uploaded across all sessions.
817    pub all_time_upload: u64,
818    /// Total bytes downloaded across all sessions.
819    pub all_time_download: u64,
820    /// Time when the torrent was added (POSIX seconds).
821    pub added_time: i64,
822    /// Whether the user has enabled seed-only mode for this torrent.
823    pub user_seed_mode: bool,
824    /// v0.187.3 / Bug 17: whether BEP 16 super-seeding is active. Surfaced
825    /// in the state column so users can see when super-seed mode is on
826    /// (previously displayed only "Seeding").
827    pub super_seeding: bool,
828    /// Whether the user force-started this torrent (bypassing queue limits).
829    pub user_forced: bool,
830    /// Progress of piece checking (0.0–1.0), meaningful when state is `Checking`.
831    pub checking_progress: f32,
832}
833
834impl From<&TorrentStats> for TorrentSummary {
835    fn from(s: &TorrentStats) -> Self {
836        Self {
837            info_hash: s.info_hashes.v1.map(|h| h.to_hex()).unwrap_or_default(),
838            name: s.name.clone(),
839            state: s.state,
840            progress: f64::from(s.progress),
841            download_rate: s.download_rate,
842            upload_rate: s.upload_rate,
843            total_size: s.total,
844            num_peers: s.num_peers,
845            num_seeds: s.num_seeds,
846            all_time_upload: s.all_time_upload,
847            all_time_download: s.all_time_download,
848            added_time: s.added_time,
849            user_seed_mode: s.user_seed_mode,
850            super_seeding: s.super_seeding,
851            user_forced: s.user_forced,
852            checking_progress: s.checking_progress,
853        }
854    }
855}
856
857/// Lightweight record of a single block write completion,
858/// carried in `PeerEvent::PieceBlocksBatch`.
859#[derive(Debug, Clone)]
860pub(crate) struct BlockEntry {
861    pub index: u32,  // piece index
862    pub begin: u32,  // byte offset within piece
863    pub length: u32, // block size (usually 16384)
864}
865
866/// Events sent from a `PeerTask` back to the `TorrentActor`.
867#[derive(Debug)]
868#[allow(dead_code)] // consumed by peer/torrent modules (not yet implemented)
869pub(crate) enum PeerEvent {
870    Bitfield {
871        peer_addr: SocketAddr,
872        bitfield: Bitfield,
873    },
874    Have {
875        peer_addr: SocketAddr,
876        index: u32,
877    },
878    /// BEP 54: Peer no longer has a piece (`lt_donthave` extension).
879    DontHave {
880        peer_addr: SocketAddr,
881        index: u32,
882    },
883    PieceData {
884        peer_addr: SocketAddr,
885        index: u32,
886        begin: u32,
887        data: Bytes,
888    },
889    /// Block completion from a peer task's direct disk write.
890    /// Sent immediately on each block write for real-time `TorrentActor` visibility.
891    PieceBlocksBatch {
892        peer_addr: SocketAddr,
893        blocks: Vec<BlockEntry>,
894    },
895    PeerChoking {
896        peer_addr: SocketAddr,
897        choking: bool,
898    },
899    PeerInterested {
900        peer_addr: SocketAddr,
901        interested: bool,
902    },
903    ExtHandshake {
904        peer_addr: SocketAddr,
905        handshake: ExtHandshake,
906    },
907    MetadataPiece {
908        peer_addr: SocketAddr,
909        piece: u32,
910        data: Bytes,
911        total_size: u64,
912    },
913    MetadataReject {
914        peer_addr: SocketAddr,
915        piece: u32,
916    },
917    PexPeers {
918        new_peers: Vec<SocketAddr>,
919    },
920    TrackersReceived {
921        tracker_urls: Vec<String>,
922    },
923    IncomingRequest {
924        peer_addr: SocketAddr,
925        index: u32,
926        begin: u32,
927        length: u32,
928    },
929    RejectRequest {
930        peer_addr: SocketAddr,
931        index: u32,
932        begin: u32,
933        length: u32,
934    },
935    AllowedFast {
936        peer_addr: SocketAddr,
937        index: u32,
938    },
939    SuggestPiece {
940        peer_addr: SocketAddr,
941        index: u32,
942    },
943    /// Peer successfully connected with a specific transport.
944    TransportIdentified {
945        peer_addr: SocketAddr,
946        transport: crate::rate_limiter::PeerTransport,
947    },
948    /// M140: BT handshake completed successfully — peer is now truly live.
949    /// Sent from `run_peer` after BT protocol handshake exchange validates
950    /// `info_hash` and `peer_id`. Triggers `mark_live()` in the actor.
951    HandshakeComplete {
952        peer_addr: SocketAddr,
953        /// M174: Whether MSE/PE negotiated RC4 encryption for this connection.
954        is_encrypted: bool,
955    },
956    Disconnected {
957        peer_addr: SocketAddr,
958        reason: Option<String>,
959    },
960    WebSeedPieceData {
961        url: String,
962        index: u32,
963        data: Bytes,
964    },
965    WebSeedError {
966        url: String,
967        piece: u32,
968        message: String,
969    },
970    /// M178: Periodic per-URL progress update from `WebSeedTask`. Coalesced
971    /// by the task's 250 ms throttle (configurable via
972    /// `Settings::web_seed_progress_throttle_ms`); the actor accumulates
973    /// `WebSeedStats` from these. `error == Some(_)` records a transition
974    /// into the errored state; the field is reset to `None` on recovery
975    /// emissions but the accumulated `last_error` on `WebSeedStats`
976    /// persists per Issue 2.2.
977    WebSeedProgress {
978        url: String,
979        bytes: u64,
980        rate_bps: u64,
981        error: Option<String>,
982    },
983    /// M186: Web seed completed backoff and is ready for new piece assignments.
984    WebSeedRetryReady {
985        url: String,
986    },
987    /// M186: Web seed permanently failed after max consecutive failures.
988    WebSeedPermanentFailure {
989        url: String,
990    },
991    /// BEP 52: Received hash response from peer.
992    HashesReceived {
993        peer_addr: SocketAddr,
994        request: irontide_core::HashRequest,
995        hashes: Vec<irontide_core::Id32>,
996    },
997    /// BEP 52: Peer rejected our hash request.
998    HashRequestRejected {
999        peer_addr: SocketAddr,
1000        request: irontide_core::HashRequest,
1001    },
1002    /// BEP 52: Peer sent a hash request to us.
1003    IncomingHashRequest {
1004        peer_addr: SocketAddr,
1005        request: irontide_core::HashRequest,
1006    },
1007    /// BEP 55: Received a Rendezvous request (we are the relay).
1008    HolepunchRendezvous {
1009        peer_addr: SocketAddr,
1010        target: SocketAddr,
1011    },
1012    /// BEP 55: Received a Connect message (we should initiate simultaneous connect).
1013    HolepunchConnect {
1014        peer_addr: SocketAddr,
1015        target: SocketAddr,
1016    },
1017    /// BEP 55: Received an Error message from the relay.
1018    HolepunchError {
1019        peer_addr: SocketAddr,
1020        target: SocketAddr,
1021        error_code: u32,
1022    },
1023    /// MSE handshake failed — peer is being retried with a different encryption mode.
1024    /// Carries the new command channel sender so the `TorrentActor` can
1025    /// update its `PeerState`.
1026    MseRetry {
1027        peer_addr: SocketAddr,
1028        cmd_tx: tokio::sync::mpsc::Sender<PeerCommand>,
1029    },
1030    /// Peer released a piece it was downloading (choke, error, disconnect).
1031    PieceReleased {
1032        peer_addr: SocketAddr,
1033        piece: u32,
1034    },
1035    /// M187: Requester asks the actor to acquire a piece via `PieceTracker`.
1036    /// Actor responds with the piece index via the oneshot, or `NoneAvailable`
1037    /// if the peer has no dispatchable pieces.
1038    AcquirePiece {
1039        peer_addr: SocketAddr,
1040        response_tx: tokio::sync::oneshot::Sender<crate::piece_reservation::AcquireResponse>,
1041    },
1042}
1043
1044/// Commands sent from the `TorrentActor` to a `PeerTask`.
1045#[derive(Debug)]
1046#[allow(dead_code)] // consumed by peer/torrent modules (not yet implemented)
1047pub(crate) enum PeerCommand {
1048    Request {
1049        index: u32,
1050        begin: u32,
1051        length: u32,
1052    },
1053    Cancel {
1054        index: u32,
1055        begin: u32,
1056        length: u32,
1057    },
1058    SetChoking(bool),
1059    SetInterested(bool),
1060    Have(u32),
1061    RequestMetadata {
1062        piece: u32,
1063    },
1064    RejectRequest {
1065        index: u32,
1066        begin: u32,
1067        length: u32,
1068    },
1069    AllowedFast(u32),
1070    SendPiece {
1071        index: u32,
1072        begin: u32,
1073        data: Bytes,
1074    },
1075    /// Send an updated extension handshake (e.g. BEP 21 upload-only).
1076    SendExtHandshake(irontide_wire::ExtHandshake),
1077    /// BEP 6: Suggest a piece to the peer.
1078    SuggestPiece(u32),
1079    /// BEP 52: Send a hash request to the peer.
1080    SendHashRequest(irontide_core::HashRequest),
1081    /// BEP 52: Send hashes in response to a peer's request.
1082    SendHashes {
1083        request: irontide_core::HashRequest,
1084        hashes: Vec<irontide_core::Id32>,
1085    },
1086    /// BEP 52: Reject a peer's hash request.
1087    SendHashReject(irontide_core::HashRequest),
1088    /// BEP 11: Send a PEX message to this peer.
1089    SendPex {
1090        message: crate::pex::PexMessage,
1091    },
1092    /// BEP 55: Send a holepunch message to this peer.
1093    SendHolepunch(irontide_wire::HolepunchMessage),
1094    /// Update the piece count after BEP 9 metadata assembly.
1095    UpdateNumPieces(u32),
1096    /// M159: Tell the peer task to stop dispatching block requests.
1097    ///
1098    /// Translated to `DispatchCommand::Stop` by the reader loop. The requester
1099    /// transitions back to the idle state waiting for a fresh `StartRequesting`.
1100    /// Uploads are unaffected.
1101    StopRequesting,
1102    /// M75: Actor sends reservation state to peer task for integrated dispatch.
1103    /// Sent after metadata download (magnet) or at peer connection (non-magnet).
1104    StartRequesting {
1105        piece_notify: std::sync::Arc<tokio::sync::Notify>,
1106        disk_handle: Option<crate::disk::DiskHandle>,
1107        write_error_tx: tokio::sync::mpsc::Sender<crate::disk::DiskWriteError>,
1108        lengths: irontide_core::Lengths,
1109    },
1110    Shutdown,
1111}
1112
1113/// Helper trait combining [`AsyncRead`] + [`AsyncWrite`] for trait-object erasure.
1114///
1115/// Rust doesn't allow `dyn AsyncRead + AsyncWrite` directly, so this trait
1116/// combines both into a single trait that can be used as a trait object.
1117pub(crate) trait AsyncReadWrite:
1118    tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send
1119{
1120}
1121
1122impl<T> AsyncReadWrite for T where T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {}
1123
1124/// Boxed async stream (`AsyncRead` + `AsyncWrite` + Unpin + Send) with a Debug impl.
1125///
1126/// Used for incoming SSL peer connections where the concrete TLS type is erased.
1127pub(crate) struct BoxedAsyncStream(pub Box<dyn AsyncReadWrite>);
1128
1129impl std::fmt::Debug for BoxedAsyncStream {
1130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1131        f.write_str("BoxedAsyncStream(..)")
1132    }
1133}
1134
1135/// Commands sent from a `TorrentHandle` to the `TorrentActor`.
1136#[derive(Debug)]
1137#[allow(dead_code)] // consumed by torrent module (not yet implemented)
1138pub(crate) enum TorrentCommand {
1139    AddPeers {
1140        peers: Vec<SocketAddr>,
1141        source: crate::peer_state::PeerSource,
1142    },
1143    Stats {
1144        reply: oneshot::Sender<TorrentStats>,
1145    },
1146    Pause,
1147    Queue,
1148    Resume,
1149    /// Resume the torrent bypassing queue limits (force-start).
1150    ForceResume,
1151    Shutdown,
1152    /// M170: update the category label recorded on this torrent. `None`
1153    /// clears the label (uncategorised).
1154    SetCategory {
1155        category: Option<String>,
1156        reply: oneshot::Sender<()>,
1157    },
1158    /// M171: replace the torrent's tag set wholesale (qBt-compat).
1159    ///
1160    /// Mirrors qBt's `addTags` / `removeTags` wire behaviour at the API
1161    /// layer — always a wholesale replacement at the engine layer.
1162    SetTags {
1163        tags: Vec<String>,
1164        reply: oneshot::Sender<()>,
1165    },
1166    /// M171 Lane B: snapshot the list of configured web seed URLs
1167    /// (BEP 19 `url-list` + BEP 17 `httpseeds`).
1168    ///
1169    /// Returns an empty vec when metadata hasn't resolved yet.
1170    GetWebSeeds {
1171        reply: oneshot::Sender<Vec<String>>,
1172    },
1173    /// M171 Lane B: snapshot the per-piece state array as qBt codes.
1174    ///
1175    /// Each element is one of {0: not downloaded, 1: downloading,
1176    /// 2: downloaded + checked}. Returns an empty vec when metadata
1177    /// hasn't resolved yet (piece count unknown).
1178    GetPieceStates {
1179        reply: oneshot::Sender<Vec<u8>>,
1180    },
1181    /// M171 Lane B: return a paginated slice of per-piece hash strings.
1182    ///
1183    /// v1 / hybrid torrents return SHA-1 hashes (40-char hex). v2-only
1184    /// torrents return SHA-256 hashes (64-char hex). Returns an empty
1185    /// vec when metadata hasn't resolved yet, or when `offset` is past
1186    /// the end of the hash list.
1187    GetPieceHashes {
1188        offset: u32,
1189        limit: u32,
1190        reply: oneshot::Sender<Vec<String>>,
1191    },
1192    SaveResumeData {
1193        reply: oneshot::Sender<crate::Result<irontide_core::FastResumeData>>,
1194    },
1195    SetFilePriority {
1196        index: usize,
1197        priority: irontide_core::FilePriority,
1198        reply: oneshot::Sender<crate::Result<()>>,
1199    },
1200    FilePriorities {
1201        reply: oneshot::Sender<Vec<irontide_core::FilePriority>>,
1202    },
1203    ForceReannounce,
1204    TrackerList {
1205        reply: oneshot::Sender<Vec<crate::tracker_manager::TrackerInfo>>,
1206    },
1207    Scrape {
1208        reply: oneshot::Sender<Option<(String, irontide_tracker::ScrapeInfo)>>,
1209    },
1210    /// Incoming peer routed from the session-level accept loop (TCP or uTP).
1211    IncomingPeer {
1212        stream: crate::transport::BoxedStream,
1213        addr: SocketAddr,
1214    },
1215    /// Open a streaming reader for a file within the torrent.
1216    OpenFile {
1217        file_index: usize,
1218        reply: oneshot::Sender<crate::Result<crate::streaming::FileStreamHandle>>,
1219    },
1220    /// Update the external IP for BEP 40 peer priority calculation.
1221    UpdateExternalIp {
1222        ip: std::net::IpAddr,
1223    },
1224    /// Move torrent data files to a new directory.
1225    MoveStorage {
1226        new_path: PathBuf,
1227        reply: oneshot::Sender<crate::Result<()>>,
1228    },
1229    /// Incoming SSL peer routed from the session-level SSL listener (M42).
1230    ///
1231    /// The TLS handshake has already been completed by the session actor.
1232    SpawnSslPeer {
1233        addr: SocketAddr,
1234        stream: BoxedAsyncStream,
1235    },
1236    /// Set the per-torrent download rate limit (bytes/sec, 0 = unlimited).
1237    SetDownloadLimit {
1238        bytes_per_sec: u64,
1239        reply: oneshot::Sender<()>,
1240    },
1241    /// Set the per-torrent upload rate limit (bytes/sec, 0 = unlimited).
1242    SetUploadLimit {
1243        bytes_per_sec: u64,
1244        reply: oneshot::Sender<()>,
1245    },
1246    /// Get the current per-torrent download rate limit (bytes/sec, 0 = unlimited).
1247    DownloadLimit {
1248        reply: oneshot::Sender<u64>,
1249    },
1250    /// Get the current per-torrent upload rate limit (bytes/sec, 0 = unlimited).
1251    UploadLimit {
1252        reply: oneshot::Sender<u64>,
1253    },
1254    /// Enable or disable sequential (in-order) piece downloading.
1255    SetSequentialDownload {
1256        enabled: bool,
1257        reply: oneshot::Sender<()>,
1258    },
1259    /// Query whether sequential downloading is enabled.
1260    IsSequentialDownload {
1261        reply: oneshot::Sender<bool>,
1262    },
1263    /// Enable or disable BEP 16 super seeding mode.
1264    SetSuperSeeding {
1265        enabled: bool,
1266        reply: oneshot::Sender<()>,
1267    },
1268    /// Query whether super seeding mode is enabled.
1269    IsSuperSeeding {
1270        reply: oneshot::Sender<bool>,
1271    },
1272    /// Enable or disable user-requested seed-only mode (M159).
1273    ///
1274    /// When enabled, the torrent stops scheduling new block requests and
1275    /// cancels all in-flight requests, but continues to serve uploads to
1276    /// interested peers. Mirrors libtorrent's `seed_mode` flag.
1277    SetSeedMode {
1278        enabled: bool,
1279        reply: oneshot::Sender<()>,
1280    },
1281    /// Override the per-torrent seed ratio limit (`None` = use session default).
1282    SetSeedRatioLimit {
1283        limit: Option<f64>,
1284        reply: oneshot::Sender<()>,
1285    },
1286    /// Add a new tracker URL (fire-and-forget at torrent level).
1287    AddTracker {
1288        url: String,
1289    },
1290    /// Replace all tracker URLs with a new set.
1291    ReplaceTrackers {
1292        urls: Vec<String>,
1293        reply: oneshot::Sender<()>,
1294    },
1295    /// Trigger a full piece verification (force recheck).
1296    ForceRecheck {
1297        reply: oneshot::Sender<crate::Result<()>>,
1298    },
1299    /// Rename a file within the torrent on disk.
1300    RenameFile {
1301        file_index: usize,
1302        new_name: String,
1303        reply: oneshot::Sender<crate::Result<()>>,
1304    },
1305    /// Set the per-torrent maximum number of connections (0 = use global default).
1306    SetMaxConnections {
1307        limit: usize,
1308        reply: oneshot::Sender<()>,
1309    },
1310    /// Get the current per-torrent maximum connection limit.
1311    MaxConnections {
1312        reply: oneshot::Sender<usize>,
1313    },
1314    /// Set the per-torrent maximum number of unchoke slots (upload slots).
1315    SetMaxUploads {
1316        limit: usize,
1317        reply: oneshot::Sender<()>,
1318    },
1319    /// Get the current per-torrent maximum unchoke slots (upload slots).
1320    MaxUploads {
1321        reply: oneshot::Sender<usize>,
1322    },
1323    /// Get per-peer details for all connected peers.
1324    GetPeerInfo {
1325        reply: oneshot::Sender<Vec<PeerInfo>>,
1326    },
1327    /// Get in-flight piece download status (the download queue).
1328    GetDownloadQueue {
1329        reply: oneshot::Sender<Vec<PartialPieceInfo>>,
1330    },
1331    /// Check whether a specific piece has been downloaded.
1332    HavePiece {
1333        index: u32,
1334        reply: oneshot::Sender<bool>,
1335    },
1336    /// Get per-piece availability counts from connected peers.
1337    PieceAvailability {
1338        reply: oneshot::Sender<Vec<u32>>,
1339    },
1340    /// Get per-file bytes-downloaded progress.
1341    FileProgress {
1342        reply: oneshot::Sender<Vec<u64>>,
1343    },
1344    /// Get the torrent's identity hashes (v1 and/or v2).
1345    InfoHashes {
1346        reply: oneshot::Sender<irontide_core::InfoHashes>,
1347    },
1348    /// Get the full v1 metainfo (None for magnet links before metadata received).
1349    TorrentFile {
1350        reply: oneshot::Sender<Option<irontide_core::TorrentMetaV1>>,
1351    },
1352    /// Get the full v2 metainfo (None if not a v2/hybrid torrent or before metadata received).
1353    TorrentFileV2 {
1354        reply: oneshot::Sender<Option<irontide_core::TorrentMetaV2>>,
1355    },
1356    /// Force an immediate DHT announce (fire-and-forget at torrent level).
1357    ForceDhtAnnounce,
1358    /// Read all data for a specific piece from disk.
1359    ReadPiece {
1360        index: u32,
1361        reply: oneshot::Sender<crate::Result<bytes::Bytes>>,
1362    },
1363    /// Flush the disk write cache for this torrent.
1364    FlushCache {
1365        reply: oneshot::Sender<crate::Result<()>>,
1366    },
1367    /// Clear the error state and resume if the torrent was paused due to error.
1368    ClearError,
1369    /// Get per-file open/mode status based on torrent state.
1370    FileStatus {
1371        reply: oneshot::Sender<Vec<crate::types::FileStatus>>,
1372    },
1373    /// Read the current torrent flags as a bitflag set.
1374    Flags {
1375        reply: oneshot::Sender<TorrentFlags>,
1376    },
1377    /// Set (enable) the specified torrent flags.
1378    SetFlags {
1379        flags: TorrentFlags,
1380        reply: oneshot::Sender<()>,
1381    },
1382    /// Unset (disable) the specified torrent flags.
1383    UnsetFlags {
1384        flags: TorrentFlags,
1385        reply: oneshot::Sender<()>,
1386    },
1387    /// Immediately initiate a peer connection to the given address.
1388    ConnectPeer {
1389        addr: SocketAddr,
1390    },
1391    /// Clear the `need_save_resume` dirty flag after a successful file save (M161).
1392    ClearSaveResumeFlag,
1393    /// Restore a piece bitmap from resume data (M161 Phase 4).
1394    ///
1395    /// Replaces the chunk tracker's bitfield with the provided raw piece bytes.
1396    /// The handler validates the bitfield length before applying.
1397    RestoreResumeBitmap {
1398        /// Raw piece bitfield bytes from resume data.
1399        pieces: Vec<u8>,
1400        /// Reply with `Ok(())` on success or an error if validation fails.
1401        reply: oneshot::Sender<crate::Result<()>>,
1402    },
1403    /// M178: Restore the per-URL web-seed stats map from resume data.
1404    ///
1405    /// Used by the post-add resume-restore path so that downloaded-byte
1406    /// counters and last-error / consecutive-failure state survive app
1407    /// restart (Tension-1 fast-resume persistence).
1408    RestoreWebSeedStats {
1409        /// Map of URL → stats from `FastResumeData::web_seed_stats`.
1410        stats: HashMap<String, irontide_core::WebSeedStats>,
1411        /// Reply with `Ok(())` on success.
1412        reply: oneshot::Sender<crate::Result<()>>,
1413    },
1414    /// M178 (Lane B3 / TODO-2): cumulative `(pex, lsd)` unique-peer counts
1415    /// for the GUI Trackers tab + qBt v2 trackers pseudo-tracker rows.
1416    GetPeerSourceCounts {
1417        /// Reply with `(pex_peer_count, lsd_peer_count)`.
1418        reply: oneshot::Sender<(usize, usize)>,
1419    },
1420    /// Per-peer cumulative unchoke duration over the torrent's lifetime.
1421    /// Keyed by `SocketAddr`; merges live `PeerState` accumulators with
1422    /// the durable per-torrent map so reconnects preserve history.
1423    /// Used by libtorrent-mirror perf scenarios that gate on
1424    /// optimistic-unchoke fairness.
1425    QueryUnchokeDurations {
1426        /// Reply with one entry per peer ever unchoked by us.
1427        reply: oneshot::Sender<HashMap<SocketAddr, std::time::Duration>>,
1428    },
1429    /// M178 (Lane C): snapshot of per-URL web-seed stats for the qBt v2
1430    /// `/api/v2/torrents/webseeds` endpoint and the GUI HTTP Sources tab.
1431    GetWebSeedStats {
1432        /// Reply with one entry per URL with active stats.
1433        reply: oneshot::Sender<Vec<irontide_core::WebSeedStats>>,
1434    },
1435    /// M147: Pre-resolved metadata from the background `MetadataResolver`.
1436    ///
1437    /// Sent by `SessionActor::spawn_metadata_resolver()` when the background
1438    /// resolver successfully obtains torrent metadata before the `TorrentActor`'s
1439    /// own `FetchingMetadata` phase completes. This is a race: first to resolve
1440    /// wins; the other path's result is silently discarded.
1441    PreResolvedMetadata {
1442        /// Raw bencoded info dictionary bytes.
1443        info_bytes: Vec<u8>,
1444        /// Peers that were successfully connected during metadata resolution
1445        /// (for pre-seeding the peer pipeline).
1446        peers: Vec<SocketAddr>,
1447    },
1448    /// v0.173.1: single source of truth for torrent metadata.
1449    ///
1450    /// Returns `Some(meta.clone())` if the actor has assembled metadata (via
1451    /// its own `ut_metadata` fetch or a `PreResolvedMetadata` push), else
1452    /// `None`. Replaces `SessionActor.TorrentEntry.meta` as the authoritative
1453    /// source — see class-A archaeology in the v0.173.1 plan file at
1454    /// `docs/plans/2026-04-22-irontide-v0.173.1-qbt-v2-bug-sweep.md`.
1455    GetMeta {
1456        /// Reply with `Some(meta)` when available, `None` for a magnet that
1457        /// hasn't resolved metadata yet.
1458        reply: oneshot::Sender<Option<irontide_core::TorrentMetaV1>>,
1459    },
1460    /// **TEST-ONLY (v0.173.2).** Synchronously inject a fully-assembled info-dict
1461    /// payload via the same internal handler as the M147 `PreResolvedMetadata`
1462    /// path, but with backpressure + completion-ack so tests can rely on the
1463    /// metadata being processed when the future resolves. The M147 fast-path
1464    /// uses `try_send` and is fire-and-forget by design (resolver shouldn't
1465    /// block); this variant is the synchronous-test counterpart.
1466    #[cfg(feature = "test-util")]
1467    TestInjectMetadata {
1468        /// Raw bencoded info dictionary bytes.
1469        info_bytes: Vec<u8>,
1470        /// Completion ack — fired after `handle_pre_resolved_metadata` returns.
1471        reply: oneshot::Sender<()>,
1472    },
1473    /// v0.187.1: broadcast changed session-level settings to a running torrent.
1474    ///
1475    /// Patches `self.config` fields so that settings changes made via
1476    /// Preferences → Apply take effect on existing torrents, not just
1477    /// newly-added ones.
1478    UpdateSettings(SettingsDelta),
1479}
1480
1481/// Delta of session-level settings that should be propagated to running
1482/// torrents. Built by diffing old vs new `Settings` after `apply_settings`.
1483/// Only non-`None` fields are applied; `None` means "unchanged".
1484#[derive(Debug, Clone, Default)]
1485#[allow(
1486    clippy::option_option,
1487    reason = "outer Option = unchanged, inner Option = the actual Settings value (None = unlimited)"
1488)]
1489pub struct SettingsDelta {
1490    pub enable_dht: Option<bool>,
1491    pub enable_pex: Option<bool>,
1492    pub max_peers: Option<usize>,
1493    pub seed_ratio_limit: Option<Option<f64>>,
1494    pub seed_time_limit_secs: Option<Option<u64>>,
1495    pub inactive_seed_time_limit_secs: Option<Option<u64>>,
1496    pub max_ratio_action: Option<crate::MaxRatioAction>,
1497    pub encryption_mode: Option<irontide_wire::mse::EncryptionMode>,
1498    pub anonymous_mode: Option<bool>,
1499    /// M224: per-torrent upload slot cap (`-1` = unlimited, `n >= 1` = cap).
1500    /// Propagated live to in-flight torrents via `handle_update_settings`.
1501    pub max_uploads_per_torrent: Option<i32>,
1502    /// M225 Step 1: periodic resume-save interval
1503    pub save_resume_interval_secs: Option<u64>,
1504    /// M225 Step 2: pieces verify concurrency cap
1505    pub hashing_threads: Option<usize>,
1506    /// M225 Step 3: IP filter toggle
1507    pub ip_filter_enabled: Option<bool>,
1508    // ── M226: Notifications / paths / watched folder / network ──
1509    /// M226: Fire OS desktop notification on torrent complete.
1510    pub notify_on_complete: Option<bool>,
1511    /// M226: Fire OS desktop notification on torrent error.
1512    pub notify_on_error: Option<bool>,
1513    /// M226: Path to a program to run on torrent completion. Nested `Option`
1514    /// — outer `None` = unchanged, inner `None` = clear to default (no program).
1515    pub on_complete_program: Option<Option<std::path::PathBuf>>,
1516    /// M226: Whether to use a separate in-progress directory.
1517    pub use_incomplete_dir: Option<bool>,
1518    /// M226: In-progress directory. Nested `Option` semantics as above.
1519    pub incomplete_dir: Option<Option<std::path::PathBuf>>,
1520    /// M226: Default `skip_checking` for new torrents.
1521    pub default_skip_hash_check: Option<bool>,
1522    /// M226: Append `.!ut` to in-progress files.
1523    pub incomplete_extension_enabled: Option<bool>,
1524    /// M226: Folder to watch for new `.torrent` files. Nested `Option`.
1525    pub watched_folder: Option<Option<std::path::PathBuf>>,
1526    /// M226: Delete source `.torrent` after successful add (else rename).
1527    pub delete_torrent_after_add: Option<bool>,
1528    /// M226: Move completed torrents to `move_completed_to`.
1529    pub move_completed_enabled: Option<bool>,
1530    /// M226: Move-completed destination. Nested `Option`.
1531    pub move_completed_to: Option<Option<std::path::PathBuf>>,
1532    /// M226: Auto-refresh the IP filter when the source file changes.
1533    /// (Field already existed in Settings; M226 wires the delta plumbing.)
1534    pub ip_filter_auto_refresh: Option<bool>,
1535    /// M226: Enable `HTTPS` for the qBt v2 `WebUI` listener (stored only).
1536    pub web_ui_https_enabled: Option<bool>,
1537    /// M226: Bind peer listeners to a specific network interface. Nested `Option`.
1538    pub network_interface: Option<Option<String>>,
1539    /// M226: Default value for `AddTorrentParams.paused` when caller passes `None`.
1540    pub default_add_paused: Option<bool>,
1541}
1542
1543impl SettingsDelta {
1544    #[must_use]
1545    pub fn from_diff(old: &crate::settings::Settings, new: &crate::settings::Settings) -> Self {
1546        let mut d = Self::default();
1547        if old.enable_dht != new.enable_dht {
1548            d.enable_dht = Some(new.enable_dht);
1549        }
1550        if old.enable_pex != new.enable_pex {
1551            d.enable_pex = Some(new.enable_pex);
1552        }
1553        if old.max_peers_per_torrent != new.max_peers_per_torrent {
1554            d.max_peers = Some(new.max_peers_per_torrent);
1555        }
1556        if old.seed_ratio_limit != new.seed_ratio_limit {
1557            d.seed_ratio_limit = Some(new.seed_ratio_limit);
1558        }
1559        if old.seed_time_limit_secs != new.seed_time_limit_secs {
1560            d.seed_time_limit_secs = Some(new.seed_time_limit_secs);
1561        }
1562        if old.inactive_seed_time_limit_secs != new.inactive_seed_time_limit_secs {
1563            d.inactive_seed_time_limit_secs = Some(new.inactive_seed_time_limit_secs);
1564        }
1565        if old.max_ratio_action != new.max_ratio_action {
1566            d.max_ratio_action = Some(new.max_ratio_action);
1567        }
1568        if old.encryption_mode != new.encryption_mode {
1569            d.encryption_mode = Some(new.encryption_mode);
1570        }
1571        if old.anonymous_mode != new.anonymous_mode {
1572            d.anonymous_mode = Some(new.anonymous_mode);
1573        }
1574        if old.max_uploads_per_torrent != new.max_uploads_per_torrent {
1575            d.max_uploads_per_torrent = Some(new.max_uploads_per_torrent);
1576        }
1577        if old.save_resume_interval_secs != new.save_resume_interval_secs {
1578            d.save_resume_interval_secs = Some(new.save_resume_interval_secs);
1579        }
1580        if old.hashing_threads != new.hashing_threads {
1581            d.hashing_threads = Some(new.hashing_threads);
1582        }
1583        if old.ip_filter_enabled != new.ip_filter_enabled {
1584            d.ip_filter_enabled = Some(new.ip_filter_enabled);
1585        }
1586        // ── M226 ──
1587        if old.notify_on_complete != new.notify_on_complete {
1588            d.notify_on_complete = Some(new.notify_on_complete);
1589        }
1590        if old.notify_on_error != new.notify_on_error {
1591            d.notify_on_error = Some(new.notify_on_error);
1592        }
1593        if old.on_complete_program != new.on_complete_program {
1594            d.on_complete_program = Some(new.on_complete_program.clone());
1595        }
1596        if old.use_incomplete_dir != new.use_incomplete_dir {
1597            d.use_incomplete_dir = Some(new.use_incomplete_dir);
1598        }
1599        if old.incomplete_dir != new.incomplete_dir {
1600            d.incomplete_dir = Some(new.incomplete_dir.clone());
1601        }
1602        if old.default_skip_hash_check != new.default_skip_hash_check {
1603            d.default_skip_hash_check = Some(new.default_skip_hash_check);
1604        }
1605        if old.incomplete_extension_enabled != new.incomplete_extension_enabled {
1606            d.incomplete_extension_enabled = Some(new.incomplete_extension_enabled);
1607        }
1608        if old.watched_folder != new.watched_folder {
1609            d.watched_folder = Some(new.watched_folder.clone());
1610        }
1611        if old.delete_torrent_after_add != new.delete_torrent_after_add {
1612            d.delete_torrent_after_add = Some(new.delete_torrent_after_add);
1613        }
1614        if old.move_completed_enabled != new.move_completed_enabled {
1615            d.move_completed_enabled = Some(new.move_completed_enabled);
1616        }
1617        if old.move_completed_to != new.move_completed_to {
1618            d.move_completed_to = Some(new.move_completed_to.clone());
1619        }
1620        if old.ip_filter_auto_refresh != new.ip_filter_auto_refresh {
1621            d.ip_filter_auto_refresh = Some(new.ip_filter_auto_refresh);
1622        }
1623        if old.web_ui_https_enabled != new.web_ui_https_enabled {
1624            d.web_ui_https_enabled = Some(new.web_ui_https_enabled);
1625        }
1626        if old.network_interface != new.network_interface {
1627            d.network_interface = Some(new.network_interface.clone());
1628        }
1629        if old.default_add_paused != new.default_add_paused {
1630            d.default_add_paused = Some(new.default_add_paused);
1631        }
1632        d
1633    }
1634
1635    #[must_use]
1636    pub fn is_empty(&self) -> bool {
1637        self.enable_dht.is_none()
1638            && self.enable_pex.is_none()
1639            && self.max_peers.is_none()
1640            && self.seed_ratio_limit.is_none()
1641            && self.seed_time_limit_secs.is_none()
1642            && self.inactive_seed_time_limit_secs.is_none()
1643            && self.max_ratio_action.is_none()
1644            && self.encryption_mode.is_none()
1645            && self.anonymous_mode.is_none()
1646            && self.max_uploads_per_torrent.is_none()
1647            && self.save_resume_interval_secs.is_none()
1648            && self.hashing_threads.is_none()
1649            && self.ip_filter_enabled.is_none()
1650            // ── M226 ──
1651            && self.notify_on_complete.is_none()
1652            && self.notify_on_error.is_none()
1653            && self.on_complete_program.is_none()
1654            && self.use_incomplete_dir.is_none()
1655            && self.incomplete_dir.is_none()
1656            && self.default_skip_hash_check.is_none()
1657            && self.incomplete_extension_enabled.is_none()
1658            && self.watched_folder.is_none()
1659            && self.delete_torrent_after_add.is_none()
1660            && self.move_completed_enabled.is_none()
1661            && self.move_completed_to.is_none()
1662            && self.ip_filter_auto_refresh.is_none()
1663            && self.web_ui_https_enabled.is_none()
1664            && self.network_interface.is_none()
1665            && self.default_add_paused.is_none()
1666    }
1667}
1668
1669
1670/// Per-peer details exported for client UI introspection.
1671#[derive(Debug, Clone, Serialize, Deserialize)]
1672pub struct PeerInfo {
1673    /// Remote peer address (IP + port).
1674    pub addr: SocketAddr,
1675    /// Client identification string (from extension handshake `v` field, or empty).
1676    pub client: String,
1677    /// Whether the peer is choking us.
1678    pub peer_choking: bool,
1679    /// Whether the peer is interested in our data.
1680    pub peer_interested: bool,
1681    /// Whether we are choking the peer.
1682    pub am_choking: bool,
1683    /// Whether we are interested in the peer's data.
1684    pub am_interested: bool,
1685    /// Current download rate from this peer in bytes/sec.
1686    pub download_rate: u64,
1687    /// Current upload rate to this peer in bytes/sec.
1688    pub upload_rate: u64,
1689    /// Number of pieces the peer has (bitfield population count).
1690    pub num_pieces: u32,
1691    /// How the peer was discovered.
1692    pub source: crate::peer_state::PeerSource,
1693    /// Whether the peer supports BEP 6 Fast Extension.
1694    pub supports_fast: bool,
1695    /// Whether the peer declared upload-only status (BEP 21).
1696    pub upload_only: bool,
1697    /// Whether the peer is snubbed (no data for `snub_timeout_secs`).
1698    pub snubbed: bool,
1699    /// Seconds since the peer connection was established.
1700    pub connected_duration_secs: u64,
1701    /// Number of outstanding piece requests to this peer.
1702    pub num_pending_requests: usize,
1703    /// Number of incoming piece requests from this peer.
1704    pub num_incoming_requests: usize,
1705    /// Whether the peer currently holds our optimistic-unchoke slot
1706    /// (M171 D5; maps to the `O` peer-flag glyph). Not yet wired from
1707    /// the choker — placeholder for the superset renderer.
1708    #[serde(default)]
1709    pub is_optimistic: bool,
1710    /// Whether the peer connection is encrypted via BEP 8 MSE/PE
1711    /// (M171 D5; maps to `E` glyph). Not yet wired from the handshake
1712    /// state — placeholder for the superset renderer.
1713    #[serde(default)]
1714    pub is_encrypted: bool,
1715    /// Whether the peer connection uses BEP 29 uTP (not raw TCP)
1716    /// (M171 D5; maps to `P` glyph).
1717    #[serde(default)]
1718    pub uses_utp: bool,
1719    /// Whether the peer advertised BEP 55 holepunch support during the
1720    /// extended handshake (M171 D5; tracked for observability — no
1721    /// qBt glyph is assigned to holepunch).
1722    #[serde(default)]
1723    pub uses_holepunch: bool,
1724    /// M187: current number of in-flight block requests to this peer.
1725    #[serde(default)]
1726    pub in_flight_requests: u32,
1727    /// M187: current target pipeline depth for this peer.
1728    #[serde(default)]
1729    pub target_pipeline_depth: u32,
1730}
1731
1732/// In-flight piece download status for the download queue.
1733#[derive(Debug, Clone, Serialize)]
1734pub struct PartialPieceInfo {
1735    /// Index of the piece being downloaded.
1736    pub piece_index: u32,
1737    /// Total number of blocks in this piece.
1738    pub blocks_in_piece: u32,
1739    /// Number of blocks that have been assigned to peers.
1740    pub blocks_assigned: u32,
1741}
1742
1743/// Info about a file within a torrent.
1744#[derive(Debug, Clone, Serialize)]
1745pub struct FileInfo {
1746    /// Relative path of the file within the torrent.
1747    pub path: PathBuf,
1748    /// File size in bytes.
1749    pub length: u64,
1750}
1751
1752/// Metadata about a torrent (available after metadata is fetched).
1753#[derive(Debug, Clone, Serialize)]
1754pub struct TorrentInfo {
1755    /// SHA-1 info hash of the torrent.
1756    pub info_hash: irontide_core::Id20,
1757    /// Display name from the torrent metadata.
1758    pub name: String,
1759    /// Total size of all files in bytes.
1760    pub total_length: u64,
1761    /// Size of each piece in bytes (last piece may be smaller).
1762    pub piece_length: u64,
1763    /// Total number of pieces in the torrent.
1764    pub num_pieces: u32,
1765    /// List of files contained in the torrent.
1766    pub files: Vec<FileInfo>,
1767    /// Whether this is a private torrent (DHT/PEX disabled).
1768    pub private: bool,
1769}
1770
1771/// Aggregate statistics for the whole session.
1772#[derive(Debug, Clone, Serialize, Deserialize)]
1773pub struct SessionStats {
1774    /// Number of non-paused torrents in the session.
1775    pub active_torrents: usize,
1776    /// Total bytes downloaded across all torrents since session start.
1777    pub total_downloaded: u64,
1778    /// Total bytes uploaded across all torrents since session start.
1779    pub total_uploaded: u64,
1780    /// Number of nodes in the DHT routing table.
1781    pub dht_nodes: usize,
1782}
1783
1784/// Whether a file in a torrent is open and its I/O access mode.
1785#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1786pub enum FileMode {
1787    /// File is open for reading only (e.g. seeding).
1788    ReadOnly,
1789    /// File is open for reading and writing (e.g. downloading).
1790    ReadWrite,
1791    /// File is not currently open.
1792    Closed,
1793}
1794
1795/// Status of a single file within a torrent.
1796#[derive(Debug, Clone, Serialize)]
1797pub struct FileStatus {
1798    /// Whether the file is currently open.
1799    pub open: bool,
1800    /// The current access mode.
1801    pub mode: FileMode,
1802}
1803
1804bitflags! {
1805    /// Bitflag convenience wrapper for common torrent state flags.
1806    ///
1807    /// These map to existing torrent actor fields; `set_flags` / `unset_flags`
1808    /// delegate to the underlying operations (pause/resume, set_sequential, etc.).
1809    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1810    pub struct TorrentFlags: u32 {
1811        /// Torrent is paused.
1812        const PAUSED = 0x1;
1813        /// Torrent is auto-managed by the session queuing system.
1814        const AUTO_MANAGED = 0x2;
1815        /// Sequential (in-order) piece downloading is enabled.
1816        const SEQUENTIAL_DOWNLOAD = 0x4;
1817        /// BEP 16 super seeding mode is active.
1818        const SUPER_SEEDING = 0x8;
1819        /// Upload-only status (seeding complete, no wanted pieces).
1820        const UPLOAD_ONLY = 0x10;
1821    }
1822}
1823
1824// ── M187 Debug state DTOs ──
1825
1826/// Top-level debug state snapshot for all torrents in the session.
1827#[derive(Debug, Clone, Serialize)]
1828pub struct DebugState {
1829    /// Per-torrent debug snapshots.
1830    pub torrents: Vec<DebugTorrentState>,
1831}
1832
1833/// Per-torrent debug state including dispatch counters and peer details.
1834#[derive(Debug, Clone, Serialize)]
1835pub struct DebugTorrentState {
1836    /// Hex-encoded info hash.
1837    pub info_hash: String,
1838    /// Current torrent state (e.g. "Downloading", "Seeding").
1839    pub state: String,
1840    /// Number of connected peers.
1841    #[serde(default)]
1842    pub num_peers: usize,
1843    /// Dispatch-level counters.
1844    #[serde(default)]
1845    pub dispatch: DebugDispatchState,
1846    /// Per-peer debug state.
1847    #[serde(default)]
1848    pub peers: Vec<DebugPeerState>,
1849}
1850
1851/// Dispatch-level diagnostic counters (session-wide + per-torrent).
1852#[derive(Debug, Clone, Default, Serialize)]
1853pub struct DebugDispatchState {
1854    /// Total acquire calls.
1855    #[serde(default)]
1856    pub acquire_total: i64,
1857    /// Acquire calls that returned None (no block available).
1858    #[serde(default)]
1859    pub acquire_none_total: i64,
1860    /// Cumulative acquire latency in microseconds.
1861    #[serde(default)]
1862    pub acquire_us: i64,
1863    /// Total notify wakeup events.
1864    #[serde(default)]
1865    pub notify_wakeup_total: i64,
1866    /// Pieces still queued for dispatch (per-torrent).
1867    #[serde(default)]
1868    pub pieces_queued: u32,
1869    /// Pieces currently in-flight (per-torrent).
1870    #[serde(default)]
1871    pub pieces_inflight: u32,
1872}
1873
1874/// Per-peer debug state for diagnosing throughput issues.
1875#[derive(Debug, Clone, Serialize)]
1876pub struct DebugPeerState {
1877    /// Remote peer socket address.
1878    pub addr: SocketAddr,
1879    /// Number of in-flight block requests.
1880    #[serde(default)]
1881    pub in_flight: u32,
1882    /// Current target pipeline depth.
1883    #[serde(default)]
1884    pub target_depth: u32,
1885    /// Whether the peer is choking us.
1886    #[serde(default)]
1887    pub choking: bool,
1888    /// Current download rate in bytes/sec.
1889    #[serde(default)]
1890    pub download_rate: u64,
1891}
1892
1893/// Type alias for a factory that creates per-torrent storage.
1894pub type StorageFactory = Box<
1895    dyn Fn(
1896            &irontide_core::TorrentMetaV1,
1897            &std::path::Path,
1898        ) -> std::sync::Arc<dyn irontide_storage::TorrentStorage>
1899        + Send
1900        + Sync,
1901>;
1902
1903#[cfg(test)]
1904mod tests {
1905    use super::*;
1906
1907    #[test]
1908    fn torrent_config_strict_end_game_default() {
1909        let config = TorrentConfig::default();
1910        assert!(config.strict_end_game);
1911    }
1912
1913    #[test]
1914    fn torrent_config_bandwidth_defaults() {
1915        let config = TorrentConfig::default();
1916        assert_eq!(config.upload_rate_limit, 0);
1917        assert_eq!(config.download_rate_limit, 0);
1918    }
1919
1920    #[test]
1921    fn torrent_config_encryption_default() {
1922        let cfg = TorrentConfig::default();
1923        assert_eq!(
1924            cfg.encryption_mode,
1925            irontide_wire::mse::EncryptionMode::Disabled
1926        );
1927    }
1928
1929    #[test]
1930    fn torrent_config_utp_default() {
1931        let cfg = TorrentConfig::default();
1932        assert!(cfg.enable_utp);
1933    }
1934
1935    #[test]
1936    fn torrent_config_web_seed_defaults() {
1937        let cfg = TorrentConfig::default();
1938        assert!(cfg.enable_web_seed);
1939        assert_eq!(cfg.max_web_seeds, 4);
1940    }
1941
1942    #[test]
1943    fn torrent_config_super_seeding_default() {
1944        let cfg = TorrentConfig::default();
1945        assert!(!cfg.super_seeding);
1946        assert!(cfg.upload_only_announce);
1947    }
1948
1949    #[test]
1950    fn torrent_config_picker_defaults() {
1951        let cfg = TorrentConfig::default();
1952        assert!(!cfg.sequential_download);
1953        assert_eq!(cfg.initial_picker_threshold, 4);
1954        assert_eq!(cfg.whole_pieces_threshold, 20);
1955        assert_eq!(cfg.snub_timeout_secs, 15);
1956        assert_eq!(cfg.readahead_pieces, 8);
1957        assert!(cfg.streaming_timeout_escalation);
1958    }
1959
1960    #[test]
1961    fn torrent_stats_has_peers_by_source() {
1962        use crate::peer_state::PeerSource;
1963        use std::collections::HashMap;
1964
1965        let stats = TorrentStats {
1966            state: TorrentState::Downloading,
1967            pieces_total: 10,
1968            ..Default::default()
1969        };
1970        assert!(stats.peers_by_source.is_empty());
1971
1972        let mut map = HashMap::new();
1973        map.insert(PeerSource::Tracker, 5);
1974        map.insert(PeerSource::Dht, 3);
1975        let stats2 = TorrentStats {
1976            peers_by_source: map.clone(),
1977            ..stats
1978        };
1979        assert_eq!(stats2.peers_by_source[&PeerSource::Tracker], 5);
1980        assert_eq!(stats2.peers_by_source[&PeerSource::Dht], 3);
1981    }
1982
1983    #[test]
1984    fn torrent_stats_default_values() {
1985        let stats = TorrentStats::default();
1986
1987        // State
1988        assert_eq!(stats.state, TorrentState::Paused);
1989
1990        // Original fields are zeroed
1991        assert_eq!(stats.downloaded, 0);
1992        assert_eq!(stats.uploaded, 0);
1993        assert_eq!(stats.pieces_have, 0);
1994        assert_eq!(stats.pieces_total, 0);
1995        assert_eq!(stats.peers_connected, 0);
1996        assert_eq!(stats.peers_available, 0);
1997        assert!((stats.checking_progress - 0.0).abs() < f32::EPSILON);
1998        assert!(stats.peers_by_source.is_empty());
1999
2000        // Identity: zeroed info hash
2001        assert_eq!(
2002            stats.info_hashes,
2003            irontide_core::InfoHashes::v1_only(irontide_core::Id20::from([0u8; 20]))
2004        );
2005        assert!(stats.name.is_empty());
2006
2007        // State flags are all false
2008        assert!(!stats.has_metadata);
2009        assert!(!stats.is_seeding);
2010        assert!(!stats.is_finished);
2011        assert!(!stats.is_paused);
2012        assert!(!stats.auto_managed);
2013        assert!(!stats.sequential_download);
2014        assert!(!stats.super_seeding);
2015        assert!(!stats.has_incoming);
2016        assert!(!stats.need_save_resume);
2017        assert!(!stats.moving_storage);
2018
2019        // Progress
2020        assert!((stats.progress - 0.0).abs() < f32::EPSILON);
2021        assert_eq!(stats.progress_ppm, 0);
2022        assert_eq!(stats.total_done, 0);
2023        assert_eq!(stats.total, 0);
2024        assert_eq!(stats.total_wanted_done, 0);
2025        assert_eq!(stats.total_wanted, 0);
2026        assert_eq!(stats.block_size, 16384);
2027
2028        // Sentinel values
2029        assert_eq!(stats.num_complete, -1);
2030        assert_eq!(stats.num_incomplete, -1);
2031        assert_eq!(stats.queue_position, -1);
2032        assert_eq!(stats.error_file, -1);
2033
2034        // Strings are empty
2035        assert!(stats.current_tracker.is_empty());
2036        assert!(stats.save_path.is_empty());
2037        assert!(stats.error.is_empty());
2038
2039        // Rates are zero
2040        assert_eq!(stats.download_rate, 0);
2041        assert_eq!(stats.upload_rate, 0);
2042        assert_eq!(stats.download_payload_rate, 0);
2043        assert_eq!(stats.upload_payload_rate, 0);
2044
2045        // Distributed copies
2046        assert_eq!(stats.distributed_full_copies, 0);
2047        assert_eq!(stats.distributed_fraction, 0);
2048        assert!((stats.distributed_copies - 0.0).abs() < f32::EPSILON);
2049    }
2050
2051    #[test]
2052    fn torrent_stats_seeding_flags() {
2053        let stats = TorrentStats {
2054            state: TorrentState::Seeding,
2055            is_seeding: true,
2056            is_finished: true,
2057            has_metadata: true,
2058            progress: 1.0,
2059            progress_ppm: 1_000_000,
2060            ..Default::default()
2061        };
2062        assert_eq!(stats.state, TorrentState::Seeding);
2063        assert!(stats.is_seeding);
2064        assert!(stats.is_finished);
2065        assert!(stats.has_metadata);
2066        assert!((stats.progress - 1.0).abs() < f32::EPSILON);
2067        assert_eq!(stats.progress_ppm, 1_000_000);
2068        // Other fields remain default
2069        assert!(!stats.is_paused);
2070        assert_eq!(stats.downloaded, 0);
2071    }
2072
2073    #[test]
2074    fn torrent_state_sharing_variant() {
2075        let state = TorrentState::Sharing;
2076        assert_ne!(state, TorrentState::Downloading);
2077        assert_ne!(state, TorrentState::Seeding);
2078        // Verify JSON round-trip
2079        let json = serde_json::to_string(&state).unwrap();
2080        assert_eq!(json, "\"Sharing\"");
2081        let decoded: TorrentState = serde_json::from_str(&json).unwrap();
2082        assert_eq!(decoded, TorrentState::Sharing);
2083    }
2084
2085    #[test]
2086    fn torrent_config_i2p_defaults() {
2087        let cfg = TorrentConfig::default();
2088        assert!(!cfg.enable_i2p);
2089        assert!(!cfg.allow_i2p_mixed);
2090    }
2091
2092    #[test]
2093    fn torrent_config_ssl_listen_port_default() {
2094        let cfg = TorrentConfig::default();
2095        assert_eq!(cfg.ssl_listen_port, 0);
2096    }
2097
2098    #[test]
2099    fn torrent_config_ssl_listen_port_from_settings() {
2100        let s = crate::settings::Settings {
2101            ssl_listen_port: 4433,
2102            ..crate::settings::Settings::default()
2103        };
2104        let tc = TorrentConfig::from(&s);
2105        assert_eq!(tc.ssl_listen_port, 4433);
2106    }
2107
2108    #[test]
2109    fn torrent_config_choking_defaults() {
2110        let cfg = TorrentConfig::default();
2111        assert_eq!(
2112            cfg.seed_choking_algorithm,
2113            SeedChokingAlgorithm::FastestUpload
2114        );
2115        assert_eq!(cfg.choking_algorithm, ChokingAlgorithm::FixedSlots);
2116    }
2117
2118    #[test]
2119    fn torrent_config_m44_defaults() {
2120        let cfg = TorrentConfig::default();
2121        assert!(cfg.piece_extent_affinity);
2122        assert!(!cfg.suggest_mode);
2123        assert_eq!(cfg.max_suggest_pieces, 10);
2124        assert_eq!(cfg.predictive_piece_announce_ms, 0);
2125    }
2126
2127    #[test]
2128    fn torrent_config_from_settings_choking() {
2129        let s = crate::settings::Settings {
2130            seed_choking_algorithm: SeedChokingAlgorithm::RoundRobin,
2131            choking_algorithm: ChokingAlgorithm::RateBased,
2132            ..crate::settings::Settings::default()
2133        };
2134        let cfg = TorrentConfig::from(&s);
2135        assert_eq!(cfg.seed_choking_algorithm, SeedChokingAlgorithm::RoundRobin);
2136        assert_eq!(cfg.choking_algorithm, ChokingAlgorithm::RateBased);
2137    }
2138
2139    #[test]
2140    fn torrent_config_holepunch_default() {
2141        let cfg = TorrentConfig::default();
2142        assert!(cfg.enable_holepunch);
2143
2144        // Also verify it inherits from Settings
2145        let s = crate::settings::Settings::default();
2146        let tc = TorrentConfig::from(&s);
2147        assert!(tc.enable_holepunch);
2148
2149        // And when disabled in Settings
2150        let s2 = crate::settings::Settings {
2151            enable_holepunch: false,
2152            ..crate::settings::Settings::default()
2153        };
2154        let tc2 = TorrentConfig::from(&s2);
2155        assert!(!tc2.enable_holepunch);
2156    }
2157
2158    #[test]
2159    fn torrent_config_url_security_default() {
2160        let cfg = TorrentConfig::default();
2161        assert!(cfg.url_security.ssrf_mitigation);
2162        assert!(!cfg.url_security.allow_idna);
2163        assert!(cfg.url_security.validate_https_trackers);
2164    }
2165
2166    #[test]
2167    fn torrent_config_url_security_from_settings() {
2168        let s = crate::settings::Settings {
2169            ssrf_mitigation: false,
2170            allow_idna: true,
2171            validate_https_trackers: false,
2172            ..crate::settings::Settings::default()
2173        };
2174        let cfg = TorrentConfig::from(&s);
2175        assert!(!cfg.url_security.ssrf_mitigation);
2176        assert!(cfg.url_security.allow_idna);
2177        assert!(!cfg.url_security.validate_https_trackers);
2178    }
2179
2180    #[test]
2181    fn torrent_config_peer_dscp_default() {
2182        let cfg = TorrentConfig::default();
2183        assert_eq!(cfg.peer_dscp, 0x08);
2184    }
2185
2186    #[test]
2187    fn torrent_config_peer_dscp_from_settings() {
2188        let s = crate::settings::Settings {
2189            peer_dscp: 0x2E,
2190            ..crate::settings::Settings::default()
2191        };
2192        let cfg = TorrentConfig::from(&s);
2193        assert_eq!(cfg.peer_dscp, 0x2E);
2194    }
2195
2196    // ── M121: TorrentSummary and Serialize tests ──
2197
2198    #[test]
2199    fn summary_from_stats() {
2200        let v1_hash =
2201            irontide_core::Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
2202        let stats = TorrentStats {
2203            info_hashes: irontide_core::InfoHashes::v1_only(v1_hash),
2204            name: "test torrent".to_string(),
2205            state: TorrentState::Downloading,
2206            progress: 0.75,
2207            download_rate: 1_000_000,
2208            upload_rate: 500_000,
2209            total: 100_000_000,
2210            num_peers: 42,
2211            added_time: 1_710_900_000,
2212            ..TorrentStats::default()
2213        };
2214
2215        let summary = super::TorrentSummary::from(&stats);
2216        assert_eq!(
2217            summary.info_hash,
2218            "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"
2219        );
2220        assert_eq!(summary.name, "test torrent");
2221        assert_eq!(summary.state, TorrentState::Downloading);
2222        assert!((summary.progress - 0.75).abs() < f64::EPSILON);
2223        assert_eq!(summary.download_rate, 1_000_000);
2224        assert_eq!(summary.upload_rate, 500_000);
2225        assert_eq!(summary.total_size, 100_000_000);
2226        assert_eq!(summary.num_peers, 42);
2227        assert_eq!(summary.added_time, 1_710_900_000);
2228    }
2229
2230    #[test]
2231    fn summary_from_stats_v2_only() {
2232        let v2_hash = irontide_core::Id32::from_hex(
2233            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
2234        )
2235        .unwrap();
2236        let stats = TorrentStats {
2237            info_hashes: irontide_core::InfoHashes::v2_only(v2_hash),
2238            name: "v2 torrent".to_string(),
2239            ..TorrentStats::default()
2240        };
2241
2242        let summary = super::TorrentSummary::from(&stats);
2243        // v2-only torrents have no v1 hash, so info_hash is empty
2244        assert_eq!(summary.info_hash, "");
2245        assert_eq!(summary.name, "v2 torrent");
2246    }
2247
2248    #[test]
2249    fn stats_serializable() {
2250        let stats = TorrentStats::default();
2251        let json = serde_json::to_string(&stats).expect("TorrentStats should serialize to JSON");
2252        assert!(json.contains("\"state\""));
2253        assert!(json.contains("\"info_hashes\""));
2254        assert!(json.contains("\"download_rate\""));
2255    }
2256
2257    #[test]
2258    fn info_hashes_serializable() {
2259        let v1 = irontide_core::Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
2260        let ih = irontide_core::InfoHashes::v1_only(v1);
2261        let json = serde_json::to_string(&ih).expect("InfoHashes should serialize to JSON");
2262        // Id20 serializes as raw bytes (not hex) — verify JSON structure
2263        assert!(json.contains("\"v1\""));
2264        assert!(json.contains("\"v2\":null"));
2265    }
2266
2267    #[test]
2268    fn summary_serializable() {
2269        let stats = TorrentStats {
2270            name: "serialize test".to_string(),
2271            state: TorrentState::Seeding,
2272            progress: 1.0,
2273            ..TorrentStats::default()
2274        };
2275        let summary = super::TorrentSummary::from(&stats);
2276        let json =
2277            serde_json::to_string(&summary).expect("TorrentSummary should serialize to JSON");
2278        assert!(json.contains("\"name\":\"serialize test\""));
2279        assert!(json.contains("\"state\":\"Seeding\""));
2280        assert!(json.contains("\"progress\":1.0"));
2281    }
2282
2283    #[test]
2284    fn test_torrent_summary_includes_seeds_and_totals() {
2285        let stats = TorrentStats {
2286            num_seeds: 7,
2287            all_time_upload: 1_500_000,
2288            all_time_download: 3_000_000,
2289            ..TorrentStats::default()
2290        };
2291
2292        let summary = super::TorrentSummary::from(&stats);
2293        assert_eq!(summary.num_seeds, 7);
2294        assert_eq!(summary.all_time_upload, 1_500_000);
2295        assert_eq!(summary.all_time_download, 3_000_000);
2296    }
2297
2298    #[test]
2299    fn settings_delta_empty_when_identical() {
2300        let s = crate::settings::Settings::default();
2301        let delta = SettingsDelta::from_diff(&s, &s);
2302        assert!(delta.is_empty());
2303    }
2304
2305    #[test]
2306    fn settings_delta_detects_dht_change() {
2307        let old = crate::settings::Settings::default();
2308        let mut new = old.clone();
2309        new.enable_dht = !old.enable_dht;
2310        let delta = SettingsDelta::from_diff(&old, &new);
2311        assert!(!delta.is_empty());
2312        assert_eq!(delta.enable_dht, Some(new.enable_dht));
2313        assert!(delta.enable_pex.is_none());
2314    }
2315
2316    #[test]
2317    fn settings_delta_detects_seed_ratio_change() {
2318        let old = crate::settings::Settings::default();
2319        let mut new = old.clone();
2320        new.seed_ratio_limit = Some(2.0);
2321        let delta = SettingsDelta::from_diff(&old, &new);
2322        assert!(!delta.is_empty());
2323        assert_eq!(delta.seed_ratio_limit, Some(Some(2.0)));
2324    }
2325
2326    #[test]
2327    fn settings_delta_detects_encryption_change() {
2328        let old = crate::settings::Settings::default();
2329        let mut new = old.clone();
2330        new.encryption_mode = irontide_wire::mse::EncryptionMode::Forced;
2331        let delta = SettingsDelta::from_diff(&old, &new);
2332        assert!(!delta.is_empty());
2333        assert_eq!(
2334            delta.encryption_mode,
2335            Some(irontide_wire::mse::EncryptionMode::Forced)
2336        );
2337    }
2338
2339    #[test]
2340    fn settings_delta_detects_max_peers_change() {
2341        let old = crate::settings::Settings::default();
2342        let mut new = old.clone();
2343        new.max_peers_per_torrent = 0;
2344        let delta = SettingsDelta::from_diff(&old, &new);
2345        assert!(!delta.is_empty());
2346        assert_eq!(delta.max_peers, Some(0));
2347    }
2348
2349    #[test]
2350    fn settings_delta_detects_max_uploads_per_torrent_change() {
2351        let old = crate::settings::Settings::default();
2352        let mut new = old.clone();
2353        new.max_uploads_per_torrent = 6;
2354        let delta = SettingsDelta::from_diff(&old, &new);
2355        assert!(!delta.is_empty());
2356        assert_eq!(delta.max_uploads_per_torrent, Some(6));
2357        assert!(delta.max_peers.is_none());
2358    }
2359
2360    #[test]
2361    fn settings_delta_detects_max_uploads_per_torrent_unlimited_change() {
2362        let old = crate::settings::Settings {
2363            max_uploads_per_torrent: 4,
2364            ..crate::settings::Settings::default()
2365        };
2366        let mut new = old.clone();
2367        new.max_uploads_per_torrent = -1;
2368        let delta = SettingsDelta::from_diff(&old, &new);
2369        assert!(!delta.is_empty());
2370        assert_eq!(delta.max_uploads_per_torrent, Some(-1));
2371    }
2372
2373    #[test]
2374    fn torrent_config_from_settings_propagates_max_uploads_per_torrent() {
2375        let mut s = crate::settings::Settings {
2376            max_uploads_per_torrent: 7,
2377            ..crate::settings::Settings::default()
2378        };
2379        let cfg = TorrentConfig::from(&s);
2380        assert_eq!(cfg.max_uploads_per_torrent, 7);
2381
2382        s.max_uploads_per_torrent = -1;
2383        let cfg = TorrentConfig::from(&s);
2384        assert_eq!(cfg.max_uploads_per_torrent, -1);
2385    }
2386
2387    #[test]
2388    fn torrent_stats_holepunch_relayed_default_zero() {
2389        let stats = TorrentStats::default();
2390        assert_eq!(stats.holepunch_relayed, 0);
2391    }
2392
2393    #[test]
2394    fn torrent_stats_holepunch_relayed_serializes() {
2395        let stats = TorrentStats::default();
2396        let json = serde_json::to_value(&stats).unwrap();
2397        assert_eq!(json["holepunch_relayed"], 0);
2398    }
2399}