feat(server): custom HTTP headers for reverse-proxy gates (#1095) (#1156)

This commit is contained in:
cucadmuh
2026-06-22 16:25:28 +03:00
committed by GitHub
parent 2c9b2eeb46
commit 15cecb5d7d
83 changed files with 2452 additions and 327 deletions
+8
View File
@@ -74,6 +74,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Psysonic is now available in **Hungarian (Magyar)** — pick it from the language menu on the Settings and Login screens.
### Custom HTTP headers for gated servers
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1156](https://github.com/Psychotoxical/psysonic/pull/1156)**, closes [#1095](https://github.com/Psychotoxical/psysonic/issues/1095)
* Per-server **custom HTTP headers** in Settings → Servers for reverse-proxy gates (Cloudflare Access, Pangolin, and similar): add name/value pairs, choose whether they apply to the local URL, public URL, or both on dual-address profiles.
* Headers attach to every user-server HTTP path — library sync, playback, covers, offline download, Navidrome admin, capability probes, and share-link preview — without putting secrets in invite links or magic strings.
* Gate header values are redacted from application logs.
## Changed
+2
View File
@@ -4219,8 +4219,10 @@ name = "psysonic-core"
version = "1.49.0-dev"
dependencies = [
"libc",
"reqwest",
"serde",
"tauri",
"url",
]
[[package]]
@@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex, OnceLock};
use tauri::{Emitter, Manager};
use psysonic_core::ports::PlaybackQueryHandle;
use psysonic_core::server_http::{apply_optional_registry_headers, ServerHttpRegistry};
use psysonic_core::user_agent::subsonic_wire_user_agent;
use psysonic_core::track_enrichment::TrackEnrichmentOutcome;
@@ -679,10 +680,22 @@ fn analysis_http_client() -> &'static reqwest::Client {
})
}
async fn analysis_backfill_download_bytes(url: &str) -> Result<(Vec<u8>, u64), String> {
async fn analysis_backfill_download_bytes(
app: &tauri::AppHandle,
server_id: &str,
url: &str,
) -> Result<(Vec<u8>, u64), String> {
let fetch_started = std::time::Instant::now();
let response = analysis_http_client()
.get(url)
let registry = app
.try_state::<Arc<ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let request = apply_optional_registry_headers(
registry.as_deref(),
Some(server_id),
url,
analysis_http_client().get(url),
);
let response = request
.send()
.await
.map_err(|e| e.to_string())?;
@@ -781,7 +794,7 @@ async fn spawn_backfill_slots(app: &tauri::AppHandle, shared: &Arc<AnalysisBackf
let app = app.clone();
let shared = shared.clone();
tauri::async_runtime::spawn(async move {
let download_result = analysis_backfill_download_bytes(&url).await;
let download_result = analysis_backfill_download_bytes(&app, &server_id, &url).await;
{
let mut st = shared
.state
@@ -11,7 +11,7 @@ use rodio::Source;
use tauri::{AppHandle, Emitter, State};
use super::decode::build_source;
use super::engine::{audio_http_client, AudioEngine};
use super::engine::AudioEngine;
use super::helpers::*;
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
use super::play_input::{select_play_input, url_format_hint, PlayInputContext};
@@ -597,7 +597,14 @@ pub async fn audio_chain_preload(
} else if let Some(path) = url.strip_prefix("psysonic-local://") {
tokio::fs::read(path).await.map_err(|e| e.to_string())?
} else {
let resp = audio_http_client(&state).get(&url).send().await
let resp = crate::engine::playback_scoped_get(
&state,
&app,
&url,
server_id.as_deref(),
)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Ok(()); // silently fail — audio_play will retry
@@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use rodio::Player;
use tauri::Manager;
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
@@ -570,3 +571,83 @@ pub fn refresh_http_user_agent(state: &AudioEngine, ua: &str) {
*slot = client;
}
}
pub(crate) fn apply_playback_request_headers(
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: Option<&str>,
url: &str,
req: reqwest::RequestBuilder,
) -> reqwest::RequestBuilder {
if let Some(reg) = registry {
if let Some(sid) = server_id.filter(|s| !s.is_empty()) {
return reg.apply_for_http_url(sid, url, req);
}
if let Some(ctx) = reg.get_for_server_url(url) {
return psysonic_core::server_http::apply_server_headers_for_http_url(req, &ctx, url);
}
}
req
}
/// Custom HTTP headers for reverse-proxy gates — cloned into background download tasks.
#[derive(Clone, Default)]
pub(crate) struct PlaybackHttpHeaders {
registry: Option<Arc<psysonic_core::server_http::ServerHttpRegistry>>,
server_id: Option<String>,
}
impl PlaybackHttpHeaders {
pub fn from_app(app: &tauri::AppHandle, server_id: Option<&str>) -> Self {
Self {
registry: app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s)),
server_id: server_id.filter(|s| !s.is_empty()).map(str::to_string),
}
}
pub fn apply(&self, url: &str, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
apply_playback_request_headers(
self.registry.as_deref(),
self.server_id.as_deref(),
url,
req,
)
}
}
pub(crate) fn scoped_http_get(
state: &AudioEngine,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: Option<&str>,
url: &str,
) -> reqwest::RequestBuilder {
apply_playback_request_headers(
registry,
server_id,
url,
audio_http_client(state).get(url),
)
}
/// Resolve registry + server id for playback/preload HTTP GETs.
pub(crate) fn playback_scoped_get(
state: &AudioEngine,
app: &tauri::AppHandle,
url: &str,
server_id: Option<&str>,
) -> reqwest::RequestBuilder {
let registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let sid = server_id
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| state.current_playback_server_id.lock().unwrap().clone());
scoped_http_get(
state,
registry.as_deref(),
sid.as_deref(),
url,
)
}
@@ -708,7 +708,10 @@ pub(crate) async fn fetch_data(
return Ok(Some(data));
}
let response = crate::engine::audio_http_client(state).get(url).send().await.map_err(|e| e.to_string())?;
let response = crate::engine::playback_scoped_get(state, app, url, None)
.send()
.await
.map_err(|e| e.to_string())?;
let status = response.status();
let ct = response.headers()
.get(reqwest::header::CONTENT_TYPE)
@@ -15,7 +15,7 @@ use super::analysis_dispatch::{
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
TrackAnalysisOrigin,
};
use super::engine::{audio_http_client, AudioEngine};
use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders};
use super::helpers::{
content_type_to_hint, fetch_data, format_hint_from_content_disposition,
normalize_stream_suffix_for_hint, sniff_stream_format_extension,
@@ -217,7 +217,12 @@ async fn open_ranged_or_streaming_input(
state: &State<'_, AudioEngine>,
app: &AppHandle,
) -> Result<Option<PlayInput>, String> {
let response = audio_http_client(state).get(ctx.url).send().await.map_err(|e| e.to_string())?;
let http_headers = PlaybackHttpHeaders::from_app(app, ctx.server_id);
let response = http_headers
.apply(ctx.url, audio_http_client(state).get(ctx.url))
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
if state.generation.load(Ordering::SeqCst) != ctx.gen {
return Ok(None); // superseded
@@ -256,8 +261,8 @@ async fn open_ranged_or_streaming_input(
let last = total_u64
.saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = audio_http_client(state)
.get(ctx.url)
if let Ok(pr) = http_headers
.apply(ctx.url, audio_http_client(state).get(ctx.url))
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send()
.await
@@ -328,6 +333,7 @@ async fn open_ranged_or_streaming_input(
state.loudness_pre_analysis_attenuation_db.clone(),
ctx.cache_id_for_tasks.map(|s| s.to_string()),
ctx.server_id.map(|s| s.to_string()),
http_headers.clone(),
loudness_hold_for_defer,
playback_armed,
stream_hint.clone(),
@@ -347,6 +353,7 @@ async fn open_ranged_or_streaming_input(
total,
state.generation.clone(),
ctx.gen,
http_headers.clone(),
)));
let reader = RangedHttpSource {
buf,
@@ -401,6 +408,7 @@ async fn open_ranged_or_streaming_input(
state.loudness_pre_analysis_attenuation_db.clone(),
ctx.cache_id_for_tasks.map(|s| s.to_string()),
ctx.server_id.map(|s| s.to_string()),
http_headers,
playback_armed,
));
@@ -16,7 +16,7 @@ use super::analysis_dispatch::{
dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file,
TrackAnalysisOrigin,
};
use super::engine::{audio_http_client, AudioEngine};
use super::engine::AudioEngine;
use super::helpers::{analysis_cache_track_id, same_playback_target};
use super::state::PreloadedTrack;
@@ -197,7 +197,15 @@ pub async fn audio_preload(
}
}
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
let response = crate::engine::playback_scoped_get(
&state,
&app,
&url,
server_id.as_deref(),
)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
emit_preload_cancelled(&app, url, track_id_for_events);
return Ok(());
@@ -8,7 +8,7 @@ use rodio::Source;
use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder;
use super::engine::{audio_http_client, AudioEngine};
use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders};
use super::helpers::{
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
@@ -155,9 +155,10 @@ async fn open_preview_decoder(
state: &AudioEngine,
app: &AppHandle,
) -> Result<Option<SizedDecoder>, String> {
let http_headers = PlaybackHttpHeaders::from_app(app, None);
let preview_http = preview_http_client(state);
let response = preview_http
.get(url)
let response = http_headers
.apply(url, preview_http.get(url))
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
@@ -194,8 +195,8 @@ async fn open_preview_decoder(
let last = total_u64
.saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = preview_http
.get(url)
if let Ok(pr) = http_headers
.apply(url, preview_http.get(url))
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send()
.await
@@ -257,6 +258,7 @@ async fn open_preview_decoder(
state.loudness_pre_analysis_attenuation_db.clone(),
None,
None,
http_headers.clone(),
None,
playback_armed,
stream_hint.clone(),
@@ -21,6 +21,7 @@ use futures_util::StreamExt;
use symphonia::core::io::MediaSource;
use tauri::{AppHandle, Emitter};
use super::super::engine::PlaybackHttpHeaders;
use super::super::state::PreloadedTrack;
use super::{
RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS,
@@ -88,6 +89,7 @@ pub(crate) struct OnDemand {
/// Bumped after every completed (success or failure) fetch so the read loop
/// can reset its stall deadline while on-demand fetches make progress.
progress: AtomicU64,
http_headers: PlaybackHttpHeaders,
}
impl OnDemand {
@@ -100,6 +102,7 @@ impl OnDemand {
total_size: u64,
gen_arc: Arc<AtomicU64>,
gen: u64,
http_headers: PlaybackHttpHeaders,
) -> Self {
OnDemand {
http,
@@ -112,6 +115,7 @@ impl OnDemand {
filled: Mutex::new(Vec::new()),
inflight: Mutex::new(Vec::new()),
progress: AtomicU64::new(0),
http_headers,
}
}
@@ -154,6 +158,7 @@ impl OnDemand {
end_inclusive,
me.gen,
&me.gen_arc,
&me.http_headers,
)
.await;
if let Ok(written) = res {
@@ -367,6 +372,7 @@ pub(crate) async fn ranged_http_download_loop<F>(
downloaded_to: &Arc<AtomicUsize>,
gen: u64,
gen_arc: &Arc<AtomicU64>,
http_headers: &PlaybackHttpHeaders,
mut on_partial: F,
playback_armed: Option<&AtomicBool>,
) -> (usize, RangedHttpLoopOutcome)
@@ -387,6 +393,7 @@ where
if downloaded > 0 {
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
}
req = http_headers.apply(url, req);
match req.send().await {
Ok(r) => r,
Err(err) => {
@@ -487,6 +494,7 @@ where
}
/// Fetch `bytes=start-end` into `buf[start..=end]` (inclusive HTTP Range).
#[allow(clippy::too_many_arguments)]
async fn ranged_write_http_range(
http_client: &reqwest::Client,
url: &str,
@@ -495,13 +503,18 @@ async fn ranged_write_http_range(
end_inclusive: u64,
gen: u64,
gen_arc: &Arc<AtomicU64>,
http_headers: &PlaybackHttpHeaders,
) -> Result<usize, ()> {
if gen_arc.load(Ordering::SeqCst) != gen {
return Err(());
}
let response = http_client
let response = http_headers
.apply(
url,
http_client
.get(url)
.header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}"))
.header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}")),
)
.send()
.await
.map_err(|_| ())?;
@@ -555,6 +568,7 @@ async fn ranged_prefetch_mp4_tail(
playback_armed: Arc<AtomicBool>,
gen: u64,
gen_arc: Arc<AtomicU64>,
http_headers: PlaybackHttpHeaders,
) {
const MIN_TAIL: u64 = 256 * 1024;
const MAX_TAIL: u64 = 8 * 1024 * 1024;
@@ -573,6 +587,7 @@ async fn ranged_prefetch_mp4_tail(
end_inclusive,
gen,
&gen_arc,
&http_headers,
)
.await
{
@@ -622,6 +637,7 @@ pub(crate) async fn ranged_download_task(
cache_track_id: Option<String>,
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
server_id: Option<String>,
http_headers: PlaybackHttpHeaders,
// When `Some`, ranged playback seeds on completion — defer HTTP backfill for that
// track; `None` for large files where ranged skips seed (needs backfill).
loudness_seed_hold: Option<LoudnessSeedHold>,
@@ -705,6 +721,7 @@ pub(crate) async fn ranged_download_task(
let tail_from_bg = tail_filled_from.clone();
let armed_bg = playback_armed.clone();
let gen_bg = gen_arc.clone();
let headers_bg = http_headers.clone();
Some(tokio::spawn(async move {
ranged_prefetch_mp4_tail(
client,
@@ -716,6 +733,7 @@ pub(crate) async fn ranged_download_task(
armed_bg,
gen,
gen_bg,
headers_bg,
)
.await;
}))
@@ -736,6 +754,7 @@ pub(crate) async fn ranged_download_task(
&downloaded_to,
gen,
&gen_arc,
&http_headers,
on_partial,
linear_arm,
)
@@ -1127,6 +1146,7 @@ mod tests {
&dl,
1,
&gen_arc,
&PlaybackHttpHeaders::default(),
|_, _| {},
None,
)
@@ -1162,6 +1182,7 @@ mod tests {
&dl,
1,
&gen_arc,
&PlaybackHttpHeaders::default(),
|downloaded, total| calls.lock().unwrap().push((downloaded, total)),
None,
)
@@ -1190,7 +1211,7 @@ mod tests {
let (buf, dl, gen_arc) = loop_state(1024);
let (downloaded, outcome) =
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
.await;
assert_eq!(outcome, RangedHttpLoopOutcome::Aborted);
@@ -1221,7 +1242,7 @@ mod tests {
gen_arc.store(99, Ordering::SeqCst);
let (downloaded, outcome) =
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
.await;
assert_eq!(outcome, RangedHttpLoopOutcome::Superseded);
@@ -1280,7 +1301,7 @@ mod tests {
let (buf, dl, gen_arc) = loop_state(body.len());
let (downloaded, outcome) =
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
.await;
// Stream finishes via a Range-resumed second request.
@@ -1354,6 +1375,7 @@ mod tests {
total as u64,
gen_arc.clone(),
1,
PlaybackHttpHeaders::default(),
)));
let mut src = RangedHttpSource {
buf,
@@ -1406,6 +1428,7 @@ mod tests {
2047,
1,
&gen_arc,
&PlaybackHttpHeaders::default(),
)
.await;
@@ -1440,7 +1463,7 @@ mod tests {
let (buf, dl, gen_arc) = loop_state(body.len());
let (downloaded, outcome) =
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
.await;
// Reconnect server returned 200 instead of 206 → Aborted, downloaded
@@ -15,6 +15,7 @@ use ringbuf::HeapProd;
use ringbuf::traits::Producer;
use tauri::AppHandle;
use super::super::engine::PlaybackHttpHeaders;
use super::super::state::PreloadedTrack;
use super::{
maybe_arm_stream_playback, TRACK_STREAM_MAX_RECONNECTS, TRACK_STREAM_PROMOTE_MAX_BYTES,
@@ -37,6 +38,7 @@ pub(crate) async fn track_download_task(
cache_track_id: Option<String>,
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
server_id: Option<String>,
http_headers: PlaybackHttpHeaders,
playback_armed: Arc<AtomicBool>,
) {
let mut downloaded: u64 = 0;
@@ -53,6 +55,7 @@ pub(crate) async fn track_download_task(
if downloaded > 0 {
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
}
req = http_headers.apply(&url, req);
match req.send().await {
Ok(r) => r,
Err(err) => {
@@ -9,6 +9,8 @@ publish = false
[dependencies]
tauri = { version = "2" }
serde = { version = "1", features = ["derive"] }
reqwest = { version = "0.13", default-features = false, features = ["rustls"] }
url = "2"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -4,6 +4,7 @@
//! macros) and the cross-crate port traits used to break dependency cycles
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
pub mod server_http;
pub mod cover_cache_layout;
pub mod log_sanitize;
pub mod media_layout;
@@ -8,12 +8,14 @@ const SENSITIVE_QUERY_KEYS: &[&str] = &[
const SENSITIVE_KV_KEYS: &[&str] = &[
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token",
"refresh_token", "authorization", "auth",
"refresh_token", "authorization", "auth", "cookie", "x-api-key",
"cf-access-client-secret", "cf-access-client-id", "x-auth-token",
];
/// Sanitize one runtime log line for display and export.
pub fn sanitize_log_line(line: &str) -> String {
let mut out = redact_bearer_tokens(line);
out = redact_pangolin_headers(&out);
out = redact_sensitive_key_values(&out);
out = redact_urls_in_text(&out);
out
@@ -43,6 +45,37 @@ fn redact_bearer_tokens(line: &str) -> String {
s
}
fn redact_pangolin_headers(line: &str) -> String {
let lower = line.to_ascii_lowercase();
let mut out = line.to_string();
let mut search_from = 0;
while let Some(rel) = lower[search_from..].find("x-pangolin-") {
let idx = search_from + rel;
let after_prefix = &lower[idx..];
let Some(sep_rel) = after_prefix.find([':', '=']) else {
search_from = idx + 1;
continue;
};
let sep_idx = idx + sep_rel;
let val_start = sep_idx + 1;
let slice = &out[val_start..];
let trimmed = slice.trim_start();
let ws = slice.len().saturating_sub(trimmed.len());
let val_start = val_start + ws;
let end = trimmed
.find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')')
.unwrap_or(trimmed.len());
if end > 0 {
out.replace_range(val_start..val_start + end, "REDACTED");
}
search_from = val_start + "REDACTED".len();
if search_from >= out.len() {
break;
}
}
out
}
fn redact_sensitive_key_values(line: &str) -> String {
let mut out = line.to_string();
for key in SENSITIVE_KV_KEYS {
@@ -368,6 +401,17 @@ mod tests {
assert!(!out.contains("user:pass"));
}
#[test]
fn redacts_reverse_proxy_gate_headers() {
let line = "req CF-Access-Client-Secret: gate-secret Authorization: Bearer tok123 x-pangolin-auth: pangolin-key";
let out = sanitize_log_line(line);
assert!(out.contains("CF-Access-Client-Secret: REDACTED"));
assert!(!out.contains("gate-secret"));
assert!(!out.contains("tok123"));
assert!(out.contains("x-pangolin-auth: REDACTED"));
assert!(!out.contains("pangolin-key"));
}
#[test]
fn stream_log_with_em_dash_does_not_panic() {
let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")";
@@ -0,0 +1,366 @@
//! Per-server custom HTTP headers for reverse-proxy gates (Pangolin, Cloudflare Access).
//! Registry is keyed by index key; app server UUID aliases resolve via `ref_to_key`.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::RequestBuilder;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EndpointKind {
Local,
Public,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CustomHeadersApplyTo {
Local,
#[default]
Public,
Both,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ServerHttpEndpointWire {
pub url: String,
pub kind: EndpointKind,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CustomHeaderEntryWire {
pub name: String,
pub value: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ServerHttpContextSyncWire {
#[serde(rename = "serverId")]
pub server_id: String,
#[serde(rename = "appServerId")]
pub app_server_id: String,
pub endpoints: Vec<ServerHttpEndpointWire>,
#[serde(rename = "customHeaders", default)]
pub custom_headers: Vec<CustomHeaderEntryWire>,
#[serde(rename = "customHeadersApplyTo", default)]
pub custom_headers_apply_to: Option<CustomHeadersApplyTo>,
}
#[derive(Clone, Debug)]
pub struct ServerHttpContext {
pub endpoints: Vec<(String, EndpointKind)>,
pub headers: Vec<(String, String)>,
pub apply_to: CustomHeadersApplyTo,
}
impl From<ServerHttpContextSyncWire> for ServerHttpContext {
fn from(w: ServerHttpContextSyncWire) -> Self {
Self {
endpoints: w
.endpoints
.into_iter()
.map(|e| (normalize_server_base_url(&e.url), e.kind))
.collect(),
headers: w
.custom_headers
.into_iter()
.map(|h| (h.name.trim().to_string(), h.value))
.filter(|(n, _)| !n.is_empty())
.collect(),
apply_to: w.custom_headers_apply_to.unwrap_or_default(),
}
}
}
fn normalize_server_base_url(raw: &str) -> String {
let trimmed = raw.trim().trim_end_matches('/');
if trimmed.is_empty() {
return String::new();
}
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
trimmed.to_string()
} else {
format!("http://{trimmed}")
}
}
/// Strip `/rest/…`, `/api/…`, `/auth/…`, and query from a full HTTP URL to match TS `requestBaseUrlFromHttpUrl`.
pub fn request_base_url_from_http_url(raw_url: &str) -> String {
let trimmed = raw_url.trim();
if trimmed.is_empty() {
return String::new();
}
let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
trimmed.to_string()
} else {
format!("http://{trimmed}")
};
let Ok(mut parsed) = url::Url::parse(&with_scheme) else {
return normalize_server_base_url(trimmed);
};
parsed.set_query(None);
parsed.set_fragment(None);
let mut path = parsed.path().to_string();
if let Some(idx) = path.find("/rest/") {
path.truncate(idx);
} else if path.ends_with("/rest") {
path.truncate(path.len().saturating_sub("/rest".len()));
} else {
for seg in ["/api/", "/auth/"] {
if let Some(idx) = path.find(seg) {
path.truncate(idx);
break;
}
}
}
while path.ends_with('/') && path.len() > 1 {
path.pop();
}
parsed.set_path(if path.is_empty() { "/" } else { &path });
let host = parsed.host_str().unwrap_or_default();
if host.is_empty() {
return normalize_server_base_url(trimmed);
}
let mut out = format!("{}://{}", parsed.scheme(), host);
if let Some(port) = parsed.port() {
out.push(':');
out.push_str(&port.to_string());
}
if !path.is_empty() && path != "/" {
out.push_str(&path);
}
normalize_server_base_url(&out)
}
pub fn headers_for_request_base_url(ctx: &ServerHttpContext, request_base_url: &str) -> HeaderMap {
let mut map = HeaderMap::new();
if ctx.headers.is_empty() {
return map;
}
let normalized = normalize_server_base_url(request_base_url);
let Some((_, kind)) = ctx.endpoints.iter().find(|(u, _)| *u == normalized) else {
return map;
};
let apply = match ctx.apply_to {
CustomHeadersApplyTo::Both => true,
CustomHeadersApplyTo::Public => *kind == EndpointKind::Public,
CustomHeadersApplyTo::Local => *kind == EndpointKind::Local,
};
if !apply {
return map;
}
for (name, value) in &ctx.headers {
let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else {
continue;
};
let Ok(header_value) = HeaderValue::from_str(value) else {
continue;
};
map.insert(header_name, header_value);
}
map
}
pub fn apply_server_headers(
builder: RequestBuilder,
ctx: &ServerHttpContext,
request_base_url: &str,
) -> RequestBuilder {
let map = headers_for_request_base_url(ctx, request_base_url);
if map.is_empty() {
return builder;
}
builder.headers(map)
}
pub fn apply_server_headers_for_http_url(
builder: RequestBuilder,
ctx: &ServerHttpContext,
full_http_url: &str,
) -> RequestBuilder {
let base = request_base_url_from_http_url(full_http_url);
apply_server_headers(builder, ctx, &base)
}
#[derive(Default)]
pub struct ServerHttpRegistry {
contexts: Mutex<HashMap<String, Arc<ServerHttpContext>>>,
ref_to_key: Mutex<HashMap<String, String>>,
}
impl ServerHttpRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn sync(&self, wire: ServerHttpContextSyncWire) {
let index_key = wire.server_id.clone();
let app_id = wire.app_server_id.clone();
let ctx = Arc::new(ServerHttpContext::from(wire));
if ctx.headers.is_empty() {
self.remove(&index_key, &app_id);
return;
}
{
let mut contexts = self.contexts.lock().unwrap();
contexts.insert(index_key.clone(), Arc::clone(&ctx));
}
let mut refs = self.ref_to_key.lock().unwrap();
refs.insert(index_key.clone(), index_key.clone());
refs.insert(app_id, index_key);
}
pub fn sync_all(&self, entries: Vec<ServerHttpContextSyncWire>) {
let mut new_contexts = HashMap::new();
let mut new_refs = HashMap::new();
for wire in entries {
let index_key = wire.server_id.clone();
let app_id = wire.app_server_id.clone();
let ctx = Arc::new(ServerHttpContext::from(wire));
if ctx.headers.is_empty() {
continue;
}
new_contexts.insert(index_key.clone(), Arc::clone(&ctx));
new_refs.insert(index_key.clone(), index_key.clone());
new_refs.insert(app_id, index_key);
}
*self.contexts.lock().unwrap() = new_contexts;
*self.ref_to_key.lock().unwrap() = new_refs;
}
pub fn remove(&self, index_key: &str, app_server_id: &str) {
self.contexts.lock().unwrap().remove(index_key);
let mut refs = self.ref_to_key.lock().unwrap();
refs.remove(index_key);
refs.remove(app_server_id);
}
pub fn get(&self, index_key: &str) -> Option<Arc<ServerHttpContext>> {
self.contexts.lock().unwrap().get(index_key).cloned()
}
pub fn get_for_server_ref(&self, server_ref: &str) -> Option<Arc<ServerHttpContext>> {
if server_ref.is_empty() {
return None;
}
let key = {
let refs = self.ref_to_key.lock().unwrap();
refs.get(server_ref).cloned()
};
if let Some(k) = key {
return self.get(&k);
}
self.get(server_ref)
}
/// Fallback when only a server base URL is known (Navidrome invoke paths).
pub fn get_for_server_url(&self, server_url: &str) -> Option<Arc<ServerHttpContext>> {
let base = request_base_url_from_http_url(server_url);
if base.is_empty() {
return None;
}
let contexts = self.contexts.lock().unwrap();
for ctx in contexts.values() {
if ctx.endpoints.iter().any(|(u, _)| *u == base) {
return Some(Arc::clone(ctx));
}
}
None
}
pub fn apply_for_http_url(
&self,
server_ref: &str,
full_http_url: &str,
builder: RequestBuilder,
) -> RequestBuilder {
let Some(ctx) = self.get_for_server_ref(server_ref) else {
return builder;
};
apply_server_headers_for_http_url(builder, &ctx, full_http_url)
}
pub fn apply_for_base_url(
&self,
server_ref: &str,
request_base_url: &str,
builder: RequestBuilder,
) -> RequestBuilder {
let Some(ctx) = self.get_for_server_ref(server_ref) else {
return builder;
};
apply_server_headers(builder, &ctx, request_base_url)
}
}
/// Apply custom headers when `registry` is present — prefers `server_ref`, falls back to URL match.
pub fn apply_optional_registry_headers(
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
full_http_url: &str,
builder: RequestBuilder,
) -> RequestBuilder {
if let Some(reg) = registry {
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
return reg.apply_for_http_url(sid, full_http_url, builder);
}
if let Some(ctx) = reg.get_for_server_url(full_http_url) {
return apply_server_headers_for_http_url(builder, &ctx, full_http_url);
}
}
builder
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_base_url_strips_rest_and_query() {
let url = "https://music.example/rest/stream.view?id=1&u=x";
assert_eq!(
request_base_url_from_http_url(url),
"https://music.example"
);
}
#[test]
fn headers_apply_public_only_on_public_endpoint() {
let ctx = ServerHttpContext {
endpoints: vec![
("http://192.168.0.10".into(), EndpointKind::Local),
("https://music.example".into(), EndpointKind::Public),
],
headers: vec![("X-Gate".into(), "secret".into())],
apply_to: CustomHeadersApplyTo::Public,
};
let lan = headers_for_request_base_url(&ctx, "http://192.168.0.10");
assert!(lan.is_empty());
let pub_ = headers_for_request_base_url(&ctx, "https://music.example");
assert_eq!(pub_.get("X-Gate").map(|v| v.to_str().ok()), Some(Some("secret")));
}
#[test]
fn registry_resolves_app_id_alias() {
let reg = ServerHttpRegistry::new();
reg.sync(ServerHttpContextSyncWire {
server_id: "music.example".into(),
app_server_id: "uuid-1".into(),
endpoints: vec![ServerHttpEndpointWire {
url: "https://music.example".into(),
kind: EndpointKind::Public,
}],
custom_headers: vec![CustomHeaderEntryWire {
name: "X-Gate".into(),
value: "tok".into(),
}],
custom_headers_apply_to: Some(CustomHeadersApplyTo::Public),
});
assert!(reg.get("music.example").is_some());
assert!(reg.get_for_server_ref("uuid-1").is_some());
assert!(reg.get("uuid-1").is_none());
}
}
@@ -1,15 +1,31 @@
//! Auth + retry + HTTP client for Navidrome's native REST API.
//! Used by every other navidrome submodule for `/auth/*` and `/api/*` calls.
use psysonic_core::server_http::{apply_server_headers_for_http_url, apply_optional_registry_headers, ServerHttpRegistry};
/// Authenticate with Navidrome's own REST API and return a Bearer token.
pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
navidrome_token_with_registry(None, server_url, username, password).await
}
pub async fn navidrome_token_with_registry(
registry: Option<&ServerHttpRegistry>,
server_url: &str,
username: &str,
password: &str,
) -> Result<String, String> {
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/auth/login", server_url))
.json(&serde_json::json!({ "username": username, "password": password }))
.send()
.await
.map_err(|e| e.to_string())?;
let base = server_url.trim_end_matches('/');
let login_url = format!("{base}/auth/login");
let mut req = client
.post(&login_url)
.json(&serde_json::json!({ "username": username, "password": password }));
if let Some(reg) = registry {
if let Some(ctx) = reg.get_for_server_url(server_url) {
req = apply_server_headers_for_http_url(req, &ctx, &login_url);
}
}
let resp = req.send().await.map_err(|e| e.to_string())?;
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
data["token"]
.as_str()
@@ -17,6 +33,16 @@ pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -
.ok_or_else(|| "Navidrome auth: no token in response".to_string())
}
/// Attach gate headers for Navidrome `/auth/*` and `/api/*` requests.
pub fn nd_apply_request(
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
full_url: &str,
builder: reqwest::RequestBuilder,
) -> reqwest::RequestBuilder {
apply_optional_registry_headers(registry, server_ref, full_url, builder)
}
/// Payload returned by Navidrome's `/auth/login`.
#[derive(serde::Serialize)]
pub struct NdLoginResult {
@@ -2,10 +2,16 @@
//! login (via `navidrome_token`) and then a multipart POST to the relevant
//! `/api/{playlist|radio|artist}/{id}/image` endpoint.
use super::client::navidrome_token;
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use tauri::State;
use super::client::{navidrome_token_with_registry, nd_apply_request, nd_http_client};
#[tauri::command]
pub async fn upload_playlist_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
playlist_id: String,
username: String,
@@ -13,16 +19,23 @@ pub async fn upload_playlist_cover(
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/playlist/{}/image", server_url, playlist_id))
let url = format!("{}/api/playlist/{}/image", server_url, playlist_id);
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.multipart(form),
)
.send()
.await
.map_err(|e| e.to_string())?
@@ -33,6 +46,7 @@ pub async fn upload_playlist_cover(
#[tauri::command]
pub async fn upload_radio_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
radio_id: String,
username: String,
@@ -40,16 +54,23 @@ pub async fn upload_radio_cover(
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/radio/{}/image", server_url, radio_id))
let url = format!("{}/api/radio/{}/image", server_url, radio_id);
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.multipart(form),
)
.send()
.await
.map_err(|e| e.to_string())?
@@ -60,6 +81,7 @@ pub async fn upload_radio_cover(
#[tauri::command]
pub async fn upload_artist_image(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
artist_id: String,
username: String,
@@ -67,16 +89,23 @@ pub async fn upload_artist_image(
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/artist/{}/image", server_url, artist_id))
let url = format!("{}/api/artist/{}/image", server_url, artist_id);
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.multipart(form),
)
.send()
.await
.map_err(|e| e.to_string())?
@@ -87,20 +116,31 @@ pub async fn upload_artist_image(
#[tauri::command]
pub async fn delete_radio_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
radio_id: String,
username: String,
password: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let resp = reqwest::Client::new()
.delete(format!("{}/api/radio/{}/image", server_url, radio_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let url = format!("{}/api/radio/{}/image", server_url, radio_id);
let resp = nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.delete(&url)
.header("X-ND-Authorization", format!("Bearer {}", token)),
)
.send()
.await
.map_err(|e| e.to_string())?;
// 404/503 = no image existed — treat as success
if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE {
if !resp.status().is_success()
&& resp.status() != reqwest::StatusCode::NOT_FOUND
&& resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE
{
resp.error_for_status().map_err(|e| e.to_string())?;
}
Ok(())
@@ -11,4 +11,4 @@ pub mod probe;
pub mod queries;
pub mod users;
pub use client::navidrome_token;
pub use client::{navidrome_token, navidrome_token_with_registry, nd_apply_request};
@@ -2,24 +2,41 @@
//! payload is forwarded as-is so the frontend can compose any rule the
//! Navidrome version supports without backend changes.
use super::client::{nd_err, nd_http_client, nd_retry};
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use tauri::State;
use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry};
/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists.
#[tauri::command]
pub async fn nd_list_playlists(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
smart: Option<bool>,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let base = format!("{}/api/playlist", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
let client = nd_http_client();
let mut req = client
.get(format!("{}/api/playlist", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token));
let base = base.clone();
let auth = auth.clone();
async move {
let mut req = nd_apply_request(
Some(reg),
None,
&base,
nd_http_client()
.get(&base)
.header("X-ND-Authorization", auth),
);
if let Some(s) = smart {
req = req.query(&[("smart", s)]);
}
req.send()
req.send().await
}
})
.await?;
if !resp.status().is_success() {
@@ -31,16 +48,31 @@ pub async fn nd_list_playlists(
/// POST `/api/playlist` — create playlist (supports smart rules payload).
#[tauri::command]
pub async fn nd_create_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
body: serde_json::Value,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/playlist", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(format!("{}/api/playlist", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
.post(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
.await
}
})
.await?;
let status = resp.status();
@@ -54,17 +86,32 @@ pub async fn nd_create_playlist(
/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload).
#[tauri::command]
pub async fn nd_update_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
body: serde_json::Value,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/playlist/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.put(format!("{}/api/playlist/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
.put(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
.await
}
})
.await?;
let status = resp.status();
@@ -78,15 +125,29 @@ pub async fn nd_update_playlist(
/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available).
#[tauri::command]
pub async fn nd_get_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/playlist/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.get(format!("{}/api/playlist/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.get(&url)
.header("X-ND-Authorization", auth),
)
.send()
.await
}
})
.await?;
let status = resp.status();
@@ -100,15 +161,29 @@ pub async fn nd_get_playlist(
/// DELETE `/api/playlist/{id}` — delete playlist.
#[tauri::command]
pub async fn nd_delete_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
) -> Result<(), String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/playlist/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.delete(format!("{}/api/playlist/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.delete(&url)
.header("X-ND-Authorization", auth),
)
.send()
.await
}
})
.await?;
let status = resp.status();
@@ -6,7 +6,7 @@
//! endpoint? — so this stays a free function rather than a client
//! struct. The full `nd_list_songs`-style ingest loop lands with PR-3b.
use super::client::{nd_err, nd_http_client};
use super::client::{nd_apply_request, nd_err, nd_http_client};
/// Returns `Ok(true)` when `GET /api/song?_start=0&_end=1` answers with
/// a 2xx status, `Ok(false)` for 4xx (auth ok but endpoint missing or
@@ -16,12 +16,22 @@ use super::client::{nd_err, nd_http_client};
/// Spec §6.1 ties the result to the `NavidromeNativeBulk` capability
/// flag. Wider call into the actual ingest path (`nd_list_songs` port)
/// is PR-3b's job.
pub async fn native_bulk_available(server_url: &str, token: &str) -> Result<bool, String> {
pub async fn native_bulk_available(
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
server_url: &str,
token: &str,
) -> Result<bool, String> {
let client = nd_http_client();
let url = format!("{}/api/song?_start=0&_end=1", server_url.trim_end_matches('/'));
let resp = client
.get(url)
.header("X-ND-Authorization", format!("Bearer {token}"))
let resp = nd_apply_request(
registry,
server_ref,
&url,
client
.get(&url)
.header("X-ND-Authorization", format!("Bearer {token}")),
)
.send()
.await
.map_err(nd_err)?;
@@ -56,7 +66,7 @@ mod tests {
.mount(&server)
.await;
let ok = native_bulk_available(&server.uri(), "tok-123").await.unwrap();
let ok = native_bulk_available(None, None, &server.uri(), "tok-123").await.unwrap();
assert!(ok);
}
@@ -71,7 +81,7 @@ mod tests {
.mount(&server)
.await;
let ok = native_bulk_available(&server.uri(), "tok").await.unwrap();
let ok = native_bulk_available(None, None, &server.uri(), "tok").await.unwrap();
assert!(!ok);
}
@@ -84,7 +94,7 @@ mod tests {
.mount(&server)
.await;
let ok = native_bulk_available(&server.uri(), "bad").await.unwrap();
let ok = native_bulk_available(None, None, &server.uri(), "bad").await.unwrap();
assert!(!ok, "401 reads as `endpoint not available for this caller`");
}
@@ -97,7 +107,7 @@ mod tests {
.mount(&server)
.await;
let err = native_bulk_available(&server.uri(), "tok").await.unwrap_err();
let err = native_bulk_available(None, None, &server.uri(), "tok").await.unwrap_err();
assert!(err.contains("503"));
}
@@ -111,6 +121,6 @@ mod tests {
.await;
let with_slash = format!("{}/", server.uri());
assert!(native_bulk_available(&with_slash, "tok").await.unwrap());
assert!(native_bulk_available(None, None, &with_slash, "tok").await.unwrap());
}
}
@@ -2,13 +2,21 @@
//! incompletely: songs, role-filtered artist/album lists, libraries,
//! per-user library assignment, and absolute song path resolution.
use super::client::{navidrome_token, nd_err, nd_http_client, nd_retry};
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use tauri::State;
use super::client::{navidrome_token_with_registry, nd_apply_request, nd_err, nd_http_client, nd_retry};
/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated
/// song list. Pure async helper used by the library-side N1 ingest
/// loop (spec §6.3, PR-3*); also wrapped by the `#[tauri::command]`
/// variant below for existing frontend callers.
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_songs_internal(
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
server_url: &str,
token: &str,
sort: &str,
@@ -20,12 +28,24 @@ pub async fn nd_list_songs_internal(
"{}/api/song?_sort={}&_order={}&_start={}&_end={}",
server_url, sort, order, start, end
);
let auth = format!("Bearer {token}");
let resp = nd_retry(|| {
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
registry,
server_ref,
&url,
nd_http_client()
.get(&url)
.header("X-ND-Authorization", format!("Bearer {token}"))
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -36,6 +56,7 @@ pub async fn nd_list_songs_internal(
/// surface unchanged for existing call sites in the WebView.
#[tauri::command]
pub async fn nd_list_songs(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
sort: String,
@@ -43,7 +64,17 @@ pub async fn nd_list_songs(
start: u32,
end: u32,
) -> Result<serde_json::Value, String> {
nd_list_songs_internal(&server_url, &token, &sort, &order, start, end).await
nd_list_songs_internal(
Some(http_registry.as_ref()),
None,
&server_url,
&token,
&sort,
&order,
start,
end,
)
.await
}
/// Build the `_filters` JSON for native-API list calls. Optionally narrows the
@@ -70,6 +101,7 @@ fn nd_build_filters(seed: serde_json::Map<String, serde_json::Value>, library_id
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_artists_by_role(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
role: String,
@@ -79,14 +111,28 @@ pub async fn nd_list_artists_by_role(
end: u32,
library_id: Option<String>,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let mut seed = serde_json::Map::new();
seed.insert("role".to_string(), serde_json::Value::String(role.clone()));
let filters = nd_build_filters(seed, library_id.as_deref());
let start_s = start.to_string();
let end_s = end.to_string();
let base = format!("{}/api/artist", server_url);
let resp = nd_retry(|| {
let base = base.clone();
let filters = filters.clone();
let sort = sort.clone();
let order = order.clone();
let start_s = start_s.clone();
let end_s = end_s.clone();
let auth = format!("Bearer {}", token);
async move {
nd_apply_request(
Some(reg),
None,
&base,
nd_http_client()
.get(format!("{}/api/artist", server_url))
.get(&base)
.query(&[
("_filters", filters.as_str()),
("_sort", sort.as_str()),
@@ -94,9 +140,13 @@ pub async fn nd_list_artists_by_role(
("_start", start_s.as_str()),
("_end", end_s.as_str()),
])
.header("X-ND-Authorization", format!("Bearer {}", token))
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -111,6 +161,7 @@ pub async fn nd_list_artists_by_role(
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_albums_by_artist_role(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
artist_id: String,
@@ -121,15 +172,29 @@ pub async fn nd_list_albums_by_artist_role(
end: u32,
library_id: Option<String>,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let filter_key = format!("role_{}_id", role);
let mut seed = serde_json::Map::new();
seed.insert(filter_key, serde_json::Value::String(artist_id.clone()));
let filters = nd_build_filters(seed, library_id.as_deref());
let start_s = start.to_string();
let end_s = end.to_string();
let base = format!("{}/api/album", server_url);
let resp = nd_retry(|| {
let base = base.clone();
let filters = filters.clone();
let sort = sort.clone();
let order = order.clone();
let start_s = start_s.clone();
let end_s = end_s.clone();
let auth = format!("Bearer {}", token);
async move {
nd_apply_request(
Some(reg),
None,
&base,
nd_http_client()
.get(format!("{}/api/album", server_url))
.get(&base)
.query(&[
("_filters", filters.as_str()),
("_sort", sort.as_str()),
@@ -137,9 +202,13 @@ pub async fn nd_list_albums_by_artist_role(
("_start", start_s.as_str()),
("_end", end_s.as_str()),
])
.header("X-ND-Authorization", format!("Bearer {}", token))
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -149,15 +218,28 @@ pub async fn nd_list_albums_by_artist_role(
/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array.
#[tauri::command]
pub async fn nd_list_libraries(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/library", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/library", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client().get(&url).header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -168,19 +250,35 @@ pub async fn nd_list_libraries(
/// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400.
#[tauri::command]
pub async fn nd_set_user_libraries(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
library_ids: Vec<i64>,
) -> Result<(), String> {
let reg = http_registry.as_ref();
let body = serde_json::json!({ "libraryIds": library_ids });
let url = format!("{}/api/user/{}/library", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.put(format!("{}/api/user/{}/library", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
.put(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
}).await?;
.await
}
})
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
@@ -202,18 +300,31 @@ pub async fn nd_set_user_libraries(
/// it for non-admin users on some configurations.
#[tauri::command]
pub async fn nd_get_song_path(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
username: String,
password: String,
id: String,
) -> Result<Option<String>, String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let url = format!("{}/api/song/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.get(&url)
.header("X-ND-Authorization", format!("Bearer {}", token))
.header("X-ND-Authorization", auth),
)
.send()
.await
}
})
.await?;
if !resp.status().is_success() {
@@ -2,22 +2,39 @@
//! `token` (obtained via `navidrome_login`); admin-only ones return 401/403
//! when the caller is not an admin.
use super::client::{nd_err, nd_http_client, nd_retry, NdLoginResult};
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use tauri::State;
use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry, NdLoginResult};
/// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin.
#[tauri::command]
pub async fn navidrome_login(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
username: String,
password: String,
) -> Result<NdLoginResult, String> {
let reg = http_registry.as_ref();
let body = serde_json::json!({ "username": username, "password": password });
let login_url = format!("{}/auth/login", server_url.trim_end_matches('/'));
let resp = nd_retry(|| {
nd_http_client()
.post(format!("{}/auth/login", server_url))
.json(&body)
let login_url = login_url.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&login_url,
nd_http_client().post(&login_url).json(&body),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("Navidrome login failed: HTTP {}", resp.status()));
}
@@ -25,21 +42,40 @@ pub async fn navidrome_login(
let token = data["token"].as_str().ok_or("no token in response")?.to_string();
let user_id = data["id"].as_str().unwrap_or("").to_string();
let is_admin = data["isAdmin"].as_bool().unwrap_or(false);
Ok(NdLoginResult { token, user_id, is_admin })
Ok(NdLoginResult {
token,
user_id,
is_admin,
})
}
/// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields.
#[tauri::command]
pub async fn nd_list_users(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/user", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.get(format!("{}/api/user", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
.get(&url)
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -48,7 +84,9 @@ pub async fn nd_list_users(
/// POST `/api/user` — create a user.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_create_user(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
user_name: String,
@@ -57,6 +95,7 @@ pub async fn nd_create_user(
password: String,
is_admin: bool,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let body = serde_json::json!({
"userName": user_name,
"name": name,
@@ -64,13 +103,27 @@ pub async fn nd_create_user(
"password": password,
"isAdmin": is_admin,
});
let url = format!("{}/api/user", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(format!("{}/api/user", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
.post(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
}).await?;
.await
}
})
.await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
@@ -83,6 +136,7 @@ pub async fn nd_create_user(
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_update_user(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
@@ -92,6 +146,7 @@ pub async fn nd_update_user(
password: String,
is_admin: bool,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let mut body = serde_json::json!({
"id": id,
"userName": user_name,
@@ -102,13 +157,27 @@ pub async fn nd_update_user(
if !password.is_empty() {
body["password"] = serde_json::Value::String(password);
}
let url = format!("{}/api/user/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.put(format!("{}/api/user/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
.put(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
}).await?;
.await
}
})
.await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
@@ -120,16 +189,31 @@ pub async fn nd_update_user(
/// DELETE `/api/user/{id}`.
#[tauri::command]
pub async fn nd_delete_user(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
) -> Result<(), String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/user/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.delete(format!("{}/api/user/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.delete(&url)
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
@@ -12,6 +12,7 @@ use serde::Deserialize;
use super::auth::SubsonicCredentials;
use super::error::{flatten_reqwest_error, SubsonicError};
use super::types::{Album, AlbumSummary, ArtistIndex, ScanStatus, SearchResult, ServerInfo, Song};
use psysonic_core::server_http::{apply_server_headers, ServerHttpContext};
/// Protocol level we advertise — pre-OpenSubsonic Subsonic baseline that
/// Navidrome and other servers in the wild support. OpenSubsonic
@@ -42,6 +43,7 @@ pub struct SubsonicClient {
base_url: String,
credentials: CredentialsMode,
http: reqwest::Client,
http_context: Option<ServerHttpContext>,
}
impl SubsonicClient {
@@ -75,9 +77,28 @@ impl SubsonicClient {
password: password.into(),
},
http,
http_context: None,
}
}
pub fn with_http_context(mut self, ctx: ServerHttpContext) -> Self {
self.http_context = Some(ctx);
self
}
/// Production helper — attach registry context when present for `server_ref`
/// (app server id or index key).
pub fn with_registry(
self,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: &str,
) -> Self {
registry
.and_then(|r| r.get_for_server_ref(server_ref))
.map(|ctx| self.clone().with_http_context((*ctx).clone()))
.unwrap_or(self)
}
/// Test-/cache-friendly constructor — re-uses the same
/// `SubsonicCredentials` triple on every call. Wiremock tests rely on
/// this for deterministic `s=` and `t=` query params; production code
@@ -95,6 +116,7 @@ impl SubsonicClient {
base_url: url,
credentials: CredentialsMode::Static(credentials),
http,
http_context: None,
}
}
@@ -301,10 +323,15 @@ impl SubsonicClient {
let mut query: Vec<(&str, &str)> = auth.to_vec();
query.extend_from_slice(extra);
let resp = self
let mut req = self
.http
.get(format!("{}/rest/{method}.view", self.base_url))
.query(&query)
.query(&query);
if let Some(ctx) = &self.http_context {
req = apply_server_headers(req, ctx, &self.base_url);
}
let resp = req
.send()
.await
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))?;
@@ -331,6 +358,16 @@ fn default_http_client() -> reqwest::Client {
.unwrap_or_else(|_| reqwest::Client::new())
}
pub fn subsonic_client_with_registry(
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: &str,
base_url: impl Into<String>,
username: impl Into<String>,
password: impl Into<String>,
) -> SubsonicClient {
SubsonicClient::new(base_url, username, password).with_registry(registry, server_ref)
}
/// Validate the Subsonic envelope and return the raw `serde_json::Value`
/// at `body_key`. Maps `error.code = 70` to the dedicated `NotFound`
/// variant; surfaces every other failed status as `Api { code, message }`.
@@ -10,7 +10,8 @@ pub mod types;
pub use auth::SubsonicCredentials;
pub use client::{
fingerprint_sample, SubsonicClient, SUBSONIC_API_VERSION, SUBSONIC_CLIENT_ID,
fingerprint_sample, subsonic_client_with_registry, SubsonicClient, SUBSONIC_API_VERSION,
SUBSONIC_CLIENT_ID,
};
pub use stream_url::{build_stream_view_url, rest_base_from_url};
pub use error::SubsonicError;
@@ -11,8 +11,9 @@ use rusqlite::params;
use serde_json::Value;
use tauri::{AppHandle, Emitter, Manager, State};
use psysonic_integration::navidrome::navidrome_token;
use psysonic_integration::subsonic::SubsonicClient;
use psysonic_core::server_http::ServerHttpRegistry;
use psysonic_integration::navidrome::navidrome_token_with_registry;
use psysonic_integration::subsonic::subsonic_client_with_registry;
use crate::advanced_search;
use crate::analysis_backfill::{self, LibraryAnalysisBackfillBatchDto, LibraryAnalysisProgressDto};
@@ -657,13 +658,14 @@ fn normalize_base_url(raw: &str) -> String {
/// caller falls back to a cached bearer / the Subsonic-only path. Never logs
/// the token or credentials.
async fn navidrome_token_with_retry(
registry: Option<&ServerHttpRegistry>,
base_url: &str,
username: &str,
password: &str,
) -> Option<String> {
const ATTEMPTS: u32 = 3;
for attempt in 1..=ATTEMPTS {
match navidrome_token(base_url, username, password).await {
match navidrome_token_with_registry(registry, base_url, username, password).await {
Ok(tok) => return Some(tok),
Err(_) if attempt < ATTEMPTS => {
tokio::time::sleep(Duration::from_millis(250 * attempt as u64)).await;
@@ -677,6 +679,7 @@ async fn navidrome_token_with_retry(
#[tauri::command]
pub async fn library_sync_bind_session(
runtime: State<'_, LibraryRuntime>,
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_id: String,
base_url: String,
username: String,
@@ -690,7 +693,12 @@ pub async fn library_sync_bind_session(
// keep a bearer cached from a prior bind rather than dropping to
// Subsonic-only — a transient miss must not strip an N1-capable server
// (R7-15 Q3). Non-Navidrome servers stay `None` and sync via Subsonic.
let navidrome_token_cached = match navidrome_token_with_retry(&base_url, &username, &password)
let navidrome_token_cached = match navidrome_token_with_retry(
Some(http_registry.as_ref()),
&base_url,
&username,
&password,
)
.await
{
Some(tok) => Some(tok),
@@ -709,7 +717,13 @@ pub async fn library_sync_bind_session(
// Run the probe + persist capability flags. Failure to probe is a
// bind-time error — caller should fix credentials / URL.
let subsonic = SubsonicClient::new(base_url, username, password);
let subsonic = subsonic_client_with_registry(
Some(http_registry.as_ref()),
&server_id,
base_url,
username,
password,
);
let navidrome_creds = navidrome_token_cached.map(|tok| NavidromeProbeCredentials {
server_url: subsonic_base_url_from(&runtime, &server_id),
bearer_token: tok,
@@ -719,6 +733,7 @@ pub async fn library_sync_bind_session(
&runtime.store,
&subsonic,
navidrome_creds.as_ref(),
Some(http_registry.as_ref()),
&server_id,
scope,
)
@@ -872,8 +887,12 @@ async fn library_sync_start_inner(
let job_id_for_task = job_id.clone();
let parallelism = ParallelismBudget::resolve(runtime.current_playback_hint());
let app_for_runner = app.clone();
let runner_handle: tokio::task::JoinHandle<Result<(), String>> = tokio::task::spawn(async move {
let subsonic = SubsonicClient::new(
let registry = app_for_runner.state::<Arc<ServerHttpRegistry>>();
let subsonic = subsonic_client_with_registry(
Some(registry.as_ref()),
&session_clone.server_id,
session_clone.base_url.clone(),
session_clone.username.clone(),
session_clone.password.clone(),
@@ -895,7 +914,8 @@ async fn library_sync_start_inner(
)
.with_cancellation(Arc::clone(&cancel_for_task))
.with_progress(Arc::clone(&progress))
.with_parallelism_budget(parallelism);
.with_parallelism_budget(parallelism)
.with_http_registry(Some(Arc::clone(&registry)));
if let Some(creds) = navidrome_creds.clone() {
runner = runner.with_navidrome_credentials(creds);
}
@@ -919,7 +939,8 @@ async fn library_sync_start_inner(
capability_flags,
)
.with_cancellation(Arc::clone(&cancel_for_task))
.with_progress(Arc::clone(&progress));
.with_progress(Arc::clone(&progress))
.with_http_registry(Some(Arc::clone(&registry)));
if tombstone_budget > 0 {
runner = runner.with_tombstone_budget(tombstone_budget);
}
@@ -1647,7 +1668,7 @@ mod tests {
})))
.mount(&server)
.await;
let tok = navidrome_token_with_retry(&server.uri(), "user", "pw").await;
let tok = navidrome_token_with_retry(None, &server.uri(), "user", "pw").await;
assert_eq!(tok.as_deref(), Some("nd-tok"));
}
@@ -1664,7 +1685,7 @@ mod tests {
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
let tok = navidrome_token_with_retry(&server.uri(), "user", "pw").await;
let tok = navidrome_token_with_retry(None, &server.uri(), "user", "pw").await;
assert!(tok.is_none());
}
}
@@ -91,6 +91,7 @@ pub async fn probe_and_persist(
store: &crate::store::LibraryStore,
subsonic: &psysonic_integration::subsonic::SubsonicClient,
navidrome: Option<&NavidromeProbeCredentials>,
http_registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: &str,
library_scope: &str,
) -> Result<CapabilityProbeResult, psysonic_integration::subsonic::SubsonicError> {
@@ -110,7 +111,7 @@ pub async fn probe_and_persist(
.map_err(psysonic_integration::subsonic::SubsonicError::Transport)?
.unwrap_or(0);
let mut result = CapabilityProbe::run(subsonic, navidrome).await?;
let mut result = CapabilityProbe::run(subsonic, navidrome, http_registry, Some(server_id)).await?;
// R7-15 Q3: a probe run without a Navidrome bearer can't test N1, so it
// must not drop a previously-learned NavidromeNativeBulk capability — the
@@ -173,6 +174,8 @@ impl CapabilityProbe {
pub async fn run(
subsonic: &SubsonicClient,
navidrome: Option<&NavidromeProbeCredentials>,
http_registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: Option<&str>,
) -> Result<CapabilityProbeResult, SubsonicError> {
let server_info = subsonic.server_info().await?;
@@ -207,7 +210,14 @@ impl CapabilityProbe {
}
if let Some(creds) = navidrome {
match native_bulk_available(&creds.server_url, &creds.bearer_token).await {
match native_bulk_available(
http_registry,
server_id,
&creds.server_url,
&creds.bearer_token,
)
.await
{
Ok(true) => flags.insert(CapabilityFlags::NAVIDROME_NATIVE_BULK),
Ok(false) => {}
Err(_) => {
@@ -345,7 +355,7 @@ mod tests {
let server = MockServer::start().await;
mount_subsonic_full_navidrome(&server).await;
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None)
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
.await
.unwrap();
assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
@@ -372,7 +382,7 @@ mod tests {
.mount(&server)
.await;
let err = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None)
let err = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
.await
.unwrap_err();
assert!(matches!(err, SubsonicError::Api { code: 40, .. }));
@@ -415,7 +425,7 @@ mod tests {
.mount(&server)
.await;
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None)
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
.await
.unwrap();
assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
@@ -440,7 +450,7 @@ mod tests {
server_url: server.uri(),
bearer_token: "nd-tok".into(),
};
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav))
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav), None, None)
.await
.unwrap();
assert!(result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
@@ -461,6 +471,7 @@ mod tests {
&store,
&test_subsonic_client(&server.uri()),
None,
None,
"s1",
"",
)
@@ -498,6 +509,7 @@ mod tests {
&store,
&test_subsonic_client(&server.uri()),
None,
None,
"s1",
"",
)
@@ -530,6 +542,7 @@ mod tests {
&store,
&test_subsonic_client(&server.uri()),
None,
None,
"s1",
"",
)
@@ -584,7 +597,7 @@ mod tests {
let store = LibraryStore::open_in_memory();
let result =
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, "s1", "")
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, None, "s1", "")
.await
.unwrap();
assert_eq!(result.server_track_count, Some(170_000));
@@ -617,6 +630,7 @@ mod tests {
&store,
&test_subsonic_client(&server.uri()),
None,
None,
"s1",
"",
)
@@ -647,7 +661,7 @@ mod tests {
mount_subsonic_full_navidrome(&server).await; // scanStatus has no count
let result =
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, "s1", "")
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, None, "s1", "")
.await
.unwrap();
assert_eq!(result.server_track_count, None);
@@ -672,7 +686,7 @@ mod tests {
server_url: server.uri(),
bearer_token: "nd-tok".into(),
};
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav))
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav), None, None)
.await
.unwrap();
assert!(!result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
@@ -20,6 +20,7 @@ use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use psysonic_core::server_http::ServerHttpRegistry;
use psysonic_integration::navidrome::queries::nd_list_songs_internal;
use psysonic_integration::subsonic::SubsonicClient;
use serde_json::Value;
@@ -71,6 +72,7 @@ pub struct DeltaSyncRunner<'a> {
store: &'a LibraryStore,
subsonic: &'a SubsonicClient,
navidrome: Option<NavidromeProbeCredentials>,
http_registry: Option<Arc<ServerHttpRegistry>>,
server_id: String,
library_scope: String,
capability_flags: CapabilityFlags,
@@ -95,6 +97,7 @@ impl<'a> DeltaSyncRunner<'a> {
store,
subsonic,
navidrome: None,
http_registry: None,
server_id: server_id.into(),
library_scope: library_scope.into(),
capability_flags,
@@ -111,6 +114,11 @@ impl<'a> DeltaSyncRunner<'a> {
self
}
pub fn with_http_registry(mut self, registry: Option<Arc<ServerHttpRegistry>>) -> Self {
self.http_registry = registry;
self
}
pub fn with_cancellation(mut self, flag: Arc<std::sync::atomic::AtomicBool>) -> Self {
self.cancel = Some(flag);
self
@@ -396,6 +404,8 @@ impl<'a> DeltaSyncRunner<'a> {
self,
|| {
nd_list_songs_internal(
self.http_registry.as_deref(),
Some(&self.server_id),
&creds.server_url,
&creds.bearer_token,
"updated_at",
@@ -12,6 +12,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use psysonic_core::server_http::ServerHttpRegistry;
use psysonic_integration::navidrome::queries::nd_list_songs_internal;
use psysonic_integration::subsonic::SubsonicClient;
use serde_json::Value;
@@ -109,6 +110,7 @@ pub struct InitialSyncRunner<'a> {
store: &'a LibraryStore,
subsonic: &'a SubsonicClient,
navidrome: Option<NavidromeProbeCredentials>,
http_registry: Option<Arc<ServerHttpRegistry>>,
server_id: String,
library_scope: String,
capability_flags: CapabilityFlags,
@@ -132,6 +134,7 @@ impl<'a> InitialSyncRunner<'a> {
store,
subsonic,
navidrome: None,
http_registry: None,
server_id: server_id.into(),
library_scope: library_scope.into(),
capability_flags,
@@ -154,6 +157,11 @@ impl<'a> InitialSyncRunner<'a> {
self
}
pub fn with_http_registry(mut self, registry: Option<Arc<ServerHttpRegistry>>) -> Self {
self.http_registry = registry;
self
}
pub fn with_cancellation(mut self, flag: Arc<AtomicBool>) -> Self {
self.cancel = Some(flag);
self
@@ -580,6 +588,8 @@ impl<'a> InitialSyncRunner<'a> {
let cancel = self.cancel.clone();
let sleep_enabled = self.sleep_enabled;
let creds = creds.clone();
let http_registry = self.http_registry.clone();
let server_id = self.server_id.clone();
let mut queue = LinearPrefetchQueue::new(&budget, batch_size, offset);
loop {
@@ -590,6 +600,8 @@ impl<'a> InitialSyncRunner<'a> {
queue.pump(|| self.check_cancellation(), |off| {
let creds = creds.clone();
let cancel = cancel.clone();
let http_registry = http_registry.clone();
let server_id = server_id.clone();
tokio::spawn(async move {
retry_fetch(
sleep_enabled,
@@ -597,6 +609,8 @@ impl<'a> InitialSyncRunner<'a> {
|| async {
let end = off.saturating_add(batch_size);
let response = nd_list_songs_internal(
http_registry.as_deref(),
Some(&server_id),
&creds.server_url,
&creds.bearer_token,
"id",
@@ -671,6 +685,8 @@ impl<'a> InitialSyncRunner<'a> {
self,
|| {
nd_list_songs_internal(
self.http_registry.as_deref(),
Some(&self.server_id),
&creds.server_url,
&creds.bearer_token,
"id",
@@ -11,6 +11,7 @@
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use psysonic_integration::subsonic::SubsonicClient;
use super::bandwidth::{ParallelismBudget, PlaybackHint};
@@ -45,6 +46,7 @@ pub struct BackgroundScheduler<'a> {
store: &'a LibraryStore,
subsonic: &'a SubsonicClient,
navidrome: Option<NavidromeProbeCredentials>,
http_registry: Option<Arc<ServerHttpRegistry>>,
server_id: String,
library_scope: String,
capability_flags: CapabilityFlags,
@@ -70,6 +72,7 @@ impl<'a> BackgroundScheduler<'a> {
store,
subsonic,
navidrome: None,
http_registry: None,
server_id: server_id.into(),
library_scope: library_scope.into(),
capability_flags,
@@ -87,6 +90,11 @@ impl<'a> BackgroundScheduler<'a> {
self
}
pub fn with_http_registry(mut self, registry: Option<Arc<ServerHttpRegistry>>) -> Self {
self.http_registry = registry;
self
}
pub fn with_playback_hint(mut self, hint: PlaybackHint) -> Self {
self.playback_hint = hint;
self
@@ -220,7 +228,8 @@ impl<'a> BackgroundScheduler<'a> {
&self.library_scope,
self.capability_flags,
)
.with_progress(Arc::clone(&self.progress));
.with_progress(Arc::clone(&self.progress))
.with_http_registry(self.http_registry.clone());
if let Some(creds) = &self.navidrome {
runner = runner.with_navidrome_credentials(creds.clone());
}
+14 -1
View File
@@ -2,6 +2,8 @@ use tauri::{Emitter, Manager};
use psysonic_core::user_agent::subsonic_wire_user_agent;
use crate::file_transfer::apply_server_http_get;
pub fn resolve_hot_cache_root(
custom_dir: Option<String>,
app: &tauri::AppHandle,
@@ -340,7 +342,18 @@ pub async fn download_zip(
.build()
.map_err(|e| e.to_string())?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
let http_registry = app
.try_state::<std::sync::Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| std::sync::Arc::clone(&*s));
let response = apply_server_http_get(
&client,
http_registry.as_deref(),
None,
&url,
)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
+12 -2
View File
@@ -1,8 +1,11 @@
use std::sync::Arc;
use psysonic_analysis::analysis_runtime::{enqueue_track_analysis, AnalysisBackfillPriority};
use psysonic_audio as audio;
use psysonic_core::user_agent::subsonic_wire_user_agent;
use tauri::Manager;
use crate::file_transfer::stream_to_file;
use crate::file_transfer::{apply_server_http_get, stream_to_file};
use super::downloads::{resolve_hot_cache_root, HotCacheDownloadResult};
use super::offline::enqueue_analysis_seed_from_file;
@@ -70,7 +73,14 @@ pub async fn download_track_hot_cache(
.build()
.map_err(|e| e.to_string())?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let response = apply_server_http_get(&client, http_registry.as_deref(), Some(&server_id), &url)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
+13 -2
View File
@@ -22,7 +22,7 @@ use psysonic_library::repos::TrackRow;
use psysonic_library::{repos::TrackRepository, LibraryRuntime};
use tauri::{AppHandle, Manager, State};
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
use crate::{offline_cancel_flags, DownloadSemaphore};
use super::offline::enqueue_analysis_seed_from_file;
@@ -359,7 +359,18 @@ pub async fn download_track_local(
}
let client = subsonic_http_client(std::time::Duration::from_secs(120))?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let response = apply_server_http_get(
&client,
http_registry.as_deref(),
Some(&server_index_key),
&url,
)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
+18 -7
View File
@@ -9,7 +9,7 @@ use psysonic_analysis::analysis_runtime::{
};
use crate::{offline_cancel_flags, DownloadSemaphore};
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
// ─── Offline Track Cache ──────────────────────────────────────────────────────
@@ -31,12 +31,15 @@ pub async fn enqueue_analysis_seed_from_file(
///
/// `cancel`, when supplied, aborts the in-flight stream with `Err("CANCELLED")`
/// (the `.part` file is cleaned up); `None` means the download is not cancellable.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn download_track_to_cache_dir(
cache_dir: &std::path::Path,
track_id: &str,
suffix: &str,
url: &str,
client: &reqwest::Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
cancel: Option<&AtomicBool>,
) -> Result<std::path::PathBuf, String> {
tokio::fs::create_dir_all(cache_dir)
@@ -48,7 +51,10 @@ pub(crate) async fn download_track_to_cache_dir(
return Ok(file_path);
}
let response = client.get(url).send().await.map_err(|e| e.to_string())?;
let response = apply_server_http_get(client, registry, server_ref, url)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
@@ -129,12 +135,17 @@ pub async fn download_track_offline(
}
let client = subsonic_http_client(std::time::Duration::from_secs(120))?;
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let final_path = download_track_to_cache_dir(
&cache_dir,
&track_id,
&suffix,
&url,
&client,
http_registry.as_deref(),
Some(&server_id),
cancel_flag.as_deref(),
)
.await?;
@@ -191,7 +202,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/stream/track-1", server.uri());
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None)
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, None)
.await
.unwrap();
assert!(path.exists());
@@ -211,7 +222,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/should-not-be-hit", server.uri());
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None)
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, None)
.await
.unwrap();
assert_eq!(path, pre_existing);
@@ -232,7 +243,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/stream/missing", server.uri());
let err = download_track_to_cache_dir(&cache_dir, "missing", "flac", &url, &client, None)
let err = download_track_to_cache_dir(&cache_dir, "missing", "flac", &url, &client, None, None, None)
.await
.unwrap_err();
assert!(err.contains("HTTP 404"), "got {err}");
@@ -256,7 +267,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/track", server.uri());
download_track_to_cache_dir(&cache_dir, "t", "mp3", &url, &client, None)
download_track_to_cache_dir(&cache_dir, "t", "mp3", &url, &client, None, None, None)
.await
.unwrap();
assert!(cache_dir.join("t.mp3").exists());
@@ -278,7 +289,7 @@ mod tests {
let cancel = AtomicBool::new(true);
let err =
download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, Some(&cancel))
download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, Some(&cancel))
.await
.unwrap_err();
assert_eq!(err, "CANCELLED");
@@ -13,6 +13,20 @@ pub fn subsonic_http_client(timeout: Duration) -> Result<reqwest::Client, String
.map_err(|e| e.to_string())
}
pub fn apply_server_http_get(
client: &reqwest::Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
url: &str,
) -> reqwest::RequestBuilder {
psysonic_core::server_http::apply_optional_registry_headers(
registry,
server_ref,
url,
client.get(url),
)
}
/// Streams an HTTP response body to `dest_path` in chunks. Never buffers the full
/// file in memory — keeps RAM flat regardless of file size.
///
@@ -1,11 +1,11 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::Emitter;
use tauri::{Emitter, Manager};
use crate::sync_cancel_flags;
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
use super::device::{
build_track_path, get_removable_drives, is_path_on_mounted_volume, SyncBatchResult,
TrackSyncInfo,
@@ -107,6 +107,7 @@ pub struct SyncDeltaResult {
pub async fn fetch_subsonic_songs(
client: &reqwest::Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
auth: &SubsonicAuthPayload,
endpoint: &str,
id: &str,
@@ -121,7 +122,11 @@ pub async fn fetch_subsonic_songs(
("f", auth.f.as_str()),
("id", id),
];
let res = client.get(&url).query(&query).send().await.map_err(|e| e.to_string())?;
let res = apply_server_http_get(client, registry, None, &url)
.query(&query)
.send()
.await
.map_err(|e| e.to_string())?;
let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
parse_subsonic_songs(&json, endpoint)
}
@@ -239,8 +244,12 @@ pub async fn calculate_sync_payload(
deletion_ids: Vec<String>,
auth: SubsonicAuthPayload,
target_dir: String,
app: tauri::AppHandle,
) -> Result<SyncDeltaResult, String> {
let client = subsonic_http_client(std::time::Duration::from_secs(30))?;
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let mut add_bytes = 0;
let mut add_count = 0;
@@ -264,17 +273,19 @@ pub async fn calculate_sync_payload(
v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
};
let cli = client.clone();
let reg_for_task = http_registry.clone();
let source_snapshot = source.clone();
let handle = tokio::spawn(async move {
let registry = reg_for_task.as_deref();
let mut res_tracks = Vec::new();
if source.source_type == "album" {
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
} else if source.source_type == "playlist" {
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
} else if source.source_type == "artist" {
let url = format!("{}/getArtist.view", auth_clone.base_url);
let query = vec![("u", auth_clone.u.as_str()), ("t", auth_clone.t.as_str()), ("s", auth_clone.s.as_str()), ("v", auth_clone.v.as_str()), ("c", auth_clone.c.as_str()), ("f", auth_clone.f.as_str()), ("id", &source.id)];
if let Ok(re) = cli.get(&url).query(&query).send().await {
if let Ok(re) = apply_server_http_get(&cli, registry, None, &url).query(&query).send().await {
if let Ok(js) = re.json::<serde_json::Value>().await {
if let Some(root) = js.get("subsonic-response").and_then(|r| r.get("artist")).and_then(|a| a.get("album")) {
let arr = root.as_array().cloned().unwrap_or_else(|| {
@@ -282,7 +293,7 @@ pub async fn calculate_sync_payload(
});
for al in arr {
if let Some(aid) = al.get("id").and_then(|i| i.as_str()) {
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", aid).await {
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", aid).await {
res_tracks.extend(ts);
}
}
@@ -303,12 +314,14 @@ pub async fn calculate_sync_payload(
v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
};
let cli = client.clone();
let reg_for_task = http_registry.clone();
del_handles.push(tokio::spawn(async move {
let registry = reg_for_task.as_deref();
let mut res_tracks = Vec::new();
if source.source_type == "album" {
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
} else if source.source_type == "playlist" {
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
}
res_tracks
}));
@@ -437,6 +450,9 @@ pub async fn sync_batch_to_device(
// Shared reqwest client — reused across all downloads.
let client = subsonic_http_client(Duration::from_secs(300))?;
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
// Concurrency limiter: max 2 parallel USB writes.
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(2));
@@ -455,6 +471,7 @@ pub async fn sync_batch_to_device(
for track in tracks {
let sem = semaphore.clone();
let cli = client.clone();
let reg_for_task = http_registry.clone();
let app2 = app.clone();
let job = job_id.clone();
let dest = dest_dir.clone();
@@ -466,6 +483,7 @@ pub async fn sync_batch_to_device(
handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.expect("semaphore closed");
let registry = reg_for_task.as_deref();
// Bail out if cancelled while waiting in the semaphore queue.
if cancel.load(Ordering::Relaxed) { return; }
@@ -492,7 +510,7 @@ pub async fn sync_batch_to_device(
}
}
let response = match cli.get(&track.url).send().await {
let response = match apply_server_http_get(&cli, registry, None, &track.url).send().await {
Ok(r) if r.status().is_success() => r,
Ok(r) => {
f.fetch_add(1, Ordering::Relaxed);
@@ -819,7 +837,7 @@ mod tests {
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
.unwrap();
let auth = fake_auth(server.uri());
let songs = fetch_subsonic_songs(&client, &auth, "getAlbum.view", "album-42")
let songs = fetch_subsonic_songs(&client, None, &auth, "getAlbum.view", "album-42")
.await
.unwrap();
assert_eq!(songs.len(), 2);
@@ -838,7 +856,7 @@ mod tests {
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
.unwrap();
let auth = fake_auth(server.uri());
let result = fetch_subsonic_songs(&client, &auth, "getAlbum.view", "missing").await;
let result = fetch_subsonic_songs(&client, None, &auth, "getAlbum.view", "missing").await;
// 404 with HTML/empty body fails the JSON parse, surfacing as an Err — we
// just assert the function does not panic and propagates an error string.
assert!(result.is_err());
@@ -989,7 +1007,7 @@ mod tests {
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
.unwrap();
let auth = fake_auth(server.uri());
let songs = fetch_subsonic_songs(&client, &auth, "getPlaylist.view", "p1")
let songs = fetch_subsonic_songs(&client, None, &auth, "getPlaylist.view", "p1")
.await
.unwrap();
assert_eq!(songs.len(), 1, "single-object response normalised to 1-element vec");
@@ -1,6 +1,6 @@
use tauri::Emitter;
use tauri::{Emitter, Manager};
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
// ─── Device Sync ─────────────────────────────────────────────────────────────
@@ -333,6 +333,8 @@ pub(crate) async fn sync_download_one_track(
suffix: &str,
url: &str,
client: &reqwest::Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
) -> Result<bool, String> {
if dest_path.exists() {
return Ok(false);
@@ -342,7 +344,10 @@ pub(crate) async fn sync_download_one_track(
.await
.map_err(|e| e.to_string())?;
}
let response = client.get(url).send().await.map_err(|e| e.to_string())?;
let response = apply_server_http_get(client, registry, server_ref, url)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
@@ -366,7 +371,19 @@ pub async fn sync_track_to_device(
let path_str = dest_path.to_string_lossy().to_string();
let client = subsonic_http_client(std::time::Duration::from_secs(300))?;
match sync_download_one_track(&dest_path, &track.suffix, &track.url, &client).await {
let http_registry = app
.try_state::<std::sync::Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| std::sync::Arc::clone(&*s));
match sync_download_one_track(
&dest_path,
&track.suffix,
&track.url,
&client,
http_registry.as_deref(),
None,
)
.await
{
Ok(false) => {
let _ = app.emit("device:sync:progress", serde_json::json!({
"jobId": job_id, "trackId": track.id, "status": "skipped", "path": path_str,
@@ -656,7 +673,7 @@ mod tests {
let dest = dir.path().join("Album").join("01 - track.flac");
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/track", server.uri());
let downloaded = sync_download_one_track(&dest, "flac", &url, &client)
let downloaded = sync_download_one_track(&dest, "flac", &url, &client, None, None)
.await
.unwrap();
assert!(downloaded, "fresh download must report Ok(true)");
@@ -672,7 +689,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/should-not-be-hit", server.uri());
let downloaded = sync_download_one_track(&dest, "mp3", &url, &client)
let downloaded = sync_download_one_track(&dest, "mp3", &url, &client, None, None)
.await
.unwrap();
assert!(!downloaded, "pre-existing file must be reported as skipped");
@@ -692,7 +709,7 @@ mod tests {
let dest = dir.path().join("track.opus");
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/missing", server.uri());
let err = sync_download_one_track(&dest, "opus", &url, &client)
let err = sync_download_one_track(&dest, "opus", &url, &client, None, None)
.await
.unwrap_err();
assert!(err.contains("HTTP 403"));
@@ -714,7 +731,7 @@ mod tests {
assert!(!dest.parent().unwrap().exists());
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/t", server.uri());
sync_download_one_track(&dest, "mp3", &url, &client)
sync_download_one_track(&dest, "mp3", &url, &client, None, None)
.await
.unwrap();
assert!(dest.exists());
+18 -3
View File
@@ -63,13 +63,15 @@ pub fn build_fanart_url(mbid: &str, api_key: &str, client_key: Option<&str>) ->
/// `Ok(None)` when the artist carries no MBID tag.
pub async fn fetch_artist_tag_mbid(
client: &Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
rest_base: &str,
username: &str,
password: &str,
artist_id: &str,
) -> Result<Option<String>, String> {
let url = build_artist_info2_url(rest_base, username, password, artist_id);
let body = http_get_text(client, &url).await?;
let body = http_get_text_scoped(client, registry, server_ref, &url).await?;
let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
let mbid = v
.get("subsonic-response")
@@ -218,8 +220,21 @@ fn classify_mb_releases(v: &serde_json::Value) -> MbResolution {
}
/// Single GET → response text; any non-2xx is an error.
async fn http_get_text(client: &Client, url: &str) -> Result<String, String> {
let resp = client.get(url).send().await.map_err(|e| e.to_string())?;
async fn http_get_text_scoped(
client: &Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
url: &str,
) -> Result<String, String> {
let resp = psysonic_core::server_http::apply_optional_registry_headers(
registry,
server_ref,
url,
client.get(url),
)
.send()
.await
.map_err(|e| e.to_string())?;
let status = resp.status();
if !status.is_success() {
return Err(format!("HTTP {status}"));
+7 -1
View File
@@ -242,12 +242,18 @@ pub(super) async fn try_external_fanart(
let _permit = fanart_sem.clone().acquire_owned().await.ok()?;
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
// §23: resolve the tag MBID Rust-side via getArtistInfo2 — unless the cache
// already carries one (skip the Navidrome round-trip).
let (mbid, mbid_source) = match cached.as_ref().and_then(|r| r.mbid.clone()) {
Some(m) => (m, cached.as_ref().and_then(|r| r.mbid_source.clone())),
None => match external::fetch_artist_tag_mbid(
client,
http_registry.as_deref(),
Some(server_id),
&args.rest_base_url,
&args.username,
&args.password,
@@ -344,7 +350,7 @@ pub(super) async fn try_external_fanart(
}
};
let bytes = match fetch::fetch_cover_bytes(client, &img_url).await {
let bytes = match fetch::fetch_cover_bytes(client, &img_url, None, None).await {
Ok(b) => b,
Err(e) => {
eprintln!("[fanart] download failed: {e}"); // transient — don't cache
+23 -4
View File
@@ -1,4 +1,5 @@
use reqwest::Client;
use psysonic_core::server_http::ServerHttpRegistry;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use url::Url;
@@ -83,8 +84,21 @@ enum FetchAttempt {
Permanent(String),
}
async fn fetch_cover_once(client: &Client, url: &str) -> FetchAttempt {
let resp = match client.get(url).send().await {
async fn fetch_cover_once(
client: &Client,
url: &str,
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
) -> FetchAttempt {
let mut req = client.get(url);
if let Some(reg) = registry {
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
req = reg.apply_for_http_url(sid, url, req);
} else if let Some(ctx) = reg.get_for_server_url(url) {
req = psysonic_core::server_http::apply_server_headers_for_http_url(req, &ctx, url);
}
}
let resp = match req.send().await {
Ok(r) => r,
// Connection reset / timeout / DNS — transient under server load.
Err(e) => return FetchAttempt::Transient(e.to_string()),
@@ -104,10 +118,15 @@ async fn fetch_cover_once(client: &Client, url: &str) -> FetchAttempt {
}
}
pub async fn fetch_cover_bytes(client: &Client, url: &str) -> Result<Vec<u8>, String> {
pub async fn fetch_cover_bytes(
client: &Client,
url: &str,
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
) -> Result<Vec<u8>, String> {
let mut last_err = String::from("cover fetch failed");
for attempt in 0..COVER_FETCH_ATTEMPTS {
match fetch_cover_once(client, url).await {
match fetch_cover_once(client, url, registry, server_ref).await {
FetchAttempt::Ok(bytes) => return Ok(bytes),
FetchAttempt::Permanent(e) => return Err(e),
FetchAttempt::Transient(e) => {
+12 -2
View File
@@ -354,7 +354,10 @@ impl CoverCacheState {
let source = if let Some(img) = load_image_from_disk(&dir) {
CoverSource::Image(img)
} else {
match download_cover_payload(&dir, &client, &http_sem, args).await {
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
match download_cover_payload(&dir, &client, &http_sem, args, http_registry).await {
Ok(bytes) => CoverSource::Bytes(bytes),
Err(err) => {
log_cover_fetch_failure(app, args, &err);
@@ -532,6 +535,7 @@ async fn download_cover_payload(
client: &Client,
http_sem: &Semaphore,
args: &CoverCacheEnsureArgs,
registry: Option<Arc<psysonic_core::server_http::ServerHttpRegistry>>,
) -> Result<Vec<u8>, String> {
let _permit = http_sem
.acquire()
@@ -549,7 +553,13 @@ async fn download_cover_payload(
&args.cover_art_id,
fetch_size,
);
fetch::fetch_cover_bytes(client, &url).await
fetch::fetch_cover_bytes(
client,
&url,
registry.as_deref(),
Some(args.server_index_key.as_str()),
)
.await
}
fn spawn_derive_remaining_tiers(
+10 -2
View File
@@ -106,6 +106,7 @@ pub fn run() {
let builder = tauri::Builder::default()
.manage(audio_engine)
.manage(Arc::new(psysonic_core::server_http::ServerHttpRegistry::new()))
.manage(ShortcutMap::default())
.manage(discord::DiscordState::new())
.manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore)
@@ -238,7 +239,10 @@ pub fn run() {
let flags = psysonic_library::sync::capability::CapabilityFlags::new(
flags_bits,
);
let subsonic = psysonic_integration::subsonic::SubsonicClient::new(
let registry = app_for_sched.state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>();
let subsonic = psysonic_integration::subsonic::subsonic_client_with_registry(
Some(registry.as_ref()),
&session.server_id,
session.base_url.clone(),
session.username.clone(),
session.password.clone(),
@@ -251,7 +255,8 @@ pub fn run() {
scope.clone(),
flags,
)
.with_playback_hint(hint);
.with_playback_hint(hint)
.with_http_registry(Some(Arc::clone(&registry)));
if let Some(tok) = session.navidrome_token.clone() {
sched = sched.with_navidrome_credentials(
psysonic_library::sync::capability::NavidromeProbeCredentials {
@@ -682,6 +687,9 @@ pub fn run() {
migration_inspect,
migration_run,
resolve_host_addresses,
server_http_context_sync,
server_http_context_sync_all,
server_http_context_clear,
psysonic_syncfs::sync::batch::calculate_sync_payload,
exit_app,
cli_publish_player_snapshot,
+4 -1
View File
@@ -36,7 +36,10 @@ pub(crate) use integration::{
unregister_global_shortcut,
};
pub(crate) use migration::{migration_inspect, migration_run};
pub(crate) use network::resolve_host_addresses;
pub(crate) use network::{
resolve_host_addresses, server_http_context_clear, server_http_context_sync,
server_http_context_sync_all,
};
// Discord, Navidrome admin, last.fm + radio-browser + CORS proxy, bandsintown,
// and analysis admin commands now live in their domain crates. invoke_handler!
@@ -2,6 +2,11 @@
//! dual-server-address add/edit form (UI hint only — not for connect).
use std::collections::HashSet;
use std::sync::Arc;
use psysonic_core::server_http::{ServerHttpContextSyncWire, ServerHttpRegistry};
use tauri::State;
use tokio::net::lookup_host;
/// Resolve a hostname to a deduped list of IP address strings (IPv4 + IPv6).
@@ -58,6 +63,34 @@ pub(crate) async fn resolve_host_addresses(hostname: String) -> Result<Vec<Strin
Ok(result)
}
#[tauri::command]
pub(crate) fn server_http_context_sync(
registry: State<'_, Arc<ServerHttpRegistry>>,
wire: ServerHttpContextSyncWire,
) -> Result<(), String> {
registry.sync(wire);
Ok(())
}
#[tauri::command]
pub(crate) fn server_http_context_sync_all(
registry: State<'_, Arc<ServerHttpRegistry>>,
entries: Vec<ServerHttpContextSyncWire>,
) -> Result<(), String> {
registry.sync_all(entries);
Ok(())
}
#[tauri::command]
pub(crate) fn server_http_context_clear(
registry: State<'_, Arc<ServerHttpRegistry>>,
server_id: String,
app_server_id: String,
) -> Result<(), String> {
registry.remove(&server_id, &app_server_id);
Ok(())
}
/// Strip a `:port` suffix. Handles `host:port` and `[ipv6]:port`; leaves
/// bracketed IPv6 with no port (`[::1]`) and bare hosts alone.
fn strip_port(input: &str) -> String {
+37 -1
View File
@@ -26,7 +26,7 @@ vi.mock('../utils/network/subsonicNetworkGuard', () => ({
}));
import axios from 'axios';
import { pingWithCredentials, ping } from './subsonic';
import { pingWithCredentials, pingWithCredentialsForProfile, ping } from './subsonic';
import { getAlbumInfo2 } from './subsonicAlbumInfo';
import { getStarred } from './subsonicStarRating';
import { search } from './subsonicSearch';
@@ -440,3 +440,39 @@ describe('pingWithCredentials — explicit URL/credentials path', () => {
expect(r.openSubsonic).toBe(false);
});
});
describe('pingWithCredentialsForProfile — custom gate headers', () => {
it('sends resolved custom headers on the ping request', async () => {
vi.mocked(axios.get).mockResolvedValue(okResponse({ type: 'navidrome' }));
await pingWithCredentialsForProfile(
{
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10:4533',
username: 'u',
password: 'p',
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
customHeadersApplyTo: 'public',
},
'https://music.example.com',
);
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
expect(config.headers?.['CF-Access-Client-Secret']).toBe('gate-secret');
});
it('omits gate headers when probing the LAN endpoint with applyTo=public', async () => {
vi.mocked(axios.get).mockResolvedValue(okResponse({}));
await pingWithCredentialsForProfile(
{
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10:4533',
username: 'u',
password: 'p',
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
customHeadersApplyTo: 'public',
},
'http://192.168.0.10:4533',
);
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
expect(config.headers?.['CF-Access-Client-Secret']).toBeUndefined();
});
});
+15
View File
@@ -31,11 +31,26 @@ describe('scheduleInstantMixProbeForServer (idempotency)', () => {
fetchMock.mockReset();
fetchMock.mockResolvedValue(['sonicSimilarity']);
reset();
useAuthStore.setState({
servers: [{
id: SID,
name: 'Probe',
url: 'https://music.example.com',
username: 'u',
password: 'p',
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
customHeadersApplyTo: 'public',
}],
} as never);
});
it('probes once, caches the result, then skips on the next poll', async () => {
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[3]).toMatchObject({
url: 'https://music.example.com',
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
});
await flush();
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present');
+56 -2
View File
@@ -1,6 +1,9 @@
import axios from 'axios';
import md5 from 'md5';
import { useAuthStore } from '../store/authStore';
import type { ServerProfile } from '../store/authStoreTypes';
import { headersForServerRequest } from '../utils/server/serverHttpHeaders';
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
import {
type InstantMixProbeResult,
type SubsonicServerIdentity,
@@ -19,6 +22,7 @@ import {
api,
apiWithCredentials,
secureRandomSalt,
type ServerHttpHeaderProfile,
} from './subsonicClient';
import type { PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
@@ -61,6 +65,47 @@ export async function pingWithCredentials(
}
}
/** Profile-aware ping for connect probe — attaches custom headers per endpoint. */
export async function pingWithCredentialsForProfile(
profile: Pick<
ServerProfile,
'url' | 'alternateUrl' | 'username' | 'password' | 'customHeaders' | 'customHeadersApplyTo'
>,
endpointBaseUrl: string,
): Promise<PingWithCredentialsResult> {
try {
const base = endpointBaseUrl.startsWith('http')
? endpointBaseUrl.replace(/\/$/, '')
: `http://${endpointBaseUrl.replace(/\/$/, '')}`;
const salt = secureRandomSalt();
const token = md5(profile.password + salt);
const resp = await axios.get(`${base}/rest/ping.view`, {
params: {
u: profile.username,
t: token,
s: salt,
v: '1.16.1',
c: SUBSONIC_CLIENT,
f: 'json',
},
headers: headersForServerRequest(profile, endpointBaseUrl),
paramsSerializer: { indexes: null },
timeout: 15000,
});
const data = resp.data?.['subsonic-response'];
const ok = data?.status === 'ok';
return {
ok,
type: typeof data?.type === 'string' ? data.type : undefined,
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
openSubsonic: data?.openSubsonic === true,
};
} catch (err) {
console.warn('[psysonic] pingWithCredentialsForProfile failed:', endpointBaseUrl, err);
return { ok: false };
}
}
const INSTANT_MIX_PROBE_RANDOM_SIZE = 8;
const INSTANT_MIX_PROBE_SIMILAR_COUNT = 12;
const INSTANT_MIX_PROBE_MAX_TRACKS = 4;
@@ -74,6 +119,7 @@ export async function probeInstantMixWithCredentials(
serverUrl: string,
username: string,
password: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<InstantMixProbeResult> {
try {
const data = await apiWithCredentials<{ randomSongs: { song: SubsonicSong | SubsonicSong[] } }>(
@@ -83,6 +129,7 @@ export async function probeInstantMixWithCredentials(
'getRandomSongs.view',
{ size: INSTANT_MIX_PROBE_RANDOM_SIZE, _t: Date.now() },
12000,
headerProfile,
);
const raw = data.randomSongs?.song;
const songs: SubsonicSong[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
@@ -98,6 +145,7 @@ export async function probeInstantMixWithCredentials(
'getSimilarSongs.view',
{ id: song.id, count: INSTANT_MIX_PROBE_SIMILAR_COUNT },
12000,
headerProfile,
);
const sRaw = simData.similarSongs?.song;
const list: SubsonicSong[] = !sRaw ? [] : Array.isArray(sRaw) ? sRaw : [sRaw];
@@ -138,6 +186,7 @@ export function scheduleInstantMixProbeForServer(
const ctx = buildCapabilityContext(identity);
const probeIds = neededProbeIds(SERVER_CAPABILITY_CATALOG, ctx);
const store = useAuthStore.getState();
const headerProfile = findServerByIdOrIndexKey(serverId);
if (probeIds.has(PROBE_OPENSUBSONIC_EXTENSIONS)) {
// One `getOpenSubsonicExtensions` fetch answers every extension-gated feature.
@@ -153,7 +202,12 @@ export function scheduleInstantMixProbeForServer(
const audiomuseStale = audiomuseEligible && (cached === undefined || cached === 'error');
if (force || listMissing || audiomuseStale) {
if (audiomuseEligible) store.setAudiomusePluginProbe(serverId, 'probing');
void fetchOpenSubsonicExtensionsWithCredentials(serverUrl, username, password).then(extensions => {
void fetchOpenSubsonicExtensionsWithCredentials(
serverUrl,
username,
password,
headerProfile,
).then(extensions => {
const st = useAuthStore.getState();
if (extensions === null) {
if (audiomuseEligible) st.setAudiomusePluginProbe(serverId, 'error');
@@ -170,7 +224,7 @@ export function scheduleInstantMixProbeForServer(
if (probeIds.has(PROBE_LEGACY_INSTANT_MIX)) {
const cached = store.instantMixProbeByServer[serverId];
if (force || cached === undefined || cached === 'error') {
void probeInstantMixWithCredentials(serverUrl, username, password).then(result =>
void probeInstantMixWithCredentials(serverUrl, username, password, headerProfile).then(result =>
useAuthStore.getState().setInstantMixProbe(serverId, result),
);
}
+16
View File
@@ -4,10 +4,17 @@ import { version } from '../../package.json';
import { useAuthStore } from '../store/authStore';
import type { ServerProfile } from '../store/authStoreTypes';
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
import { headersForServerRequest } from '../utils/server/serverHttpHeaders';
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup';
export const SUBSONIC_CLIENT = `psysonic/${version}`;
/** Subset of `ServerProfile` needed to attach gate headers on credential-based REST calls. */
export type ServerHttpHeaderProfile = Pick<
ServerProfile,
'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
>;
export function secureRandomSalt(): string {
const buf = new Uint8Array(8);
crypto.getRandomValues(buf);
@@ -32,10 +39,13 @@ export async function apiWithCredentials<T>(
endpoint: string,
extra: Record<string, unknown> = {},
timeout = 15000,
headerProfile?: ServerHttpHeaderProfile,
): Promise<T> {
const params = { ...getAuthParams(username, password), ...extra };
const headers = headerProfile ? headersForServerRequest(headerProfile, serverUrl) : {};
const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, {
params,
headers,
paramsSerializer: { indexes: null },
timeout,
});
@@ -78,6 +88,7 @@ export async function apiForServer<T>(
endpoint,
extra,
timeout,
server,
);
}
@@ -88,8 +99,13 @@ export async function api<T>(
signal?: AbortSignal,
): Promise<T> {
const { baseUrl, params } = getClient();
const server = useAuthStore.getState().getActiveServer();
const connectBase = useAuthStore.getState().getBaseUrl();
const headers =
server && connectBase ? headersForServerRequest(server, connectBase) : {};
const resp = await axios.get(`${baseUrl}/${endpoint}`, {
params: { ...params, ...extra },
headers,
paramsSerializer: { indexes: null },
timeout,
signal,
+10 -1
View File
@@ -1,4 +1,4 @@
import { apiWithCredentials } from './subsonicClient';
import { apiWithCredentials, type ServerHttpHeaderProfile } from './subsonicClient';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
export async function getSongWithCredentials(
@@ -6,6 +6,7 @@ export async function getSongWithCredentials(
username: string,
password: string,
id: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<SubsonicSong | null> {
try {
const data = await apiWithCredentials<{ song: SubsonicSong }>(
@@ -14,6 +15,8 @@ export async function getSongWithCredentials(
password,
'getSong.view',
{ id },
15000,
headerProfile,
);
return data.song ?? null;
} catch {
@@ -26,6 +29,7 @@ export async function getAlbumWithCredentials(
username: string,
password: string,
id: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
const data = await apiWithCredentials<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(
serverUrl,
@@ -33,6 +37,8 @@ export async function getAlbumWithCredentials(
password,
'getAlbum.view',
{ id },
15000,
headerProfile,
);
const { song, ...album } = data.album;
return { album, songs: song ?? [] };
@@ -43,6 +49,7 @@ export async function getArtistWithCredentials(
username: string,
password: string,
id: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
const data = await apiWithCredentials<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>(
serverUrl,
@@ -50,6 +57,8 @@ export async function getArtistWithCredentials(
password,
'getArtist.view',
{ id },
15000,
headerProfile,
);
const { album, ...artist } = data.artist;
return { artist, albums: album ?? [] };
+11
View File
@@ -69,4 +69,15 @@ describe('fetchOpenSubsonicExtensionsWithCredentials', () => {
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
).resolves.toBeNull();
});
it('sends custom gate headers when a header profile is supplied', async () => {
vi.mocked(axios.get).mockResolvedValue(okExtensions([]));
await fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p', {
url: 'https://music.test',
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
customHeadersApplyTo: 'public',
});
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
expect(config.headers?.['CF-Access-Client-Secret']).toBe('gate-secret');
});
});
+3 -1
View File
@@ -1,4 +1,4 @@
import { apiWithCredentials } from './subsonicClient';
import { apiWithCredentials, type ServerHttpHeaderProfile } from './subsonicClient';
export interface OpenSubsonicExtension {
name: string;
@@ -30,6 +30,7 @@ export async function fetchOpenSubsonicExtensionsWithCredentials(
serverUrl: string,
username: string,
password: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<string[] | null> {
try {
const data = await apiWithCredentials<{ openSubsonicExtensions?: unknown }>(
@@ -39,6 +40,7 @@ export async function fetchOpenSubsonicExtensionsWithCredentials(
'getOpenSubsonicExtensions.view',
{},
12000,
headerProfile,
);
return parseOpenSubsonicExtensions(data.openSubsonicExtensions).map(ext => ext.name);
} catch {
+6
View File
@@ -16,6 +16,12 @@ vi.mock('@/api/subsonic', () => ({
serverVersion: '0.55.0',
openSubsonic: true,
})),
pingWithCredentialsForProfile: vi.fn(async () => ({
ok: true,
type: 'navidrome',
serverVersion: '0.55.0',
openSubsonic: true,
})),
scheduleInstantMixProbeForServer: vi.fn(),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
@@ -143,3 +143,33 @@ describe('AddServerForm — dual-address behaviour', () => {
expect(arg).not.toHaveProperty('shareUsesLocalUrl');
});
});
describe('AddServerForm — custom HTTP headers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('includes configured custom headers on save', async () => {
const onSave = vi.fn();
renderWithProviders(<AddServerForm onSave={onSave} onCancel={vi.fn()} />);
const user = userEvent.setup();
const inputs = screen.getAllByRole('textbox');
await user.type(inputs[1]!, 'https://music.example.com');
await user.type(inputs[3]!, 'tester');
await user.type(screen.getByPlaceholderText('••••••••'), 'pw');
await user.click(screen.getByRole('button', { name: /custom http headers/i }));
const headerNameInputs = screen.getAllByPlaceholderText(/header name/i);
const headerValueInputs = screen.getAllByPlaceholderText(/header value/i);
await user.type(headerNameInputs[0]!, 'CF-Access-Client-Secret');
await user.type(headerValueInputs[0]!, 'gate-secret');
await user.click(screen.getByRole('button', { name: 'Add' }));
expect(onSave).toHaveBeenCalledTimes(1);
const arg = onSave.mock.calls[0]![0];
expect(arg.customHeaders).toEqual([{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }]);
expect(arg.customHeadersApplyTo).toBe('public');
});
});
+144 -1
View File
@@ -1,7 +1,11 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ServerProfile } from '../../store/authStoreTypes';
import type { CustomHeaderEntry, CustomHeadersApplyTo, ServerProfile } from '../../store/authStoreTypes';
import { showToast } from '../../utils/ui/toast';
import {
DEFAULT_CUSTOM_HEADERS_APPLY_TO,
validateCustomHeaders,
} from '../../utils/server/serverHttpHeaders';
import {
decodeServerMagicString,
encodeServerMagicString,
@@ -19,6 +23,9 @@ type FormState = {
shareUsesLocalUrl: boolean;
username: string;
password: string;
customHeaders: CustomHeaderEntry[];
customHeadersApplyTo: CustomHeadersApplyTo;
customHeadersOpen: boolean;
};
/** Hostname for the DNS-based form hint, or null when the input is a literal IP. */
@@ -62,6 +69,12 @@ export function AddServerForm({
shareUsesLocalUrl: editingServer.shareUsesLocalUrl ?? false,
username: editingServer.username,
password: editingServer.password,
customHeaders: editingServer.customHeaders?.length
? editingServer.customHeaders.map(h => ({ ...h }))
: [{ name: '', value: '' }],
customHeadersApplyTo:
editingServer.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO,
customHeadersOpen: Boolean(editingServer.customHeaders?.length),
}
: {
name: '',
@@ -70,6 +83,9 @@ export function AddServerForm({
shareUsesLocalUrl: false,
username: '',
password: '',
customHeaders: [{ name: '', value: '' }],
customHeadersApplyTo: DEFAULT_CUSTOM_HEADERS_APPLY_TO,
customHeadersOpen: false,
},
);
const [magicString, setMagicString] = useState('');
@@ -175,6 +191,20 @@ export function AddServerForm({
return true;
};
const customHeadersPayload = (): Pick<
ServerProfile,
'customHeaders' | 'customHeadersApplyTo'
> => {
const rows = form.customHeaders
.map(h => ({ name: h.name.trim(), value: h.value }))
.filter(h => h.name || h.value);
if (!rows.length) return {};
return {
customHeaders: rows,
customHeadersApplyTo: form.customHeadersApplyTo,
};
};
const submit = async () => {
const ms = magicString.trim();
if (ms) {
@@ -193,6 +223,7 @@ export function AddServerForm({
url: decoded.url,
username: decoded.username,
password: decoded.password,
...customHeadersPayload(),
...(altDecoded
? {
alternateUrl: altDecoded,
@@ -205,6 +236,15 @@ export function AddServerForm({
if (!form.url.trim()) return;
if (!validateAddresses()) return;
const headerValidation = validateCustomHeaders(
form.customHeaders.filter(h => h.name.trim() || h.value),
);
if (!headerValidation.ok) {
const first = headerValidation.fieldErrors[0];
showToast(t(first.messageKey, { defaultValue: first.messageKey }), 5000, 'error');
return;
}
const altTrimmed = form.alternateUrl.trim();
// If the user clears the second address, strip the share-flag as well so
// we don't leave a dangling preference (spec §5.3 last row).
@@ -213,6 +253,7 @@ export function AddServerForm({
url: form.url.trim(),
username: form.username.trim(),
password: form.password,
...customHeadersPayload(),
...(altTrimmed
? {
alternateUrl: altTrimmed,
@@ -338,6 +379,108 @@ export function AddServerForm({
)}
</div>
</div>
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 13, padding: '4px 0' }}
onClick={() => setForm(f => ({ ...f, customHeadersOpen: !f.customHeadersOpen }))}
>
{form.customHeadersOpen ? '▾' : '▸'} {t('settings.customHeadersTitle')}
</button>
{form.customHeadersOpen && (
<div style={{ marginTop: 8 }}>
<p style={{ fontSize: 11, opacity: 0.75, margin: '0 0 8px' }}>
{t('settings.customHeadersHelp')}
</p>
{form.customHeaders.map((row, index) => (
<div key={index} className="form-row" style={{ marginBottom: 6, gap: 8 }}>
<input
className="input"
type="text"
value={row.name}
onChange={e => {
const name = e.target.value;
setForm(f => {
const customHeaders = f.customHeaders.map((h, i) =>
i === index ? { ...h, name } : h,
);
return { ...f, customHeaders };
});
}}
placeholder={t('settings.customHeadersNamePlaceholder')}
autoComplete="off"
/>
<input
className="input"
type="password"
value={row.value}
onChange={e => {
const value = e.target.value;
setForm(f => ({
...f,
customHeaders: f.customHeaders.map((h, i) =>
i === index ? { ...h, value } : h,
),
}));
}}
placeholder={t('settings.customHeadersValuePlaceholder')}
autoComplete="off"
/>
<button
type="button"
className="btn btn-ghost"
aria-label={t('settings.customHeadersRemoveRow')}
onClick={() =>
setForm(f => ({
...f,
customHeaders:
f.customHeaders.length <= 1
? [{ name: '', value: '' }]
: f.customHeaders.filter((_, i) => i !== index),
}))
}
>
×
</button>
</div>
))}
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, marginBottom: 8 }}
onClick={() =>
setForm(f => ({
...f,
customHeaders: [...f.customHeaders, { name: '', value: '' }],
}))
}
>
{t('settings.customHeadersAddRow')}
</button>
<fieldset
disabled={!form.customHeaders.some(h => h.name.trim() || h.value)}
style={{ border: 'none', padding: 0, margin: 0 }}
>
<legend style={{ fontSize: 12, marginBottom: 4 }}>{t('settings.customHeadersApplyTo')}</legend>
{(['public', 'local', 'both'] as const).map(kind => (
<label key={kind} style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
<input
type="radio"
name="customHeadersApplyTo"
checked={form.customHeadersApplyTo === kind}
onChange={() => setForm(f => ({ ...f, customHeadersApplyTo: kind }))}
/>{' '}
{t(`settings.customHeadersApplyTo_${kind}`)}
</label>
))}
</fieldset>
<p style={{ fontSize: 11, opacity: 0.65, marginTop: 6 }}>
{t('settings.customHeadersNotInShare')}
</p>
</div>
)}
</div>
{!isEdit && (
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13 }}>{t('login.orMagicString')}</label>
+26 -7
View File
@@ -12,7 +12,11 @@ import { bootstrapIndexedServer } from '../../utils/library/librarySession';
import { useLibraryIndexSync } from '../../hooks/useLibraryIndexSync';
import ServerLibraryIndexControls from './ServerLibraryIndexControls';
import type { ServerProfile } from '../../store/authStoreTypes';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic';
import { pingWithCredentialsForProfile, scheduleInstantMixProbeForServer } from '../../api/subsonic';
import {
clearServerHttpContext,
syncServerHttpContextForProfile,
} from '../../utils/server/syncServerHttpContext';
import { useDragDrop } from '../../contexts/DragDropContext';
import { type ServerMagicPayload } from '../../utils/server/serverMagicString';
import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '../../utils/server/serverEndpoint';
@@ -191,6 +195,7 @@ export function ServersTab({
auth.removeServer(server.id);
try {
await clearServerHttpContext(server);
await librarySyncClearSession(server.id);
if (purgeLibrary) {
await libraryDeleteServerData(server.id);
@@ -238,7 +243,12 @@ export function ServersTab({
// straight to the legacy ping (which is also the connect-test).
if (data.alternateUrl) {
const verify = await verifySameServerEndpoints(
{ url: data.url, alternateUrl: data.alternateUrl },
{
url: data.url,
alternateUrl: data.alternateUrl,
customHeaders: data.customHeaders,
customHeadersApplyTo: data.customHeadersApplyTo,
},
data.username,
data.password,
);
@@ -247,7 +257,7 @@ export function ServersTab({
return;
}
}
const ping = await pingWithCredentials(data.url, data.username, data.password);
const ping = await pingWithCredentialsForProfile(data, data.url);
if (ping.ok) {
const id = auth.addServer(data);
const identity = {
@@ -259,7 +269,10 @@ export function ServersTab({
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity, true);
setConnStatus(s => ({ ...s, [id]: 'ok' }));
const added = useAuthStore.getState().servers.find(s => s.id === id);
if (added) void bootstrapIndexedServer(added);
if (added) {
void syncServerHttpContextForProfile(added);
void bootstrapIndexedServer(added);
}
} else {
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
}
@@ -323,7 +336,12 @@ export function ServersTab({
if (dualAddressChanged) {
setConnStatus(s => ({ ...s, [id]: 'testing' }));
const verify = await verifySameServerEndpoints(
{ url: data.url, alternateUrl: data.alternateUrl },
{
url: data.url,
alternateUrl: data.alternateUrl,
customHeaders: data.customHeaders,
customHeadersApplyTo: data.customHeadersApplyTo,
},
data.username,
data.password,
);
@@ -335,12 +353,13 @@ export function ServersTab({
setEditingServerId(null);
auth.updateServer(id, data);
const updated = useAuthStore.getState().servers.find(s => s.id === id);
if (updated) void syncServerHttpContextForProfile(updated);
// Profile edited → any cached sticky connect URL for this id may now be
// stale (credentials may have changed, alternate may have been added).
invalidateReachableEndpointCache(id);
setConnStatus(s => ({ ...s, [id]: 'testing' }));
try {
const ping = await pingWithCredentials(data.url, data.username, data.password);
const ping = await pingWithCredentialsForProfile(data, data.url);
if (ping.ok) {
const identity = {
type: ping.type,
+1
View File
@@ -172,6 +172,7 @@ const CONTRIBUTOR_ENTRIES = [
'Niri compositor tiling WM detection (PR #1127)',
'Local library index: Navidrome ignored-articles artist/composer letter buckets (name_sort + server ignoredArticles), idempotent migration with safe open/swap and poisoned-lock recovery (PR #1145)',
'All Albums browse: compilation and favorites filters in slice mode — skip redundant client comp filter, route favorites through getStarred2, pre-index page scan, offline isCompilation from tracks (PR #1151)',
'Custom HTTP headers for reverse-proxy gates — per-server header editor, TS/Rust registry sync, full playback/sync/cover path coverage, log redaction (PR #1156)',
],
},
{
+17 -16
View File
@@ -9,6 +9,7 @@ import {
vi.mock('@/api/subsonic', () => ({
pingWithCredentials: vi.fn(),
pingWithCredentialsForProfile: vi.fn(),
scheduleInstantMixProbeForServer: vi.fn(),
}));
@@ -16,7 +17,7 @@ vi.mock('@/utils/perf/perfFlags', () => ({
usePerfProbeFlags: () => ({ disableBackgroundPolling: false }),
}));
import { pingWithCredentials } from '@/api/subsonic';
import { pingWithCredentialsForProfile } from '@/api/subsonic';
import { useDevOfflineBrowseStore } from '@/store/devOfflineBrowseStore';
import { useConnectionStatus } from './useConnectionStatus';
@@ -24,7 +25,7 @@ beforeEach(() => {
resetAuthStore();
invalidateReachableEndpointCache();
useDevOfflineBrowseStore.getState().setForceOffline(false);
vi.mocked(pingWithCredentials).mockReset();
vi.mocked(pingWithCredentialsForProfile).mockReset();
});
afterEach(() => {
@@ -49,7 +50,7 @@ describe('useConnectionStatus.isLan', () => {
// LAN endpoint answers — alternateUrl is the LAN side here, so a
// primary-url-only check would say "public". We assert it says "local"
// (active endpoint kind).
vi.mocked(pingWithCredentials).mockImplementation(async url =>
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
url === 'http://192.168.0.10'
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
: { ok: false },
@@ -63,7 +64,7 @@ describe('useConnectionStatus.isLan', () => {
it('falls back to public when only the public address answers', async () => {
vi.useFakeTimers();
seedDualAddressServer();
vi.mocked(pingWithCredentials).mockImplementation(async url =>
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
url === 'https://music.example.com'
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
: { ok: false },
@@ -88,7 +89,7 @@ describe('useConnectionStatus.isLan', () => {
seedDualAddressServer();
// Don't resolve the ping — the hook is still in the `checking` state.
let _resolve: ((v: PickReachableResult) => void) | null = null;
vi.mocked(pingWithCredentials).mockReturnValue(
vi.mocked(pingWithCredentialsForProfile).mockReturnValue(
new Promise(r => {
_resolve = ((res: PickReachableResult) => {
if (res.ok) {
@@ -116,7 +117,7 @@ describe('useConnectionStatus online event', () => {
it('flushes the reachable-endpoint cache when the browser fires online', async () => {
seedDualAddressServer();
// Initial probe: LAN answers.
vi.mocked(pingWithCredentials).mockImplementation(async url =>
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
url === 'http://192.168.0.10'
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
: { ok: false },
@@ -129,8 +130,8 @@ describe('useConnectionStatus online event', () => {
// fire in this test; we trigger the online event instead. The handler
// invalidates the sticky cache so the next probe goes LAN-first and
// flips over to public when LAN refuses.
vi.mocked(pingWithCredentials).mockClear();
vi.mocked(pingWithCredentials).mockImplementation(async url =>
vi.mocked(pingWithCredentialsForProfile).mockClear();
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
url === 'https://music.example.com'
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
: { ok: false },
@@ -147,14 +148,14 @@ describe('useConnectionStatus online event', () => {
await waitFor(() => expect(result.current.isLan).toBe(false));
// Both endpoints were probed (LAN refused, public answered).
expect(vi.mocked(pingWithCredentials).mock.calls.length).toBeGreaterThanOrEqual(2);
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBeGreaterThanOrEqual(2);
});
});
describe('useConnectionStatus DEV offline toggle', () => {
it('does not probe again on mount beyond the polling effect', async () => {
seedDualAddressServer();
vi.mocked(pingWithCredentials).mockResolvedValue({
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue({
ok: true,
type: 'navidrome',
serverVersion: '0.55.0',
@@ -162,15 +163,15 @@ describe('useConnectionStatus DEV offline toggle', () => {
});
renderHook(() => useConnectionStatus());
await waitFor(() => expect(vi.mocked(pingWithCredentials).mock.calls.length).toBeGreaterThanOrEqual(1));
const callsAfterMount = vi.mocked(pingWithCredentials).mock.calls.length;
await waitFor(() => expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBeGreaterThanOrEqual(1));
const callsAfterMount = vi.mocked(pingWithCredentialsForProfile).mock.calls.length;
await new Promise(r => setTimeout(r, 20));
expect(vi.mocked(pingWithCredentials).mock.calls.length).toBe(callsAfterMount);
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBe(callsAfterMount);
});
it('disconnects on force-offline toggle without an extra probe', async () => {
seedDualAddressServer();
vi.mocked(pingWithCredentials).mockResolvedValue({
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue({
ok: true,
type: 'navidrome',
serverVersion: '0.55.0',
@@ -179,10 +180,10 @@ describe('useConnectionStatus DEV offline toggle', () => {
const { result } = renderHook(() => useConnectionStatus());
await waitFor(() => expect(result.current.status).toBe('connected'));
const callsBeforeToggle = vi.mocked(pingWithCredentials).mock.calls.length;
const callsBeforeToggle = vi.mocked(pingWithCredentialsForProfile).mock.calls.length;
act(() => useDevOfflineBrowseStore.getState().setForceOffline(true));
await waitFor(() => expect(result.current.status).toBe('disconnected'));
expect(vi.mocked(pingWithCredentials).mock.calls.length).toBe(callsBeforeToggle);
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBe(callsBeforeToggle);
});
});
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: 'Wirkt sich auf Orbit-Einladungen und Bibliotheks-Freigaben aus. Standard: öffentliche Adresse.',
serverUsername: 'Benutzername',
serverPassword: 'Passwort',
customHeadersTitle: 'Benutzerdefinierte HTTP-Header',
customHeadersHelp: 'Für Reverse-Proxy-Gates (Pangolin, Cloudflare Access). Werden nur an deinen Server gesendet — nicht an Drittanbieter.',
customHeadersNamePlaceholder: 'Header-Name',
customHeadersValuePlaceholder: 'Header-Wert',
customHeadersAddRow: 'Header hinzufügen',
customHeadersRemoveRow: 'Header entfernen',
customHeadersApplyTo: 'Header anwenden auf',
customHeadersApplyTo_public: 'Öffentliche Adresse',
customHeadersApplyTo_local: 'Lokale Adresse',
customHeadersApplyTo_both: 'Beide Adressen',
customHeadersNotInShare: 'Benutzerdefinierte Header sind nicht in Freigabe-Einladungen enthalten — auf jedem Gerät erneut konfigurieren.',
'customHeadersValidation.tooMany': 'Zu viele benutzerdefinierte Header (max. 16).',
'customHeadersValidation.nameRequired': 'Header-Name ist erforderlich.',
'customHeadersValidation.nameTooLong': 'Header-Name ist zu lang.',
'customHeadersValidation.valueTooLong': 'Header-Wert ist zu lang.',
'customHeadersValidation.crlf': 'Header-Namen und -Werte dürfen keine Zeilenumbrüche enthalten.',
'customHeadersValidation.blocked': 'Dieser Header-Name ist nicht erlaubt.',
'customHeadersValidation.duplicate': 'Doppelter Header-Name.',
addServer: 'Server hinzufügen',
addServerTitle: 'Neuen Server hinzufügen',
editServer: 'Bearbeiten',
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: 'Affects Orbit invites and library shares. Default: public address.',
serverUsername: 'Username',
serverPassword: 'Password',
customHeadersTitle: 'Custom HTTP headers',
customHeadersHelp: 'For reverse-proxy gates (Pangolin, Cloudflare Access). Sent only to your server — not to third-party services.',
customHeadersNamePlaceholder: 'Header name',
customHeadersValuePlaceholder: 'Header value',
customHeadersAddRow: 'Add header',
customHeadersRemoveRow: 'Remove header',
customHeadersApplyTo: 'Apply headers to',
customHeadersApplyTo_public: 'Public address',
customHeadersApplyTo_local: 'Local address',
customHeadersApplyTo_both: 'Both addresses',
customHeadersNotInShare: 'Custom headers are not included in share invites — configure them again on each device.',
'customHeadersValidation.tooMany': 'Too many custom headers (max 16).',
'customHeadersValidation.nameRequired': 'Header name is required.',
'customHeadersValidation.nameTooLong': 'Header name is too long.',
'customHeadersValidation.valueTooLong': 'Header value is too long.',
'customHeadersValidation.crlf': 'Header names and values cannot contain line breaks.',
'customHeadersValidation.blocked': 'This header name is not allowed.',
'customHeadersValidation.duplicate': 'Duplicate header name.',
addServer: 'Add Server',
addServerTitle: 'Add New Server',
editServer: 'Edit',
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: 'Afecta a las invitaciones de Orbit y a los enlaces de la biblioteca. Por defecto: dirección pública.',
serverUsername: 'Usuario',
serverPassword: 'Contraseña',
customHeadersTitle: 'Encabezados HTTP personalizados',
customHeadersHelp: 'Para puertas de reverse proxy (Pangolin, Cloudflare Access). Se envían solo a tu servidor — no a servicios de terceros.',
customHeadersNamePlaceholder: 'Nombre del encabezado',
customHeadersValuePlaceholder: 'Valor del encabezado',
customHeadersAddRow: 'Añadir encabezado',
customHeadersRemoveRow: 'Eliminar encabezado',
customHeadersApplyTo: 'Aplicar encabezados a',
customHeadersApplyTo_public: 'Dirección pública',
customHeadersApplyTo_local: 'Dirección local',
customHeadersApplyTo_both: 'Ambas direcciones',
customHeadersNotInShare: 'Los encabezados personalizados no se incluyen en las invitaciones — configúralos de nuevo en cada dispositivo.',
'customHeadersValidation.tooMany': 'Demasiados encabezados personalizados (máx. 16).',
'customHeadersValidation.nameRequired': 'El nombre del encabezado es obligatorio.',
'customHeadersValidation.nameTooLong': 'El nombre del encabezado es demasiado largo.',
'customHeadersValidation.valueTooLong': 'El valor del encabezado es demasiado largo.',
'customHeadersValidation.crlf': 'Los nombres y valores no pueden contener saltos de línea.',
'customHeadersValidation.blocked': 'Este nombre de encabezado no está permitido.',
'customHeadersValidation.duplicate': 'Nombre de encabezado duplicado.',
addServer: 'Agregar Servidor',
addServerTitle: 'Agregar Nuevo Servidor',
editServer: 'Editar',
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: 'Affecte les invitations Orbit et les partages de bibliothèque. Par défaut : adresse publique.',
serverUsername: 'Nom d\'utilisateur',
serverPassword: 'Mot de passe',
customHeadersTitle: 'En-têtes HTTP personnalisés',
customHeadersHelp: 'Pour les passerelles reverse proxy (Pangolin, Cloudflare Access). Envoyés uniquement à votre serveur — pas aux services tiers.',
customHeadersNamePlaceholder: 'Nom de len-tête',
customHeadersValuePlaceholder: 'Valeur de len-tête',
customHeadersAddRow: 'Ajouter un en-tête',
customHeadersRemoveRow: 'Supprimer len-tête',
customHeadersApplyTo: 'Appliquer les en-têtes à',
customHeadersApplyTo_public: 'Adresse publique',
customHeadersApplyTo_local: 'Adresse locale',
customHeadersApplyTo_both: 'Les deux adresses',
customHeadersNotInShare: 'Les en-têtes personnalisés ne sont pas inclus dans les invitations — configurez-les à nouveau sur chaque appareil.',
'customHeadersValidation.tooMany': 'Trop den-têtes personnalisés (max. 16).',
'customHeadersValidation.nameRequired': 'Le nom de len-tête est requis.',
'customHeadersValidation.nameTooLong': 'Le nom de len-tête est trop long.',
'customHeadersValidation.valueTooLong': 'La valeur de len-tête est trop longue.',
'customHeadersValidation.crlf': 'Les noms et valeurs ne peuvent pas contenir de saut de ligne.',
'customHeadersValidation.blocked': 'Ce nom den-tête nest pas autorisé.',
'customHeadersValidation.duplicate': 'Nom den-tête en double.',
addServer: 'Ajouter un serveur',
addServerTitle: 'Ajouter un nouveau serveur',
editServer: 'Modifier',
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: 'Érinti az Orbit-meghívókat és a könyvtármegosztásokat. Alapértelmezett: nyilvános cím.',
serverUsername: 'Felhasználónév',
serverPassword: 'Jelszó',
customHeadersTitle: 'Egyéni HTTP fejlécek',
customHeadersHelp: 'Reverse-proxy kapukhoz (Pangolin, Cloudflare Access). Csak a szerveredhez kerülnek — harmadik fél szolgáltatásaihoz nem.',
customHeadersNamePlaceholder: 'Fejléc neve',
customHeadersValuePlaceholder: 'Fejléc értéke',
customHeadersAddRow: 'Fejléc hozzáadása',
customHeadersRemoveRow: 'Fejléc eltávolítása',
customHeadersApplyTo: 'Fejlécek alkalmazása',
customHeadersApplyTo_public: 'Nyilvános cím',
customHeadersApplyTo_local: 'Helyi cím',
customHeadersApplyTo_both: 'Mindkét cím',
customHeadersNotInShare: 'Az egyéni fejlécek nem szerepelnek a meghívókban — minden eszközön külön kell beállítani.',
'customHeadersValidation.tooMany': 'Túl sok egyéni fejléc (max. 16).',
'customHeadersValidation.nameRequired': 'A fejléc neve kötelező.',
'customHeadersValidation.nameTooLong': 'A fejléc neve túl hosszú.',
'customHeadersValidation.valueTooLong': 'A fejléc értéke túl hosszú.',
'customHeadersValidation.crlf': 'A név és az érték nem tartalmazhat sortörést.',
'customHeadersValidation.blocked': 'Ez a fejlécnév nem engedélyezett.',
'customHeadersValidation.duplicate': 'Ismétlődő fejlécnév.',
addServer: 'Szerver hozzáadása',
addServerTitle: 'Új szerver hozzáadása',
editServer: 'Szerkesztés',
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: 'Orbit 招待とライブラリ共有に影響します。既定: 公開アドレス。',
serverUsername: 'ユーザー名',
serverPassword: 'パスワード',
customHeadersTitle: 'カスタム HTTP ヘッダー',
customHeadersHelp: 'リバースプロキシゲート用(Pangolin、Cloudflare Access)。サーバーへの送信のみ — 第三者サービスには送信しません。',
customHeadersNamePlaceholder: 'ヘッダー名',
customHeadersValuePlaceholder: 'ヘッダー値',
customHeadersAddRow: 'ヘッダーを追加',
customHeadersRemoveRow: 'ヘッダーを削除',
customHeadersApplyTo: 'ヘッダーの適用先',
customHeadersApplyTo_public: '公開アドレス',
customHeadersApplyTo_local: 'ローカルアドレス',
customHeadersApplyTo_both: '両方のアドレス',
customHeadersNotInShare: 'カスタムヘッダーは招待リンクに含まれません — 各デバイスで再度設定してください。',
'customHeadersValidation.tooMany': 'カスタムヘッダーが多すぎます(最大 16)。',
'customHeadersValidation.nameRequired': 'ヘッダー名は必須です。',
'customHeadersValidation.nameTooLong': 'ヘッダー名が長すぎます。',
'customHeadersValidation.valueTooLong': 'ヘッダー値が長すぎます。',
'customHeadersValidation.crlf': 'ヘッダー名と値に改行を含めることはできません。',
'customHeadersValidation.blocked': 'このヘッダー名は使用できません。',
'customHeadersValidation.duplicate': 'ヘッダー名が重複しています。',
addServer: 'サーバーを追加',
addServerTitle: '新しいサーバーを追加',
editServer: '編集',
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: 'Påvirker Orbit-invitasjoner og biblioteksdeling. Standard: offentlig adresse.',
serverUsername: 'Brukernavn',
serverPassword: 'Passord',
customHeadersTitle: 'Egendefinerte HTTP-headere',
customHeadersHelp: 'For reverse-proxy-gates (Pangolin, Cloudflare Access). Sendes bare til serveren din — ikke til tredjepartstjenester.',
customHeadersNamePlaceholder: 'Headernavn',
customHeadersValuePlaceholder: 'Headerverdi',
customHeadersAddRow: 'Legg til header',
customHeadersRemoveRow: 'Fjern header',
customHeadersApplyTo: 'Bruk headere på',
customHeadersApplyTo_public: 'Offentlig adresse',
customHeadersApplyTo_local: 'Lokal adresse',
customHeadersApplyTo_both: 'Begge adresser',
customHeadersNotInShare: 'Egendefinerte headere er ikke inkludert i invitasjoner — konfigurer dem på nytt på hver enhet.',
'customHeadersValidation.tooMany': 'For mange egendefinerte headere (maks. 16).',
'customHeadersValidation.nameRequired': 'Headernavn er påkrevd.',
'customHeadersValidation.nameTooLong': 'Headernavnet er for langt.',
'customHeadersValidation.valueTooLong': 'Headerverdien er for lang.',
'customHeadersValidation.crlf': 'Headernavn og -verdier kan ikke inneholde linjeskift.',
'customHeadersValidation.blocked': 'Dette headernavnet er ikke tillatt.',
'customHeadersValidation.duplicate': 'Duplikat headernavn.',
addServer: 'Legg til tjener',
addServerTitle: 'Legg til ny tjener',
editServer: 'Rediger',
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: 'Geldt voor Orbit-uitnodigingen en bibliotheekdelen. Standaard: openbaar adres.',
serverUsername: 'Gebruikersnaam',
serverPassword: 'Wachtwoord',
customHeadersTitle: 'Aangepaste HTTP-headers',
customHeadersHelp: 'Voor reverse-proxy-gates (Pangolin, Cloudflare Access). Alleen naar je server gestuurd — niet naar diensten van derden.',
customHeadersNamePlaceholder: 'Headernaam',
customHeadersValuePlaceholder: 'Headervalue',
customHeadersAddRow: 'Header toevoegen',
customHeadersRemoveRow: 'Header verwijderen',
customHeadersApplyTo: 'Headers toepassen op',
customHeadersApplyTo_public: 'Openbaar adres',
customHeadersApplyTo_local: 'Lokaal adres',
customHeadersApplyTo_both: 'Beide adressen',
customHeadersNotInShare: 'Aangepaste headers worden niet opgenomen in uitnodigingen — configureer ze opnieuw op elk apparaat.',
'customHeadersValidation.tooMany': 'Te veel aangepaste headers (max. 16).',
'customHeadersValidation.nameRequired': 'Headernaam is verplicht.',
'customHeadersValidation.nameTooLong': 'Headernaam is te lang.',
'customHeadersValidation.valueTooLong': 'Headervalue is te lang.',
'customHeadersValidation.crlf': 'Headernamen en -waarden mogen geen regeleinden bevatten.',
'customHeadersValidation.blocked': 'Deze headernaam is niet toegestaan.',
'customHeadersValidation.duplicate': 'Dubbele headernaam.',
addServer: 'Server toevoegen',
addServerTitle: 'Nieuwe server toevoegen',
editServer: 'Bewerken',
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: 'Afectează invitațiile Orbit și partajările bibliotecii. Implicit: adresa publică.',
serverUsername: 'Nume utilizator',
serverPassword: 'Parolă',
customHeadersTitle: 'Antete HTTP personalizate',
customHeadersHelp: 'Pentru porți reverse proxy (Pangolin, Cloudflare Access). Trimise doar către serverul tău — nu către servicii terțe.',
customHeadersNamePlaceholder: 'Nume antet',
customHeadersValuePlaceholder: 'Valoare antet',
customHeadersAddRow: 'Adaugă antet',
customHeadersRemoveRow: 'Elimină antet',
customHeadersApplyTo: 'Aplică antetele la',
customHeadersApplyTo_public: 'Adresă publică',
customHeadersApplyTo_local: 'Adresă locală',
customHeadersApplyTo_both: 'Ambele adrese',
customHeadersNotInShare: 'Antetele personalizate nu sunt incluse în invitații — configurează-le din nou pe fiecare dispozitiv.',
'customHeadersValidation.tooMany': 'Prea multe antete personalizate (max. 16).',
'customHeadersValidation.nameRequired': 'Numele antetului este obligatoriu.',
'customHeadersValidation.nameTooLong': 'Numele antetului este prea lung.',
'customHeadersValidation.valueTooLong': 'Valoarea antetului este prea lungă.',
'customHeadersValidation.crlf': 'Numele și valorile nu pot conține linii noi.',
'customHeadersValidation.blocked': 'Acest nume de antet nu este permis.',
'customHeadersValidation.duplicate': 'Nume de antet duplicat.',
addServer: 'Adaugă Server',
addServerTitle: 'Adaugă Server nou',
editServer: 'Editează',
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: 'Применяется к приглашениям Orbit и ссылкам на библиотеку. По умолчанию — публичный адрес.',
serverUsername: 'Логин',
serverPassword: 'Пароль',
customHeadersTitle: 'Пользовательские HTTP-заголовки',
customHeadersHelp: 'Для reverse-proxy (Pangolin, Cloudflare Access). Отправляются только на ваш сервер — не сторонним сервисам.',
customHeadersNamePlaceholder: 'Имя заголовка',
customHeadersValuePlaceholder: 'Значение заголовка',
customHeadersAddRow: 'Добавить заголовок',
customHeadersRemoveRow: 'Удалить заголовок',
customHeadersApplyTo: 'Применять заголовки к',
customHeadersApplyTo_public: 'Публичному адресу',
customHeadersApplyTo_local: 'Локальному адресу',
customHeadersApplyTo_both: 'Оба адреса',
customHeadersNotInShare: 'Заголовки не входят в приглашения — настройте их на каждом устройстве отдельно.',
'customHeadersValidation.tooMany': 'Слишком много заголовков (макс. 16).',
'customHeadersValidation.nameRequired': 'Укажите имя заголовка.',
'customHeadersValidation.nameTooLong': 'Имя заголовка слишком длинное.',
'customHeadersValidation.valueTooLong': 'Значение заголовка слишком длинное.',
'customHeadersValidation.crlf': 'Имя и значение не могут содержать переводы строк.',
'customHeadersValidation.blocked': 'Это имя заголовка запрещено.',
'customHeadersValidation.duplicate': 'Дублирующееся имя заголовка.',
addServer: 'Добавить сервер',
addServerTitle: 'Новый сервер',
editServer: 'Изменить',
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: '影响 Orbit 邀请和资料库分享。默认:公共地址。',
serverUsername: '用户名',
serverPassword: '密码',
customHeadersTitle: '自定义 HTTP 标头',
customHeadersHelp: '用于反向代理网关(Pangolin、Cloudflare Access)。仅发送到你的服务器 — 不会发送给第三方服务。',
customHeadersNamePlaceholder: '标头名称',
customHeadersValuePlaceholder: '标头值',
customHeadersAddRow: '添加标头',
customHeadersRemoveRow: '删除标头',
customHeadersApplyTo: '将标头应用于',
customHeadersApplyTo_public: '公共地址',
customHeadersApplyTo_local: '本地地址',
customHeadersApplyTo_both: '两个地址',
customHeadersNotInShare: '自定义标头不会包含在分享邀请中 — 请在每台设备上重新配置。',
'customHeadersValidation.tooMany': '自定义标头过多(最多 16 个)。',
'customHeadersValidation.nameRequired': '标头名称不能为空。',
'customHeadersValidation.nameTooLong': '标头名称过长。',
'customHeadersValidation.valueTooLong': '标头值过长。',
'customHeadersValidation.crlf': '标头名称和值不能包含换行符。',
'customHeadersValidation.blocked': '不允许使用此标头名称。',
'customHeadersValidation.duplicate': '标头名称重复。',
addServer: '添加服务器',
addServerTitle: '添加新服务器',
editServer: '编辑',
+2
View File
@@ -20,6 +20,7 @@ import {
DEFAULT_LIBRARY_GRID_MAX_COLUMNS,
} from './authStoreDefaults';
import { computeAuthStoreRehydration } from './authStoreRehydrate';
import { syncAllServerHttpContexts } from '../utils/server/syncServerHttpContext';
import type { AuthState } from './authStoreTypes';
import { getCachedConnectBaseUrl } from '../utils/server/serverEndpoint';
import { serverProfileBaseUrl } from '../utils/server/serverBaseUrl';
@@ -172,6 +173,7 @@ export const useAuthStore = create<AuthState>()(
onRehydrateStorage: () => (state, error) => {
if (error || !state) return;
useAuthStore.setState(computeAuthStoreRehydration(state));
void syncAllServerHttpContexts(useAuthStore.getState().servers);
},
}
)
+21
View File
@@ -6,6 +6,23 @@ import type {
} from '../utils/server/subsonicServerIdentity';
import type { PersistedAccount } from '../music-network';
export type CustomHeaderEntry = {
name: string;
value: string;
};
export type CustomHeadersApplyTo = 'local' | 'public' | 'both';
export type CustomHeadersFieldError = {
index: number;
field: 'name' | 'value';
messageKey: string;
};
export type CustomHeadersValidationResult =
| { ok: true }
| { ok: false; fieldErrors: CustomHeadersFieldError[]; formMessage?: string };
export interface ServerProfile {
id: string;
name: string;
@@ -30,6 +47,10 @@ export interface ServerProfile {
shareUsesLocalUrl?: boolean;
username: string;
password: string;
/** Optional HTTP headers for reverse-proxy gates (Pangolin, Cloudflare Access). */
customHeaders?: CustomHeaderEntry[];
/** Which profile endpoint(s) receive `customHeaders`. Default when absent: `'public'`. */
customHeadersApplyTo?: CustomHeadersApplyTo;
}
export type SeekbarStyle = 'truewave' | 'pseudowave' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
@@ -23,6 +23,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Listing every export the store uses keeps the override stable.
vi.mock('@/api/subsonic', () => ({
pingWithCredentials: vi.fn(async () => ({ ok: true })),
pingWithCredentialsForProfile: vi.fn(async () => ({ ok: true })),
scheduleInstantMixProbeForServer: vi.fn(),
}));
vi.mock('@/api/subsonicPlayQueue', () => ({
savePlayQueue: vi.fn(async () => undefined),
+2
View File
@@ -8,6 +8,7 @@ import { useAuthStore } from '../../store/authStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
import { ensureConnectUrlResolved } from '../server/serverEndpoint';
import { serverIndexKeyForProfile } from '../server/serverIndexKey';
import { syncServerHttpContextForProfile } from '../server/syncServerHttpContext';
import { libraryDevEnabled, logLibraryStatus, logLibrarySync, timed } from './libraryDevLog';
export type BindServerResult = 'bound' | 'offline' | 'error';
@@ -49,6 +50,7 @@ export async function bindIndexedServer(server: ServerProfile): Promise<BindServ
});
logLibraryStatus(server.id, status, 'bind_session');
}
void syncServerHttpContextForProfile(server);
return 'bound';
} catch {
return 'error';
+11
View File
@@ -34,4 +34,15 @@ describe('sanitizeLogLine', () => {
expect(out).toContain('password=REDACTED');
expect(out).not.toContain('sekrit');
});
it('redacts reverse-proxy gate header values', () => {
const line = 'req CF-Access-Client-Secret: gate-secret Authorization: Bearer tok123 x-pangolin-auth: pangolin-key';
const out = sanitizeLogLine(line);
expect(out).toContain('CF-Access-Client-Secret: REDACTED');
expect(out).not.toContain('gate-secret');
expect(out).not.toContain('tok123');
expect(out).toContain('x-pangolin-auth: REDACTED');
expect(out).not.toContain('pangolin-key');
expect(out).not.toMatch(/Authorization:\s*Bearer\s+\S/i);
});
});
+9 -1
View File
@@ -11,8 +11,12 @@ const SENSITIVE_QUERY_KEYS = new Set([
const SENSITIVE_KV_KEYS = [
'password', 'passwd', 'token', 'secret', 'api_key', 'apikey',
'access_token', 'refresh_token', 'authorization', 'auth',
'cookie', 'x-api-key', 'cf-access-client-secret', 'cf-access-client-id', 'x-auth-token',
];
/** Gate / reverse-proxy header names — redact any `x-pangolin-*` prefix. */
const PANGOLIN_HEADER_RE = /(\bx-pangolin-[a-z0-9-]+\s*[:=]\s*)(\S+)/gi;
function isIpv4LanLiteral(ip: string): boolean {
const parts = ip.split('.');
if (parts.length !== 4) return false;
@@ -175,6 +179,10 @@ function redactBearerTokens(line: string): string {
return s;
}
function redactPangolinHeaders(line: string): string {
return line.replace(PANGOLIN_HEADER_RE, '$1REDACTED');
}
function redactSensitiveKeyValues(line: string): string {
let out = line;
for (const key of SENSITIVE_KV_KEYS) {
@@ -231,5 +239,5 @@ function redactUrlsInText(line: string): string {
}
export function sanitizeLogLine(line: string): string {
return redactUrlsInText(redactSensitiveKeyValues(redactBearerTokens(line)));
return redactUrlsInText(redactSensitiveKeyValues(redactPangolinHeaders(redactBearerTokens(line))));
}
+40 -39
View File
@@ -2,9 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../api/subsonic', () => ({
pingWithCredentials: vi.fn(),
pingWithCredentialsForProfile: vi.fn(),
}));
import { pingWithCredentials } from '../../api/subsonic';
import { pingWithCredentialsForProfile } from '../../api/subsonic';
import {
allNormalizedAddresses,
ensureConnectUrlResolved,
@@ -47,7 +48,7 @@ function pingFail() {
const CONNECT_PROBE_ATTEMPTS = 3;
function mockDualAddressLanFailPublicOk() {
vi.mocked(pingWithCredentials).mockImplementation(async (url: string) => {
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url: string) => {
if (url === 'http://192.168.0.10') return pingFail();
return pingOk();
});
@@ -212,7 +213,7 @@ describe('serverAddressEndpoints', () => {
describe('pickReachableBaseUrl', () => {
beforeEach(() => {
invalidateReachableEndpointCache();
vi.mocked(pingWithCredentials).mockReset();
vi.mocked(pingWithCredentialsForProfile).mockReset();
});
afterEach(() => {
@@ -220,7 +221,7 @@ describe('pickReachableBaseUrl', () => {
});
it('returns the single endpoint when it pings ok and caches it', async () => {
vi.mocked(pingWithCredentials).mockResolvedValue(pingOk());
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingOk());
const result = await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
expect(result.ok).toBe(true);
if (result.ok) {
@@ -230,11 +231,11 @@ describe('pickReachableBaseUrl', () => {
expect(result.ping.type).toBe('navidrome');
}
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
});
it('prefers the LAN endpoint even when alternateUrl is the LAN one', async () => {
vi.mocked(pingWithCredentials).mockResolvedValue(pingOk());
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingOk());
const result = await pickReachableBaseUrl(
makeProfile({
url: 'https://music.example.com',
@@ -243,8 +244,8 @@ describe('pickReachableBaseUrl', () => {
);
expect(result.ok).toBe(true);
if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10');
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
expect(vi.mocked(pingWithCredentials).mock.calls[0]![0]).toBe('http://192.168.0.10');
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls[0]![1]).toBe('http://192.168.0.10');
});
it('falls through to the public endpoint when LAN ping fails', async () => {
@@ -260,13 +261,13 @@ describe('pickReachableBaseUrl', () => {
const result = await promise;
expect(result.ok).toBe(true);
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com');
expect(pingWithCredentials).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS + 1);
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS + 1);
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
});
it('retries a flaky endpoint before declaring it unreachable', async () => {
vi.useFakeTimers();
vi.mocked(pingWithCredentials)
vi.mocked(pingWithCredentialsForProfile)
.mockResolvedValueOnce(pingFail())
.mockResolvedValueOnce(pingOk());
const promise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
@@ -274,32 +275,32 @@ describe('pickReachableBaseUrl', () => {
const result = await promise;
expect(result.ok).toBe(true);
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com');
expect(pingWithCredentials).toHaveBeenCalledTimes(2);
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
it('returns unreachable and clears cache when every endpoint fails', async () => {
vi.mocked(pingWithCredentials).mockResolvedValue(pingFail());
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingFail());
// Seed a stale cache entry first.
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
vi.mocked(pingWithCredentials).mockReset();
vi.mocked(pingWithCredentials).mockResolvedValue(pingFail());
vi.mocked(pingWithCredentialsForProfile).mockReset();
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingFail());
vi.useFakeTimers();
const unreachablePromise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
await vi.runAllTimersAsync();
const result = await unreachablePromise;
expect(result).toEqual({ ok: false, reason: 'unreachable' });
expect(getCachedConnectBaseUrl('profile-1')).toBeNull();
expect(pingWithCredentials).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS);
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS);
});
it('returns unreachable when the profile has no usable url', async () => {
const result = await pickReachableBaseUrl(makeProfile({ url: '' }));
expect(result).toEqual({ ok: false, reason: 'unreachable' });
expect(pingWithCredentials).not.toHaveBeenCalled();
expect(pingWithCredentialsForProfile).not.toHaveBeenCalled();
});
it('tries the cached endpoint first on subsequent calls (sticky)', async () => {
@@ -308,18 +309,18 @@ describe('pickReachableBaseUrl', () => {
alternateUrl: 'http://192.168.0.10',
});
// First call: LAN responds ok, becomes cached.
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile);
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
// Second call: cached URL is tried first; sole ping happens against it.
vi.mocked(pingWithCredentials).mockClear();
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
vi.mocked(pingWithCredentialsForProfile).mockClear();
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
const result = await pickReachableBaseUrl(profile);
expect(result.ok).toBe(true);
if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10');
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
expect(vi.mocked(pingWithCredentials).mock.calls[0]![0]).toBe('http://192.168.0.10');
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls[0]![1]).toBe('http://192.168.0.10');
});
it('dedupes concurrent calls for the same profile (single shared probe)', async () => {
@@ -328,7 +329,7 @@ describe('pickReachableBaseUrl', () => {
// cache write, with last-write-wins potentially clobbering the correct
// LAN sticky a millisecond after it was set.
let resolvePing: ((v: ReturnType<typeof pingOk>) => void) | null = null;
vi.mocked(pingWithCredentials).mockReturnValueOnce(
vi.mocked(pingWithCredentialsForProfile).mockReturnValueOnce(
new Promise(r => {
resolvePing = r;
}),
@@ -338,7 +339,7 @@ describe('pickReachableBaseUrl', () => {
const p2 = pickReachableBaseUrl(profile);
// Both calls saw a pending probe — only one ping should have been fired.
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
resolvePing!(pingOk());
const [r1, r2] = await Promise.all([p1, p2]);
@@ -354,13 +355,13 @@ describe('pickReachableBaseUrl', () => {
it('starts a fresh probe after the in-flight one settles', async () => {
// Once the previous probe resolves, the in-flight slot is freed and
// the next call hits the network again (subject to the sticky cache).
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' }));
vi.mocked(pingWithCredentials).mockClear();
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
vi.mocked(pingWithCredentialsForProfile).mockClear();
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' }));
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
});
it('falls back to the natural order if the cached endpoint stops answering', async () => {
@@ -368,12 +369,12 @@ describe('pickReachableBaseUrl', () => {
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10',
});
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile);
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
// LAN now fails; public answers.
vi.mocked(pingWithCredentials).mockClear();
vi.mocked(pingWithCredentialsForProfile).mockClear();
vi.useFakeTimers();
mockDualAddressLanFailPublicOk();
const fallbackPromise = pickReachableBaseUrl(profile);
@@ -388,13 +389,13 @@ describe('pickReachableBaseUrl', () => {
describe('invalidateReachableEndpointCache', () => {
beforeEach(() => {
invalidateReachableEndpointCache();
vi.mocked(pingWithCredentials).mockReset();
vi.mocked(pingWithCredentialsForProfile).mockReset();
});
it('clears a specific profile', async () => {
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await ensureConnectUrlResolved(makeProfile({ id: 'a' }));
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await ensureConnectUrlResolved(makeProfile({ id: 'b' }));
expect(getCachedConnectBaseUrl('a')).not.toBeNull();
expect(getCachedConnectBaseUrl('b')).not.toBeNull();
@@ -405,7 +406,7 @@ describe('invalidateReachableEndpointCache', () => {
});
it('clears everything when called with no argument', async () => {
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await ensureConnectUrlResolved(makeProfile({ id: 'a' }));
invalidateReachableEndpointCache();
expect(getCachedConnectBaseUrl('a')).toBeNull();
@@ -415,7 +416,7 @@ describe('invalidateReachableEndpointCache', () => {
describe('subscribeConnectCache — connect-URL flip notifications', () => {
beforeEach(() => {
invalidateReachableEndpointCache();
vi.mocked(pingWithCredentials).mockReset();
vi.mocked(pingWithCredentialsForProfile).mockReset();
});
it('notifies when a probe resolves a new endpoint and on a later flip', async () => {
@@ -427,7 +428,7 @@ describe('subscribeConnectCache — connect-URL flip notifications', () => {
});
// First probe: LAN answers → cache set → one notification.
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile);
expect(listener).toHaveBeenCalledTimes(1);
@@ -444,13 +445,13 @@ describe('subscribeConnectCache — connect-URL flip notifications', () => {
it('does not notify when the sticky endpoint is unchanged', async () => {
const profile = makeProfile({ url: 'http://192.168.0.10' });
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile);
const listener = vi.fn();
const unsubscribe = subscribeConnectCache(listener);
// Re-probe, same endpoint answers → cache value identical → no notification.
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile);
expect(listener).not.toHaveBeenCalled();
@@ -458,7 +459,7 @@ describe('subscribeConnectCache — connect-URL flip notifications', () => {
});
it('notifies on explicit cache invalidation when an entry existed', async () => {
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(makeProfile({ id: 'a' }));
const listener = vi.fn();
+6 -7
View File
@@ -1,4 +1,4 @@
import { pingWithCredentials } from '../../api/subsonic';
import { pingWithCredentialsForProfile } from '../../api/subsonic';
import type { PingWithCredentialsResult } from '../../api/subsonicTypes';
import type { ServerProfile } from '../../store/authStoreTypes';
import { serverProfileBaseUrl } from './serverBaseUrl';
@@ -261,15 +261,14 @@ function sleepMs(ms: number): Promise<void> {
* proxy TLS flakes) before the connection indicator marks the server down.
*/
async function pingWithConnectRetries(
baseUrl: string,
username: string,
password: string,
profile: ServerProfile,
endpointUrl: string,
): Promise<PingWithCredentialsResult> {
let ping = await pingWithCredentials(baseUrl, username, password);
let ping = await pingWithCredentialsForProfile(profile, endpointUrl);
if (ping.ok) return ping;
for (let retry = 0; retry < CONNECT_PING_RETRIES; retry++) {
await sleepMs(CONNECT_PING_RETRY_DELAY_MS);
ping = await pingWithCredentials(baseUrl, username, password);
ping = await pingWithCredentialsForProfile(profile, endpointUrl);
if (ping.ok) return ping;
}
return ping;
@@ -309,7 +308,7 @@ export async function pickReachableBaseUrl(
: ordered;
for (const endpoint of endpoints) {
const ping = await pingWithConnectRetries(endpoint.url, profile.username, profile.password);
const ping = await pingWithConnectRetries(profile, endpoint.url);
if (ping.ok) {
const prev = connectCache.get(profile.id);
connectCache.set(profile.id, endpoint.url);
+62 -9
View File
@@ -15,9 +15,16 @@
*/
import md5 from 'md5';
import { apiWithCredentials, restBaseFromUrl, secureRandomSalt, SUBSONIC_CLIENT } from '../../api/subsonicClient';
import {
apiWithCredentials,
restBaseFromUrl,
secureRandomSalt,
SUBSONIC_CLIENT,
type ServerHttpHeaderProfile,
} from '../../api/subsonicClient';
import type { ServerProfile } from '../../store/authStoreTypes';
import { allNormalizedAddresses } from './serverEndpoint';
import { headersForServerRequest } from './serverHttpHeaders';
export type ServerFingerprint = {
ping: {
@@ -55,6 +62,7 @@ async function fetchPingFingerprint(
baseUrl: string,
username: string,
password: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<PingFingerprint> {
// Mirrors pingWithCredentials but also extracts envelope `version`.
// Using fetch (the bundled axios pulls in extra noise here; one call is fine).
@@ -69,9 +77,18 @@ async function fetchPingFingerprint(
f: 'json',
});
const url = `${restBaseFromUrl(baseUrl)}/ping.view?${params.toString()}`;
const profileForHeaders: ServerHttpHeaderProfile = headerProfile ?? {
url: baseUrl,
alternateUrl: undefined,
customHeaders: undefined,
customHeadersApplyTo: undefined,
};
let body: Record<string, unknown> | null = null;
try {
const resp = await fetch(url, { method: 'GET' });
const resp = await fetch(url, {
method: 'GET',
headers: headersForServerRequest(profileForHeaders, baseUrl),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const json = (await resp.json()) as Record<string, unknown>;
body = (json?.['subsonic-response'] as Record<string, unknown>) ?? null;
@@ -171,12 +188,13 @@ export async function fetchServerFingerprint(
baseUrl: string,
username: string,
password: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<ServerFingerprint> {
// Ping is required — but we still build a (mostly-null) fingerprint when
// it fails so callers can tell the difference between "server unreachable"
// (verify reports `unreachable`) and "server reachable but disagrees"
// (verify reports `mismatch` / `insufficient`).
const ping = await fetchPingFingerprint(baseUrl, username, password);
const ping = await fetchPingFingerprint(baseUrl, username, password, headerProfile);
// The optional calls only make sense once ping succeeded — without that,
// any subsequent call against the same URL is just wasted bandwidth.
@@ -196,10 +214,42 @@ export async function fetchServerFingerprint(
}
const settled = await Promise.allSettled([
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getMusicFolders.view'),
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getUser.view', { username }),
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getLicense.view'),
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getIndexes.view'),
apiWithCredentials<Record<string, unknown>>(
baseUrl,
username,
password,
'getMusicFolders.view',
{},
15000,
headerProfile,
),
apiWithCredentials<Record<string, unknown>>(
baseUrl,
username,
password,
'getUser.view',
{ username },
15000,
headerProfile,
),
apiWithCredentials<Record<string, unknown>>(
baseUrl,
username,
password,
'getLicense.view',
{},
15000,
headerProfile,
),
apiWithCredentials<Record<string, unknown>>(
baseUrl,
username,
password,
'getIndexes.view',
{},
15000,
headerProfile,
),
]);
const [foldersResult, userResult, licenseResult, indexesResult] = settled;
@@ -299,7 +349,10 @@ function musicFoldersEqual(
* `ok: true` nothing to verify.
*/
export async function verifySameServerEndpoints(
profile: Pick<ServerProfile, 'url' | 'alternateUrl'>,
profile: Pick<
ServerProfile,
'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
>,
username: string,
password: string,
): Promise<VerifySameServerResult> {
@@ -307,7 +360,7 @@ export async function verifySameServerEndpoints(
if (endpoints.length <= 1) return { ok: true };
const fingerprints = await Promise.all(
endpoints.map(baseUrl => fetchServerFingerprint(baseUrl, username, password)),
endpoints.map(baseUrl => fetchServerFingerprint(baseUrl, username, password, profile)),
);
// If any ping failed → unreachable (with the offending host for the UI).
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
headersForServerRequest,
requestBaseUrlFromHttpUrl,
validateCustomHeaders,
} from './serverHttpHeaders';
describe('requestBaseUrlFromHttpUrl', () => {
it('strips /rest/ path and query from stream URLs', () => {
expect(
requestBaseUrlFromHttpUrl(
'https://music.example.com/rest/stream.view?id=1&u=x&t=y&s=z',
),
).toBe('https://music.example.com');
});
it('strips /api/ Navidrome paths', () => {
expect(requestBaseUrlFromHttpUrl('https://nd.local/api/album')).toBe('https://nd.local');
});
});
describe('headersForServerRequest', () => {
const profile = {
url: 'https://music.example.com',
alternateUrl: 'http://192.168.1.10:4533',
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'secret' }],
customHeadersApplyTo: 'public' as const,
};
it('applies headers on public endpoint only when applyTo is public', () => {
expect(headersForServerRequest(profile, 'https://music.example.com')).toEqual({
'CF-Access-Client-Secret': 'secret',
});
expect(headersForServerRequest(profile, 'http://192.168.1.10:4533')).toEqual({});
});
it('returns empty for foreign base URL', () => {
expect(headersForServerRequest(profile, 'https://other.example.com')).toEqual({});
});
});
describe('validateCustomHeaders', () => {
it('rejects blocked header names', () => {
const result = validateCustomHeaders([{ name: 'Host', value: 'x' }]);
expect(result.ok).toBe(false);
});
it('accepts valid rows', () => {
expect(
validateCustomHeaders([{ name: 'X-Custom', value: 'ok' }]),
).toEqual({ ok: true });
});
});
+152
View File
@@ -0,0 +1,152 @@
import type {
CustomHeaderEntry,
CustomHeadersApplyTo,
CustomHeadersFieldError,
CustomHeadersValidationResult,
ServerProfile,
} from '../../store/authStoreTypes';
import { serverIndexKeyForProfile } from './serverIndexKey';
import { normalizeServerBaseUrl, serverAddressEndpoints, type ServerEndpointKind } from './serverEndpoint';
export const DEFAULT_CUSTOM_HEADERS_APPLY_TO: CustomHeadersApplyTo = 'public';
export const CUSTOM_HEADER_NAME_BLOCKLIST = new Set([
'host',
'content-length',
'transfer-encoding',
'connection',
'cookie',
]);
const MAX_CUSTOM_HEADERS = 16;
const MAX_HEADER_NAME_LEN = 256;
const MAX_HEADER_VALUE_LEN = 8192;
export function normalizeHeaderName(name: string): string {
return name.trim();
}
export function requestBaseUrlFromHttpUrl(rawUrl: string): string {
const trimmed = rawUrl.trim();
if (!trimmed) return '';
try {
const parsed = new URL(trimmed.startsWith('http') ? trimmed : `http://${trimmed}`);
parsed.search = '';
parsed.hash = '';
let path = parsed.pathname;
const restIdx = path.indexOf('/rest/');
if (restIdx >= 0 || path.endsWith('/rest')) {
path = path.slice(0, restIdx >= 0 ? restIdx : path.length - '/rest'.length);
} else {
for (const seg of ['/api/', '/auth/'] as const) {
const idx = path.indexOf(seg);
if (idx >= 0) {
path = path.slice(0, idx);
break;
}
}
}
if (path.endsWith('/') && path.length > 1) path = path.replace(/\/+$/, '');
parsed.pathname = path || '/';
const origin = `${parsed.protocol}//${parsed.host}${path === '/' ? '' : path}`;
return normalizeServerBaseUrl(origin);
} catch {
return normalizeServerBaseUrl(trimmed);
}
}
export function validateCustomHeaders(
headers: CustomHeaderEntry[] | undefined,
): CustomHeadersValidationResult {
if (!headers?.length) return { ok: true };
if (headers.length > MAX_CUSTOM_HEADERS) {
return {
ok: false,
fieldErrors: [{ index: 0, field: 'name', messageKey: 'settings.customHeadersValidation.tooMany' }],
};
}
const fieldErrors: CustomHeadersFieldError[] = [];
const seen = new Set<string>();
headers.forEach((row, index) => {
const name = normalizeHeaderName(row.name);
const value = row.value;
if (!name) {
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.nameRequired' });
return;
}
if (name.length > MAX_HEADER_NAME_LEN) {
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.nameTooLong' });
}
if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) {
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.crlf' });
}
if (CUSTOM_HEADER_NAME_BLOCKLIST.has(name.toLowerCase())) {
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.blocked' });
}
const key = name.toLowerCase();
if (seen.has(key)) {
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.duplicate' });
}
seen.add(key);
if (value.length > MAX_HEADER_VALUE_LEN) {
fieldErrors.push({ index, field: 'value', messageKey: 'settings.customHeadersValidation.valueTooLong' });
}
});
if (fieldErrors.length) return { ok: false, fieldErrors };
return { ok: true };
}
function headersRecord(entries: CustomHeaderEntry[]): Record<string, string> {
const out: Record<string, string> = {};
for (const row of entries) {
const name = normalizeHeaderName(row.name);
if (!name) continue;
out[name] = row.value;
}
return out;
}
export function headersForServerRequest(
profile: Pick<
ServerProfile,
'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
>,
requestBaseUrl: string,
): Record<string, string> {
if (!profile.customHeaders?.length) return {};
const normalized = normalizeServerBaseUrl(requestBaseUrl);
const endpoint = serverAddressEndpoints(profile).find(e => e.url === normalized);
if (!endpoint) return {};
const apply = profile.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO;
if (apply === 'both' || apply === endpoint.kind) {
return headersRecord(profile.customHeaders);
}
return {};
}
export type ServerHttpEndpointWire = {
url: string;
kind: ServerEndpointKind;
};
/** Payload for Rust registry sync — endpoint kinds from TS dual-address layer. */
export function serverHttpContextWireForProfile(
server: Pick<
ServerProfile,
'id' | 'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
>,
): {
serverId: string;
appServerId: string;
endpoints: ServerHttpEndpointWire[];
customHeaders: CustomHeaderEntry[];
customHeadersApplyTo: CustomHeadersApplyTo;
} {
return {
serverId: serverIndexKeyForProfile(server),
appServerId: server.id,
endpoints: serverAddressEndpoints(server).map(e => ({ url: e.url, kind: e.kind })),
customHeaders: server.customHeaders ?? [],
customHeadersApplyTo: server.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO,
};
}
+2
View File
@@ -10,6 +10,7 @@ import { flushPlayQueueForServer } from '../../store/queueSync';
import { markQueueHandoffPending } from '../../store/queueSyncUiState';
import { endOrbitSession, leaveOrbitSession } from '../orbit';
import { ensureConnectUrlResolved } from './serverEndpoint';
import { syncServerHttpContextForProfile } from './syncServerHttpContext';
export async function switchActiveServer(server: ServerProfile): Promise<boolean> {
coverTrafficBeginServerSwitch();
@@ -56,6 +57,7 @@ export async function switchActiveServer(server: ServerProfile): Promise<boolean
if (oldActiveId && oldActiveId !== server.id) {
markQueueHandoffPending();
}
void syncServerHttpContextForProfile(server);
return true;
} catch {
return false;
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const invokeMock = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }));
import { syncServerHttpContextForProfile } from './syncServerHttpContext';
const server = {
id: 'app-uuid-1',
name: 'Gated',
url: 'https://music.example.com',
username: 'u',
password: 'p',
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'secret' }],
customHeadersApplyTo: 'public' as const,
};
describe('syncServerHttpContextForProfile', () => {
beforeEach(() => {
invokeMock.mockClear();
});
it('passes the wire payload under the wire key for Tauri struct args', async () => {
await syncServerHttpContextForProfile(server);
expect(invokeMock).toHaveBeenCalledTimes(1);
expect(invokeMock).toHaveBeenCalledWith('server_http_context_sync', {
wire: expect.objectContaining({
serverId: expect.any(String),
appServerId: 'app-uuid-1',
customHeaders: server.customHeaders,
customHeadersApplyTo: 'public',
}),
});
});
});
+21
View File
@@ -0,0 +1,21 @@
import { invoke } from '@tauri-apps/api/core';
import type { ServerProfile } from '../../store/authStoreTypes';
import { serverHttpContextWireForProfile } from './serverHttpHeaders';
import { serverIndexKeyForProfile } from './serverIndexKey';
export async function syncServerHttpContextForProfile(server: ServerProfile): Promise<void> {
const wire = serverHttpContextWireForProfile(server);
await invoke('server_http_context_sync', { wire });
}
export async function syncAllServerHttpContexts(servers: ServerProfile[]): Promise<void> {
if (servers.length === 0) return;
await invoke('server_http_context_sync_all', {
entries: servers.map(s => serverHttpContextWireForProfile(s)),
});
}
export async function clearServerHttpContext(server: Pick<ServerProfile, 'id' | 'url'>): Promise<void> {
const indexKey = serverIndexKeyForProfile(server);
await invoke('server_http_context_clear', { serverId: indexKey, appServerId: server.id });
}
@@ -133,6 +133,7 @@ describe('share search payload resolution', () => {
sharedServer.username,
sharedServer.password,
'song-1',
sharedServer,
);
expect(mocks.getSong).not.toHaveBeenCalled();
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
@@ -147,12 +148,14 @@ describe('share search payload resolution', () => {
sharedServer.username,
sharedServer.password,
'album-1',
sharedServer,
);
expect(mocks.getArtistWithCredentials).toHaveBeenCalledWith(
sharedServer.url,
sharedServer.username,
sharedServer.password,
'artist-1',
sharedServer,
);
expect(mocks.getAlbum).not.toHaveBeenCalled();
expect(mocks.getArtist).not.toHaveBeenCalled();
@@ -172,6 +175,7 @@ describe('share search payload resolution', () => {
sharedServer.username,
sharedServer.password,
'composer-1',
sharedServer,
);
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
});
@@ -110,6 +110,7 @@ async function resolveSharedSong(
lookup.server.username,
lookup.server.password,
id,
lookup.server,
);
}
@@ -187,6 +188,7 @@ export async function resolveShareSearchAlbum(
lookup.server.username,
lookup.server.password,
payload.id,
lookup.server,
);
return { type: 'ok', album };
} catch {
@@ -214,6 +216,7 @@ export async function resolveShareSearchArtist(
lookup.server.username,
lookup.server.password,
payload.id,
lookup.server,
);
return { type: 'ok', artist };
} catch {