mod diff; mod scgi; mod sse; mod xmlrpc; use clap::Parser; use rust_embed::RustEmbed; use axum::{ extract::State, http::{header, StatusCode, Uri}, response::IntoResponse, routing::{get, post}, Router, Json, }; use tower_http::{ cors::CorsLayer, trace::TraceLayer, compression::{CompressionLayer, CompressionLevel}, }; use axum::{ error_handling::HandleErrorLayer, BoxError, }; use tower::ServiceBuilder; use serde::Deserialize; use std::net::SocketAddr; use shared::{Torrent, TorrentActionRequest, AppEvent}; // shared crates imports use tokio::sync::{watch, broadcast}; use std::sync::Arc; #[derive(Clone)] pub struct AppState { pub tx: Arc>>, pub event_bus: broadcast::Sender, pub scgi_socket_path: String, } #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] struct Args { /// Path to rTorrent SCGI socket #[arg(short, long, default_value = "/tmp/rtorrent.sock")] socket: String, /// Port to listen on #[arg(short, long, default_value_t = 3000)] port: u16, } #[derive(RustEmbed)] #[folder = "../frontend/dist"] struct Asset; async fn static_handler(uri: Uri) -> impl IntoResponse { let mut path = uri.path().trim_start_matches('/').to_string(); if path.is_empty() { path = "index.html".to_string(); } match Asset::get(&path) { Some(content) => { let mime = mime_guess::from_path(&path).first_or_octet_stream(); ([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response() } None => { if path.contains('.') { return StatusCode::NOT_FOUND.into_response(); } // Fallback to index.html for SPA routing match Asset::get("index.html") { Some(content) => { let mime = mime_guess::from_path("index.html").first_or_octet_stream(); ([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response() } None => StatusCode::NOT_FOUND.into_response(), } } } } use std::time::Duration; #[derive(Deserialize)] struct AddTorrentRequest { uri: String, } async fn add_torrent_handler( State(state): State, Json(payload): Json, ) -> StatusCode { tracing::info!("Received add_torrent request. URI length: {}", payload.uri.len()); let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path); match client.call("load.start", &["", &payload.uri]).await { Ok(response) => { tracing::debug!("rTorrent response to load.start: {}", response); if response.contains("faultCode") { tracing::error!("rTorrent returned fault: {}", response); return StatusCode::INTERNAL_SERVER_ERROR; } StatusCode::OK }, Err(e) => { tracing::error!("Failed to add torrent: {}", e); StatusCode::INTERNAL_SERVER_ERROR } } } #[tokio::main] async fn main() { // initialize tracing with env filter (default to info) tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env() .add_directive(tracing::Level::INFO.into())) .init(); // Parse CLI Args let args = Args::parse(); tracing::info!("Starting VibeTorrent Backend..."); tracing::info!("Socket: {}", args.socket); tracing::info!("Port: {}", args.port); // Channel for latest state (for new clients) let (tx, _rx) = watch::channel(vec![]); let tx = Arc::new(tx); // Channel for Events (Diffs) let (event_bus, _) = broadcast::channel::(1024); let app_state = AppState { tx: tx.clone(), event_bus: event_bus.clone(), scgi_socket_path: args.socket.clone(), }; // Spawn background task to poll rTorrent let tx_clone = tx.clone(); let event_bus_tx = event_bus.clone(); let socket_path = args.socket.clone(); // Clone for background task tokio::spawn(async move { let client = xmlrpc::RtorrentClient::new(&socket_path); let mut previous_torrents: Vec = Vec::new(); loop { match sse::fetch_torrents(&client).await { Ok(new_torrents) => { // 1. Update latest state (always) let _ = tx_clone.send(new_torrents.clone()); // 2. Calculate Diff and Broadcasting let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); let mut structural_change = false; if previous_torrents.len() != new_torrents.len() { structural_change = true; } else { // Check for order/hash change for (i, t) in new_torrents.iter().enumerate() { if previous_torrents[i].hash != t.hash { structural_change = true; break; } } } if structural_change { // Structural change -> Send FullList let _ = event_bus_tx.send(AppEvent::FullList(new_torrents.clone(), now)); } else { // Same structure -> Calculate partial updates let updates = diff::diff_torrents(&previous_torrents, &new_torrents); if !updates.is_empty() { for update in updates { let _ = event_bus_tx.send(update); } } } previous_torrents = new_torrents; } Err(e) => { tracing::error!("Error fetching torrents in background: {}", e); } } tokio::time::sleep(Duration::from_secs(1)).await; } }); let app = Router::new() .route("/api/events", get(sse::sse_handler)) .route("/api/torrents/add", post(add_torrent_handler)) .route("/api/torrents/action", post(handle_torrent_action)) .fallback(static_handler) // Serve static files for everything else .layer(TraceLayer::new_for_http()) .layer(CompressionLayer::new() .br(false) .gzip(true) .quality(CompressionLevel::Fastest)) .layer(ServiceBuilder::new() .layer(HandleErrorLayer::new(handle_timeout_error)) .layer(tower::timeout::TimeoutLayer::new(Duration::from_secs(30))) ) .layer(CorsLayer::permissive()) .with_state(app_state); let addr = SocketAddr::from(([0, 0, 0, 0], args.port)); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); tracing::info!("Backend listening on {}", addr); axum::serve(listener, app).await.unwrap(); } async fn handle_torrent_action( State(state): State, Json(payload): Json, ) -> impl IntoResponse { tracing::info!("Received action: {} for hash: {}", payload.action, payload.hash); // Special handling for delete_with_data if payload.action == "delete_with_data" { let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path); // 1. Get Base Path let path_xml = match client.call("d.base_path", &[&payload.hash]).await { Ok(xml) => xml, Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to call rTorrent: {}", e)).into_response(), }; let path = match xmlrpc::parse_string_response(&path_xml) { Ok(p) => p, Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse path: {}", e)).into_response(), }; tracing::info!("Attempting to delete torrent and data at path: {}", path); if path.trim().is_empty() || path == "/" { return (StatusCode::BAD_REQUEST, "Safety check failed: Path is empty or root").into_response(); } // 2. Erase Torrent first (so rTorrent releases locks?) if let Err(e) = client.call("d.erase", &[&payload.hash]).await { tracing::warn!("Failed to erase torrent entry: {}", e); // Proceed anyway to delete files? Maybe not. } // 3. Delete Files via rTorrent (execute.throw.bg) // Command: rm -rf match client.call("execute.throw.bg", &["", "rm", "-rf", &path]).await { Ok(_) => return (StatusCode::OK, "Torrent and data deleted").into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to delete data: {}", e)).into_response(), } } let method = match payload.action.as_str() { "start" => "d.start", "stop" => "d.stop", "delete" => "d.erase", _ => return (StatusCode::BAD_REQUEST, "Invalid action").into_response(), }; match scgi::system_call(&state.scgi_socket_path, method, vec![&payload.hash]).await { Ok(_) => (StatusCode::OK, "Action executed").into_response(), Err(e) => { tracing::error!("SCGI error: {:?}", e); (StatusCode::INTERNAL_SERVER_ERROR, "Failed to execute action").into_response() } } } async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) { if err.is::() { (StatusCode::REQUEST_TIMEOUT, "Request timed out") } else { (StatusCode::INTERNAL_SERVER_ERROR, "Unhandled internal error") } }