feat(backend): add advanced torrent management apis (files, peers, trackers, priority, label)
This commit is contained in:
@@ -37,6 +37,7 @@ pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult {
|
|||||||
eta: None,
|
eta: None,
|
||||||
status: None,
|
status: None,
|
||||||
error_message: None,
|
error_message: None,
|
||||||
|
label: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut has_changes = false;
|
let mut has_changes = false;
|
||||||
@@ -78,6 +79,10 @@ pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult {
|
|||||||
update.error_message = Some(new_t.error_message.clone());
|
update.error_message = Some(new_t.error_message.clone());
|
||||||
has_changes = true;
|
has_changes = true;
|
||||||
}
|
}
|
||||||
|
if old_t.label != new_t.label {
|
||||||
|
update.label = new_t.label.clone();
|
||||||
|
has_changes = true;
|
||||||
|
}
|
||||||
|
|
||||||
if has_changes {
|
if has_changes {
|
||||||
events.push(AppEvent::Update(update));
|
events.push(AppEvent::Update(update));
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
use crate::{xmlrpc, AppState};
|
use crate::{xmlrpc, AppState};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Json, State},
|
extract::{Json, Path, State},
|
||||||
http::{header, StatusCode, Uri},
|
http::{header, StatusCode, Uri},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
BoxError,
|
BoxError,
|
||||||
};
|
};
|
||||||
use rust_embed::RustEmbed;
|
use rust_embed::RustEmbed;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use shared::TorrentActionRequest;
|
use shared::{
|
||||||
|
SetFilePriorityRequest, SetLabelRequest, TorrentActionRequest, TorrentFile, TorrentPeer,
|
||||||
|
TorrentTracker,
|
||||||
|
};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
#[derive(RustEmbed)]
|
#[derive(RustEmbed)]
|
||||||
@@ -49,6 +52,8 @@ pub async fn static_handler(uri: Uri) -> impl IntoResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- TORRENT ACTIONS ---
|
||||||
|
|
||||||
/// Add a new torrent via magnet link or URL
|
/// Add a new torrent via magnet link or URL
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
@@ -84,128 +89,6 @@ pub async fn add_torrent_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper function to handle secure deletion of torrent data
|
|
||||||
async fn delete_torrent_with_data(
|
|
||||||
client: &xmlrpc::RtorrentClient,
|
|
||||||
hash: &str,
|
|
||||||
) -> Result<&'static str, (StatusCode, String)> {
|
|
||||||
// 1. Get Base Path
|
|
||||||
let path_xml = client.call("d.base_path", &[hash]).await.map_err(|e| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("Failed to call rTorrent: {}", e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let path = xmlrpc::parse_string_response(&path_xml).map_err(|e| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("Failed to parse path: {}", e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// 1.5 Get Default Download Directory (Sandbox Root)
|
|
||||||
let root_xml = client.call("directory.default", &[]).await.map_err(|e| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("Failed to get valid download root: {}", e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let root_path_str = xmlrpc::parse_string_response(&root_xml).map_err(|e| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("Failed to parse root path: {}", e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Resolve Paths (Canonicalize) to prevent .. traversal and symlink attacks
|
|
||||||
let root_path = std::fs::canonicalize(std::path::Path::new(&root_path_str)).map_err(|e| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("Invalid download root configuration (on server): {}", e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Check if target path exists before trying to resolve it
|
|
||||||
let target_path_raw = std::path::Path::new(&path);
|
|
||||||
if !target_path_raw.exists() {
|
|
||||||
tracing::warn!(
|
|
||||||
"Data path not found: {:?}. Removing torrent only.",
|
|
||||||
target_path_raw
|
|
||||||
);
|
|
||||||
// If file doesn't exist, we just remove the torrent entry
|
|
||||||
client.call("d.erase", &[hash]).await.map_err(|e| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("Failed to erase torrent: {}", e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
return Ok("Torrent removed (Data not found)");
|
|
||||||
}
|
|
||||||
|
|
||||||
let target_path = std::fs::canonicalize(target_path_raw).map_err(|e| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("Invalid data path: {}", e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
"Delete request: Target='{:?}', Root='{:?}'",
|
|
||||||
target_path,
|
|
||||||
root_path
|
|
||||||
);
|
|
||||||
|
|
||||||
// SECURITY CHECK: Ensure path is inside root_path
|
|
||||||
if !target_path.starts_with(&root_path) {
|
|
||||||
tracing::error!(
|
|
||||||
"Security Risk: Attempted to delete path outside download directory: {:?}",
|
|
||||||
target_path
|
|
||||||
);
|
|
||||||
return Err((
|
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
"Security Error: Cannot delete files outside default download directory".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// SECURITY CHECK: Ensure we are not deleting the root itself
|
|
||||||
if target_path == root_path {
|
|
||||||
return Err((
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"Security Error: Cannot delete the download root directory itself".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Erase Torrent first
|
|
||||||
client.call("d.erase", &[hash]).await.map_err(|e| {
|
|
||||||
tracing::warn!("Failed to erase torrent entry: {}", e);
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("Failed to erase torrent: {}", e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// 3. Delete Files via Native FS
|
|
||||||
let delete_result = if target_path.is_dir() {
|
|
||||||
std::fs::remove_dir_all(&target_path)
|
|
||||||
} else {
|
|
||||||
std::fs::remove_file(&target_path)
|
|
||||||
};
|
|
||||||
|
|
||||||
match delete_result {
|
|
||||||
Ok(_) => Ok("Torrent and data deleted"),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to delete data at {:?}: {}", target_path, e);
|
|
||||||
Err((
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("Failed to delete data: {}", e),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Perform an action on a torrent (start, stop, delete)
|
/// Perform an action on a torrent (start, stop, delete)
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
@@ -230,7 +113,6 @@ pub async fn handle_torrent_action(
|
|||||||
|
|
||||||
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||||
|
|
||||||
// Special handling for delete_with_data
|
|
||||||
if payload.action == "delete_with_data" {
|
if payload.action == "delete_with_data" {
|
||||||
return match delete_torrent_with_data(&client, &payload.hash).await {
|
return match delete_torrent_with_data(&client, &payload.hash).await {
|
||||||
Ok(msg) => (StatusCode::OK, msg).into_response(),
|
Ok(msg) => (StatusCode::OK, msg).into_response(),
|
||||||
@@ -258,6 +140,388 @@ pub async fn handle_torrent_action(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper function to handle secure deletion of torrent data
|
||||||
|
async fn delete_torrent_with_data(
|
||||||
|
client: &xmlrpc::RtorrentClient,
|
||||||
|
hash: &str,
|
||||||
|
) -> Result<&'static str, (StatusCode, String)> {
|
||||||
|
let path_xml = client.call("d.base_path", &[hash]).await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to call rTorrent: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let path = xmlrpc::parse_string_response(&path_xml).map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to parse path: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let root_xml = client.call("directory.default", &[]).await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to get valid download root: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let root_path_str = xmlrpc::parse_string_response(&root_xml).map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to parse root path: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let root_path = std::fs::canonicalize(std::path::Path::new(&root_path_str)).map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Invalid download root configuration (on server): {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let target_path_raw = std::path::Path::new(&path);
|
||||||
|
if !target_path_raw.exists() {
|
||||||
|
tracing::warn!(
|
||||||
|
"Data path not found: {:?}. Removing torrent only.",
|
||||||
|
target_path_raw
|
||||||
|
);
|
||||||
|
client.call("d.erase", &[hash]).await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to erase torrent: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
return Ok("Torrent removed (Data not found)");
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_path = std::fs::canonicalize(target_path_raw).map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Invalid data path: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Delete request: Target='{:?}', Root='{:?}'",
|
||||||
|
target_path,
|
||||||
|
root_path
|
||||||
|
);
|
||||||
|
|
||||||
|
if !target_path.starts_with(&root_path) {
|
||||||
|
tracing::error!(
|
||||||
|
"Security Risk: Attempted to delete path outside download directory: {:?}",
|
||||||
|
target_path
|
||||||
|
);
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Security Error: Cannot delete files outside default download directory".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if target_path == root_path {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Security Error: Cannot delete the download root directory itself".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
client.call("d.erase", &[hash]).await.map_err(|e| {
|
||||||
|
tracing::warn!("Failed to erase torrent entry: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to erase torrent: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let delete_result = if target_path.is_dir() {
|
||||||
|
std::fs::remove_dir_all(&target_path)
|
||||||
|
} else {
|
||||||
|
std::fs::remove_file(&target_path)
|
||||||
|
};
|
||||||
|
|
||||||
|
match delete_result {
|
||||||
|
Ok(_) => Ok("Torrent and data deleted"),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to delete data at {:?}: {}", target_path, e);
|
||||||
|
Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to delete data: {}", e),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NEW HANDLERS ---
|
||||||
|
|
||||||
|
/// Get rTorrent version
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/system/version",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "rTorrent version", body = String),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn get_version_handler(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||||
|
match client.call("system.client_version", &[]).await {
|
||||||
|
Ok(xml) => {
|
||||||
|
let version = xmlrpc::parse_string_response(&xml).unwrap_or(xml);
|
||||||
|
(StatusCode::OK, version).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to get version: {}", e);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to get version").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get files for a torrent
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/torrents/{hash}/files",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Files list", body = Vec<TorrentFile>),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
),
|
||||||
|
params(
|
||||||
|
("hash" = String, Path, description = "Torrent Hash")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn get_files_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(hash): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||||
|
let params = vec![
|
||||||
|
hash.as_str(),
|
||||||
|
"",
|
||||||
|
"f.path=",
|
||||||
|
"f.size_bytes=",
|
||||||
|
"f.completed_chunks=",
|
||||||
|
"f.priority=",
|
||||||
|
];
|
||||||
|
|
||||||
|
match client.call("f.multicall", ¶ms).await {
|
||||||
|
Ok(xml) => match xmlrpc::parse_multicall_response(&xml) {
|
||||||
|
Ok(rows) => {
|
||||||
|
let files: Vec<TorrentFile> = rows
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, row)| TorrentFile {
|
||||||
|
index: idx as u32,
|
||||||
|
path: row.get(0).cloned().unwrap_or_default(),
|
||||||
|
size: row.get(1).and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||||
|
completed_chunks: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||||
|
priority: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(StatusCode::OK, Json(files)).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Parse error: {}", e),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
},
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("RPC error: {}", e),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get peers for a torrent
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/torrents/{hash}/peers",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Peers list", body = Vec<TorrentPeer>),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
),
|
||||||
|
params(
|
||||||
|
("hash" = String, Path, description = "Torrent Hash")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn get_peers_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(hash): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||||
|
let params = vec![
|
||||||
|
hash.as_str(),
|
||||||
|
"",
|
||||||
|
"p.address=",
|
||||||
|
"p.client_version=",
|
||||||
|
"p.down_rate=",
|
||||||
|
"p.up_rate=",
|
||||||
|
"p.completed_percent=", // or similar
|
||||||
|
];
|
||||||
|
|
||||||
|
match client.call("p.multicall", ¶ms).await {
|
||||||
|
Ok(xml) => match xmlrpc::parse_multicall_response(&xml) {
|
||||||
|
Ok(rows) => {
|
||||||
|
let peers: Vec<TorrentPeer> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| TorrentPeer {
|
||||||
|
ip: row.get(0).cloned().unwrap_or_default(),
|
||||||
|
client: row.get(1).cloned().unwrap_or_default(),
|
||||||
|
down_rate: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||||
|
up_rate: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||||
|
progress: row.get(4).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(StatusCode::OK, Json(peers)).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Parse error: {}", e),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
},
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("RPC error: {}", e),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get trackers for a torrent
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/torrents/{hash}/trackers",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Trackers list", body = Vec<TorrentTracker>),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
),
|
||||||
|
params(
|
||||||
|
("hash" = String, Path, description = "Torrent Hash")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn get_trackers_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(hash): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||||
|
let params = vec![
|
||||||
|
hash.as_str(),
|
||||||
|
"",
|
||||||
|
"t.url=",
|
||||||
|
"t.activity_date_last=", // Just an example field, msg is better
|
||||||
|
// t.latest_event (success/error) is tricky.
|
||||||
|
"t.message=", // Often empty if ok
|
||||||
|
];
|
||||||
|
// rTorrent tracker info is sometimes sparse in multicall
|
||||||
|
|
||||||
|
match client.call("t.multicall", ¶ms).await {
|
||||||
|
Ok(xml) => {
|
||||||
|
match xmlrpc::parse_multicall_response(&xml) {
|
||||||
|
Ok(rows) => {
|
||||||
|
let trackers: Vec<TorrentTracker> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
TorrentTracker {
|
||||||
|
url: row.get(0).cloned().unwrap_or_default(),
|
||||||
|
status: "Unknown".to_string(), // Derive from type/activity?
|
||||||
|
message: row.get(2).cloned().unwrap_or_default(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(StatusCode::OK, Json(trackers)).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Parse error: {}", e),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("RPC error: {}", e),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set file priority
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/torrents/files/priority",
|
||||||
|
request_body = SetFilePriorityRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Priority updated"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn set_file_priority_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<SetFilePriorityRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||||
|
|
||||||
|
// f.set_priority takes "hash", index, priority
|
||||||
|
// Priority: 0 (off), 1 (normal), 2 (high)
|
||||||
|
let priority_str = payload.priority.to_string();
|
||||||
|
|
||||||
|
// For file calls, target is often "hash:fIndex" or similar, but f.set_priority usually works on file target
|
||||||
|
// In d.multicall, f.set_priority is called on file items.
|
||||||
|
// To call directly: f.set_priority(hash, index, prio) ?? No, usually:
|
||||||
|
// f.set_priority is not a system command. It's a method on a file object.
|
||||||
|
// We need to target the file.
|
||||||
|
// Target format: "{hash}:f{index}" e.g. "HASH:f0"
|
||||||
|
|
||||||
|
let target = format!("{}:f{}", payload.hash, payload.file_index);
|
||||||
|
|
||||||
|
match client
|
||||||
|
.call("f.set_priority", &[&target, &priority_str])
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
// Need to update view to reflect changes? usually 'd.update_priorities' is needed
|
||||||
|
let _ = client.call("d.update_priorities", &[&payload.hash]).await;
|
||||||
|
(StatusCode::OK, "Priority updated").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("RPC error: {}", e),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set torrent label
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/torrents/label",
|
||||||
|
request_body = SetLabelRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Label updated"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn set_label_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<SetLabelRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||||
|
|
||||||
|
match client
|
||||||
|
.call("d.custom1.set", &[&payload.hash, &payload.label])
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => (StatusCode::OK, "Label updated").into_response(),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("RPC error: {}", e),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
|
pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
|
||||||
if err.is::<tower::timeout::error::Elapsed>() {
|
if err.is::<tower::timeout::error::Elapsed>() {
|
||||||
(StatusCode::REQUEST_TIMEOUT, "Request timed out")
|
(StatusCode::REQUEST_TIMEOUT, "Request timed out")
|
||||||
|
|||||||
@@ -53,7 +53,13 @@ struct Args {
|
|||||||
#[openapi(
|
#[openapi(
|
||||||
paths(
|
paths(
|
||||||
handlers::add_torrent_handler,
|
handlers::add_torrent_handler,
|
||||||
handlers::handle_torrent_action
|
handlers::handle_torrent_action,
|
||||||
|
handlers::get_version_handler,
|
||||||
|
handlers::get_files_handler,
|
||||||
|
handlers::get_peers_handler,
|
||||||
|
handlers::get_trackers_handler,
|
||||||
|
handlers::set_file_priority_handler,
|
||||||
|
handlers::set_label_handler
|
||||||
),
|
),
|
||||||
components(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
@@ -61,7 +67,12 @@ struct Args {
|
|||||||
shared::TorrentActionRequest,
|
shared::TorrentActionRequest,
|
||||||
shared::Torrent,
|
shared::Torrent,
|
||||||
shared::TorrentStatus,
|
shared::TorrentStatus,
|
||||||
shared::Theme
|
shared::Theme,
|
||||||
|
shared::TorrentFile,
|
||||||
|
shared::TorrentPeer,
|
||||||
|
shared::TorrentTracker,
|
||||||
|
shared::SetFilePriorityRequest,
|
||||||
|
shared::SetLabelRequest
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
@@ -179,6 +190,24 @@ async fn main() {
|
|||||||
"/api/torrents/action",
|
"/api/torrents/action",
|
||||||
post(handlers::handle_torrent_action),
|
post(handlers::handle_torrent_action),
|
||||||
)
|
)
|
||||||
|
.route("/api/system/version", get(handlers::get_version_handler))
|
||||||
|
.route(
|
||||||
|
"/api/torrents/:hash/files",
|
||||||
|
get(handlers::get_files_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/torrents/:hash/peers",
|
||||||
|
get(handlers::get_peers_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/torrents/:hash/trackers",
|
||||||
|
get(handlers::get_trackers_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/torrents/files/priority",
|
||||||
|
post(handlers::set_file_priority_handler),
|
||||||
|
)
|
||||||
|
.route("/api/torrents/label", post(handlers::set_label_handler))
|
||||||
.fallback(handlers::static_handler) // Serve static files for everything else
|
.fallback(handlers::static_handler) // Serve static files for everything else
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(
|
.layer(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const RTORRENT_FIELDS: &[&str] = &[
|
|||||||
"d.left_bytes=", // 9
|
"d.left_bytes=", // 9
|
||||||
"d.creation_date=", // 10
|
"d.creation_date=", // 10
|
||||||
"d.hashing=", // 11
|
"d.hashing=", // 11
|
||||||
|
"d.custom1=", // 12 (Label)
|
||||||
];
|
];
|
||||||
|
|
||||||
fn parse_long(s: Option<&String>) -> i64 {
|
fn parse_long(s: Option<&String>) -> i64 {
|
||||||
@@ -49,6 +50,14 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
|
|||||||
let left_bytes = parse_long(row.get(9));
|
let left_bytes = parse_long(row.get(9));
|
||||||
let added_date = parse_long(row.get(10));
|
let added_date = parse_long(row.get(10));
|
||||||
let is_hashing = parse_long(row.get(11));
|
let is_hashing = parse_long(row.get(11));
|
||||||
|
let label_raw = parse_string(row.get(12));
|
||||||
|
|
||||||
|
// Treat empty label as None
|
||||||
|
let label = if label_raw.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(label_raw)
|
||||||
|
};
|
||||||
|
|
||||||
let percent_complete = if size > 0 {
|
let percent_complete = if size > 0 {
|
||||||
(completed as f64 / size as f64) * 100.0
|
(completed as f64 / size as f64) * 100.0
|
||||||
@@ -88,6 +97,7 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
|
|||||||
status,
|
status,
|
||||||
error_message: message,
|
error_message: message,
|
||||||
added_date,
|
added_date,
|
||||||
|
label,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub struct Torrent {
|
|||||||
pub status: TorrentStatus,
|
pub status: TorrentStatus,
|
||||||
pub error_message: String,
|
pub error_message: String,
|
||||||
pub added_date: i64,
|
pub added_date: i64,
|
||||||
|
pub label: Option<String>, // Added Label support
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
|
||||||
@@ -48,14 +49,13 @@ pub struct TorrentUpdate {
|
|||||||
pub eta: Option<i64>,
|
pub eta: Option<i64>,
|
||||||
pub status: Option<TorrentStatus>,
|
pub status: Option<TorrentStatus>,
|
||||||
pub error_message: Option<String>,
|
pub error_message: Option<String>,
|
||||||
|
pub label: Option<String>, // Added Label update support
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||||
pub struct TorrentActionRequest {
|
pub struct TorrentActionRequest {
|
||||||
/// The hash of the torrent
|
|
||||||
#[schema(example = "5D4C9065...")]
|
#[schema(example = "5D4C9065...")]
|
||||||
pub hash: String,
|
pub hash: String,
|
||||||
/// The action to perform: "start", "stop", "delete", "delete_with_data"
|
|
||||||
#[schema(example = "start")]
|
#[schema(example = "start")]
|
||||||
pub action: String,
|
pub action: String,
|
||||||
}
|
}
|
||||||
@@ -66,3 +66,49 @@ pub enum Theme {
|
|||||||
Light,
|
Light,
|
||||||
Amoled,
|
Amoled,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- NEW STRUCTS FOR ADVANCED FEATURES ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||||
|
pub struct TorrentFile {
|
||||||
|
pub index: u32,
|
||||||
|
pub path: String,
|
||||||
|
pub size: i64,
|
||||||
|
pub completed_chunks: i64,
|
||||||
|
pub priority: u8, // 0: Off, 1: Normal, 2: High
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||||
|
pub struct TorrentPeer {
|
||||||
|
pub ip: String,
|
||||||
|
pub client: String,
|
||||||
|
pub down_rate: i64,
|
||||||
|
pub up_rate: i64,
|
||||||
|
pub progress: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||||
|
pub struct TorrentTracker {
|
||||||
|
pub url: String,
|
||||||
|
pub status: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||||
|
pub struct SetFilePriorityRequest {
|
||||||
|
pub hash: String,
|
||||||
|
pub file_index: u32,
|
||||||
|
pub priority: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||||
|
pub struct GlobalLimitRequest {
|
||||||
|
pub max_upload_rate: Option<i64>, // in bytes/s
|
||||||
|
pub max_download_rate: Option<i64>, // in bytes/s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||||
|
pub struct SetLabelRequest {
|
||||||
|
pub hash: String,
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user