use actix_cors::Cors; use actix_session::{SessionMiddleware, storage::CookieSessionStore}; use actix_web::{App, HttpServer, cookie::Key, web}; use clap::{Parser, Subcommand}; use tracing_actix_web::TracingLogger; use tracing_subscriber::EnvFilter; use shanty_config::AppConfig; use shanty_data::WikipediaFetcher; use shanty_data::{HybridMusicBrainzFetcher, LocalMusicBrainzFetcher, MusicBrainzFetcher}; use shanty_db::Database; use shanty_search::MusicBrainzSearch; use shanty_web::routes; use shanty_web::state::AppState; use shanty_web::tasks::TaskManager; use shanty_web::workers::WorkerManager; #[derive(Parser)] #[command(name = "shanty", about = "Shanty — self-hosted music management")] 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, #[command(subcommand)] command: Option, } #[derive(Subcommand)] enum Commands { /// Import MusicBrainz JSON data dumps into local SQLite database. MbImport { /// Download fresh dump files from metabrainz.org before importing. #[arg(long)] download: bool, /// Directory containing (or to download) dump files. /// Defaults to the application data directory. #[arg(long)] data_dir: Option, }, } #[actix_web::main] async fn main() -> anyhow::Result<()> { // Load .env file if present (before anything reads env vars) dotenvy::dotenv().ok(); let cli = Cli::parse(); // Load config early so we can use log_level from it let mut config = AppConfig::load(cli.config.as_deref()); // CLI -v flags override config log_level let filter = match cli.verbose { 0 => { // Use config log_level let level = config.log_level.to_lowercase(); match level.as_str() { "error" => "error".to_string(), "warn" => "warn".to_string(), "debug" => "debug,shanty=debug,shanty_web=debug".to_string(), "trace" => "trace,shanty=trace,shanty_web=trace".to_string(), _ => "info,shanty=info,shanty_web=info".to_string(), } } 1 => "info,shanty=debug,shanty_web=debug".to_string(), _ => "debug,shanty=trace,shanty_web=trace".to_string(), }; tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&filter)), ) .init(); if let Some(port) = cli.port { config.web.port = port; } // Handle subcommands if let Some(Commands::MbImport { download, data_dir }) = cli.command { return run_mb_import(&config, download, data_dir.as_deref()).await; } tracing::info!(url = %config.database_url, "connecting to database"); let db = Database::new(&config.database_url).await?; let mb_remote = MusicBrainzFetcher::new()?; let search = MusicBrainzSearch::with_limiter(mb_remote.limiter())?; // Set up local MB database if configured let local_mb = create_local_mb_fetcher(&config); let mb_client = HybridMusicBrainzFetcher::new(local_mb, mb_remote); if mb_client.has_local_db() && let Some(stats) = mb_client.local_stats() { tracing::info!( artists = stats.artists, release_groups = stats.release_groups, releases = stats.releases, recordings = stats.recordings, last_import = ?stats.last_import_date, "local MusicBrainz database loaded" ); } else if !mb_client.has_local_db() { tracing::info!("no local MusicBrainz database — using API only"); } 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(), workers: WorkerManager::new(), firefox_login: tokio::sync::Mutex::new(None), }); // Start work queue workers and unified scheduler WorkerManager::spawn_all(state.clone()); shanty_web::scheduler::spawn(state.clone()); shanty_web::mb_update::spawn(state.clone()); // Resolve static files directory 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")); let static_dir = if static_dir.is_dir() { static_dir } else { // Check next to shanty-web crate root (for development) let dev_path = std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/shanty-web/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"); // Generate a random session key (sessions won't survive restarts, which is fine) let session_key = Key::generate(); let server = 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) .guard(actix_web::guard::fn_guard(|ctx| { !ctx.head().uri.path().starts_with("/rest") })), ) .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 { if req.path().starts_with("/rest") { return Ok(actix_web::HttpResponse::NotFound() .content_type("application/json") .body(r#"{"subsonic-response":{"status":"failed","version":"1.16.1","error":{"code":0,"message":"Unknown endpoint"}}}"#)); } actix_files::NamedFile::open_async(index_path) .await .map(|f| f.into_response(&req)) } } })) }) .bind(&bind)? .run(); // Graceful shutdown on Ctrl+C / SIGTERM let handle = server.handle(); tokio::spawn(async move { tokio::signal::ctrl_c().await.ok(); tracing::info!("shutdown signal received, stopping server"); handle.stop(true).await; }); server.await?; tracing::info!("server stopped"); Ok(()) } /// Create a LocalMusicBrainzFetcher from config if available. fn create_local_mb_fetcher(config: &AppConfig) -> Option { let db_path = config .musicbrainz .local_db_path .as_ref() .map(|p| p.to_string_lossy().to_string()) .or_else(|| { // Default location: data_dir/shanty-mb.db (only if it exists) let default_path = shanty_config::data_dir().join("shanty-mb.db"); if default_path.exists() { Some(default_path.to_string_lossy().to_string()) } else { None } })?; match LocalMusicBrainzFetcher::new(&db_path) { Ok(fetcher) => { if fetcher.is_available() { tracing::info!(path = %db_path, "opened local MusicBrainz database"); Some(fetcher) } else { tracing::debug!(path = %db_path, "local MB database exists but has no data"); None } } Err(e) => { tracing::warn!(path = %db_path, error = %e, "failed to open local MB database"); None } } } /// Run the `mb-import` subcommand. async fn run_mb_import( config: &AppConfig, download: bool, data_dir_override: Option<&str>, ) -> anyhow::Result<()> { let data_dir = data_dir_override .map(std::path::PathBuf::from) .unwrap_or_else(|| shanty_config::data_dir().join("mb-dumps")); let db_path = config .musicbrainz .local_db_path .clone() .unwrap_or_else(|| shanty_config::data_dir().join("shanty-mb.db")); tracing::info!( dump_dir = %data_dir.display(), db_path = %db_path.display(), download = download, "starting MusicBrainz import" ); // Download dumps if requested if download { std::fs::create_dir_all(&data_dir)?; let timestamp = shanty_data::mb_import::discover_latest_dump_folder() .await .map_err(|e| anyhow::anyhow!("failed to discover latest dump: {e}"))?; tracing::info!(timestamp = %timestamp, "using dump folder"); for filename in shanty_data::mb_import::DUMP_FILES { shanty_data::mb_import::download_dump(filename, ×tamp, &data_dir, |msg| { tracing::info!("{msg}"); }) .await .map_err(|e| anyhow::anyhow!("{e}"))?; } } // Ensure the data directory exists and has at least one dump file if !data_dir.exists() { anyhow::bail!( "dump directory {} does not exist. Use --download to fetch dumps, or provide --data-dir pointing to existing dump files.", data_dir.display() ); } // Run import (handles opening the database internally) let stats = shanty_data::mb_import::run_import_at_path(&db_path, &data_dir, |msg| { tracing::info!("{msg}"); }) .map_err(|e| anyhow::anyhow!("{e}"))?; tracing::info!(%stats, db_path = %db_path.display(), "MusicBrainz import complete"); println!("{stats}"); println!("Database: {}", db_path.display()); Ok(()) }