use actix_cors::Cors; use actix_session::{SessionMiddleware, storage::CookieSessionStore}; use actix_web::{App, HttpServer, cookie::Key, web}; use clap::Parser; use tracing_actix_web::TracingLogger; use tracing_subscriber::EnvFilter; use shanty_data::MusicBrainzFetcher; use shanty_data::WikipediaFetcher; use shanty_db::Database; use shanty_search::MusicBrainzSearch; use shanty_web::config::AppConfig; use shanty_web::routes; use shanty_web::state::AppState; use shanty_web::tasks::TaskManager; #[derive(Parser)] #[command(name = "shanty-web", about = "Shanty web interface backend")] struct Cli { /// Path to config file. #[arg(long, env = "SHANTY_CONFIG")] config: Option, /// Override the port. #[arg(long)] port: Option, /// Increase verbosity (-v info, -vv debug, -vvv trace). #[arg(short, long, action = clap::ArgAction::Count)] verbose: u8, } #[actix_web::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); let filter = match cli.verbose { 0 => "info,shanty_web=info", 1 => "info,shanty_web=debug", _ => "debug,shanty_web=trace", }; tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(filter)), ) .init(); let mut config = AppConfig::load(cli.config.as_deref()); if let Some(port) = cli.port { config.web.port = port; } tracing::info!(url = %config.database_url, "connecting to database"); let db = Database::new(&config.database_url).await?; let mb_client = MusicBrainzFetcher::new()?; let search = MusicBrainzSearch::new()?; let wiki_fetcher = WikipediaFetcher::new()?; let bind = format!("{}:{}", config.web.bind, config.web.port); tracing::info!(bind = %bind, "starting server"); let config_path = cli.config.clone(); let state = web::Data::new(AppState { db, mb_client, search, wiki_fetcher, config: std::sync::Arc::new(tokio::sync::RwLock::new(config)), config_path, tasks: TaskManager::new(), firefox_login: tokio::sync::Mutex::new(None), scheduler: tokio::sync::Mutex::new(shanty_web::state::SchedulerInfo { next_pipeline: None, next_monitor: None, }), }); // Start background cookie refresh task shanty_web::cookie_refresh::spawn(state.config.clone()); // Start pipeline and monitor schedulers shanty_web::pipeline_scheduler::spawn(state.clone()); shanty_web::monitor::spawn(state.clone()); // Resolve static files directory relative to the binary location let static_dir = std::env::current_exe() .ok() .and_then(|exe| exe.parent().map(|p| p.to_owned())) .map(|p| p.join("static")) .unwrap_or_else(|| std::path::PathBuf::from("static")); // Also check next to the crate root (for development) let static_dir = if static_dir.is_dir() { static_dir } else { let dev_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static"); if dev_path.is_dir() { dev_path } else { tracing::warn!("static directory not found — frontend will not be served"); static_dir } }; tracing::info!(path = %static_dir.display(), "serving static files"); let session_key = Key::generate(); HttpServer::new(move || { let cors = Cors::permissive(); let static_dir = static_dir.clone(); App::new() .wrap(cors) .wrap( SessionMiddleware::builder(CookieSessionStore::default(), session_key.clone()) .cookie_secure(false) .build(), ) .wrap(TracingLogger::default()) .app_data(state.clone()) .configure(routes::configure) .service( actix_files::Files::new("/", static_dir.clone()) .index_file("index.html") .prefer_utf8(true), ) // SPA fallback: serve index.html for any route not matched // by API or static files, so client-side routing works on refresh .default_service(web::to({ let index_path = static_dir.join("index.html"); move |req: actix_web::HttpRequest| { let index_path = index_path.clone(); async move { actix_files::NamedFile::open_async(index_path) .await .map(|f| f.into_response(&req)) } } })) }) .bind(&bind)? .run() .await?; Ok(()) }