Added config support
This commit is contained in:
@@ -196,3 +196,18 @@ pub async fn get_task(id: &str) -> Result<TaskInfo, ApiError> {
|
||||
pub async fn get_config() -> Result<AppConfig, ApiError> {
|
||||
get_json(&format!("{BASE}/config")).await
|
||||
}
|
||||
|
||||
pub async fn save_config(config: &AppConfig) -> Result<AppConfig, ApiError> {
|
||||
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()))
|
||||
}
|
||||
|
||||
@@ -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::<AppConfig>);
|
||||
let error = use_state(|| None::<String>);
|
||||
let message = use_state(|| None::<String>);
|
||||
|
||||
{
|
||||
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! { <div class="error">{ format!("Error: {err}") }</div> };
|
||||
}
|
||||
}
|
||||
|
||||
let Some(ref c) = *config else {
|
||||
return html! { <p class="loading">{ "Loading configuration..." }</p> };
|
||||
};
|
||||
|
||||
html! {
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{ "Settings" }</h2>
|
||||
</div>
|
||||
|
||||
if let Some(ref msg) = *message {
|
||||
<div class="card" style="border-color: var(--success);">
|
||||
<p>{ msg }</p>
|
||||
</div>
|
||||
}
|
||||
if let Some(ref err) = *error {
|
||||
<div class="card error">{ err }</div>
|
||||
}
|
||||
|
||||
{ match &*config {
|
||||
None => html! { <p class="loading">{ "Loading configuration..." }</p> },
|
||||
Some(c) => html! {
|
||||
<div class="card">
|
||||
<h3>{ "Configuration" }</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td class="text-muted">{ "Library Path" }</td><td>{ &c.library_path }</td></tr>
|
||||
<tr><td class="text-muted">{ "Database URL" }</td><td class="text-sm">{ &c.database_url }</td></tr>
|
||||
<tr><td class="text-muted">{ "Download Path" }</td><td>{ &c.download_path }</td></tr>
|
||||
<tr><td class="text-muted">{ "Organization Format" }</td><td><code>{ &c.organization_format }</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<form onsubmit={on_save}>
|
||||
// Paths
|
||||
<div class="card">
|
||||
<h3>{ "Paths" }</h3>
|
||||
<div class="form-group">
|
||||
<label>{ "Library Path" }</label>
|
||||
<input type="text" value={c.library_path.clone()}
|
||||
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
field_setter!(config, library_path, input.value());
|
||||
})} />
|
||||
</div>
|
||||
},
|
||||
}}
|
||||
<div class="form-group">
|
||||
<label>{ "Download Path" }</label>
|
||||
<input type="text" value={c.download_path.clone()}
|
||||
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
field_setter!(config, download_path, input.value());
|
||||
})} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{ "Organization Format" }</label>
|
||||
<input type="text" value={c.organization_format.clone()}
|
||||
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
field_setter!(config, organization_format, input.value());
|
||||
})} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Web Server
|
||||
<div class="card">
|
||||
<h3>{ "Web Server" }</h3>
|
||||
<p class="text-sm text-muted mb-1">{ "Changes require restart" }</p>
|
||||
<div class="form-group">
|
||||
<label>{ "Port" }</label>
|
||||
<input type="number" value={c.web.port.to_string()}
|
||||
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
if let Ok(port) = input.value().parse() {
|
||||
let mut cfg = (*config).clone().unwrap();
|
||||
cfg.web.port = port;
|
||||
config.set(Some(cfg));
|
||||
}
|
||||
})} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{ "Bind Address" }</label>
|
||||
<input type="text" value={c.web.bind.clone()}
|
||||
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
let mut cfg = (*config).clone().unwrap();
|
||||
cfg.web.bind = input.value();
|
||||
config.set(Some(cfg));
|
||||
})} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Tagging
|
||||
<div class="card">
|
||||
<h3>{ "Tagging" }</h3>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={c.tagging.write_tags}
|
||||
onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
let mut cfg = (*config).clone().unwrap();
|
||||
cfg.tagging.write_tags = input.checked();
|
||||
config.set(Some(cfg));
|
||||
})} />
|
||||
{ " Write tags to files" }
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={c.tagging.auto_tag}
|
||||
onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
let mut cfg = (*config).clone().unwrap();
|
||||
cfg.tagging.auto_tag = input.checked();
|
||||
config.set(Some(cfg));
|
||||
})} />
|
||||
{ " Auto-tag after indexing" }
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{ "Confidence Threshold" }</label>
|
||||
<input type="number" step="0.05" min="0" max="1" value={c.tagging.confidence.to_string()}
|
||||
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
if let Ok(v) = input.value().parse() {
|
||||
let mut cfg = (*config).clone().unwrap();
|
||||
cfg.tagging.confidence = v;
|
||||
config.set(Some(cfg));
|
||||
}
|
||||
})} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Downloads
|
||||
<div class="card">
|
||||
<h3>{ "Downloads" }</h3>
|
||||
<div class="form-group">
|
||||
<label>{ "Audio Format" }</label>
|
||||
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
let mut cfg = (*config).clone().unwrap();
|
||||
cfg.download.format = select.value();
|
||||
config.set(Some(cfg));
|
||||
})}>
|
||||
{ for ["opus", "mp3", "flac", "best"].iter().map(|f| html! {
|
||||
<option value={*f} selected={c.download.format == *f}>{ f }</option>
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{ "Search Source" }</label>
|
||||
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
let mut cfg = (*config).clone().unwrap();
|
||||
cfg.download.search_source = select.value();
|
||||
config.set(Some(cfg));
|
||||
})}>
|
||||
{ for [("ytmusic", "YouTube Music"), ("youtube", "YouTube")].iter().map(|(v, label)| html! {
|
||||
<option value={*v} selected={c.download.search_source == *v}>{ label }</option>
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{ "Cookies Path (optional)" }</label>
|
||||
<input type="text" value={c.download.cookies_path.clone().unwrap_or_default()}
|
||||
placeholder="~/.config/shanty/cookies.txt"
|
||||
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
let mut cfg = (*config).clone().unwrap();
|
||||
let v = input.value();
|
||||
cfg.download.cookies_path = if v.is_empty() { None } else { Some(v) };
|
||||
config.set(Some(cfg));
|
||||
})} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{ "Rate Limit (requests/hour, guest)" }</label>
|
||||
<input type="number" value={c.download.rate_limit.to_string()}
|
||||
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
if let Ok(v) = input.value().parse() {
|
||||
let mut cfg = (*config).clone().unwrap();
|
||||
cfg.download.rate_limit = v;
|
||||
config.set(Some(cfg));
|
||||
}
|
||||
})} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{ "Rate Limit (requests/hour, with cookies)" }</label>
|
||||
<input type="number" value={c.download.rate_limit_auth.to_string()}
|
||||
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
if let Ok(v) = input.value().parse() {
|
||||
let mut cfg = (*config).clone().unwrap();
|
||||
cfg.download.rate_limit_auth = v;
|
||||
config.set(Some(cfg));
|
||||
}
|
||||
})} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Indexing
|
||||
<div class="card">
|
||||
<h3>{ "Indexing" }</h3>
|
||||
<div class="form-group">
|
||||
<label>{ "Concurrency (file processors)" }</label>
|
||||
<input type="number" min="1" max="32" value={c.indexing.concurrency.to_string()}
|
||||
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
if let Ok(v) = input.value().parse() {
|
||||
let mut cfg = (*config).clone().unwrap();
|
||||
cfg.indexing.concurrency = v;
|
||||
config.set(Some(cfg));
|
||||
}
|
||||
})} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">{ "Save Settings" }</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
#[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<String>,
|
||||
#[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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user