Compare commits

..

1 Commits

Author SHA1 Message Date
spinline
ce10c5dfb2 refactor: replace magic indices with RtorrentField enum for type-safe parsing
All checks were successful
Build MIPS Binary / build (push) Successful in 4m22s
2026-02-08 22:50:26 +03:00
15 changed files with 439 additions and 534 deletions

34
Cargo.lock generated
View File

@@ -310,6 +310,7 @@ dependencies = [
"serde_json", "serde_json",
"shared", "shared",
"sqlx", "sqlx",
"strum",
"thiserror 2.0.18", "thiserror 2.0.18",
"time", "time",
"tokio", "tokio",
@@ -544,7 +545,7 @@ version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [ dependencies = [
"heck", "heck 0.5.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.114", "syn 2.0.114",
@@ -1218,7 +1219,6 @@ dependencies = [
"serde_json", "serde_json",
"shared", "shared",
"tailwind_fuse", "tailwind_fuse",
"thiserror 2.0.18",
"uuid", "uuid",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
@@ -1537,6 +1537,12 @@ dependencies = [
"hashbrown 0.15.5", "hashbrown 0.15.5",
] ]
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -3704,7 +3710,7 @@ checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
dependencies = [ dependencies = [
"dotenvy", "dotenvy",
"either", "either",
"heck", "heck 0.5.0",
"hex", "hex",
"once_cell", "once_cell",
"proc-macro2", "proc-macro2",
@@ -3847,6 +3853,28 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.25.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0"
dependencies = [
"heck 0.4.1",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.114",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"

View File

@@ -42,3 +42,4 @@ anyhow = "1.0.101"
time = { version = "0.3.47", features = ["serde", "formatting", "parsing"] } time = { version = "0.3.47", features = ["serde", "formatting", "parsing"] }
tower_governor = "0.8.0" tower_governor = "0.8.0"
governor = "0.10.4" governor = "0.10.4"
strum = { version = "0.25", features = ["derive", "strum_macros"] }

View File

@@ -359,14 +359,6 @@ async fn main() {
let mut backoff_duration = Duration::from_secs(1); let mut backoff_duration = Duration::from_secs(1);
loop { loop {
// Determine polling interval based on active clients
let active_clients = event_bus_tx.receiver_count();
let loop_interval = if active_clients > 0 {
Duration::from_secs(1)
} else {
Duration::from_secs(30)
};
// 1. Fetch Torrents // 1. Fetch Torrents
let torrents_result = sse::fetch_torrents(&client).await; let torrents_result = sse::fetch_torrents(&client).await;
@@ -437,9 +429,6 @@ async fn main() {
} }
previous_torrents = new_torrents; previous_torrents = new_torrents;
// Success case: sleep for the determined interval
tokio::time::sleep(loop_interval).await;
} }
Err(e) => { Err(e) => {
tracing::error!("Error fetching torrents in background: {}", e); tracing::error!("Error fetching torrents in background: {}", e);
@@ -460,15 +449,20 @@ async fn main() {
"Backoff: Sleeping for {:?} due to rTorrent error.", "Backoff: Sleeping for {:?} due to rTorrent error.",
backoff_duration backoff_duration
); );
tokio::time::sleep(backoff_duration).await;
} }
} }
// Handle Stats // Handle Stats
if let Ok(stats) = stats_result { match stats_result {
let _ = event_bus_tx.send(AppEvent::Stats(stats)); Ok(stats) => {
let _ = event_bus_tx.send(AppEvent::Stats(stats));
}
Err(e) => {
tracing::warn!("Error fetching global stats: {}", e);
}
} }
tokio::time::sleep(backoff_duration).await;
} }
}); });

View File

@@ -7,95 +7,80 @@ use axum::response::sse::{Event, Sse};
use futures::stream::{self, Stream}; use futures::stream::{self, Stream};
use shared::{AppEvent, GlobalStats, Torrent, TorrentStatus}; use shared::{AppEvent, GlobalStats, Torrent, TorrentStatus};
use std::convert::Infallible; use std::convert::Infallible;
use strum::{Display, EnumString};
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
// Field definitions to keep query and parser in sync #[derive(Debug, Clone, Copy, EnumString, Display, PartialEq)]
mod fields { enum RtorrentField {
pub const IDX_HASH: usize = 0; #[strum(serialize = "d.hash=")]
pub const CMD_HASH: &str = "d.hash="; Hash,
#[strum(serialize = "d.name=")]
pub const IDX_NAME: usize = 1; Name,
pub const CMD_NAME: &str = "d.name="; #[strum(serialize = "d.size_bytes=")]
Size,
pub const IDX_SIZE: usize = 2; #[strum(serialize = "d.bytes_done=")]
pub const CMD_SIZE: &str = "d.size_bytes="; Completed,
#[strum(serialize = "d.down.rate=")]
pub const IDX_COMPLETED: usize = 3; DownRate,
pub const CMD_COMPLETED: &str = "d.bytes_done="; #[strum(serialize = "d.up.rate=")]
UpRate,
pub const IDX_DOWN_RATE: usize = 4; #[strum(serialize = "d.state=")]
pub const CMD_DOWN_RATE: &str = "d.down.rate="; State,
#[strum(serialize = "d.complete=")]
pub const IDX_UP_RATE: usize = 5; Complete,
pub const CMD_UP_RATE: &str = "d.up.rate="; #[strum(serialize = "d.message=")]
Message,
pub const IDX_STATE: usize = 6; #[strum(serialize = "d.left_bytes=")]
pub const CMD_STATE: &str = "d.state="; LeftBytes,
#[strum(serialize = "d.creation_date=")]
pub const IDX_COMPLETE: usize = 7; CreationDate,
pub const CMD_COMPLETE: &str = "d.complete="; #[strum(serialize = "d.hashing=")]
Hashing,
pub const IDX_MESSAGE: usize = 8; #[strum(serialize = "d.custom1=")]
pub const CMD_MESSAGE: &str = "d.message="; Label,
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::*; const RTORRENT_FIELDS: &[RtorrentField] = &[
RtorrentField::Hash,
// Constants for rTorrent fields to ensure query and parser stay in sync RtorrentField::Name,
const RTORRENT_FIELDS: &[&str] = &[ RtorrentField::Size,
"", // Ignored by multicall pattern RtorrentField::Completed,
"main", // View RtorrentField::DownRate,
CMD_HASH, RtorrentField::UpRate,
CMD_NAME, RtorrentField::State,
CMD_SIZE, RtorrentField::Complete,
CMD_COMPLETED, RtorrentField::Message,
CMD_DOWN_RATE, RtorrentField::LeftBytes,
CMD_UP_RATE, RtorrentField::CreationDate,
CMD_STATE, RtorrentField::Hashing,
CMD_COMPLETE, RtorrentField::Label,
CMD_MESSAGE,
CMD_LEFT_BYTES,
CMD_CREATION_DATE,
CMD_HASHING,
CMD_LABEL,
]; ];
fn parse_long(s: Option<&String>) -> i64 { fn get_field_value(row: &Vec<String>, field: RtorrentField) -> String {
s.map(|v| v.parse().unwrap_or(0)).unwrap_or(0) let idx = RTORRENT_FIELDS.iter().position(|&f| f == field).unwrap_or(0);
row.get(idx).cloned().unwrap_or_default()
} }
fn parse_string(s: Option<&String>) -> String { fn parse_long(s: &str) -> i64 {
s.cloned().unwrap_or_default() s.parse().unwrap_or(0)
} }
/// Converts a raw row of strings from rTorrent XML-RPC into a generic Torrent struct /// Converts a raw row of strings from rTorrent XML-RPC into a generic Torrent struct
fn from_rtorrent_row(row: Vec<String>) -> Torrent { fn from_rtorrent_row(row: &Vec<String>) -> Torrent {
let hash = parse_string(row.get(IDX_HASH)); let hash = get_field_value(row, RtorrentField::Hash);
let name = parse_string(row.get(IDX_NAME)); let name = get_field_value(row, RtorrentField::Name);
let size = parse_long(row.get(IDX_SIZE)); let size = parse_long(&get_field_value(row, RtorrentField::Size));
let completed = parse_long(row.get(IDX_COMPLETED)); let completed = parse_long(&get_field_value(row, RtorrentField::Completed));
let down_rate = parse_long(row.get(IDX_DOWN_RATE)); let down_rate = parse_long(&get_field_value(row, RtorrentField::DownRate));
let up_rate = parse_long(row.get(IDX_UP_RATE)); let up_rate = parse_long(&get_field_value(row, RtorrentField::UpRate));
let state = parse_long(row.get(IDX_STATE)); let state = parse_long(&get_field_value(row, RtorrentField::State));
let is_complete = parse_long(row.get(IDX_COMPLETE)); let is_complete = parse_long(&get_field_value(row, RtorrentField::Complete));
let message = parse_string(row.get(IDX_MESSAGE)); let message = get_field_value(row, RtorrentField::Message);
let left_bytes = parse_long(row.get(IDX_LEFT_BYTES)); let left_bytes = parse_long(&get_field_value(row, RtorrentField::LeftBytes));
let added_date = parse_long(row.get(IDX_CREATION_DATE)); let added_date = parse_long(&get_field_value(row, RtorrentField::CreationDate));
let is_hashing = parse_long(row.get(IDX_HASHING)); let is_hashing = parse_long(&get_field_value(row, RtorrentField::Hashing));
let label_raw = parse_string(row.get(IDX_LABEL)); let label_raw = get_field_value(row, RtorrentField::Label);
let label = if label_raw.is_empty() { let label = if label_raw.is_empty() {
None None
@@ -146,7 +131,10 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
} }
pub async fn fetch_torrents(client: &RtorrentClient) -> Result<Vec<Torrent>, XmlRpcError> { pub async fn fetch_torrents(client: &RtorrentClient) -> Result<Vec<Torrent>, XmlRpcError> {
let params: Vec<RpcParam> = RTORRENT_FIELDS.iter().map(|s| RpcParam::from(*s)).collect(); let params: Vec<RpcParam> = RTORRENT_FIELDS
.iter()
.map(|&f| RpcParam::from(f.to_string()))
.collect();
let xml = client.call("d.multicall2", &params).await?; let xml = client.call("d.multicall2", &params).await?;
if xml.trim().is_empty() { if xml.trim().is_empty() {
@@ -155,7 +143,7 @@ pub async fn fetch_torrents(client: &RtorrentClient) -> Result<Vec<Torrent>, Xml
let rows = parse_multicall_response(&xml)?; let rows = parse_multicall_response(&xml)?;
let torrents = rows.into_iter().map(from_rtorrent_row).collect(); let torrents = rows.iter().map(from_rtorrent_row).collect();
Ok(torrents) Ok(torrents)
} }

View File

@@ -30,4 +30,3 @@ base64 = "0.22.1"
serde-wasm-bindgen = "0.6.5" serde-wasm-bindgen = "0.6.5"
leptos-use = "0.13" leptos-use = "0.13"
codee = "0.2" codee = "0.2"
thiserror = "2.0"

View File

@@ -1,243 +0,0 @@
use gloo_net::http::Request;
use shared::{AddTorrentRequest, TorrentActionRequest};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiError {
#[error("Network error")]
Network,
#[error("Server error: {status}")]
Server { status: u16 },
#[error("Login failed")]
LoginFailed,
#[error("Unauthorized")]
Unauthorized,
#[error("Too many requests")]
RateLimited,
}
fn base_url() -> String {
"/api".to_string()
}
pub mod auth {
use super::*;
#[derive(serde::Serialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
pub remember_me: bool,
}
pub async fn login(
username: &str,
password: &str,
remember_me: bool,
) -> Result<(), ApiError> {
let req = LoginRequest {
username: username.to_string(),
password: password.to_string(),
remember_me,
};
let resp = Request::post(&format!("{}/auth/login", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
if resp.ok() {
Ok(())
} else if resp.status() == 429 {
Err(ApiError::RateLimited)
} else {
Err(ApiError::LoginFailed)
}
}
pub async fn logout() -> Result<(), ApiError> {
Request::post(&format!("{}/auth/logout", base_url()))
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
}
pub async fn check_auth() -> Result<bool, ApiError> {
let resp = Request::get(&format!("{}/auth/check", base_url()))
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(resp.ok())
}
#[derive(serde::Deserialize)]
pub struct UserResponse {
pub username: String,
}
pub async fn get_user() -> Result<UserResponse, ApiError> {
let resp = Request::get(&format!("{}/auth/check", base_url()))
.send()
.await
.map_err(|_| ApiError::Network)?;
let user = resp.json().await.map_err(|_| ApiError::Network)?;
Ok(user)
}
}
pub mod setup {
use super::*;
#[derive(serde::Serialize)]
pub struct SetupRequest {
pub username: String,
pub password: String,
}
#[derive(serde::Deserialize)]
pub struct SetupStatusResponse {
pub completed: bool,
}
pub async fn get_status() -> Result<SetupStatusResponse, ApiError> {
let resp = Request::get(&format!("{}/setup/status", base_url()))
.send()
.await
.map_err(|_| ApiError::Network)?;
let status = resp.json().await.map_err(|_| ApiError::Network)?;
Ok(status)
}
pub async fn setup(username: &str, password: &str) -> Result<(), ApiError> {
let req = SetupRequest {
username: username.to_string(),
password: password.to_string(),
};
Request::post(&format!("{}/setup", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
}
}
pub mod settings {
use super::*;
use shared::GlobalLimitRequest;
pub async fn set_global_limits(req: &GlobalLimitRequest) -> Result<(), ApiError> {
Request::post(&format!("{}/settings/global-limits", base_url()))
.json(req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
}
}
pub mod push {
use super::*;
use crate::store::PushSubscriptionData;
pub async fn get_public_key() -> Result<String, ApiError> {
let resp = Request::get(&format!("{}/push/public-key", base_url()))
.send()
.await
.map_err(|_| ApiError::Network)?;
let key = resp.text().await.map_err(|_| ApiError::Network)?;
Ok(key)
}
pub async fn subscribe(req: &PushSubscriptionData) -> Result<(), ApiError> {
Request::post(&format!("{}/push/subscribe", base_url()))
.json(req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
}
}
pub mod torrent {
use super::*;
pub async fn add(uri: &str) -> Result<(), ApiError> {
let req = AddTorrentRequest {
uri: uri.to_string(),
};
Request::post(&format!("{}/torrents/add", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
}
pub async fn action(hash: &str, action: &str) -> Result<(), ApiError> {
let req = TorrentActionRequest {
hash: hash.to_string(),
action: action.to_string(),
};
Request::post(&format!("{}/torrents/action", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
}
pub async fn delete(hash: &str) -> Result<(), ApiError> {
action(hash, "delete").await
}
pub async fn delete_with_data(hash: &str) -> Result<(), ApiError> {
action(hash, "delete_with_data").await
}
pub async fn start(hash: &str) -> Result<(), ApiError> {
action(hash, "start").await
}
pub async fn stop(hash: &str) -> Result<(), ApiError> {
action(hash, "stop").await
}
pub async fn set_label(hash: &str, label: &str) -> Result<(), ApiError> {
use shared::SetLabelRequest;
let req = SetLabelRequest {
hash: hash.to_string(),
label: label.to_string(),
};
Request::post(&format!("{}/torrents/set_label", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
}
pub async fn set_priority(hash: &str, file_index: u32, priority: u8) -> Result<(), ApiError> {
use shared::SetFilePriorityRequest;
let req = SetFilePriorityRequest {
hash: hash.to_string(),
file_index,
priority,
};
Request::post(&format!("{}/torrents/set_priority", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
}
}

View File

@@ -3,80 +3,100 @@ use crate::components::toast::ToastContainer;
use crate::components::torrent::table::TorrentTable; use crate::components::torrent::table::TorrentTable;
use crate::components::auth::login::Login; use crate::components::auth::login::Login;
use crate::components::auth::setup::Setup; use crate::components::auth::setup::Setup;
use crate::api;
use leptos::*; use leptos::*;
use leptos_router::*; use leptos_router::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct SetupStatus {
completed: bool,
}
#[derive(Deserialize)]
struct UserResponse {
username: String,
}
#[component] #[component]
pub fn App() -> impl IntoView { pub fn App() -> impl IntoView {
crate::store::provide_torrent_store(); crate::store::provide_torrent_store();
// Auth State
let (is_loading, set_is_loading) = create_signal(true); let (is_loading, set_is_loading) = create_signal(true);
let (is_authenticated, set_is_authenticated) = create_signal(false); let (is_authenticated, set_is_authenticated) = create_signal(false);
// Check Auth & Setup Status on load
create_effect(move |_| { create_effect(move |_| {
spawn_local(async move { spawn_local(async move {
logging::log!("App initialization started..."); logging::log!("App initialization started...");
let setup_res = api::setup::get_status().await; // 1. Check Setup Status
let setup_res = gloo_net::http::Request::get("/api/setup/status").send().await;
match setup_res { match setup_res {
Ok(status) => { Ok(resp) => {
if !status.completed { if resp.ok() {
logging::log!("Setup not completed, redirecting to /setup"); match resp.json::<SetupStatus>().await {
let navigate = use_navigate(); Ok(status) => {
navigate("/setup", Default::default()); if !status.completed {
set_is_loading.set(false); logging::log!("Setup not completed, redirecting to /setup");
return; let navigate = use_navigate();
} navigate("/setup", Default::default());
} set_is_loading.set(false);
Err(e) => logging::error!("Failed to get setup status: {:?}", e), return;
} }
}
let auth_res = api::auth::check_auth().await; Err(e) => logging::error!("Failed to parse setup status: {}", e),
match auth_res {
Ok(true) => {
logging::log!("Authenticated!");
if let Ok(user_info) = api::auth::get_user().await {
if let Some(store) = use_context::<crate::store::TorrentStore>() {
store.user.set(Some(user_info.username));
} }
} }
set_is_authenticated.set(true);
let pathname = window().location().pathname().unwrap_or_default();
if pathname == "/login" || pathname == "/setup" {
logging::log!("Already authenticated, redirecting to home");
let navigate = use_navigate();
navigate("/", Default::default());
}
}
Ok(false) => {
logging::log!("Not authenticated");
let pathname = window().location().pathname().unwrap_or_default();
if pathname != "/login" && pathname != "/setup" {
let navigate = use_navigate();
navigate("/login", Default::default());
}
}
Err(e) => {
logging::error!("Auth check failed: {:?}", e);
let navigate = use_navigate();
navigate("/login", Default::default());
} }
Err(e) => logging::error!("Network error checking setup status: {}", e),
} }
// 2. Check Auth Status
let auth_res = gloo_net::http::Request::get("/api/auth/check").send().await;
match auth_res {
Ok(resp) => {
if resp.status() == 200 {
logging::log!("Authenticated!");
// Parse user info
if let Ok(user_info) = resp.json::<UserResponse>().await {
if let Some(store) = use_context::<crate::store::TorrentStore>() {
store.user.set(Some(user_info.username));
}
}
set_is_authenticated.set(true);
// If user is already authenticated but on login/setup page, redirect to home
let pathname = window().location().pathname().unwrap_or_default();
if pathname == "/login" || pathname == "/setup" {
logging::log!("Already authenticated, redirecting to home");
let navigate = use_navigate();
navigate("/", Default::default());
}
} else {
logging::log!("Not authenticated, redirecting to /login");
let navigate = use_navigate();
let pathname = window().location().pathname().unwrap_or_default();
if pathname != "/login" && pathname != "/setup" {
navigate("/login", Default::default());
}
}
}
Err(e) => logging::error!("Network error checking auth status: {}", e),
}
set_is_loading.set(false); set_is_loading.set(false);
}); });
}); });
// Initialize push notifications (Only if authenticated)
create_effect(move |_| { create_effect(move |_| {
if is_authenticated.get() { if is_authenticated.get() {
spawn_local(async { spawn_local(async {
// ... (Push notification logic kept same, shortened for brevity in this replace)
// Wait a bit for service worker to be ready
gloo_timers::future::TimeoutFuture::new(2000).await; gloo_timers::future::TimeoutFuture::new(2000).await;
if crate::utils::platform::supports_push_notifications() && !crate::utils::platform::is_safari() { if crate::utils::platform::supports_push_notifications() && !crate::utils::platform::is_safari() {

View File

@@ -1,5 +1,12 @@
use leptos::*; use leptos::*;
use crate::api; use serde::Serialize;
#[derive(Serialize)]
struct LoginRequest {
username: String,
password: String,
remember_me: bool,
}
#[component] #[component]
pub fn Login() -> impl IntoView { pub fn Login() -> impl IntoView {
@@ -16,31 +23,35 @@ pub fn Login() -> impl IntoView {
logging::log!("Attempting login for user: {}", username.get()); logging::log!("Attempting login for user: {}", username.get());
let username = username.get();
let password = password.get();
let remember_me = remember_me.get();
spawn_local(async move { spawn_local(async move {
match api::auth::login(&username, &password, remember_me).await { let req = LoginRequest {
Ok(_) => { username: username.get(),
logging::log!("Login successful, redirecting..."); password: password.get(),
let _ = window().location().set_href("/"); remember_me: remember_me.get(),
};
let client = gloo_net::http::Request::post("/api/auth/login")
.json(&req)
.expect("Failed to create request");
match client.send().await {
Ok(resp) => {
logging::log!("Login response status: {}", resp.status());
if resp.ok() {
logging::log!("Login successful, redirecting...");
// Force a full reload to re-run auth checks in App.rs
let _ = window().location().set_href("/");
} else if resp.status() == 429 {
set_error.set(Some("Çok fazla başarısız deneme yaptınız. Lütfen bir süre bekleyip tekrar deneyin.".to_string()));
} else {
let text = resp.text().await.unwrap_or_default();
logging::error!("Login failed: {}", text);
set_error.set(Some("Kullanıcı adı veya şifre hatalı".to_string()));
}
} }
Err(e) => { Err(e) => {
logging::error!("Login failed: {:?}", e); logging::error!("Network error: {}", e);
let msg = match e { set_error.set(Some("Bağlantı hatası".to_string()));
crate::api::ApiError::RateLimited => {
"Çok fazla başarısız deneme yaptınız. Lütfen bir süre bekleyip tekrar deneyin.".to_string()
}
crate::api::ApiError::Unauthorized | crate::api::ApiError::LoginFailed => {
"Kullanıcı adı veya şifre hatalı".to_string()
}
crate::api::ApiError::Network => {
"Bağlantı hatası".to_string()
}
_ => "Bir hata oluştu".to_string()
};
set_error.set(Some(msg));
} }
} }
set_loading.set(false); set_loading.set(false);

View File

@@ -1,5 +1,11 @@
use leptos::*; use leptos::*;
use crate::api; use serde::Serialize;
#[derive(Serialize)]
struct SetupRequest {
username: String,
password: String,
}
#[component] #[component]
pub fn Setup() -> impl IntoView { pub fn Setup() -> impl IntoView {
@@ -29,18 +35,29 @@ pub fn Setup() -> impl IntoView {
return; return;
} }
let username = username.get();
let password = pass;
spawn_local(async move { spawn_local(async move {
match api::setup::setup(&username, &password).await { let req = SetupRequest {
Ok(_) => { username: username.get(),
logging::log!("Setup completed successfully, redirecting..."); password: pass,
let _ = window().location().set_href("/"); };
let client = gloo_net::http::Request::post("/api/setup")
.json(&req)
.expect("Failed to create request");
match client.send().await {
Ok(resp) => {
if resp.ok() {
// Redirect to home after setup (auto-login handled by backend)
// Full reload to ensure auth state is refreshed
let _ = window().location().set_href("/");
} else {
let text = resp.text().await.unwrap_or_default();
set_error.set(Some(format!("Hata: {}", text)));
}
} }
Err(e) => { Err(_) => {
logging::error!("Setup failed: {:?}", e); set_error.set(Some("Bağlantı hatası".to_string()));
set_error.set(Some("Kurulum başarısız oldu".to_string()));
} }
} }
set_loading.set(false); set_loading.set(false);

View File

@@ -1,52 +1,56 @@
use leptos::wasm_bindgen::JsCast; use leptos::wasm_bindgen::JsCast;
use leptos::*; use leptos::*;
use crate::api;
#[component] #[component]
pub fn Sidebar() -> impl IntoView { pub fn Sidebar() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided"); let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let total_count = move || store.torrents.with(|map| map.len()); let total_count = move || store.torrents.get().len();
let downloading_count = move || { let downloading_count = move || {
store.torrents.with(|map| { store
map.values() .torrents
.filter(|t| t.status == shared::TorrentStatus::Downloading) .get()
.count() .iter()
}) .filter(|t| t.status == shared::TorrentStatus::Downloading)
.count()
}; };
let seeding_count = move || { let seeding_count = move || {
store.torrents.with(|map| { store
map.values() .torrents
.filter(|t| t.status == shared::TorrentStatus::Seeding) .get()
.count() .iter()
}) .filter(|t| t.status == shared::TorrentStatus::Seeding)
.count()
}; };
let completed_count = move || { let completed_count = move || {
store.torrents.with(|map| { store
map.values() .torrents
.filter(|t| { .get()
t.status == shared::TorrentStatus::Seeding .iter()
|| (t.status == shared::TorrentStatus::Paused && t.percent_complete >= 100.0) .filter(|t| {
}) t.status == shared::TorrentStatus::Seeding
.count() || (t.status == shared::TorrentStatus::Paused && t.percent_complete >= 100.0)
}) })
.count()
}; };
let paused_count = move || { let paused_count = move || {
store.torrents.with(|map| { store
map.values() .torrents
.filter(|t| t.status == shared::TorrentStatus::Paused) .get()
.count() .iter()
}) .filter(|t| t.status == shared::TorrentStatus::Paused)
.count()
}; };
let inactive_count = move || { let inactive_count = move || {
store.torrents.with(|map| { store
map.values() .torrents
.filter(|t| { .get()
t.status == shared::TorrentStatus::Paused .iter()
|| t.status == shared::TorrentStatus::Error .filter(|t| {
}) t.status == shared::TorrentStatus::Paused
.count() || t.status == shared::TorrentStatus::Error
}) })
.count()
}; };
let close_drawer = move || { let close_drawer = move || {
@@ -72,8 +76,12 @@ pub fn Sidebar() -> impl IntoView {
let handle_logout = move |_| { let handle_logout = move |_| {
spawn_local(async move { spawn_local(async move {
if api::auth::logout().await.is_ok() { let client = gloo_net::http::Request::post("/api/auth/logout");
let _ = window().location().set_href("/login"); if let Ok(resp) = client.send().await {
if resp.ok() {
// Force full reload to clear state
let _ = window().location().set_href("/login");
}
} }
}); });
}; };

View File

@@ -2,7 +2,6 @@ use leptos::*;
use leptos_use::storage::use_local_storage; use leptos_use::storage::use_local_storage;
use codee::string::FromToStringCodec; use codee::string::FromToStringCodec;
use shared::GlobalLimitRequest; use shared::GlobalLimitRequest;
use crate::api;
fn format_bytes(bytes: i64) -> String { fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"]; const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
@@ -61,23 +60,38 @@ pub fn StatusBar() -> impl IntoView {
let limit_type = limit_type.to_string(); let limit_type = limit_type.to_string();
logging::log!("Setting {} limit to {}", limit_type, val); logging::log!("Setting {} limit to {}", limit_type, val);
let req = if limit_type == "down" {
GlobalLimitRequest {
max_download_rate: Some(val),
max_upload_rate: None,
}
} else {
GlobalLimitRequest {
max_download_rate: None,
max_upload_rate: Some(val),
}
};
spawn_local(async move { spawn_local(async move {
if let Err(e) = api::settings::set_global_limits(&req).await { let req_body = if limit_type == "down" {
logging::error!("Failed to set limit: {:?}", e); GlobalLimitRequest {
max_download_rate: Some(val),
max_upload_rate: None,
}
} else { } else {
logging::log!("Limit set successfully"); GlobalLimitRequest {
max_download_rate: None,
max_upload_rate: Some(val),
}
};
let client =
gloo_net::http::Request::post("/api/settings/global-limits").json(&req_body);
match client {
Ok(req) => match req.send().await {
Ok(resp) => {
if !resp.ok() {
logging::error!(
"Failed to set limit: {} {}",
resp.status(),
resp.status_text()
);
} else {
logging::log!("Limit set successfully");
}
}
Err(e) => logging::error!("Network error setting limit: {}", e),
},
Err(e) => logging::error!("Failed to create request: {}", e),
} }
}); });
}; };

View File

@@ -1,8 +1,7 @@
use leptos::*; use leptos::*;
use leptos::html::Dialog; use leptos::html::Dialog;
use crate::store::{show_toast_with_signal, TorrentStore}; use crate::store::{show_toast_with_signal, TorrentStore};
use crate::api; use shared::{AddTorrentRequest, NotificationLevel};
use shared::NotificationLevel;
#[component] #[component]
@@ -18,6 +17,7 @@ pub fn AddTorrentModal(
let (is_loading, set_loading) = create_signal(false); let (is_loading, set_loading) = create_signal(false);
let (error_msg, set_error_msg) = create_signal(Option::<String>::None); let (error_msg, set_error_msg) = create_signal(Option::<String>::None);
// Effect to open the dialog when the component mounts/renders
create_effect(move |_| { create_effect(move |_| {
if let Some(dialog) = dialog_ref.get() { if let Some(dialog) = dialog_ref.get() {
let _ = dialog.show_modal(); let _ = dialog.show_modal();
@@ -35,22 +35,44 @@ pub fn AddTorrentModal(
set_loading.set(true); set_loading.set(true);
set_error_msg.set(None); set_error_msg.set(None);
let uri_val = uri_val;
spawn_local(async move { spawn_local(async move {
match api::torrent::add(&uri_val).await { let req_body = AddTorrentRequest { uri: uri_val };
Ok(_) => {
logging::log!("Torrent added successfully"); match gloo_net::http::Request::post("/api/torrents/add")
show_toast_with_signal(notifications, NotificationLevel::Success, "Torrent eklendi"); .json(&req_body)
set_loading.set(false); {
if let Some(dialog) = dialog_ref.get() { Ok(req) => {
dialog.close(); match req.send().await {
Ok(resp) => {
if resp.ok() {
logging::log!("Torrent added successfully");
show_toast_with_signal(notifications, NotificationLevel::Success, "Torrent eklendi");
set_loading.set(false);
if let Some(dialog) = dialog_ref.get() {
dialog.close();
}
on_close.call(());
} else {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
logging::error!("Failed to add torrent: {} - {}", status, text);
show_toast_with_signal(notifications, NotificationLevel::Error, "Torrent eklenemedi");
set_error_msg.set(Some(format!("Error {}: {}", status, text)));
set_loading.set(false);
}
}
Err(e) => {
logging::error!("Network error: {}", e);
show_toast_with_signal(notifications, NotificationLevel::Error, "Bağlantı hatası");
set_error_msg.set(Some(format!("Network Error: {}", e)));
set_loading.set(false);
}
} }
on_close.call(());
} }
Err(e) => { Err(e) => {
logging::error!("Failed to add torrent: {:?}", e); logging::error!("Serialization error: {}", e);
show_toast_with_signal(notifications, NotificationLevel::Error, "Torrent eklenemedi"); show_toast_with_signal(notifications, NotificationLevel::Error, "İstek hatası");
set_error_msg.set(Some(format!("Error: {:?}", e))); set_error_msg.set(Some(format!("Request Error: {}", e)));
set_loading.set(false); set_loading.set(false);
} }
} }

View File

@@ -1,7 +1,6 @@
use leptos::*; use leptos::*;
use leptos_use::{on_click_outside, use_timeout_fn}; use leptos_use::{on_click_outside, use_timeout_fn};
use crate::store::{get_action_messages, show_toast_with_signal}; use crate::store::{get_action_messages, show_toast_with_signal};
use crate::api;
use shared::NotificationLevel; use shared::NotificationLevel;
fn format_bytes(bytes: i64) -> String { fn format_bytes(bytes: i64) -> String {
@@ -82,10 +81,9 @@ pub fn TorrentTable() -> impl IntoView {
let sort_dir = create_rw_signal(SortDirection::Descending); let sort_dir = create_rw_signal(SortDirection::Descending);
let filtered_torrents = move || { let filtered_torrents = move || {
// Convert HashMap values to Vec for filtering and sorting let mut torrents = store
let torrents: Vec<shared::Torrent> = store.torrents.with(|map| map.values().cloned().collect()); .torrents
.get()
let mut torrents = torrents
.into_iter() .into_iter()
.filter(|t| { .filter(|t| {
let filter = store.filter.get(); let filter = store.filter.get();
@@ -203,33 +201,54 @@ pub fn TorrentTable() -> impl IntoView {
let on_action = move |(action, hash): (String, String)| { let on_action = move |(action, hash): (String, String)| {
logging::log!("TorrentTable Action: {} on {}", action, hash); logging::log!("TorrentTable Action: {} on {}", action, hash);
// Note: Don't close menu here - ContextMenu's on_close handles it
// Closing here would dispose ContextMenu while still in callback chain
// Get action messages for toast (Clean Code: DRY)
let (success_msg, error_msg) = get_action_messages(&action); let (success_msg, error_msg) = get_action_messages(&action);
let success_msg = success_msg.to_string(); let success_msg = success_msg.to_string();
let error_msg = error_msg.to_string(); let error_msg = error_msg.to_string();
// Capture notifications signal before async (use_context unavailable in spawn_local)
let notifications = store.notifications; let notifications = store.notifications;
let hash = hash.clone();
let action = action.clone();
spawn_local(async move { spawn_local(async move {
let result = match action.as_str() { let action_req = if action == "delete_with_data" {
"delete" => api::torrent::delete(&hash).await, "delete_with_data"
"delete_with_data" => api::torrent::delete_with_data(&hash).await, } else {
"start" => api::torrent::start(&hash).await, &action
"stop" => api::torrent::stop(&hash).await,
_ => api::torrent::action(&hash, &action).await,
}; };
match result { let req_body = shared::TorrentActionRequest {
Ok(_) => { hash: hash.clone(),
logging::log!("Action {} executed successfully", action); action: action_req.to_string(),
show_toast_with_signal(notifications, NotificationLevel::Success, success_msg); };
}
let client = gloo_net::http::Request::post("/api/torrents/action").json(&req_body);
match client {
Ok(req) => match req.send().await {
Ok(resp) => {
if !resp.ok() {
logging::error!(
"Failed to execute action: {} {}",
resp.status(),
resp.status_text()
);
show_toast_with_signal(notifications, NotificationLevel::Error, error_msg);
} else {
logging::log!("Action {} executed successfully", action);
show_toast_with_signal(notifications, NotificationLevel::Success, success_msg);
}
}
Err(e) => {
logging::error!("Network error executing action: {}", e);
show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: Bağlantı hatası", error_msg));
}
},
Err(e) => { Err(e) => {
logging::error!("Action failed: {:?}", e); logging::error!("Failed to serialize request: {}", e);
show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: {:?}", error_msg, e)); show_toast_with_signal(notifications, NotificationLevel::Error, error_msg);
} }
} }
}); });
@@ -254,7 +273,7 @@ pub fn TorrentTable() -> impl IntoView {
<div class="flex items-center">"Status" {move || sort_arrow(SortColumn::Status)}</div> <div class="flex items-center">"Status" {move || sort_arrow(SortColumn::Status)}</div>
</th> </th>
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::DownSpeed)> <th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
<div class="flex items-center">"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}</div> <div class="flex items-center">"Down Speed" {move || sort_arrow(SortColumn::DownSpeed)}</div>
</th> </th>
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::UpSpeed)> <th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
<div class="flex items-center">"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}</div> <div class="flex items-center">"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}</div>
@@ -345,7 +364,7 @@ pub fn TorrentTable() -> impl IntoView {
(SortColumn::Size, "Size"), (SortColumn::Size, "Size"),
(SortColumn::Progress, "Progress"), (SortColumn::Progress, "Progress"),
(SortColumn::Status, "Status"), (SortColumn::Status, "Status"),
(SortColumn::DownSpeed, "DL Speed"), (SortColumn::DownSpeed, "Down Speed"),
(SortColumn::UpSpeed, "Up Speed"), (SortColumn::UpSpeed, "Up Speed"),
(SortColumn::ETA, "ETA"), (SortColumn::ETA, "ETA"),
(SortColumn::AddedDate, "Date"), (SortColumn::AddedDate, "Date"),

View File

@@ -1,8 +1,8 @@
mod app; mod app;
// mod models; // Removed
mod components; mod components;
pub mod utils; pub mod utils;
pub mod store; pub mod store;
pub mod api;
use leptos::*; use leptos::*;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;

View File

@@ -113,11 +113,9 @@ impl FilterStatus {
} }
} }
use std::collections::HashMap;
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct TorrentStore { pub struct TorrentStore {
pub torrents: RwSignal<HashMap<String, Torrent>>, pub torrents: RwSignal<Vec<Torrent>>,
pub filter: RwSignal<FilterStatus>, pub filter: RwSignal<FilterStatus>,
pub search_query: RwSignal<String>, pub search_query: RwSignal<String>,
pub global_stats: RwSignal<GlobalStats>, pub global_stats: RwSignal<GlobalStats>,
@@ -126,7 +124,7 @@ pub struct TorrentStore {
} }
pub fn provide_torrent_store() { pub fn provide_torrent_store() {
let torrents = create_rw_signal(HashMap::new()); let torrents = create_rw_signal(Vec::<Torrent>::new());
let filter = create_rw_signal(FilterStatus::All); let filter = create_rw_signal(FilterStatus::All);
let search_query = create_rw_signal(String::new()); let search_query = create_rw_signal(String::new());
let global_stats = create_rw_signal(GlobalStats::default()); let global_stats = create_rw_signal(GlobalStats::default());
@@ -195,15 +193,12 @@ pub fn provide_torrent_store() {
if let Ok(event) = serde_json::from_str::<AppEvent>(&data_str) { if let Ok(event) = serde_json::from_str::<AppEvent>(&data_str) {
match event { match event {
AppEvent::FullList { torrents: list, .. } => { AppEvent::FullList { torrents: list, .. } => {
let map: HashMap<String, Torrent> = list torrents.set(list);
.into_iter()
.map(|t| (t.hash.clone(), t))
.collect();
torrents.set(map);
} }
AppEvent::Update(update) => { AppEvent::Update(update) => {
torrents.update(|map| { torrents.update(|list| {
if let Some(t) = map.get_mut(&update.hash) { if let Some(t) = list.iter_mut().find(|t| t.hash == update.hash)
{
if let Some(name) = update.name { if let Some(name) = update.name {
t.name = name; t.name = name;
} }
@@ -319,21 +314,21 @@ use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct PushSubscriptionData { struct PushSubscriptionData {
pub endpoint: String, endpoint: String,
pub keys: PushKeys, keys: PushKeys,
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct PushKeys { struct PushKeys {
pub p256dh: String, p256dh: String,
pub auth: String, auth: String,
} }
/// Subscribe user to push notifications /// Subscribe user to push notifications
/// Requests notification permission if needed, then subscribes to push /// Requests notification permission if needed, then subscribes to push
pub async fn subscribe_to_push_notifications() { pub async fn subscribe_to_push_notifications() {
use crate::api; use gloo_net::http::Request;
// First, request notification permission if not already granted // First, request notification permission if not already granted
let window = web_sys::window().expect("window should exist"); let window = web_sys::window().expect("window should exist");
@@ -352,19 +347,36 @@ pub async fn subscribe_to_push_notifications() {
log::info!("Notification permission granted! Proceeding with push subscription..."); log::info!("Notification permission granted! Proceeding with push subscription...");
// Get VAPID public key from backend // Get VAPID public key from backend
let public_key = match api::push::get_public_key().await { let public_key_response = match Request::get("/api/push/public-key").send().await {
Ok(key) => key, Ok(resp) => resp,
Err(e) => { Err(e) => {
log::error!("Failed to get VAPID public key: {:?}", e); log::error!("Failed to get VAPID public key: {:?}", e);
return; return;
} }
}; };
let public_key_data: serde_json::Value = match public_key_response.json().await {
Ok(data) => data,
Err(e) => {
log::error!("Failed to parse VAPID public key: {:?}", e);
return;
}
};
let public_key = match public_key_data.get("publicKey").and_then(|v| v.as_str()) {
Some(key) => key,
None => {
log::error!("Missing publicKey in response");
return;
}
};
log::info!("VAPID public key from backend: {} (len: {})", public_key, public_key.len()); log::info!("VAPID public key from backend: {} (len: {})", public_key, public_key.len());
// Convert VAPID public key to Uint8Array // Convert VAPID public key to Uint8Array
let public_key_array = match url_base64_to_uint8array(&public_key) { let public_key_array = match url_base64_to_uint8array(public_key) {
Ok(arr) => { Ok(arr) => {
log::info!("VAPID key converted to Uint8Array (length: {})", arr.length()); log::info!("VAPID key converted to Uint8Array (length: {})", arr.length());
arr arr
@@ -483,8 +495,23 @@ pub async fn subscribe_to_push_notifications() {
}; };
// Send to backend (subscription_data is already the struct we need) // Send to backend (subscription_data is already the struct we need)
if let Err(e) = api::push::subscribe(&subscription_data).await { let response = match Request::post("/api/push/subscribe")
log::error!("Failed to send subscription to backend: {:?}", e); .json(&subscription_data)
.expect("serialization should succeed")
.send()
.await
{
Ok(resp) => resp,
Err(e) => {
log::error!("Failed to send subscription to backend: {:?}", e);
return;
}
};
if response.ok() {
log::info!("Successfully subscribed to push notifications");
} else {
log::error!("Backend rejected push subscription: {:?}", response.status());
} }
} }