Skip to content
Merged
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
28 changes: 27 additions & 1 deletion crates/librtbit/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ impl Api {
trackers: Some(
mgr.shared()
.trackers
.read()
.iter()
.map(|t| t.to_string())
.collect(),
Expand Down Expand Up @@ -352,6 +353,31 @@ impl Api {
Ok(Default::default())
}

pub async fn api_torrent_action_recheck(
&self,
idx: TorrentIdOrHash,
) -> Result<EmptyJsonResponse> {
let handle = self.mgr_handle(idx)?;
self.session
.force_recheck(&handle)
.await
.with_status(StatusCode::BAD_REQUEST)?;
Ok(Default::default())
}

pub async fn api_torrent_action_add_trackers(
&self,
idx: TorrentIdOrHash,
trackers: Vec<String>,
) -> Result<EmptyJsonResponse> {
let handle = self.mgr_handle(idx)?;
self.session
.add_trackers(&handle, trackers)
.await
.with_status(StatusCode::BAD_REQUEST)?;
Ok(Default::default())
}

pub async fn api_torrent_action_forget(
&self,
idx: TorrentIdOrHash,
Expand Down Expand Up @@ -589,7 +615,7 @@ impl Api {
let mgr = self.mgr_handle(idx)?;
let registry = &mgr.shared().tracker_status;
// Make sure every configured tracker shows up, even before any announce.
for t in mgr.shared().trackers.iter() {
for t in mgr.shared().trackers.read().iter() {
registry.ensure(t.as_str());
}
Ok(TrackerStatusResponse {
Expand Down
10 changes: 10 additions & 0 deletions crates/librtbit/src/http_api/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ async fn h_api_root(parts: Parts) -> impl IntoResponse {
"POST /torrents/resolve_magnet": "Resolve a magnet to torrent file bytes",
"POST /torrents/{id_or_infohash}/pause": "Pause torrent",
"POST /torrents/{id_or_infohash}/start": "Resume torrent",
"POST /torrents/{id_or_infohash}/recheck": "Force a full integrity check",
"POST /torrents/{id_or_infohash}/trackers": "Attach tracker announce URLs",
"POST /torrents/{id_or_infohash}/forget": "Forget about the torrent, keep the files",
"POST /torrents/{id_or_infohash}/delete": "Forget about the torrent, remove the files",
"POST /torrents/{id_or_infohash}/add_peers": "Add peers (newline-delimited)",
Expand Down Expand Up @@ -137,6 +139,14 @@ pub fn make_api_router(state: ApiState) -> Router {
"/torrents/{id}/start",
post(torrents::h_torrent_action_start),
)
.route(
"/torrents/{id}/recheck",
post(torrents::h_torrent_action_recheck),
)
.route(
"/torrents/{id}/trackers",
post(torrents::h_torrent_action_add_trackers),
)
.route(
"/torrents/{id}/forget",
post(torrents::h_torrent_action_forget),
Expand Down
70 changes: 66 additions & 4 deletions crates/librtbit/src/http_api/handlers/qbit_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -589,12 +589,13 @@ async fn h_torrents_info(
let tracker = handle
.shared()
.trackers
.read()
.iter()
.next()
.map(|u| u.to_string())
.unwrap_or_default();

let trackers_count = handle.shared().trackers.len();
let trackers_count = handle.shared().trackers.read().len();

Some(QbitTorrentInfo {
added_on,
Expand Down Expand Up @@ -1062,6 +1063,17 @@ async fn h_torrents_resume(State(state): State<Arc<QbitState>>, body: Bytes) ->
"Ok."
}

async fn h_torrents_recheck(State(state): State<Arc<QbitState>>, body: Bytes) -> &'static str {
let form: HashesForm = serde_urlencoded::from_bytes(&body).unwrap_or_default();
let api = &state.api_state.api;
for idx in resolve_hashes(api, &form.hashes) {
if let Err(error) = api.api_torrent_action_recheck(idx).await {
warn!(%error, "qbit compat: error rechecking torrent");
}
}
"Ok."
}

#[derive(Deserialize, Default)]
struct DeleteForm {
#[serde(default)]
Expand Down Expand Up @@ -1243,6 +1255,7 @@ pub(crate) fn make_qbit_router(api_state: ApiState) -> Router {
.route("/add", post(h_torrents_add))
.route("/pause", post(h_torrents_pause))
.route("/resume", post(h_torrents_resume))
.route("/recheck", post(h_torrents_recheck))
.route("/delete", post(h_torrents_delete))
.route("/setCategory", post(h_torrents_set_category))
.route("/categories", get(h_categories))
Expand Down Expand Up @@ -1304,14 +1317,17 @@ mod tests {
use http::StatusCode;

use crate::{
Api, ListenerMode, Session, SessionOptions,
AddTorrent, AddTorrentOptions, Api, CreateTorrentOptions, ListenerMode, Session,
SessionOptions, create_torrent,
http_api::{HttpApi, HttpApiOptions},
listen::ListenerOptions,
spawn_utils::BlockingSpawner,
torrent_state::TorrentStatsState,
};

use super::{
QbitSessions, QbitState, h_app_preferences, h_app_set_preferences, matches_category,
qbit_file_name,
QbitSessions, QbitState, h_app_preferences, h_app_set_preferences, h_torrents_recheck,
matches_category, qbit_file_name,
};

async fn qbit_state() -> (Arc<QbitState>, Arc<Session>, tempfile::TempDir) {
Expand Down Expand Up @@ -1409,6 +1425,52 @@ mod tests {
assert_eq!(session.announce_port(), Some(4241));
}

#[tokio::test]
async fn recheck_endpoint_accepts_qbittorrent_hash_form() {
let (state, session, output) = qbit_state().await;
std::fs::write(output.path().join("payload.bin"), vec![0x71; 32 * 1024]).unwrap();
let torrent = create_torrent(
output.path(),
CreateTorrentOptions {
piece_length: Some(16_384),
..Default::default()
},
&BlockingSpawner::new(1),
)
.await
.unwrap()
.as_bytes()
.unwrap()
.to_vec();
let handle = session
.add_torrent(
AddTorrent::from_bytes(torrent),
Some(AddTorrentOptions {
paused: true,
overwrite: true,
output_folder: Some(output.path().to_string_lossy().into_owned()),
..Default::default()
}),
)
.await
.unwrap()
.into_handle()
.unwrap();
handle.wait_until_initialized().await.unwrap();

let body = Bytes::from(format!("hashes={}", handle.info_hash().as_string()));
assert_eq!(
h_torrents_recheck(axum::extract::State(state), body).await,
"Ok."
);
assert!(matches!(
handle.stats().state,
TorrentStatsState::Initializing
));
handle.wait_until_initialized().await.unwrap();
assert!(matches!(handle.stats().state, TorrentStatsState::Paused));
}

#[test]
fn category_filter_supports_qbittorrent_special_values() {
assert!(matches_category("all", "Linux ISOs"));
Expand Down
142 changes: 142 additions & 0 deletions crates/librtbit/src/http_api/handlers/torrents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,52 @@ pub async fn h_torrent_action_start(
.map(axum::Json)
}

#[cfg_attr(feature = "swagger", utoipa::path(
post,
path = "/torrents/{id}/recheck",
params(("id" = String, Path, description = "Torrent ID or info hash")),
responses(
(status = 200, description = "Full integrity check started", body = EmptyJsonResponse)
)
))]
pub async fn h_torrent_action_recheck(
State(state): State<ApiState>,
Path(idx): Path<TorrentIdOrHash>,
) -> Result<impl IntoResponse> {
state
.api
.api_torrent_action_recheck(idx)
.await
.map(axum::Json)
}

#[derive(Deserialize)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
pub struct AddTrackersRequest {
trackers: Vec<String>,
}

#[cfg_attr(feature = "swagger", utoipa::path(
post,
path = "/torrents/{id}/trackers",
params(("id" = String, Path, description = "Torrent ID or info hash")),
request_body(content = AddTrackersRequest, description = "Tracker announce URLs to merge"),
responses(
(status = 200, description = "Trackers attached", body = EmptyJsonResponse)
)
))]
pub async fn h_torrent_action_add_trackers(
State(state): State<ApiState>,
Path(idx): Path<TorrentIdOrHash>,
axum::Json(req): axum::Json<AddTrackersRequest>,
) -> Result<impl IntoResponse> {
state
.api
.api_torrent_action_add_trackers(idx, req.trackers)
.await
.map(axum::Json)
}

#[cfg_attr(feature = "swagger", utoipa::path(
post,
path = "/torrents/{id}/forget",
Expand Down Expand Up @@ -636,3 +682,99 @@ pub async fn h_set_torrent_category(
.await
.map(axum::Json)
}

#[cfg(test)]
mod tests {
use std::sync::Arc;

use anyhow::Context;
use axum::extract::{Path, State};
use tempfile::TempDir;

use super::{AddTrackersRequest, h_torrent_action_add_trackers, h_torrent_action_recheck};
use crate::{
AddTorrent, AddTorrentOptions, Api, CreateTorrentOptions, Session, SessionOptions,
create_torrent,
http_api::{HttpApi, HttpApiOptions},
spawn_utils::BlockingSpawner,
torrent_state::{ManagedTorrentHandle, TorrentStatsState},
};

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])?;
let torrent = create_torrent(
payload.path(),
CreateTorrentOptions {
piece_length: Some(16_384),
..Default::default()
},
&BlockingSpawner::new(1),
)
.await?
.as_bytes()?
.to_vec();
let session = Session::new_with_opts(
payload.path().to_owned(),
SessionOptions {
disable_dht: true,
disable_trackers: true,
disable_local_service_discovery: true,
..Default::default()
},
)
.await?;
let handle = 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 torrent handle")?;
handle.wait_until_initialized().await?;
let api = Api::new(
session,
None,
#[cfg(feature = "tracing-subscriber-utils")]
None,
);
Ok((
Arc::new(HttpApi::new(api, Some(HttpApiOptions::default()))),
handle,
payload,
))
}

#[tokio::test]
async fn add_trackers_handler_merges_urls() -> anyhow::Result<()> {
let (state, handle, _payload) = torrent_api().await?;
h_torrent_action_add_trackers(
State(state),
Path(handle.id().into()),
axum::Json(AddTrackersRequest {
trackers: vec!["https://tracker.example.test/announce".to_string()],
}),
)
.await
.map_err(|error| anyhow::anyhow!("{error:?}"))?;
assert_eq!(handle.shared().trackers.read().len(), 1);
Ok(())
}

#[tokio::test]
async fn recheck_handler_starts_full_check() -> anyhow::Result<()> {
let (state, handle, _payload) = torrent_api().await?;
h_torrent_action_recheck(State(state), Path(handle.id().into()))
.await
.map_err(|error| anyhow::anyhow!("{error:?}"))?;
handle.wait_until_initialized().await?;
assert!(matches!(handle.stats().state, TorrentStatsState::Paused));
Ok(())
}
}
3 changes: 3 additions & 0 deletions crates/librtbit/src/http_api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ mod webui;
handlers::torrents::h_peer_stats,
handlers::torrents::h_torrent_action_pause,
handlers::torrents::h_torrent_action_start,
handlers::torrents::h_torrent_action_recheck,
handlers::torrents::h_torrent_action_add_trackers,
handlers::torrents::h_torrent_action_forget,
handlers::torrents::h_torrent_action_delete,
handlers::torrents::h_torrent_action_update_only_files,
Expand Down Expand Up @@ -66,6 +68,7 @@ mod webui;
crate::api::ApiAddTorrentResponse,
crate::limits::LimitsConfig,
handlers::torrents::UpdateOnlyFilesRequest,
handlers::torrents::AddTrackersRequest,
))
)]
struct ApiDoc;
Expand Down
Loading
Loading