751 lines
38 KiB
Rust
751 lines
38 KiB
Rust
use web_sys::HtmlInputElement;
|
|
use web_sys::HtmlSelectElement;
|
|
use yew::prelude::*;
|
|
|
|
use crate::api;
|
|
use crate::types::{AppConfig, SubsonicPasswordStatus, YtAuthStatus};
|
|
|
|
#[function_component(SettingsPage)]
|
|
pub fn settings_page() -> Html {
|
|
let config = use_state(|| None::<AppConfig>);
|
|
let error = use_state(|| None::<String>);
|
|
let message = use_state(|| None::<String>);
|
|
let ytauth = use_state(|| None::<YtAuthStatus>);
|
|
let ytauth_loading = use_state(|| false);
|
|
let subsonic_status = use_state(|| None::<SubsonicPasswordStatus>);
|
|
let subsonic_password = use_state(String::new);
|
|
let subsonic_saving = use_state(|| false);
|
|
|
|
{
|
|
let config = config.clone();
|
|
let error = error.clone();
|
|
let ytauth = ytauth.clone();
|
|
let subsonic_status = subsonic_status.clone();
|
|
use_effect_with((), move |_| {
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
match api::get_config().await {
|
|
Ok(c) => config.set(Some(c)),
|
|
Err(e) => error.set(Some(e.0)),
|
|
}
|
|
});
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
if let Ok(status) = api::get_ytauth_status().await {
|
|
ytauth.set(Some(status));
|
|
}
|
|
});
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
if let Ok(status) = api::get_subsonic_password_status().await {
|
|
subsonic_status.set(Some(status));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
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> };
|
|
};
|
|
|
|
// Build YouTube auth card HTML outside the main html! macro
|
|
let ytauth_html = {
|
|
let ytauth = ytauth.clone();
|
|
let ytauth_loading = ytauth_loading.clone();
|
|
let message = message.clone();
|
|
let error = error.clone();
|
|
if let Some(ref status) = *ytauth {
|
|
if status.login_session_active {
|
|
let vnc_url = status.vnc_url.clone().unwrap_or_default();
|
|
let on_done = {
|
|
let ytauth = ytauth.clone();
|
|
let ytauth_loading = ytauth_loading.clone();
|
|
let message = message.clone();
|
|
let error = error.clone();
|
|
Callback::from(move |_: MouseEvent| {
|
|
let ytauth = ytauth.clone();
|
|
let ytauth_loading = ytauth_loading.clone();
|
|
let message = message.clone();
|
|
let error = error.clone();
|
|
ytauth_loading.set(true);
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
match api::ytauth_login_stop().await {
|
|
Ok(_) => {
|
|
message.set(Some(
|
|
"YouTube login complete! Cookies exported.".into(),
|
|
));
|
|
if let Ok(s) = api::get_ytauth_status().await {
|
|
ytauth.set(Some(s));
|
|
}
|
|
}
|
|
Err(e) => error.set(Some(e.0)),
|
|
}
|
|
ytauth_loading.set(false);
|
|
});
|
|
})
|
|
};
|
|
html! {
|
|
<>
|
|
<p class="text-sm">{ "Log into YouTube in the browser below, then click Done." }</p>
|
|
if !vnc_url.is_empty() {
|
|
<p class="text-sm"><a href={vnc_url.clone()} target="_blank">{ "Open login window" }</a></p>
|
|
<iframe src={vnc_url} style="width:100%;height:500px;border:1px solid var(--border);border-radius:var(--radius);margin:0.5rem 0;" />
|
|
}
|
|
<button class="btn btn-primary" onclick={on_done} disabled={*ytauth_loading}>
|
|
{ if *ytauth_loading { "Finishing..." } else { "Done \u{2014} I've logged in" } }
|
|
</button>
|
|
</>
|
|
}
|
|
} else if status.authenticated {
|
|
let age_text = status
|
|
.cookie_age_hours
|
|
.map(|h| format!("cookies {h:.0}h old"))
|
|
.unwrap_or_else(|| "authenticated".into());
|
|
let on_refresh = {
|
|
let ytauth = ytauth.clone();
|
|
let ytauth_loading = ytauth_loading.clone();
|
|
let message = message.clone();
|
|
let error = error.clone();
|
|
Callback::from(move |_: MouseEvent| {
|
|
let ytauth = ytauth.clone();
|
|
let ytauth_loading = ytauth_loading.clone();
|
|
let message = message.clone();
|
|
let error = error.clone();
|
|
ytauth_loading.set(true);
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
match api::ytauth_refresh().await {
|
|
Ok(_) => {
|
|
message.set(Some("Cookies refreshed".into()));
|
|
if let Ok(s) = api::get_ytauth_status().await {
|
|
ytauth.set(Some(s));
|
|
}
|
|
}
|
|
Err(e) => error.set(Some(e.0)),
|
|
}
|
|
ytauth_loading.set(false);
|
|
});
|
|
})
|
|
};
|
|
let on_clear = {
|
|
let ytauth = ytauth.clone();
|
|
let message = message.clone();
|
|
let error = error.clone();
|
|
Callback::from(move |_: MouseEvent| {
|
|
let ytauth = ytauth.clone();
|
|
let message = message.clone();
|
|
let error = error.clone();
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
match api::ytauth_clear_cookies().await {
|
|
Ok(_) => {
|
|
message.set(Some("YouTube auth cleared".into()));
|
|
if let Ok(s) = api::get_ytauth_status().await {
|
|
ytauth.set(Some(s));
|
|
}
|
|
}
|
|
Err(e) => error.set(Some(e.0)),
|
|
}
|
|
});
|
|
})
|
|
};
|
|
html! {
|
|
<>
|
|
<p>
|
|
<span class="badge badge-success">{ "Authenticated" }</span>
|
|
<span class="text-muted text-sm" style="margin-left: 0.5rem;">{ age_text }</span>
|
|
</p>
|
|
if status.refresh_enabled {
|
|
<p class="text-sm text-muted">{ "Auto-refresh is enabled" }</p>
|
|
}
|
|
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">
|
|
<button class="btn btn-sm btn-secondary" onclick={on_refresh} disabled={*ytauth_loading}>
|
|
{ if *ytauth_loading { "Refreshing..." } else { "Refresh Now" } }
|
|
</button>
|
|
<button class="btn btn-sm btn-danger" onclick={on_clear}>
|
|
{ "Clear Authentication" }
|
|
</button>
|
|
</div>
|
|
</>
|
|
}
|
|
} else {
|
|
let on_start = {
|
|
let ytauth = ytauth.clone();
|
|
let ytauth_loading = ytauth_loading.clone();
|
|
let error = error.clone();
|
|
Callback::from(move |_: MouseEvent| {
|
|
let ytauth = ytauth.clone();
|
|
let ytauth_loading = ytauth_loading.clone();
|
|
let error = error.clone();
|
|
ytauth_loading.set(true);
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
match api::ytauth_login_start().await {
|
|
Ok(_) => {
|
|
if let Ok(s) = api::get_ytauth_status().await {
|
|
ytauth.set(Some(s));
|
|
}
|
|
}
|
|
Err(e) => error.set(Some(e.0)),
|
|
}
|
|
ytauth_loading.set(false);
|
|
});
|
|
})
|
|
};
|
|
html! {
|
|
<>
|
|
<p class="text-muted text-sm">
|
|
{ "Authenticate with YouTube for higher download rate limits (~2000/hr vs ~300/hr) and access to age-restricted content. " }
|
|
{ "This launches a browser where you log into your Google account." }
|
|
</p>
|
|
<div class="card" style="border-color: var(--warning); background: rgba(234, 179, 8, 0.08); margin: 0.75rem 0;">
|
|
<p class="text-sm" style="margin:0;">
|
|
<strong style="color: var(--warning);">{ "Warning: " }</strong>
|
|
{ "YouTube may permanently suspend accounts that are used with third-party download tools. " }
|
|
<strong>{ "Use a throwaway Google account" }</strong>
|
|
{ " \u{2014} do not log in with your primary account." }
|
|
</p>
|
|
</div>
|
|
<button class="btn btn-primary" onclick={on_start} disabled={*ytauth_loading}>
|
|
{ if *ytauth_loading { "Starting..." } else { "Authenticate YouTube" } }
|
|
</button>
|
|
</>
|
|
}
|
|
}
|
|
} else {
|
|
html! { <p class="text-muted text-sm">{ "Loading..." }</p> }
|
|
}
|
|
};
|
|
|
|
let ytdlp_version_html = if let Some(ref status) = *ytauth {
|
|
let version = status
|
|
.ytdlp_version
|
|
.clone()
|
|
.unwrap_or_else(|| "not found".into());
|
|
if status.ytdlp_update_available {
|
|
let latest = status.ytdlp_latest.clone().unwrap_or_default();
|
|
html! {
|
|
<div class="card" style="border-color: var(--warning); background: rgba(234, 179, 8, 0.08); margin: 0 0 0.75rem 0; padding: 0.5rem 0.75rem;">
|
|
<p class="text-sm" style="margin:0;">
|
|
<strong style="color: var(--warning);">{ "yt-dlp update available: " }</strong>
|
|
{ format!("{version} \u{2192} {latest}") }
|
|
<span class="text-muted">{ " \u{2014} run " }</span>
|
|
<code>{ "pip install -U yt-dlp" }</code>
|
|
</p>
|
|
</div>
|
|
}
|
|
} else {
|
|
html! {
|
|
<p class="text-muted text-sm" style="margin: 0 0 0.75rem 0;">
|
|
{ format!("yt-dlp {version}") }
|
|
<span style="color: var(--success); margin-left: 0.3rem;">{ "\u{2713} up to date" }</span>
|
|
</p>
|
|
}
|
|
}
|
|
} else {
|
|
html! {}
|
|
};
|
|
|
|
let lastfm_key_html = {
|
|
let key_set = ytauth
|
|
.as_ref()
|
|
.map(|s| s.lastfm_api_key_set)
|
|
.unwrap_or(false);
|
|
if key_set {
|
|
html! {
|
|
<p class="text-sm" style="margin: 0.25rem 0 0 0;">
|
|
<span style="color: var(--success);">{ "\u{2713}" }</span>
|
|
{ " API key configured via " }
|
|
<code>{ "SHANTY_LASTFM_API_KEY" }</code>
|
|
</p>
|
|
}
|
|
} else {
|
|
html! {
|
|
<p class="text-sm" style="margin: 0.25rem 0 0 0; color: var(--warning);">
|
|
{ "Set " }
|
|
<code>{ "SHANTY_LASTFM_API_KEY" }</code>
|
|
{ " environment variable. Get a key at " }
|
|
<a href="https://www.last.fm/api/account/create" target="_blank">{ "last.fm/api/account/create" }</a>
|
|
{ " (use any name, leave callback URL blank)." }
|
|
</p>
|
|
}
|
|
}
|
|
};
|
|
|
|
let fanart_key_html = {
|
|
let key_set = ytauth
|
|
.as_ref()
|
|
.map(|s| s.fanart_api_key_set)
|
|
.unwrap_or(false);
|
|
if key_set {
|
|
html! {
|
|
<p class="text-sm" style="margin: 0.25rem 0 0 0;">
|
|
<span style="color: var(--success);">{ "\u{2713}" }</span>
|
|
{ " API key configured via " }
|
|
<code>{ "SHANTY_FANART_API_KEY" }</code>
|
|
{ ". Provides artist thumbnails and HD banners." }
|
|
</p>
|
|
}
|
|
} else {
|
|
html! {
|
|
<p class="text-sm" style="margin: 0.25rem 0 0 0; color: var(--warning);">
|
|
{ "Set " }
|
|
<code>{ "SHANTY_FANART_API_KEY" }</code>
|
|
{ " environment variable. Get a key at " }
|
|
<a href="https://fanart.tv/get-an-api-key/" target="_blank">{ "fanart.tv" }</a>
|
|
{ "." }
|
|
</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>
|
|
}
|
|
|
|
<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>{ "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>
|
|
|
|
// YouTube Authentication
|
|
<div class="card">
|
|
<h3>{ "YouTube Authentication" }</h3>
|
|
{ ytdlp_version_html }
|
|
{ ytauth_html }
|
|
</div>
|
|
|
|
// Subsonic API
|
|
<div class="card">
|
|
<h3>{ "Subsonic API" }</h3>
|
|
<p class="text-sm text-muted mb-1">
|
|
{ "Connect Subsonic-compatible apps (DSub, Symfonium, Feishin) to stream your library. " }
|
|
{ "This is a minimal Subsonic implementation for basic browsing and playback. " }
|
|
{ "For a full-featured Subsonic server, consider " }
|
|
<a href="https://www.navidrome.org" target="_blank">{ "Navidrome" }</a>
|
|
{ " pointed at the same library." }
|
|
</p>
|
|
<div class="form-group">
|
|
<label>{ "Server URL (most clients add /rest automatically)" }</label>
|
|
<input type="text" readonly=true value={
|
|
format!("http://{}:{}",
|
|
c.web.bind.clone(),
|
|
c.web.port)
|
|
} />
|
|
</div>
|
|
{
|
|
if let Some(ref status) = *subsonic_status {
|
|
if status.set {
|
|
html! {
|
|
<p class="text-sm">
|
|
<span class="badge badge-success">{ "Password set" }</span>
|
|
</p>
|
|
}
|
|
} else {
|
|
html! {
|
|
<p class="text-sm text-muted">{ "No Subsonic password set. Set one below to enable access." }</p>
|
|
}
|
|
}
|
|
} else {
|
|
html! { <p class="text-sm text-muted">{ "Loading..." }</p> }
|
|
}
|
|
}
|
|
<div class="card" style="border-color: var(--warning); background: rgba(234, 179, 8, 0.08); margin: 0.75rem 0;">
|
|
<p class="text-sm" style="margin:0;">
|
|
<strong style="color: var(--warning);">{ "Warning: " }</strong>
|
|
{ "This password is stored in plaintext per the Subsonic protocol. Do " }
|
|
<strong>{ "not" }</strong>
|
|
{ " reuse a password from another account." }
|
|
</p>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>{ "Subsonic Password" }</label>
|
|
<input type="password" placeholder="Enter Subsonic password"
|
|
value={(*subsonic_password).clone()}
|
|
oninput={let subsonic_password = subsonic_password.clone(); Callback::from(move |e: InputEvent| {
|
|
let input: HtmlInputElement = e.target_unchecked_into();
|
|
subsonic_password.set(input.value());
|
|
})} />
|
|
</div>
|
|
<button type="button" class="btn btn-primary"
|
|
disabled={*subsonic_saving || subsonic_password.is_empty()}
|
|
onclick={{
|
|
let subsonic_password = subsonic_password.clone();
|
|
let subsonic_saving = subsonic_saving.clone();
|
|
let subsonic_status = subsonic_status.clone();
|
|
let message = message.clone();
|
|
let error = error.clone();
|
|
Callback::from(move |_: MouseEvent| {
|
|
let pw = (*subsonic_password).clone();
|
|
let subsonic_saving = subsonic_saving.clone();
|
|
let subsonic_status = subsonic_status.clone();
|
|
let subsonic_password = subsonic_password.clone();
|
|
let message = message.clone();
|
|
let error = error.clone();
|
|
subsonic_saving.set(true);
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
match api::set_subsonic_password(&pw).await {
|
|
Ok(_) => {
|
|
message.set(Some("Subsonic password saved".into()));
|
|
subsonic_password.set(String::new());
|
|
if let Ok(s) = api::get_subsonic_password_status().await {
|
|
subsonic_status.set(Some(s));
|
|
}
|
|
}
|
|
Err(e) => error.set(Some(e.0)),
|
|
}
|
|
subsonic_saving.set(false);
|
|
});
|
|
})
|
|
}}>
|
|
{ if *subsonic_saving { "Saving..." } else { "Save Subsonic Password" } }
|
|
</button>
|
|
</div>
|
|
|
|
// Metadata Providers
|
|
<div class="card">
|
|
<h3>{ "Metadata Providers" }</h3>
|
|
<div class="form-group">
|
|
<label>{ "Music Database" }</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.metadata.metadata_source = select.value();
|
|
config.set(Some(cfg));
|
|
})}>
|
|
{ for [("musicbrainz", "MusicBrainz")].iter().map(|(v, label)| html! {
|
|
<option value={*v} selected={c.metadata.metadata_source == *v}>{ label }</option>
|
|
})}
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>{ "Artist Images" }</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.metadata.artist_image_source = select.value();
|
|
config.set(Some(cfg));
|
|
})}>
|
|
{ for [("wikipedia", "Wikipedia"), ("fanarttv", "fanart.tv")].iter().map(|(v, label)| html! {
|
|
<option value={*v} selected={c.metadata.artist_image_source == *v}>{ label }</option>
|
|
})}
|
|
</select>
|
|
</div>
|
|
if c.metadata.artist_image_source == "fanarttv" {
|
|
{ fanart_key_html.clone() }
|
|
}
|
|
<div class="form-group">
|
|
<label>{ "Artist Bios" }</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.metadata.artist_bio_source = select.value();
|
|
config.set(Some(cfg));
|
|
})}>
|
|
{ for [("wikipedia", "Wikipedia"), ("lastfm", "Last.fm")].iter().map(|(v, label)| html! {
|
|
<option value={*v} selected={c.metadata.artist_bio_source == *v}>{ label }</option>
|
|
})}
|
|
</select>
|
|
</div>
|
|
if c.metadata.artist_bio_source == "lastfm" {
|
|
{ lastfm_key_html.clone() }
|
|
}
|
|
<div class="form-group">
|
|
<label>{ "Lyrics" }</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.metadata.lyrics_source = select.value();
|
|
config.set(Some(cfg));
|
|
})}>
|
|
{ for [("lrclib", "LRCLIB")].iter().map(|(v, label)| html! {
|
|
<option value={*v} selected={c.metadata.lyrics_source == *v}>{ label }</option>
|
|
})}
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>{ "Cover Art" }</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.metadata.cover_art_source = select.value();
|
|
config.set(Some(cfg));
|
|
})}>
|
|
{ for [("coverartarchive", "Cover Art Archive")].iter().map(|(v, label)| html! {
|
|
<option value={*v} selected={c.metadata.cover_art_source == *v}>{ label }</option>
|
|
})}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
// Scheduling
|
|
<div class="card">
|
|
<h3>{ "Scheduling" }</h3>
|
|
<p class="text-sm text-muted mb-1">{ "Automate pipeline runs and new release monitoring" }</p>
|
|
<div class="form-group">
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" checked={c.scheduling.pipeline_enabled}
|
|
onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
|
let input: HtmlInputElement = e.target_unchecked_into();
|
|
let mut cfg = (*config).clone().unwrap();
|
|
cfg.scheduling.pipeline_enabled = input.checked();
|
|
config.set(Some(cfg));
|
|
})} />
|
|
{ " Automatically run full pipeline (sync, download, index, tag, organize)" }
|
|
</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>{ "Pipeline Interval (hours)" }</label>
|
|
<input type="number" min="1" max="168" value={c.scheduling.pipeline_interval_hours.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.scheduling.pipeline_interval_hours = v;
|
|
config.set(Some(cfg));
|
|
}
|
|
})} />
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" checked={c.scheduling.monitor_enabled}
|
|
onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
|
let input: HtmlInputElement = e.target_unchecked_into();
|
|
let mut cfg = (*config).clone().unwrap();
|
|
cfg.scheduling.monitor_enabled = input.checked();
|
|
config.set(Some(cfg));
|
|
})} />
|
|
{ " Automatically check monitored artists for new releases" }
|
|
</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>{ "Monitor Interval (hours)" }</label>
|
|
<input type="number" min="1" max="168" value={c.scheduling.monitor_interval_hours.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.scheduling.monitor_interval_hours = 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>
|
|
}
|
|
}
|