VYPR
Medium severity6.5OSV Advisory· Published Aug 19, 2025· Updated Apr 15, 2026

CVE-2025-55295

CVE-2025-55295

Description

qBit Manage is a tool that helps manage tedious tasks in qBittorrent and automate them. A path traversal vulnerability exists in qbit_manage's web API that allows authenticated users to read arbitrary files from the server filesystem through the restore_config_from_backup endpoint. The vulnerability allows attackers to bypass directory restrictions and read arbitrary files from the server filesystem by manipulating the backup_id parameter with path traversal sequences (e.g., ../). This vulnerability is fixed in 4.5.4.

Affected products

1

Patches

1
1e12a1610fe0

4.5.4 (#910)

44 files changed · +8536 568
  • CHANGELOG+10 20 modified
    @@ -1,24 +1,14 @@
    -# Requirements Updated
    -- "retrying==1.4.2",
    -
    -
    -# New Features
    -- **Web UI**: Implement dynamic schedule management via web UI/API
    -- **Share Limits**: Add limit upload speed when share limits are reached (New config option: `upload_speed_on_limit_reached`) (Fixes #731, #737, #703)
    -- **Share Limits**: Add min/max torrent size filters (New config option: `min_torrent_size` / `max_torrent_size`) (Fixes #472)
    -- **Remove Unregistered**: Add grace period for unregistered torrent removal (New config option: `rem_unregistered_grace_minutes`) (Fixes #898)
    -- **Scheduler (Web API)**: Implement dynamic schedule management via web API
    -
     # Improvements
    -- **Mover Script**: Allow granular control with pause, resume and move args
    -- **web UI**: When saving, don’t delete config comments and empty lines (Fixes #890)
    +- Support cross-platform binary builds (Linux/Windows/MacOS)
    +- Adds desktop app installers (Linux/Windows/MacOS)
    +- Container images for latest now pointed to newest version automatically (Fixes #897)
    +- Enable automatic open of webUI in local installs
    +- Add persistence toggling for webUI scheduler
     
     # Bug Fixes
    -- Fix Error acquiring lock: cannot assign to field '_last_run_start' (Fixes #895)
    -- Fix remove_orphaned not working correctly with `remote_dir` and reporting 0 files removed
    -- fix(web-ui): prevent XSS vulnerabilities and prototype pollution
    -- Potential fix for code scanning alert no. 13: Client-side cross-site scripting (#896)
    -
    -
    +- Fix schedule.yml not loaded upon restarting Docker container (Fixes #906)
    +- Fix bug where torrents were not being paused after share limits reached (Fixes #901)
    +- Fix(api): prevent path traversal vulnerability in backup restore endpoint (Fixes CWE-22 Security Vulnerability)
    +- Fix scheduler to run interval jobs immediately on startup
     
    -**Full Changelog**: https://github.com/StuffAnThings/qbit_manage/compare/v4.5.2...v4.5.3
    +**Full Changelog**: https://github.com/StuffAnThings/qbit_manage/compare/v4.5.3...v4.5.4
    
  • config/config.yml.sample+1 1 modified
    @@ -229,7 +229,7 @@ share_limits:
         # <OPTIONAL> Limit Upload Speed <int>: Will limit the upload speed KiB/s (KiloBytes/second) (`-1` : No Limit)
         limit_upload_speed: -1
         # <OPTIONAL> upload_speed_on_limit_reached <int>: When cleanup is false and share limits are reached, throttle per-torrent upload to this value (KiB/s). Use -1 for unlimited.
    -    upload_speed_on_limit_reached: -1
    +    upload_speed_on_limit_reached: 0
         # <OPTIONAL> Enable Group Upload Speed <bool>: Upload speed limits are applied at the group level. This will take limit_upload_speed defined and divide it equally among the number of torrents in the group.
         enable_group_upload_speed: false
         # <OPTIONAL> reset_upload_speed_on_unmet_minimums <bool>: If true (default), upload speed limits will be reset to unlimited when minimum conditions are not met. Set to false to preserve upload speed limits.
    
  • desktop/tauri/src/index.html+130 0 added
    @@ -0,0 +1,130 @@
    +<!DOCTYPE html>
    +<html lang="en">
    +<head>
    +    <meta charset="UTF-8">
    +    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    +    <title>qBit Manage</title>
    +    <style>
    +        /* CSS Custom Properties matching web-ui theme */
    +        :root {
    +            /* Dark theme colors matching web-ui/css/themes.css */
    +            --bg-primary: #0f172a;
    +            --bg-secondary: #1e293b;
    +            --text-primary: #f9fafb;
    +            --text-secondary: #d1d5db;
    +            --primary-color: #3b82f6;
    +            --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
    +        }
    +
    +        /* Auto theme based on system preference */
    +        @media (prefers-color-scheme: light) {
    +            :root {
    +                --bg-primary: #ffffff;
    +                --bg-secondary: #f8fafc;
    +                --text-primary: #1e293b;
    +                --text-secondary: #64748b;
    +            }
    +        }
    +
    +        body {
    +            margin: 0;
    +            padding: 0;
    +            font-family: var(--font-family);
    +            background-color: var(--bg-primary);
    +            display: flex;
    +            justify-content: center;
    +            align-items: center;
    +            height: 100vh;
    +            color: var(--text-primary);
    +            transition: background-color 0.25s ease-in-out, color 0.25s ease-in-out;
    +        }
    +        .container {
    +            text-align: center;
    +            padding: 2rem;
    +        }
    +        .logo {
    +            width: 120px;
    +            height: 120px;
    +            margin: 0 auto 2rem;
    +            display: flex;
    +            align-items: center;
    +            justify-content: center;
    +            /* Removed white background and border-radius for transparent logo */
    +        }
    +        .logo img {
    +            width: 100%;
    +            height: 100%;
    +            object-fit: contain;
    +            /* Logo will now be transparent without white background */
    +        }
    +        h1 {
    +            font-size: 2rem;
    +            font-weight: 600;
    +            margin-bottom: 1rem;
    +            color: var(--text-primary);
    +        }
    +        .spinner {
    +            width: 40px;
    +            height: 40px;
    +            border: 4px solid rgba(59, 130, 246, 0.2);
    +            border-top: 4px solid var(--primary-color);
    +            border-radius: 50%;
    +            animation: spin 1s linear infinite;
    +            margin: 1rem auto;
    +        }
    +        @keyframes spin {
    +            0% { transform: rotate(0deg); }
    +            100% { transform: rotate(360deg); }
    +        }
    +        .status {
    +            margin-top: 1rem;
    +            color: var(--text-secondary);
    +            font-size: 0.875rem;
    +        }
    +    </style>
    +</head>
    +<body>
    +    <div class="container">
    +        <div class="logo">
    +            <img src="qbm_logo.png" alt="qBit Manage Logo" />
    +        </div>
    +        <h1>qBit Manage</h1>
    +        <div class="spinner"></div>
    +        <div class="status" id="status">Starting server...</div>
    +    </div>
    +
    +    <script>
    +        // This will be replaced by the Rust backend when the server is ready
    +        console.log('qBit Manage loading...');
    +
    +        // Listen for server status updates
    +        if (window.__TAURI__) {
    +            const { listen } = window.__TAURI__.event;
    +
    +            listen('server-log', (event) => {
    +                console.log('Server log:', event.payload);
    +            });
    +
    +            listen('app-config', (event) => {
    +                console.log('App config:', event.payload);
    +            });
    +        }
    +
    +        // Update status periodically
    +        const statusMessages = [
    +            'Starting server...',
    +            'Initializing qbit_manage...',
    +            'Almost ready...'
    +        ];
    +        let currentStatus = 0;
    +
    +        setInterval(() => {
    +            const statusEl = document.getElementById('status');
    +            if (statusEl && currentStatus < statusMessages.length - 1) {
    +                currentStatus++;
    +                statusEl.textContent = statusMessages[currentStatus];
    +            }
    +        }, 2000);
    +    </script>
    +</body>
    +</html>
    
  • desktop/tauri/src/qbm_logo.png+0 0 renamed
  • desktop/tauri/src-tauri/bin/.gitkeep+2 0 added
    @@ -0,0 +1,2 @@
    +# This directory contains server binaries during CI builds
    +# Local development doesn't require these files
    
  • desktop/tauri/src-tauri/build.rs+63 0 added
    @@ -0,0 +1,63 @@
    +// Build script required for tauri::generate_context! macro (generates code into OUT_DIR)
    +use std::fs;
    +use std::path::Path;
    +
    +fn main() {
    +    // Check which binaries exist for debugging
    +    let bin_dir = std::path::Path::new("bin");
    +    if bin_dir.exists() {
    +        println!("cargo:rerun-if-changed=bin");
    +    }
    +
    +    // Read version from VERSION file and update both Cargo.toml and tauri.conf.json
    +    let version_file_path = Path::new("../../../VERSION");
    +    let cargo_toml_path = Path::new("Cargo.toml");
    +    let config_path = Path::new("tauri.conf.json");
    +
    +    if let Ok(version_content) = fs::read_to_string(version_file_path) {
    +        let version = version_content.trim();
    +
    +        // Update Cargo.toml with the version
    +        if let Ok(cargo_content) = fs::read_to_string(cargo_toml_path) {
    +            if let Ok(mut cargo_toml) = cargo_content.parse::<toml::Value>() {
    +                // Update the version field in [package] section
    +                if let Some(package) = cargo_toml.get_mut("package") {
    +                    if let Some(package_table) = package.as_table_mut() {
    +                        package_table.insert("version".to_string(), toml::Value::String(version.to_string()));
    +
    +                        // Write back the updated Cargo.toml
    +                        if let Ok(updated_cargo) = toml::to_string(&cargo_toml) {
    +                            let _ = fs::write(cargo_toml_path, updated_cargo);
    +                        }
    +                    }
    +                }
    +            }
    +        }
    +
    +        // Update tauri.conf.json with the version
    +        if let Ok(config_content) = fs::read_to_string(config_path) {
    +            // Parse as JSON value
    +            if let Ok(mut config_json) = serde_json::from_str::<serde_json::Value>(&config_content) {
    +                // Update the version field
    +                config_json["version"] = serde_json::Value::String(version.to_string());
    +
    +                // Write back the updated configuration
    +                if let Ok(updated_config) = serde_json::to_string_pretty(&config_json) {
    +                    let _ = fs::write(config_path, updated_config);
    +                }
    +            }
    +        }
    +
    +        println!("cargo:rustc-env=TAURI_APP_VERSION={}", version);
    +    } else {
    +        // Fallback to default version if VERSION file not found
    +        println!("cargo:rustc-env=TAURI_APP_VERSION=0.1.0");
    +    }
    +
    +    // Tell cargo to rerun this script if VERSION file changes
    +    println!("cargo:rerun-if-changed=../../../VERSION");
    +    // Also rerun if Cargo.toml changes to prevent infinite loops
    +    println!("cargo:rerun-if-changed=Cargo.toml");
    +
    +    tauri_build::build();
    +}
    
  • desktop/tauri/src-tauri/Cargo.lock+5752 0 added
  • desktop/tauri/src-tauri/Cargo.toml+54 0 added
    @@ -0,0 +1,54 @@
    +[build-dependencies]
    +serde_json = "1.0"
    +toml = "0.8"
    +
    +[build-dependencies.tauri-build]
    +features = []
    +version = "2"
    +
    +[dependencies]
    +once_cell = "1.19"
    +serde_json = "1.0"
    +tauri-plugin-opener = "2"
    +tauri-plugin-shell = "2"
    +tauri-plugin-single-instance = "2"
    +
    +[dependencies.reqwest]
    +default-features = false
    +features = ["rustls-tls", "json"]
    +version = "0.11"
    +
    +[dependencies.serde]
    +features = ["derive"]
    +version = "1.0"
    +
    +[dependencies.tauri]
    +features = ["tray-icon"]
    +version = "2"
    +
    +[dependencies.tokio]
    +features = ["macros", "rt-multi-thread", "time", "process"]
    +version = "1.37"
    +
    +[features]
    +default = []
    +winjob = ["windows"]
    +
    +[package]
    +authors = ["qbit_manage"]
    +build = "build.rs"
    +description = "Tauri desktop shell for qbit_manage with tray + minimize-to-tray and server lifecycle"
    +edition = "2021"
    +license = "MIT"
    +name = "qbit-manage-desktop"
    +repository = ""
    +rust-version = "1.70"
    +version = "4.5.4-develop52"
    +
    +[target."cfg(unix)".dependencies]
    +libc = "0.2"
    +
    +[target."cfg(windows)".dependencies.windows]
    +features = ["Win32_Foundation", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Security"]
    +optional = true
    +version = "0.58.0"
    
  • desktop/tauri/src-tauri/src/main.rs+497 0 added
    @@ -0,0 +1,497 @@
    +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
    +
    +use once_cell::sync::Lazy;
    +use std::{
    +  process::{Child, Command, Stdio},
    +  sync::{Arc, Mutex},
    +  time::Duration,
    +};
    +use tauri::{
    +  AppHandle,
    +  Manager,
    +  WindowEvent,
    +  Emitter,
    +  menu::{MenuBuilder, MenuItemBuilder},
    +  tray::{TrayIconBuilder, TrayIconEvent, MouseButton, MouseButtonState},
    +  RunEvent,
    +};
    +use tauri_plugin_single_instance::init as single_instance;
    +use tokio::time::sleep;
    +
    +static SERVER_STATE: Lazy<Arc<Mutex<Option<ServerProcess>>>> = Lazy::new(|| Arc::new(Mutex::new(None)));
    +static SHOULD_EXIT: Lazy<Arc<Mutex<bool>>> = Lazy::new(|| Arc::new(Mutex::new(false)));
    +
    +struct ServerProcess {
    +  child: Child,
    +  #[cfg(all(windows, feature = "winjob"))]
    +  job: Option<windows::Win32::Foundation::HANDLE>,
    +}
    +
    +#[derive(Debug, Clone)]
    +struct AppConfig {
    +  port: u16,
    +  base_url: Option<String>,
    +}
    +
    +fn app_config(app: &AppHandle) -> AppConfig {
    +  // simple env-based configuration; could be read from a file later
    +  let port = std::env::var("QBT_PORT").ok().and_then(|v| v.parse().ok()).unwrap_or(8080);
    +  let base_url = std::env::var("QBT_BASE_URL").ok().and_then(|v| {
    +    let s = v.trim().to_string();
    +    if s.is_empty() { None } else { Some(s) }
    +  });
    +
    +  // log for debug
    +  let _ = app.emit("app-config", format!("port={port}, base_url={base_url:?}"));
    +
    +  AppConfig { port, base_url }
    +}
    +
    +fn resolve_server_binary(app: &AppHandle) -> Option<std::path::PathBuf> {
    +  // Priority:
    +  // 1) QBM_SERVER_PATH env override
    +  if let Ok(p) = std::env::var("QBM_SERVER_PATH") {
    +    let candidate = std::path::PathBuf::from(p);
    +    if candidate.exists() {
    +      return Some(candidate);
    +    }
    +  }
    +
    +  // 2) resources/bin/{platform}/qbit-manage*
    +  // 3) resources/ (same dir) qbit-manage*
    +  // 4) current executable dir siblings
    +  let bin_names = if cfg!(target_os = "windows") {
    +    vec!["qbit-manage.exe", "qbit-manage-windows-amd64.exe"]
    +  } else {
    +    vec![
    +      "qbit-manage",
    +      "qbit-manage-linux-amd64",
    +      "qbit-manage-macos-x86_64",
    +      "qbit-manage-macos-arm64"
    +    ]
    +  };
    +
    +  // resource dir (Tauri 2 path resolver)
    +  if let Ok(resource_dir) = app.path().resource_dir() {
    +    for name in &bin_names {
    +      let p = resource_dir.join("bin").join(name);
    +      if p.exists() {
    +        return Some(p);
    +      }
    +    }
    +    for name in &bin_names {
    +      let p = resource_dir.join(name);
    +      if p.exists() {
    +        return Some(p);
    +      }
    +    }
    +  }
    +
    +  // executable dir
    +  if let Ok(exe) = std::env::current_exe() {
    +    if let Some(mut exe_dir) = exe.parent().map(|p| p.to_path_buf()) {
    +      for name in &bin_names {
    +        let p = exe_dir.join(name);
    +        if p.exists() {
    +          return Some(p);
    +        }
    +      }
    +      // try ../Resources
    +      exe_dir = exe_dir.join("..");
    +      for name in &bin_names {
    +        let p = exe_dir.join(name);
    +        if p.exists() {
    +          return Some(p);
    +        }
    +      }
    +    }
    +  }
    +
    +  None
    +}
    +
    +fn stop_server() {
    +  if let Some(server_process) = SERVER_STATE.lock().unwrap().take() {
    +    let mut child = server_process.child;
    +    let pid = child.id();
    +
    +    // On Windows, use immediate process tree termination for faster cleanup
    +    #[cfg(all(windows, feature = "winjob"))]
    +    {
    +      if let Some(job) = server_process.job {
    +        unsafe {
    +          use windows::Win32::System::JobObjects::TerminateJobObject;
    +          let _ = TerminateJobObject(job, 1);
    +        }
    +      } else {
    +        terminate_process_tree_windows(pid);
    +      }
    +    }
    +
    +    #[cfg(all(windows, not(feature = "winjob")))]
    +    {
    +      terminate_process_tree_windows(pid);
    +    }
    +
    +    // On Unix, try graceful shutdown first but with minimal delay
    +    #[cfg(unix)]
    +    {
    +      unsafe { libc::kill(pid as i32, libc::SIGTERM); }
    +      // Very brief wait for graceful shutdown
    +      std::thread::sleep(Duration::from_millis(50));
    +      if child.try_wait().ok().flatten().is_none() {
    +        let _ = child.kill();
    +      }
    +    }
    +
    +    // Brief wait to ensure process termination, but don't wait too long
    +    let start = std::time::Instant::now();
    +    while start.elapsed() < Duration::from_millis(200) {
    +      match child.try_wait() {
    +        Ok(Some(_)) => break, // Process has exited
    +        Ok(None) => {
    +          // Process still running, wait a bit more
    +          std::thread::sleep(Duration::from_millis(10));
    +        }
    +        Err(_) => break, // Error occurred, assume process is gone
    +      }
    +    }
    +
    +    // Force kill if still running
    +    let _ = child.kill();
    +    let _ = child.wait();
    +  }
    +}
    +
    +#[cfg(windows)]
    +fn terminate_process_tree_windows(pid: u32) {
    +  use std::os::windows::process::CommandExt;
    +
    +  // Kill the process tree on Windows using taskkill with hidden window
    +  let _ = std::process::Command::new("taskkill")
    +    .args(&["/F", "/T", "/PID", &pid.to_string()])
    +    .creation_flags(0x08000000) // CREATE_NO_WINDOW
    +    .stdin(std::process::Stdio::null())
    +    .stdout(std::process::Stdio::null())
    +    .stderr(std::process::Stdio::null())
    +    .output();
    +
    +  // Also try direct process termination as backup with hidden window
    +  let _ = std::process::Command::new("taskkill")
    +    .args(&["/F", "/IM", "qbit-manage-windows-amd64.exe"])
    +    .creation_flags(0x08000000) // CREATE_NO_WINDOW
    +    .stdin(std::process::Stdio::null())
    +    .stdout(std::process::Stdio::null())
    +    .stderr(std::process::Stdio::null())
    +    .output();
    +}
    +
    +
    +fn cleanup_and_exit_with_app(app: &AppHandle) {
    +  *SHOULD_EXIT.lock().unwrap() = true;
    +
    +  // Hide window immediately for instant visual feedback
    +  if let Some(win) = app.get_webview_window("main") {
    +    let _ = win.hide();
    +    // Also minimize to ensure it's completely hidden
    +    let _ = win.minimize();
    +  }
    +
    +  // Do cleanup and exit in background thread so UI doesn't freeze
    +  // The tray will disappear when the process exits
    +  std::thread::spawn(|| {
    +    stop_server();
    +    std::process::exit(0);
    +  });
    +}
    +
    +
    +async fn wait_until_ready(port: u16, base_url: &Option<String>, timeout: Duration) -> bool {
    +  let client = reqwest::Client::builder().danger_accept_invalid_certs(true).build().ok();
    +  if client.is_none() {
    +    return false;
    +  }
    +  let client = client.unwrap();
    +
    +  let url = match base_url {
    +    Some(b) if !b.trim().is_empty() => format!("http://127.0.0.1:{}/{}", port, b.trim().trim_start_matches('/')),
    +    _ => format!("http://127.0.0.1:{}", port),
    +  };
    +
    +  let start = std::time::Instant::now();
    +  while start.elapsed() < timeout {
    +    if let Ok(resp) = client.get(&url).send().await {
    +      if resp.status().as_u16() < 500 {
    +        return true;
    +      }
    +    }
    +    sleep(Duration::from_millis(250)).await;
    +  }
    +  false
    +}
    +
    +fn open_app_window(app: &AppHandle) {
    +  if let Some(win) = app.get_webview_window("main") {
    +    let _ = win.show();
    +    let _ = win.set_focus();
    +  }
    +}
    +
    +
    +
    +fn redirect_to_server(app: &AppHandle, cfg: &AppConfig) {
    +  let url = match &cfg.base_url {
    +    Some(b) if !b.trim().is_empty() => format!("http://127.0.0.1:{}/{}", cfg.port, b.trim().trim_start_matches('/')),
    +    _ => format!("http://127.0.0.1:{}", cfg.port),
    +  };
    +  if let Some(win) = app.get_webview_window("main") {
    +    let _ = win.eval(&format!("window.location.replace('{}')", url));
    +  }
    +}
    +
    +fn start_server(app: &AppHandle, cfg: &AppConfig) -> tauri::Result<()> {
    +  let mut guard = SERVER_STATE.lock().unwrap();
    +
    +  // Check if server is already running and clean up if needed
    +  if let Some(server_process) = guard.as_mut() {
    +    match server_process.child.try_wait() {
    +      Ok(Some(_)) => {
    +        // Process has exited, clean up the old entry
    +        *guard = None;
    +      }
    +      Ok(None) => {
    +        // Process is still running, don't start another
    +        return Ok(());
    +      }
    +      Err(_) => {
    +        // Error checking process status, assume it's dead and clean up
    +        *guard = None;
    +      }
    +    }
    +  }
    +
    +  let server_path = resolve_server_binary(app).unwrap_or_else(|| {
    +    // fall back to expecting binary on PATH
    +    if cfg!(target_os = "windows") {
    +      std::path::PathBuf::from("qbit-manage.exe")
    +    } else {
    +      std::path::PathBuf::from("qbit-manage")
    +    }
    +  });
    +
    +  // Create Windows Job Object if feature is enabled
    +  #[cfg(all(windows, feature = "winjob"))]
    +  let job = unsafe {
    +    use windows::Win32::System::JobObjects::*;
    +    use windows::Win32::Foundation::*;
    +
    +    let job = CreateJobObjectW(None, None).ok();
    +    if let Some(job) = job {
    +      // Configure job to kill all processes when the job handle is closed
    +      // and prevent processes from breaking out of the job
    +      let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
    +      info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
    +        | JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION;
    +
    +      let _ = SetInformationJobObject(
    +        job,
    +        JobObjectExtendedLimitInformation,
    +        &info as *const _ as *const _,
    +        std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
    +      );
    +
    +      Some(job)
    +    } else {
    +      None
    +    }
    +  };
    +
    +  // build command
    +  let mut cmd = Command::new(server_path);
    +  cmd.env("QBT_WEB_SERVER", "true")
    +    .env("QBT_PORT", cfg.port.to_string())
    +    .env("QBT_DESKTOP_APP", "true")  // Indicate running in desktop app to prevent browser opening
    +    .stdin(Stdio::null())
    +    .stdout(Stdio::piped())
    +    .stderr(Stdio::piped());
    +
    +  if let Some(base) = &cfg.base_url {
    +    cmd.env("QBT_BASE_URL", base);
    +  }
    +
    +  // On Windows, make sure process does not open a console window
    +  #[cfg(target_os = "windows")]
    +  {
    +    use std::os::windows::process::CommandExt;
    +    cmd.creation_flags(0x08000000);
    +  }
    +
    +  let child = cmd.spawn()?;
    +
    +  // Add process to job object on Windows
    +  #[cfg(all(windows, feature = "winjob"))]
    +  if let Some(job) = job {
    +    unsafe {
    +      use windows::Win32::System::JobObjects::AssignProcessToJobObject;
    +      use windows::Win32::Foundation::HANDLE;
    +      use windows::Win32::System::Threading::OpenProcess;
    +      use windows::Win32::System::Threading::PROCESS_ALL_ACCESS;
    +
    +      // Get proper process handle from PID
    +      let process_handle = OpenProcess(PROCESS_ALL_ACCESS, false, child.id());
    +      if let Ok(handle) = process_handle {
    +        let result = AssignProcessToJobObject(job, handle);
    +        if result.is_err() {
    +          eprintln!("Failed to assign process to job object: {:?}", result);
    +        }
    +        // Don't close the handle here as it's managed by the system
    +      } else {
    +        eprintln!("Failed to open process handle for PID: {}", child.id());
    +      }
    +    }
    +  }
    +
    +  *guard = Some(ServerProcess {
    +    child,
    +    #[cfg(all(windows, feature = "winjob"))]
    +    job,
    +  });
    +  Ok(())
    +}
    +
    +
    +
    +pub fn run() {
    +  tauri::Builder::default()
    +    // Single instance should be first (per docs)
    +    .plugin(single_instance(|app, _argv, _cwd| {
    +      if let Some(win) = app.get_webview_window("main") {
    +        let _ = win.show();
    +        let _ = win.set_focus();
    +      }
    +    }))
    +    .plugin(tauri_plugin_shell::init())
    +    .plugin(tauri_plugin_opener::init())
    +    .setup(|app| {
    +      let app_handle = app.handle().clone();
    +
    +      // Build tray menu (v2 API)
    +      let open_item = MenuItemBuilder::with_id("open", "Open").build(app)?;
    +      let restart_item = MenuItemBuilder::with_id("restart", "Restart Server").build(app)?;
    +      let quit_item = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
    +      let tray_menu = MenuBuilder::new(app)
    +        .items(&[&open_item, &restart_item, &quit_item])
    +        .build()?;
    +
    +      // Create tray icon with explicit icon
    +      let _tray_icon = TrayIconBuilder::new()
    +        .menu(&tray_menu)
    +        .icon(app.default_window_icon().unwrap().clone())
    +        .on_tray_icon_event(|tray, event| {
    +          if let TrayIconEvent::Click {
    +            button: MouseButton::Left,
    +            button_state: MouseButtonState::Up,
    +            ..
    +          } = event {
    +            let app = tray.app_handle();
    +            if let Some(win) = app.get_webview_window("main") {
    +              let _ = win.show();
    +              let _ = win.set_focus();
    +            }
    +          }
    +        })
    +        .on_menu_event(move |app, event| {
    +          match event.id().as_ref() {
    +            "open" => {
    +              open_app_window(app);
    +            }
    +            "restart" => {
    +              // Stop server first, then start it again with minimal delay
    +              stop_server();
    +
    +              let cfg = app_config(app);
    +              let app_handle_restart = app.clone();
    +
    +              // Start server in a separate thread to avoid blocking the UI
    +              std::thread::spawn(move || {
    +                // Brief delay to ensure process cleanup
    +                std::thread::sleep(Duration::from_millis(200));
    +                if start_server(&app_handle_restart, &cfg).is_ok() {
    +                  tauri::async_runtime::spawn(async move {
    +                    if wait_until_ready(cfg.port, &cfg.base_url, Duration::from_secs(15)).await {
    +                      redirect_to_server(&app_handle_restart, &cfg);
    +                    }
    +                  });
    +                }
    +              });
    +            }
    +            "quit" => {
    +              cleanup_and_exit_with_app(app);
    +            }
    +            _ => {}
    +          }
    +        })
    +        .build(app)?;
    +
    +      // Intercept window close to hide instead (minimize to tray)
    +      if let Some(win) = app.get_webview_window("main") {
    +        let app_handle2 = app_handle.clone();
    +        win.on_window_event(move |e| {
    +          if let WindowEvent::CloseRequested { api, .. } = e {
    +            api.prevent_close();
    +            if let Some(w) = app_handle2.get_webview_window("main") {
    +              let _ = w.hide();
    +            }
    +          }
    +        });
    +      }
    +
    +      // Show the window immediately with loading page
    +      open_app_window(&app_handle);
    +
    +      // Start server automatically and redirect when ready
    +      let cfg = app_config(&app_handle);
    +      let app_handle3 = app_handle.clone();
    +      tauri::async_runtime::spawn(async move {
    +        let _ = start_server(&app_handle3, &cfg);
    +        if wait_until_ready(cfg.port, &cfg.base_url, Duration::from_secs(20)).await {
    +          redirect_to_server(&app_handle3, &cfg);
    +        }
    +      });
    +
    +      Ok(())
    +    })
    +    .build(tauri::generate_context!())
    +    .expect("error while building tauri application")
    +    .run(move |_app, event| {
    +      match event {
    +        RunEvent::ExitRequested { .. } => {
    +          // Check if we should exit cleanly
    +          if *SHOULD_EXIT.lock().unwrap() {
    +            // Already in exit process, allow immediate exit
    +            return;
    +          }
    +
    +          // Set exit flag immediately for responsive UI
    +          *SHOULD_EXIT.lock().unwrap() = true;
    +
    +          // Do cleanup in background thread to avoid UI freeze
    +          std::thread::spawn(|| {
    +            stop_server();
    +            std::process::exit(0);
    +          });
    +        }
    +        RunEvent::Exit => {
    +          // Final cleanup on actual exit
    +          if !*SHOULD_EXIT.lock().unwrap() {
    +            stop_server();
    +          }
    +        }
    +        _ => {}
    +      }
    +    });
    +}
    +
    +fn main() {
    +  run();
    +}
    
  • desktop/tauri/src-tauri/tauri.conf.json+72 0 added
    @@ -0,0 +1,72 @@
    +{
    +    "app": {
    +        "security": {
    +            "csp": null
    +        },
    +        "windows": [
    +            {
    +                "decorations": true,
    +                "fullscreen": false,
    +                "height": 800,
    +                "label": "main",
    +                "minHeight": 600,
    +                "minWidth": 900,
    +                "resizable": true,
    +                "title": "qBit Manage",
    +                "visible": false,
    +                "width": 1100
    +            }
    +        ],
    +        "withGlobalTauri": true
    +    },
    +    "build": {
    +        "beforeBuildCommand": "",
    +        "beforeDevCommand": "",
    +        "devUrl": "http://localhost:8080",
    +        "frontendDist": "../src"
    +    },
    +    "bundle": {
    +        "active": true,
    +        "category": "Utility",
    +        "icon": [
    +            "../../../icons/qbm_logo.icns",
    +            "../../../icons/qbm_logo.ico",
    +            "../../../icons/qbm_logo.png"
    +        ],
    +        "linux": {
    +            "deb": {
    +                "depends": [
    +                    "libgtk-3-0",
    +                    "libayatana-appindicator3-1",
    +                    "libwebkit2gtk-4.1-0"
    +                ]
    +            }
    +        },
    +        "macOS": {
    +            "frameworks": [],
    +            "minimumSystemVersion": "10.13"
    +        },
    +        "resources": [
    +            "bin/*"
    +        ],
    +        "targets": [
    +            "deb",
    +            "nsis",
    +            "app",
    +            "dmg"
    +        ],
    +        "windows": {
    +            "certificateThumbprint": null,
    +            "digestAlgorithm": "sha256",
    +            "nsis": {
    +                "displayLanguageSelector": true,
    +                "installMode": "currentUser",
    +                "installerIcon": "../../../icons/qbm_logo.ico"
    +            },
    +            "timestampUrl": ""
    +        }
    +    },
    +    "identifier": "com.qbitmanage.desktop",
    +    "productName": "qBit Manage",
    +    "version": "4.5.4-develop52"
    +}
    
  • docs/Commands.md+1 1 modified
    @@ -8,7 +8,7 @@
     |              `-r` or`--run`             |             QBT_RUN             |           N/A         | Run without the scheduler. Script will exit after completion.                                                                                                                                                                                                                                                                                                                                                                                              |       False       |
    
     |          `-sch` or `--schedule`         |           QBT_SCHEDULE          |           N/A         | Schedule to run every x minutes or choose customize schedule via [cron](https://crontab.guru/examples.html). (Default set to 1440 (1 day))                                                                                                                                                                                                                                                                                                                 |        1440       |
    
     |        `-sd` or `--startup-delay`       |        QBT_STARTUP_DELAY        |           N/A         | Set delay in seconds on the first run of a schedule (Default set to 0)                                                                                                                                                                                                                                                                                                                                                                                     |         0         |
    
    -|  `-c CONFIG` or `--config-file CONFIG`  |            QBT_CONFIG           |           N/A         | This is used if you want to use a different name for your config.yml. `Example: tv.yml`. Supports wildcards to use multiple configs. `Example: config-*.yml`                                                                                                                                                                                                                                                                                               |     config.yml    |
    
    +|  `-c CONFIG` or `--config-file CONFIG`  |            QBT_CONFIG           |           N/A         | Override the default config file location. By default, qbit_manage looks for `config.yml` in platform-specific directories (see [Config-Setup](Config-Setup.md) for details). Use this to specify a custom path or filename. `Example: tv.yml`. Supports wildcards to use multiple configs. `Example: config-*.yml`                                                                                                                                                                    | Platform-specific |
    
     | `-lf LOGFILE,` or `--log-file LOGFILE,` |           QBT_LOGFILE           |           N/A         | This is used if you want to use a different name for your log file. `Example: tv.log`                                                                                                                                                                                                                                                                                                                                                                      |    activity.log   |
    
     |           `-re` or `--recheck`          |           QBT_RECHECK           |         recheck       | Recheck paused torrents sorted by lowest size. Resume if Completed.                                                                                                                                                                                                                                                                                                                                                                                        |       False       |
    
     |         `-cu` or `--cat-update`         |          QBT_CAT_UPDATE         |       cat_update      | Use this if you would like to update your categories or move from one category to another.                                                                                                                                                                                                                                                                                                                                                                 |       False       |
    
    
  • docs/Config-Setup.md+14 2 modified
    @@ -3,7 +3,19 @@
     
    
     The script utilizes a YAML config file to load information to connect to the various APIs you can connect with. Alternatively, you can configure qBit Manage using the [Web UI](Web-UI.md), which requires the [Web API](Web-API.md) to be enabled.
    
     
    
    -By default, the script looks at `/config/config.yml` when running locally or `/app/config.yml` in docker for the Configuration File unless otherwise specified.
    
    +## Default Configuration File Locations
    
    +
    
    +The script looks for the configuration file in different locations depending on your platform:
    
    +
    
    +### Local Installation (Platform-specific)
    
    +- **Windows**: `%APPDATA%\qbit-manage\config.yml` (typically `C:\Users\<username>\AppData\Roaming\qbit-manage\config.yml`)
    
    +- **macOS**: `~/Library/Application Support/qbit-manage/config.yml`
    
    +- **Linux/Unix**: `~/.config/qbit-manage/config.yml` (or `$XDG_CONFIG_HOME/qbit-manage/config.yml` if XDG_CONFIG_HOME is set)
    
    +
    
    +### Docker Installation
    
    +- `/app/config.yml` (inside the container)
    
    +
    
    +You can override the default location by using the `--config-file` or `-c` command line option to specify a custom path.
    
     
    
     A template Configuration File can be found in the repo [config/config.yml.sample](https://github.com/StuffAnThings/qbit_manage/blob/master/config/config.yml.sample).
    
     
    
    @@ -178,7 +190,7 @@ Control how torrent share limits are set depending on the priority of your group
     | `max_last_active`                 | Will delete the torrent if cleanup variable is set and if torrent has been inactive longer than x minutes. See Some examples of [valid time expressions](https://github.com/onegreyonewhite/pytimeparse2?tab=readme-ov-file#pytimeparse2-time-expression-parser) 32m, 2h32m, 3d2h32m, 1w3d2h32m                                                                                                                                                                                                                                                                           | -1                    | str       | <center>❌</center> |
    
     | `min_seeding_time`            | Will prevent torrent deletion by the cleanup variable if the torrent has reached the `max_ratio` limit you have set.  If the torrent has not yet reached this minimum seeding time, it will change the share limits back to no limits and resume the torrent to continue seeding. See Some examples of [valid time expressions](https://github.com/onegreyonewhite/pytimeparse2?tab=readme-ov-file#pytimeparse2-time-expression-parser) 32m, 2h32m, 3d2h32m, 1w3d2h32m. **MANDATORY: Must use also `max_ratio` with a value greater than `0` (default: `-1`) for this to work.** If you use both `min_seed_time` and `max_seed_time`, then you must set the value of `max_seed_time` to a number greater than `min_seed_time`. | 0                    | str       | <center>❌</center> |
    
     | `min_last_active`                 | Will prevent torrent deletion by cleanup variable if torrent has been active within the last x minutes. If the torrent has been active within the last x minutes, it will change the share limits back to no limits and resume the torrent to continue seeding. See Some examples of [valid time expressions](https://github.com/onegreyonewhite/pytimeparse2?tab=readme-ov-file#pytimeparse2-time-expression-parser) 32m, 2h32m, 3d2h32m, 1w3d2h32m                                                                                                                                                                                                                                                                           | 0                    | str       | <center>❌</center> |
    
    -| `limit_upload_speed`          | Will limit the upload speed KiB/s (KiloBytes/second) (`-1` : No Limit)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | -1                   | int       | <center>❌</center> |
    
    +| `limit_upload_speed`          | Will limit the upload speed KiB/s (KiloBytes/second) (`-1` : No Limit)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | 0                   | int       | <center>❌</center> |
    
     | `upload_speed_on_limit_reached` | When cleanup is `false` and a torrent reaches its share limits, throttle per‑torrent upload to this value (KiB/s). Use `-1` for unlimited. QBM will also clear the share limits to prevent qBittorrent from pausing, allowing continued seeding at the throttled rate.                                                                                                                                                                                                                                                                                                                                                                                                                                                           | -1                   | int       | <center>❌</center> |
    
     | `enable_group_upload_speed`   | Upload speed limits are applied at the group level. This will take `limit_upload_speed` defined and divide it equally among the number of torrents in the group.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | False                | bool      | <center>❌</center> |
    
     | `reset_upload_speed_on_unmet_minimums` | Controls whether upload speed limits are reset when minimum conditions are not met. When `true` (default), upload speed limits will be reset to unlimited if minimum seeding time, number of seeds, or last active time conditions are not satisfied. When `false`, existing upload speed limits will be preserved for bandwidth management purposes.                                                                                                                                                                                                                                                                                                                                                                                | True                 | bool      | <center>❌</center> |
    
    
  • docs/Home.md+15 3 modified
    @@ -4,9 +4,21 @@ This wiki should tell you everything you need to know about the script to get it
     
    
     ## Getting Started
    
     
    
    -1. Install qbit_manage either by installing Python3.9.0+ on the localhost and following the [Local Installation](https://github.com/StuffAnThings/qbit_manage/wiki/Local-Installations) Guide or by installing Docker and following the [Docker Installation](https://github.com/StuffAnThings/qbit_manage/wiki/Docker-Installation) Guide or the [unRAID Installation](https://github.com/StuffAnThings/qbit_manage/wiki/Unraid-Installation) Guide.<br>
    
    -1. Once installed, you have to [set up your Configuration](https://github.com/StuffAnThings/qbit_manage/wiki/Config-Setup) by create a [Configuration File](https://github.com/StuffAnThings/qbit_manage/blob/master/config/config.yml.sample) filled with all your values to connect to your qBittorrent instance.
    
    -1. Please refer to the list of [Commands](https://github.com/StuffAnThings/qbit_manage/wiki/Commands) that can be used with this tool.
    
    +1. **Choose your installation method:**
    
    +   - **Desktop App** (Recommended): Download and install the GUI application for [Windows, macOS, or Linux](Installation.md#desktop-app-installation)
    
    +   - **Standalone Binary**: Download the command-line executable for [Windows, macOS, or Linux](Installation.md#standalone-binary-installation)
    
    +   - **Docker**: Follow the [Docker Installation](Docker-Installation) guide for containerized environments
    
    +   - **Python/Source**: Install from [PyPI or source code](Local-Installations) for development
    
    +   - **unRAID**: Follow the [unRAID Installation](Unraid-Installation) guide for unRAID systems
    
    +
    
    +2. **Configure qbit_manage:**
    
    +   - Desktop app users: Configuration is handled through the GUI
    
    +   - Command-line users: [Set up your Configuration](Config-Setup) by creating a [Configuration File](https://github.com/StuffAnThings/qbit_manage/blob/master/config/config.yml.sample) with your qBittorrent connection details
    
    +
    
    +3. **Start using qbit_manage:**
    
    +   - Review the [Commands](Commands) documentation to understand available features
    
    +   - Try the [Web UI](Web-UI) for an intuitive configuration experience
    
    +   - Use the [Web API](Web-API) for automation and integration
    
     
    
     ## Support
    
     
    
    
  • docs/Installation.md+119 6 modified
    @@ -1,7 +1,120 @@
    -# Installation Table of Contents
    
    +# Installation Options
    
     
    
    -- [Installation](https://github.com/StuffAnThings/qbit_manage/wiki/Installation)
    
    -  - [unRAID Installation](https://github.com/StuffAnThings/qbit_manage/wiki/Unraid-Installation)
    
    -  - [Local Installation](https://github.com/StuffAnThings/qbit_manage/wiki/Local-Installations)
    
    -  - [NIX Installation](https://github.com/StuffAnThings/qbit_manage/wiki/Nix-Installation)
    
    -  - [Docker Installation](https://github.com/StuffAnThings/qbit_manage/wiki/Docker-Installation)
    
    +qbit_manage offers multiple installation methods to suit different use cases:
    
    +
    
    +## Installation Methods
    
    +
    
    +### 1. Desktop App (Recommended for most users)
    
    +- **Windows**: Download and run the `.exe` installer
    
    +- **macOS**: Download and install the `.dmg` package
    
    +- **Linux**: Download and install the `.deb` package
    
    +
    
    +The desktop app provides a graphical interface and automatically handles configuration file setup.
    
    +
    
    +### 2. Standalone Binary (Command-line)
    
    +- **Windows**: `qbit-manage-windows-amd64.exe`
    
    +- **macOS**: `qbit-manage-macos-arm64` (Apple Silicon) or `qbit-manage-macos-x86_64` (Intel)
    
    +- **Linux**: `qbit-manage-linux-amd64`
    
    +
    
    +Perfect for server environments, automation, or users who prefer command-line tools.
    
    +
    
    +### 3. Docker Container
    
    +- Multi-architecture support (amd64, arm64, arm/v7)
    
    +- Ideal for containerized environments and NAS systems
    
    +
    
    +### 4. Python Installation
    
    +- Install from source or PyPI
    
    +- For developers or users who want to modify the code
    
    +
    
    +## Detailed Installation Guides
    
    +
    
    +- [Desktop App Installation](#desktop-app-installation)
    
    +- [Standalone Binary Installation](#standalone-binary-installation)
    
    +- [Docker Installation](Docker-Installation)
    
    +- [Python/Source Installation](Local-Installations)
    
    +- [unRAID Installation](Unraid-Installation)
    
    +- [NIX Installation](Nix-Installation)
    
    +
    
    +## Desktop App Installation
    
    +
    
    +### Windows
    
    +1. Download `qbit-manage-*-desktop-installer-setup.exe` from the [releases page](https://github.com/StuffAnThings/qbit_manage/releases)
    
    +2. Run the installer and follow the setup wizard
    
    +3. Launch qbit_manage from the Start Menu or desktop shortcut
    
    +4. The app will automatically create the configuration directory and files
    
    +
    
    +### macOS
    
    +1. Download `qbit-manage-*-desktop-installer.dmg` from the [releases page](https://github.com/StuffAnThings/qbit_manage/releases)
    
    +2. Open the DMG file and drag qbit_manage to your Applications folder
    
    +3. Launch qbit_manage from Applications (you may need to allow it in System Preferences > Security & Privacy)
    
    +4. The app will automatically create the configuration directory and files
    
    +
    
    +### Linux
    
    +1. Download `qbit-manage-*-desktop-installer.deb` from the [releases page](https://github.com/StuffAnThings/qbit_manage/releases)
    
    +2. Install using your package manager:
    
    +   ```bash
    
    +   sudo dpkg -i qbit-manage-*-desktop-installer.deb
    
    +   sudo apt-get install -f  # Fix any dependency issues
    
    +   ```
    
    +3. Launch qbit_manage from your applications menu or run `qbit-manage` in terminal
    
    +4. The app will automatically create the configuration directory and files
    
    +
    
    +## Standalone Binary Installation
    
    +
    
    +### Windows
    
    +1. Download `qbit-manage-windows-amd64.exe` from the [releases page](https://github.com/StuffAnThings/qbit_manage/releases)
    
    +2. Place the executable in a directory of your choice (e.g., `C:\Program Files\qbit-manage\`)
    
    +3. Add the directory to your PATH environment variable (optional)
    
    +4. Run from Command Prompt or PowerShell:
    
    +   ```cmd
    
    +   qbit-manage-windows-amd64.exe --help
    
    +   ```
    
    +
    
    +### macOS
    
    +1. Download the appropriate binary from the [releases page](https://github.com/StuffAnThings/qbit_manage/releases):
    
    +   - `qbit-manage-macos-arm64` for Apple Silicon Macs (M1, M2, M3, etc.)
    
    +   - `qbit-manage-macos-x86_64` for Intel Macs
    
    +2. Make the binary executable:
    
    +   ```bash
    
    +   chmod +x qbit-manage-macos-*
    
    +   ```
    
    +3. Move to a directory in your PATH (optional):
    
    +   ```bash
    
    +   sudo mv qbit-manage-macos-* /usr/local/bin/qbit-manage
    
    +   ```
    
    +4. Run the binary:
    
    +   ```bash
    
    +   ./qbit-manage-macos-* --help
    
    +   ```
    
    +
    
    +### Linux
    
    +1. Download `qbit-manage-linux-amd64` from the [releases page](https://github.com/StuffAnThings/qbit_manage/releases)
    
    +2. Make the binary executable:
    
    +   ```bash
    
    +   chmod +x qbit-manage-linux-amd64
    
    +   ```
    
    +3. Move to a directory in your PATH (optional):
    
    +   ```bash
    
    +   sudo mv qbit-manage-linux-amd64 /usr/local/bin/qbit-manage
    
    +   ```
    
    +4. Run the binary:
    
    +   ```bash
    
    +   ./qbit-manage-linux-amd64 --help
    
    +   ```
    
    +
    
    +## Quick Reference: Default Configuration File Locations
    
    +
    
    +### Desktop App & Standalone Binary
    
    +- **Windows**: `%APPDATA%\qbit-manage\config.yml`
    
    +- **macOS**: `~/Library/Application Support/qbit-manage/config.yml`
    
    +- **Linux**: `~/.config/qbit-manage/config.yml`
    
    +
    
    +### Docker Installation
    
    +- **Container Path**: `/app/config.yml`
    
    +- **Host Mount**: Typically mounted from `/path/to/your/config:/config`
    
    +
    
    +### Custom Location
    
    +You can override the default location using the `--config-file` or `-c` command line option:
    
    +```bash
    
    +qbit-manage --config-file /path/to/your/config.yml
    
    +```
    
    
  • docs/Local-Installations.md+137 22 modified
    @@ -1,64 +1,179 @@
    -# Local Installations
    
    +# Python/Source Installation
    
     
    
    -Below is a simple high level set of instructions for cloning the repository and executing qbit_manage
    
    +This guide covers installing qbit_manage from source code or PyPI for development purposes or when you need the latest features.
    
     
    
    -* Requires `python 3.9.0`. Dependencies must be installed by running:
    
    +**Note**: For most users, we recommend using the [Desktop App or Standalone Binary](Installation.md) instead, as they're easier to install and use.
    
     
    
    -Navigate to the directory you'd liked to clone the repo into
    
    +## Prerequisites
    
     
    
    -Clone the repo
    
    +* Python 3.9.0 or higher
    
    +* pip (Python package installer)
    
    +* Git (for source installation)
    
    +
    
    +## Installation Methods
    
    +
    
    +### Method 1: Install from PyPI (Recommended)
    
     
    
     ```bash
    
    -git clone https://github.com/StuffAnThings/qbit_manage
    
    +pip install qbit-manage
    
     ```
    
     
    
    -Install requirements
    
    +### Method 2: Install from Source
    
    +
    
    +Navigate to the directory where you'd like to clone the repository:
    
     
    
     ```bash
    
    +# Clone the repository
    
    +git clone https://github.com/StuffAnThings/qbit_manage
    
    +cd qbit_manage
    
    +
    
    +# Install the package
    
     pip install .
    
     ```
    
     
    
    -If there are issues installing dependencies try:
    
    +If you encounter dependency issues, try:
    
     
    
     ```bash
    
     pip install . --ignore-installed
    
     ```
    
     
    
    +### Method 3: Development Installation
    
    +
    
    +For development or to get the latest unreleased features:
    
    +
    
    +```bash
    
    +# Clone the repository
    
    +git clone https://github.com/StuffAnThings/qbit_manage
    
    +cd qbit_manage
    
    +
    
    +# Install in development mode
    
    +pip install -e .
    
    +```
    
    +
    
    +## Configuration File Setup
    
    +
    
    +After installation, qbit_manage will look for configuration files in platform-specific locations:
    
    +
    
    +- **Windows**: `%APPDATA%\qbit-manage\config.yml`
    
    +- **macOS**: `~/Library/Application Support/qbit-manage/config.yml`
    
    +- **Linux/Unix**: `~/.config/qbit-manage/config.yml`
    
    +
    
    +### Setting up the Configuration
    
    +
    
    +1. Create the configuration directory:
    
    +   ```bash
    
    +   # Windows (PowerShell)
    
    +   New-Item -ItemType Directory -Force -Path "$env:APPDATA\qbit-manage"
    
    +
    
    +   # macOS/Linux
    
    +   mkdir -p ~/.config/qbit-manage  # Linux
    
    +   mkdir -p ~/Library/Application\ Support/qbit-manage  # macOS
    
    +   ```
    
    +
    
    +2. Copy the sample configuration:
    
    +   ```bash
    
    +   # From the cloned repository
    
    +   cp config/config.yml.sample ~/.config/qbit-manage/config.yml  # Linux
    
    +   cp config/config.yml.sample ~/Library/Application\ Support/qbit-manage/config.yml  # macOS
    
    +   copy config\config.yml.sample "%APPDATA%\qbit-manage\config.yml"  # Windows
    
    +   ```
    
    +
    
    +3. Edit the configuration file as outlined in the [Config-Setup](Config-Setup.md) guide.
    
    +
    
    +**Alternative**: You can place the config file anywhere and specify its location using the `--config-file` option.
    
    +
    
     ## Usage
    
     
    
    -To run the script in an interactive terminal run:
    
    +### Running the Script
    
     
    
    -* copy the `config.yml.sample` file to `config.yml`
    
    -* Fill out the config file as outlined in the [Config-Setup](https://github.com/StuffAnThings/qbit_manage/wiki/Config-Setup)
    
    +### Basic Usage
    
     
    
    -Run the script `-h` to see a list of commands
    
    +Run the script with `-h` to see all available commands:
    
     
    
     ```bash
    
    +qbit-manage -h
    
    +# or if installed from source
    
     python qbit_manage.py -h
    
     ```
    
     
    
    -### Web API and Web UI
    
    +### Common Usage Examples
    
     
    
    -To run the Web API and Web UI, use the `--web-server` flag:
    
    +**Run with default configuration:**
    
    +```bash
    
    +qbit-manage
    
    +```
    
     
    
    +**Run specific commands:**
    
     ```bash
    
    -python qbit_manage.py --web-server
    
    +qbit-manage --cat-update --tag-update
    
     ```
    
     
    
    -You can then access the Web UI in your browser, typically at `http://localhost:8080`.
    
    +**Run with Web API and Web UI:**
    
    +```bash
    
    +qbit-manage --web-server
    
    +```
    
    +You can then access the Web UI in your browser at `http://localhost:8080`.
    
     
    
    -### Config
    
    +**Use custom configuration file:**
    
    +```bash
    
    +qbit-manage --config-file /path/to/your/config.yml
    
    +```
    
     
    
    -To choose the location of the YAML config file
    
    +**Run in dry-run mode (preview changes without applying them):**
    
    +```bash
    
    +qbit-manage --dry-run --cat-update --tag-update
    
    +```
    
     
    
    +**Run on a schedule:**
    
     ```bash
    
    -python qbit_manage.py --config-file <path_to_config>
    
    +qbit-manage --schedule 1440  # Run every 24 hours (1440 minutes)
    
     ```
    
     
    
    -### Log
    
    +### Command Line Options
    
    +
    
    +| Option | Description |
    
    +|--------|-------------|
    
    +| `--config-file`, `-c` | Specify custom config file location |
    
    +| `--log-file`, `-lf` | Specify custom log file location |
    
    +| `--web-server`, `-ws` | Start the web server for API and UI |
    
    +| `--port`, `-p` | Web server port (default: 8080) |
    
    +| `--dry-run`, `-dr` | Preview changes without applying them |
    
    +| `--schedule`, `-sch` | Run on a schedule (minutes) |
    
    +| `--run`, `-r` | Run once and exit (no scheduler) |
    
    +
    
    +For a complete list of commands and options, see the [Commands](Commands.md) documentation.
    
    +
    
    +### Virtual Environment (Recommended)
    
     
    
    -To choose the location of the Log File
    
    +For Python installations, it's recommended to use a virtual environment:
    
    +
    
    +```bash
    
    +# Create virtual environment
    
    +python -m venv qbit-manage-env
    
    +
    
    +# Activate virtual environment
    
    +# Linux/macOS:
    
    +source qbit-manage-env/bin/activate
    
    +# Windows:
    
    +qbit-manage-env\Scripts\activate
    
    +
    
    +# Install qbit-manage
    
    +pip install qbit-manage
    
    +
    
    +# Run qbit-manage
    
    +qbit-manage --help
    
    +```
    
    +
    
    +### Updating
    
    +
    
    +**PyPI installation:**
    
    +```bash
    
    +pip install --upgrade qbit-manage
    
    +```
    
     
    
    +**Source installation:**
    
     ```bash
    
    -python qbit_manage.py --log-file <path_to_log>
    
    +cd qbit_manage
    
    +git pull
    
    +pip install . --upgrade
    
     ```
    
    
  • docs/Nix-Installation.md+19 2 modified
    @@ -20,6 +20,23 @@ pip install .
     
    
     * Create Config
    
     
    
    +**Note:** If using the standalone desktop app, it will automatically create the necessary directories and config files. For command-line usage, you have these options:
    
    +
    
    +**Option 1 - Use default system location:**
    
    +
    
    +```bash
    
    +# Create the config directory
    
    +mkdir -p ~/.config/qbit-manage
    
    +
    
    +# Copy the sample config
    
    +cp config/config.yml.sample ~/.config/qbit-manage/config.yml
    
    +
    
    +# Edit the config file
    
    +nano ~/.config/qbit-manage/config.yml
    
    +```
    
    +
    
    +**Option 2 - Keep in project directory:**
    
    +
    
     ```bash
    
     cd config
    
     cp config.yml.sample config.yml
    
    @@ -120,12 +137,12 @@ chmod +x qbm-update.sh
     
    
     To run qBit Manage with the Web API and Web UI enabled, execute the `qbit_manage.py` script with the `--web-server` flag:
    
     
    
    +**If using the default config location (`~/.config/qbit-manage/config.yml`):**
    
     ```bash
    
     python qbit_manage.py --web-server
    
     ```
    
     
    
    -You can also specify a configuration file and log file:
    
    -
    
    +**If using a custom config location:**
    
     ```bash
    
     python qbit_manage.py --web-server --config-file /path/to/your/config.yml --log-file /path/to/your/activity.log
    
     ```
    
    
  • docs/_Sidebar.md+3 2 modified
    @@ -1,9 +1,10 @@
     - [Home](Home)
    
         - [Installation](Installation)
    
    +        - [Desktop App & Binary Installation](Installation)
    
    +        - [Docker Installation](Docker-Installation)
    
    +        - [Python/Source Installation](Local-Installations)
    
             - [unRAID Installation](Unraid-Installation)
    
    -        - [Local Installation](Local-Installations)
    
             - [NIX Installation](Nix-Installation)
    
    -        - [Docker Installation](Docker-Installation)
    
             - [V4 Migration Guide](v4-Migration-Guide)
    
         - [Config Setup](Config-Setup)
    
             - [Sample Config File](Config-Setup#config-file)
    
    
  • .github/workflows/ci.yml+2 2 modified
    @@ -14,7 +14,7 @@ jobs:
     
         steps:
           - name: Checkout code
    -        uses: actions/checkout@v4
    +        uses: actions/checkout@v5
     
           - name: Set up Python
             uses: actions/setup-python@v5
    @@ -40,7 +40,7 @@ jobs:
       ruff:
         runs-on: ubuntu-latest
         steps:
    -      - uses: actions/checkout@v4
    +      - uses: actions/checkout@v5
           - uses: astral-sh/ruff-action@v3
             with:
               token: ${{ secrets.GITHUB_TOKEN }}
    
  • .github/workflows/develop.yml+357 1 modified
    @@ -4,8 +4,364 @@ on:
       push:
         branches: [ develop ]
     
    +concurrency:
    +  group: ${{ github.workflow }}-${{ github.ref }}
    +  cancel-in-progress: true
    +
     jobs:
     
    +  build-binaries:
    +    name: Build Standalone Binaries (${{ matrix.os }})
    +    permissions:
    +      contents: read
    +    runs-on: ${{ matrix.os }}
    +    strategy:
    +      fail-fast: false
    +      matrix:
    +        include:
    +          - os: ubuntu-latest
    +            python-version: '3.12'
    +          - os: windows-latest
    +            python-version: '3.12'
    +          - os: 'macos-latest' # for Arm based macs (M1 and above).
    +            python-version: '3.12'
    +            arch: arm64
    +          - os: 'macos-13' # for Intel based macs.
    +            python-version: '3.12'
    +            arch: x86_64
    +    env:
    +      APP_NAME: qbit-manage
    +      ENTRY: qbit_manage.py
    +    steps:
    +      - name: Check Out Repo
    +        uses: actions/checkout@v5
    +        with:
    +          ref: develop
    +
    +      - name: Setup Python
    +        uses: actions/setup-python@v5
    +        with:
    +          python-version: ${{ matrix.python-version }}
    +          cache: 'pip'
    +          cache-dependency-path: |
    +            pyproject.toml
    +            uv.lock
    +
    +      - name: Upgrade Pip and install project + PyInstaller
    +        run: |
    +          python -m pip install --upgrade pip wheel
    +          python -m pip install . pyinstaller
    +
    +      - name: Compute add-data separator
    +        id: sep
    +        shell: bash
    +        run: |
    +          if [[ "${{ runner.os }}" == "Windows" ]]; then
    +            echo "SEP=;" >> $GITHUB_OUTPUT
    +          else
    +            echo "SEP=:" >> $GITHUB_OUTPUT
    +          fi
    +
    +      - name: Build (PyInstaller onefile)
    +        shell: bash
    +        run: |
    +          ADD_WEBUI="web-ui${{ steps.sep.outputs.SEP }}web-ui"
    +          ADD_SAMPLE_CFG="config/config.yml.sample${{ steps.sep.outputs.SEP }}config"
    +          ADD_LOGO="icons/qbm_logo.png${{ steps.sep.outputs.SEP }}."
    +          ADD_VERSION="VERSION${{ steps.sep.outputs.SEP }}."
    +          ICON_ARG=""
    +          if [[ "${{ runner.os }}" == "Windows" ]]; then
    +            ICON_ARG=--icon=icons/qbm_logo.ico
    +          elif [[ "${{ runner.os }}" == "macOS" ]]; then
    +            ICON_ARG=--icon=icons/qbm_logo.icns
    +          else
    +            # Linux: optional icon (helps some desktop environments)
    +            if [[ -f icons/qbm_logo.png ]]; then
    +              ICON_ARG=--icon=icons/qbm_logo.png
    +            elif [[ -f icons/qbm_logo.ico ]]; then
    +              ICON_ARG=--icon=icons/qbm_logo.ico
    +            fi
    +          fi
    +          pyinstaller --noconfirm \
    +                      --clean \
    +                      --onefile \
    +                      --name "${APP_NAME}" \
    +                      --add-data "$ADD_WEBUI" \
    +                      --add-data "$ADD_SAMPLE_CFG" \
    +                      --add-data "$ADD_LOGO" \
    +                      --add-data "$ADD_VERSION" \
    +                      $ICON_ARG \
    +                      "${ENTRY}"
    +
    +      - name: Rename output for OS
    +        shell: bash
    +        run: |
    +          mkdir -p out
    +          if [[ "${{ runner.os }}" == "Windows" ]]; then
    +            mv "dist/${APP_NAME}.exe" "out/${APP_NAME}-windows-amd64.exe"
    +          elif [[ "${{ runner.os }}" == "macOS" ]]; then
    +            ARCH=$(uname -m)
    +            if [[ "$ARCH" == "arm64" ]]; then
    +              mv "dist/${APP_NAME}" "out/${APP_NAME}-macos-arm64"
    +            else
    +              mv "dist/${APP_NAME}" "out/${APP_NAME}-macos-x86_64"
    +            fi
    +          else
    +            mv "dist/${APP_NAME}" "out/${APP_NAME}-linux-amd64"
    +          fi
    +
    +
    +      # Build Tauri desktop shell after binaries are ready
    +      - name: Setup Rust toolchain
    +        uses: dtolnay/rust-toolchain@stable
    +        with:
    +          toolchain: stable
    +        env:
    +          RUSTUP_MAX_RETRIES: 10
    +        timeout-minutes: 10
    +        continue-on-error: true
    +        id: rust-setup
    +
    +      - name: Wait before retry (network recovery)
    +        if: steps.rust-setup.outcome == 'failure'
    +        run: sleep 30
    +        shell: bash
    +
    +      - name: Retry Rust toolchain setup on failure
    +        if: steps.rust-setup.outcome == 'failure'
    +        uses: dtolnay/rust-toolchain@stable
    +        with:
    +          toolchain: stable
    +        env:
    +          RUSTUP_MAX_RETRIES: 10
    +        timeout-minutes: 10
    +
    +      - name: Rust cache
    +        uses: swatinem/rust-cache@v2
    +        with:
    +          workspaces: |
    +            desktop/tauri/src-tauri -> desktop/tauri/src-tauri/target
    +
    +      - name: Install NSIS (Windows)
    +        if: runner.os == 'Windows'
    +        shell: powershell
    +        run: choco install nsis -y
    +
    +      - name: Install Tauri dependencies (Linux)
    +        if: runner.os == 'Linux'
    +        run: |
    +          sudo apt-get update
    +          # Ubuntu 24.04 (noble) uses libwebkit2gtk-4.1-dev (4.0 no longer available)
    +          # Install core deps first
    +          sudo apt-get install -y libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf
    +          # Try new package name, fall back for older runners
    +          sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
    +
    +      - name: Prepare Tauri server binary
    +        shell: bash
    +        run: |
    +          mkdir -p desktop/tauri/src-tauri/bin
    +
    +          # Copy the actual binary for this platform as a resource
    +          if [[ "${{ runner.os }}" == "Windows" ]]; then
    +            cp "out/${APP_NAME}-windows-amd64.exe" "desktop/tauri/src-tauri/bin/qbit-manage-windows-amd64.exe"
    +          elif [[ "${{ runner.os }}" == "macOS" ]]; then
    +            ARCH=$(uname -m)
    +            if [[ "$ARCH" == "arm64" ]]; then
    +              cp "out/${APP_NAME}-macos-arm64" "desktop/tauri/src-tauri/bin/qbit-manage-macos-arm64"
    +              chmod +x "desktop/tauri/src-tauri/bin/qbit-manage-macos-arm64"
    +            else
    +              cp "out/${APP_NAME}-macos-x86_64" "desktop/tauri/src-tauri/bin/qbit-manage-macos-x86_64"
    +              chmod +x "desktop/tauri/src-tauri/bin/qbit-manage-macos-x86_64"
    +            fi
    +          else
    +            cp "out/${APP_NAME}-linux-amd64" "desktop/tauri/src-tauri/bin/qbit-manage-linux-amd64"
    +            chmod +x "desktop/tauri/src-tauri/bin/qbit-manage-linux-amd64"
    +          fi
    +
    +      - name: Update Tauri version files
    +        working-directory: desktop/tauri/src-tauri
    +        shell: bash
    +        run: |
    +          # Run cargo check to trigger build script and update version files
    +          cargo check
    +
    +      - name: Build Tauri app
    +        working-directory: desktop/tauri/src-tauri
    +        shell: bash
    +        run: |
    +          # Install Tauri 2 CLI (project migrated to Tauri v2 config & deps)
    +          cargo install tauri-cli --version ^2 --locked --force
    +
    +          # Build with explicit bundle targets for this platform
    +          if [[ "${{ runner.os }}" == "Windows" ]]; then
    +            cargo tauri build --target x86_64-pc-windows-msvc --bundles nsis
    +          elif [[ "${{ runner.os }}" == "macOS" ]]; then
    +            cargo tauri build --bundles app,dmg
    +          else
    +            cargo tauri build --bundles deb
    +          fi
    +
    +      - name: Set BUILD_ARCH for artifact naming
    +        shell: bash
    +        run: |
    +          if [[ "${{ runner.os }}" == "macOS" ]]; then
    +            ARCH=$(uname -m)
    +            if [[ "$ARCH" == "arm64" ]]; then
    +              echo "BUILD_ARCH=arm64" >> $GITHUB_ENV
    +            else
    +              echo "BUILD_ARCH=x86_64" >> $GITHUB_ENV
    +            fi
    +          elif [[ "${{ runner.os }}" == "Windows" ]]; then
    +            echo "BUILD_ARCH=amd64" >> $GITHUB_ENV
    +          else
    +            echo "BUILD_ARCH=amd64" >> $GITHUB_ENV
    +          fi
    +
    +      - name: Upload build outputs (binary + Tauri bundles)
    +        uses: actions/upload-artifact@v4
    +        with:
    +          name: build-outputs-${{ runner.os }}-${{ env.BUILD_ARCH }}
    +          path: |
    +            out/**
    +            desktop/tauri/src-tauri/target/**/release/bundle/**
    +          if-no-files-found: error
    +          retention-days: 7
    +          compression-level: 0
    +
    +  prepare-release-assets:
    +    runs-on: ubuntu-latest
    +    needs: build-binaries
    +    permissions:
    +      actions: write
    +      contents: read
    +    steps:
    +      - name: Download and collect all build outputs
    +        uses: actions/download-artifact@v5
    +        with:
    +          pattern: build-outputs-*
    +          path: collected
    +          merge-multiple: true
    +
    +      - name: Filter and rename files for release
    +        shell: bash
    +        run: |
    +          set -euo pipefail
    +          mkdir -p release-assets
    +
    +          echo "=== Collecting server binaries (standalone executables) ==="
    +          # Copy server binaries with error checking
    +          server_files=(
    +            "qbit-manage-linux-amd64"
    +            "qbit-manage-macos-arm64"
    +            "qbit-manage-macos-x86_64"
    +            "qbit-manage-windows-amd64.exe"
    +          )
    +
    +          for server_file in "${server_files[@]}"; do
    +            if find collected -name "$server_file" -exec cp {} release-assets/ \; -print | grep -q .; then
    +              echo "✓ Found and copied: $server_file"
    +            else
    +              echo "⚠️  Warning: $server_file not found"
    +            fi
    +          done
    +
    +          echo "=== Processing installer files (desktop app packages) ==="
    +
    +          # Linux .deb installer
    +          deb_count=$(find collected -name "*.deb" -exec cp {} release-assets/ \; -print | wc -l)
    +          if [ "$deb_count" -gt 0 ]; then
    +            echo "✓ Found $deb_count .deb installer(s)"
    +            for file in release-assets/*.deb; do
    +              if [ -f "$file" ]; then
    +                basename=$(basename "$file" .deb)
    +                mv "$file" "release-assets/${basename}-desktop-installer.deb"
    +                echo "  → Renamed to: ${basename}-desktop-installer.deb"
    +              fi
    +            done
    +          else
    +            echo "⚠️  Warning: No .deb installers found"
    +          fi
    +
    +          # macOS .dmg installers
    +          dmg_count=$(find collected -name "*.dmg" -exec cp {} release-assets/ \; -print | wc -l)
    +          if [ "$dmg_count" -gt 0 ]; then
    +            echo "✓ Found $dmg_count .dmg installer(s)"
    +            for file in release-assets/*.dmg; do
    +              if [ -f "$file" ]; then
    +                basename=$(basename "$file" .dmg)
    +                mv "$file" "release-assets/${basename}-desktop-installer.dmg"
    +                echo "  → Renamed to: ${basename}-desktop-installer.dmg"
    +              fi
    +            done
    +          else
    +            echo "⚠️  Warning: No .dmg installers found"
    +          fi
    +
    +          # Windows .exe installer
    +          exe_count=$(find collected -name "*-setup.exe" -exec cp {} release-assets/ \; -print | wc -l)
    +          if [ "$exe_count" -gt 0 ]; then
    +            echo "✓ Found $exe_count .exe installer(s)"
    +            for file in release-assets/*-setup.exe; do
    +              if [ -f "$file" ]; then
    +                basename=$(basename "$file" -setup.exe)
    +                mv "$file" "release-assets/${basename}-desktop-installer-setup.exe"
    +                echo "  → Renamed to: ${basename}-desktop-installer-setup.exe"
    +              fi
    +            done
    +          else
    +            echo "⚠️  Warning: No .exe installers found"
    +          fi
    +
    +          echo "=== File processing completed ==="
    +
    +      - name: Display final release assets
    +        run: |
    +          echo "=== Final Release Assets ==="
    +          ls -la release-assets/
    +          echo ""
    +          echo "=== File Count Summary ==="
    +          echo "Server binaries: $(find release-assets -name "qbit-manage-*" -not -name "*desktop*" | wc -l)"
    +          echo "Desktop installers: $(find release-assets -name "*desktop-installer*" | wc -l)"
    +
    +      - name: Upload final release assets
    +        uses: actions/upload-artifact@v4
    +        with:
    +          name: qbit-manage-release-assets
    +          path: release-assets/*
    +          if-no-files-found: error
    +          compression-level: 6
    +
    +      - name: Clean up temporary build artifacts
    +        uses: actions/github-script@v7
    +        with:
    +          script: |
    +            // Get all artifacts from this workflow run
    +            const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
    +              owner: context.repo.owner,
    +              repo: context.repo.repo,
    +              run_id: context.runId,
    +            });
    +
    +            // Delete temporary build artifacts, keep only the final release assets
    +            for (const artifact of artifacts.data.artifacts) {
    +              if (artifact.name.startsWith('build-outputs-')) {
    +                console.log(`Deleting temporary artifact: ${artifact.name}`);
    +                try {
    +                  await github.rest.actions.deleteArtifact({
    +                    owner: context.repo.owner,
    +                    repo: context.repo.repo,
    +                    artifact_id: artifact.id,
    +                  });
    +                  console.log(`✓ Successfully deleted: ${artifact.name}`);
    +                } catch (error) {
    +                  console.log(`⚠️  Failed to delete ${artifact.name}: ${error.message}`);
    +                }
    +              } else {
    +                console.log(`✓ Keeping final artifact: ${artifact.name}`);
    +              }
    +            }
    +
       docker-develop:
         runs-on: ubuntu-latest
     
    @@ -17,7 +373,7 @@ jobs:
               OWNER: '${{ github.repository_owner }}'
     
           - name: Check Out Repo
    -        uses: actions/checkout@v4
    +        uses: actions/checkout@v5
             with:
               ref: develop
     
    
  • .github/workflows/docs.yml+2 2 modified
    @@ -20,7 +20,7 @@ jobs:
         if: github.event_name != 'gollum'
         steps:
           - name: Checkout Repo
    -        uses: actions/checkout@v4
    +        uses: actions/checkout@v5
           - name: Sync docs to wiki
             uses: newrelic/wiki-sync-action@main
             with:
    @@ -35,7 +35,7 @@ jobs:
         if: github.event_name == 'gollum'
         steps:
           - name: Checkout Repo
    -        uses: actions/checkout@v4
    +        uses: actions/checkout@v5
             with:
               token: ${{ secrets.PAT }} # allows us to push back to repo
               ref: develop
    
  • .github/workflows/tag.yml+1 1 modified
    @@ -9,7 +9,7 @@ jobs:
         runs-on: ubuntu-latest
         steps:
     
    -      - uses: actions/checkout@v4
    +      - uses: actions/checkout@v5
             with:
               token: ${{ secrets.PAT }}
               fetch-depth: 2
    
  • .github/workflows/update-develop-branch.yml+3 53 renamed
    @@ -1,70 +1,20 @@
    -name: Docker Latest Release
    +name: Update Develop Branch
     
     on:
       push:
         branches: [ master ]
     
     permissions:
    -  contents: read
    +  contents: write
     
     jobs:
     
    -  docker-latest:
    -    runs-on: ubuntu-latest
    -
    -    steps:
    -      - name: set lower case owner name
    -        run: |
    -          echo "OWNER_LC=${OWNER,,}" >>${GITHUB_ENV}
    -        env:
    -          OWNER: '${{ github.repository_owner }}'
    -
    -      - name: Check Out Repo
    -        uses: actions/checkout@v4
    -
    -      - name: Login to Docker Hub
    -        uses: docker/login-action@v3
    -        with:
    -          username: ${{ secrets.DOCKER_HUB_USERNAME }}
    -          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
    -
    -      - name: Login to ghcr.io
    -        uses: docker/login-action@v3
    -        with:
    -          registry: ghcr.io
    -          username: ${{ env.OWNER_LC }}
    -          password: ${{ secrets.GHCR_TOKEN }}
    -
    -      - name: Set up QEMU
    -        uses: docker/setup-qemu-action@v3
    -        with:
    -          platforms: all
    -
    -      - name: Set up Docker Buildx
    -        id: buildx
    -        uses: docker/setup-buildx-action@v3
    -
    -      - name: Build and push
    -        id: docker_build
    -        uses: docker/build-push-action@v6
    -        with:
    -          context: ./
    -          file: ./Dockerfile
    -          platforms: linux/amd64,linux/arm64,linux/arm/v7
    -          push: true
    -          tags: |
    -            ${{ secrets.DOCKER_HUB_USERNAME }}/qbit_manage:latest
    -            ghcr.io/${{ env.OWNER_LC }}/qbit_manage:latest
    -
       update-develop:
         runs-on: ubuntu-latest
    -    needs: docker-latest
    -    permissions:
    -      contents: write
     
         steps:
           - name: Check Out Repo
    -        uses: actions/checkout@v4
    +        uses: actions/checkout@v5
             with:
               token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }}
               fetch-depth: 0
    
  • .github/workflows/update-supported-versions.yml+1 1 modified
    @@ -23,7 +23,7 @@ jobs:
         runs-on: ubuntu-latest
         steps:
           - name: Checkout repository
    -        uses: actions/checkout@v4
    +        uses: actions/checkout@v5
             with:
               ref: ${{ github.event.inputs.targetBranch || github.ref_name }}
     
    
  • .github/workflows/version.yml+397 13 modified
    @@ -1,13 +1,367 @@
    -name: Docker Version Release
    +name: Version Release
     
     on:
       create:
         tags:
           - v*
     
    +concurrency:
    +  group: ${{ github.workflow }}-${{ github.ref }}
    +  cancel-in-progress: true
    +
     jobs:
     
    -  docker-develop:
    +  build-binaries:
    +    name: Build Standalone Binaries (${{ matrix.os }})
    +    permissions:
    +      contents: read
    +    runs-on: ${{ matrix.os }}
    +    strategy:
    +      fail-fast: false
    +      matrix:
    +        include:
    +          - os: ubuntu-latest
    +            python-version: '3.12'
    +          - os: windows-latest
    +            python-version: '3.12'
    +          - os: 'macos-latest' # for Arm based macs (M1 and above).
    +            python-version: '3.12'
    +            arch: arm64
    +          - os: 'macos-13' # for Intel based macs.
    +            python-version: '3.12'
    +            arch: x86_64
    +    env:
    +      APP_NAME: qbit-manage
    +      ENTRY: qbit_manage.py
    +    steps:
    +      - name: Check Out Repo
    +        uses: actions/checkout@v5
    +
    +      - name: Setup Python
    +        uses: actions/setup-python@v5
    +        with:
    +          python-version: ${{ matrix.python-version }}
    +          cache: 'pip'
    +          cache-dependency-path: |
    +            pyproject.toml
    +            uv.lock
    +
    +      - name: Upgrade Pip and install project + PyInstaller
    +        run: |
    +          python -m pip install --upgrade pip wheel
    +          python -m pip install . pyinstaller
    +
    +      - name: Compute add-data separator
    +        id: sep
    +        shell: bash
    +        run: |
    +          if [[ "${{ runner.os }}" == "Windows" ]]; then
    +            echo "SEP=;" >> $GITHUB_OUTPUT
    +          else
    +            echo "SEP=:" >> $GITHUB_OUTPUT
    +          fi
    +
    +      - name: Build (PyInstaller onefile)
    +        shell: bash
    +        run: |
    +          ADD_WEBUI="web-ui${{ steps.sep.outputs.SEP }}web-ui"
    +          ADD_SAMPLE_CFG="config/config.yml.sample${{ steps.sep.outputs.SEP }}config"
    +          ADD_LOGO="icons/qbm_logo.png${{ steps.sep.outputs.SEP }}."
    +          ADD_VERSION="VERSION${{ steps.sep.outputs.SEP }}."
    +          ICON_ARG=""
    +          if [[ "${{ runner.os }}" == "Windows" ]]; then
    +            ICON_ARG=--icon=icons/qbm_logo.ico
    +          elif [[ "${{ runner.os }}" == "macOS" ]]; then
    +            ICON_ARG=--icon=icons/qbm_logo.icns
    +          else
    +            # Linux: optional icon (helps some desktop environments)
    +            if [[ -f icons/qbm_logo.png ]]; then
    +              ICON_ARG=--icon=icons/qbm_logo.png
    +            elif [[ -f icons/qbm_logo.ico ]]; then
    +              ICON_ARG=--icon=icons/qbm_logo.ico
    +            fi
    +          fi
    +          pyinstaller --noconfirm \
    +                      --clean \
    +                      --onefile \
    +                      --name "${APP_NAME}" \
    +                      --add-data "$ADD_WEBUI" \
    +                      --add-data "$ADD_SAMPLE_CFG" \
    +                      --add-data "$ADD_LOGO" \
    +                      --add-data "$ADD_VERSION" \
    +                      $ICON_ARG \
    +                      "${ENTRY}"
    +
    +      - name: Rename output for OS
    +        shell: bash
    +        run: |
    +          mkdir -p out
    +          if [[ "${{ runner.os }}" == "Windows" ]]; then
    +            mv "dist/${APP_NAME}.exe" "out/${APP_NAME}-windows-amd64.exe"
    +          elif [[ "${{ runner.os }}" == "macOS" ]]; then
    +            ARCH=$(uname -m)
    +            if [[ "$ARCH" == "arm64" ]]; then
    +              mv "dist/${APP_NAME}" "out/${APP_NAME}-macos-arm64"
    +            else
    +              mv "dist/${APP_NAME}" "out/${APP_NAME}-macos-x86_64"
    +            fi
    +          else
    +            mv "dist/${APP_NAME}" "out/${APP_NAME}-linux-amd64"
    +          fi
    +
    +
    +      # Build Tauri desktop shell after binaries are ready
    +      - name: Setup Rust toolchain
    +        uses: dtolnay/rust-toolchain@stable
    +        with:
    +          toolchain: stable
    +        env:
    +          RUSTUP_MAX_RETRIES: 10
    +        timeout-minutes: 10
    +        continue-on-error: true
    +        id: rust-setup
    +
    +      - name: Wait before retry (network recovery)
    +        if: steps.rust-setup.outcome == 'failure'
    +        run: sleep 30
    +        shell: bash
    +
    +      - name: Retry Rust toolchain setup on failure
    +        if: steps.rust-setup.outcome == 'failure'
    +        uses: dtolnay/rust-toolchain@stable
    +        with:
    +          toolchain: stable
    +        env:
    +          RUSTUP_MAX_RETRIES: 10
    +        timeout-minutes: 10
    +
    +      - name: Rust cache
    +        uses: swatinem/rust-cache@v2
    +        with:
    +          workspaces: |
    +            desktop/tauri/src-tauri -> desktop/tauri/src-tauri/target
    +
    +      - name: Install NSIS (Windows)
    +        if: runner.os == 'Windows'
    +        shell: powershell
    +        run: choco install nsis -y
    +
    +      - name: Install Tauri dependencies (Linux)
    +        if: runner.os == 'Linux'
    +        run: |
    +          sudo apt-get update
    +          # Ubuntu 24.04 (noble) uses libwebkit2gtk-4.1-dev (4.0 no longer available)
    +          # Install core deps first
    +          sudo apt-get install -y libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf
    +          # Try new package name, fall back for older runners
    +          sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
    +
    +      - name: Prepare Tauri server binary
    +        shell: bash
    +        run: |
    +          mkdir -p desktop/tauri/src-tauri/bin
    +
    +          # Copy the actual binary for this platform as a resource
    +          if [[ "${{ runner.os }}" == "Windows" ]]; then
    +            cp "out/${APP_NAME}-windows-amd64.exe" "desktop/tauri/src-tauri/bin/qbit-manage-windows-amd64.exe"
    +          elif [[ "${{ runner.os }}" == "macOS" ]]; then
    +            ARCH=$(uname -m)
    +            if [[ "$ARCH" == "arm64" ]]; then
    +              cp "out/${APP_NAME}-macos-arm64" "desktop/tauri/src-tauri/bin/qbit-manage-macos-arm64"
    +              chmod +x "desktop/tauri/src-tauri/bin/qbit-manage-macos-arm64"
    +            else
    +              cp "out/${APP_NAME}-macos-x86_64" "desktop/tauri/src-tauri/bin/qbit-manage-macos-x86_64"
    +              chmod +x "desktop/tauri/src-tauri/bin/qbit-manage-macos-x86_64"
    +            fi
    +          else
    +            cp "out/${APP_NAME}-linux-amd64" "desktop/tauri/src-tauri/bin/qbit-manage-linux-amd64"
    +            chmod +x "desktop/tauri/src-tauri/bin/qbit-manage-linux-amd64"
    +          fi
    +
    +      - name: Update Tauri version files
    +        working-directory: desktop/tauri/src-tauri
    +        shell: bash
    +        run: |
    +          # Run cargo check to trigger build script and update version files
    +          cargo check
    +
    +      - name: Build Tauri app
    +        working-directory: desktop/tauri/src-tauri
    +        shell: bash
    +        run: |
    +          # Install Tauri 2 CLI (project migrated to Tauri v2 config & deps)
    +          cargo install tauri-cli --version ^2 --locked --force
    +
    +          # Build with explicit bundle targets for this platform
    +          if [[ "${{ runner.os }}" == "Windows" ]]; then
    +            cargo tauri build --target x86_64-pc-windows-msvc --bundles nsis
    +          elif [[ "${{ runner.os }}" == "macOS" ]]; then
    +            cargo tauri build --bundles app,dmg
    +          else
    +            cargo tauri build --bundles deb
    +          fi
    +
    +      - name: Set BUILD_ARCH for artifact naming
    +        shell: bash
    +        run: |
    +          if [[ "${{ runner.os }}" == "macOS" ]]; then
    +            ARCH=$(uname -m)
    +            if [[ "$ARCH" == "arm64" ]]; then
    +              echo "BUILD_ARCH=arm64" >> $GITHUB_ENV
    +            else
    +              echo "BUILD_ARCH=x86_64" >> $GITHUB_ENV
    +            fi
    +          elif [[ "${{ runner.os }}" == "Windows" ]]; then
    +            echo "BUILD_ARCH=amd64" >> $GITHUB_ENV
    +          else
    +            echo "BUILD_ARCH=amd64" >> $GITHUB_ENV
    +          fi
    +
    +      - name: Upload build outputs (binary + Tauri bundles)
    +        uses: actions/upload-artifact@v4
    +        with:
    +          name: build-outputs-${{ runner.os }}-${{ env.BUILD_ARCH }}
    +          path: |
    +            out/**
    +            desktop/tauri/src-tauri/target/**/release/bundle/**
    +          if-no-files-found: error
    +          retention-days: 7
    +          compression-level: 0
    +
    +  prepare-release-assets:
    +    runs-on: ubuntu-latest
    +    needs: build-binaries
    +    permissions:
    +      actions: write
    +      contents: read
    +    steps:
    +      - name: Download and collect all build outputs
    +        uses: actions/download-artifact@v5
    +        with:
    +          pattern: build-outputs-*
    +          path: collected
    +          merge-multiple: true
    +
    +      - name: Filter and rename files for release
    +        shell: bash
    +        run: |
    +          set -euo pipefail
    +          mkdir -p release-assets
    +
    +          echo "=== Collecting server binaries (standalone executables) ==="
    +          # Copy server binaries with error checking
    +          server_files=(
    +            "qbit-manage-linux-amd64"
    +            "qbit-manage-macos-arm64"
    +            "qbit-manage-macos-x86_64"
    +            "qbit-manage-windows-amd64.exe"
    +          )
    +
    +          for server_file in "${server_files[@]}"; do
    +            if find collected -name "$server_file" -exec cp {} release-assets/ \; -print | grep -q .; then
    +              echo "✓ Found and copied: $server_file"
    +            else
    +              echo "⚠️  Warning: $server_file not found"
    +            fi
    +          done
    +
    +          echo "=== Processing installer files (desktop app packages) ==="
    +
    +          # Linux .deb installer
    +          deb_count=$(find collected -name "*.deb" -exec cp {} release-assets/ \; -print | wc -l)
    +          if [ "$deb_count" -gt 0 ]; then
    +            echo "✓ Found $deb_count .deb installer(s)"
    +            for file in release-assets/*.deb; do
    +              if [ -f "$file" ]; then
    +                basename=$(basename "$file" .deb)
    +                mv "$file" "release-assets/${basename}-desktop-installer.deb"
    +                echo "  → Renamed to: ${basename}-desktop-installer.deb"
    +              fi
    +            done
    +          else
    +            echo "⚠️  Warning: No .deb installers found"
    +          fi
    +
    +          # macOS .dmg installers
    +          dmg_count=$(find collected -name "*.dmg" -exec cp {} release-assets/ \; -print | wc -l)
    +          if [ "$dmg_count" -gt 0 ]; then
    +            echo "✓ Found $dmg_count .dmg installer(s)"
    +            for file in release-assets/*.dmg; do
    +              if [ -f "$file" ]; then
    +                basename=$(basename "$file" .dmg)
    +                mv "$file" "release-assets/${basename}-desktop-installer.dmg"
    +                echo "  → Renamed to: ${basename}-desktop-installer.dmg"
    +              fi
    +            done
    +          else
    +            echo "⚠️  Warning: No .dmg installers found"
    +          fi
    +
    +          # Windows .exe installer
    +          exe_count=$(find collected -name "*-setup.exe" -exec cp {} release-assets/ \; -print | wc -l)
    +          if [ "$exe_count" -gt 0 ]; then
    +            echo "✓ Found $exe_count .exe installer(s)"
    +            for file in release-assets/*-setup.exe; do
    +              if [ -f "$file" ]; then
    +                basename=$(basename "$file" -setup.exe)
    +                mv "$file" "release-assets/${basename}-desktop-installer-setup.exe"
    +                echo "  → Renamed to: ${basename}-desktop-installer-setup.exe"
    +              fi
    +            done
    +          else
    +            echo "⚠️  Warning: No .exe installers found"
    +          fi
    +
    +          echo "=== File processing completed ==="
    +
    +      - name: Display final release assets
    +        run: |
    +          echo "=== Final Release Assets ==="
    +          ls -la release-assets/
    +          echo ""
    +          echo "=== File Count Summary ==="
    +          echo "Server binaries: $(find release-assets -name "qbit-manage-*" -not -name "*desktop*" | wc -l)"
    +          echo "Desktop installers: $(find release-assets -name "*desktop-installer*" | wc -l)"
    +
    +      - name: Upload final release assets
    +        uses: actions/upload-artifact@v4
    +        with:
    +          name: qbit-manage-release-assets
    +          path: release-assets/*
    +          if-no-files-found: error
    +          compression-level: 6
    +
    +      - name: Clean up temporary build artifacts
    +        uses: actions/github-script@v7
    +        with:
    +          script: |
    +            // Get all artifacts from this workflow run
    +            const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
    +              owner: context.repo.owner,
    +              repo: context.repo.repo,
    +              run_id: context.runId,
    +            });
    +
    +            // Delete temporary build artifacts, keep only the final release assets
    +            for (const artifact of artifacts.data.artifacts) {
    +              if (artifact.name.startsWith('build-outputs-')) {
    +                console.log(`Deleting temporary artifact: ${artifact.name}`);
    +                try {
    +                  await github.rest.actions.deleteArtifact({
    +                    owner: context.repo.owner,
    +                    repo: context.repo.repo,
    +                    artifact_id: artifact.id,
    +                  });
    +                  console.log(`✓ Successfully deleted: ${artifact.name}`);
    +                } catch (error) {
    +                  console.log(`⚠️  Failed to delete ${artifact.name}: ${error.message}`);
    +                }
    +              } else {
    +                console.log(`✓ Keeping final artifact: ${artifact.name}`);
    +              }
    +            }
    +
    +  docker-release:
         runs-on: ubuntu-latest
     
         steps:
    @@ -18,9 +372,7 @@ jobs:
               OWNER: '${{ github.repository_owner }}'
     
           - name: Check Out Repo
    -        uses: actions/checkout@v4
    -        with:
    -          fetch-depth: 0
    +        uses: actions/checkout@v5
     
           - name: Login to Docker Hub
             uses: docker/login-action@v3
    @@ -58,19 +410,51 @@ jobs:
               push: true
               tags: |
                 ${{ secrets.DOCKER_HUB_USERNAME }}/qbit_manage:${{ steps.get_version.outputs.VERSION }}
    +            ${{ secrets.DOCKER_HUB_USERNAME }}/qbit_manage:latest
                 ghcr.io/${{ env.OWNER_LC }}/qbit_manage:${{ steps.get_version.outputs.VERSION }}
    -
    -      - name: Create release
    -        id: create_release
    -        uses: softprops/action-gh-release@v2
    -        with:
    -          body_path: CHANGELOG
    -          token: ${{ secrets.PAT }}
    -          tag_name: ${{ steps.get_version.outputs.VERSION }}
    +            ghcr.io/${{ env.OWNER_LC }}/qbit_manage:latest
     
           - name: Trigger Hotio Webhook
             uses: joelwmale/webhook-action@master
             with:
               url: ${{ secrets.HOTIO_WEBHOOK_URL }}
               headers: '{"Authorization": "Bearer ${{ secrets.HOTIO_WEBHOOK_SECRET }}"}'
               body: '{ "application": "qbitmanage", "branch":  "release" }'
    +
    +  create-release:
    +    runs-on: ubuntu-latest
    +    needs: [prepare-release-assets, docker-release]
    +    permissions:
    +      contents: write
    +    steps:
    +      - name: Check Out Repo
    +        uses: actions/checkout@v5
    +
    +      - name: Get the version
    +        id: get_version
    +        run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
    +
    +      - name: Download prepared release assets
    +        uses: actions/download-artifact@v5
    +        with:
    +          name: qbit-manage-release-assets
    +          path: release-assets
    +
    +      - name: Display assets to be released
    +        run: |
    +          echo "=== Assets to be released ==="
    +          ls -la release-assets/
    +          echo ""
    +          echo "=== Individual files that will be uploaded ==="
    +          find release-assets -type f -exec basename {} \;
    +
    +      - name: Create release with individual assets
    +        id: create_release
    +        uses: softprops/action-gh-release@v2
    +        with:
    +          body_path: CHANGELOG
    +          token: ${{ secrets.PAT }}
    +          tag_name: ${{ steps.get_version.outputs.VERSION }}
    +          files: release-assets/*
    +          draft: false
    +          prerelease: false
    
  • .gitignore+2 0 modified
    @@ -15,3 +15,5 @@ qbit_manage.egg-info/
     **/build
     .roo*
     memory-bank
    +**/src-tauri/gen
    +**/src-tauri/target
    
  • icons/qbm_logo.icns+0 0 added
  • icons/qbm_logo.ico+0 0 added
  • icons/qbm_logo.png+0 0 added
  • modules/config.py+1 1 modified
    @@ -681,7 +681,7 @@ def _sort_share_limits(share_limits):
                         subparent=group,
                         var_type="int",
                         min_int=-1,
    -                    default=-1,
    +                    default=0,
                         do_print=False,
                         save=False,
                     )
    
  • modules/core/share_limits.py+23 25 modified
    @@ -342,32 +342,30 @@ def update_share_limits_for_group(self, group_name, group_config, torrents):
                         self.tdel_dict[t_hash]["body"] = tor_reached_seed_limit
                     else:
                         # New behavior: throttle upload speed instead of pausing/removing
    -                    throttle_kib = group_config.get("upload_speed_on_limit_reached", -1)
    -                    body = []
    -                    body += logger.print_line(logger.insert_space(f"Torrent Name: {t_name}", 3), self.config.loglevel)
    -                    body += logger.print_line(logger.insert_space(f"Tracker: {tracker['url']}", 8), self.config.loglevel)
    -                    body += logger.print_line(
    -                        logger.insert_space("Cleanup: False [Meets Share Limits]", 8),
    -                        self.config.loglevel,
    -                    )
    -
    -                    # Clear share limits to prevent qBittorrent from pausing again, then apply throttle
    -                    if not self.config.dry_run:
    -                        # Allow continued seeding by removing share limits
    -                        torrent.set_share_limits(ratio_limit=-1, seeding_time_limit=-1, inactive_seeding_time_limit=-1)
    -                        # Apply per-torrent upload throttle (KiB/s) or unlimited if -1/0
    -                        limit_val = -1 if throttle_kib in (-1, 0) else throttle_kib * 1024
    -                        if limit_val is not None:
    +                    throttle_kib = group_config.get("upload_speed_on_limit_reached", 0)
    +                    # Apply per-torrent upload throttle (KiB/s) or unlimited if -1/0
    +                    limit_val = -1 if throttle_kib == -1 else throttle_kib * 1024
    +                    if limit_val and throttle_kib != torrent_upload_limit:
    +                        body = []
    +                        body += logger.print_line(logger.insert_space(f"Torrent Name: {t_name}", 3), self.config.loglevel)
    +                        body += logger.print_line(logger.insert_space(f"Tracker: {tracker['url']}", 8), self.config.loglevel)
    +                        body += logger.print_line(
    +                            logger.insert_space("Cleanup: False [Meets Share Limits]", 8),
    +                            self.config.loglevel,
    +                        )
    +                        disp = "unlimited" if throttle_kib == -1 else f"{throttle_kib} kB/s"
    +                        body += logger.print_line(
    +                            logger.insert_space(f"Applied upload throttle after limits reached: {disp}", 8),
    +                            self.config.loglevel,
    +                        )
    +                        # Clear share limits to prevent qBittorrent from pausing again, then apply throttle
    +                        if not self.config.dry_run:
    +                            # Allow continued seeding by removing share limits
    +                            torrent.set_share_limits(ratio_limit=-1, seeding_time_limit=-1, inactive_seeding_time_limit=-1)
                                 torrent.set_upload_limit(limit_val)
    -                        # Optionally resume if configured
    -                        if group_config["resume_torrent_after_change"] and torrent.state_enum.is_complete:
    -                            torrent.resume()
    -
    -                    disp = "unlimited" if throttle_kib in (-1, 0) else f"{throttle_kib} kB/s"
    -                    body += logger.print_line(
    -                        logger.insert_space(f"Applied upload throttle after limits reached: {disp}", 8),
    -                        self.config.loglevel,
    -                    )
    +                            # Optionally resume if configured
    +                            if group_config["resume_torrent_after_change"] and torrent.state_enum.is_complete:
    +                                torrent.resume()
                 self.torrent_hash_checked.append(t_hash)
     
         def tag_and_update_share_limits_for_torrent(self, torrent, group_config):
    
  • modules/__init__.py+25 10 modified
    @@ -1,22 +1,37 @@
    -import os
    +from pathlib import Path
     
     # Define an empty version_info tuple
     __version_info__ = ()
     
    -# Get the path to the project directory
    -project_dir = os.path.dirname(os.path.abspath(__file__))
    +# Try to resolve VERSION in a PyInstaller-safe way first, then fall back to repo-relative
    +version_str = "0.0.0"
    +try:
    +    # Prefer runtime-extracted path when bundled
    +    try:
    +        from .util import runtime_path  # Safe relative import within package
     
    -# Get the path to the VERSION file
    -version_file_path = os.path.join(project_dir, "..", "VERSION")
    -
    -# Read the version from the file
    -with open(version_file_path) as f:
    -    version_str = f.read().strip()
    +        version_path = runtime_path("VERSION")
    +        if version_path.exists():
    +            version_str = version_path.read_text(encoding="utf-8").strip()
    +        else:
    +            raise FileNotFoundError
    +    except Exception:
    +        # Fallback to repository structure: modules/../VERSION
    +        project_dir = Path(__file__).resolve().parent
    +        version_file_path = (project_dir / ".." / "VERSION").resolve()
    +        with open(version_file_path, encoding="utf-8") as f:
    +            version_str = f.read().strip()
    +except Exception:
    +    # Last resort default (keeps package importable even if VERSION missing)
    +    version_str = "0.0.0"
     
     # Get only the first 3 digits
     version_str_split = version_str.rsplit("-", 1)[0]
     # Convert the version string to a tuple of integers
    -__version_info__ = tuple(map(int, version_str_split.split(".")))
    +try:
    +    __version_info__ = tuple(map(int, version_str_split.split(".")))
    +except Exception:
    +    __version_info__ = (0, 0, 0)
     
     # Define the version string using the version_info tuple
     __version__ = ".".join(str(i) for i in __version_info__)
    
  • modules/scheduler.py+162 79 modified
    @@ -50,7 +50,10 @@ def __init__(self, config_dir: str = "config", suppress_logging: bool = False, r
             self._callback = None
             self._read_only = read_only
     
    -        # Load schedule on initialization
    +        # Persistence disabled flag (stored inside schedule.yml as 'disabled: true')
    +        self._persistence_disabled = False
    +
    +        # Load schedule on initialization (will set _persistence_disabled if file says disabled)
             self._load_schedule(suppress_logging=suppress_logging)
             if not suppress_logging:
                 logger.debug("Scheduler initialized")
    @@ -59,48 +62,99 @@ def _load_schedule(self, suppress_logging: bool = False) -> bool:
             """
             Load schedule from persistent file or environment variable.
     
    -        Priority:
    +        Priority (when not disabled):
             1. schedule.yml file (persistent)
             2. QBT_SCHEDULE environment variable (fallback)
     
    -        Args:
    -            suppress_logging: If True, suppress info logging during load
    +        If 'disabled: true' in schedule.yml, skip loading its schedule and fall back to env/none.
     
             Returns:
                 bool: True if schedule loaded successfully
             """
    -        # Try loading from schedule.yml first
    +        schedule_path = str(self.schedule_file)
    +
    +        # Reset in-memory state; _persistence_disabled will be set if file indicates
    +        self._persistence_disabled = False
    +
             if self.schedule_file.exists():
                 try:
                     yaml_loader = YAML(str(self.schedule_file))
                     data = yaml_loader.data
                     if data and isinstance(data, dict):
    +                    # Read disabled flag first
    +                    if bool(data.get("disabled")):
    +                        self._persistence_disabled = True
    +                        if not suppress_logging:
    +                            logger.debug(f"Persistent schedule disabled (disabled: true in {schedule_path})")
                         schedule_type = data.get("type")
                         schedule_value = data.get("value")
    -
    -                    if schedule_type and schedule_value is not None:
    +                    if not self._persistence_disabled and schedule_type and schedule_value is not None:
                             if self._validate_schedule(schedule_type, schedule_value):
                                 self.current_schedule = (schedule_type, schedule_value)
                                 if not self._read_only:
                                     self.next_run = self._calculate_next_run()
    +                                next_run_info = calc_next_run(self.next_run)
    +                                logger.info(f"{next_run_info['next_run_str']}")
                                 if not suppress_logging:
    -                                logger.info(f"Schedule loaded from file: {schedule_type}={schedule_value}")
    +                                logger.debug(
    +                                    f"Schedule loaded from file: {schedule_type}={schedule_value} (path={schedule_path})"
    +                                )
                                 return True
                             else:
    -                            logger.warning(f"Invalid schedule in {self.schedule_file}")
    +                            logger.warning(f"Invalid schedule structure in file {schedule_path}: {data}")
    +                else:
    +                    logger.warning(f"schedule.yml did not contain a dict at {schedule_path}")
                 except Exception as e:
    -                logger.error(f"Error loading schedule.yml: {e}")
    +                logger.error(f"Error loading schedule.yml at {schedule_path}: {e}")
    +        else:
    +            if not suppress_logging:
    +                logger.debug(f"No schedule.yml found at startup (expected path: {schedule_path})")
     
    -        # Fallback to environment variable
    +        # If disabled, do not attempt env override unless we want an environment fallback
    +        if self._persistence_disabled:
    +            # Attempt env fallback only if present
    +            env_schedule = os.getenv("QBT_SCHEDULE")
    +            if env_schedule:
    +                if not suppress_logging:
    +                    logger.debug(f"Attempting environment schedule while disabled: QBT_SCHEDULE={env_schedule!r}")
    +                if self._validate_schedule("cron", env_schedule):
    +                    self.current_schedule = ("cron", env_schedule)
    +                    if not self._read_only:
    +                        self.next_run = self._calculate_next_run()
    +                        next_run_info = calc_next_run(self.next_run)
    +                        logger.info(f"{next_run_info['next_run_str']}")
    +                    if not suppress_logging:
    +                        logger.debug(f"Environment schedule active (disabled persistent file): cron={env_schedule}")
    +                    return True
    +                else:
    +                    try:
    +                        interval = int(env_schedule)
    +                        if interval > 0:
    +                            self.current_schedule = ("interval", interval)
    +                            if not self._read_only:
    +                                self.next_run = self._calculate_next_run()
    +                                next_run_info = calc_next_run(self.next_run)
    +                                logger.info(f"{next_run_info['next_run_str']}")
    +                            if not suppress_logging:
    +                                logger.debug(
    +                                    f"Environment schedule active (disabled persistent file): interval={interval} minutes"
    +                                )
    +                            return True
    +                    except ValueError:
    +                        pass
    +            return False
    +
    +        # Fallback to environment variable (only if not disabled)
             env_schedule = os.getenv("QBT_SCHEDULE")
             if env_schedule:
    -            # Try as cron first, then as interval
    +            if not suppress_logging:
    +                logger.debug(f"Attempting to load schedule from environment variable QBT_SCHEDULE={env_schedule!r}")
                 if self._validate_schedule("cron", env_schedule):
                     self.current_schedule = ("cron", env_schedule)
                     if not self._read_only:
                         self.next_run = self._calculate_next_run()
                     if not suppress_logging:
    -                    logger.info(f"Schedule loaded from environment: cron={env_schedule}")
    +                    logger.debug(f"Schedule loaded from environment: cron={env_schedule}")
                     return True
                 else:
                     try:
    @@ -110,83 +164,89 @@ def _load_schedule(self, suppress_logging: bool = False) -> bool:
                             if not self._read_only:
                                 self.next_run = self._calculate_next_run()
                             if not suppress_logging:
    -                            logger.info(f"Schedule loaded from environment: interval={interval} minutes")
    +                            logger.debug(f"Schedule loaded from environment: interval={interval} minutes")
                             return True
    +                    else:
    +                        logger.warning(f"QBT_SCHEDULE interval must be > 0 (got {interval})")
                     except ValueError:
    -                    logger.warning(f"Invalid QBT_SCHEDULE environment variable: {env_schedule}")
    +                    logger.warning(f"Invalid QBT_SCHEDULE environment variable (not cron or positive int): {env_schedule}")
     
             if not suppress_logging:
    -            logger.debug("No valid schedule configuration found")
    +            logger.debug("No valid schedule configuration found (file + env both absent/invalid)")
             return False
     
         def save_schedule(self, schedule_type: str, schedule_value: Union[str, int]) -> bool:
             """
    -        Save schedule configuration to persistent file.
    -
    -        Args:
    -            schedule_type: Either 'cron' or 'interval'
    -            schedule_value: Cron expression string or interval in minutes
    -
    -        Returns:
    -            bool: True if saved successfully
    +        Save schedule configuration to persistent file (includes disabled flag).
    +        Always re-enables persistence (disabled flag cleared) when an explicit save is requested.
             """
             if not self._validate_schedule(schedule_type, schedule_value):
                 logger.error(f"Invalid schedule: {schedule_type}={schedule_value}")
                 return False
    -
             try:
    -            data = {"type": schedule_type, "value": schedule_value, "updated_at": datetime.now().isoformat(), "version": 1}
    +            # Requirement: any explicit save (e.g. via WebUI) must re-enable persistence
    +            if self._persistence_disabled:
    +                logger.debug("save_schedule: auto re-enabling persistence (was disabled)")
    +            self._persistence_disabled = False
     
    -            yaml_writer = YAML(input_data="")
    -            yaml_writer.data = data
    -            yaml_writer.path = str(self.schedule_file)
    -            yaml_writer.save()
    -
    -            # Update current schedule
    +            self._persist_schedule_file(schedule_type, schedule_value)
                 with self.lock:
                     self.current_schedule = (schedule_type, schedule_value)
                     self.next_run = self._calculate_next_run()
    -
    -            # Log with formatted next run information
                 if self.next_run:
                     next_run_info = calc_next_run(self.next_run)
                     logger.info(f"Schedule saved and updated: {schedule_type}={schedule_value}")
                     logger.info(f"{next_run_info['next_run_str']}")
                 else:
                     logger.info(f"Schedule saved and updated: {schedule_type}={schedule_value}")
                 return True
    -
             except Exception as e:
                 logger.error(f"Failed to save schedule: {e}")
                 return False
     
    -    def delete_schedule(self) -> bool:
    -        """Delete the persistent schedule file and reload from environment variable."""
    +    def toggle_persistence(self) -> bool:
    +        """
    +        Toggle persistent schedule enable/disable (non-destructive, stored in schedule.yml as disabled flag).
    +        Reduced logging (no stack trace).
    +        """
             try:
    +            # Load existing file data (if any) to preserve schedule type/value
    +            existing_type = None
    +            existing_value = None
                 if self.schedule_file.exists():
    -                self.schedule_file.unlink()
    -                logger.info("Persistent schedule deleted")
    -
    -                # Reload schedule from environment variable
    +                file_data = self._read_schedule_file()
    +                if file_data:
    +                    existing_type = file_data.get("type")
    +                    existing_value = file_data.get("value")
    +
    +            if not self._persistence_disabled:
    +                # Disable persistence (set disabled true, keep schedule metadata)
    +                self._persistence_disabled = True
    +                self._persist_schedule_file(existing_type, existing_value)  # includes disabled=True
                     with self.lock:
                         self.current_schedule = None
                         self.next_run = None
    -
    -                # Try to load from environment variable
    -                self._load_schedule()
    -
    +                # Reload with suppressed logging to avoid duplicate lines
    +                self._load_schedule(suppress_logging=True)
                     if self.current_schedule:
    -                    logger.info("Scheduler reloaded from environment variable")
    -                    # Log formatted next run information
    -                    if self.next_run:
    -                        next_run_info = calc_next_run(self.next_run)
    -                        logger.info(f"{next_run_info['next_run_str']}")
    +                    st, sv = self.current_schedule
    +                    logger.info(f"Persistence disabled; active {st}={sv} (env fallback)")
    +                else:
    +                    logger.info("Persistence disabled; no active schedule")
    +            else:
    +                # Enable persistence
    +                self._persistence_disabled = False
    +                self._persist_schedule_file(existing_type, existing_value)
    +                self._load_schedule(suppress_logging=True)
    +                if self.current_schedule:
    +                    st, sv = self.current_schedule
    +                    logger.info(f"Persistence enabled; active {st}={sv}")
                     else:
    -                    logger.info("No environment variable found - scheduler stopped")
    +                    logger.info("Persistence enabled; no schedule configured")
     
                 return True
             except Exception as e:
    -            logger.error(f"Failed to delete schedule file: {e}")
    +            logger.error(f"Failed to toggle persistent schedule: {e}")
                 return False
     
         def _read_schedule_file(self) -> Optional[dict[str, Any]]:
    @@ -204,50 +264,52 @@ def _read_schedule_file(self) -> Optional[dict[str, Any]]:
             return None
     
         def get_schedule_info(self) -> dict[str, Any]:
    -        """Get detailed schedule information including source and persistence status."""
    +        """Get detailed schedule information including source, persistence, and disabled state."""
             with self.lock:
    -            # Always check file first for most up-to-date data
    -            if self.schedule_file.exists():
    +            disabled = self._persistence_disabled
    +            file_exists = self.schedule_file.exists()
    +            file_data = None
    +            if file_exists:
                     try:
    -                    # Read current file contents
                         file_data = self._read_schedule_file()
    -                    if file_data:
    -                        schedule_type = file_data.get("type")
    -                        schedule_value = file_data.get("value")
    -                        source = self.schedule_file.name  # Show actual filename
    -                        persistent = True
    -
    -                        return {
    -                            "schedule": str(schedule_value),
    -                            "type": schedule_type,
    -                            "source": source,
    -                            "persistent": persistent,
    -                            "file_exists": True,
    -                        }
    +                    if file_data and bool(file_data.get("disabled")) != disabled:
    +                        # Keep in-memory flag consistent with file if manual edits occurred
    +                        disabled = bool(file_data.get("disabled"))
    +                        self._persistence_disabled = disabled
                     except Exception as e:
                         logger.error(f"Error reading schedule file: {e}")
    -                    # Fall through to use in-memory data
     
    -            # Fall back to in-memory schedule (environment variable or no schedule)
    +            if not disabled and file_data:
    +                schedule_type = file_data.get("type")
    +                schedule_value = file_data.get("value")
    +                return {
    +                    "schedule": str(schedule_value),
    +                    "type": schedule_type,
    +                    "source": self.schedule_file.name,
    +                    "persistent": True,
    +                    "file_exists": True,
    +                    "disabled": False,
    +                }
    +
    +            # Disabled or no file schedule active
                 if self.current_schedule:
                     schedule_type, schedule_value = self.current_schedule
    -                source = "QBT_SCHEDULE"  # Environment variable
    -                persistent = False
    -
                     return {
                         "schedule": str(schedule_value),
                         "type": schedule_type,
    -                    "source": source,
    -                    "persistent": persistent,
    -                    "file_exists": self.schedule_file.exists(),
    +                    "source": "QBT_SCHEDULE" if not disabled else "disabled",
    +                    "persistent": False,
    +                    "file_exists": file_exists,
    +                    "disabled": disabled,
                     }
                 else:
                     return {
                         "schedule": None,
                         "type": None,
    -                    "source": None,
    +                    "source": "disabled" if disabled else None,
                         "persistent": False,
    -                    "file_exists": self.schedule_file.exists(),
    +                    "file_exists": file_exists,
    +                    "disabled": disabled,
                     }
     
         def update_schedule(self, schedule_type: str, schedule_value: Union[str, int], suppress_logging: bool = False) -> bool:
    @@ -374,6 +436,8 @@ def get_current_schedule(self) -> Optional[tuple[str, Union[str, int]]]:
     
         def _validate_schedule(self, schedule_type: str, schedule_value: Union[str, int]) -> bool:
             """Validate schedule parameters."""
    +        if schedule_type is None:
    +            return False
             if schedule_type not in ["cron", "interval"]:
                 return False
     
    @@ -422,6 +486,25 @@ def _calculate_next_run(self) -> Optional[datetime]:
     
             return None
     
    +    def _persist_schedule_file(self, schedule_type: Optional[str], schedule_value: Optional[Union[str, int]]) -> None:
    +        """
    +        Internal helper to persist schedule.yml including disabled flag.
    +        If schedule_type/value are None (e.g., user disabled before ever saving), we still write disabled state.
    +        """
    +        data = {
    +            "type": schedule_type,
    +            "value": schedule_value,
    +            "disabled": self._persistence_disabled,
    +            "updated_at": datetime.now().isoformat(),
    +            "version": 1,
    +        }
    +        tmp_path = self.schedule_file.with_suffix(".yml.tmp")
    +        yaml_writer = YAML(input_data="")
    +        yaml_writer.data = data
    +        yaml_writer.path = str(tmp_path)
    +        yaml_writer.save()
    +        os.replace(tmp_path, self.schedule_file)
    +
         def _scheduler_loop(self):
             """Main scheduler loop running in background thread."""
             logger.debug("Scheduler loop started")
    
  • modules/util.py+204 36 modified
    @@ -4,9 +4,11 @@
     import json
     import logging
     import os
    +import platform
     import re
     import shutil
     import signal
    +import sys
     import time
     from fnmatch import fnmatch
     from pathlib import Path
    @@ -15,7 +17,30 @@
     import ruamel.yaml
     from pytimeparse2 import parse
     
    -logger = logging.getLogger("qBit Manage")
    +
    +class LoggerProxy:
    +    """Proxy that defers attribute access to the active logger instance.
    +
    +    This allows modules that import `util.logger` at import time to still
    +    route all logging calls to the final MyLogger instance once it is
    +    initialized and set via `set_logger`.
    +    """
    +
    +    def __init__(self):
    +        self._logger = None
    +
    +    def set_logger(self, logger):
    +        self._logger = logger
    +
    +    def __getattr__(self, name):
    +        # If MyLogger is set, delegate to it; otherwise, fallback to std logging.
    +        if self._logger is not None:
    +            return getattr(self._logger, name)
    +        fallback = logging.getLogger("qBit Manage")
    +        return getattr(fallback, name)
    +
    +
    +logger = LoggerProxy()
     
     
     def get_list(data, lower=False, split=True, int_list=False, upper=False):
    @@ -174,6 +199,114 @@ def get_arg(env_str, default, arg_bool=False, arg_int=False):
             return default
     
     
    +def runtime_path(*parts) -> Path:
    +    """
    +    Resolve a bundled/runtime-safe path for assets.
    +    - In PyInstaller bundles, files are extracted under sys._MEIPASS.
    +    - In source runs, resolve relative to the project root.
    +    """
    +    if hasattr(sys, "_MEIPASS"):  # type: ignore[attr-defined]
    +        return Path(getattr(sys, "_MEIPASS")).joinpath(*parts)  # type: ignore[arg-type]
    +    # modules/util.py =&gt; project root is parent of modules/
    +    return Path(__file__).resolve().parent.parent.joinpath(*parts)
    +
    +
    +def _platform_config_base() -> Path:
    +    """Return the platform-specific base directory for app config."""
    +    system = platform.system()
    +    home = Path.home()
    +
    +    if system == "Windows":
    +        appdata = os.environ.get("APPDATA")
    +        base = Path(appdata) if appdata else home / "AppData" / "Roaming"
    +        return base / "qbit-manage"
    +    elif system == "Darwin":
    +        return home / "Library" / "Application Support" / "qbit-manage"
    +    else:
    +        xdg = os.environ.get("XDG_CONFIG_HOME")
    +        base = Path(xdg) if xdg else home / ".config"
    +        return base / "qbit-manage"
    +
    +
    +def get_default_config_dir(config_hint: str = None) -> str:
    +    """
    +    Determine the default persistent config directory, leveraging a provided config path/pattern first.
    +
    +    Resolution order:
    +    1) If config_hint is an absolute path or contains a directory component, use its parent directory
    +    2) Otherwise, if config_hint is a name/pattern (e.g. 'config.yml'), search common bases for:
    +         - A direct match to that filename/pattern
    +         - OR a persisted scheduler file 'schedule.yml' (so we don't lose an existing schedule when config.yml is absent)
    +       Common bases (in order):
    +         - /config (container volume)
    +         - repository ./config
    +         - user OS config directory
    +       Return the first base containing either.
    +    3) Fallback to legacy-ish behavior:
    +         - /config if it contains any *.yml / *.yaml
    +         - otherwise user OS config directory
    +    """
    +    # 1) If a direct path is provided, prefer its parent directory
    +    if config_hint:
    +        primary = str(config_hint).split(",")[0].strip()  # take first if comma-separated
    +        if primary:
    +            p = Path(primary).expanduser()
    +            # If absolute or contains a parent component, use that directory
    +            if p.is_absolute() or (str(p.parent) not in (".", "")):
    +                base = p if p.is_dir() else p.parent
    +                return str(base.resolve())
    +
    +            # 2) Try to resolve a plain filename/pattern or schedule.yml in common bases
    +            candidates = []
    +            if os.path.isdir("/config"):
    +                candidates.append(Path("/config"))
    +            repo_config = Path(__file__).resolve().parent.parent / "config"
    +            candidates.append(repo_config)
    +            candidates.append(_platform_config_base())
    +
    +            for base in candidates:
    +                try:
    +                    # Match the primary pattern OR detect existing schedule.yml (persistence)
    +                    if list(base.glob(primary)) or (base / "schedule.yml").exists():
    +                        return str(base.resolve())
    +                except Exception:
    +                    # ignore and continue to next base
    +                    pass
    +
    +    # 3) Fallbacks
    +    has_yaml = glob.glob(os.path.join("/config", "*.yml")) or glob.glob(os.path.join("/config", "*.yaml"))
    +    if os.path.isdir("/config") and has_yaml:
    +        return "/config"
    +    return str(_platform_config_base())
    +
    +
    +def ensure_config_dir_initialized(config_dir) -> str:
    +    """
    +    Ensure the config directory exists and is initialized:
    +    - Creates the config directory
    +    - Creates logs/ and .backups/ subdirectories
    +    - Seeds a default config.yml from bundled config/config.yml.sample if no *.yml/*.yaml present
    +    Returns the absolute config directory as a string.
    +    """
    +    p = Path(config_dir).expanduser().resolve()
    +    p.mkdir(parents=True, exist_ok=True)
    +    (p / "logs").mkdir(parents=True, exist_ok=True)
    +    (p / ".backups").mkdir(parents=True, exist_ok=True)
    +
    +    has_yaml = any(p.glob("*.yml")) or any(p.glob("*.yaml"))
    +    if not has_yaml:
    +        sample = runtime_path("config", "config.yml.sample")
    +        if sample.exists():
    +            dest = p / "config.yml"
    +            try:
    +                shutil.copyfile(sample, dest)
    +            except Exception:
    +                # Non-fatal; if copy fails, user can create a config manually
    +                pass
    +
    +    return str(p)
    +
    +
     class TorrentMessages:
         """Contains list of messages to check against a status of a torrent"""
     
    @@ -309,18 +442,35 @@ def get_current_version():
         # Initialize version tuple
         version = ("Unknown", "Unknown", 0)
     
    -    # Read and parse VERSION file (same logic as qbit_manage.py:400-406)
    +    # Read and parse VERSION file with PyInstaller-safe resolution
         try:
    -        version_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "VERSION")
    -        with open(version_file_path) as handle:
    -            for line in handle.readlines():
    -                line = line.strip()
    -                if len(line) > 0:
    -                    version = parse_version(line)
    -                    break
    +        # Prefer bundled path when running as a frozen app
    +        version_path = None
    +        try:
    +            bundled = runtime_path("VERSION")
    +            if bundled.exists():
    +                version_path = bundled
    +        except Exception:
    +            pass
    +
    +        # Fallback to repository structure: modules/../VERSION
    +        if version_path is None:
    +            repo_relative = Path(__file__).resolve().parent.parent / "VERSION"
    +            if repo_relative.exists():
    +                version_path = repo_relative
    +
    +        # If we found a version file, parse it
    +        if version_path is not None:
    +            with open(version_path, encoding="utf-8") as handle:
    +                for line in handle:
    +                    line = line.strip()
    +                    if line:
    +                        version = parse_version(line)
    +                        break
    +        # If not found, leave version as ("Unknown", "Unknown", 0)
         except Exception as e:
    -        logger.error(f"Error reading VERSION file: {str(e)}")
    -        return version, "Unknown"
    +        # Non-fatal in frozen apps; keep noise low if VERSION is missing
    +        logger.debug(f"VERSION read fallback hit: {e}")
     
         # Get environment version (same as qbit_manage.py:282)
         env_version = os.environ.get("BRANCH_NAME", "master")
    @@ -917,43 +1067,61 @@ def has_hardlinks(self, file, ignore_root_dir):
     
     
     def get_root_files(root_dir, remote_dir, exclude_dir=None):
    -    """Get all files in root directory with optimized path handling and filtering."""
    +    """
    +    Get all files in root directory with optimized path handling and filtering.
    +
    +    Windows/UNC-safe:
    +    - If remote_dir is empty or effectively the same as root_dir, walk root_dir directly.
    +    - Otherwise, walk remote_dir (the accessible path) and map paths back to the root_dir representation.
    +    """
         if not root_dir:
             return []
    -    # Don't check if root_dir exists when remote_dir != root_dir, since root_dir might not be accessible
    -    if root_dir == remote_dir and not os.path.exists(root_dir):
    +
    +    # Normalize for robust equality checks across platforms (handles UNC vs local, slashes, case on Windows)
    +    try:
    +        rd_norm = os.path.normcase(os.path.normpath(root_dir)) if root_dir else ""
    +        rem_norm = os.path.normcase(os.path.normpath(remote_dir)) if remote_dir else ""
    +    except Exception:
    +        rd_norm = root_dir or ""
    +        rem_norm = remote_dir or ""
    +
    +    # Treat missing/empty remote_dir as "same path" (walk root_dir directly)
    +    is_same_path = (not remote_dir) or (rem_norm == rd_norm)
    +
    +    # Determine which base directory to walk and validate it exists
    +    base_to_walk = root_dir if is_same_path else remote_dir
    +    if not base_to_walk or not os.path.isdir(base_to_walk):
             return []
     
    -    # Pre-calculate path transformations
    -    is_same_path = remote_dir == root_dir
    +    # Build an exclude path in the correct namespace
         local_exclude_dir = None
    +    if exclude_dir:
    +        if is_same_path:
    +            local_exclude_dir = exclude_dir
    +        else:
    +            # Convert an exclude in remote namespace to root namespace for comparison after replacement
    +            try:
    +                local_exclude_dir = exclude_dir.replace(remote_dir, root_dir, 1)
    +            except Exception:
    +                local_exclude_dir = None
     
    -    if exclude_dir and not is_same_path:
    -        local_exclude_dir = exclude_dir.replace(remote_dir, root_dir)
    -
    -    # Use list comprehension with pre-filtered results
         root_files = []
     
    -    # Optimize path replacement
         if is_same_path:
    -        # Fast path when paths are the same
    -        for path, subdirs, files in os.walk(root_dir):
    -            if local_exclude_dir and local_exclude_dir in path:
    +        # Fast path when paths are the same or remote_dir not provided
    +        for path, subdirs, files in os.walk(base_to_walk):
    +            if local_exclude_dir and os.path.normcase(local_exclude_dir) in os.path.normcase(path):
                     continue
    -            root_files.extend(os.path.join(path, name) for name in files)
    +            for name in files:
    +                root_files.append(os.path.join(path, name))
         else:
    -        # Path replacement needed - walk remote_dir (accessible) and convert paths to root_dir format
    -        path_replacements = {}
    -        for path, subdirs, files in os.walk(remote_dir):
    -            if local_exclude_dir and local_exclude_dir in path:
    +        # Walk the accessible remote_dir and convert to root_dir representation once per directory
    +        for path, subdirs, files in os.walk(base_to_walk):
    +            replaced_path = path.replace(remote_dir, root_dir, 1)
    +            if local_exclude_dir and os.path.normcase(local_exclude_dir) in os.path.normcase(replaced_path):
                     continue
    -
    -            # Cache path replacement - convert remote_dir paths to root_dir paths
    -            if path not in path_replacements:
    -                path_replacements[path] = path.replace(remote_dir, root_dir)
    -
    -            replaced_path = path_replacements[path]
    -            root_files.extend(os.path.join(replaced_path, name) for name in files)
    +            for name in files:
    +                root_files.append(os.path.join(replaced_path, name))
     
         return root_files
     
    
  • modules/web_api.py+133 52 modified
    @@ -3,12 +3,12 @@
     from __future__ import annotations
     
     import asyncio
    -import glob
     import json
     import logging
     import os
     import re
     import shutil
    +import uuid
     from contextlib import asynccontextmanager
     from dataclasses import dataclass
     from dataclasses import field
    @@ -24,6 +24,7 @@
     from fastapi import APIRouter
     from fastapi import FastAPI
     from fastapi import HTTPException
    +from fastapi import Request
     from fastapi.middleware.cors import CORSMiddleware
     from fastapi.responses import FileResponse
     from fastapi.staticfiles import StaticFiles
    @@ -35,7 +36,13 @@
     from modules.util import format_stats_summary
     from modules.util import get_matching_config_files
     
    -logger = util.logger
    +
    +class _LoggerProxy:
    +    def __getattr__(self, name):
    +        return getattr(util.logger, name)
    +
    +
    +logger = _LoggerProxy()
     
     
     class CommandRequest(BaseModel):
    @@ -132,13 +139,7 @@ async def process_queue_periodically(web_api: WebAPI) -> None:
     class WebAPI:
         """Web API handler for qBittorrent-Manage."""
     
    -    default_dir: str = field(
    -        default_factory=lambda: (
    -            "/config"
    -            if os.path.isdir("/config") and glob.glob(os.path.join("/config", "*.yml"))
    -            else os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config")
    -        )
    -    )
    +    default_dir: str = field(default_factory=lambda: util.ensure_config_dir_initialized(util.get_default_config_dir()))
         args: dict = field(default_factory=dict)
         app: FastAPI = field(default=None)
         is_running: Synchronized[bool] = field(default=None)
    @@ -175,6 +176,18 @@ async def lifespan(app: FastAPI):
             app = FastAPI(lifespan=lifespan)
             object.__setattr__(self, "app", app)
     
    +        # If caller provided a config_dir (e.g., computed in qbit_manage), prefer it
    +        try:
    +            provided_dir = self.args.get("config_dir")
    +            if provided_dir:
    +                resolved_dir = util.ensure_config_dir_initialized(provided_dir)
    +                object.__setattr__(self, "default_dir", resolved_dir)
    +            else:
    +                # Ensure default dir is initialized
    +                object.__setattr__(self, "default_dir", util.ensure_config_dir_initialized(self.default_dir))
    +        except Exception as e:
    +            logger.error(f"Failed to apply provided config_dir '{self.args.get('config_dir')}': {e}")
    +
             # Initialize paths during startup
             object.__setattr__(self, "config_path", Path(self.default_dir))
             object.__setattr__(self, "logs_path", Path(self.default_dir) / "logs")
    @@ -209,7 +222,7 @@ async def lifespan(app: FastAPI):
             # Schedule management routes
             api_router.get("/scheduler")(self.get_scheduler_status)
             api_router.put("/schedule")(self.update_schedule)
    -        api_router.delete("/schedule")(self.delete_schedule)
    +        api_router.post("/schedule/persistence/toggle")(self.toggle_schedule_persistence)
     
             api_router.get("/logs")(self.get_logs)
             api_router.get("/log_files")(self.list_log_files)
    @@ -225,7 +238,7 @@ async def lifespan(app: FastAPI):
             self.app.include_router(api_router, prefix=api_prefix)
     
             # Mount static files for web UI
    -        web_ui_dir = Path(__file__).parent.parent / "web-ui"
    +        web_ui_dir = util.runtime_path("web-ui")
             if web_ui_dir.exists():
                 if base_url:
                     # When base URL is configured, mount static files at the base URL path
    @@ -244,7 +257,7 @@ async def serve_index():
                     return RedirectResponse(url=base_url + "/", status_code=302)
     
                 # Otherwise, serve the web UI normally
    -            web_ui_path = Path(__file__).parent.parent / "web-ui" / "index.html"
    +            web_ui_path = util.runtime_path("web-ui", "index.html")
                 if web_ui_path.exists():
                     return FileResponse(str(web_ui_path))
                 raise HTTPException(status_code=404, detail="Web UI not found")
    @@ -254,7 +267,7 @@ async def serve_index():
     
                 @self.app.get(base_url + "/")
                 async def serve_base_url_index():
    -                web_ui_path = Path(__file__).parent.parent / "web-ui" / "index.html"
    +                web_ui_path = util.runtime_path("web-ui", "index.html")
                     if web_ui_path.exists():
                         return FileResponse(str(web_ui_path))
                     raise HTTPException(status_code=404, detail="Web UI not found")
    @@ -268,7 +281,7 @@ async def catch_all(full_path: str):
     
                 # For any non-API route that doesn't start with api/ or static/, serve the index.html (SPA routing)
                 if not full_path.startswith(f"{api_path}/") and not full_path.startswith(f"{static_path}/"):
    -                web_ui_path = Path(__file__).parent.parent / "web-ui" / "index.html"
    +                web_ui_path = util.runtime_path("web-ui", "index.html")
                     if web_ui_path.exists():
                         return FileResponse(str(web_ui_path))
     
    @@ -1024,17 +1037,38 @@ async def list_config_backups(self, filename: str) -> dict:
                 logger.error(f"Error listing backups for '{filename}': {str(e)}")
                 raise HTTPException(status_code=500, detail=str(e))
     
    -    async def restore_config_from_backup(self, filename: str, request: dict) -> dict:
    +    async def restore_config_from_backup(self, filename: str) -> dict:
             """Restore configuration from a backup file."""
             try:
    -            backup_filename = request.get("backup_id")
    +            # Use the filename from the URL path as the backup file to restore
    +            backup_filename = filename
                 if not backup_filename:
    -                raise HTTPException(status_code=400, detail="backup_id is required")
    +                raise HTTPException(status_code=400, detail="filename is required")
    +
    +            # Security: Validate and sanitize the backup_filename to prevent path traversal
    +            # Remove any path separators and parent directory references
    +            sanitized_backup_filename = os.path.basename(backup_filename)
    +            if not sanitized_backup_filename or sanitized_backup_filename != backup_filename:
    +                raise HTTPException(status_code=400, detail="Invalid filename: path traversal not allowed")
     
    -            backup_file_path = self.backup_path / backup_filename
    +            # Additional validation: ensure the backup filename doesn't contain dangerous characters
    +            if any(char in sanitized_backup_filename for char in ["..", "/", "\\", "\0"]):
    +                raise HTTPException(status_code=400, detail="Invalid filename: contains forbidden characters")
    +
    +            # Construct the backup file path safely
    +            backup_file_path = self.backup_path / sanitized_backup_filename
    +
    +            # Security: Ensure the resolved path is still within the backup directory
    +            try:
    +                backup_file_path = backup_file_path.resolve()
    +                backup_dir_resolved = self.backup_path.resolve()
    +                if not str(backup_file_path).startswith(str(backup_dir_resolved)):
    +                    raise HTTPException(status_code=400, detail="Invalid filename: path traversal not allowed")
    +            except Exception:
    +                raise HTTPException(status_code=400, detail="Invalid filename: unable to resolve path")
     
                 if not backup_file_path.exists():
    -                raise HTTPException(status_code=404, detail=f"Backup file '{backup_filename}' not found")
    +                raise HTTPException(status_code=404, detail=f"Backup file '{sanitized_backup_filename}' not found")
     
                 # Load backup data
                 yaml_loader = YAML(str(backup_file_path))
    @@ -1045,7 +1079,7 @@ async def restore_config_from_backup(self, filename: str, request: dict) -> dict
     
                 return {
                     "status": "success",
    -                "message": f"Backup '{backup_filename}' loaded successfully",
    +                "message": f"Backup '{sanitized_backup_filename}' loaded successfully",
                     "data": backup_data_for_frontend,
                 }
     
    @@ -1150,6 +1184,7 @@ async def get_scheduler_status(self) -> dict:
                     "source": schedule_info.get("source"),
                     "persistent": schedule_info.get("persistent", False),
                     "file_exists": schedule_info.get("file_exists", False),
    +                "disabled": schedule_info.get("disabled", False),
                 }
     
             except HTTPException:
    @@ -1158,15 +1193,28 @@ async def get_scheduler_status(self) -> dict:
                 logger.error(f"Error getting scheduler status: {str(e)}")
                 raise HTTPException(status_code=500, detail=str(e))
     
    -    async def update_schedule(self, request: dict) -> dict:
    -        """Update and persist schedule configuration."""
    +    async def update_schedule(self, request: Request) -> dict:
    +        """Update and persist schedule configuration with diagnostic instrumentation."""
             try:
                 from modules.scheduler import Scheduler
     
    -            # Extract schedule data from request
    -            schedule_data = await request.json() if hasattr(request, "json") else request
    -            schedule_value = schedule_data.get("schedule", "").strip()
    -            schedule_type = schedule_data.get("type", "").strip()
    +            correlation_id = uuid.uuid4().hex[:12]
    +            client_host = "n/a"
    +            if getattr(request, "client", None):
    +                try:
    +                    client_host = request.client.host  # type: ignore[attr-defined]
    +                except Exception:
    +                    pass
    +
    +            # Extract schedule data from FastAPI Request
    +            schedule_data = await request.json()
    +            schedule_value = (schedule_data.get("schedule") or "").strip()
    +            schedule_type = (schedule_data.get("type") or "").strip()
    +
    +            logger.debug(
    +                f"UPDATE /schedule cid={correlation_id} client={client_host} "
    +                f"payload_raw_type={type(schedule_data).__name__} value={schedule_value!r} type_hint={schedule_type!r}"
    +            )
     
                 if not schedule_value:
                     raise HTTPException(status_code=400, detail="Schedule value is required")
    @@ -1176,47 +1224,68 @@ async def update_schedule(self, request: dict) -> dict:
                     schedule_type, parsed_value = self._parse_schedule(schedule_value)
                     if not schedule_type:
                         raise HTTPException(
    -                        status_code=400, detail="Invalid schedule format. Must be a cron expression or interval in minutes"
    +                        status_code=400,
    +                        detail="Invalid schedule format. Must be a cron expression or interval in minutes",
                         )
                 else:
                     # Validate provided type
                     if schedule_type not in ["cron", "interval"]:
                         raise HTTPException(status_code=400, detail="Schedule type must be 'cron' or 'interval'")
    -
    -                # Parse and validate the value
    +                # Parse & validate value
                     if schedule_type == "interval":
                         try:
                             parsed_value = int(schedule_value)
                             if parsed_value <= 0:
                                 raise ValueError("Interval must be positive")
                         except ValueError:
                             raise HTTPException(status_code=400, detail="Invalid interval value")
    -                else:  # cron
    -                    parsed_value = schedule_value
    +                else:
    +                    parsed_value = schedule_value  # cron
     
    -            # Create a scheduler instance to save the schedule
                 scheduler = Scheduler(self.default_dir, suppress_logging=True, read_only=True)
    +            existed_before = scheduler.schedule_file.exists()
    +            prev_contents = None
    +            if existed_before:
    +                try:
    +                    with open(scheduler.schedule_file, encoding="utf-8", errors="ignore") as f:
    +                        prev_contents = f.read().strip()
    +                except Exception:
    +                    prev_contents = "<read_error>"
     
    -            # Save to persistent storage
                 success = scheduler.save_schedule(schedule_type, str(parsed_value))
    +            new_size = None
    +            if scheduler.schedule_file.exists():
    +                try:
    +                    new_size = scheduler.schedule_file.stat().st_size
    +                except Exception:
    +                    pass
     
                 if not success:
    +                logger.error(f"UPDATE /schedule cid={correlation_id} failed to save schedule")
                     raise HTTPException(status_code=500, detail="Failed to save schedule")
     
    +            logger.debug(
    +                f"UPDATE /schedule cid={correlation_id} persisted path={scheduler.schedule_file} "
    +                f"existed_before={existed_before} new_exists={scheduler.schedule_file.exists()} "
    +                f"new_size={new_size} prev_hash={hash(prev_contents) if prev_contents else None}"
    +            )
    +
                 # Send update to main process via IPC queue
                 if self.scheduler_update_queue:
                     try:
    -                    update_data = {"type": schedule_type, "value": parsed_value}
    +                    update_data = {"type": schedule_type, "value": parsed_value, "cid": correlation_id}
                         self.scheduler_update_queue.put(update_data)
    +                    logger.debug(f"UPDATE /schedule cid={correlation_id} IPC sent")
                     except Exception as e:
    -                    logger.error(f"Failed to send scheduler update to main process: {e}")
    +                    logger.error(f"Failed IPC scheduler update cid={correlation_id}: {e}")
     
                 return {
                     "success": True,
                     "message": f"Schedule saved successfully: {schedule_type}={parsed_value}",
                     "schedule": str(parsed_value),
                     "type": schedule_type,
                     "persistent": True,
    +                "correlationId": correlation_id,
                 }
     
             except HTTPException:
    @@ -1228,34 +1297,46 @@ async def update_schedule(self, request: dict) -> dict:
                 logger.error(f"Error updating schedule: {str(e)}")
                 raise HTTPException(status_code=500, detail=str(e))
     
    -    async def delete_schedule(self) -> dict:
    -        """Delete persistent schedule configuration."""
    +    async def toggle_schedule_persistence(self, request: Request) -> dict:
    +        """
    +        Toggle persistent schedule enable/disable (non-destructive) with diagnostics.
    +        """
             try:
                 from modules.scheduler import Scheduler
     
    -            # Create a scheduler instance to delete the schedule
    +            correlation_id = uuid.uuid4().hex[:12]
                 scheduler = Scheduler(self.default_dir, suppress_logging=True, read_only=True)
    +            file_exists_before = scheduler.schedule_file.exists()
    +
    +            # Execute toggle (scheduler emits single summary line internally)
    +            success = scheduler.toggle_persistence()
    +            if not success:
    +                raise HTTPException(status_code=500, detail="Failed to toggle persistence")
     
    -            success = scheduler.delete_schedule()
    +            disabled_after = getattr(scheduler, "_persistence_disabled", False)
    +            action = "disabled" if disabled_after else "enabled"
     
    -            if success:
    -                # Send delete notification to main process via IPC queue
    -                if self.scheduler_update_queue:
    -                    try:
    -                        update_data = {"type": "delete", "value": None}
    -                        self.scheduler_update_queue.put(update_data)
    -                        logger.debug("Sent scheduler delete notification to main process")
    -                    except Exception as e:
    -                        logger.error(f"Failed to send scheduler delete notification to main process: {e}")
    +            # Notify main process with new explicit type (minimal logging)
    +            if self.scheduler_update_queue:
    +                try:
    +                    update_data = {"type": "toggle_persistence", "value": None, "cid": correlation_id}
    +                    self.scheduler_update_queue.put(update_data)
    +                except Exception as e:
    +                    logger.error(f"Failed to send scheduler toggle notification: {e}")
     
    -                return {"success": True, "message": "Persistent schedule deleted successfully"}
    -            else:
    -                raise HTTPException(status_code=500, detail="Failed to delete schedule")
    +            return {
    +                "success": True,
    +                "message": f"Persistent schedule {action}",
    +                "correlationId": correlation_id,
    +                "fileExistedBefore": file_exists_before,
    +                "disabled": disabled_after,
    +                "action": action,
    +            }
     
             except HTTPException:
                 raise
             except Exception as e:
    -            logger.error(f"Error deleting schedule: {str(e)}")
    +            logger.error(f"Error toggling persistent schedule: {str(e)}")
                 raise HTTPException(status_code=500, detail=str(e))
     
         def _parse_schedule(self, schedule_value: str) -> tuple[Optional[str], Optional[Union[str, int]]]:
    
  • .pre-commit-config.yaml+2 1 modified
    @@ -1,14 +1,15 @@
     ---
     repos:
       - repo: https://github.com/pre-commit/pre-commit-hooks
    -    rev: v5.0.0
    +    rev: v6.0.0
         hooks:
           - id: trailing-whitespace
           - id: end-of-file-fixer
           - id: check-merge-conflict
           - id: check-json
           - id: check-yaml
           - id: check-added-large-files
    +        args: [--maxkb=600]
           - id: fix-byte-order-marker
           - id: pretty-format-json
             args: [--autofix, --indent, '4', --no-sort-keys]
    
  • pyproject.toml+2 2 modified
    @@ -36,8 +36,8 @@ Repository = "https://github.com/StuffAnThings/qbit_manage"
     
     [project.optional-dependencies]
     dev = [
    -    "pre-commit==4.2.0",
    -    "ruff==0.12.8",
    +    "pre-commit==4.3.0",
    +    "ruff==0.12.9",
     ]
     
     [tool.ruff]
    
  • qbit_manage.py+165 82 modified
    @@ -2,25 +2,31 @@
     """qBittorrent Manager."""
     
     import argparse
    -import glob
     import multiprocessing
     import os
     import platform
     import sys
    +import threading
     import time
    +import webbrowser
     from datetime import datetime
     from datetime import timedelta
     from multiprocessing import Manager
     
    +import requests
    +
     from modules.scheduler import Scheduler
     from modules.scheduler import calc_next_run
     from modules.scheduler import is_valid_cron_syntax
    +from modules.util import ensure_config_dir_initialized
     from modules.util import execute_qbit_commands
     from modules.util import format_stats_summary
     from modules.util import get_arg
    +from modules.util import get_default_config_dir
     from modules.util import get_matching_config_files
     
     try:
    +    from croniter import croniter
         from humanize import precisedelta
     
         from modules.logs import MyLogger
    @@ -45,8 +51,9 @@
         "--web-server",
         dest="web_server",
         action="store_true",
    -    default=False,
    -    help="Start the webUI server to handle command requests via HTTP API.",
    +    default=None,
    +    help="Start the webUI server to handle command requests via HTTP API. "
    +    "Default: enabled on desktop (non-Docker) runs; disabled in Docker.",
     )
     parser.add_argument(
         "-p",
    @@ -229,7 +236,8 @@
     parser.add_argument(
         "-lc", "--log-count", dest="log_count", action="store", default=5, type=int, help="Maximum mumber of logs to keep"
     )
    -args = parser.parse_args()
    +# Use parse_known_args to ignore PyInstaller/multiprocessing injected flags on Windows
    +args, _unknown_cli = parser.parse_known_args()
     
     
     try:
    @@ -246,6 +254,9 @@
     env_version = get_arg("BRANCH_NAME", "master")
     is_docker = get_arg("QBM_DOCKER", False, arg_bool=True)
     web_server = get_arg("QBT_WEB_SERVER", args.web_server, arg_bool=True)
    +# Auto-enable web server by default on non-Docker if not explicitly set via env/flag
    +if web_server is None and not is_docker:
    +    web_server = True
     port = get_arg("QBT_PORT", args.port, arg_int=True)
     base_url = get_arg("QBT_BASE_URL", args.base_url)
     run = get_arg("QBT_RUN", args.run, arg_bool=True)
    @@ -281,10 +292,8 @@
     args = {}
     scheduler = None  # Global scheduler instance
     
    -if os.path.isdir("/config") and glob.glob(os.path.join("/config", config_files)):
    -    default_dir = "/config"
    -else:
    -    default_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config")
    +default_dir = ensure_config_dir_initialized(get_default_config_dir(config_files))
    +args["config_dir"] = default_dir
     
     config_files = get_matching_config_files(config_files, default_dir)
     
    @@ -342,7 +351,12 @@
     logger = MyLogger("qBit Manage", log_file, log_level, default_dir, screen_width, divider[0], False, log_size, log_count)
     from modules import util  # noqa
     
    -util.logger = logger
    +# Ensure all modules that imported util.logger earlier route to this MyLogger
    +try:
    +    util.logger.set_logger(logger)
    +except AttributeError:
    +    # Fallback if util.logger is not a proxy (legacy behavior)
    +    util.logger = logger
     from modules.config import Config  # noqa
     from modules.core.category import Category  # noqa
     from modules.core.recheck import ReCheck  # noqa
    @@ -369,6 +383,27 @@ def my_except_hook(exctype, value, tbi):
     version, branch = util.get_current_version()
     
     
    +def _open_browser_when_ready(url: str, logger):
    +    """Poll the web API until it responds, then open the browser to the WebUI."""
    +    try:
    +        for _ in range(40):  # ~10 seconds total with 0.25s sleep
    +            try:
    +                resp = requests.get(url, timeout=1)
    +                if resp.status_code < 500:
    +                    logger.debug(f"Opening browser to {url}")
    +                    try:
    +                        webbrowser.open(url, new=2)  # new tab if possible
    +                    except Exception:
    +                        pass
    +                    return
    +            except Exception:
    +                pass
    +            time.sleep(0.25)
    +    except Exception:
    +        # Never let browser-opening issues impact main app
    +        pass
    +
    +
     def start_loop(first_run=False):
         """Start the main loop"""
         if len(config_files) == 1:
    @@ -445,8 +480,6 @@ def next_run_from_config():
                     if scheduler and getattr(scheduler, "current_schedule", None):
                         stype, sval = scheduler.current_schedule
                         if stype == "cron":
    -                        from croniter import croniter
    -
                             cron = croniter(sval, now_local)
                             nxt = cron.get_next(datetime)
                             while nxt <= now_local:
    @@ -472,8 +505,6 @@ def next_run_from_config():
                         if stype_chk == "cron":
                             now_guard = datetime.now()
                             if nxt_time <= now_guard:
    -                            from croniter import croniter
    -
                                 cron = croniter(sval_chk, now_guard)
                                 nxt_time = cron.get_next(datetime)
                                 while nxt_time <= now_guard:
    @@ -556,6 +587,55 @@ def end():
         sys.exit(0)
     
     
    +# Define the web server target at module level (required for Windows spawn/frozen PyInstaller)
    +def run_web_server(
    +    port,
    +    process_args,
    +    is_running,
    +    is_running_lock,
    +    web_api_queue,
    +    scheduler_update_queue,
    +    next_scheduled_run_info_shared,
    +):
    +    """Run web server in a separate process with shared args (safe for Windows/PyInstaller)."""
    +    try:
    +        # Create a read-only scheduler in the child process (avoid pickling issues on Windows)
    +        from modules.scheduler import Scheduler
    +        from modules.web_api import create_app
    +
    +        config_dir_local = process_args.get("config_dir", "config")
    +        child_scheduler = Scheduler(config_dir_local, suppress_logging=True, read_only=True)
    +
    +        # Create FastAPI app instance with process args and shared state
    +        app = create_app(
    +            process_args,
    +            is_running,
    +            is_running_lock,
    +            web_api_queue,
    +            scheduler_update_queue,
    +            next_scheduled_run_info_shared,
    +            child_scheduler,
    +        )
    +
    +        # Configure and run uvicorn
    +        import uvicorn as _uvicorn
    +
    +        config = _uvicorn.Config(app, host="0.0.0.0", port=port, log_level="info", access_log=False)
    +        server = _uvicorn.Server(config)
    +        server.run()
    +    except KeyboardInterrupt:
    +        # Gracefully allow shutdown
    +        pass
    +    except Exception:
    +        # Avoid dependency on application logger here; print minimal traceback for diagnostics
    +        try:
    +            import traceback
    +
    +            traceback.print_exc()
    +        except Exception:
    +            pass
    +
    +
     def print_logo(logger):
         global is_docker, version, git_branch
         logger.separator()
    @@ -581,6 +661,7 @@ def print_logo(logger):
     
     
     if __name__ == "__main__":
    +    multiprocessing.freeze_support()
         killer = GracefulKiller()
         logger.add_main_handler()
         print_logo(logger)
    @@ -595,46 +676,6 @@ def print_logo(logger):
             raise
     
         try:
    -
    -        def run_web_server(
    -            port,
    -            process_args,
    -            is_running,
    -            is_running_lock,
    -            web_api_queue,
    -            scheduler_update_queue,
    -            next_scheduled_run_info_shared,
    -            scheduler,
    -        ):
    -            """Run web server in a separate process with shared args"""
    -            try:
    -                import uvicorn
    -
    -                from modules.web_api import create_app
    -
    -                # Create FastAPI app instance with process args and shared state
    -                app = create_app(
    -                    process_args,
    -                    is_running,
    -                    is_running_lock,
    -                    web_api_queue,
    -                    scheduler_update_queue,
    -                    next_scheduled_run_info_shared,
    -                    scheduler,
    -                )
    -
    -                # Configure uvicorn settings
    -                config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="info", access_log=False)
    -
    -                # Run the server
    -                server = uvicorn.Server(config)
    -                server.run()
    -            except ImportError:
    -                logger.critical("Web server dependencies not installed. Please install with: pip install qbit_manage[web]")
    -                sys.exit(1)
    -            except KeyboardInterrupt:
    -                pass
    -
             manager = Manager()
             is_running = manager.Value("b", False)  # 'b' for boolean, initialized to False
             is_running_lock = manager.Lock()  # Separate lock for is_running synchronization
    @@ -645,6 +686,24 @@ def run_web_server(
             # Start web server if enabled and not in run mode
             web_process = None
             if web_server:
    +            # One-time info message about how the web server was enabled
    +            try:
    +                _cli_ws_flag = any(a in ("-ws", "--web-server") for a in sys.argv[1:])
    +                _env_ws_set = "QBT_WEB_SERVER" in os.environ
    +                if web_server:
    +                    if _env_ws_set:
    +                        logger.info("    Web server enabled via override: ENV (QBT_WEB_SERVER)")
    +                    elif _cli_ws_flag:
    +                        logger.info("    Web server enabled via override: CLI (-ws/--web-server)")
    +                    else:
    +                        logger.info("    Web server enabled automatically (desktop default)")
    +            except Exception:
    +                pass
    +
    +            # Log any unknown CLI args (useful for PyInstaller/multiprocessing flags on Windows)
    +            if _unknown_cli:
    +                logger.debug(f"Unknown CLI arguments ignored: {_unknown_cli}")
    +
                 logger.separator("Starting Web Server")
                 logger.info(f"Web API server running on http://0.0.0.0:{port}")
                 if base_url:
    @@ -656,9 +715,6 @@ def run_web_server(
                 # Create a copy of args to pass to the web server process
                 process_args = args.copy()
                 process_args["web_server"] = True  # Indicate this is for the web server
    -            # Create a read-only scheduler for the web server process
    -            # This scheduler can provide status information (for webAPI) but cannot execute tasks
    -            web_scheduler = Scheduler(default_dir, suppress_logging=True, read_only=True)
     
                 web_process = multiprocessing.Process(
                     target=run_web_server,
    @@ -670,12 +726,22 @@ def run_web_server(
                         web_api_queue,
                         scheduler_update_queue,
                         next_scheduled_run_info_shared,
    -                    web_scheduler,
                     ),
                 )
                 web_process.start()
                 logger.info("Web server started in separate process")
     
    +            # If not running in Docker or desktop app, open the WebUI automatically in a browser tab when ready
    +            is_desktop_app = os.getenv("QBT_DESKTOP_APP", "").lower() == "true"
    +            if not is_docker and not is_desktop_app:
    +                try:
    +                    ui_url = f"http://127.0.0.1:{port}"
    +                    if base_url:
    +                        ui_url = f"{ui_url}/{base_url.lstrip('/')}"
    +                    threading.Thread(target=_open_browser_when_ready, args=(ui_url, logger), daemon=True).start()
    +                except Exception:
    +                    pass
    +
             # Handle normal run modes
             if run:
                 run_mode_message = "    Run Mode: Script will exit after completion."
    @@ -689,32 +755,48 @@ def run_scheduled_mode(schedule_type_local, schedule_value_local, source_text_lo
                         run_msg = f"   Scheduled Mode: Running cron '{schedule_value_local}' (from {source_text_local})"
                         if not already_configured:
                             scheduler.update_schedule("cron", schedule_value_local)
    -                else:
    -                    interval_minutes_local = int(schedule_value_local)
    -                    delta_local = timedelta(minutes=interval_minutes_local)
    -                    run_msg = f"   Scheduled Mode: Running every {precisedelta(delta_local)} (from {source_text_local})."
    -                    if not already_configured:
    -                        # Ensure interval schedule is set so next_run is available
    -                        scheduler.update_schedule("interval", interval_minutes_local)
     
    -                # Compute next run info for logging/UI
    -                nr_time = scheduler.get_next_run()
    -                nr_info = calc_next_run(nr_time)
    -                next_scheduled_run_info_shared.update(nr_info)
    +                    # Compute next run info for logging/UI
    +                    nr_time = scheduler.get_next_run()
    +                    nr_info = calc_next_run(nr_time)
    +                    next_scheduled_run_info_shared.update(nr_info)
     
    -                if schedule_type_local == "interval" and startupDelay:
    -                    run_msg += f"\n    Startup Delay: Initial Run will start after {startupDelay} seconds"
                         run_msg += f"\n     {nr_info['next_run_str']}"
                         logger.info(run_msg)
    +
    +                    # Start scheduler for subsequent runs
    +                    scheduler.start(callback=start_loop)
    +                    return
    +
    +                # Interval schedule
    +                interval_minutes_local = int(schedule_value_local)
    +                delta_local = timedelta(minutes=interval_minutes_local)
    +                run_msg = f"   Scheduled Mode: Running every {precisedelta(delta_local)} (from {source_text_local})."
    +                if not already_configured:
    +                    # Ensure interval schedule is set so next_run is available
    +                    scheduler.update_schedule("interval", interval_minutes_local)
    +
    +                # For interval mode: ALWAYS execute an immediate first run on startup
    +                if startupDelay:
    +                    run_msg += f"\n    Startup Delay: Initial Run will start after {startupDelay} seconds"
    +                    logger.info(run_msg)
                         time.sleep(startupDelay)
    -                    # Execute first run immediately after startup delay
    -                    start_loop()
    -                    # Reset baseline for subsequent interval runs
    -                    scheduler.update_schedule("interval", int(schedule_value_local), suppress_logging=True)
                     else:
    -                    run_msg += f"\n     {nr_info['next_run_str']}"
    +                    run_msg += "\n    Initial Run: Starting immediately"
                         logger.info(run_msg)
     
    +                # Execute first run immediately (after optional startupDelay)
    +                start_loop()
    +                # Reset baseline for subsequent interval runs
    +                scheduler.update_schedule("interval", int(schedule_value_local), suppress_logging=True)
    +
    +                # Refresh and publish the corrected next run info after immediate run
    +                corrected_next = scheduler.get_next_run()
    +                if corrected_next:
    +                    corrected_info = calc_next_run(corrected_next)
    +                    next_scheduled_run_info_shared.update(corrected_info)
    +                    logger.info(f"     {corrected_info['next_run_str']}")
    +
                     # Start scheduler for subsequent runs
                     scheduler.start(callback=start_loop)
     
    @@ -753,13 +835,12 @@ def run_scheduled_mode(schedule_type_local, schedule_value_local, source_text_lo
                             schedule_type = update_data["type"]
                             schedule_value = update_data["value"]
     
    -                        if schedule_type == "delete":
    -                            logger.debug("Received scheduler delete notification from web API")
    -                            success = scheduler.delete_schedule()
    -                            if success:
    -                                logger.debug("Main process scheduler reloaded after deletion")
    -                            else:
    -                                logger.error("Failed to reload main process scheduler after deletion")
    +                        if schedule_type == "toggle_persistence":
    +                            try:
    +                                scheduler._load_schedule(suppress_logging=True)
    +                                logger.debug("Scheduler persistence toggle processed")
    +                            except Exception as e:
    +                                logger.error(f"Failed to refresh scheduler after persistence toggle: {e}")
                             else:
                                 logger.debug(f"Received scheduler update from web API: {schedule_type} = {schedule_value}")
                                 success = scheduler.update_schedule(schedule_type, schedule_value, suppress_logging=True)
    @@ -775,6 +856,8 @@ def run_scheduled_mode(schedule_type_local, schedule_value_local, source_text_lo
                     if next_run_time:
                         next_run_info = calc_next_run(next_run_time)
                         next_scheduled_run_info_shared.update(next_run_info)
    +                    if next_run_info["next_run"] != next_run_time:
    +                        logger.info(f"Next scheduled run updated: {next_run_info['next_run_str']}")
     
                     # Sleep for a reasonable interval
                     time.sleep(30)
    
  • README.md+1 1 modified
    @@ -1,4 +1,4 @@
    -# <img src="qbm_logo.png" width="75"> qBit Manage
    +# <img src="icons/qbm_logo.png" width="75"> qBit Manage
     
     [![GitHub release (latest by date)](https://img.shields.io/github/v/release/StuffAnThings/qbit_manage?style=plastic)](https://github.com/StuffAnThings/qbit_manage/releases)
     [![GitHub commits since latest release (by SemVer)](https://img.shields.io/github/commits-since/StuffAnThings/qbit_manage/latest/develop?label=Commits%20in%20Develop&style=plastic)](https://github.com/StuffAnThings/qbit_manage/tree/develop)
    
  • uv.lock+118 115 modified
    @@ -17,17 +17,17 @@ wheels = [
     
     [[package]]
     name = "anyio"
    -version = "4.9.0"
    +version = "4.10.0"
     source = { registry = "https://pypi.org/simple" }
     dependencies = [
         { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
         { name = "idna" },
         { name = "sniffio" },
         { name = "typing-extensions", marker = "python_full_version < '3.13'" },
     ]
    -sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" }
    +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" }
     wheels = [
    -    { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
    +    { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" },
     ]
     
     [[package]]
    @@ -38,11 +38,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/d8/72/e2ee9f8a93c92af1b
     
     [[package]]
     name = "certifi"
    -version = "2025.7.14"
    +version = "2025.8.3"
     source = { registry = "https://pypi.org/simple" }
    -sdist = { url = "https://files.pythonhosted.org/packages/b3/76/52c535bcebe74590f296d6c77c86dabf761c41980e1347a2422e4aa2ae41/certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995", size = 163981, upload-time = "2025-07-14T03:29:28.449Z" }
    +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
     wheels = [
    -    { url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" },
    +    { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
     ]
     
     [[package]]
    @@ -56,76 +56,77 @@ wheels = [
     
     [[package]]
     name = "charset-normalizer"
    -version = "3.4.2"
    -source = { registry = "https://pypi.org/simple" }
    -sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" }
    -wheels = [
    -    { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818, upload-time = "2025-05-02T08:31:46.725Z" },
    -    { url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", size = 144649, upload-time = "2025-05-02T08:31:48.889Z" },
    -    { url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", size = 155045, upload-time = "2025-05-02T08:31:50.757Z" },
    -    { url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", size = 147356, upload-time = "2025-05-02T08:31:52.634Z" },
    -    { url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", size = 149471, upload-time = "2025-05-02T08:31:56.207Z" },
    -    { url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", size = 151317, upload-time = "2025-05-02T08:31:57.613Z" },
    -    { url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", size = 146368, upload-time = "2025-05-02T08:31:59.468Z" },
    -    { url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", size = 154491, upload-time = "2025-05-02T08:32:01.219Z" },
    -    { url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", size = 157695, upload-time = "2025-05-02T08:32:03.045Z" },
    -    { url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", size = 154849, upload-time = "2025-05-02T08:32:04.651Z" },
    -    { url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", size = 150091, upload-time = "2025-05-02T08:32:06.719Z" },
    -    { url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", size = 98445, upload-time = "2025-05-02T08:32:08.66Z" },
    -    { url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", size = 105782, upload-time = "2025-05-02T08:32:10.46Z" },
    -    { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" },
    -    { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" },
    -    { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" },
    -    { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" },
    -    { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" },
    -    { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" },
    -    { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" },
    -    { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" },
    -    { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" },
    -    { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" },
    -    { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" },
    -    { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" },
    -    { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" },
    -    { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" },
    -    { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" },
    -    { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" },
    -    { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" },
    -    { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" },
    -    { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" },
    -    { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" },
    -    { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" },
    -    { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" },
    -    { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" },
    -    { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" },
    -    { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" },
    -    { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" },
    -    { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" },
    -    { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" },
    -    { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" },
    -    { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" },
    -    { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" },
    -    { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" },
    -    { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" },
    -    { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" },
    -    { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" },
    -    { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" },
    -    { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" },
    -    { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" },
    -    { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" },
    -    { url = "https://files.pythonhosted.org/packages/28/f8/dfb01ff6cc9af38552c69c9027501ff5a5117c4cc18dcd27cb5259fa1888/charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", size = 201671, upload-time = "2025-05-02T08:34:12.696Z" },
    -    { url = "https://files.pythonhosted.org/packages/32/fb/74e26ee556a9dbfe3bd264289b67be1e6d616329403036f6507bb9f3f29c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", size = 144744, upload-time = "2025-05-02T08:34:14.665Z" },
    -    { url = "https://files.pythonhosted.org/packages/ad/06/8499ee5aa7addc6f6d72e068691826ff093329fe59891e83b092ae4c851c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", size = 154993, upload-time = "2025-05-02T08:34:17.134Z" },
    -    { url = "https://files.pythonhosted.org/packages/f1/a2/5e4c187680728219254ef107a6949c60ee0e9a916a5dadb148c7ae82459c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", size = 147382, upload-time = "2025-05-02T08:34:19.081Z" },
    -    { url = "https://files.pythonhosted.org/packages/4c/fe/56aca740dda674f0cc1ba1418c4d84534be51f639b5f98f538b332dc9a95/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", size = 149536, upload-time = "2025-05-02T08:34:21.073Z" },
    -    { url = "https://files.pythonhosted.org/packages/53/13/db2e7779f892386b589173dd689c1b1e304621c5792046edd8a978cbf9e0/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", size = 151349, upload-time = "2025-05-02T08:34:23.193Z" },
    -    { url = "https://files.pythonhosted.org/packages/69/35/e52ab9a276186f729bce7a0638585d2982f50402046e4b0faa5d2c3ef2da/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", size = 146365, upload-time = "2025-05-02T08:34:25.187Z" },
    -    { url = "https://files.pythonhosted.org/packages/a6/d8/af7333f732fc2e7635867d56cb7c349c28c7094910c72267586947561b4b/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", size = 154499, upload-time = "2025-05-02T08:34:27.359Z" },
    -    { url = "https://files.pythonhosted.org/packages/7a/3d/a5b2e48acef264d71e036ff30bcc49e51bde80219bb628ba3e00cf59baac/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", size = 157735, upload-time = "2025-05-02T08:34:29.798Z" },
    -    { url = "https://files.pythonhosted.org/packages/85/d8/23e2c112532a29f3eef374375a8684a4f3b8e784f62b01da931186f43494/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", size = 154786, upload-time = "2025-05-02T08:34:31.858Z" },
    -    { url = "https://files.pythonhosted.org/packages/c7/57/93e0169f08ecc20fe82d12254a200dfaceddc1c12a4077bf454ecc597e33/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", size = 150203, upload-time = "2025-05-02T08:34:33.88Z" },
    -    { url = "https://files.pythonhosted.org/packages/2c/9d/9bf2b005138e7e060d7ebdec7503d0ef3240141587651f4b445bdf7286c2/charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", size = 98436, upload-time = "2025-05-02T08:34:35.907Z" },
    -    { url = "https://files.pythonhosted.org/packages/6d/24/5849d46cf4311bbf21b424c443b09b459f5b436b1558c04e45dbb7cc478b/charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", size = 105772, upload-time = "2025-05-02T08:34:37.935Z" },
    -    { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
    +version = "3.4.3"
    +source = { registry = "https://pypi.org/simple" }
    +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" }
    +wheels = [
    +    { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" },
    +    { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" },
    +    { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" },
    +    { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" },
    +    { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" },
    +    { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" },
    +    { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" },
    +    { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" },
    +    { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" },
    +    { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" },
    +    { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" },
    +    { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" },
    +    { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" },
    +    { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" },
    +    { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" },
    +    { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" },
    +    { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" },
    +    { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" },
    +    { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" },
    +    { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" },
    +    { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" },
    +    { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" },
    +    { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" },
    +    { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" },
    +    { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" },
    +    { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" },
    +    { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" },
    +    { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" },
    +    { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" },
    +    { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" },
    +    { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" },
    +    { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" },
    +    { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" },
    +    { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" },
    +    { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" },
    +    { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" },
    +    { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" },
    +    { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" },
    +    { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" },
    +    { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" },
    +    { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" },
    +    { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" },
    +    { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" },
    +    { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" },
    +    { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" },
    +    { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" },
    +    { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" },
    +    { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" },
    +    { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" },
    +    { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" },
    +    { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" },
    +    { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" },
    +    { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" },
    +    { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" },
    +    { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" },
    +    { url = "https://files.pythonhosted.org/packages/c2/ca/9a0983dd5c8e9733565cf3db4df2b0a2e9a82659fd8aa2a868ac6e4a991f/charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", size = 207520, upload-time = "2025-08-09T07:57:11.026Z" },
    +    { url = "https://files.pythonhosted.org/packages/39/c6/99271dc37243a4f925b09090493fb96c9333d7992c6187f5cfe5312008d2/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", size = 147307, upload-time = "2025-08-09T07:57:12.4Z" },
    +    { url = "https://files.pythonhosted.org/packages/e4/69/132eab043356bba06eb333cc2cc60c6340857d0a2e4ca6dc2b51312886b3/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", size = 160448, upload-time = "2025-08-09T07:57:13.712Z" },
    +    { url = "https://files.pythonhosted.org/packages/04/9a/914d294daa4809c57667b77470533e65def9c0be1ef8b4c1183a99170e9d/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", size = 157758, upload-time = "2025-08-09T07:57:14.979Z" },
    +    { url = "https://files.pythonhosted.org/packages/b0/a8/6f5bcf1bcf63cb45625f7c5cadca026121ff8a6c8a3256d8d8cd59302663/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", size = 152487, upload-time = "2025-08-09T07:57:16.332Z" },
    +    { url = "https://files.pythonhosted.org/packages/c4/72/d3d0e9592f4e504f9dea08b8db270821c909558c353dc3b457ed2509f2fb/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", size = 150054, upload-time = "2025-08-09T07:57:17.576Z" },
    +    { url = "https://files.pythonhosted.org/packages/20/30/5f64fe3981677fe63fa987b80e6c01042eb5ff653ff7cec1b7bd9268e54e/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", size = 161703, upload-time = "2025-08-09T07:57:20.012Z" },
    +    { url = "https://files.pythonhosted.org/packages/e1/ef/dd08b2cac9284fd59e70f7d97382c33a3d0a926e45b15fc21b3308324ffd/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", size = 159096, upload-time = "2025-08-09T07:57:21.329Z" },
    +    { url = "https://files.pythonhosted.org/packages/45/8c/dcef87cfc2b3f002a6478f38906f9040302c68aebe21468090e39cde1445/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", size = 153852, upload-time = "2025-08-09T07:57:22.608Z" },
    +    { url = "https://files.pythonhosted.org/packages/63/86/9cbd533bd37883d467fcd1bd491b3547a3532d0fbb46de2b99feeebf185e/charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", size = 99840, upload-time = "2025-08-09T07:57:23.883Z" },
    +    { url = "https://files.pythonhosted.org/packages/ce/d6/7e805c8e5c46ff9729c49950acc4ee0aeb55efb8b3a56687658ad10c3216/charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", size = 107438, upload-time = "2025-08-09T07:57:25.287Z" },
    +    { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
     ]
     
     [[package]]
    @@ -217,11 +218,11 @@ wheels = [
     
     [[package]]
     name = "filelock"
    -version = "3.18.0"
    +version = "3.19.1"
     source = { registry = "https://pypi.org/simple" }
    -sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" }
    +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" }
     wheels = [
    -    { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" },
    +    { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" },
     ]
     
     [[package]]
    @@ -269,11 +270,11 @@ wheels = [
     
     [[package]]
     name = "identify"
    -version = "2.6.12"
    +version = "2.6.13"
     source = { registry = "https://pypi.org/simple" }
    -sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254, upload-time = "2025-05-23T20:37:53.3Z" }
    +sdist = { url = "https://files.pythonhosted.org/packages/82/ca/ffbabe3635bb839aa36b3a893c91a9b0d368cb4d8073e03a12896970af82/identify-2.6.13.tar.gz", hash = "sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32", size = 99243, upload-time = "2025-08-09T19:35:00.6Z" }
     wheels = [
    -    { url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145, upload-time = "2025-05-23T20:37:51.495Z" },
    +    { url = "https://files.pythonhosted.org/packages/e7/ce/461b60a3ee109518c055953729bf9ed089a04db895d47e95444071dcdef2/identify-2.6.13-py2.py3-none-any.whl", hash = "sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b", size = 99153, upload-time = "2025-08-09T19:34:59.1Z" },
     ]
     
     [[package]]
    @@ -314,7 +315,7 @@ wheels = [
     
     [[package]]
     name = "pre-commit"
    -version = "4.2.0"
    +version = "4.3.0"
     source = { registry = "https://pypi.org/simple" }
     dependencies = [
         { name = "cfgv" },
    @@ -323,9 +324,9 @@ dependencies = [
         { name = "pyyaml" },
         { name = "virtualenv" },
     ]
    -sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" }
    +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" }
     wheels = [
    -    { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" },
    +    { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" },
     ]
     
     [[package]]
    @@ -565,13 +566,13 @@ requires-dist = [
         { name = "fastapi", specifier = "==0.116.1" },
         { name = "gitpython", specifier = "==3.1.45" },
         { name = "humanize", specifier = "==4.12.3" },
    -    { name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.2.0" },
    +    { name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.3.0" },
         { name = "pytimeparse2", specifier = "==1.7.1" },
         { name = "qbittorrent-api", specifier = "==2025.7.0" },
         { name = "requests", specifier = "==2.32.4" },
    -    { name = "retrying", specifier = "==1.4.1" },
    +    { name = "retrying", specifier = "==1.4.2" },
         { name = "ruamel-yaml", specifier = "==0.18.14" },
    -    { name = "ruff", marker = "extra == 'dev'", specifier = "==0.12.7" },
    +    { name = "ruff", marker = "extra == 'dev'", specifier = "==0.12.9" },
         { name = "uvicorn", specifier = "==0.35.0" },
     ]
     provides-extras = ["dev"]
    @@ -607,11 +608,11 @@ wheels = [
     
     [[package]]
     name = "retrying"
    -version = "1.4.1"
    +version = "1.4.2"
     source = { registry = "https://pypi.org/simple" }
    -sdist = { url = "https://files.pythonhosted.org/packages/6a/2e/90b236e496810c23eb428a1f3e2723849eb219d6196a4f7afe16f4981b5c/retrying-1.4.1.tar.gz", hash = "sha256:4d206e0ed2aff5ef2f3cd867abb9511e9e8f31127c5aca20f1d5246e476903b0", size = 11344, upload-time = "2025-07-19T09:39:01.906Z" }
    +sdist = { url = "https://files.pythonhosted.org/packages/c8/5a/b17e1e257d3e6f2e7758930e1256832c9ddd576f8631781e6a072914befa/retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39", size = 11411, upload-time = "2025-08-03T03:35:25.189Z" }
     wheels = [
    -    { url = "https://files.pythonhosted.org/packages/7f/25/f3b628e123699139b959551ed922f35af97fa1505e195ae3e6537a14fbc3/retrying-1.4.1-py3-none-any.whl", hash = "sha256:d736050c1adfc0a71fa022d9198ee130b0e66be318678a3fdd8b1b8872dc0997", size = 12184, upload-time = "2025-07-19T09:39:00.574Z" },
    +    { url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" },
     ]
     
     [[package]]
    @@ -681,27 +682,28 @@ wheels = [
     
     [[package]]
     name = "ruff"
    -version = "0.12.7"
    -source = { registry = "https://pypi.org/simple" }
    -sdist = { url = "https://files.pythonhosted.org/packages/a1/81/0bd3594fa0f690466e41bd033bdcdf86cba8288345ac77ad4afbe5ec743a/ruff-0.12.7.tar.gz", hash = "sha256:1fc3193f238bc2d7968772c82831a4ff69252f673be371fb49663f0068b7ec71", size = 5197814, upload-time = "2025-07-29T22:32:35.877Z" }
    -wheels = [
    -    { url = "https://files.pythonhosted.org/packages/e1/d2/6cb35e9c85e7a91e8d22ab32ae07ac39cc34a71f1009a6f9e4a2a019e602/ruff-0.12.7-py3-none-linux_armv6l.whl", hash = "sha256:76e4f31529899b8c434c3c1dede98c4483b89590e15fb49f2d46183801565303", size = 11852189, upload-time = "2025-07-29T22:31:41.281Z" },
    -    { url = "https://files.pythonhosted.org/packages/63/5b/a4136b9921aa84638f1a6be7fb086f8cad0fde538ba76bda3682f2599a2f/ruff-0.12.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:789b7a03e72507c54fb3ba6209e4bb36517b90f1a3569ea17084e3fd295500fb", size = 12519389, upload-time = "2025-07-29T22:31:54.265Z" },
    -    { url = "https://files.pythonhosted.org/packages/a8/c9/3e24a8472484269b6b1821794141f879c54645a111ded4b6f58f9ab0705f/ruff-0.12.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e1c2a3b8626339bb6369116e7030a4cf194ea48f49b64bb505732a7fce4f4e3", size = 11743384, upload-time = "2025-07-29T22:31:59.575Z" },
    -    { url = "https://files.pythonhosted.org/packages/26/7c/458dd25deeb3452c43eaee853c0b17a1e84169f8021a26d500ead77964fd/ruff-0.12.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32dec41817623d388e645612ec70d5757a6d9c035f3744a52c7b195a57e03860", size = 11943759, upload-time = "2025-07-29T22:32:01.95Z" },
    -    { url = "https://files.pythonhosted.org/packages/7f/8b/658798472ef260ca050e400ab96ef7e85c366c39cf3dfbef4d0a46a528b6/ruff-0.12.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47ef751f722053a5df5fa48d412dbb54d41ab9b17875c6840a58ec63ff0c247c", size = 11654028, upload-time = "2025-07-29T22:32:04.367Z" },
    -    { url = "https://files.pythonhosted.org/packages/a8/86/9c2336f13b2a3326d06d39178fd3448dcc7025f82514d1b15816fe42bfe8/ruff-0.12.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a828a5fc25a3efd3e1ff7b241fd392686c9386f20e5ac90aa9234a5faa12c423", size = 13225209, upload-time = "2025-07-29T22:32:06.952Z" },
    -    { url = "https://files.pythonhosted.org/packages/76/69/df73f65f53d6c463b19b6b312fd2391dc36425d926ec237a7ed028a90fc1/ruff-0.12.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5726f59b171111fa6a69d82aef48f00b56598b03a22f0f4170664ff4d8298efb", size = 14182353, upload-time = "2025-07-29T22:32:10.053Z" },
    -    { url = "https://files.pythonhosted.org/packages/58/1e/de6cda406d99fea84b66811c189b5ea139814b98125b052424b55d28a41c/ruff-0.12.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74e6f5c04c4dd4aba223f4fe6e7104f79e0eebf7d307e4f9b18c18362124bccd", size = 13631555, upload-time = "2025-07-29T22:32:12.644Z" },
    -    { url = "https://files.pythonhosted.org/packages/6f/ae/625d46d5164a6cc9261945a5e89df24457dc8262539ace3ac36c40f0b51e/ruff-0.12.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d0bfe4e77fba61bf2ccadf8cf005d6133e3ce08793bbe870dd1c734f2699a3e", size = 12667556, upload-time = "2025-07-29T22:32:15.312Z" },
    -    { url = "https://files.pythonhosted.org/packages/55/bf/9cb1ea5e3066779e42ade8d0cd3d3b0582a5720a814ae1586f85014656b6/ruff-0.12.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06bfb01e1623bf7f59ea749a841da56f8f653d641bfd046edee32ede7ff6c606", size = 12939784, upload-time = "2025-07-29T22:32:17.69Z" },
    -    { url = "https://files.pythonhosted.org/packages/55/7f/7ead2663be5627c04be83754c4f3096603bf5e99ed856c7cd29618c691bd/ruff-0.12.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e41df94a957d50083fd09b916d6e89e497246698c3f3d5c681c8b3e7b9bb4ac8", size = 11771356, upload-time = "2025-07-29T22:32:20.134Z" },
    -    { url = "https://files.pythonhosted.org/packages/17/40/a95352ea16edf78cd3a938085dccc55df692a4d8ba1b3af7accbe2c806b0/ruff-0.12.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4000623300563c709458d0ce170c3d0d788c23a058912f28bbadc6f905d67afa", size = 11612124, upload-time = "2025-07-29T22:32:22.645Z" },
    -    { url = "https://files.pythonhosted.org/packages/4d/74/633b04871c669e23b8917877e812376827c06df866e1677f15abfadc95cb/ruff-0.12.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:69ffe0e5f9b2cf2b8e289a3f8945b402a1b19eff24ec389f45f23c42a3dd6fb5", size = 12479945, upload-time = "2025-07-29T22:32:24.765Z" },
    -    { url = "https://files.pythonhosted.org/packages/be/34/c3ef2d7799c9778b835a76189c6f53c179d3bdebc8c65288c29032e03613/ruff-0.12.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a07a5c8ffa2611a52732bdc67bf88e243abd84fe2d7f6daef3826b59abbfeda4", size = 12998677, upload-time = "2025-07-29T22:32:27.022Z" },
    -    { url = "https://files.pythonhosted.org/packages/77/ab/aca2e756ad7b09b3d662a41773f3edcbd262872a4fc81f920dc1ffa44541/ruff-0.12.7-py3-none-win32.whl", hash = "sha256:c928f1b2ec59fb77dfdf70e0419408898b63998789cc98197e15f560b9e77f77", size = 11756687, upload-time = "2025-07-29T22:32:29.381Z" },
    -    { url = "https://files.pythonhosted.org/packages/b4/71/26d45a5042bc71db22ddd8252ca9d01e9ca454f230e2996bb04f16d72799/ruff-0.12.7-py3-none-win_amd64.whl", hash = "sha256:9c18f3d707ee9edf89da76131956aba1270c6348bfee8f6c647de841eac7194f", size = 12912365, upload-time = "2025-07-29T22:32:31.517Z" },
    -    { url = "https://files.pythonhosted.org/packages/4c/9b/0b8aa09817b63e78d94b4977f18b1fcaead3165a5ee49251c5d5c245bb2d/ruff-0.12.7-py3-none-win_arm64.whl", hash = "sha256:dfce05101dbd11833a0776716d5d1578641b7fddb537fe7fa956ab85d1769b69", size = 11982083, upload-time = "2025-07-29T22:32:33.881Z" },
    +version = "0.12.9"
    +source = { registry = "https://pypi.org/simple" }
    +sdist = { url = "https://files.pythonhosted.org/packages/4a/45/2e403fa7007816b5fbb324cb4f8ed3c7402a927a0a0cb2b6279879a8bfdc/ruff-0.12.9.tar.gz", hash = "sha256:fbd94b2e3c623f659962934e52c2bea6fc6da11f667a427a368adaf3af2c866a", size = 5254702, upload-time = "2025-08-14T16:08:55.2Z" }
    +wheels = [
    +    { url = "https://files.pythonhosted.org/packages/ad/20/53bf098537adb7b6a97d98fcdebf6e916fcd11b2e21d15f8c171507909cc/ruff-0.12.9-py3-none-linux_armv6l.whl", hash = "sha256:fcebc6c79fcae3f220d05585229463621f5dbf24d79fdc4936d9302e177cfa3e", size = 11759705, upload-time = "2025-08-14T16:08:12.968Z" },
    +    { url = "https://files.pythonhosted.org/packages/20/4d/c764ee423002aac1ec66b9d541285dd29d2c0640a8086c87de59ebbe80d5/ruff-0.12.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aed9d15f8c5755c0e74467731a007fcad41f19bcce41cd75f768bbd687f8535f", size = 12527042, upload-time = "2025-08-14T16:08:16.54Z" },
    +    { url = "https://files.pythonhosted.org/packages/8b/45/cfcdf6d3eb5fc78a5b419e7e616d6ccba0013dc5b180522920af2897e1be/ruff-0.12.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5b15ea354c6ff0d7423814ba6d44be2807644d0c05e9ed60caca87e963e93f70", size = 11724457, upload-time = "2025-08-14T16:08:18.686Z" },
    +    { url = "https://files.pythonhosted.org/packages/72/e6/44615c754b55662200c48bebb02196dbb14111b6e266ab071b7e7297b4ec/ruff-0.12.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d596c2d0393c2502eaabfef723bd74ca35348a8dac4267d18a94910087807c53", size = 11949446, upload-time = "2025-08-14T16:08:21.059Z" },
    +    { url = "https://files.pythonhosted.org/packages/fd/d1/9b7d46625d617c7df520d40d5ac6cdcdf20cbccb88fad4b5ecd476a6bb8d/ruff-0.12.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b15599931a1a7a03c388b9c5df1bfa62be7ede6eb7ef753b272381f39c3d0ff", size = 11566350, upload-time = "2025-08-14T16:08:23.433Z" },
    +    { url = "https://files.pythonhosted.org/packages/59/20/b73132f66f2856bc29d2d263c6ca457f8476b0bbbe064dac3ac3337a270f/ruff-0.12.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3d02faa2977fb6f3f32ddb7828e212b7dd499c59eb896ae6c03ea5c303575756", size = 13270430, upload-time = "2025-08-14T16:08:25.837Z" },
    +    { url = "https://files.pythonhosted.org/packages/a2/21/eaf3806f0a3d4c6be0a69d435646fba775b65f3f2097d54898b0fd4bb12e/ruff-0.12.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:17d5b6b0b3a25259b69ebcba87908496e6830e03acfb929ef9fd4c58675fa2ea", size = 14264717, upload-time = "2025-08-14T16:08:27.907Z" },
    +    { url = "https://files.pythonhosted.org/packages/d2/82/1d0c53bd37dcb582b2c521d352fbf4876b1e28bc0d8894344198f6c9950d/ruff-0.12.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72db7521860e246adbb43f6ef464dd2a532ef2ef1f5dd0d470455b8d9f1773e0", size = 13684331, upload-time = "2025-08-14T16:08:30.352Z" },
    +    { url = "https://files.pythonhosted.org/packages/3b/2f/1c5cf6d8f656306d42a686f1e207f71d7cebdcbe7b2aa18e4e8a0cb74da3/ruff-0.12.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a03242c1522b4e0885af63320ad754d53983c9599157ee33e77d748363c561ce", size = 12739151, upload-time = "2025-08-14T16:08:32.55Z" },
    +    { url = "https://files.pythonhosted.org/packages/47/09/25033198bff89b24d734e6479e39b1968e4c992e82262d61cdccaf11afb9/ruff-0.12.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fc83e4e9751e6c13b5046d7162f205d0a7bac5840183c5beebf824b08a27340", size = 12954992, upload-time = "2025-08-14T16:08:34.816Z" },
    +    { url = "https://files.pythonhosted.org/packages/52/8e/d0dbf2f9dca66c2d7131feefc386523404014968cd6d22f057763935ab32/ruff-0.12.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:881465ed56ba4dd26a691954650de6ad389a2d1fdb130fe51ff18a25639fe4bb", size = 12899569, upload-time = "2025-08-14T16:08:36.852Z" },
    +    { url = "https://files.pythonhosted.org/packages/a0/bd/b614d7c08515b1428ed4d3f1d4e3d687deffb2479703b90237682586fa66/ruff-0.12.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:43f07a3ccfc62cdb4d3a3348bf0588358a66da756aa113e071b8ca8c3b9826af", size = 11751983, upload-time = "2025-08-14T16:08:39.314Z" },
    +    { url = "https://files.pythonhosted.org/packages/58/d6/383e9f818a2441b1a0ed898d7875f11273f10882f997388b2b51cb2ae8b5/ruff-0.12.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:07adb221c54b6bba24387911e5734357f042e5669fa5718920ee728aba3cbadc", size = 11538635, upload-time = "2025-08-14T16:08:41.297Z" },
    +    { u
    ... [truncated]
    
  • VERSION+1 1 modified
    @@ -1 +1 @@
    -4.5.3
    +4.5.4
    
  • web-ui/img/favicon.ico+0 0 modified
  • web-ui/js/api.js+2 4 modified
    @@ -166,10 +166,8 @@ class API {
         /**
          * Restore configuration from backup
          */
    -    async restoreConfig(filename, backupId) {
    -        return this.post(`/configs/${encodeURIComponent(filename)}/restore`, {
    -            backup_id: backupId
    -        });
    +    async restoreConfig(filename) {
    +        return this.post(`/configs/${encodeURIComponent(filename)}/restore`, {});
         }
     
         /**
    
  • web-ui/js/components/SchedulerControl.js+42 26 modified
    @@ -141,11 +141,11 @@ class SchedulerControl {
                                 Reset
                             </button>
                             <button type="button" class="btn btn-outline btn-danger" id="delete-schedule-btn" aria-describedby="delete-help">
    -                            Delete Persistent Schedule
    +                            Disable Persistent Schedule
                             </button>
                             <div id="update-help" class="sr-only">Saves the schedule persistently across restarts</div>
                             <div id="reset-help" class="sr-only">Resets the form to its default state</div>
    -                        <div id="delete-help" class="sr-only">Deletes the persistent schedule file</div>
    +                        <div id="delete-help" class="sr-only">Toggles persistent schedule enable/disable</div>
                         </div>
                     </form>
                 </div>
    @@ -219,10 +219,10 @@ class SchedulerControl {
                 });
             }
     
    -        // Delete persistent schedule
    +        // Toggle persistent schedule (disable/enable without deleting file)
             if (deleteBtn) {
                 deleteBtn.addEventListener('click', () => {
    -                this.handleScheduleDelete();
    +                this.handlePersistenceToggle();
                 });
             }
     
    @@ -295,9 +295,21 @@ class SchedulerControl {
                 scheduleSource.textContent = status.source || '-';
             }
     
    -        // Show/hide delete button based on persistence
    +        // Show & update toggle button state
             if (deleteBtn) {
    -            deleteBtn.style.display = status.persistent ? 'inline-block' : 'none';
    +            const disabled = !!status.disabled;
    +            const fileExists = !!status.file_exists;
    +            // Show button if a file exists OR currently disabled (so user can re-enable)
    +            deleteBtn.style.display = (fileExists || disabled) ? 'inline-block' : 'none';
    +            if (disabled) {
    +                deleteBtn.textContent = 'Enable Persistent Schedule';
    +                deleteBtn.classList.remove('btn-danger');
    +                deleteBtn.classList.add('btn-success');
    +            } else {
    +                deleteBtn.textContent = 'Disable Persistent Schedule';
    +                deleteBtn.classList.add('btn-danger');
    +                deleteBtn.classList.remove('btn-success');
    +            }
             }
     
             // Pre-populate the form with current schedule values
    @@ -496,39 +508,43 @@ class SchedulerControl {
             }
         }
     
    -    async handleScheduleDelete() {
    -        if (!confirm('Are you sure you want to delete the persistent schedule? This will remove the saved schedule file and the scheduler will fall back to the environment variable or stop running.')) {
    +    async handlePersistenceToggle() {
    +        const status = this.currentStatus || {};
    +        const currentlyDisabled = !!status.disabled;
    +        const promptMsg = currentlyDisabled
    +            ? 'Re-enable persistent schedule (will resume using schedule.yml contents)?'
    +            : 'Disable persistent schedule (file retained; environment fallback used if set)?';
    +        if (!confirm(promptMsg)) {
                 return;
             }
     
    -        const deleteBtn = this.container.querySelector('#delete-schedule-btn');
    -
    -        // Show loading state
    -        this.setButtonLoading(deleteBtn, true);
    +        const btn = this.container.querySelector('#delete-schedule-btn');
    +        this.setButtonLoading(btn, true);
     
             try {
    -            console.log('Deleting persistent schedule');
    -
    -            const response = await this.api.delete('/schedule');
    -
    -            console.log('Schedule deletion response:', response);
    +            console.log('Toggling persistent schedule disabled_before=', currentlyDisabled);
    +            // New endpoint replaces legacy DELETE /schedule?confirm=1
    +            const response = await this.api.post('/schedule/persistence/toggle', {});
    +            console.log('Persistence toggle response:', response);
     
                 if (response.success) {
    -                showToast('Persistent schedule deleted successfully', 'success');
    -
    -                // Reload the current status to get the updated information
    +                const action = response.action || (response.disabled ? 'disabled' : 'enabled');
    +                const toastMsg = action === 'disabled'
    +                    ? 'Persistent schedule disabled (metadata retained)'
    +                    : 'Persistent schedule re-enabled';
    +                showToast(toastMsg, 'success');
                     await this.loadCurrentStatus();
    -
                 } else {
    -                throw new Error(response.error || response.message || 'Failed to delete schedule');
    +                const msg = response.error || response.message || 'Failed to toggle persistence';
    +                showToast(msg, 'error');
    +                throw new Error(msg);
                 }
    -
             } catch (error) {
    -            console.error('Failed to delete schedule:', error);
    -            const errorMessage = error.message || 'Failed to delete schedule';
    +            console.error('Failed to toggle persistent schedule:', error);
    +            const errorMessage = error.message || 'Failed to toggle persistent schedule';
                 showToast(errorMessage, 'error');
             } finally {
    -            this.setButtonLoading(deleteBtn, false);
    +            this.setButtonLoading(btn, false);
             }
         }
     
    
  • web-ui/js/utils/history-manager.js+1 1 modified
    @@ -387,7 +387,7 @@ export class HistoryManager {
          */
         async _loadFromBackup(configName, backupFilename) {
             try {
    -            const response = await this.api.restoreConfig(configName, backupFilename);
    +            const response = await this.api.restoreConfig(backupFilename);
                 return response.data;
             } catch (error) {
                 throw new Error(`Failed to load backup ${backupFilename}: ${error.message}`);
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.