diff --git a/Cargo.lock b/Cargo.lock index bce3669..1009e1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2906,6 +2906,37 @@ dependencies = [ "digest", ] +[[package]] +name = "shanty" +version = "0.1.0" +dependencies = [ + "actix-cors", + "actix-files", + "actix-web", + "anyhow", + "clap", + "serde_json", + "shanty-config", + "shanty-db", + "shanty-search", + "shanty-tag", + "shanty-web", + "tokio", + "tracing", + "tracing-actix-web", + "tracing-subscriber", +] + +[[package]] +name = "shanty-config" +version = "0.1.0" +dependencies = [ + "dirs", + "serde", + "serde_yaml", + "tracing", +] + [[package]] name = "shanty-db" version = "0.1.0" @@ -3105,6 +3136,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "shanty-config", "shanty-db", "shanty-dl", "shanty-index", diff --git a/Cargo.toml b/Cargo.toml index b1e32d6..797e1ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] exclude = ["shanty-web/frontend"] members = [ + "shanty-config", "shanty-db", "shanty-index", "shanty-tag", @@ -40,3 +41,31 @@ anyhow = "1" # Logging tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Top-level binary +[package] +name = "shanty" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "shanty" +path = "src/main.rs" + +[dependencies] +shanty-config = { path = "shanty-config" } +shanty-db = { path = "shanty-db" } +shanty-web = { path = "shanty-web" } +shanty-tag = { path = "shanty-tag" } +shanty-search = { path = "shanty-search" } +actix-web = "4" +actix-cors = "0.7" +actix-files = "0.6" +tracing-actix-web = "0.7" +tokio = { workspace = true } +clap = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } +serde_json = { workspace = true } diff --git a/shanty-config/Cargo.toml b/shanty-config/Cargo.toml new file mode 100644 index 0000000..f0519e3 --- /dev/null +++ b/shanty-config/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "shanty-config" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde = { workspace = true } +serde_yaml = "0.9" +dirs = "6" +tracing = { workspace = true } diff --git a/shanty-config/src/lib.rs b/shanty-config/src/lib.rs new file mode 100644 index 0000000..e17c93b --- /dev/null +++ b/shanty-config/src/lib.rs @@ -0,0 +1,254 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + #[serde(default = "default_library_path")] + pub library_path: PathBuf, + + #[serde(default = "default_database_url")] + pub database_url: String, + + #[serde(default = "default_download_path")] + pub download_path: PathBuf, + + #[serde(default = "default_organization_format")] + pub organization_format: String, + + /// Which secondary release group types to include. Empty = studio releases only. + /// Options: "Compilation", "Live", "Soundtrack", "Remix", "DJ-mix", "Demo", etc. + #[serde(default)] + pub allowed_secondary_types: Vec, + + #[serde(default)] + pub web: WebConfig, + + #[serde(default)] + pub tagging: TaggingConfig, + + #[serde(default)] + pub download: DownloadConfig, + + #[serde(default)] + pub indexing: IndexingConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebConfig { + #[serde(default = "default_port")] + pub port: u16, + + #[serde(default = "default_bind")] + pub bind: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaggingConfig { + #[serde(default)] + pub auto_tag: bool, + + #[serde(default = "default_true")] + pub write_tags: bool, + + #[serde(default = "default_confidence")] + pub confidence: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DownloadConfig { + #[serde(default = "default_format")] + pub format: String, + + #[serde(default = "default_search_source")] + pub search_source: String, + + #[serde(default)] + pub cookies_path: Option, + + /// Requests per hour (unauthenticated). Actual YouTube limit is ~500. + #[serde(default = "default_rate_limit")] + pub rate_limit: u32, + + /// Requests per hour (with cookies). Actual YouTube limit is ~2000. + #[serde(default = "default_rate_limit_auth")] + pub rate_limit_auth: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IndexingConfig { + /// Number of concurrent file processors during library scan. + #[serde(default = "default_concurrency")] + pub concurrency: usize, +} + +// --- Defaults --- + +impl Default for AppConfig { + fn default() -> Self { + Self { + library_path: default_library_path(), + database_url: default_database_url(), + download_path: default_download_path(), + organization_format: default_organization_format(), + allowed_secondary_types: vec![], + web: WebConfig::default(), + tagging: TaggingConfig::default(), + download: DownloadConfig::default(), + indexing: IndexingConfig::default(), + } + } +} + +impl Default for WebConfig { + fn default() -> Self { + Self { + port: default_port(), + bind: default_bind(), + } + } +} + +impl Default for TaggingConfig { + fn default() -> Self { + Self { + auto_tag: false, + write_tags: true, + confidence: default_confidence(), + } + } +} + +impl Default for DownloadConfig { + fn default() -> Self { + Self { + format: default_format(), + search_source: default_search_source(), + cookies_path: None, + rate_limit: default_rate_limit(), + rate_limit_auth: default_rate_limit_auth(), + } + } +} + +impl Default for IndexingConfig { + fn default() -> Self { + Self { + concurrency: default_concurrency(), + } + } +} + +fn default_library_path() -> PathBuf { + dirs::audio_dir().unwrap_or_else(|| PathBuf::from("~/Music")) +} + +fn default_database_url() -> String { + let data_dir = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("shanty"); + std::fs::create_dir_all(&data_dir).ok(); + format!("sqlite://{}?mode=rwc", data_dir.join("shanty.db").display()) +} + +fn default_download_path() -> PathBuf { + let dir = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("shanty") + .join("downloads"); + std::fs::create_dir_all(&dir).ok(); + dir +} + +fn default_organization_format() -> String { + "{artist}/{album}/{track_number} - {title}.{ext}".to_string() +} + +fn default_port() -> u16 { 8085 } +fn default_bind() -> String { "0.0.0.0".to_string() } +fn default_confidence() -> f64 { 0.8 } +fn default_format() -> String { "opus".to_string() } +fn default_search_source() -> String { "ytmusic".to_string() } +fn default_true() -> bool { true } +fn default_rate_limit() -> u32 { 450 } +fn default_rate_limit_auth() -> u32 { 1800 } +fn default_concurrency() -> usize { 4 } + +// --- Loading and Saving --- + +impl AppConfig { + /// Resolve the config file path. + pub fn config_path(override_path: Option<&str>) -> PathBuf { + override_path + .map(PathBuf::from) + .or_else(|| std::env::var("SHANTY_CONFIG").ok().map(PathBuf::from)) + .unwrap_or_else(|| { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("shanty") + .join("config.yaml") + }) + } + + /// Load config from file, falling back to defaults. + pub fn load(path: Option<&str>) -> Self { + let config_path = Self::config_path(path); + + if config_path.exists() { + match std::fs::read_to_string(&config_path) { + Ok(contents) => match serde_yaml::from_str(&contents) { + Ok(config) => { + tracing::info!(path = %config_path.display(), "loaded config"); + return Self::apply_env_overrides(config); + } + Err(e) => { + tracing::warn!(path = %config_path.display(), error = %e, "failed to parse config, using defaults"); + } + }, + Err(e) => { + tracing::warn!(path = %config_path.display(), error = %e, "failed to read config, using defaults"); + } + } + } else { + tracing::info!(path = %config_path.display(), "no config file found, using defaults"); + } + + Self::apply_env_overrides(AppConfig::default()) + } + + /// Save config to YAML file. + pub fn save(&self, path: Option<&str>) -> Result<(), String> { + let config_path = Self::config_path(path); + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create config directory: {e}"))?; + } + let yaml = serde_yaml::to_string(self) + .map_err(|e| format!("failed to serialize config: {e}"))?; + std::fs::write(&config_path, yaml) + .map_err(|e| format!("failed to write config to {}: {e}", config_path.display()))?; + tracing::info!(path = %config_path.display(), "config saved"); + Ok(()) + } + + fn apply_env_overrides(mut config: Self) -> Self { + if let Ok(v) = std::env::var("SHANTY_DATABASE_URL") { + config.database_url = v; + } + if let Ok(v) = std::env::var("SHANTY_LIBRARY_PATH") { + config.library_path = PathBuf::from(v); + } + if let Ok(v) = std::env::var("SHANTY_DOWNLOAD_PATH") { + config.download_path = PathBuf::from(v); + } + if let Ok(v) = std::env::var("SHANTY_WEB_PORT") { + if let Ok(port) = v.parse() { + config.web.port = port; + } + } + if let Ok(v) = std::env::var("SHANTY_WEB_BIND") { + config.web.bind = v; + } + config + } +} diff --git a/shanty-web b/shanty-web index ff41233..32b4b53 160000 --- a/shanty-web +++ b/shanty-web @@ -1 +1 @@ -Subproject commit ff41233a96de9309300e713da375d53505d3ac1a +Subproject commit 32b4b533c0bb50bc781cd3179b3537eb784b4094 diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..01ca946 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,134 @@ +use actix_cors::Cors; +use actix_web::{web, App, HttpServer}; +use clap::Parser; +use tracing_actix_web::TracingLogger; +use tracing_subscriber::EnvFilter; + +use shanty_config::AppConfig; +use shanty_db::Database; +use shanty_search::MusicBrainzSearch; +use shanty_tag::MusicBrainzClient; + +use shanty_web::routes; +use shanty_web::state::AppState; +use shanty_web::tasks::TaskManager; + +#[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, +} + +#[actix_web::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + let filter = match cli.verbose { + 0 => "info,shanty=info,shanty_web=info", + 1 => "info,shanty=debug,shanty_web=debug", + _ => "debug,shanty=trace,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 = MusicBrainzClient::new()?; + let search = MusicBrainzSearch::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, + config: std::sync::Arc::new(tokio::sync::RwLock::new(config)), + config_path, + tasks: TaskManager::new(), + }); + + // 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"); + + let server = HttpServer::new(move || { + let cors = Cors::permissive(); + let static_dir = static_dir.clone(); + + App::new() + .wrap(cors) + .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), + ) + .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(); + + // 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(()) +}