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,
|
||||
status: None,
|
||||
error_message: None,
|
||||
label: None,
|
||||
};
|
||||
|
||||
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());
|
||||
has_changes = true;
|
||||
}
|
||||
if old_t.label != new_t.label {
|
||||
update.label = new_t.label.clone();
|
||||
has_changes = true;
|
||||
}
|
||||
|
||||
if has_changes {
|
||||
events.push(AppEvent::Update(update));
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
use crate::{xmlrpc, AppState};
|
||||
use axum::{
|
||||
extract::{Json, State},
|
||||
extract::{Json, Path, State},
|
||||
http::{header, StatusCode, Uri},
|
||||
response::IntoResponse,
|
||||
BoxError,
|
||||
};
|
||||
use rust_embed::RustEmbed;
|
||||
use serde::Deserialize;
|
||||
use shared::TorrentActionRequest;
|
||||
use shared::{
|
||||
SetFilePriorityRequest, SetLabelRequest, TorrentActionRequest, TorrentFile, TorrentPeer,
|
||||
TorrentTracker,
|
||||
};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[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
|
||||
#[utoipa::path(
|
||||
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)
|
||||
#[utoipa::path(
|
||||
post,
|
||||
@@ -230,7 +113,6 @@ pub async fn handle_torrent_action(
|
||||
|
||||
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||
|
||||
// Special handling for delete_with_data
|
||||
if payload.action == "delete_with_data" {
|
||||
return match delete_torrent_with_data(&client, &payload.hash).await {
|
||||
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) {
|
||||
if err.is::<tower::timeout::error::Elapsed>() {
|
||||
(StatusCode::REQUEST_TIMEOUT, "Request timed out")
|
||||
|
||||
@@ -53,7 +53,13 @@ struct Args {
|
||||
#[openapi(
|
||||
paths(
|
||||
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(
|
||||
schemas(
|
||||
@@ -61,7 +67,12 @@ struct Args {
|
||||
shared::TorrentActionRequest,
|
||||
shared::Torrent,
|
||||
shared::TorrentStatus,
|
||||
shared::Theme
|
||||
shared::Theme,
|
||||
shared::TorrentFile,
|
||||
shared::TorrentPeer,
|
||||
shared::TorrentTracker,
|
||||
shared::SetFilePriorityRequest,
|
||||
shared::SetLabelRequest
|
||||
)
|
||||
),
|
||||
tags(
|
||||
@@ -179,6 +190,24 @@ async fn main() {
|
||||
"/api/torrents/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
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(
|
||||
|
||||
@@ -23,6 +23,7 @@ const RTORRENT_FIELDS: &[&str] = &[
|
||||
"d.left_bytes=", // 9
|
||||
"d.creation_date=", // 10
|
||||
"d.hashing=", // 11
|
||||
"d.custom1=", // 12 (Label)
|
||||
];
|
||||
|
||||
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 added_date = parse_long(row.get(10));
|
||||
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 {
|
||||
(completed as f64 / size as f64) * 100.0
|
||||
@@ -88,6 +97,7 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
|
||||
status,
|
||||
error_message: message,
|
||||
added_date,
|
||||
label,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct Torrent {
|
||||
pub status: TorrentStatus,
|
||||
pub error_message: String,
|
||||
pub added_date: i64,
|
||||
pub label: Option<String>, // Added Label support
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
|
||||
@@ -48,14 +49,13 @@ pub struct TorrentUpdate {
|
||||
pub eta: Option<i64>,
|
||||
pub status: Option<TorrentStatus>,
|
||||
pub error_message: Option<String>,
|
||||
pub label: Option<String>, // Added Label update support
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||
pub struct TorrentActionRequest {
|
||||
/// The hash of the torrent
|
||||
#[schema(example = "5D4C9065...")]
|
||||
pub hash: String,
|
||||
/// The action to perform: "start", "stop", "delete", "delete_with_data"
|
||||
#[schema(example = "start")]
|
||||
pub action: String,
|
||||
}
|
||||
@@ -66,3 +66,49 @@ pub enum Theme {
|
||||
Light,
|
||||
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