Minimal subsonic functionality
This commit is contained in:
@@ -326,14 +326,8 @@ pub async fn add_track_to_playlist(
|
||||
post_json(&format!("{BASE}/playlists/{playlist_id}/tracks"), &body).await
|
||||
}
|
||||
|
||||
pub async fn remove_track_from_playlist(
|
||||
playlist_id: i32,
|
||||
track_id: i32,
|
||||
) -> Result<(), ApiError> {
|
||||
delete(&format!(
|
||||
"{BASE}/playlists/{playlist_id}/tracks/{track_id}"
|
||||
))
|
||||
.await
|
||||
pub async fn remove_track_from_playlist(playlist_id: i32, track_id: i32) -> Result<(), ApiError> {
|
||||
delete(&format!("{BASE}/playlists/{playlist_id}/tracks/{track_id}")).await
|
||||
}
|
||||
|
||||
pub async fn reorder_playlist_tracks(
|
||||
@@ -348,6 +342,17 @@ pub async fn search_tracks(query: &str) -> Result<Vec<Track>, ApiError> {
|
||||
get_json(&format!("{BASE}/tracks?q={query}&limit=50")).await
|
||||
}
|
||||
|
||||
// --- Subsonic ---
|
||||
|
||||
pub async fn get_subsonic_password_status() -> Result<SubsonicPasswordStatus, ApiError> {
|
||||
get_json(&format!("{BASE}/auth/subsonic-password-status")).await
|
||||
}
|
||||
|
||||
pub async fn set_subsonic_password(password: &str) -> Result<serde_json::Value, ApiError> {
|
||||
let body = serde_json::json!({"password": password}).to_string();
|
||||
put_json(&format!("{BASE}/auth/subsonic-password"), &body).await
|
||||
}
|
||||
|
||||
// --- YouTube Auth ---
|
||||
|
||||
pub async fn get_ytauth_status() -> Result<YtAuthStatus, ApiError> {
|
||||
|
||||
@@ -560,16 +560,8 @@ pub fn playlists_page() -> Html {
|
||||
if existing_ids.contains(&t.id) {
|
||||
return false;
|
||||
}
|
||||
let title_lower = t
|
||||
.title
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let artist_lower = t
|
||||
.artist
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let title_lower = t.title.as_deref().unwrap_or("").to_lowercase();
|
||||
let artist_lower = t.artist.as_deref().unwrap_or("").to_lowercase();
|
||||
// Subsequence match on title or artist
|
||||
let matches_field = |field: &str| {
|
||||
let mut chars = search_query.chars();
|
||||
|
||||
@@ -3,7 +3,7 @@ use web_sys::HtmlSelectElement;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::types::{AppConfig, YtAuthStatus};
|
||||
use crate::types::{AppConfig, SubsonicPasswordStatus, YtAuthStatus};
|
||||
|
||||
#[function_component(SettingsPage)]
|
||||
pub fn settings_page() -> Html {
|
||||
@@ -12,11 +12,15 @@ pub fn settings_page() -> Html {
|
||||
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 {
|
||||
@@ -29,6 +33,11 @@ pub fn settings_page() -> Html {
|
||||
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));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -500,6 +509,93 @@ pub fn settings_page() -> 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>
|
||||
|
||||
@@ -371,6 +371,13 @@ pub struct SavedPlaylist {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
// --- Subsonic ---
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct SubsonicPasswordStatus {
|
||||
pub set: bool,
|
||||
}
|
||||
|
||||
// --- Config ---
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user