From 7273e85fa6774d34d70c62259e07cceb1428f7ca Mon Sep 17 00:00:00 2001 From: Brooklyn Date: Tue, 6 Jan 2026 23:09:46 -0500 Subject: [PATCH] feat: establish core application structure, CLI/server modes, and interactive configuration wizard --- .gitattributes | 12 ++ Cargo.lock | 137 ++++++++++++++++-- Cargo.toml | 2 + README.md | 18 +++ src/lib.rs | 2 + src/main.rs | 34 ++++- src/wizard.rs | 382 +++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 574 insertions(+), 13 deletions(-) create mode 100644 .gitattributes create mode 100644 src/wizard.rs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..04882bb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Linguist overrides for GitHub language statistics + +# Exclude Dockerfile from language stats +Dockerfile linguist-vendored + +# Exclude generated/build files +target/** linguist-generated +*.lock linguist-generated +Cargo.lock linguist-generated + +# Mark as documentation (optional) +*.md linguist-documentation diff --git a/Cargo.lock b/Cargo.lock index 6f5ba3a..39733b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,6 +36,7 @@ dependencies = [ "console_log", "futures", "gloo-net 0.5.0", + "inquire", "leptos", "leptos_axum", "leptos_dom", @@ -290,6 +291,12 @@ version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d809780667f4410e7c41b07f52439b94d2bdf8528eeedc287fa38d3b7f95d82" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" @@ -605,6 +612,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -726,6 +758,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -915,6 +953,24 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1351,6 +1407,23 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "inquire" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +dependencies = [ + "bitflags 2.10.0", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "fxhash", + "newline-converter", + "once_cell", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "instant" version = "0.1.13" @@ -1665,7 +1738,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags", + "bitflags 2.10.0", "libc", "redox_syscall 0.7.0", ] @@ -1803,6 +1876,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.1.1" @@ -1831,6 +1916,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "nom" version = "7.1.3" @@ -2252,7 +2346,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] @@ -2261,7 +2355,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] @@ -2339,7 +2433,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", @@ -2601,6 +2695,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2782,7 +2897,7 @@ checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" dependencies = [ "atoi", "base64 0.21.7", - "bitflags", + "bitflags 2.10.0", "byteorder", "bytes", "chrono", @@ -2825,7 +2940,7 @@ checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" dependencies = [ "atoi", "base64 0.21.7", - "bitflags", + "bitflags 2.10.0", "byteorder", "chrono", "crc", @@ -3071,7 +3186,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", - "mio", + "mio 1.1.1", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -3191,7 +3306,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ - "bitflags", + "bitflags 2.10.0", "bytes", "futures-util", "http 1.4.0", @@ -3349,6 +3464,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index 1e0dc39..390aa15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ subprocess = { version = "=0.2.9", optional = true } sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "macros", "chrono"], optional = true } chrono = { version = "0.4", features = ["serde"] } num_cpus = "1.16" +inquire = { version = "0.7", optional = true } futures = { version = "0.3" } toml = "0.8" leptos = { version = "=0.6.14", features = ["ssr"] } @@ -53,6 +54,7 @@ ssr = [ "leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", + "dep:inquire", ] [package.metadata.leptos] diff --git a/README.md b/README.md index fc74047..2724f07 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,24 @@ cargo build --release ./target/release/alchemist --server ``` +### First-Boot Configuration + +When you start Alchemist server for the first time, an interactive configuration wizard will run automatically: + +```bash +# First time running the server +./target/release/alchemist --server + +# The wizard runs automatically and guides you through: +# - Transcoding settings (quality thresholds, concurrency) +# - Hardware configuration (GPU/CPU encoding preferences) +# - Auto-scan directories (media library paths) +# +# After completion, config.toml is created and the server starts +``` + +On subsequent runs, the server loads your saved configuration. + ### Docker Deployment ```bash diff --git a/src/lib.rs b/src/lib.rs index 33891df..ae0996f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,8 @@ pub mod orchestrator; pub mod processor; #[cfg(feature = "ssr")] pub mod server; +#[cfg(feature = "ssr")] +pub mod wizard; pub mod app; diff --git a/src/main.rs b/src/main.rs index aa426fd..e241756 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,12 +56,36 @@ async fn main() -> Result<()> { let args = Args::parse(); - // 0. Load Configuration + // 0. Load Configuration (with first-boot wizard) let config_path = std::path::Path::new("config.toml"); - let config = config::Config::load(config_path).unwrap_or_else(|e| { - warn!("Failed to load config.toml: {}. Using defaults.", e); - config::Config::default() - }); + let config = if !config_path.exists() && args.server { + // First boot: Run configuration wizard + info!("No configuration file found. Starting configuration wizard..."); + info!(""); + + match alchemist::wizard::ConfigWizard::run(config_path) { + Ok(cfg) => { + info!(""); + info!("Configuration complete! Continuing with server startup..."); + info!(""); + cfg + } + Err(e) => { + error!("Configuration wizard failed: {}", e); + error!("You can:"); + error!(" 1. Run the wizard again: alchemist --server"); + error!(" 2. Create config.toml manually"); + error!(" 3. Use Python wizard: python setup/configure.py"); + return Err(e); + } + } + } else { + // Normal boot or CLI mode: Load config or use defaults + config::Config::load(config_path).unwrap_or_else(|e| { + warn!("Failed to load config.toml: {}. Using defaults.", e); + config::Config::default() + }) + }; // Log Configuration info!("Configuration:"); diff --git a/src/wizard.rs b/src/wizard.rs new file mode 100644 index 0000000..9d22917 --- /dev/null +++ b/src/wizard.rs @@ -0,0 +1,382 @@ +use crate::config::Config; +use crate::error::{AlchemistError, Result}; +use inquire::{Confirm, Select, Text}; +use std::path::Path; + +/// Interactive configuration wizard +pub struct ConfigWizard; + +impl ConfigWizard { + /// Run the configuration wizard and create config.toml + pub fn run(config_path: &Path) -> Result { + println!("\n╔═══════════════════════════════════════════════════════════════╗"); + println!("║ ⚗️ ALCHEMIST CONFIGURATION WIZARD ║"); + println!("╚═══════════════════════════════════════════════════════════════╝\n"); + + println!("Welcome! This wizard will help you configure Alchemist."); + println!("Press Enter to accept the default value shown in [brackets].\n"); + + if config_path.exists() { + let overwrite = Confirm::new("config.toml already exists. Overwrite it?") + .with_default(false) + .prompt() + .map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?; + + if !overwrite { + return Err(AlchemistError::Config("Configuration cancelled".into())); + } + } + + // Section 1: Transcoding + println!("\n┌─────────────────────────────────────────┐"); + println!("│ Section 1/3: Transcoding Settings │"); + println!("└─────────────────────────────────────────┘\n"); + + let size_threshold = Self::prompt_size_threshold()?; + let min_bpp = Self::prompt_min_bpp()?; + let min_file_size = Self::prompt_min_file_size()?; + let concurrent_jobs = Self::prompt_concurrent_jobs()?; + + // Section 2: Hardware + println!("\n┌─────────────────────────────────────────┐"); + println!("│ Section 2/3: Hardware Settings │"); + println!("└─────────────────────────────────────────┘\n"); + + let allow_cpu_fallback = Self::prompt_cpu_fallback()?; + let allow_cpu_encoding = Self::prompt_cpu_encoding()?; + let cpu_preset = Self::prompt_cpu_preset()?; + let preferred_vendor = Self::prompt_preferred_vendor()?; + + // Section 3: Scanner + println!("\n┌─────────────────────────────────────────┐"); + println!("│ Section 3/3: Scanner Settings │"); + println!("└─────────────────────────────────────────┘\n"); + + let directories = Self::prompt_directories()?; + + // Build config + let config = Config { + transcode: crate::config::TranscodeConfig { + size_reduction_threshold: size_threshold, + min_bpp_threshold: min_bpp, + min_file_size_mb: min_file_size, + concurrent_jobs, + }, + hardware: crate::config::HardwareConfig { + preferred_vendor, + device_path: None, + allow_cpu_fallback, + cpu_preset, + allow_cpu_encoding, + }, + scanner: crate::config::ScannerConfig { directories }, + }; + + // Show summary + Self::show_summary(&config); + + let confirm = Confirm::new("Save this configuration?") + .with_default(true) + .prompt() + .map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?; + + if !confirm { + return Err(AlchemistError::Config("Configuration cancelled".into())); + } + + // Write to file + Self::write_config(config_path, &config)?; + + println!("\n✅ Configuration saved to {}", config_path.display()); + println!("\nYou can now use Alchemist!"); + println!(" • Run: alchemist --server"); + println!(" • Edit: {}\n", config_path.display()); + + Ok(config) + } + + fn prompt_size_threshold() -> Result { + println!("📏 Size Reduction Threshold"); + println!(" How much smaller must the output file be to keep it?\n"); + println!(" • 0.3 (30%) - Balanced (recommended)"); + println!(" • 0.2 (20%) - More aggressive"); + println!(" • 0.5 (50%) - Conservative\n"); + println!(" Files that don't compress enough are kept as-is.\n"); + + let input = Text::new("Size reduction threshold:") + .with_default("0.3") + .with_help_message("Enter a value between 0.0 and 1.0") + .prompt() + .map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?; + + input + .parse::() + .map_err(|_| AlchemistError::Config("Invalid number".into())) + } + + fn prompt_min_bpp() -> Result { + println!("\n🎨 Minimum Bits Per Pixel (BPP)"); + println!(" Skip files that are already heavily compressed.\n"); + println!(" • 0.10 - Good default (recommended)"); + println!(" • 0.05 - Very aggressive"); + println!(" • 0.20 - Conservative\n"); + println!(" Lower BPP = already compressed. Re-encoding destroys quality.\n"); + + let input = Text::new("Minimum BPP threshold:") + .with_default("0.1") + .with_help_message("Typical range: 0.05 to 0.30") + .prompt() + .map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?; + + input + .parse::() + .map_err(|_| AlchemistError::Config("Invalid number".into())) + } + + fn prompt_min_file_size() -> Result { + println!("\n📦 Minimum File Size"); + println!(" Skip small files to avoid wasting time.\n"); + println!(" • 50 MB - Good for most libraries (recommended)"); + println!(" • 100 MB - Focus on movies/TV episodes"); + println!(" • 10 MB - Process everything\n"); + + let input = Text::new("Minimum file size (MB):") + .with_default("50") + .with_help_message("Files smaller than this are skipped") + .prompt() + .map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?; + + input + .parse::() + .map_err(|_| AlchemistError::Config("Invalid number".into())) + } + + fn prompt_concurrent_jobs() -> Result { + println!("\n⚡ Concurrent Jobs"); + println!(" How many videos to transcode simultaneously.\n"); + println!(" • 1 - Safe default (CPU or single GPU)"); + println!(" • 2-4 - Powerful systems with dedicated GPU"); + println!(" • More = faster BUT uses more resources\n"); + println!(" ⚠️ CPU users: Use 1 only (CPU is very slow)\n"); + + let input = Text::new("Number of concurrent jobs:") + .with_default("1") + .with_help_message("Recommended: 1-2 for most systems") + .prompt() + .map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?; + + input + .parse::() + .map_err(|_| AlchemistError::Config("Invalid number".into())) + } + + fn prompt_cpu_fallback() -> Result { + println!("💻 CPU Fallback"); + println!(" Allow software encoding if no GPU is detected?\n"); + println!(" If enabled: App uses CPU when no GPU found (SLOW)"); + println!(" If disabled: App fails to start without GPU\n"); + println!(" ⚠️ CPU encoding is 10-50x slower than GPU!\n"); + + Confirm::new("Enable CPU fallback?") + .with_default(true) + .with_help_message("Recommended: yes for compatibility") + .prompt() + .map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e))) + } + + fn prompt_cpu_encoding() -> Result { + println!("\n🔧 CPU Encoding"); + println!(" Explicitly allow CPU encoding in production?\n"); + println!(" Safety flag to prevent accidental slow transcodes."); + println!(" Even with fallback enabled, you can block CPU jobs.\n"); + + Confirm::new("Allow CPU encoding?") + .with_default(true) + .with_help_message("Recommended: yes (same as fallback)") + .prompt() + .map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e))) + } + + fn prompt_cpu_preset() -> Result { + println!("\n⚙️ CPU Encoding Preset"); + println!(" How fast/slow should CPU encoding be?\n"); + println!(" • slow (0-4) - Best quality, very slow"); + println!(" • medium (5-8) - Balanced (recommended)"); + println!(" • fast (9-12) - Lower quality, faster"); + println!(" • faster (13) - Lowest quality, fastest\n"); + + let choices = vec!["medium", "slow", "fast", "faster"]; + + Select::new("CPU preset:", choices) + .with_help_message("Recommendation: medium for CPU encoding") + .prompt() + .map(|s| s.to_string()) + .map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e))) + } + + fn prompt_preferred_vendor() -> Result> { + println!("\n🎯 Preferred GPU Vendor (Optional)"); + println!(" Force a specific GPU if multiple are available.\n"); + println!(" Leave blank to auto-detect (recommended).\n"); + + let set_vendor = Confirm::new("Set preferred vendor?") + .with_default(false) + .prompt() + .map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?; + + if !set_vendor { + return Ok(None); + } + + let choices = vec!["nvidia", "intel", "amd", "apple"]; + + Select::new("Vendor:", choices) + .prompt() + .map(|s| Some(s.to_string())) + .map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e))) + } + + fn prompt_directories() -> Result> { + println!("📁 Auto-Scan Directories"); + println!(" Directories to automatically scan for media files.\n"); + println!(" Used in server mode for automatic discovery."); + println!(" In CLI mode, you specify directories at runtime.\n"); + + let add_dirs = Confirm::new("Add auto-scan directories?") + .with_default(false) + .with_help_message("Optional: can configure later") + .prompt() + .map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?; + + if !add_dirs { + return Ok(vec![]); + } + + let mut directories = Vec::new(); + println!("\nEnter directories one at a time. Leave empty to finish.\n"); + + loop { + let dir = Text::new(&format!("Directory {}:", directories.len() + 1)) + .with_default("") + .prompt() + .map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?; + + if dir.is_empty() { + break; + } + + let path = Path::new(&dir); + if !path.exists() { + println!("⚠️ Warning: {} does not exist", dir); + let add_anyway = Confirm::new("Add anyway?") + .with_default(true) + .prompt() + .map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?; + + if !add_anyway { + continue; + } + } + + directories.push(dir); + println!("✓ Added"); + } + + Ok(directories) + } + + fn show_summary(config: &Config) { + println!("\n┌─────────────────────────────────────────┐"); + println!("│ 📋 Configuration Summary │"); + println!("└─────────────────────────────────────────┘\n"); + + println!("Transcoding:"); + println!( + " Size Reduction: {:.0}%", + config.transcode.size_reduction_threshold * 100.0 + ); + println!(" Min BPP: {:.2}", config.transcode.min_bpp_threshold); + println!(" Min File Size: {} MB", config.transcode.min_file_size_mb); + println!(" Concurrent Jobs: {}\n", config.transcode.concurrent_jobs); + + println!("Hardware:"); + println!( + " CPU Fallback: {}", + if config.hardware.allow_cpu_fallback { + "Enabled" + } else { + "Disabled" + } + ); + println!( + " CPU Encoding: {}", + if config.hardware.allow_cpu_encoding { + "Enabled" + } else { + "Disabled" + } + ); + println!(" CPU Preset: {}", config.hardware.cpu_preset); + if let Some(ref vendor) = config.hardware.preferred_vendor { + println!(" Preferred Vendor: {}\n", vendor); + } else { + println!(" Preferred Vendor: Auto-detect\n"); + } + + println!("Scanner:"); + if config.scanner.directories.is_empty() { + println!(" (No auto-scan directories)\n"); + } else { + for d in &config.scanner.directories { + println!(" 📁 {}", d); + } + println!(); + } + } + + fn write_config(path: &Path, config: &Config) -> Result<()> { + let toml_content = format!( + r#"# Alchemist Configuration File +# Generated by configuration wizard + +[transcode] +size_reduction_threshold = {} # Require this % size reduction +min_bpp_threshold = {} # Skip files with BPP below this +min_file_size_mb = {} # Skip files smaller than this (MB) +concurrent_jobs = {} # Number of parallel transcodes + +[hardware] +allow_cpu_fallback = {} # Allow software encoding +allow_cpu_encoding = {} # Enable CPU encoding +cpu_preset = "{}" # CPU encoding speed: slow|medium|fast|faster +{} +[scanner] +directories = [ # Auto-scan directories (server mode) +{}] +"#, + config.transcode.size_reduction_threshold, + config.transcode.min_bpp_threshold, + config.transcode.min_file_size_mb, + config.transcode.concurrent_jobs, + config.hardware.allow_cpu_fallback, + config.hardware.allow_cpu_encoding, + config.hardware.cpu_preset, + config + .hardware + .preferred_vendor + .as_ref() + .map(|v| format!("preferred_vendor = \"{}\"\n", v)) + .unwrap_or_default(), + config + .scanner + .directories + .iter() + .map(|d| format!(" \"{}\",", d)) + .collect::>() + .join("\n") + ); + + std::fs::write(path, toml_content) + .map_err(|e| AlchemistError::Config(format!("Failed to write config: {}", e))) + } +}