diff --git a/crates/librtbit/src/api.rs b/crates/librtbit/src/api.rs index f38a9e6..c595dbe 100644 --- a/crates/librtbit/src/api.rs +++ b/crates/librtbit/src/api.rs @@ -36,7 +36,7 @@ use crate::{ #[cfg(feature = "tracing-subscriber-utils")] use crate::tracing_subscriber_config_utils::LineBroadcast; #[cfg(feature = "tracing-subscriber-utils")] -use futures::Stream; +use futures::{Stream, StreamExt, stream}; #[cfg(feature = "tracing-subscriber-utils")] use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError}; @@ -463,11 +463,12 @@ impl Api { + Sync + 'static, > { - Ok(self + let (history, receiver) = self .line_broadcast .as_ref() - .map(|sender| BroadcastStream::new(sender.subscribe())) - .context("line_rx wasn't set")?) + .map(LineBroadcast::subscribe) + .context("line_rx wasn't set")?; + Ok(stream::iter(history.into_iter().map(Ok)).chain(BroadcastStream::new(receiver))) } pub async fn api_add_torrent( diff --git a/crates/librtbit/src/http_api/handlers/logging.rs b/crates/librtbit/src/http_api/handlers/logging.rs index c8acea2..6429ed4 100644 --- a/crates/librtbit/src/http_api/handlers/logging.rs +++ b/crates/librtbit/src/http_api/handlers/logging.rs @@ -27,7 +27,7 @@ pub async fn h_set_rust_log( get, path = "/stream_logs", responses( - (status = 200, description = "Continuous log stream (Server-Sent Events)") + (status = 200, description = "Recent and continuous newline-delimited JSON log stream") ) ))] pub async fn h_stream_logs(State(state): State) -> Result { @@ -35,5 +35,11 @@ pub async fn h_stream_logs(State(state): State) -> Result impl IntoResponse { "POST /torrents": "Add a torrent here. magnet: or http:// or a local file.", "POST /torrents/create": "Create a torrent and start seeding. Body should be a local folder", "POST /torrents/resolve_magnet": "Resolve a magnet to torrent file bytes", + "GET /torrents/seed_limits": "Global seed ratio and time limits", + "POST /torrents/seed_limits": "Set global seed limits: {\"ratio_limit\": n, \"time_limit_secs\": n}, null = off", + "GET /torrents/{id_or_infohash}/seed_limits": "Per-torrent seed ratio and time limits", + "POST /torrents/{id_or_infohash}/seed_limits": "Set per-torrent seed limits", "GET /torrents/{id_or_infohash}/limits": "Per-torrent speed limits", "POST /torrents/{id_or_infohash}/limits": "Set per-torrent speed limits: {\"download_bps\": n, \"upload_bps\": n}, omitted = unlimited", "POST /torrents/{id_or_infohash}/pause": "Pause torrent", @@ -121,7 +125,12 @@ pub fn make_api_router(state: ApiState) -> Router { "/torrents/{id}/stream/{file_id}/{*filename}", get(streaming::h_torrent_stream_file), ) + .route("/torrents/seed_limits", get(torrents::h_get_seed_limits)) .route("/torrents/limits", get(configure::h_get_session_ratelimits)) + .route( + "/torrents/{id}/seed_limits", + get(torrents::h_get_torrent_seed_limits), + ) .route( "/torrents/{id}/limits", get(torrents::h_get_torrent_rate_limits), @@ -141,6 +150,7 @@ pub fn make_api_router(state: ApiState) -> Router { if !state.opts.read_only { api_router = api_router .route("/torrents", post(torrents::h_torrents_post)) + .route("/torrents/seed_limits", post(torrents::h_set_seed_limits)) .route( "/torrents/limits", post(configure::h_update_session_ratelimits), @@ -199,10 +209,30 @@ pub fn make_api_router(state: ApiState) -> Router { "/torrents/{id}/set_category", post(torrents::h_set_torrent_category), ) + .route( + "/torrents/{id}/seed_limits", + post(torrents::h_set_torrent_seed_limits), + ) .route( "/torrents/{id}/limits", post(torrents::h_set_torrent_rate_limits), ) + .route( + "/torrents/{id}/queue/top", + post(torrents::h_torrent_queue_top), + ) + .route( + "/torrents/{id}/queue/up", + post(torrents::h_torrent_queue_up), + ) + .route( + "/torrents/{id}/queue/down", + post(torrents::h_torrent_queue_down), + ) + .route( + "/torrents/{id}/queue/bottom", + post(torrents::h_torrent_queue_bottom), + ) .route("/session/folders", post(configure::h_set_folders)) .route("/session/downloads", post(configure::h_set_downloads)) .route("/speed/alt", post(speed::h_toggle_alt_speed)) diff --git a/crates/librtbit/src/http_api/handlers/torrents.rs b/crates/librtbit/src/http_api/handlers/torrents.rs index 2f6336a..7ef8c87 100644 --- a/crates/librtbit/src/http_api/handlers/torrents.rs +++ b/crates/librtbit/src/http_api/handlers/torrents.rs @@ -294,6 +294,52 @@ pub async fn h_torrent_action_pause( (status = 200, description = "Torrent started", body = EmptyJsonResponse) ) ))] +#[cfg_attr(feature = "swagger", utoipa::path( + post, + path = "/torrents/{id}/queue/{action}", + params(("id" = String, Path, description = "Torrent ID or info hash")), + responses( + (status = 200, description = "Torrent moved in the queue", body = EmptyJsonResponse) + ) +))] +async fn h_torrent_queue_move( + state: ApiState, + idx: TorrentIdOrHash, + mv: crate::session::scheduler::QueueMove, +) -> Result { + let id = state.api.mgr_handle(idx)?.id(); + state.api.session().move_in_queue(&[id], mv).await; + Ok(axum::Json(crate::api::EmptyJsonResponse {})) +} + +pub async fn h_torrent_queue_top( + State(state): State, + Path(idx): Path, +) -> Result { + h_torrent_queue_move(state, idx, crate::session::scheduler::QueueMove::Top).await +} + +pub async fn h_torrent_queue_up( + State(state): State, + Path(idx): Path, +) -> Result { + h_torrent_queue_move(state, idx, crate::session::scheduler::QueueMove::Up).await +} + +pub async fn h_torrent_queue_down( + State(state): State, + Path(idx): Path, +) -> Result { + h_torrent_queue_move(state, idx, crate::session::scheduler::QueueMove::Down).await +} + +pub async fn h_torrent_queue_bottom( + State(state): State, + Path(idx): Path, +) -> Result { + h_torrent_queue_move(state, idx, crate::session::scheduler::QueueMove::Bottom).await +} + pub async fn h_torrent_action_start( State(state): State, Path(idx): Path, @@ -761,6 +807,80 @@ pub async fn h_get_torrent_rate_limits( state.api.api_torrent_rate_limits(idx).map(axum::Json) } +/// Global seed limits the Seeding tab reads and writes. `null` disables a limit. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq)] +pub struct SeedLimitsBody { + pub ratio_limit: Option, + pub time_limit_secs: Option, +} + +impl From for SeedLimitsBody { + fn from(limits: crate::session::ShareLimits) -> Self { + Self { + ratio_limit: limits.max_ratio, + time_limit_secs: limits.max_seeding_time.map(|d| d.as_secs()), + } + } +} + +impl From for crate::session::ShareLimits { + fn from(body: SeedLimitsBody) -> Self { + use std::time::Duration; + Self { + max_ratio: body.ratio_limit.filter(|ratio| *ratio > 0.0), + max_seeding_time: body + .time_limit_secs + .filter(|secs| *secs > 0) + .map(Duration::from_secs), + max_inactive_seeding_time: None, + } + } +} + +pub async fn h_get_seed_limits(State(state): State) -> impl IntoResponse { + axum::Json(SeedLimitsBody::from(state.api.session().seed_limits())) +} + +pub async fn h_set_seed_limits( + State(state): State, + axum::Json(body): axum::Json, +) -> Result { + state + .api + .session() + .set_seed_limits(body.into()) + .await + .context("error saving seed limits")?; + Ok(axum::Json(serde_json::json!({}))) +} + +pub async fn h_get_torrent_seed_limits( + State(state): State, + Path(idx): Path, +) -> Result { + let handle = state.api.mgr_handle(idx)?; + Ok(axum::Json(SeedLimitsBody::from( + state.api.session().share_limits(&handle), + ))) +} + +pub async fn h_set_torrent_seed_limits( + State(state): State, + Path(idx): Path, + axum::Json(body): axum::Json, +) -> Result { + let handle = state.api.mgr_handle(idx)?; + // A per-torrent change keeps the inactive-seeding limit the torrent already has. + let mut limits = crate::session::ShareLimits::from(body); + limits.max_inactive_seeding_time = state + .api + .session() + .share_limits(&handle) + .max_inactive_seeding_time; + state.api.session().set_share_limits(&handle, limits).await; + Ok(axum::Json(serde_json::json!({}))) +} + pub async fn h_set_torrent_rate_limits( State(state): State, Path(idx): Path, @@ -784,7 +904,8 @@ mod tests { use super::{ AddTrackersRequest, EditTrackerRequest, RemoveTrackersRequest, h_torrent_action_add_trackers, h_torrent_action_edit_tracker, h_torrent_action_recheck, - h_torrent_action_remove_trackers, + h_torrent_action_remove_trackers, h_torrent_queue_bottom, h_torrent_queue_down, + h_torrent_queue_top, h_torrent_queue_up, }; use crate::{ AddTorrent, AddTorrentOptions, Api, CreateTorrentOptions, Session, SessionOptions, @@ -794,6 +915,26 @@ mod tests { torrent_state::{ManagedTorrentHandle, TorrentStatsState}, }; + #[test] + fn seed_limits_body_roundtrips_and_normalizes_disabled_values() { + let limits = crate::session::ShareLimits::from(super::SeedLimitsBody { + ratio_limit: Some(2.5), + time_limit_secs: Some(7200), + }); + assert_eq!(limits.max_ratio, Some(2.5)); + assert_eq!( + limits.max_seeding_time, + Some(std::time::Duration::from_secs(7200)) + ); + assert_eq!(super::SeedLimitsBody::from(limits).ratio_limit, Some(2.5)); + + let disabled = crate::session::ShareLimits::from(super::SeedLimitsBody { + ratio_limit: Some(0.0), + time_limit_secs: Some(0), + }); + assert!(disabled.is_unset()); + } + async fn torrent_api() -> anyhow::Result<(Arc, ManagedTorrentHandle, TempDir)> { let payload = TempDir::with_prefix("rtbit_http_action_payload")?; std::fs::write(payload.path().join("payload.bin"), vec![0x5e; 32 * 1024])?; @@ -944,4 +1085,60 @@ mod tests { assert!(matches!(handle.stats().state, TorrentStatsState::Paused)); Ok(()) } + + #[tokio::test] + async fn queue_move_handlers_reorder() -> anyhow::Result<()> { + let (state, first, payload) = torrent_api().await?; + let session = state.api.session().clone(); + let torrent = create_torrent( + payload.path(), + CreateTorrentOptions { + name: Some("second"), + piece_length: Some(16_384), + ..Default::default() + }, + &BlockingSpawner::new(1), + ) + .await? + .as_bytes()? + .to_vec(); + let second = session + .add_torrent( + AddTorrent::from_bytes(torrent), + Some(AddTorrentOptions { + paused: true, + overwrite: true, + output_folder: Some(payload.path().to_string_lossy().into_owned()), + ..Default::default() + }), + ) + .await? + .into_handle() + .context("expected second torrent")?; + second.wait_until_initialized().await?; + + let order = || session.queue_order(); + let first_id = first.id(); + let second_id = second.id(); + assert_eq!(order(), vec![first_id, second_id]); + + let move_err = |error| anyhow::anyhow!("{error:?}"); + h_torrent_queue_top(State(state.clone()), Path(second_id.into())) + .await + .map_err(move_err)?; + assert_eq!(order(), vec![second_id, first_id]); + h_torrent_queue_down(State(state.clone()), Path(second_id.into())) + .await + .map_err(move_err)?; + assert_eq!(order(), vec![first_id, second_id]); + h_torrent_queue_up(State(state.clone()), Path(second_id.into())) + .await + .map_err(move_err)?; + assert_eq!(order(), vec![second_id, first_id]); + h_torrent_queue_bottom(State(state), Path(second_id.into())) + .await + .map_err(move_err)?; + assert_eq!(order(), vec![first_id, second_id]); + Ok(()) + } } diff --git a/crates/librtbit/src/http_api/mod.rs b/crates/librtbit/src/http_api/mod.rs index 538f0c5..99deaf8 100644 --- a/crates/librtbit/src/http_api/mod.rs +++ b/crates/librtbit/src/http_api/mod.rs @@ -166,7 +166,13 @@ fn is_public_auth_path(path: &str) -> bool { /// Paths reachable before any credentials exist: the setup status and setup endpoints, /// and the static web UI that hosts the setup page. Everything else waits for setup. fn is_pre_setup_path(path: &str) -> bool { - path == "/auth/status" || path == "/auth/setup" || path == "/web" || path.starts_with("/web/") + path == "/auth/status" || path == "/auth/setup" || is_web_ui_path(path) +} + +/// Static web UI. Served without a session so the browser can load the sign-in page +/// instead of a Basic-auth dialog. API calls from the page still authenticate. +fn is_web_ui_path(path: &str) -> bool { + path == "/" || path == "/web" || path.starts_with("/web/") } impl HttpApi { @@ -294,7 +300,7 @@ impl HttpApi { async move { // Always skip auth for these public endpoints let path = request.uri().path(); - if is_public_auth_path(path) { + if is_public_auth_path(path) || is_web_ui_path(path) { return Ok(next.run(request).await); } @@ -355,6 +361,20 @@ impl HttpApi { let user_pass = match user_pass { Some(up) => up, None => { + // Browser navigation to the UI gets the sign-in page, not a + // Basic challenge. API clients still receive the challenge. + if headers + .get("Accept") + .and_then(|h| h.to_str().ok()) + .is_some_and(|h| h.contains("text/html")) + { + return Ok(( + StatusCode::TEMPORARY_REDIRECT, + [("Location", "/web/")], + "sign in", + ) + .into_response()); + } return Ok(( StatusCode::UNAUTHORIZED, [("WWW-Authenticate", "Basic realm=\"API\"")], @@ -515,7 +535,8 @@ mod tests { assert!(is_pre_setup_path("/web/")); assert!(is_pre_setup_path("/web/assets/index.js")); - assert!(!is_pre_setup_path("/")); + // `/` is the browser entry point; it redirects to the sign-in page. + assert!(is_pre_setup_path("/")); assert!(!is_pre_setup_path("/torrents")); assert!(!is_pre_setup_path("/fs/browse")); assert!(!is_pre_setup_path("/auth/change_credentials")); diff --git a/crates/librtbit/src/session/mod.rs b/crates/librtbit/src/session/mod.rs index f8ed426..eefd061 100644 --- a/crates/librtbit/src/session/mod.rs +++ b/crates/librtbit/src/session/mod.rs @@ -175,6 +175,8 @@ pub struct Session { max_active_seeds: AtomicUsize, min_free_disk_space: std::sync::atomic::AtomicU64, use_part_files: std::sync::atomic::AtomicBool, + /// Global seed limits for torrents that have none of their own. + seed_limits: RwLock, // Alternative ("turtle mode") speed limits state. pub(crate) alt_speed: crate::alt_speed::AltSpeedState, @@ -443,12 +445,16 @@ impl Session { let mut max_active_downloads = 0; let mut max_active_seeds = 0; let mut use_part_files = false; + let mut seed_limits = crate::session::ShareLimits::default(); if let Some(p) = persistence.as_ref() { match p.load_session_settings().await { Ok(settings) => { if let Some(output_folder) = settings.output_folder { resolved_output_folder = output_folder; } + if let Some(limits) = settings.seed_limits { + seed_limits = limits; + } if resolved_completed_folder.is_none() { resolved_completed_folder = settings.completed_folder; } @@ -532,6 +538,7 @@ impl Session { max_active_seeds: AtomicUsize::new(max_active_seeds), min_free_disk_space: std::sync::atomic::AtomicU64::new(0), use_part_files: std::sync::atomic::AtomicBool::new(use_part_files), + seed_limits: RwLock::new(seed_limits), alt_speed: Default::default(), }); @@ -1501,6 +1508,7 @@ impl Session { max_active_downloads: self.max_active_downloads(), max_active_seeds: self.max_active_seeds(), part_files: Some(self.use_part_files()), + seed_limits: Some(self.seed_limits()), }) .await } diff --git a/crates/librtbit/src/session/scheduler.rs b/crates/librtbit/src/session/scheduler.rs index e11908a..3b1e664 100644 --- a/crates/librtbit/src/session/scheduler.rs +++ b/crates/librtbit/src/session/scheduler.rs @@ -115,6 +115,10 @@ pub(crate) enum ShareLimitReason { /// `seeding_secs` is zero when the completion time is unknown, which disables /// the seeding-time check rather than tripping it. `inactive_secs` is the time /// since the upload counter last moved, and zero likewise disables its check. +pub(crate) fn effective_share_limits(own: ShareLimits, global: ShareLimits) -> ShareLimits { + if own.is_unset() { global } else { own } +} + pub(crate) fn share_limit_reached( limits: &ShareLimits, uploaded_bytes: u64, @@ -307,6 +311,18 @@ impl Session { *handle.shared().share_limits.read() } + /// Global seed limits. Applied to torrents that have no limits of their own. + pub fn seed_limits(&self) -> ShareLimits { + *self.seed_limits.read() + } + + /// Set the global seed limits and save them with the session settings. + /// The next policy pass applies them to torrents without their own limits. + pub async fn set_seed_limits(&self, limits: ShareLimits) -> anyhow::Result<()> { + *self.seed_limits.write() = limits; + self.persist_session_settings().await + } + /// Maximum concurrently downloading torrents. `None` means unlimited. pub fn max_active_downloads(&self) -> Option { decode_limit(self.max_active_downloads.load(Ordering::Relaxed)) @@ -471,7 +487,8 @@ impl Session { ) && stats.finished && !handle.is_paused() { - let limits = *handle.shared().share_limits.read(); + let own = *handle.shared().share_limits.read(); + let limits = effective_share_limits(own, *self.seed_limits.read()); let completed_at = handle.shared().completion_on.load(Ordering::Relaxed); let seeding_secs = if completed_at > 0 { now.saturating_sub(completed_at) @@ -593,6 +610,18 @@ mod tests { } } + #[test] + fn global_share_limits_apply_only_without_per_torrent_limits() { + let global = limits(Some(2.0), Some(3600)); + assert_eq!( + effective_share_limits(ShareLimits::default(), global), + global + ); + + let own = limits(Some(1.5), None); + assert_eq!(effective_share_limits(own, global), own); + } + #[test] fn no_limits_never_triggers() { assert_eq!( diff --git a/crates/librtbit/src/session_persistence/json.rs b/crates/librtbit/src/session_persistence/json.rs index 6295985..6c7f3c2 100644 --- a/crates/librtbit/src/session_persistence/json.rs +++ b/crates/librtbit/src/session_persistence/json.rs @@ -485,6 +485,7 @@ mod tests { max_active_downloads: Some(3), max_active_seeds: None, part_files: Some(true), + seed_limits: None, }; store.store_session_settings(&settings).await.unwrap(); diff --git a/crates/librtbit/src/session_persistence/mod.rs b/crates/librtbit/src/session_persistence/mod.rs index 7738f3f..4d8787c 100644 --- a/crates/librtbit/src/session_persistence/mod.rs +++ b/crates/librtbit/src/session_persistence/mod.rs @@ -25,7 +25,7 @@ use crate::{ torrent_state::{ManagedTorrentHandle, ManagedTorrentShared}, }; -#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)] pub struct SessionSettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub output_folder: Option, @@ -39,6 +39,10 @@ pub struct SessionSettings { /// Write new torrents' incomplete files under `.part` names. Absent means off. #[serde(default, skip_serializing_if = "Option::is_none")] pub part_files: Option, + /// Global seed limits applied to torrents that have none of their own. + /// Absent means no global limit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seed_limits: Option, } #[derive(Serialize, Deserialize, Clone)] diff --git a/crates/librtbit/src/tracing_subscriber_config_utils.rs b/crates/librtbit/src/tracing_subscriber_config_utils.rs index fd05ba4..7df598d 100644 --- a/crates/librtbit/src/tracing_subscriber_config_utils.rs +++ b/crates/librtbit/src/tracing_subscriber_config_utils.rs @@ -1,27 +1,64 @@ -use std::{io::LineWriter, path::Path}; +use std::{collections::VecDeque, io::LineWriter, path::Path, sync::Arc}; use anyhow::Context; use bytes::Bytes; use librtbit_core::spawn_utils::spawn; +use parking_lot::Mutex; use tracing::debug_span; use tracing_subscriber::{ EnvFilter, Layer, filter::FilterExt, fmt, fmt::MakeWriter, prelude::*, registry::LookupSpan, }; struct Subscriber { - tx: tokio::sync::broadcast::Sender, + broadcast: LineBroadcast, } struct Writer { + broadcast: LineBroadcast, +} + +const LINE_BROADCAST_CAPACITY: usize = 1000; + +#[derive(Clone)] +pub struct LineBroadcast { tx: tokio::sync::broadcast::Sender, + history: Arc>>, } -pub type LineBroadcast = tokio::sync::broadcast::Sender; +impl LineBroadcast { + pub fn receiver_count(&self) -> usize { + self.tx.receiver_count() + } + + pub fn subscribe(&self) -> (VecDeque, tokio::sync::broadcast::Receiver) { + let history = self.history.lock(); + let receiver = self.tx.subscribe(); + (history.clone(), receiver) + } + + fn send(&self, line: Bytes) { + let mut history = self.history.lock(); + if history.len() == LINE_BROADCAST_CAPACITY { + history.pop_front(); + } + history.push_back(line.clone()); + let _ = self.tx.send(line); + } +} impl Subscriber { pub fn new() -> (Self, LineBroadcast) { - let (tx, _) = tokio::sync::broadcast::channel(100); - (Self { tx: tx.clone() }, tx) + let (tx, _) = tokio::sync::broadcast::channel(LINE_BROADCAST_CAPACITY); + let broadcast = LineBroadcast { + tx, + history: Arc::new(Mutex::new(VecDeque::with_capacity(LINE_BROADCAST_CAPACITY))), + }; + ( + Self { + broadcast: broadcast.clone(), + }, + broadcast, + ) } } @@ -30,7 +67,7 @@ impl MakeWriter<'_> for Subscriber { fn make_writer(&self) -> Self::Writer { LineWriter::new(Writer { - tx: self.tx.clone(), + broadcast: self.broadcast.clone(), }) } } @@ -38,11 +75,7 @@ impl MakeWriter<'_> for Subscriber { impl std::io::Write for Writer { fn write(&mut self, buf: &[u8]) -> std::io::Result { let len = buf.len(); - if self.tx.receiver_count() == 0 { - return Ok(len); - } - let arc = buf.to_vec().into(); - let _ = self.tx.send(arc); + self.broadcast.send(Bytes::copy_from_slice(buf)); Ok(len) } @@ -292,6 +325,8 @@ pub fn init_logging(opts: InitLoggingOptions) -> anyhow::Result(output.trim()).is_err()); assert!(!output.contains('\x1b'), "ANSI escapes with ansi=false"); } + + #[test] + fn line_broadcast_replays_history_then_streams_new_lines() { + let (subscriber, broadcast) = super::Subscriber::new(); + let mut writer = subscriber.make_writer(); + writer.write_all(b"before\n").unwrap(); + + let (history, mut receiver) = broadcast.subscribe(); + assert_eq!(history.into_iter().collect::>(), ["before\n"]); + + writer.write_all(b"after\n").unwrap(); + assert_eq!(receiver.try_recv().unwrap(), "after\n"); + } } diff --git a/crates/librtbit/webui/src/api-types.ts b/crates/librtbit/webui/src/api-types.ts index 0fdfd57..3a3ae5e 100644 --- a/crates/librtbit/webui/src/api-types.ts +++ b/crates/librtbit/webui/src/api-types.ts @@ -158,6 +158,10 @@ export interface TrackerStatusEntry { last_error: string | null; } +export interface WebSeedEntry { + url: string; +} + export interface TrackerStatusResponse { trackers: TrackerStatusEntry[]; } @@ -197,11 +201,13 @@ export interface QueueLimitsConfig { export interface DhtStats { id?: string; outstanding_requests?: number; + routing_table_size?: number; + routing_table_size_v6?: number; + // Older mocks reported peer counts instead of the routing table. seen_peers?: number; have_peers?: number; inflight_peers?: number; - // Make it flexible - the exact shape may vary - [key: string]: any; + [key: string]: string | number | undefined; } // Interface for the Torrent Stats API response @@ -498,6 +504,7 @@ export interface RtbitAPI { getTorrentDetails: (index: number) => Promise; getTorrentStats: (index: number) => Promise; getTrackerStatus: (index: number) => Promise; + getWebSeeds: (index: number) => Promise; getTorrentHaves: (index: number) => Promise; getPeerStats: (index: number) => Promise; getTorrentStreamUrl: ( diff --git a/crates/librtbit/webui/src/components/Footer.tsx b/crates/librtbit/webui/src/components/Footer.tsx index 21ecf62..8882f33 100644 --- a/crates/librtbit/webui/src/components/Footer.tsx +++ b/crates/librtbit/webui/src/components/Footer.tsx @@ -1,12 +1,14 @@ import { useContext, useEffect, useState } from "react"; import { FaArrowDown, FaArrowUp } from "react-icons/fa"; -import { BsPeople, BsClock, BsSpeedometer2 } from "react-icons/bs"; +import { BsPeople, BsClock, BsSpeedometer2, BsHdd, BsDiagram3 } from "react-icons/bs"; import { APIContext } from "../context"; import { formatBytes } from "../helper/formatBytes"; import { formatSecondsToTime } from "../helper/formatSecondsToTime"; import { useStatsStore } from "../stores/statsStore"; +import { useUIStore } from "../stores/uiStore"; +import { DhtStats, LimitsConfig } from "../api-types"; -/** Turtle icon for alternative speed limits (qBittorrent-style). */ +/** Turtle icon for alternative speed limits (WebUI API v2 style). */ const TurtleIcon: React.FC<{ className?: string }> = ({ className }) => ( @@ -27,17 +29,60 @@ const FooterPiece: React.FC<{ ); }; +function dhtNodeCount(stats: DhtStats | null): number | null { + if (!stats) return null; + const v4 = Number(stats.routing_table_size ?? 0); + const v6 = Number(stats.routing_table_size_v6 ?? 0); + if (!Number.isFinite(v4) || !Number.isFinite(v6)) return null; + return v4 + v6; +} + +/** A null or zero cap is unlimited. Caps are bytes per second. */ +function formatRateLimit(bps: number | null | undefined): string { + if (bps == null || bps <= 0) return "∞"; + return `${formatBytes(bps)}/s`; +} + +function connectionSummary(stats: ReturnType["stats"]): { + label: string; + title: string; +} { + const families = [stats.connections.tcp, stats.connections.utp, stats.connections.socks]; + let successes = 0; + let errors = 0; + for (const family of families) { + successes += family.v4.successes + family.v6.successes; + errors += family.v4.errors + family.v6.errors; + } + if (successes > 0) { + return { + label: "connected", + title: `Session connections: ${successes} succeeded, ${errors} errors`, + }; + } + if (errors > 0) { + return { + label: "firewalled", + title: `No successful session connections (${errors} errors)`, + }; + } + return { label: "disconnected", title: "No session connections yet" }; +} + export const Footer: React.FC = () => { const API = useContext(APIContext); const stats = useStatsStore((stats) => stats.stats); + const setCurrentPage = useUIStore((state) => state.setCurrentPage); + const onStatistics = useUIStore((state) => state.currentPage) === "statistics"; - // Alternative speed limits toggle const [altSupported, setAltSupported] = useState(false); const [altEnabled, setAltEnabled] = useState(false); + const [dhtNodes, setDhtNodes] = useState(null); + const [limits, setLimits] = useState(null); useEffect(() => { let cancelled = false; - const poll = () => + const poll = () => { API.getAltSpeed() .then((status) => { if (cancelled) return; @@ -47,6 +92,21 @@ export const Footer: React.FC = () => { .catch(() => { if (!cancelled) setAltSupported(false); }); + API.getDhtStats() + .then((dht) => { + if (!cancelled) setDhtNodes(dhtNodeCount(dht)); + }) + .catch(() => { + if (!cancelled) setDhtNodes(null); + }); + API.getLimits() + .then((next) => { + if (!cancelled) setLimits(next); + }) + .catch(() => { + if (!cancelled) setLimits(null); + }); + }; poll(); const interval = setInterval(poll, 10000); return () => { @@ -65,6 +125,10 @@ export const Footer: React.FC = () => { } }; + const connection = connectionSummary(stats); + const downLimit = formatRateLimit(limits?.download_bps); + const upLimit = formatRateLimit(limits?.upload_bps); + return (
{altSupported && ( @@ -89,22 +153,40 @@ export const Footer: React.FC = () => { )}
- + + + DHT + {dhtNodes ?? "—"} + + + + {connection.label} + + {stats.download_speed.human_readable} - ({formatBytes(stats.counters.fetched_bytes)}) + [{downLimit}] ({formatBytes(stats.counters.fetched_bytes)}) - + {stats.upload_speed.human_readable} - ({formatBytes(stats.counters.uploaded_bytes)}) + [{upLimit}] ({formatBytes(stats.counters.uploaded_bytes)}) @@ -117,8 +199,26 @@ export const Footer: React.FC = () => { {formatSecondsToTime(stats.uptime_seconds)} + + + Free + n/a + + + + - 0) { + addLine(line); + } buffer = buffer.slice(newLineIdx + 1); } } diff --git a/crates/librtbit/webui/src/components/RootContent.tsx b/crates/librtbit/webui/src/components/RootContent.tsx index 63e5820..dcf9815 100644 --- a/crates/librtbit/webui/src/components/RootContent.tsx +++ b/crates/librtbit/webui/src/components/RootContent.tsx @@ -15,6 +15,9 @@ const IndexarrBrowse = lazy(() => const RSSPage = lazy(() => import("./RSSPage").then((m) => ({ default: m.RSSPage })), ); +const StatisticsPage = lazy(() => + import("./StatisticsPage").then((m) => ({ default: m.StatisticsPage })), +); export const RootContent = () => { const closeableError = useErrorStore((state) => state.closeableError); @@ -48,6 +51,23 @@ export const RootContent = () => { ); } + if (currentPage === "statistics") { + return ( +
+ setCloseableError(null)} + /> + +
+ }> + + +
+
+ ); + } + if (currentPage === "rss") { return (
diff --git a/crates/librtbit/webui/src/components/StatisticsPage.tsx b/crates/librtbit/webui/src/components/StatisticsPage.tsx new file mode 100644 index 0000000..52bbe73 --- /dev/null +++ b/crates/librtbit/webui/src/components/StatisticsPage.tsx @@ -0,0 +1,161 @@ +import { useContext, useEffect, useState } from "react"; +import { APIContext } from "../context"; +import { DhtStats, SessionStats } from "../api-types"; +import { useStatsStore } from "../stores/statsStore"; +import { formatBytes } from "../helper/formatBytes"; +import { formatSecondsToTime } from "../helper/formatSecondsToTime"; + +function dhtNodeCount(stats: DhtStats | null): number | null { + if (!stats) return null; + const v4 = Number(stats.routing_table_size ?? 0); + const v6 = Number(stats.routing_table_size_v6 ?? 0); + if (!Number.isFinite(v4) || !Number.isFinite(v6)) return null; + return v4 + v6; +} + +function ratio(uploaded: number, downloaded: number): string | null { + if (downloaded <= 0) return null; + return (uploaded / downloaded).toFixed(2); +} + +const Stat: React.FC<{ + label: string; + value: string; + hint?: string; +}> = ({ label, value, hint }) => ( +
+
{label}
+
{value}
+ {hint &&
{hint}
} +
+); + +const Unavailable: React.FC<{ label: string; reason: string }> = ({ + label, + reason, +}) => ( +
+
{label}
+
Not available
+
{reason}
+
+); + +export const StatisticsPage: React.FC = () => { + const API = useContext(APIContext); + const stats = useStatsStore((state) => state.stats); + const [dht, setDht] = useState(null); + const [dhtError, setDhtError] = useState(false); + + useEffect(() => { + let cancelled = false; + const poll = () => { + API.stats() + .then((next: SessionStats) => { + if (!cancelled) useStatsStore.getState().setStats(next); + }) + .catch(() => {}); + API.getDhtStats() + .then((next) => { + if (cancelled) return; + setDht(next); + setDhtError(false); + }) + .catch(() => { + if (!cancelled) setDhtError(true); + }); + }; + poll(); + const interval = setInterval(poll, 5000); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [API]); + + const sessionRatio = ratio( + stats.counters.uploaded_bytes, + stats.counters.fetched_bytes, + ); + const nodes = dhtNodeCount(dht); + + return ( +
+

Statistics

+

+ Session totals from GET /stats. All-time counters, a share ratio that + survives restarts, and the piece cache are not exposed by the API. +

+ +

+ Transfer (this session) +

+
+ + + + +
+ +

Peers

+
+ + + + + + + +
+ +

+ Not exposed by the API +

+
+ + + + + +
+
+ ); +}; diff --git a/crates/librtbit/webui/src/components/compact/DetailPane.tsx b/crates/librtbit/webui/src/components/compact/DetailPane.tsx index a066ba3..df980c2 100644 --- a/crates/librtbit/webui/src/components/compact/DetailPane.tsx +++ b/crates/librtbit/webui/src/components/compact/DetailPane.tsx @@ -7,18 +7,20 @@ import { OverviewTab } from "./OverviewTab"; import { FilesTab } from "./FilesTab"; import { PeersTab } from "./PeersTab"; import { TrackersTab } from "./TrackersTab"; +import { HttpSourcesTab } from "./HttpSourcesTab"; import { SpeedTab } from "./SpeedTab"; import { TabButton, TabList } from "../Tabs"; import { LogStream } from "../LogStream"; import { loadViewPref, oneOf, saveViewPref } from "../../helper/viewPrefs"; -type TabId = "overview" | "files" | "peers" | "trackers" | "speed" | "logs"; +type TabId = "overview" | "files" | "peers" | "trackers" | "http-sources" | "speed" | "logs"; const VALID_TABS: TabId[] = [ "overview", "files", "peers", "trackers", + "http-sources", "speed", "logs", ]; @@ -95,6 +97,12 @@ export const DetailPane: React.FC = () => { active={activeTab === "trackers"} onClick={() => setActiveTab("trackers")} /> + setActiveTab("http-sources")} + /> = ({ )} {activeTab === "peers" && } {activeTab === "trackers" && } + {activeTab === "http-sources" && ( + + )} {activeTab === "speed" && } {activeTab === "logs" && (
diff --git a/crates/librtbit/webui/src/components/compact/HttpSourcesTab.tsx b/crates/librtbit/webui/src/components/compact/HttpSourcesTab.tsx new file mode 100644 index 0000000..8ce77f4 --- /dev/null +++ b/crates/librtbit/webui/src/components/compact/HttpSourcesTab.tsx @@ -0,0 +1,96 @@ +import { useContext, useEffect, useState } from "react"; +import { TorrentListItem, WebSeedEntry } from "../../api-types"; +import { APIContext } from "../../context"; +import { Spinner } from "../Spinner"; + +interface HttpSourcesTabProps { + torrent: TorrentListItem | null; +} + +const headerCell = + "px-2 py-1.5 text-left text-xs font-semibold text-tertiary uppercase tracking-wide whitespace-nowrap"; + +export const HttpSourcesTab: React.FC = ({ torrent }) => { + const API = useContext(APIContext); + const [entries, setEntries] = useState(null); + const [loading, setLoading] = useState(true); + const [unavailable, setUnavailable] = useState(false); + + const torrentId = torrent?.id; + + useEffect(() => { + if (torrentId == null) return; + let cancelled = false; + setEntries(null); + setLoading(true); + setUnavailable(false); + API.getWebSeeds(torrentId) + .then((seeds) => { + if (cancelled) return; + setEntries(seeds); + setLoading(false); + }) + .catch(() => { + if (cancelled) return; + setLoading(false); + setUnavailable(true); + }); + return () => { + cancelled = true; + }; + }, [torrentId, API]); + + if (!torrent) return null; + + return ( +
+

+ HTTP sources (web seeds) this torrent can download from. +

+ + {loading && ( +
+ + Loading HTTP sources... +
+ )} + + {unavailable && ( +

+ HTTP sources are not available from the API. +

+ )} + + {!loading && entries && entries.length === 0 && ( +

No HTTP sources configured.

+ )} + + {!loading && entries && entries.length > 0 && ( +
+ + + + + + + + {entries.map((seed) => ( + + + + ))} + +
URL
+ {seed.url} +
+
+ )} +
+ ); +}; diff --git a/crates/librtbit/webui/src/components/compact/PeersTab.tsx b/crates/librtbit/webui/src/components/compact/PeersTab.tsx index 454db8a..34cab73 100644 --- a/crates/librtbit/webui/src/components/compact/PeersTab.tsx +++ b/crates/librtbit/webui/src/components/compact/PeersTab.tsx @@ -34,11 +34,27 @@ const SPEED_AVERAGE_WINDOW_MS = 5000; type PeerSortColumn = | "addr" + | "state" | "connKind" | "downloadSpeed" | "uploadSpeed" | "downloaded" - | "uploaded"; + | "uploaded" + | "chunks" + | "errors"; + +const PEER_COLUMNS: { column: PeerSortColumn; label: string }[] = [ + { column: "addr", label: "Address" }, + { column: "state", label: "State" }, + { column: "connKind", label: "Connection" }, + { column: "downloadSpeed", label: "Down Speed" }, + { column: "uploadSpeed", label: "Up Speed" }, + { column: "downloaded", label: "Downloaded" }, + { column: "uploaded", label: "Uploaded" }, + { column: "chunks", label: "Chunks" }, + { column: "errors", label: "Errors" }, +]; + type PeerSortDirection = "asc" | "desc"; const formatSpeed = (bytesPerSecond: number): string => { @@ -175,11 +191,21 @@ export const PeersTab: React.FC = ({ torrent }) => { case "addr": cmp = a.addr.localeCompare(b.addr); break; + case "state": + cmp = a.stats.state.localeCompare(b.stats.state); + break; case "connKind": cmp = (a.stats.conn_kind ?? "").localeCompare( b.stats.conn_kind ?? "", ); break; + case "chunks": + cmp = + a.stats.counters.fetched_chunks - b.stats.counters.fetched_chunks; + break; + case "errors": + cmp = a.stats.counters.errors - b.stats.counters.errors; + break; case "downloadSpeed": cmp = a.downloadSpeed - b.downloadSpeed; break; @@ -215,155 +241,47 @@ export const PeersTab: React.FC = ({ torrent }) => { return (
+

+ Client name, flags and relevance are not in the peer stats API, so + they are not shown. Connection, state and transfer counters are. +

- - - - - - + + + ))} {sortedPeers.length === 0 ? ( + @@ -390,6 +311,12 @@ export const PeersTab: React.FC = ({ torrent }) => { + + )) )} diff --git a/crates/librtbit/webui/src/context.tsx b/crates/librtbit/webui/src/context.tsx index cbbb3e6..9480c65 100644 --- a/crates/librtbit/webui/src/context.tsx +++ b/crates/librtbit/webui/src/context.tsx @@ -29,6 +29,9 @@ export const APIContext = createContext({ getTrackerStatus: () => { throw new Error("Function not implemented."); }, + getWebSeeds: () => { + throw new Error("Function not implemented."); + }, getPeerStats: () => { throw new Error("Function not implemented."); }, diff --git a/crates/librtbit/webui/src/helper/featurePageTokens.test.mjs b/crates/librtbit/webui/src/helper/featurePageTokens.test.mjs index bde8f55..3ffd93b 100644 --- a/crates/librtbit/webui/src/helper/featurePageTokens.test.mjs +++ b/crates/librtbit/webui/src/helper/featurePageTokens.test.mjs @@ -13,6 +13,7 @@ const read = (path) => const FILES = [ "../components/RSSPage.tsx", + "../components/StatisticsPage.tsx", "../components/IndexarrBrowse.tsx", "../components/IndexarrSetup.tsx", "../components/LogStream.tsx", diff --git a/crates/librtbit/webui/src/http-api.ts b/crates/librtbit/webui/src/http-api.ts index 81266c8..3f5c4e8 100644 --- a/crates/librtbit/webui/src/http-api.ts +++ b/crates/librtbit/webui/src/http-api.ts @@ -29,6 +29,7 @@ import { TorrentLimits, TorrentStats, TrackerStatusResponse, + WebSeedEntry, } from "./api-types"; import { useAuthStore } from "./stores/authStore"; @@ -333,6 +334,14 @@ export const API: RtbitAPI & { getVersion: () => Promise } = { getTrackerStatus: (index: number): Promise => { return makeRequest("GET", `/torrents/${index}/trackers`); }, + getWebSeeds: async (index: number): Promise => { + const torrent: { info_hash: string } = await makeRequest( + "GET", + `/torrents/${index}`, + ); + const params = new URLSearchParams({ hash: torrent.info_hash }); + return makeRequest("GET", `/api/v2/torrents/webseeds?${params}`); + }, getPeerStats: (index: number): Promise => { return makeRequest("GET", `/torrents/${index}/peer_stats?state=live`); }, diff --git a/crates/librtbit/webui/src/mock-api.ts b/crates/librtbit/webui/src/mock-api.ts index 7afedc5..ac71beb 100644 --- a/crates/librtbit/webui/src/mock-api.ts +++ b/crates/librtbit/webui/src/mock-api.ts @@ -21,6 +21,7 @@ import { TorrentListItem, TrackerStatusEntry, TrackerStatusResponse, + WebSeedEntry, LiveTorrentStats, TorrentFile, RssFeedConfig, @@ -677,6 +678,18 @@ export const MockAPI: RtbitAPI & { getVersion: () => Promise } = { }; }, + getWebSeeds: async (index: number): Promise => { + await new Promise((r) => setTimeout(r, 15)); + if (deletedTorrents.has(index)) { + throw { text: "Torrent not found", status: 404 }; + } + if (index % 3 !== 0) return []; + return [ + { url: `https://seeds.example/t${index}/` }, + { url: `https://mirror.example/files/${index}` }, + ]; + }, + getTrackerStatus: async (index: number): Promise => { await new Promise((r) => setTimeout(r, 20 + Math.random() * 40)); @@ -912,9 +925,8 @@ export const MockAPI: RtbitAPI & { getVersion: () => Promise } = { return { id: "mock-dht-node-id-abc123def456", outstanding_requests: 5, - seen_peers: 1234, - have_peers: 567, - inflight_peers: 12, + routing_table_size: 180, + routing_table_size_v6: 24, }; }, diff --git a/crates/librtbit/webui/src/stores/uiStore.ts b/crates/librtbit/webui/src/stores/uiStore.ts index f15830c..844ea7d 100644 --- a/crates/librtbit/webui/src/stores/uiStore.ts +++ b/crates/librtbit/webui/src/stores/uiStore.ts @@ -20,7 +20,7 @@ function getDefaultViewMode(): "full" | "compact" { return window.innerWidth >= LARGE_SCREEN_BREAKPOINT ? "compact" : "full"; } -export type CurrentPage = "torrents" | "indexarr" | "rss"; +export type CurrentPage = "torrents" | "indexarr" | "rss" | "statistics"; export interface UIStore { currentPage: CurrentPage;
- - - - - - - - Up Speed - - - - - - -
{peerSnapshot === null @@ -375,6 +293,9 @@ export const PeersTab: React.FC = ({ torrent }) => { sortedPeers.map((peer) => (
{peer.addr} + {peer.stats.state || "-"} + {peer.stats.conn_kind ?? "-"} {formatBytes(peer.stats.counters.uploaded_bytes)} + {peer.stats.counters.fetched_chunks} + + {peer.stats.counters.errors} +