diff --git a/Cargo.toml b/Cargo.toml index e08bb7c..3fa297d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ description = "Web interface backend for Shanty" repository = "ssh://connor@git.rcjohnstone.com:2222/Shanty/web.git" [dependencies] +shanty-config = { path = "../shanty-config" } shanty-db = { path = "../shanty-db" } shanty-index = { path = "../shanty-index" } shanty-tag = { path = "../shanty-tag" } diff --git a/frontend/src/api.rs b/frontend/src/api.rs index 84238f2..fb50675 100644 --- a/frontend/src/api.rs +++ b/frontend/src/api.rs @@ -196,3 +196,18 @@ pub async fn get_task(id: &str) -> Result { pub async fn get_config() -> Result { get_json(&format!("{BASE}/config")).await } + +pub async fn save_config(config: &AppConfig) -> Result { + let body = serde_json::to_string(config).map_err(|e| ApiError(e.to_string()))?; + let resp = Request::put(&format!("{BASE}/config")) + .header("Content-Type", "application/json") + .body(&body) + .map_err(|e| ApiError(e.to_string()))? + .send() + .await + .map_err(|e| ApiError(e.to_string()))?; + if !resp.ok() { + return Err(ApiError(format!("HTTP {}", resp.status()))); + } + resp.json().await.map_err(|e| ApiError(e.to_string())) +} diff --git a/frontend/src/pages/settings.rs b/frontend/src/pages/settings.rs index 8f031f9..57834d9 100644 --- a/frontend/src/pages/settings.rs +++ b/frontend/src/pages/settings.rs @@ -1,3 +1,5 @@ +use web_sys::HtmlInputElement; +use web_sys::HtmlSelectElement; use yew::prelude::*; use crate::api; @@ -7,6 +9,7 @@ use crate::types::AppConfig; pub fn settings_page() -> Html { let config = use_state(|| None::); let error = use_state(|| None::); + let message = use_state(|| None::); { let config = config.clone(); @@ -21,32 +24,250 @@ pub fn settings_page() -> Html { }); } + let on_save = { + let config = config.clone(); + let message = message.clone(); + let error = error.clone(); + Callback::from(move |e: SubmitEvent| { + e.prevent_default(); + let config = config.clone(); + let message = message.clone(); + let error = error.clone(); + if let Some(ref c) = *config { + let c = c.clone(); + wasm_bindgen_futures::spawn_local(async move { + match api::save_config(&c).await { + Ok(updated) => { + config.set(Some(updated)); + message.set(Some("Settings saved".to_string())); + error.set(None); + } + Err(e) => error.set(Some(e.0)), + } + }); + } + }) + }; + + // Helper to update a field in config state + macro_rules! field_setter { + ($config:expr, $field:ident, $val:expr) => {{ + let mut c = (*$config).clone().unwrap(); + c.$field = $val; + $config.set(Some(c)); + }}; + } + + if let Some(ref err) = *error { + if config.is_none() { + return html! {
{ format!("Error: {err}") }
}; + } + } + + let Some(ref c) = *config else { + return html! {

{ "Loading configuration..." }

}; + }; + html! {
+ if let Some(ref msg) = *message { +
+

{ msg }

+
+ } if let Some(ref err) = *error {
{ err }
} - { match &*config { - None => html! {

{ "Loading configuration..." }

}, - Some(c) => html! { -
-

{ "Configuration" }

- - - - - - - -
{ "Library Path" }{ &c.library_path }
{ "Database URL" }{ &c.database_url }
{ "Download Path" }{ &c.download_path }
{ "Organization Format" }{ &c.organization_format }
+
+ // Paths +
+

{ "Paths" }

+
+ +
- }, - }} +
+ + +
+
+ + +
+
+ + // Web Server +
+

{ "Web Server" }

+

{ "Changes require restart" }

+
+ + +
+
+ + +
+
+ + // Tagging +
+

{ "Tagging" }

+
+ +
+
+ +
+
+ + +
+
+ + // Downloads +
+

{ "Downloads" }

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + // Indexing +
+

{ "Indexing" }

+
+ + +
+
+ + +
} } diff --git a/frontend/src/types.rs b/frontend/src/types.rs index e0e5159..ba80ea6 100644 --- a/frontend/src/types.rs +++ b/frontend/src/types.rs @@ -240,10 +240,58 @@ pub struct SyncStats { // --- Config --- -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AppConfig { pub library_path: String, pub database_url: String, pub download_path: String, pub organization_format: String, + #[serde(default)] + pub allowed_secondary_types: Vec, + #[serde(default)] + pub web: WebConfigFe, + #[serde(default)] + pub tagging: TaggingConfigFe, + #[serde(default)] + pub download: DownloadConfigFe, + #[serde(default)] + pub indexing: IndexingConfigFe, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct WebConfigFe { + #[serde(default)] + pub port: u16, + #[serde(default)] + pub bind: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct TaggingConfigFe { + #[serde(default)] + pub auto_tag: bool, + #[serde(default)] + pub write_tags: bool, + #[serde(default)] + pub confidence: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct DownloadConfigFe { + #[serde(default)] + pub format: String, + #[serde(default)] + pub search_source: String, + #[serde(default)] + pub cookies_path: Option, + #[serde(default)] + pub rate_limit: u32, + #[serde(default)] + pub rate_limit_auth: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct IndexingConfigFe { + #[serde(default)] + pub concurrency: usize, } diff --git a/frontend/style.css b/frontend/style.css index c0ed652..8f48f39 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -125,6 +125,14 @@ input:focus, select:focus { outline: none; border-color: var(--accent); } .search-bar input { flex: 1; } .search-bar select { width: auto; min-width: 120px; } +/* Form groups */ +.form-group { margin-bottom: 0.75rem; } +.form-group label { display: block; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 0.25rem; } +.form-group input[type="number"] { width: 150px; } +.form-group select { width: auto; min-width: 150px; } +.checkbox-label { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; } +.checkbox-label input[type="checkbox"] { width: auto; } + /* Badges */ .badge { display: inline-block; diff --git a/src/config.rs b/src/config.rs index 53aae1b..e53a27a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,210 +1 @@ -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, -} - -#[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, -} - -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![], // empty = studio only - web: WebConfig::default(), - tagging: TaggingConfig::default(), - download: DownloadConfig::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, - } - } -} - -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 { - shanty_org::DEFAULT_FORMAT.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 -} - -impl AppConfig { - /// Load config from file, falling back to defaults. - pub fn load(path: Option<&str>) -> Self { - let config_path = 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") - }); - - 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()) - } - - 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 - } -} +pub use shanty_config::*; diff --git a/src/main.rs b/src/main.rs index 4199649..a252260 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,11 +58,13 @@ async fn main() -> anyhow::Result<()> { 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, + config: std::sync::Arc::new(tokio::sync::RwLock::new(config)), + config_path: config_path, tasks: TaskManager::new(), }); diff --git a/src/routes/artists.rs b/src/routes/artists.rs index 509f42e..655c160 100644 --- a/src/routes/artists.rs +++ b/src/routes/artists.rs @@ -301,7 +301,7 @@ pub async fn enrich_artist( // Fetch release groups and filter by allowed secondary types let all_release_groups = state.search.get_release_groups(&mbid).await .map_err(|e| ApiError::Internal(e.to_string()))?; - let allowed = &state.config.allowed_secondary_types; + let allowed = state.config.read().await.allowed_secondary_types.clone(); let release_groups: Vec<_> = all_release_groups .into_iter() .filter(|rg| { diff --git a/src/routes/downloads.rs b/src/routes/downloads.rs index d474f6b..ae76f64 100644 --- a/src/routes/downloads.rs +++ b/src/routes/downloads.rs @@ -88,13 +88,14 @@ async fn trigger_process( let tid = task_id.clone(); tokio::spawn(async move { - let cookies = state.config.download.cookies_path.clone(); - let format: shanty_dl::AudioFormat = state.config.download.format.parse().unwrap_or(shanty_dl::AudioFormat::Opus); - let source: shanty_dl::SearchSource = state.config.download.search_source.parse().unwrap_or(shanty_dl::SearchSource::YouTubeMusic); - let rate = if cookies.is_some() { 1800 } else { 450 }; + let cfg = state.config.read().await.clone(); + let cookies = cfg.download.cookies_path.clone(); + let format: shanty_dl::AudioFormat = cfg.download.format.parse().unwrap_or(shanty_dl::AudioFormat::Opus); + let source: shanty_dl::SearchSource = cfg.download.search_source.parse().unwrap_or(shanty_dl::SearchSource::YouTubeMusic); + let rate = if cookies.is_some() { cfg.download.rate_limit_auth } else { cfg.download.rate_limit }; let backend = shanty_dl::YtDlpBackend::new(rate, source, cookies.clone()); let backend_config = shanty_dl::BackendConfig { - output_dir: state.config.download_path.clone(), + output_dir: cfg.download_path.clone(), format, cookies_path: cookies, }; diff --git a/src/routes/system.rs b/src/routes/system.rs index c5b513e..a381a66 100644 --- a/src/routes/system.rs +++ b/src/routes/system.rs @@ -1,8 +1,10 @@ use actix_web::{web, HttpResponse}; +use serde::Deserialize; use shanty_db::entities::download_queue::DownloadStatus; use shanty_db::queries; +use crate::config::AppConfig; use crate::error::ApiError; use crate::routes::artists::enrich_all_watched_artists; use crate::state::AppState; @@ -16,7 +18,11 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(web::resource("/tasks/{id}").route(web::get().to(get_task))) .service(web::resource("/watchlist").route(web::get().to(list_watchlist))) .service(web::resource("/watchlist/{id}").route(web::delete().to(remove_watchlist))) - .service(web::resource("/config").route(web::get().to(get_config))); + .service( + web::resource("/config") + .route(web::get().to(get_config)) + .route(web::put().to(save_config)), + ); } async fn get_status( @@ -28,13 +34,11 @@ async fn get_status( let failed_items = queries::downloads::list(state.db.conn(), Some(DownloadStatus::Failed)).await?; let tasks = state.tasks.list(); - // Combine active/recent download items for the dashboard let mut queue_items = Vec::new(); queue_items.extend(downloading_items.iter().cloned()); queue_items.extend(pending_items.iter().cloned()); queue_items.extend(failed_items.iter().take(5).cloned()); - // Tracks needing metadata (tagging queue) let needs_tagging = queries::tracks::get_needing_metadata(state.db.conn()).await?; Ok(HttpResponse::Ok().json(serde_json::json!({ @@ -61,11 +65,12 @@ async fn trigger_index( let tid = task_id.clone(); tokio::spawn(async move { + let cfg = state.config.read().await.clone(); state.tasks.update_progress(&tid, 0, 0, "Scanning library..."); let scan_config = shanty_index::ScanConfig { - root: state.config.library_path.clone(), + root: cfg.library_path.clone(), dry_run: false, - concurrency: 4, + concurrency: cfg.indexing.concurrency, }; match shanty_index::run_scan(state.db.conn(), &scan_config).await { Ok(stats) => state.tasks.complete(&tid, format!("{stats}")), @@ -84,6 +89,7 @@ async fn trigger_tag( let tid = task_id.clone(); tokio::spawn(async move { + let cfg = state.config.read().await.clone(); state.tasks.update_progress(&tid, 0, 0, "Preparing tagger..."); let mb = match shanty_tag::MusicBrainzClient::new() { Ok(c) => c, @@ -94,8 +100,8 @@ async fn trigger_tag( }; let tag_config = shanty_tag::TagConfig { dry_run: false, - write_tags: state.config.tagging.write_tags, - confidence: state.config.tagging.confidence, + write_tags: cfg.tagging.write_tags, + confidence: cfg.tagging.confidence, }; state.tasks.update_progress(&tid, 0, 0, "Tagging tracks..."); match shanty_tag::run_tagging(state.db.conn(), &mb, &tag_config, None).await { @@ -115,16 +121,16 @@ async fn trigger_organize( let tid = task_id.clone(); tokio::spawn(async move { + let cfg = state.config.read().await.clone(); state.tasks.update_progress(&tid, 0, 0, "Organizing files..."); let org_config = shanty_org::OrgConfig { - target_dir: state.config.library_path.clone(), - format: state.config.organization_format.clone(), + target_dir: cfg.library_path.clone(), + format: cfg.organization_format.clone(), dry_run: false, copy: false, }; match shanty_org::organize_from_db(state.db.conn(), &org_config).await { Ok(stats) => { - // Promote all Downloaded wanted items to Owned let promoted = queries::wanted::promote_downloaded_to_owned(state.db.conn()) .await .unwrap_or(0); @@ -134,7 +140,6 @@ async fn trigger_organize( format!("{stats}") }; state.tasks.complete(&tid, msg); - // Refresh artist data in background let _ = enrich_all_watched_artists(&state).await; } Err(e) => state.tasks.fail(&tid, e.to_string()), @@ -147,7 +152,6 @@ async fn trigger_organize( async fn trigger_pipeline( state: web::Data, ) -> Result { - // Register all 6 pipeline tasks as Pending let sync_id = state.tasks.register_pending("sync"); let download_id = state.tasks.register_pending("download"); let index_id = state.tasks.register_pending("index"); @@ -167,6 +171,8 @@ async fn trigger_pipeline( let state = state.clone(); tokio::spawn(async move { + let cfg = state.config.read().await.clone(); + // Step 1: Sync state.tasks.start(&sync_id); state.tasks.update_progress(&sync_id, 0, 0, "Syncing watchlist to download queue..."); @@ -177,13 +183,13 @@ async fn trigger_pipeline( // Step 2: Download state.tasks.start(&download_id); - let cookies = state.config.download.cookies_path.clone(); - let format: shanty_dl::AudioFormat = state.config.download.format.parse().unwrap_or(shanty_dl::AudioFormat::Opus); - let source: shanty_dl::SearchSource = state.config.download.search_source.parse().unwrap_or(shanty_dl::SearchSource::YouTubeMusic); - let rate = if cookies.is_some() { 1800 } else { 450 }; + let cookies = cfg.download.cookies_path.clone(); + let format: shanty_dl::AudioFormat = cfg.download.format.parse().unwrap_or(shanty_dl::AudioFormat::Opus); + let source: shanty_dl::SearchSource = cfg.download.search_source.parse().unwrap_or(shanty_dl::SearchSource::YouTubeMusic); + let rate = if cookies.is_some() { cfg.download.rate_limit_auth } else { cfg.download.rate_limit }; let backend = shanty_dl::YtDlpBackend::new(rate, source, cookies.clone()); let backend_config = shanty_dl::BackendConfig { - output_dir: state.config.download_path.clone(), + output_dir: cfg.download_path.clone(), format, cookies_path: cookies, }; @@ -204,9 +210,9 @@ async fn trigger_pipeline( state.tasks.start(&index_id); state.tasks.update_progress(&index_id, 0, 0, "Scanning library..."); let scan_config = shanty_index::ScanConfig { - root: state.config.library_path.clone(), + root: cfg.library_path.clone(), dry_run: false, - concurrency: 4, + concurrency: cfg.indexing.concurrency, }; match shanty_index::run_scan(state.db.conn(), &scan_config).await { Ok(stats) => state.tasks.complete(&index_id, format!("{stats}")), @@ -220,8 +226,8 @@ async fn trigger_pipeline( Ok(mb) => { let tag_config = shanty_tag::TagConfig { dry_run: false, - write_tags: state.config.tagging.write_tags, - confidence: state.config.tagging.confidence, + write_tags: cfg.tagging.write_tags, + confidence: cfg.tagging.confidence, }; match shanty_tag::run_tagging(state.db.conn(), &mb, &tag_config, None).await { Ok(stats) => state.tasks.complete(&tag_id, format!("{stats}")), @@ -235,8 +241,8 @@ async fn trigger_pipeline( state.tasks.start(&organize_id); state.tasks.update_progress(&organize_id, 0, 0, "Organizing files..."); let org_config = shanty_org::OrgConfig { - target_dir: state.config.library_path.clone(), - format: state.config.organization_format.clone(), + target_dir: cfg.library_path.clone(), + format: cfg.organization_format.clone(), dry_run: false, copy: false, }; @@ -254,7 +260,7 @@ async fn trigger_pipeline( Err(e) => state.tasks.fail(&organize_id, e.to_string()), } - // Step 6: Enrich — refresh cached artist totals for the library page + // Step 6: Enrich state.tasks.start(&enrich_id); state.tasks.update_progress(&enrich_id, 0, 0, "Refreshing artist data..."); match enrich_all_watched_artists(&state).await { @@ -296,5 +302,30 @@ async fn remove_watchlist( async fn get_config( state: web::Data, ) -> Result { - Ok(HttpResponse::Ok().json(&state.config)) + let config = state.config.read().await; + Ok(HttpResponse::Ok().json(&*config)) +} + +#[derive(Deserialize)] +struct SaveConfigRequest { + #[serde(flatten)] + config: AppConfig, +} + +async fn save_config( + state: web::Data, + body: web::Json, +) -> Result { + let new_config = body.into_inner().config; + + // Persist to YAML + new_config.save(state.config_path.as_deref()) + .map_err(|e| ApiError::Internal(e))?; + + // Update in-memory config + let mut config = state.config.write().await; + *config = new_config.clone(); + + tracing::info!("config updated via API"); + Ok(HttpResponse::Ok().json(&new_config)) } diff --git a/src/state.rs b/src/state.rs index 023d2c0..8c47cd6 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,3 +1,6 @@ +use std::sync::Arc; +use tokio::sync::RwLock; + use shanty_db::Database; use shanty_search::MusicBrainzSearch; use shanty_tag::MusicBrainzClient; @@ -9,6 +12,7 @@ pub struct AppState { pub db: Database, pub mb_client: MusicBrainzClient, pub search: MusicBrainzSearch, - pub config: AppConfig, + pub config: Arc>, + pub config_path: Option, pub tasks: TaskManager, }