mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 09:53:33 -04:00
946 lines
34 KiB
Rust
946 lines
34 KiB
Rust
use alchemist::db::EventChannels;
|
|
use alchemist::error::Result;
|
|
use alchemist::system::hardware;
|
|
use alchemist::version;
|
|
use alchemist::{Agent, Transcoder, config, db, runtime};
|
|
use clap::Parser;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
use tracing::{debug, error, info, warn};
|
|
use tracing_subscriber::EnvFilter;
|
|
|
|
use notify::{RecursiveMode, Watcher};
|
|
use tokio::sync::RwLock;
|
|
use tokio::sync::broadcast;
|
|
|
|
#[derive(Parser, Debug)]
|
|
#[command(author, version = version::current(), about, long_about = None)]
|
|
struct Args {
|
|
/// Run in CLI mode (process directories and exit)
|
|
#[arg(long)]
|
|
cli: bool,
|
|
|
|
/// Directories to scan for media files (CLI mode only)
|
|
#[arg(long, value_name = "DIR")]
|
|
directories: Vec<PathBuf>,
|
|
|
|
/// Dry run (don't actually transcode)
|
|
#[arg(short, long)]
|
|
dry_run: bool,
|
|
|
|
/// Reset admin user/password and sessions (forces setup mode)
|
|
#[arg(long)]
|
|
reset_auth: bool,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
// 0. Ensure CWD is set to executable directory (fixes double-click issues on Windows)
|
|
#[cfg(target_os = "windows")]
|
|
if let Ok(exe_path) = std::env::current_exe() {
|
|
if let Some(exe_dir) = exe_path.parent() {
|
|
if let Err(e) = std::env::set_current_dir(exe_dir) {
|
|
eprintln!("Failed to set working directory: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
match run().await {
|
|
Ok(_) => Ok(()),
|
|
Err(e) => {
|
|
error!("Application error: {}", e);
|
|
// On error, if we are in a terminal that might close, pause
|
|
if std::env::var("ALCHEMIST_NO_PAUSE").is_err() {
|
|
println!("\nPress Enter to exit...");
|
|
let mut input = String::new();
|
|
let _ = std::io::stdin().read_line(&mut input);
|
|
}
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn apply_reloaded_config(
|
|
db: &Arc<db::Db>,
|
|
config_path: &Path,
|
|
config_state: &Arc<RwLock<config::Config>>,
|
|
agent: &Arc<Agent>,
|
|
hardware_state: &hardware::HardwareState,
|
|
hardware_probe_log: &Arc<RwLock<hardware::HardwareProbeLog>>,
|
|
) -> Result<hardware::HardwareInfo> {
|
|
let new_config = config::Config::load(config_path)
|
|
.map_err(|err| alchemist::error::AlchemistError::Config(err.to_string()))?;
|
|
let (detected_hardware, probe_log) = hardware::detect_hardware_with_log(&new_config).await?;
|
|
let new_limit = new_config.transcode.concurrent_jobs;
|
|
alchemist::settings::project_config_to_db(db.as_ref(), &new_config).await?;
|
|
|
|
{
|
|
let mut config_guard = config_state.write().await;
|
|
*config_guard = new_config;
|
|
}
|
|
|
|
hardware_state
|
|
.replace(Some(detected_hardware.clone()))
|
|
.await;
|
|
*hardware_probe_log.write().await = probe_log;
|
|
agent.set_concurrent_jobs(new_limit).await;
|
|
|
|
Ok(detected_hardware)
|
|
}
|
|
|
|
fn config_watch_target(config_path: &Path) -> &Path {
|
|
config_path
|
|
.parent()
|
|
.filter(|parent| !parent.as_os_str().is_empty())
|
|
.unwrap_or_else(|| Path::new("."))
|
|
}
|
|
|
|
fn should_reload_config_for_event(event: ¬ify::Event, config_path: &Path) -> bool {
|
|
if !event.paths.iter().any(|path| path == config_path) {
|
|
return false;
|
|
}
|
|
|
|
matches!(
|
|
&event.kind,
|
|
notify::EventKind::Create(_) | notify::EventKind::Modify(_) | notify::EventKind::Any
|
|
)
|
|
}
|
|
|
|
fn orphaned_temp_output_path(output_path: &str) -> PathBuf {
|
|
PathBuf::from(format!("{output_path}.alchemist.tmp"))
|
|
}
|
|
|
|
async fn run() -> Result<()> {
|
|
// Initialize logging
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into()))
|
|
.with_target(true)
|
|
.with_thread_ids(true)
|
|
.with_thread_names(true)
|
|
.init();
|
|
|
|
let boot_start = Instant::now();
|
|
|
|
// Startup Banner
|
|
info!(
|
|
" ______ __ ______ __ __ ______ __ __ __ ______ ______ "
|
|
);
|
|
info!(
|
|
"/\\ __ \\ /\\ \\ /\\ ___\\ /\\ \\_\\ \\ /\\ ___\\ /\\ \"-./ \\ /\\ \\ /\\ ___\\ /\\__ _\\"
|
|
);
|
|
info!(
|
|
"\\ \\ __ \\ \\ \\ \\____ \\ \\ \\____ \\ \\ __ \\ \\ \\ __\\ \\ \\ \\-./\\ \\ \\ \\ \\ \\ \\___ \\ \\/_/\\ \\/"
|
|
);
|
|
info!(
|
|
" \\ \\_\\ \\_\\ \\ \\_____\\ \\ \\_____\\ \\ \\_\\ \\_\\ \\ \\_____\\ \\ \\_\\ \\ \\_\\ \\ \\_\\ \\/\\_____\\ \\ \\_\\"
|
|
);
|
|
info!(
|
|
" \\/_/\\/_/ \\/_____/ \\/_____/ \\/_/\\/_/ \\/_____/ \\/_/ \\/_/ \\/_/ \\/_____/ \\/_/"
|
|
);
|
|
info!("");
|
|
info!("");
|
|
let version = alchemist::version::current();
|
|
let build_info = option_env!("BUILD_INFO")
|
|
.or(option_env!("GIT_SHA"))
|
|
.or(option_env!("VERGEN_GIT_SHA"))
|
|
.unwrap_or("unknown");
|
|
info!("Version: {}", version);
|
|
info!("Build: {}", build_info);
|
|
info!("");
|
|
info!("System Information:");
|
|
info!(
|
|
" OS: {} ({})",
|
|
std::env::consts::OS,
|
|
std::env::consts::ARCH
|
|
);
|
|
info!(" CPUs: {}", num_cpus::get());
|
|
info!("");
|
|
|
|
let args = Args::parse();
|
|
info!(
|
|
target: "startup",
|
|
"Parsed CLI args: cli_mode={}, reset_auth={}, dry_run={}, directories={}",
|
|
args.cli,
|
|
args.reset_auth,
|
|
args.dry_run,
|
|
args.directories.len()
|
|
);
|
|
|
|
// ... rest of logic remains largely the same, just inside run()
|
|
// Default to server mode unless CLI is explicitly requested.
|
|
let is_server_mode = !args.cli;
|
|
info!(target: "startup", "Resolved server mode: {}", is_server_mode);
|
|
if is_server_mode && !args.directories.is_empty() {
|
|
warn!("Directories were provided without --cli; ignoring CLI inputs.");
|
|
}
|
|
|
|
// 0. Load Configuration
|
|
let config_start = Instant::now();
|
|
let config_path = runtime::config_path();
|
|
let db_path = runtime::db_path();
|
|
let config_mutable = runtime::config_mutable();
|
|
let config_exists = config_path.exists();
|
|
let (config, mut setup_mode) = if !config_exists {
|
|
let cwd = std::env::current_dir().ok();
|
|
info!(
|
|
target: "startup",
|
|
"Config file not found at {:?} (cwd={:?})",
|
|
config_path,
|
|
cwd
|
|
);
|
|
if is_server_mode {
|
|
info!("No configuration file found. Entering Setup Mode (Web UI).");
|
|
(config::Config::default(), true)
|
|
} else {
|
|
// CLI mode requires config or explicit args
|
|
warn!("No configuration file found. Using defaults.");
|
|
(config::Config::default(), false)
|
|
}
|
|
} else {
|
|
match config::Config::load(config_path.as_path()) {
|
|
Ok(c) => (c, false),
|
|
Err(e) => {
|
|
warn!(
|
|
"Failed to load config file at {:?}: {}. Using defaults.",
|
|
config_path, e
|
|
);
|
|
if is_server_mode {
|
|
warn!(
|
|
"Config load failed in server mode. \
|
|
Will check for existing users before \
|
|
entering Setup Mode."
|
|
);
|
|
// Do not force setup_mode=true here.
|
|
// The user-check below will set it if needed.
|
|
// If users exist, we start with defaults but
|
|
// do NOT re-enable unauthenticated setup endpoints.
|
|
(config::Config::default(), false)
|
|
} else {
|
|
(config::Config::default(), false)
|
|
}
|
|
}
|
|
}
|
|
};
|
|
info!(
|
|
target: "startup",
|
|
"Config loaded (path={:?}, exists={}, mutable={}, setup_mode={}) in {} ms",
|
|
config_path,
|
|
config_exists,
|
|
config_mutable,
|
|
setup_mode,
|
|
config_start.elapsed().as_millis()
|
|
);
|
|
|
|
// 1. Initialize Database
|
|
let db_start = Instant::now();
|
|
if let Some(parent) = db_path.parent() {
|
|
if !parent.as_os_str().is_empty() && !parent.exists() {
|
|
std::fs::create_dir_all(parent).map_err(alchemist::error::AlchemistError::Io)?;
|
|
}
|
|
}
|
|
let db = Arc::new(db::Db::new(db_path.to_string_lossy().as_ref()).await?);
|
|
alchemist::settings::project_config_to_db(db.as_ref(), &config).await?;
|
|
info!(
|
|
target: "startup",
|
|
"Database initialized at {:?} in {} ms",
|
|
db_path,
|
|
db_start.elapsed().as_millis()
|
|
);
|
|
|
|
let interrupted_jobs = {
|
|
let mut jobs = Vec::new();
|
|
match db.get_jobs_by_status(db::JobState::Encoding).await {
|
|
Ok(mut encoding_jobs) => jobs.append(&mut encoding_jobs),
|
|
Err(err) => error!("Failed to load interrupted encoding jobs: {}", err),
|
|
}
|
|
match db.get_jobs_by_status(db::JobState::Remuxing).await {
|
|
Ok(mut remuxing_jobs) => jobs.append(&mut remuxing_jobs),
|
|
Err(err) => error!("Failed to load interrupted remuxing jobs: {}", err),
|
|
}
|
|
match db.get_jobs_by_status(db::JobState::Analyzing).await {
|
|
Ok(mut analyzing_jobs) => jobs.append(&mut analyzing_jobs),
|
|
Err(err) => error!("Failed to load interrupted analyzing jobs: {}", err),
|
|
}
|
|
jobs
|
|
};
|
|
|
|
match db.reset_interrupted_jobs().await {
|
|
Ok(count) if count > 0 => {
|
|
warn!("{} interrupted jobs reset to queued", count);
|
|
for job in interrupted_jobs {
|
|
let temp_path = orphaned_temp_output_path(&job.output_path);
|
|
if std::fs::metadata(&temp_path).is_ok() {
|
|
match std::fs::remove_file(&temp_path) {
|
|
Ok(_) => warn!("Removed orphaned temp file: {}", temp_path.display()),
|
|
Err(err) => error!(
|
|
"Failed to remove orphaned temp file {}: {}",
|
|
temp_path.display(),
|
|
err
|
|
),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(_) => {}
|
|
Err(err) => error!("Failed to reset interrupted jobs: {}", err),
|
|
}
|
|
|
|
// Also clean up any temp files left by cancelled jobs
|
|
// (process was killed before runtime cleanup could run)
|
|
match db.get_jobs_by_status(db::JobState::Cancelled).await {
|
|
Ok(cancelled_jobs) => {
|
|
for job in cancelled_jobs {
|
|
let temp_path = orphaned_temp_output_path(&job.output_path);
|
|
if std::fs::metadata(&temp_path).is_ok() {
|
|
match std::fs::remove_file(&temp_path) {
|
|
Ok(_) => warn!(
|
|
"Removed orphaned temp file \
|
|
from cancelled job: {}",
|
|
temp_path.display()
|
|
),
|
|
Err(err) => error!(
|
|
"Failed to remove cancelled \
|
|
job temp file {}: {}",
|
|
temp_path.display(),
|
|
err
|
|
),
|
|
}
|
|
}
|
|
// Also check for subtitle sidecar temps
|
|
// Pattern: output_path + ".alchemist-part"
|
|
// and output_path + ".N.alchemist-part"
|
|
let sidecar_glob = format!("{}*.alchemist-part", job.output_path);
|
|
// Use glob-style scan: check parent dir
|
|
// for files matching the pattern
|
|
if let Some(parent) = std::path::Path::new(&job.output_path).parent() {
|
|
if let Ok(entries) = std::fs::read_dir(parent) {
|
|
for entry in entries.flatten() {
|
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
if name.ends_with(".alchemist-part") {
|
|
let path = entry.path();
|
|
match std::fs::remove_file(&path) {
|
|
Ok(_) => warn!(
|
|
"Removed orphaned \
|
|
subtitle sidecar: {}",
|
|
path.display()
|
|
),
|
|
Err(err) => error!(
|
|
"Failed to remove \
|
|
sidecar {}: {}",
|
|
path.display(),
|
|
err
|
|
),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
drop(sidecar_glob);
|
|
}
|
|
}
|
|
Err(err) => error!(
|
|
"Failed to load cancelled jobs for \
|
|
cleanup: {}",
|
|
err
|
|
),
|
|
}
|
|
|
|
let log_retention_days = config.system.log_retention_days.unwrap_or(30);
|
|
match db.prune_old_logs(log_retention_days).await {
|
|
Ok(count) if count > 0 => info!("Pruned {} old log rows", count),
|
|
Ok(_) => {}
|
|
Err(err) => error!("Failed to prune old logs: {}", err),
|
|
}
|
|
|
|
match db.cleanup_expired_sessions().await {
|
|
Ok(count) => debug!("Removed {} expired sessions at startup", count),
|
|
Err(err) => error!("Failed to cleanup expired sessions: {}", err),
|
|
}
|
|
|
|
if args.reset_auth {
|
|
db.reset_auth().await?;
|
|
warn!("Auth reset requested. All users and sessions cleared.");
|
|
setup_mode = true;
|
|
}
|
|
if is_server_mode {
|
|
let users_start = Instant::now();
|
|
let has_users = db.has_users().await?;
|
|
info!(
|
|
target: "startup",
|
|
"User check completed (has_users={}) in {} ms",
|
|
has_users,
|
|
users_start.elapsed().as_millis()
|
|
);
|
|
if !has_users {
|
|
if !setup_mode {
|
|
info!("No users found. Entering Setup Mode (Web UI).");
|
|
}
|
|
setup_mode = true;
|
|
}
|
|
}
|
|
|
|
if !setup_mode {
|
|
// Log Configuration only if not in setup mode
|
|
info!("Configuration:");
|
|
info!(" Concurrent Jobs: {}", config.transcode.concurrent_jobs);
|
|
info!(
|
|
" Size Reduction Threshold: {:.1}%",
|
|
config.transcode.size_reduction_threshold * 100.0
|
|
);
|
|
info!(" Min File Size: {} MB", config.transcode.min_file_size_mb);
|
|
info!(
|
|
" CPU Fallback: {}",
|
|
if config.hardware.allow_cpu_fallback {
|
|
"Enabled"
|
|
} else {
|
|
"Disabled"
|
|
}
|
|
);
|
|
info!(
|
|
" CPU Encoding: {}",
|
|
if config.hardware.allow_cpu_encoding {
|
|
"Enabled"
|
|
} else {
|
|
"Disabled"
|
|
}
|
|
);
|
|
info!(" CPU Preset: {}", config.hardware.cpu_preset);
|
|
}
|
|
info!("");
|
|
|
|
// 2. Hardware Detection (using async version to avoid blocking runtime)
|
|
let hw_start = Instant::now();
|
|
let mut detection_config = config.clone();
|
|
if setup_mode {
|
|
detection_config.hardware.allow_cpu_fallback = true;
|
|
}
|
|
let (hw_info, initial_probe_log) =
|
|
hardware::detect_hardware_with_log(&detection_config).await?;
|
|
info!(
|
|
target: "startup",
|
|
"Hardware detection completed in {} ms",
|
|
hw_start.elapsed().as_millis()
|
|
);
|
|
info!("");
|
|
info!("Selected Hardware: {}", hw_info.vendor);
|
|
if let Some(ref path) = hw_info.device_path {
|
|
info!(" Device Path: {}", path);
|
|
}
|
|
alchemist::media::ffmpeg::warm_encoder_cache();
|
|
|
|
// Check CPU encoding policy
|
|
if !setup_mode && hw_info.vendor == hardware::Vendor::Cpu {
|
|
if !config.hardware.allow_cpu_encoding {
|
|
// In setup mode, we might not have set this yet, so don't error out.
|
|
error!("CPU encoding is disabled in configuration.");
|
|
error!(
|
|
"Set hardware.allow_cpu_encoding = true in {:?} to enable CPU fallback.",
|
|
config_path
|
|
);
|
|
return Err(alchemist::error::AlchemistError::Config(
|
|
"CPU encoding disabled".into(),
|
|
));
|
|
}
|
|
warn!("Running in CPU-only mode. Transcoding will be slower.");
|
|
}
|
|
info!("");
|
|
|
|
// 3. Initialize Broadcast Channels, Orchestrator, and Processor
|
|
let services_start = Instant::now();
|
|
|
|
// Create separate event channels by type and volume
|
|
let (jobs_tx, _jobs_rx) = broadcast::channel(1000); // High volume - job events
|
|
let (config_tx, _config_rx) = broadcast::channel(50); // Low volume - config events
|
|
let (system_tx, _system_rx) = broadcast::channel(100); // Medium volume - system events
|
|
|
|
let event_channels = Arc::new(EventChannels {
|
|
jobs: jobs_tx,
|
|
config: config_tx,
|
|
system: system_tx,
|
|
});
|
|
|
|
// Keep legacy channel for transition compatibility
|
|
let (tx, _rx) = broadcast::channel(100);
|
|
|
|
let transcoder = Arc::new(Transcoder::new());
|
|
let hardware_state = hardware::HardwareState::new(Some(hw_info.clone()));
|
|
let hardware_probe_log = Arc::new(RwLock::new(initial_probe_log));
|
|
let config = Arc::new(RwLock::new(config));
|
|
|
|
// Initialize Notification Manager (needs config for allow_local_notifications)
|
|
let notification_manager = Arc::new(alchemist::notifications::NotificationManager::new(
|
|
db.as_ref().clone(),
|
|
config.clone(),
|
|
));
|
|
notification_manager.start_listener(tx.subscribe());
|
|
|
|
let maintenance_db = db.clone();
|
|
let maintenance_config = config.clone();
|
|
tokio::spawn(async move {
|
|
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(60 * 60 * 24));
|
|
interval.tick().await;
|
|
loop {
|
|
interval.tick().await;
|
|
|
|
let retention_days = maintenance_config
|
|
.read()
|
|
.await
|
|
.system
|
|
.log_retention_days
|
|
.unwrap_or(30);
|
|
match maintenance_db.prune_old_logs(retention_days).await {
|
|
Ok(count) if count > 0 => info!("Pruned {} old log rows", count),
|
|
Ok(_) => {}
|
|
Err(err) => error!("Failed to prune old logs: {}", err),
|
|
}
|
|
|
|
match maintenance_db.cleanup_expired_sessions().await {
|
|
Ok(count) => debug!("Removed {} expired sessions", count),
|
|
Err(err) => error!("Failed to cleanup expired sessions: {}", err),
|
|
}
|
|
}
|
|
});
|
|
|
|
let agent = Arc::new(
|
|
Agent::new(
|
|
db.clone(),
|
|
transcoder.clone(),
|
|
config.clone(),
|
|
hardware_state.clone(),
|
|
tx.clone(),
|
|
event_channels.clone(),
|
|
args.dry_run,
|
|
)
|
|
.await,
|
|
);
|
|
|
|
info!("Database and services initialized.");
|
|
info!(
|
|
target: "startup",
|
|
"Core services initialized in {} ms",
|
|
services_start.elapsed().as_millis()
|
|
);
|
|
|
|
// 3. Start Background Processor Loop
|
|
// In server mode the engine starts paused and waits for an explicit user action.
|
|
if is_server_mode || setup_mode {
|
|
agent.pause();
|
|
}
|
|
let proc = agent.clone();
|
|
tokio::spawn(async move {
|
|
proc.run_loop().await;
|
|
});
|
|
|
|
if is_server_mode {
|
|
info!("Starting web server...");
|
|
|
|
// Initialize File Watcher
|
|
let file_watcher = Arc::new(alchemist::system::watcher::FileWatcher::new(
|
|
db.clone(),
|
|
Some(agent.clone()),
|
|
));
|
|
|
|
// Initialize Library Scanner (shared between boot task and server)
|
|
let library_scanner = Arc::new(alchemist::system::scanner::LibraryScanner::new(
|
|
db.clone(),
|
|
config.clone(),
|
|
));
|
|
|
|
if !setup_mode {
|
|
let scan_agent = agent.clone();
|
|
let startup_scanner = library_scanner.clone();
|
|
tokio::spawn(async move {
|
|
// Small delay to let the server fully initialize
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
|
|
|
// Trigger a full library scan first
|
|
if let Err(e) = startup_scanner.start_scan().await {
|
|
error!("Startup scan failed: {e}");
|
|
}
|
|
|
|
// Wait for scan to complete (poll until not running)
|
|
loop {
|
|
let status = startup_scanner.get_status().await;
|
|
if !status.is_running {
|
|
break;
|
|
}
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
|
}
|
|
|
|
// Now analyze all queued + failed jobs
|
|
scan_agent.analyze_pending_jobs_boot().await;
|
|
});
|
|
}
|
|
|
|
// Function to reload watcher (Config + DB)
|
|
let reload_watcher = {
|
|
let config = config.clone();
|
|
let db = db.clone();
|
|
let file_watcher = file_watcher.clone();
|
|
|
|
move |setup_mode: bool| {
|
|
let config = config.clone();
|
|
let db = db.clone();
|
|
let file_watcher = file_watcher.clone();
|
|
async move {
|
|
let config_snapshot = config.read().await.clone();
|
|
match alchemist::system::watcher::resolve_watch_paths(
|
|
db.as_ref(),
|
|
&config_snapshot,
|
|
setup_mode,
|
|
)
|
|
.await
|
|
{
|
|
Ok(all_dirs) => {
|
|
info!("Updating file watcher with {} directories", all_dirs.len());
|
|
if let Err(e) = file_watcher.watch(&all_dirs) {
|
|
error!("Failed to update file watcher: {}", e);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
error!("Failed to resolve watch dirs: {}", e);
|
|
if let Err(stop_err) = file_watcher.watch(&[]) {
|
|
debug!("Watcher stop after resolution failure: {}", stop_err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Initial Watcher Load (async to reduce boot latency)
|
|
let reload_setup_mode = setup_mode;
|
|
let reload_watcher_task = reload_watcher.clone();
|
|
tokio::spawn(async move {
|
|
let watcher_start = Instant::now();
|
|
reload_watcher_task(reload_setup_mode).await;
|
|
info!(
|
|
target: "startup",
|
|
"Initial file watcher load completed in {} ms",
|
|
watcher_start.elapsed().as_millis()
|
|
);
|
|
});
|
|
|
|
// Start Scheduler
|
|
let scheduler = alchemist::scheduler::Scheduler::new(db.clone(), agent.clone());
|
|
let scheduler_handle = scheduler.start();
|
|
|
|
// Async Config Watcher
|
|
let config_watcher_arc = config.clone();
|
|
let reload_watcher_clone = reload_watcher.clone();
|
|
let agent_for_config = agent.clone();
|
|
let hardware_state_for_config = hardware_state.clone();
|
|
let hardware_probe_log_for_config = hardware_probe_log.clone();
|
|
let config_watch_path = config_path.clone();
|
|
let db_for_config = db.clone();
|
|
|
|
// Channel for file events
|
|
let (tx_notify, mut rx_notify) = tokio::sync::mpsc::unbounded_channel();
|
|
|
|
let tx_notify_clone = tx_notify.clone();
|
|
let watcher_res = notify::recommended_watcher(
|
|
move |res: std::result::Result<notify::Event, notify::Error>| {
|
|
if let Ok(event) = res {
|
|
let _ = tx_notify_clone.send(event);
|
|
}
|
|
},
|
|
);
|
|
|
|
match watcher_res {
|
|
Ok(mut watcher) => {
|
|
let watch_target = config_watch_target(config_watch_path.as_path()).to_path_buf();
|
|
if let Err(e) = watcher.watch(watch_target.as_path(), RecursiveMode::NonRecursive) {
|
|
error!(
|
|
"Failed to watch config path {:?} via {:?}: {}",
|
|
config_watch_path, watch_target, e
|
|
);
|
|
} else {
|
|
// Prevent watcher from dropping by keeping it in the spawn if needed,
|
|
// or just spawning the processing loop.
|
|
// notify watcher works in background thread usually.
|
|
// We need to keep `watcher` alive.
|
|
|
|
tokio::spawn(async move {
|
|
// Keep watcher alive by moving it here
|
|
let _watcher = watcher;
|
|
|
|
while let Some(event) = rx_notify.recv().await {
|
|
if should_reload_config_for_event(&event, config_watch_path.as_path()) {
|
|
info!("Config file changed ({:?}). Reloading...", &event.kind);
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
|
|
|
match apply_reloaded_config(
|
|
&db_for_config,
|
|
config_watch_path.as_path(),
|
|
&config_watcher_arc,
|
|
&agent_for_config,
|
|
&hardware_state_for_config,
|
|
&hardware_probe_log_for_config,
|
|
)
|
|
.await
|
|
{
|
|
Ok(detected_hardware) => {
|
|
info!("Configuration reloaded successfully.");
|
|
info!(
|
|
"Runtime hardware reloaded: {}",
|
|
detected_hardware.vendor
|
|
);
|
|
reload_watcher_clone(false).await;
|
|
}
|
|
Err(e) => {
|
|
error!("Failed to reload config: {}", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
Err(e) => error!("Failed to create config watcher: {}", e),
|
|
}
|
|
|
|
info!(
|
|
target: "startup",
|
|
"Boot sequence completed in {} ms",
|
|
boot_start.elapsed().as_millis()
|
|
);
|
|
let server_result = alchemist::server::run_server(alchemist::server::RunServerArgs {
|
|
db,
|
|
config,
|
|
agent,
|
|
transcoder,
|
|
scheduler: scheduler_handle,
|
|
event_channels,
|
|
tx,
|
|
setup_required: setup_mode,
|
|
config_path: config_path.clone(),
|
|
config_mutable,
|
|
hardware_state,
|
|
hardware_probe_log,
|
|
notification_manager: notification_manager.clone(),
|
|
file_watcher,
|
|
library_scanner,
|
|
})
|
|
.await;
|
|
|
|
// Background tasks (run_loop, scheduler, watcher,
|
|
// maintenance) have no shutdown signal and run
|
|
// forever. After run_server returns, graceful
|
|
// shutdown is complete — all jobs are drained
|
|
// and FFmpeg processes are cancelled. Exit cleanly.
|
|
match server_result {
|
|
Ok(()) => {
|
|
info!("Server shutdown complete. Exiting.");
|
|
std::process::exit(0);
|
|
}
|
|
Err(e) => {
|
|
error!("Server exited with error: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
} else {
|
|
// CLI Mode
|
|
if setup_mode {
|
|
error!(
|
|
"Configuration required. Run without --cli to use the web-based setup wizard, or create {:?} manually.",
|
|
config_path
|
|
);
|
|
|
|
// CLI early exit - error
|
|
// (Caller will handle pause-on-exit if needed)
|
|
return Err(alchemist::error::AlchemistError::Config(
|
|
"Missing configuration".into(),
|
|
));
|
|
}
|
|
|
|
if args.directories.is_empty() {
|
|
error!("No directories provided. Usage: alchemist --cli --dir <DIR> [--dir <DIR> ...]");
|
|
return Err(alchemist::error::AlchemistError::Config(
|
|
"Missing directories for CLI mode".into(),
|
|
));
|
|
}
|
|
agent.scan_and_enqueue(args.directories).await?;
|
|
|
|
// Wait until all jobs are processed
|
|
info!("Waiting for jobs to complete...");
|
|
loop {
|
|
let stats = db.get_stats().await?;
|
|
let active = stats
|
|
.as_object()
|
|
.map(|m| {
|
|
m.iter()
|
|
.filter(|(k, _)| {
|
|
["encoding", "analyzing", "remuxing", "resuming"].contains(&k.as_str())
|
|
})
|
|
.map(|(_, v)| v.as_i64().unwrap_or(0))
|
|
.sum::<i64>()
|
|
})
|
|
.unwrap_or(0);
|
|
let queued = stats.get("queued").and_then(|v| v.as_i64()).unwrap_or(0);
|
|
|
|
if active + queued == 0 {
|
|
break;
|
|
}
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
|
}
|
|
info!("All jobs processed.");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod version_cli_tests {
|
|
use super::*;
|
|
use clap::CommandFactory;
|
|
|
|
#[test]
|
|
fn clap_command_uses_runtime_version_source() {
|
|
let command = Args::command();
|
|
let version = command.get_version().unwrap_or_default();
|
|
assert_eq!(version, version::current());
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use clap::Parser;
|
|
use notify::{
|
|
Event, EventKind,
|
|
event::{CreateKind, ModifyKind, RenameMode},
|
|
};
|
|
fn temp_db_path(prefix: &str) -> PathBuf {
|
|
let mut db_path = std::env::temp_dir();
|
|
db_path.push(format!("{prefix}_{}.db", rand::random::<u64>()));
|
|
db_path
|
|
}
|
|
|
|
fn temp_config_path(prefix: &str) -> PathBuf {
|
|
let mut config_path = std::env::temp_dir();
|
|
config_path.push(format!("{prefix}_{}.toml", rand::random::<u64>()));
|
|
config_path
|
|
}
|
|
|
|
#[test]
|
|
fn args_reject_removed_output_dir_flag() {
|
|
assert!(Args::try_parse_from(["alchemist", "--output-dir", "/tmp/out"]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn config_reload_matches_create_modify_and_rename_events() {
|
|
let config_path = PathBuf::from("/tmp/alchemist-config.toml");
|
|
|
|
let create = Event {
|
|
kind: EventKind::Create(CreateKind::File),
|
|
paths: vec![config_path.clone()],
|
|
attrs: Default::default(),
|
|
};
|
|
assert!(should_reload_config_for_event(&create, &config_path));
|
|
|
|
let rename = Event {
|
|
kind: EventKind::Modify(ModifyKind::Name(RenameMode::To)),
|
|
paths: vec![
|
|
PathBuf::from("/tmp/alchemist-config.toml.tmp"),
|
|
config_path.clone(),
|
|
],
|
|
attrs: Default::default(),
|
|
};
|
|
assert!(should_reload_config_for_event(&rename, &config_path));
|
|
|
|
let unrelated = Event {
|
|
kind: EventKind::Modify(ModifyKind::Data(notify::event::DataChange::Content)),
|
|
paths: vec![PathBuf::from("/tmp/other.toml")],
|
|
attrs: Default::default(),
|
|
};
|
|
assert!(!should_reload_config_for_event(&unrelated, &config_path));
|
|
|
|
assert_eq!(
|
|
config_watch_target(config_path.as_path()),
|
|
Path::new("/tmp")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn config_reload_refreshes_runtime_hardware_state()
|
|
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
|
let db_path = temp_db_path("alchemist_config_reload");
|
|
let config_path = temp_config_path("alchemist_config_reload");
|
|
let db = Arc::new(db::Db::new(db_path.to_string_lossy().as_ref()).await?);
|
|
|
|
let initial_config = config::Config::default();
|
|
initial_config.save(&config_path)?;
|
|
let config_state = Arc::new(RwLock::new(initial_config.clone()));
|
|
let hardware_state = hardware::HardwareState::new(Some(hardware::HardwareInfo {
|
|
vendor: hardware::Vendor::Nvidia,
|
|
device_path: None,
|
|
supported_codecs: vec!["av1".to_string()],
|
|
backends: Vec::new(),
|
|
detection_notes: Vec::new(),
|
|
selection_reason: String::new(),
|
|
probe_summary: hardware::ProbeSummary::default(),
|
|
}));
|
|
let hardware_probe_log = Arc::new(RwLock::new(hardware::HardwareProbeLog::default()));
|
|
let transcoder = Arc::new(Transcoder::new());
|
|
let (tx, _rx) = broadcast::channel(8);
|
|
let (jobs_tx, _) = broadcast::channel(100);
|
|
let (config_tx, _) = broadcast::channel(10);
|
|
let (system_tx, _) = broadcast::channel(10);
|
|
let event_channels = Arc::new(EventChannels {
|
|
jobs: jobs_tx,
|
|
config: config_tx,
|
|
system: system_tx,
|
|
});
|
|
let agent = Arc::new(
|
|
Agent::new(
|
|
db.clone(),
|
|
transcoder,
|
|
config_state.clone(),
|
|
hardware_state.clone(),
|
|
tx,
|
|
event_channels,
|
|
true,
|
|
)
|
|
.await,
|
|
);
|
|
|
|
let mut reloaded_config = initial_config;
|
|
reloaded_config.hardware.preferred_vendor = Some("cpu".to_string());
|
|
reloaded_config.hardware.allow_cpu_fallback = true;
|
|
reloaded_config.hardware.allow_cpu_encoding = true;
|
|
reloaded_config.transcode.concurrent_jobs = 2;
|
|
reloaded_config.save(&config_path)?;
|
|
|
|
let detected = apply_reloaded_config(
|
|
&db,
|
|
config_path.as_path(),
|
|
&config_state,
|
|
&agent,
|
|
&hardware_state,
|
|
&hardware_probe_log,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(detected.vendor, hardware::Vendor::Cpu);
|
|
assert_eq!(
|
|
hardware_state.snapshot().await.unwrap().vendor,
|
|
hardware::Vendor::Cpu
|
|
);
|
|
|
|
let config_guard = config_state.read().await;
|
|
assert_eq!(
|
|
config_guard.hardware.preferred_vendor.as_deref(),
|
|
Some("cpu")
|
|
);
|
|
assert_eq!(config_guard.transcode.concurrent_jobs, 2);
|
|
drop(config_guard);
|
|
|
|
drop(agent);
|
|
drop(db);
|
|
let _ = std::fs::remove_file(config_path);
|
|
let _ = std::fs::remove_file(db_path);
|
|
Ok(())
|
|
}
|
|
}
|