Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions crates/librtbit/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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(
Expand Down
10 changes: 8 additions & 2 deletions crates/librtbit/src/http_api/handlers/logging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,19 @@ 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<ApiState>) -> Result<impl IntoResponse> {
let s = state.api.api_log_lines_stream()?.map_err(|e| {
debug!(error=%e, "stream_logs");
e
});
Ok(axum::body::Body::from_stream(s))
Ok((
[
(axum::http::header::CONTENT_TYPE, "application/x-ndjson"),
(axum::http::header::CACHE_CONTROL, "no-store"),
],
axum::body::Body::from_stream(s),
))
}
30 changes: 30 additions & 0 deletions crates/librtbit/src/http_api/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ async fn h_api_root(parts: Parts) -> 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",
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand Down Expand Up @@ -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))
Expand Down
199 changes: 198 additions & 1 deletion crates/librtbit/src/http_api/handlers/torrents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<impl IntoResponse> {
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<ApiState>,
Path(idx): Path<TorrentIdOrHash>,
) -> Result<impl IntoResponse> {
h_torrent_queue_move(state, idx, crate::session::scheduler::QueueMove::Top).await
}

pub async fn h_torrent_queue_up(
State(state): State<ApiState>,
Path(idx): Path<TorrentIdOrHash>,
) -> Result<impl IntoResponse> {
h_torrent_queue_move(state, idx, crate::session::scheduler::QueueMove::Up).await
}

pub async fn h_torrent_queue_down(
State(state): State<ApiState>,
Path(idx): Path<TorrentIdOrHash>,
) -> Result<impl IntoResponse> {
h_torrent_queue_move(state, idx, crate::session::scheduler::QueueMove::Down).await
}

pub async fn h_torrent_queue_bottom(
State(state): State<ApiState>,
Path(idx): Path<TorrentIdOrHash>,
) -> Result<impl IntoResponse> {
h_torrent_queue_move(state, idx, crate::session::scheduler::QueueMove::Bottom).await
}

pub async fn h_torrent_action_start(
State(state): State<ApiState>,
Path(idx): Path<TorrentIdOrHash>,
Expand Down Expand Up @@ -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<f64>,
pub time_limit_secs: Option<u64>,
}

impl From<crate::session::ShareLimits> 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<SeedLimitsBody> 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<ApiState>) -> impl IntoResponse {
axum::Json(SeedLimitsBody::from(state.api.session().seed_limits()))
}

pub async fn h_set_seed_limits(
State(state): State<ApiState>,
axum::Json(body): axum::Json<SeedLimitsBody>,
) -> Result<impl IntoResponse> {
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<ApiState>,
Path(idx): Path<TorrentIdOrHash>,
) -> Result<impl IntoResponse> {
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<ApiState>,
Path(idx): Path<TorrentIdOrHash>,
axum::Json(body): axum::Json<SeedLimitsBody>,
) -> Result<impl IntoResponse> {
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<ApiState>,
Path(idx): Path<TorrentIdOrHash>,
Expand All @@ -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,
Expand All @@ -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<HttpApi>, ManagedTorrentHandle, TempDir)> {
let payload = TempDir::with_prefix("rtbit_http_action_payload")?;
std::fs::write(payload.path().join("payload.bin"), vec![0x5e; 32 * 1024])?;
Expand Down Expand Up @@ -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(())
}
}
27 changes: 24 additions & 3 deletions crates/librtbit/src/http_api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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\"")],
Expand Down Expand Up @@ -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"));
Expand Down
Loading
Loading