diff --git a/crates/librtbit-tracker-comms/src/tracker_comms.rs b/crates/librtbit-tracker-comms/src/tracker_comms.rs index 5f3ee6a2..4dde28eb 100644 --- a/crates/librtbit-tracker-comms/src/tracker_comms.rs +++ b/crates/librtbit-tracker-comms/src/tracker_comms.rs @@ -474,6 +474,7 @@ impl TrackerComms { u32::try_from(response.complete).ok(), u32::try_from(response.incomplete).ok(), peers_returned, + Some(response.complete), interval, ); } @@ -609,6 +610,12 @@ impl TrackerComms { Ok(response) => { trace!(len = response.addrs.len(), "received announce response"); let peers_returned = response.addrs.len() as u32; + let num_downloaded = client + .scrape(addr, vec![self.info_hash]) + .await + .ok() + .and_then(|stats| stats.into_iter().next()) + .map(|stats| u64::from(stats.completed)); for addr in response.addrs { self.tx.send(addr).await.context("rx closed")?; } @@ -619,6 +626,7 @@ impl TrackerComms { Some(response.seeders), Some(response.leechers), peers_returned, + num_downloaded, sleep, ); } diff --git a/crates/librtbit-tracker-comms/src/tracker_status.rs b/crates/librtbit-tracker-comms/src/tracker_status.rs index 20c343c7..e7bfba03 100644 --- a/crates/librtbit-tracker-comms/src/tracker_status.rs +++ b/crates/librtbit-tracker-comms/src/tracker_status.rs @@ -34,12 +34,16 @@ pub enum TrackerAnnounceState { pub struct TrackerStatus { pub url: String, pub state: TrackerAnnounceState, + /// Zero-based announce-list tier containing this tracker. + pub tier: u32, /// Seeder count ("complete") reported by the last successful announce. pub seeders: Option, /// Leecher count ("incomplete") reported by the last successful announce. pub leechers: Option, /// Number of peer addresses returned by the last successful announce. pub peers_returned: Option, + /// Number of peers that have completed the torrent, as reported by the tracker. + pub num_downloaded: Option, /// Unix timestamp of the last successful announce. pub last_announce_unix: Option, /// Announce interval (seconds) the tracker asked for. @@ -48,13 +52,15 @@ pub struct TrackerStatus { } impl TrackerStatus { - fn new(url: String) -> Self { + fn new(url: String, tier: u32) -> Self { Self { url, state: TrackerAnnounceState::NotContacted, + tier, seeders: None, leechers: None, peers_returned: None, + num_downloaded: None, last_announce_unix: None, interval_secs: None, last_error: None, @@ -92,9 +98,14 @@ impl Default for TrackerStatusRegistry { } impl TrackerStatusRegistry { - fn insert_or_get<'a>(&self, g: &'a mut HashMap, url: &str) -> &'a mut Entry { + fn insert_or_get<'a>( + &self, + g: &'a mut HashMap, + url: &str, + tier: u32, + ) -> &'a mut Entry { g.entry(url.to_owned()).or_insert_with(|| Entry { - status: TrackerStatus::new(url.to_owned()), + status: TrackerStatus::new(url.to_owned(), tier), generation: self.next_generation.fetch_add(1, Ordering::Relaxed), }) } @@ -110,8 +121,13 @@ impl TrackerStatusRegistry { /// Register a tracker without changing its state. Returns the entry's /// generation, which an announce loop passes to [`Self::is_current`]. pub fn ensure(&self, url: &str) -> u64 { + self.ensure_with_tier(url, 0) + } + + /// Register a tracker with its announce-list tier. + pub fn ensure_with_tier(&self, url: &str, tier: u32) -> u64 { let mut g = self.inner.write(); - self.insert_or_get(&mut g, url).generation + self.insert_or_get(&mut g, url, tier).generation } /// Whether a loop started at `generation` for `url` should keep announcing. @@ -161,6 +177,7 @@ impl TrackerStatusRegistry { seeders: Option, leechers: Option, peers_returned: u32, + num_downloaded: Option, interval_secs: u64, ) { self.with_existing(url, |e| { @@ -168,6 +185,7 @@ impl TrackerStatusRegistry { e.seeders = seeders.or(e.seeders); e.leechers = leechers.or(e.leechers); e.peers_returned = Some(peers_returned); + e.num_downloaded = num_downloaded.or(e.num_downloaded); e.last_announce_unix = Some(now_unix()); e.interval_secs = Some(interval_secs); e.last_error = None; @@ -183,7 +201,7 @@ impl TrackerStatusRegistry { pub fn record_disabled(&self, url: &str, reason: &str) { let mut g = self.inner.write(); - let e = &mut self.insert_or_get(&mut g, url).status; + let e = &mut self.insert_or_get(&mut g, url, 0).status; e.state = TrackerAnnounceState::Disabled; e.last_error = Some(reason.to_owned()); } @@ -219,7 +237,7 @@ mod tests { assert!(!r.remove(URL)); // A stopping loop's late report must not resurrect the entry. - r.record_success(URL, Some(1), Some(1), 1, 60); + r.record_success(URL, Some(1), Some(1), 1, Some(1), 60); r.record_error(URL, "boom"); assert!(r.snapshot().is_empty()); @@ -230,6 +248,17 @@ mod tests { assert!(r.is_current(URL, readded)); } + #[test] + fn snapshot_retains_tier_and_completed_count() { + let r = TrackerStatusRegistry::default(); + r.ensure_with_tier(URL, 2); + r.record_success(URL, Some(4), Some(3), 7, Some(19), 60); + + let status = r.snapshot().pop().unwrap(); + assert_eq!(status.tier, 2); + assert_eq!(status.num_downloaded, Some(19)); + } + #[test] fn reannounce_and_remove_wake_subscribers() { let r = TrackerStatusRegistry::default(); diff --git a/crates/librtbit/examples/simulate_traffic.rs b/crates/librtbit/examples/simulate_traffic.rs index 33ae20bf..1e1838ba 100644 --- a/crates/librtbit/examples/simulate_traffic.rs +++ b/crates/librtbit/examples/simulate_traffic.rs @@ -179,8 +179,8 @@ impl TestHarness { download_bps: Default::default(), peer_limit: None, concurrent_init_limit: None, - recheck_read_mibps: None, }, + recheck_read_mibps: None, disable_local_service_discovery: false, connect: Some(ConnectionOptions { proxy_url: None, diff --git a/crates/librtbit/src/http_api/handlers/qbit/tests.rs b/crates/librtbit/src/http_api/handlers/qbit/tests.rs index d677fedc..7b3088b4 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/tests.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/tests.rs @@ -1570,6 +1570,28 @@ fn tracker_status_maps_to_qbit_codes() { assert_eq!(qbit_tracker_status(Error), 4); } +#[test] +fn tracker_info_maps_tier_and_completed_count() { + use super::qbit_tracker_info; + use tracker_comms::{TrackerAnnounceState, TrackerStatus}; + + let info = qbit_tracker_info(TrackerStatus { + url: "https://tracker.example/announce".to_owned(), + state: TrackerAnnounceState::Working, + tier: 3, + seeders: Some(4), + leechers: Some(2), + peers_returned: Some(6), + num_downloaded: Some(17), + last_announce_unix: Some(1), + interval_secs: Some(60), + last_error: None, + }); + + assert_eq!(info.tier, 3); + assert_eq!(info.num_downloaded, 17); +} + #[test] fn rate_limit_conversions_round_trip_and_treat_zero_as_unlimited() { use std::num::NonZeroU32; @@ -1675,6 +1697,12 @@ fn incomplete_read_only_payload_reports_missing_files() { live: None, queued_for_init: None, }; + let mut checking = stats(None); + checking.state = TorrentStatsState::Initializing; + checking.queued_for_init = Some(false); + assert_eq!(qbit_state_for(&checking, false, false), "checkingDL"); + checking.queued_for_init = Some(true); + assert_eq!(qbit_state_for(&checking, false, false), "queuedForChecking"); assert_eq!( qbit_state_for(&stats(Some(READ_ONLY_INCOMPLETE_PAYLOAD)), false, false), "missingFiles" diff --git a/crates/librtbit/src/http_api/handlers/qbit/torrents.rs b/crates/librtbit/src/http_api/handlers/qbit/torrents.rs index 9fcc9b7b..5e6fe428 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/torrents.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/torrents.rs @@ -28,7 +28,7 @@ pub(super) fn map_state( forced: bool, ) -> &'static str { match (state, finished) { - (TorrentStatsState::Initializing, _) => "metaDL", + (TorrentStatsState::Initializing, _) => "checkingDL", (TorrentStatsState::Live, false) if forced => "forcedDL", (TorrentStatsState::Live, true) if forced => "forcedUP", (TorrentStatsState::Live, false) => "downloading", @@ -56,6 +56,10 @@ pub(super) fn qbit_state_for( { return "missingFiles"; } + if matches!(stats.state, TorrentStatsState::Initializing) && stats.queued_for_init == Some(true) + { + return "queuedForChecking"; + } map_state(stats.state, stats.finished, queued, forced) } @@ -81,7 +85,7 @@ pub(super) fn matches_filter( "all" => true, "downloading" => matches!( qbit_state, - "downloading" | "metaDL" | "forcedDL" | "queuedDL" + "downloading" | "checkingDL" | "queuedForChecking" | "forcedDL" | "queuedDL" ), "seeding" => matches!(qbit_state, "uploading" | "forcedUP" | "queuedUP"), "completed" => stats.finished, @@ -710,6 +714,21 @@ pub(super) fn qbit_tracker_status(state: tracker_comms::TrackerAnnounceState) -> } } +pub(super) fn qbit_tracker_info(t: tracker_comms::TrackerStatus) -> QbitTrackerInfo { + QbitTrackerInfo { + status: qbit_tracker_status(t.state), + tier: i32::try_from(t.tier).unwrap_or(i32::MAX), + num_peers: t.peers_returned.map_or(-1, i64::from), + num_seeds: t.seeders.map_or(-1, i64::from), + num_leeches: t.leechers.map_or(-1, i64::from), + num_downloaded: t + .num_downloaded + .map_or(-1, |n| i64::try_from(n).unwrap_or(i64::MAX)), + msg: t.last_error.unwrap_or_default(), + url: t.url, + } +} + pub(super) async fn h_torrents_trackers( State(state): State>, Query(query): Query, @@ -727,16 +746,7 @@ pub(super) async fn h_torrents_trackers( let trackers: Vec = response .trackers .into_iter() - .map(|t| QbitTrackerInfo { - status: qbit_tracker_status(t.state), - tier: 0, - num_peers: t.peers_returned.map_or(-1, i64::from), - num_seeds: t.seeders.map_or(-1, i64::from), - num_leeches: t.leechers.map_or(-1, i64::from), - num_downloaded: -1, - msg: t.last_error.unwrap_or_default(), - url: t.url, - }) + .map(qbit_tracker_info) .collect(); axum::Json(trackers).into_response() } diff --git a/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json b/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json index 3386ff52..88cfafbf 100644 --- a/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json +++ b/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json @@ -1287,7 +1287,7 @@ "endpoint": "torrents/trackers", "method": "GET", "status": "partial", - "notes": "Real per-tracker announce state and peer counts. Tracker tier and num_downloaded remain unavailable because configured trackers are currently stored without tier or scrape-download metadata.", + "notes": "Real per-tracker announce state, announce-list tier, peer counts, and completed/downloaded count reported by HTTP announce responses or UDP scrape responses.", "response": { "content": "json", "schema": [ diff --git a/crates/librtbit/src/ip_ranges.rs b/crates/librtbit/src/ip_ranges.rs index 91bfe07d..f7f960f9 100644 --- a/crates/librtbit/src/ip_ranges.rs +++ b/crates/librtbit/src/ip_ranges.rs @@ -69,6 +69,9 @@ impl IpRanges { let limits = crate::safe_fetch::FetchLimits { max_bytes: 256 * 1024 * 1024, + // Large compressed blocklists can legitimately take longer than + // the normal per-hop timeout to download and parse. + timeout: std::time::Duration::from_secs(5 * 60), ..Default::default() }; let body = crate::safe_fetch::fetch_bytes(parsed_url.as_str(), &limits) diff --git a/crates/librtbit/src/rss/fetch.rs b/crates/librtbit/src/rss/fetch.rs index 35ae10bc..422e18e3 100644 --- a/crates/librtbit/src/rss/fetch.rs +++ b/crates/librtbit/src/rss/fetch.rs @@ -73,9 +73,15 @@ pub fn assert_public_addresses(addresses: &[IpAddr]) -> Result<(), FeedUrlError> Ok(()) } -/// Keep at most [`MAX_FEED_BYTES`] of a feed body. -pub fn cap_feed_body(body: &[u8]) -> Vec { - body.iter().take(MAX_FEED_BYTES).copied().collect() +/// Reject a feed body that exceeds [`MAX_FEED_BYTES`]. +/// +/// Truncating XML can turn a valid feed into malformed input and hide the +/// fact that the remote response exceeded our safety limit. +pub fn cap_feed_body(body: &[u8]) -> anyhow::Result> { + if body.len() > MAX_FEED_BYTES { + anyhow::bail!("RSS feed body exceeds the {MAX_FEED_BYTES} byte cap") + } + Ok(body.to_vec()) } #[cfg(test)] @@ -134,6 +140,8 @@ mod tests { "http://240.0.0.1/feed.xml", "http://255.255.255.255/feed.xml", "http://[64:ff9b::1.2.3.4]/feed.xml", + "http://[64:ff9b:1::1]/feed.xml", + "http://[2001::1]/feed.xml", "http://[::192.0.2.1]/feed.xml", "http://[2002:c000:201::]/feed.xml", ] { @@ -151,7 +159,7 @@ mod tests { #[test] fn caps_a_feed_body_at_the_limit() { let big = vec![b'a'; MAX_FEED_BYTES + 100]; - assert_eq!(cap_feed_body(&big).len(), MAX_FEED_BYTES); - assert_eq!(cap_feed_body(b""), b""); + assert!(cap_feed_body(&big).is_err()); + assert_eq!(cap_feed_body(b"").unwrap(), b""); } } diff --git a/crates/librtbit/src/rss/monitor.rs b/crates/librtbit/src/rss/monitor.rs index 13ec21dc..5c50bedd 100644 --- a/crates/librtbit/src/rss/monitor.rs +++ b/crates/librtbit/src/rss/monitor.rs @@ -94,7 +94,7 @@ impl RssMonitor { let bytes = tokio::fs::read(&path).await.map_err(|e| { anyhow::anyhow!("error reading RSS feed file {}: {e}", path.display()) })?; - return Ok(fetch::cap_feed_body(&bytes)); + return fetch::cap_feed_body(&bytes); } let _ = client; diff --git a/crates/librtbit/src/safe_fetch.rs b/crates/librtbit/src/safe_fetch.rs index 654b949e..6480b0a9 100644 --- a/crates/librtbit/src/safe_fetch.rs +++ b/crates/librtbit/src/safe_fetch.rs @@ -86,8 +86,12 @@ pub fn is_forbidden_address(ip: IpAddr) -> bool { // Unique local fc00::/7 and link local fe80::/10. || (seg0 & 0xfe00) == 0xfc00 || (seg0 & 0xffc0) == 0xfe80 - // NAT64 well-known prefix 64:ff9b::/96. + // NAT64 well-known prefixes (including the RFC 6052 /96 + // form and the RFC 6052 /48 form used by local translators). || is_nat64(v6) + || (seg0 == 0x64 && v6.segments()[1] == 0xff9b && v6.segments()[2] == 1) + // Teredo tunnels can expose a client behind a private NAT. + || seg0 == 0x2001 && v6.segments()[1] == 0 // IPv4-compatible ::a.b.c.d and 6to4 2002::/16. || is_ipv4_compatible(v6) || seg0 == 0x2002 @@ -148,6 +152,9 @@ pub async fn get(url: &str, limits: &FetchLimits) -> anyhow::Result { let (host, port, addrs) = vet_host(¤t).await?; let mut builder = Client::builder() .timeout(limits.timeout) + // Resolve-pinning only protects direct connections. Never let an + // environment proxy route around the vetted destination. + .no_proxy() .redirect(reqwest::redirect::Policy::none()); for ip in addrs { builder = builder.resolve(&host, SocketAddr::new(ip, port)); diff --git a/crates/librtbit/src/session/mod.rs b/crates/librtbit/src/session/mod.rs index 2979c98f..f74845cb 100644 --- a/crates/librtbit/src/session/mod.rs +++ b/crates/librtbit/src/session/mod.rs @@ -1116,6 +1116,7 @@ impl Session { .into_iter() .filter_map(|t| url::Url::parse(&t).ok()) .collect(), + tracker_tiers: std::collections::HashMap::new(), metadata: None, name: magnet.name, web_seed_urls: Vec::new(), @@ -1152,6 +1153,19 @@ impl Session { } }) .collect::>(); + let tracker_tiers = torrent.meta.announce_list.iter().enumerate().fold( + std::collections::HashMap::new(), + |mut tiers, (tier, urls)| { + for url in urls { + if let Ok(url) = std::str::from_utf8(url.as_ref()) { + tiers + .entry(url.to_owned()) + .or_insert(u32::try_from(tier).unwrap_or(u32::MAX)); + } + } + tiers + }, + ); if let Some(custom_trackers) = opts.trackers.clone() { trackers.extend(custom_trackers); } @@ -1176,6 +1190,7 @@ impl Session { .iter() .filter_map(|t| url::Url::parse(t).ok()) .collect(), + tracker_tiers, name: None, web_seed_urls, } @@ -1241,6 +1256,7 @@ impl Session { info_hash, metadata, trackers, + tracker_tiers, name, web_seed_urls, } = add_res; @@ -1253,7 +1269,10 @@ impl Session { // are recorded too; moved into ManagedTorrentShared below. let tracker_status = Arc::new(tracker_comms::TrackerStatusRegistry::default()); for t in &trackers { - tracker_status.ensure(t.as_str()); + tracker_status.ensure_with_tier( + t.as_str(), + tracker_tiers.get(t.as_str()).copied().unwrap_or(0), + ); } let make_peer_rx = || { diff --git a/crates/librtbit/src/session/types.rs b/crates/librtbit/src/session/types.rs index 7af6d5ed..7c98126a 100644 --- a/crates/librtbit/src/session/types.rs +++ b/crates/librtbit/src/session/types.rs @@ -418,6 +418,7 @@ pub(super) struct InternalAddResult { pub info_hash: Id20, pub metadata: Option, pub trackers: Vec, + pub tracker_tiers: std::collections::HashMap, pub name: Option, pub web_seed_urls: Vec, } diff --git a/crates/librtbit/src/tests/read_only_storage.rs b/crates/librtbit/src/tests/read_only_storage.rs index a4643140..28cf91fe 100644 --- a/crates/librtbit/src/tests/read_only_storage.rs +++ b/crates/librtbit/src/tests/read_only_storage.rs @@ -774,3 +774,58 @@ async fn read_only_complete_payload_is_not_flagged_and_seeds() -> anyhow::Result assert!(stats.error.is_none()); Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn recheck_progress_rises_monotonically() -> anyhow::Result<()> { + let payload_dir = TempDir::with_prefix("rtbit_recheck_progress_payload")?; + std::fs::write( + payload_dir.path().join("payload.bin"), + vec![0x6a; 8 * 1024 * 1024], + )?; + let torrent = create_single_file_torrent(payload_dir.path()).await?; + + let session_dir = TempDir::with_prefix("rtbit_recheck_progress_session")?; + let session = create_read_only_session(session_dir.path()).await?; + let handle = session + .add_torrent( + AddTorrent::from_bytes(torrent), + Some(AddTorrentOptions { + paused: true, + overwrite: true, + output_folder: Some(payload_dir.path().to_string_lossy().into_owned()), + ..Default::default() + }), + ) + .await? + .into_handle() + .context("expected a torrent handle")?; + timeout(Duration::from_secs(10), handle.wait_until_initialized()) + .await + .context("timed out waiting for initial validation")??; + + session.force_recheck(&handle).await?; + let mut last = 0; + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let stats = handle.stats(); + assert!(stats.total_bytes > 0); + assert!( + stats.progress_bytes >= last, + "progress went backwards: {} -> {}", + last, + stats.progress_bytes + ); + assert!(stats.progress_bytes <= stats.total_bytes); + last = stats.progress_bytes; + if !matches!( + stats.state, + crate::torrent_state::TorrentStatsState::Initializing + ) { + break; + } + assert!(std::time::Instant::now() < deadline, "recheck timed out"); + tokio::time::sleep(Duration::from_millis(1)).await; + } + assert_eq!(last, handle.stats().total_bytes); + Ok(()) +} diff --git a/crates/librtbit/src/torrent_state/mod.rs b/crates/librtbit/src/torrent_state/mod.rs index a12cc2f8..7535f574 100644 --- a/crates/librtbit/src/torrent_state/mod.rs +++ b/crates/librtbit/src/torrent_state/mod.rs @@ -128,6 +128,7 @@ pub const READ_ONLY_INCOMPLETE_PAYLOAD: &str = /// True for [`READ_ONLY_INCOMPLETE_PAYLOAD`] with or without the /// `: ` suffix the stats add. +#[cfg_attr(not(test), allow(dead_code))] pub fn is_read_only_incomplete_payload(error: &str) -> bool { error.starts_with(READ_ONLY_INCOMPLETE_PAYLOAD) } diff --git a/crates/librtbit/webui/src/api-types.ts b/crates/librtbit/webui/src/api-types.ts index 390e1979..e6fa64ec 100644 --- a/crates/librtbit/webui/src/api-types.ts +++ b/crates/librtbit/webui/src/api-types.ts @@ -250,6 +250,7 @@ export interface TorrentStats { error: string | null; file_progress: number[]; progress_bytes: number; + queued_for_init?: boolean; finished: boolean; total_bytes: number; live: LiveTorrentStats | null; @@ -310,6 +311,17 @@ export interface AddTorrentOptions { export interface CategoryInfo { name: string; save_path?: string; + // Blank/absent = use the global limit. Durations arrive as {secs, nanos}. + ratio_limit?: number | null; + seeding_time_limit?: number | { secs: number; nanos?: number } | null; + inactive_seeding_time_limit?: + number | { secs: number; nanos?: number } | null; +} + +export interface CategoryLimitRequest { + ratio_limit?: number; + seeding_time_limit?: number; // seconds + inactive_seeding_time_limit?: number; // seconds } export type Value = string | number | boolean; @@ -589,7 +601,11 @@ export interface RtbitAPI { // Category management getCategories: () => Promise>; - createCategory: (name: string, savePath?: string) => Promise; + createCategory: ( + name: string, + savePath?: string, + limits?: CategoryLimitRequest, + ) => Promise; deleteCategory: (name: string) => Promise; setTorrentCategory: ( torrentId: number, diff --git a/crates/librtbit/webui/src/components/compact/CategoryFilter.tsx b/crates/librtbit/webui/src/components/compact/CategoryFilter.tsx index 8f30d417..5bcf6df7 100644 --- a/crates/librtbit/webui/src/components/compact/CategoryFilter.tsx +++ b/crates/librtbit/webui/src/components/compact/CategoryFilter.tsx @@ -1,5 +1,6 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react"; -import { BsTag, BsTagFill, BsPlus } from "react-icons/bs"; +import { BsTag, BsTagFill, BsPlus, BsGear } from "react-icons/bs"; +import { CategoryLimitsModal } from "../modal/CategoryLimitsModal"; import { APIContext } from "../../context"; import { useUIStore } from "../../stores/uiStore"; import { useTorrentStore } from "../../stores/torrentStore"; @@ -15,6 +16,7 @@ export const CategoryFilter: React.FC = () => { const [showNewInput, setShowNewInput] = useState(false); const [newCategoryName, setNewCategoryName] = useState(""); const inputRef = useRef(null); + const [editing, setEditing] = useState(null); // Fetch categories on mount useEffect(() => { @@ -175,28 +177,50 @@ export const CategoryFilter: React.FC = () => { {categoryNames.map((name) => { const count = categoryCounts.counts[name] ?? 0; return ( - + + {name} + + {count} + + + + ); })} + {editing && ( + setEditing(null)} + onSaved={() => { + setEditing(null); + API.getCategories() + .then(setCategories) + .catch(() => {}); + }} + /> + )} ); }; diff --git a/crates/librtbit/webui/src/components/compact/OverviewTab.tsx b/crates/librtbit/webui/src/components/compact/OverviewTab.tsx index 693b4586..10cd6478 100644 --- a/crates/librtbit/webui/src/components/compact/OverviewTab.tsx +++ b/crates/librtbit/webui/src/components/compact/OverviewTab.tsx @@ -92,7 +92,12 @@ export const OverviewTab: React.FC = ({ torrent }) => { const stateDisplay = (() => { if (error) return { text: "Error", color: "text-error" }; if (state === STATE_INITIALIZING) - return { text: "Initializing", color: "text-warning" }; + return { + text: statsResponse.queued_for_init + ? "Queued for check" + : `Checking ${Math.floor(progressPct)}%`, + color: "text-warning", + }; if (state === STATE_PAUSED) return { text: "Paused", color: "text-secondary" }; if (state === STATE_LIVE && finished) diff --git a/crates/librtbit/webui/src/components/modal/CategoryLimitsModal.tsx b/crates/librtbit/webui/src/components/modal/CategoryLimitsModal.tsx new file mode 100644 index 00000000..266b27dd --- /dev/null +++ b/crates/librtbit/webui/src/components/modal/CategoryLimitsModal.tsx @@ -0,0 +1,95 @@ +import { useContext, useState } from "react"; +import { APIContext } from "../../context"; +import { CategoryInfo } from "../../api-types"; +import { + limitFieldsFromCategory, + parseCategoryLimits, +} from "../../helper/categoryLimits"; +import { Button } from "../buttons/Button"; +import { Modal } from "./Modal"; +import { ModalBody } from "./ModalBody"; +import { ModalFooter } from "./ModalFooter"; + +const inputClass = + "w-full px-2 py-1 text-sm bg-surface border border-divider rounded focus-ring focus:border-primary placeholder:text-tertiary"; + +export const CategoryLimitsModal: React.FC<{ + category: CategoryInfo; + onClose: () => void; + onSaved: () => void; +}> = ({ category, onClose, onSaved }) => { + const API = useContext(APIContext); + const [fields, setFields] = useState(() => limitFieldsFromCategory(category)); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + const save = async () => { + const parsed = parseCategoryLimits(fields); + if ("error" in parsed) { + setError(parsed.error); + return; + } + setSaving(true); + try { + // Editing replaces the category, so keep its save path. + await API.createCategory( + category.name, + category.save_path, + parsed.values, + ); + onSaved(); + } catch (e: any) { + setError(e?.text ?? e?.message ?? "Could not save the category."); + setSaving(false); + } + }; + + const field = ( + label: string, + key: "ratio" | "seedingMinutes" | "inactiveMinutes", + inputMode: "decimal" | "numeric", + ) => ( + + ); + + return ( + + + {field("Ratio limit", "ratio", "decimal")} + {field("Seeding time limit (minutes)", "seedingMinutes", "numeric")} + {field( + "Inactive seeding time limit (minutes)", + "inactiveMinutes", + "numeric", + )} + {error && ( +
+ {error} +
+ )} +
+ + + + +
+ ); +}; diff --git a/crates/librtbit/webui/src/context.tsx b/crates/librtbit/webui/src/context.tsx index 78969366..2b3539a8 100644 --- a/crates/librtbit/webui/src/context.tsx +++ b/crates/librtbit/webui/src/context.tsx @@ -5,6 +5,7 @@ import { AltSpeedStatus, BrowseResponse, CategoryInfo, + CategoryLimitRequest, DhtStats, AdvancedConfig, ProxyConfig, @@ -100,7 +101,11 @@ export const APIContext = createContext({ getCategories: function (): Promise> { throw new Error("Function not implemented."); }, - createCategory: function (name: string, savePath?: string): Promise { + createCategory: function ( + name: string, + savePath?: string, + limits?: CategoryLimitRequest, + ): Promise { throw new Error("Function not implemented."); }, deleteCategory: function (name: string): Promise { @@ -127,10 +132,7 @@ export const APIContext = createContext({ addTorrentTags: function (_ids: number[], _tags: string[]): Promise { throw new Error("Function not implemented."); }, - removeTorrentTags: function ( - _ids: number[], - _tags: string[], - ): Promise { + removeTorrentTags: function (_ids: number[], _tags: string[]): Promise { throw new Error("Function not implemented."); }, setTorrentTags: function (_ids: number[], _tags: string[]): Promise { diff --git a/crates/librtbit/webui/src/helper/categoryLimits.test.mjs b/crates/librtbit/webui/src/helper/categoryLimits.test.mjs new file mode 100644 index 00000000..84069f96 --- /dev/null +++ b/crates/librtbit/webui/src/helper/categoryLimits.test.mjs @@ -0,0 +1,68 @@ +// @ts-nocheck +import test from "node:test"; +import assert from "node:assert/strict"; +import { + limitFieldsFromCategory, + parseCategoryLimits, +} from "./categoryLimits.ts"; + +test("blank fields mean use global", () => { + const r = parseCategoryLimits({ + ratio: " ", + seedingMinutes: "", + inactiveMinutes: "", + }); + assert.deepEqual(r, { values: {} }); +}); + +test("minutes convert to seconds", () => { + const r = parseCategoryLimits({ + ratio: "1.5", + seedingMinutes: "90", + inactiveMinutes: "0", + }); + assert.deepEqual(r, { + values: { + ratio_limit: 1.5, + seeding_time_limit: 5400, + inactive_seeding_time_limit: 0, + }, + }); +}); + +test("rejects bad input", () => { + const base = { ratio: "", seedingMinutes: "", inactiveMinutes: "" }; + assert.match( + parseCategoryLimits({ ...base, ratio: "-1" }).error, + /0 or more/, + ); + assert.match(parseCategoryLimits({ ...base, ratio: "abc" }).error, /number/); + assert.match( + parseCategoryLimits({ ...base, ratio: "Infinity" }).error, + /number/, + ); + assert.match( + parseCategoryLimits({ ...base, seedingMinutes: "1.5" }).error, + /minutes/, + ); + assert.match( + parseCategoryLimits({ ...base, inactiveMinutes: "-4" }).error, + /minutes/, + ); +}); + +test("fields load from the daemon's duration shape", () => { + assert.deepEqual( + limitFieldsFromCategory({ + ratio_limit: 2, + seeding_time_limit: { secs: 3600, nanos: 0 }, + inactive_seeding_time_limit: 120, + }), + { ratio: "2", seedingMinutes: "60", inactiveMinutes: "2" }, + ); + assert.deepEqual(limitFieldsFromCategory(undefined), { + ratio: "", + seedingMinutes: "", + inactiveMinutes: "", + }); +}); diff --git a/crates/librtbit/webui/src/helper/categoryLimits.ts b/crates/librtbit/webui/src/helper/categoryLimits.ts new file mode 100644 index 00000000..f78fde62 --- /dev/null +++ b/crates/librtbit/webui/src/helper/categoryLimits.ts @@ -0,0 +1,64 @@ +export interface CategoryLimitFields { + ratio: string; + seedingMinutes: string; + inactiveMinutes: string; +} + +export interface CategoryLimitValues { + ratio_limit?: number; + seeding_time_limit?: number; + inactive_seeding_time_limit?: number; +} + +// The daemon serialises a Duration as {secs, nanos}; accept plain seconds too. +type WireSecs = number | { secs: number } | null | undefined; + +const toSecs = (v: WireSecs): number | undefined => + v == null ? undefined : typeof v === "number" ? v : v.secs; + +const minutesText = (v: WireSecs): string => { + const secs = toSecs(v); + return secs === undefined ? "" : String(Math.floor(secs / 60)); +}; + +export function limitFieldsFromCategory(cat?: { + ratio_limit?: number | null; + seeding_time_limit?: WireSecs; + inactive_seeding_time_limit?: WireSecs; +}): CategoryLimitFields { + return { + ratio: cat?.ratio_limit == null ? "" : String(cat.ratio_limit), + seedingMinutes: minutesText(cat?.seeding_time_limit), + inactiveMinutes: minutesText(cat?.inactive_seeding_time_limit), + }; +} + +function wholeMinutes(text: string, what: string): number | string | undefined { + const t = text.trim(); + if (t === "") return undefined; + if (!/^\d+$/.test(t) || !Number.isSafeInteger(Number(t) * 60)) { + return `${what} must be a whole number of minutes.`; + } + return Number(t) * 60; +} + +/** Blank = use global. Returns either the request values or an error message. */ +export function parseCategoryLimits( + f: CategoryLimitFields, +): { values: CategoryLimitValues } | { error: string } { + const values: CategoryLimitValues = {}; + const r = f.ratio.trim(); + if (r !== "") { + const n = Number(r); + if (!Number.isFinite(n)) return { error: "Ratio limit must be a number." }; + if (n < 0) return { error: "Ratio limit must be 0 or more." }; + values.ratio_limit = n; + } + const s = wholeMinutes(f.seedingMinutes, "Seeding time limit"); + if (typeof s === "string") return { error: s }; + if (s !== undefined) values.seeding_time_limit = s; + const i = wholeMinutes(f.inactiveMinutes, "Inactive seeding time limit"); + if (typeof i === "string") return { error: i }; + if (i !== undefined) values.inactive_seeding_time_limit = i; + return { values }; +} diff --git a/crates/librtbit/webui/src/http-api.ts b/crates/librtbit/webui/src/http-api.ts index 6c5b59ef..f099f260 100644 --- a/crates/librtbit/webui/src/http-api.ts +++ b/crates/librtbit/webui/src/http-api.ts @@ -5,6 +5,7 @@ import { AltSpeedStatus, BrowseResponse, CategoryInfo, + CategoryLimitRequest, DhtStats, ErrorDetails, AdvancedConfig, @@ -528,11 +529,15 @@ export const API: RtbitAPI & { getVersion: () => Promise } = { getCategories: (): Promise> => { return makeRequest("GET", "/torrents/categories"); }, - createCategory: (name: string, savePath?: string): Promise => { + createCategory: ( + name: string, + savePath?: string, + limits?: CategoryLimitRequest, + ): Promise => { return makeRequest( "POST", "/torrents/categories", - { name, save_path: savePath }, + { name, save_path: savePath, ...limits }, true, ); }, @@ -565,12 +570,7 @@ export const API: RtbitAPI & { getVersion: () => Promise } = { return postTagForm("/api/v2/torrents/deleteTags", [], tags); }, renameTag: (from: string, to: string): Promise => { - return postTagForm( - "/api/v2/torrents/renameTag", - [], - [], - { from, to }, - ); + return postTagForm("/api/v2/torrents/renameTag", [], [], { from, to }); }, addTorrentTags: (ids: number[], tags: string[]): Promise => { return postTagForm("/api/v2/torrents/addTags", ids, tags); diff --git a/crates/librtbit/webui/src/mock-api.ts b/crates/librtbit/webui/src/mock-api.ts index cabf62e1..4de1fb13 100644 --- a/crates/librtbit/webui/src/mock-api.ts +++ b/crates/librtbit/webui/src/mock-api.ts @@ -7,6 +7,7 @@ import { AltSpeedSchedule, AltSpeedStatus, CategoryInfo, + CategoryLimitRequest, DhtStats, AdvancedConfig, ProxyConfig, @@ -1020,9 +1021,17 @@ export const MockAPI: RtbitAPI & { getVersion: () => Promise } = { return { ...mockCategories }; }, - createCategory: async (name: string, savePath?: string): Promise => { + createCategory: async ( + name: string, + savePath?: string, + limits?: CategoryLimitRequest, + ): Promise => { await new Promise((r) => setTimeout(r, 50)); - mockCategories[name] = { name, save_path: savePath }; + mockCategories[name] = { + name, + save_path: savePath ?? mockCategories[name]?.save_path, + ...limits, + }; }, deleteCategory: async (name: string): Promise => { diff --git a/crates/rtbit-client/src/client.rs b/crates/rtbit-client/src/client.rs index 50d01907..6ae4727b 100644 --- a/crates/rtbit-client/src/client.rs +++ b/crates/rtbit-client/src/client.rs @@ -155,6 +155,20 @@ impl Client { .await } + /// `GET /torrents/categories`, sorted by name. + pub async fn list_categories(&mut self) -> Result, Error> { + let map: std::collections::HashMap = + self.authed_get("torrents/categories").await?; + let mut all: Vec<_> = map.into_values().collect(); + all.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(all) + } + + /// `POST /torrents/categories`. Creates the category or replaces its save path and limits. + pub async fn save_category(&mut self, body: &crate::types::CategoryBody) -> Result<(), Error> { + self.authed_post_json("torrents/categories", body).await + } + /// `POST /torrents/{id}/set_category`. `None` clears the category. pub async fn set_torrent_category( &mut self, diff --git a/crates/rtbit-client/src/lib.rs b/crates/rtbit-client/src/lib.rs index 20fbd970..e8d6f815 100644 --- a/crates/rtbit-client/src/lib.rs +++ b/crates/rtbit-client/src/lib.rs @@ -13,12 +13,13 @@ pub use error::Error; pub use http::StatusCode; pub use types::{ AdvancedConfig, AdvancedSaved, AltSpeedConfig, AltSpeedSchedule, AltSpeedStatus, AuthStatus, - BulkEditFailure, BulkEditResponse, ConnectionFamily, ConnectionKindStats, Connections, - DownloadsConfig, FoldersConfig, IpFilterConfig, LiveSpeeds, NetworkConfig, NetworkSettings, - PeerCounts, PeerList, PeerView, PieceProgress, ProxyConfig, RateLimits, Role, RssFeed, RssItem, - RssRule, RssRuleBody, RssRuleEpisode, RssSnapshot, SavedNetworkStatus, SeedLimits, - SessionCounters, SessionStats, SettingsSnapshot, Speed, TokenPair, Torrent, TorrentFile, - TorrentList, TorrentState, TorrentStats, TrackerList, TrackerState, TrackerStatus, + BulkEditFailure, BulkEditResponse, Category, CategoryBody, CategoryLimits, ConnectionFamily, + ConnectionKindStats, Connections, DownloadsConfig, FoldersConfig, IpFilterConfig, LiveSpeeds, + NetworkConfig, NetworkSettings, PeerCounts, PeerList, PeerView, PieceProgress, ProxyConfig, + RateLimits, Role, RssFeed, RssItem, RssRule, RssRuleBody, RssRuleEpisode, RssSnapshot, + SavedNetworkStatus, SeedLimits, SessionCounters, SessionStats, SettingsSnapshot, Speed, + TokenPair, Torrent, TorrentFile, TorrentList, TorrentState, TorrentStats, TrackerList, + TrackerState, TrackerStatus, }; /// Caps for `AdvancedConfig`, matching the daemon's limits. diff --git a/crates/rtbit-client/src/types.rs b/crates/rtbit-client/src/types.rs index bb845049..d9530c3b 100644 --- a/crates/rtbit-client/src/types.rs +++ b/crates/rtbit-client/src/types.rs @@ -201,6 +201,10 @@ pub struct TorrentStats { /// The daemon's error flag on the torrent, if any. #[serde(default)] pub error: Option, + /// While initializing: true when waiting for a check slot rather than + /// hashing. Absent otherwise. + #[serde(default)] + pub queued_for_init: Option, } /// One row of `GET /torrents`. Extra daemon fields are ignored. @@ -809,3 +813,98 @@ pub struct RssSnapshot { pub items: Vec, pub rules: Vec, } + +/// One entry of `GET /torrents/categories`. A `None` limit means "use the global limit". +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct Category { + pub name: String, + #[serde(default)] + pub save_path: Option, + #[serde(default)] + pub ratio_limit: Option, + #[serde(default, deserialize_with = "secs_or_duration")] + pub seeding_time_limit: Option, + #[serde(default, deserialize_with = "secs_or_duration")] + pub inactive_seeding_time_limit: Option, +} + +/// The daemon serialises a `Duration` as `{secs, nanos}`; accept a bare number of seconds too. +fn secs_or_duration<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum Wire { + Secs(u64), + Duration { secs: u64 }, + } + Ok(Option::::deserialize(d)?.map(|w| match w { + Wire::Secs(secs) | Wire::Duration { secs } => secs, + })) +} + +/// Body of `POST /torrents/categories`, which creates or edits a category. `None` limits are +/// left out of the body, so the category follows the global limit. +#[derive(Debug, Clone, PartialEq, Serialize, Default)] +pub struct CategoryBody { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub save_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ratio_limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub seeding_time_limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub inactive_seeding_time_limit: Option, +} + +/// Validated category share limits; every field blank means "use global". +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct CategoryLimits { + pub ratio: Option, + pub seeding_minutes: Option, + pub inactive_minutes: Option, +} + +impl CategoryLimits { + /// Parse the three text fields. Blank means unset; otherwise ratio must be a finite + /// number >= 0 and the times whole minutes. + pub fn parse(ratio: &str, seeding: &str, inactive: &str) -> Result { + let ratio = ratio.trim(); + let ratio = if ratio.is_empty() { + None + } else { + let v: f64 = ratio + .parse() + .map_err(|_| "Ratio limit must be a number.".to_owned())?; + if !v.is_finite() || v < 0.0 { + return Err("Ratio limit must be 0 or more.".to_owned()); + } + Some(v) + }; + let minutes = |text: &str, what: &str| -> Result, String> { + let text = text.trim(); + if text.is_empty() { + return Ok(None); + } + text.parse::() + .ok() + .filter(|m| m.checked_mul(60).is_some()) + .map(Some) + .ok_or_else(|| format!("{what} must be a whole number of minutes.")) + }; + Ok(Self { + ratio, + seeding_minutes: minutes(seeding, "Seeding time limit")?, + inactive_minutes: minutes(inactive, "Inactive seeding time limit")?, + }) + } + + pub fn into_body(self, name: &str, save_path: Option) -> CategoryBody { + CategoryBody { + name: name.to_owned(), + save_path, + ratio_limit: self.ratio, + seeding_time_limit: self.seeding_minutes.map(|m| m * 60), + inactive_seeding_time_limit: self.inactive_minutes.map(|m| m * 60), + } + } +} diff --git a/crates/rtbit-client/tests/wire.rs b/crates/rtbit-client/tests/wire.rs index c7a0d9b3..3333aaed 100644 --- a/crates/rtbit-client/tests/wire.rs +++ b/crates/rtbit-client/tests/wire.rs @@ -1082,3 +1082,52 @@ async fn bulk_tag_add_and_remove_each_use_one_request() { .unwrap(); server.verify().await; } + +#[tokio::test] +async fn categories_list_reads_limits_and_save_posts_only_set_limits() { + let server = MockServer::start().await; + let mut client = logged_in(&server).await; + Mock::given(method("GET")) + .and(path("/torrents/categories")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "tv": {"name": "tv", "ratio_limit": 2.5, + "seeding_time_limit": {"secs": 3600, "nanos": 0}}, + "iso": {"name": "iso"} + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/torrents/categories")) + .and(body_json(serde_json::json!({ + "name": "tv", "ratio_limit": 1.5, "seeding_time_limit": 7200 + }))) + .respond_with(ResponseTemplate::new(200).set_body_raw("{}", "application/json")) + .expect(1) + .mount(&server) + .await; + + let cats = client.list_categories().await.unwrap(); + assert_eq!(cats[0].name, "iso"); + assert_eq!(cats[0].ratio_limit, None); + assert_eq!(cats[1].ratio_limit, Some(2.5)); + assert_eq!(cats[1].seeding_time_limit, Some(3600)); + assert_eq!(cats[1].inactive_seeding_time_limit, None); + + let limits = rtbit_client::CategoryLimits::parse("1.5", "120", "").unwrap(); + client + .save_category(&limits.into_body("tv", None)) + .await + .unwrap(); +} + +#[test] +fn category_limits_validate() { + use rtbit_client::CategoryLimits as L; + assert_eq!(L::parse(" ", "", "").unwrap(), L::default()); + assert!(L::parse("-1", "", "").is_err()); + assert!(L::parse("abc", "", "").is_err()); + assert!(L::parse("inf", "", "").is_err()); + assert!(L::parse("", "1.5", "").is_err()); + assert!(L::parse("", "", "-3").is_err()); + assert_eq!(L::parse("0", "5", "6").unwrap().inactive_minutes, Some(6)); +} diff --git a/crates/rtbit-fluent/src/lib.rs b/crates/rtbit-fluent/src/lib.rs index 7cb7f59d..0bf66616 100644 --- a/crates/rtbit-fluent/src/lib.rs +++ b/crates/rtbit-fluent/src/lib.rs @@ -6,14 +6,17 @@ use fluent_app::{FluentApp, WindowTitle}; use fluent_layout::Workspace; -use gpui::AppContext; +use gpui::{App, AppContext, Bounds, px, size}; use rtbit_ui_core::Connection; +use window_state::{Rect, WindowState, clamp_to_displays}; + pub mod bridge; pub mod screens; pub mod shell; pub mod table_prefs; pub mod widgets; +pub mod window_state; /// Window title shown in the title bar. pub const WINDOW_TITLE: &str = "rustTorrent"; @@ -27,18 +30,78 @@ pub fn run() { return; } - let (w, h) = WINDOW_SIZE; - FluentApp::new(WINDOW_TITLE).window_size(w, h).run(|cx| { - screens::main_screen::bind_keys(cx); - let status = cx.new(|_| widgets::status_bar::StatusBar::new()); - let main = cx.new(|cx| screens::MainScreen::new(status.clone(), cx)); - let shell = cx.new(|cx| shell::Shell::new(status.clone(), main, cx)); - WindowTitle::set_status(cx, Connection::Disconnected.label()); - shell.update(cx, |shell, cx| shell.try_resume(cx)); - cx.new(|cx| { - Workspace::new(cx) - .content(shell.into()) - .status_bar(status.into()) + let state_path = WindowState::default_path(); + let saved = state_path + .as_deref() + .map(WindowState::load) + .unwrap_or_default(); + let restore_screen = saved.screen; + let (w, h) = saved + .bounds + .filter(|b| b.w >= window_state::MIN_EDGE && b.h >= window_state::MIN_EDGE) + .map_or(WINDOW_SIZE, |b| (b.w, b.h)); + let close_path = state_path.clone(); + FluentApp::new(WINDOW_TITLE) + .window_size(w, h) + .on_window_should_close(move |window, _cx| { + if let Some(path) = &close_path { + let mut state = WindowState::load(path); + let b = window.bounds(); + state.maximized = window.is_maximized(); + // A maximized window's bounds are the screen, not the size to come back to. + if !state.maximized { + state.bounds = Some(rect_of(b)); + } + let _ = state.save(path); + } + true }) + .run(move |cx| { + fit_window_to_displays(&saved, cx); + screens::main_screen::bind_keys(cx); + let status = cx.new(|_| widgets::status_bar::StatusBar::new()); + let main = cx.new(|cx| screens::MainScreen::new(status.clone(), cx)); + let shell = cx.new(|cx| shell::Shell::new(status.clone(), main, cx)); + WindowTitle::set_status(cx, Connection::Disconnected.label()); + shell.update(cx, |shell, cx| { + shell.remember_screen(state_path, restore_screen); + shell.try_resume(cx); + }); + cx.new(|cx| { + Workspace::new(cx) + .content(shell.into()) + .status_bar(status.into()) + }) + }); +} + +fn rect_of(b: Bounds) -> Rect { + Rect::new( + f32::from(b.origin.x), + f32::from(b.origin.y), + f32::from(b.size.width), + f32::from(b.size.height), + ) +} + +/// Once the window exists, shrink it to the display it was saved on, drop saved bounds that +/// are off every current display, and re-maximize. The platform picks the position. +fn fit_window_to_displays(saved: &WindowState, cx: &mut App) { + let saved = saved.clone(); + cx.defer(move |cx| { + let displays: Vec = cx.displays().iter().map(|d| rect_of(d.bounds())).collect(); + let fitted = saved.bounds.and_then(|b| clamp_to_displays(b, &displays)); + let Some(handle) = cx.windows().into_iter().next() else { + return; + }; + let _ = handle.update(cx, |_, window, _| { + match fitted { + Some(b) => window.resize(size(px(b.w), px(b.h))), + None => window.resize(size(px(WINDOW_SIZE.0), px(WINDOW_SIZE.1))), + } + if saved.maximized && !window.is_maximized() { + window.zoom_window(); + } + }); }); } diff --git a/crates/rtbit-fluent/src/screens/category_dialog.rs b/crates/rtbit-fluent/src/screens/category_dialog.rs new file mode 100644 index 00000000..731d5f78 --- /dev/null +++ b/crates/rtbit-fluent/src/screens/category_dialog.rs @@ -0,0 +1,193 @@ +use fluent_core::ThemeProvider as _; +use fluent_layout::{MessageBar, MessageIntent}; +use fluent_primitives::{Button, ButtonAppearance, Label, TextInput}; +use gpui::{Context, Entity, EventEmitter, IntoElement, Render, Window, div, prelude::*, px}; +use rtbit_client::{Category, CategoryBody, CategoryLimits}; + +#[derive(Clone, Debug, PartialEq)] +pub enum CategoryDialogEvent { + Submit(CategoryBody), + Cancel, +} + +/// Edits one category's three share limits. Blank fields use the global limit. +pub struct CategoryDialog { + name: String, + save_path: Option, + ratio: Entity, + seeding: Entity, + inactive: Entity, + error: Option, + submitted: bool, +} + +impl EventEmitter for CategoryDialog {} + +/// Validate the three text fields and build the save request. +pub fn build_body( + name: &str, + save_path: Option, + ratio: &str, + seeding: &str, + inactive: &str, +) -> Result { + Ok(CategoryLimits::parse(ratio, seeding, inactive)?.into_body(name, save_path)) +} + +impl CategoryDialog { + pub fn new(name: String, current: Option<&Category>, cx: &mut Context) -> Self { + let ratio = current + .and_then(|c| c.ratio_limit) + .map(|v| v.to_string()) + .unwrap_or_default(); + let minutes = |secs: Option| secs.map(|s| (s / 60).to_string()).unwrap_or_default(); + let seeding = minutes(current.and_then(|c| c.seeding_time_limit)); + let inactive = minutes(current.and_then(|c| c.inactive_seeding_time_limit)); + Self { + name, + save_path: current.and_then(|c| c.save_path.clone()), + ratio: cx.new(|_| TextInput::new().placeholder("use global").value(ratio)), + seeding: cx.new(|_| TextInput::new().placeholder("use global").value(seeding)), + inactive: cx.new(|_| TextInput::new().placeholder("use global").value(inactive)), + error: None, + submitted: false, + } + } + + pub fn set_error(&mut self, error: String, cx: &mut Context) { + self.error = Some(error); + self.submitted = false; + cx.notify(); + } + + fn submit(&mut self, cx: &mut Context) { + if self.submitted { + return; + } + match build_body( + &self.name, + self.save_path.clone(), + self.ratio.read(cx).text(), + self.seeding.read(cx).text(), + self.inactive.read(cx).text(), + ) { + Ok(body) => { + self.submitted = true; + self.error = None; + cx.emit(CategoryDialogEvent::Submit(body)); + } + Err(error) => self.set_error(error, cx), + } + } +} + +fn field(label: &'static str, input: Entity) -> impl IntoElement { + div() + .flex() + .flex_col() + .gap_1() + .child(Label::new(label)) + .child(input) +} + +impl Render for CategoryDialog { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.theme().colors.clone(); + let cancel = cx.entity(); + let submit = cx.entity(); + let mut card = div() + .flex() + .flex_col() + .gap_3() + .w(px(480.)) + .p_4() + .rounded_md() + .bg(colors.surface) + .border_1() + .border_color(colors.stroke_neutral) + .child(Label::new(format!("Share limits for “{}”", self.name))) + .child(field("Ratio limit (blank = global)", self.ratio.clone())) + .child(field( + "Seeding time limit, minutes (blank = global)", + self.seeding.clone(), + )) + .child(field( + "Inactive seeding time limit, minutes (blank = global)", + self.inactive.clone(), + )); + if let Some(error) = &self.error { + card = card.child(MessageBar::new(error.clone()).intent(MessageIntent::Error)); + } + card.child( + div() + .flex() + .gap_2() + .justify_end() + .child( + Button::new("category-cancel") + .label("Cancel") + .on_click(move |_, _, app| { + cancel.update(app, |_, cx| cx.emit(CategoryDialogEvent::Cancel)); + }), + ) + .child( + Button::new("category-save") + .label("Save") + .appearance(ButtonAppearance::Accent) + .on_click(move |_, _, app| { + submit.update(app, |dialog, cx| dialog.submit(cx)); + }), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rtbit_client::Client; + use wiremock::matchers::{body_json, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[test] + fn blank_fields_leave_every_limit_unset() { + let body = build_body("tv", None, "", " ", "").unwrap(); + assert_eq!(body.ratio_limit, None); + assert_eq!(body.seeding_time_limit, None); + assert_eq!(body.inactive_seeding_time_limit, None); + } + + #[test] + fn invalid_fields_are_rejected() { + assert!(build_body("tv", None, "-1", "", "").is_err()); + assert!(build_body("tv", None, "x", "", "").is_err()); + assert!(build_body("tv", None, "", "1.5", "").is_err()); + assert!(build_body("tv", None, "", "", "-2").is_err()); + } + + #[tokio::test] + async fn the_built_body_is_posted_to_the_categories_route() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/auth/login")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "a", "refresh_token": "r", "token_type": "Bearer", "expires_in": 900, "role": "admin" + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/torrents/categories")) + .and(body_json(serde_json::json!({ + "name": "tv", "save_path": "/data/tv", + "ratio_limit": 2.0, "seeding_time_limit": 3600 + }))) + .respond_with(ResponseTemplate::new(200).set_body_raw("{}", "application/json")) + .expect(1) + .mount(&server) + .await; + let mut client = Client::new(server.uri()).unwrap(); + client.login("ada", "secret").await.unwrap(); + let body = build_body("tv", Some("/data/tv".into()), "2", "60", "").unwrap(); + client.save_category(&body).await.unwrap(); + } +} diff --git a/crates/rtbit-fluent/src/screens/main_screen.rs b/crates/rtbit-fluent/src/screens/main_screen.rs index 69aa7f27..4d29ce5a 100644 --- a/crates/rtbit-fluent/src/screens/main_screen.rs +++ b/crates/rtbit-fluent/src/screens/main_screen.rs @@ -29,6 +29,7 @@ use rtbit_ui_core::{AddRequest, AddSource, RowAction, Snapshot, StatusFilter, fo use crate::bridge::{Bridge, LOST_AFTER_FAILURES, PollHealth, Poller, fetch_snapshot}; use crate::screens::add_dialog::{AddDialog, AddDialogEvent}; use crate::screens::bulk_edit_dialog::{BulkEditDialog, BulkEditDialogEvent, BulkEditRequest}; +use crate::screens::category_dialog::{CategoryDialog, CategoryDialogEvent}; use crate::screens::detail::DetailPane; use crate::screens::torrent_edit_dialog::{ TorrentEditDialog, TorrentEditDialogEvent, TorrentEditKind, @@ -206,6 +207,8 @@ pub struct MainScreen { _add_send: Option>, bulk_edit: Option>, _bulk_edit_events: Option, + category_dialog: Option>, + _category_events: Option, torrent_edit: Option>, _torrent_edit_events: Option, toasts: Entity, @@ -276,6 +279,8 @@ impl MainScreen { _add_events: None, _add_send: None, bulk_edit: None, + category_dialog: None, + _category_events: None, _bulk_edit_events: None, torrent_edit: None, _torrent_edit_events: None, @@ -822,6 +827,11 @@ impl MainScreen { !targets.is_empty(), MenuCommand::BulkEdit, )) + .item(item( + "Category share limits…", + targets.first().is_some_and(|t| t.category.is_some()), + MenuCommand::CategoryLimits, + )) .item(item( "Set location…", available.edit, @@ -846,6 +856,7 @@ impl MainScreen { self.menu = None; match command { MenuCommand::BulkEdit => self.open_bulk_edit(cx), + MenuCommand::CategoryLimits => self.open_category_dialog(cx), MenuCommand::SetLocation => self.open_torrent_edit(TorrentEditKind::Location, cx), MenuCommand::Rename => self.open_torrent_edit(TorrentEditKind::Name, cx), MenuCommand::Delete => self.request(RowAction::Delete, cx), @@ -944,6 +955,86 @@ impl MainScreen { })); } + /// Load the first target's category from the daemon, then open its share-limit editor. + fn open_category_dialog(&mut self, cx: &mut Context) { + let Some(name) = self.targets().first().and_then(|t| t.category.clone()) else { + self.action_error = Some("Select a torrent that has a category first.".to_owned()); + cx.notify(); + return; + }; + let Some(bridge) = cx.try_global::() else { + self.action_error = Some("Not connected to a daemon.".to_owned()); + cx.notify(); + return; + }; + let rx = + bridge.call(move |client| async move { client.lock().await.list_categories().await }); + self._action = Some(cx.spawn(async move |view, cx| { + let result = match rx.await { + Ok(result) => result, + Err(_) => Err(rtbit_client::Error::Unreachable( + "request cancelled".to_owned(), + )), + }; + let _ = view.update(cx, |screen, cx| match result { + Ok(categories) => { + let current = categories.iter().find(|c| c.name == name).cloned(); + let dialog = cx.new(|cx| CategoryDialog::new(name, current.as_ref(), cx)); + let events = cx.subscribe(&dialog, |screen, dialog, event, cx| match event { + CategoryDialogEvent::Cancel => { + screen.category_dialog = None; + screen._category_events = None; + cx.notify(); + } + CategoryDialogEvent::Submit(body) => { + screen.send_category(dialog, body.clone(), cx) + } + }); + screen.category_dialog = Some(dialog); + screen._category_events = Some(events); + cx.notify(); + } + Err(error) => { + screen.action_error = Some(map_error(&error)); + cx.notify(); + } + }); + })); + } + + fn send_category( + &mut self, + dialog: Entity, + body: rtbit_client::CategoryBody, + cx: &mut Context, + ) { + let Some(bridge) = cx.try_global::() else { + dialog.update(cx, |d, cx| { + d.set_error("Not connected to a daemon.".to_owned(), cx) + }); + return; + }; + let rx = bridge + .call(move |client| async move { client.lock().await.save_category(&body).await }); + self._action = Some(cx.spawn(async move |view, cx| { + let result = match rx.await { + Ok(result) => result, + Err(_) => Err(rtbit_client::Error::Unreachable( + "request cancelled".to_owned(), + )), + }; + let _ = view.update(cx, |screen, cx| match result { + Ok(()) => { + screen.category_dialog = None; + screen._category_events = None; + screen.action_error = None; + cx.notify(); + } + Err(error) => dialog.update(cx, |d, cx| d.set_error(map_error(&error), cx)), + }); + })); + } + fn open_bulk_edit(&mut self, cx: &mut Context) { let count = self.targets().len(); if count == 0 { @@ -1606,6 +1697,9 @@ impl Render for MainScreen { if let Some(dialog) = &self.bulk_edit { root = root.child(dialog.clone()); } + if let Some(dialog) = &self.category_dialog { + root = root.child(dialog.clone()); + } if let Some(dialog) = &self.torrent_edit { root = root.child(dialog.clone()); } @@ -1670,6 +1764,7 @@ enum MenuCommand { Recheck, SetCategory(Option), BulkEdit, + CategoryLimits, SetLocation, Rename, CopyMagnet, diff --git a/crates/rtbit-fluent/src/screens/mod.rs b/crates/rtbit-fluent/src/screens/mod.rs index 641fa7e0..6325f949 100644 --- a/crates/rtbit-fluent/src/screens/mod.rs +++ b/crates/rtbit-fluent/src/screens/mod.rs @@ -2,6 +2,7 @@ pub mod add_dialog; pub mod bulk_edit_dialog; +pub mod category_dialog; pub mod connect; pub mod detail; pub mod main_screen; diff --git a/crates/rtbit-fluent/src/shell.rs b/crates/rtbit-fluent/src/shell.rs index 41ca2678..8cf7f96a 100644 --- a/crates/rtbit-fluent/src/shell.rs +++ b/crates/rtbit-fluent/src/shell.rs @@ -14,6 +14,7 @@ use crate::screens::main_screen::MainEvent; use crate::screens::rss::RssScreen; use crate::screens::settings::SettingsScreen; use crate::widgets::status_bar::StatusBar; +use crate::window_state::WindowState; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum DropKind { @@ -58,6 +59,10 @@ pub struct Shell { lost_retry_secs: Option, _connect_events: Subscription, _main_events: Subscription, + /// Screen to open on after the next successful sign-in, from the last session. + restore_screen: Option, + /// Where the current screen is remembered; `None` keeps nothing (tests). + state_path: Option, } impl Shell { @@ -95,6 +100,30 @@ impl Shell { lost_retry_secs: None, _connect_events: connect_events, _main_events: main_events, + restore_screen: None, + state_path: None, + } + } + + /// Remember the screen in `path`, and open on `restore` after the first sign-in. + pub fn remember_screen(&mut self, path: Option, restore: Option) { + self.state_path = path; + self.restore_screen = restore; + } + + /// Show a screen reached by navigation or restore, loading what it needs. + fn show(&mut self, screen: Screen, cx: &mut Context) { + self.screen = screen; + if screen == Screen::Rss { + self.rss.update(cx, |rss, cx| rss.load(cx)); + } + if screen == Screen::Settings { + self.settings.update(cx, |settings, cx| settings.load(cx)); + } + if let Some(path) = &self.state_path { + let mut state = WindowState::load(path); + state.screen = Some(screen); + let _ = state.save(path); } } @@ -131,7 +160,8 @@ impl Shell { match event { ConnectEvent::Connecting => self.set_connection(Connection::Connecting, cx), ConnectEvent::Connected => { - self.screen = Screen::Main; + let screen = self.restore_screen.take().unwrap_or(Screen::Main); + self.show(screen, cx); self.set_connection(Connection::Connected, cx); self.status.update(cx, |bar, cx| bar.load_settings(cx)); } @@ -157,13 +187,7 @@ impl Shell { /// Switch screens by sidebar id. Unknown ids are ignored. pub fn select_screen(&mut self, id: &str, cx: &mut Context) { if let Some((_, _, screen)) = NAV.iter().find(|(nav_id, _, _)| *nav_id == id) { - self.screen = *screen; - if self.screen == Screen::Rss { - self.rss.update(cx, |rss, cx| rss.load(cx)); - } - if self.screen == Screen::Settings { - self.settings.update(cx, |settings, cx| settings.load(cx)); - } + self.show(*screen, cx); cx.notify(); } } diff --git a/crates/rtbit-fluent/src/window_state.rs b/crates/rtbit-fluent/src/window_state.rs new file mode 100644 index 00000000..9879612e --- /dev/null +++ b/crates/rtbit-fluent/src/window_state.rs @@ -0,0 +1,242 @@ +//! Window geometry and the last screen, kept in a small local JSON file so the +//! next launch opens where the last one closed. + +use std::path::{Path, PathBuf}; + +use rtbit_ui_core::Screen; +use serde_json::{Value, json}; + +const FILE_NAME: &str = "window.json"; + +/// Smallest window edge a stored size may have, so a bad file cannot open a sliver. +pub const MIN_EDGE: f32 = 320.0; +/// How much of the window must overlap a display (width, height) to count as on it. +const MIN_VISIBLE: (f32, f32) = (100.0, 40.0); + +/// A rectangle in logical pixels, in the desktop's shared coordinate space. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +impl Rect { + #[must_use] + pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self { + Self { x, y, w, h } + } + + fn is_finite(&self) -> bool { + [self.x, self.y, self.w, self.h] + .iter() + .all(|v| v.is_finite()) + } + + /// Overlapping width and height with `other`, zero when they do not meet. + fn overlap(&self, other: &Rect) -> (f32, f32) { + let w = (self.x + self.w).min(other.x + other.w) - self.x.max(other.x); + let h = (self.y + self.h).min(other.y + other.h) - self.y.max(other.y); + (w.max(0.0), h.max(0.0)) + } +} + +/// Fit saved bounds to the current displays. Bounds that no longer touch any display +/// (a monitor was unplugged) give `None`; otherwise the window is shrunk to the display +/// it mostly sits on and moved fully onto it. +#[must_use] +pub fn clamp_to_displays(saved: Rect, displays: &[Rect]) -> Option { + if !saved.is_finite() || saved.w < MIN_EDGE || saved.h < MIN_EDGE { + return None; + } + let (display, (ow, oh)) = displays + .iter() + .map(|d| (d, saved.overlap(d))) + .max_by(|a, b| (a.1.0 * a.1.1).total_cmp(&(b.1.0 * b.1.1)))?; + if ow < MIN_VISIBLE.0 || oh < MIN_VISIBLE.1 { + return None; + } + let w = saved.w.min(display.w); + let h = saved.h.min(display.h); + let x = saved.x.clamp(display.x, display.x + display.w - w); + let y = saved.y.clamp(display.y, display.y + display.h - h); + Some(Rect::new(x, y, w, h)) +} + +/// What is remembered between launches. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct WindowState { + pub bounds: Option, + pub maximized: bool, + pub screen: Option, +} + +fn screen_name(screen: Screen) -> Option<&'static str> { + match screen { + Screen::Main => Some("main"), + Screen::Rss => Some("rss"), + Screen::Settings => Some("settings"), + // Sign-in screens are never worth returning to. + _ => None, + } +} + +fn screen_from_name(name: &str) -> Option { + match name { + "main" => Some(Screen::Main), + "rss" => Some(Screen::Rss), + "settings" => Some(Screen::Settings), + _ => None, + } +} + +impl WindowState { + /// `/rtbit-fluent/window.json`, next to the table preferences. + #[must_use] + pub fn default_path() -> Option { + crate::table_prefs::TablePrefs::default_path().map(|p| p.with_file_name(FILE_NAME)) + } + + /// Read the file; a missing or unreadable file gives the defaults. + #[must_use] + pub fn load(path: &Path) -> Self { + std::fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .map(|value| Self::from_json(&value)) + .unwrap_or_default() + } + + pub fn save(&self, path: &Path) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(path, serde_json::to_string_pretty(&self.to_json())?) + } + + fn to_json(&self) -> Value { + let bounds = self + .bounds + .map(|b| json!({ "x": b.x, "y": b.y, "w": b.w, "h": b.h })); + json!({ "bounds": bounds, "maximized": self.maximized, + "screen": self.screen.and_then(screen_name) }) + } + + fn from_json(value: &Value) -> Self { + let bounds = value.get("bounds").and_then(|b| { + let n = |key| b.get(key)?.as_f64().map(|v| v as f32); + Some(Rect::new(n("x")?, n("y")?, n("w")?, n("h")?)) + }); + Self { + bounds: bounds.filter(Rect::is_finite), + maximized: value + .get("maximized") + .and_then(Value::as_bool) + .unwrap_or(false), + screen: value + .get("screen") + .and_then(Value::as_str) + .and_then(screen_from_name), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const PRIMARY: Rect = Rect { + x: 0.0, + y: 0.0, + w: 1920.0, + h: 1080.0, + }; + const SECOND: Rect = Rect { + x: 1920.0, + y: 0.0, + w: 1280.0, + h: 1024.0, + }; + + fn temp_dir(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("rtbit-fluent-ws-{name}-{}", std::process::id())) + } + + #[test] + fn bounds_on_a_display_are_kept() { + let saved = Rect::new(100.0, 80.0, 1100.0, 720.0); + assert_eq!(clamp_to_displays(saved, &[PRIMARY]), Some(saved)); + } + + #[test] + fn bounds_on_an_unplugged_display_are_discarded() { + let saved = Rect::new(2100.0, 80.0, 1100.0, 720.0); + assert_eq!(clamp_to_displays(saved, &[SECOND]), Some(saved)); + assert_eq!(clamp_to_displays(saved, &[PRIMARY]), None); + assert_eq!(clamp_to_displays(saved, &[]), None); + } + + #[test] + fn barely_visible_bounds_are_discarded() { + let saved = Rect::new(1900.0, 1060.0, 1100.0, 720.0); + assert_eq!(clamp_to_displays(saved, &[PRIMARY]), None); + } + + #[test] + fn partly_off_screen_bounds_move_back_on() { + let saved = Rect::new(1200.0, -50.0, 1100.0, 720.0); + let fixed = clamp_to_displays(saved, &[PRIMARY]).unwrap(); + assert_eq!(fixed, Rect::new(820.0, 0.0, 1100.0, 720.0)); + } + + #[test] + fn oversized_bounds_shrink_to_the_display() { + let saved = Rect::new(0.0, 0.0, 5000.0, 4000.0); + let fixed = clamp_to_displays(saved, &[PRIMARY]).unwrap(); + assert_eq!(fixed, PRIMARY); + } + + #[test] + fn nonsense_bounds_are_discarded() { + assert_eq!( + clamp_to_displays(Rect::new(0.0, 0.0, 10.0, 10.0), &[PRIMARY]), + None + ); + assert_eq!( + clamp_to_displays(Rect::new(f32::NAN, 0.0, 800.0, 600.0), &[PRIMARY]), + None + ); + } + + #[test] + fn round_trips_through_the_file() { + let dir = temp_dir("roundtrip"); + let path = dir.join("nested").join(FILE_NAME); + let state = WindowState { + bounds: Some(Rect::new(10.5, 20.0, 900.0, 700.0)), + maximized: true, + screen: Some(Screen::Rss), + }; + state.save(&path).unwrap(); + assert_eq!(WindowState::load(&path), state); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn sign_in_screens_are_not_stored_and_bad_files_give_defaults() { + let dir = temp_dir("bad"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(FILE_NAME); + assert_eq!(WindowState::load(&path), WindowState::default()); + std::fs::write(&path, "not json").unwrap(); + assert_eq!(WindowState::load(&path), WindowState::default()); + let state = WindowState { + screen: Some(Screen::Connect), + ..Default::default() + }; + state.save(&path).unwrap(); + assert_eq!(WindowState::load(&path).screen, None); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/rtbit-ui-core/src/format.rs b/crates/rtbit-ui-core/src/format.rs index 94d8694e..37d481d3 100644 --- a/crates/rtbit-ui-core/src/format.rs +++ b/crates/rtbit-ui-core/src/format.rs @@ -53,6 +53,16 @@ pub fn is_missing_files(stats: Option<&rtbit_client::TorrentStats>) -> bool { pub fn row_state_label(stats: Option<&rtbit_client::TorrentStats>) -> String { if is_missing_files(stats) { "Files missing".to_owned() + } else if let Some(stats) = stats.filter(|s| s.state == Some(TorrentState::Initializing)) { + if stats.queued_for_init == Some(true) { + "Queued for check".to_owned() + } else if let Some(pct) = + (stats.progress_bytes.min(stats.total_bytes) * 100).checked_div(stats.total_bytes) + { + format!("Checking {pct}%") + } else { + "Checking".to_owned() + } } else { state_label(stats.and_then(|stats| stats.state)) } @@ -92,5 +102,15 @@ mod tests { }; assert_eq!(row_state_label(Some(&missing)), "Files missing"); assert_eq!(row_state_label(None), "—"); + let mut checking = rtbit_client::TorrentStats { + state: Some(TorrentState::Initializing), + progress_bytes: 42, + total_bytes: 100, + queued_for_init: Some(false), + ..Default::default() + }; + assert_eq!(row_state_label(Some(&checking)), "Checking 42%"); + checking.queued_for_init = Some(true); + assert_eq!(row_state_label(Some(&checking)), "Queued for check"); } }