use crate::xmlrpc::{ parse_i64_response, parse_multicall_response, RpcParam, RtorrentClient, XmlRpcError, }; use crate::AppState; use axum::extract::State; use axum::response::sse::{Event, Sse}; use futures::stream::{self, Stream}; use shared::{AppEvent, GlobalStats, Torrent, TorrentStatus}; use std::convert::Infallible; use tokio_stream::StreamExt; // Field definitions to keep query and parser in sync mod fields { pub const IDX_HASH: usize = 0; pub const CMD_HASH: &str = "d.hash="; pub const IDX_NAME: usize = 1; pub const CMD_NAME: &str = "d.name="; pub const IDX_SIZE: usize = 2; pub const CMD_SIZE: &str = "d.size_bytes="; pub const IDX_COMPLETED: usize = 3; pub const CMD_COMPLETED: &str = "d.bytes_done="; pub const IDX_DOWN_RATE: usize = 4; pub const CMD_DOWN_RATE: &str = "d.down.rate="; pub const IDX_UP_RATE: usize = 5; pub const CMD_UP_RATE: &str = "d.up.rate="; pub const IDX_STATE: usize = 6; pub const CMD_STATE: &str = "d.state="; pub const IDX_COMPLETE: usize = 7; pub const CMD_COMPLETE: &str = "d.complete="; pub const IDX_MESSAGE: usize = 8; pub const CMD_MESSAGE: &str = "d.message="; pub const IDX_LEFT_BYTES: usize = 9; pub const CMD_LEFT_BYTES: &str = "d.left_bytes="; pub const IDX_CREATION_DATE: usize = 10; pub const CMD_CREATION_DATE: &str = "d.creation_date="; pub const IDX_HASHING: usize = 11; pub const CMD_HASHING: &str = "d.hashing="; pub const IDX_LABEL: usize = 12; pub const CMD_LABEL: &str = "d.custom1="; } use fields::*; // Constants for rTorrent fields to ensure query and parser stay in sync const RTORRENT_FIELDS: &[&str] = &[ "", // Ignored by multicall pattern "main", // View CMD_HASH, CMD_NAME, CMD_SIZE, CMD_COMPLETED, CMD_DOWN_RATE, CMD_UP_RATE, CMD_STATE, CMD_COMPLETE, CMD_MESSAGE, CMD_LEFT_BYTES, CMD_CREATION_DATE, CMD_HASHING, CMD_LABEL, ]; fn parse_long(s: Option<&String>) -> i64 { s.map(|v| v.parse().unwrap_or(0)).unwrap_or(0) } fn parse_string(s: Option<&String>) -> String { s.cloned().unwrap_or_default() } /// Converts a raw row of strings from rTorrent XML-RPC into a generic Torrent struct fn from_rtorrent_row(row: Vec) -> Torrent { let hash = parse_string(row.get(IDX_HASH)); let name = parse_string(row.get(IDX_NAME)); let size = parse_long(row.get(IDX_SIZE)); let completed = parse_long(row.get(IDX_COMPLETED)); let down_rate = parse_long(row.get(IDX_DOWN_RATE)); let up_rate = parse_long(row.get(IDX_UP_RATE)); let state = parse_long(row.get(IDX_STATE)); let is_complete = parse_long(row.get(IDX_COMPLETE)); let message = parse_string(row.get(IDX_MESSAGE)); let left_bytes = parse_long(row.get(IDX_LEFT_BYTES)); let added_date = parse_long(row.get(IDX_CREATION_DATE)); let is_hashing = parse_long(row.get(IDX_HASHING)); let label_raw = parse_string(row.get(IDX_LABEL)); 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 } else { 0.0 }; // Status Logic let status = if !message.is_empty() { TorrentStatus::Error } else if is_hashing != 0 { TorrentStatus::Checking } else if state == 0 { TorrentStatus::Paused } else if is_complete != 0 { TorrentStatus::Seeding } else { TorrentStatus::Downloading }; // ETA Logic (seconds) let eta = if down_rate > 0 && left_bytes > 0 { left_bytes / down_rate } else { 0 }; Torrent { hash, name, size, completed, down_rate, up_rate, eta, percent_complete, status, error_message: message, added_date, label, } } pub async fn fetch_torrents(client: &RtorrentClient) -> Result, XmlRpcError> { let params: Vec = RTORRENT_FIELDS.iter().map(|s| RpcParam::from(*s)).collect(); let xml = client.call("d.multicall2", ¶ms).await?; if xml.trim().is_empty() { return Err(XmlRpcError::Parse("Empty response from SCGI".to_string())); } let rows = parse_multicall_response(&xml)?; let torrents = rows.into_iter().map(from_rtorrent_row).collect(); Ok(torrents) } pub async fn fetch_global_stats(client: &RtorrentClient) -> Result { let empty_params: Vec = vec![]; let down_rate_xml = client .call("throttle.global_down.rate", &empty_params) .await?; let down_rate = parse_i64_response(&down_rate_xml).unwrap_or(0); let up_rate_xml = client .call("throttle.global_up.rate", &empty_params) .await?; let up_rate = parse_i64_response(&up_rate_xml).unwrap_or(0); let down_limit_xml = client .call("throttle.global_down.max_rate", &empty_params) .await?; let down_limit = parse_i64_response(&down_limit_xml).ok(); let up_limit_xml = client .call("throttle.global_up.max_rate", &empty_params) .await?; let up_limit = parse_i64_response(&up_limit_xml).ok(); Ok(GlobalStats { down_rate, up_rate, down_limit, up_limit, free_space: None, }) } pub async fn sse_handler( State(state): State, ) -> Sse>> { // Get initial value synchronously (from the watch channel's current state) let initial_rx = state.tx.subscribe(); let initial_torrents = initial_rx.borrow().clone(); let initial_event = { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); let event_data = AppEvent::FullList { torrents: initial_torrents, timestamp, }; match serde_json::to_string(&event_data) { Ok(json) => Event::default().data(json), Err(_) => Event::default().comment("init_error"), } }; // Stream that yields the initial event once let initial_stream = stream::once(async { Ok::(initial_event) }); // Stream that waits for subsequent changes via Broadcast channel let rx = state.event_bus.subscribe(); let update_stream = stream::unfold(rx, |mut rx| async move { match rx.recv().await { Ok(event) => match serde_json::to_string(&event) { Ok(json) => Some((Ok::(Event::default().data(json)), rx)), Err(e) => { tracing::warn!("Failed to serialize SSE event: {}", e); Some(( Ok::(Event::default().comment("error")), rx, )) } }, Err(e) => { // If channel closed or lagged, close stream so client reconnects and gets fresh state tracing::warn!("SSE Broadcast channel error (lagged/closed): {}", e); None } } }); Sse::new(initial_stream.chain(update_stream)) .keep_alive(axum::response::sse::KeepAlive::default()) }