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. * 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 ## Changed
+2
View File
@@ -4219,8 +4219,10 @@ name = "psysonic-core"
version = "1.49.0-dev" version = "1.49.0-dev"
dependencies = [ dependencies = [
"libc", "libc",
"reqwest",
"serde", "serde",
"tauri", "tauri",
"url",
] ]
[[package]] [[package]]
@@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex, OnceLock};
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
use psysonic_core::ports::PlaybackQueryHandle; 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::user_agent::subsonic_wire_user_agent;
use psysonic_core::track_enrichment::TrackEnrichmentOutcome; 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 fetch_started = std::time::Instant::now();
let response = analysis_http_client() let registry = app
.get(url) .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() .send()
.await .await
.map_err(|e| e.to_string())?; .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 app = app.clone();
let shared = shared.clone(); let shared = shared.clone();
tauri::async_runtime::spawn(async move { 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 let mut st = shared
.state .state
@@ -11,7 +11,7 @@ use rodio::Source;
use tauri::{AppHandle, Emitter, State}; use tauri::{AppHandle, Emitter, State};
use super::decode::build_source; use super::decode::build_source;
use super::engine::{audio_http_client, AudioEngine}; use super::engine::AudioEngine;
use super::helpers::*; use super::helpers::*;
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload}; use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
use super::play_input::{select_play_input, url_format_hint, PlayInputContext}; 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://") { } else if let Some(path) = url.strip_prefix("psysonic-local://") {
tokio::fs::read(path).await.map_err(|e| e.to_string())? tokio::fs::read(path).await.map_err(|e| e.to_string())?
} else { } 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())?; .map_err(|e| e.to_string())?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Ok(()); // silently fail — audio_play will retry return Ok(()); // silently fail — audio_play will retry
@@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use rodio::Player; use rodio::Player;
use tauri::Manager;
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill}; use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
@@ -570,3 +571,83 @@ pub fn refresh_http_user_agent(state: &AudioEngine, ua: &str) {
*slot = client; *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)); 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 status = response.status();
let ct = response.headers() let ct = response.headers()
.get(reqwest::header::CONTENT_TYPE) .get(reqwest::header::CONTENT_TYPE)
@@ -15,7 +15,7 @@ use super::analysis_dispatch::{
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file, prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
TrackAnalysisOrigin, TrackAnalysisOrigin,
}; };
use super::engine::{audio_http_client, AudioEngine}; use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders};
use super::helpers::{ use super::helpers::{
content_type_to_hint, fetch_data, format_hint_from_content_disposition, content_type_to_hint, fetch_data, format_hint_from_content_disposition,
normalize_stream_suffix_for_hint, sniff_stream_format_extension, normalize_stream_suffix_for_hint, sniff_stream_format_extension,
@@ -217,7 +217,12 @@ async fn open_ranged_or_streaming_input(
state: &State<'_, AudioEngine>, state: &State<'_, AudioEngine>,
app: &AppHandle, app: &AppHandle,
) -> Result<Option<PlayInput>, String> { ) -> 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 !response.status().is_success() {
if state.generation.load(Ordering::SeqCst) != ctx.gen { if state.generation.load(Ordering::SeqCst) != ctx.gen {
return Ok(None); // superseded return Ok(None); // superseded
@@ -256,8 +261,8 @@ async fn open_ranged_or_streaming_input(
let last = total_u64 let last = total_u64
.saturating_sub(1) .saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64); .min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = audio_http_client(state) if let Ok(pr) = http_headers
.get(ctx.url) .apply(ctx.url, audio_http_client(state).get(ctx.url))
.header(reqwest::header::RANGE, format!("bytes=0-{last}")) .header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send() .send()
.await .await
@@ -328,6 +333,7 @@ async fn open_ranged_or_streaming_input(
state.loudness_pre_analysis_attenuation_db.clone(), state.loudness_pre_analysis_attenuation_db.clone(),
ctx.cache_id_for_tasks.map(|s| s.to_string()), ctx.cache_id_for_tasks.map(|s| s.to_string()),
ctx.server_id.map(|s| s.to_string()), ctx.server_id.map(|s| s.to_string()),
http_headers.clone(),
loudness_hold_for_defer, loudness_hold_for_defer,
playback_armed, playback_armed,
stream_hint.clone(), stream_hint.clone(),
@@ -347,6 +353,7 @@ async fn open_ranged_or_streaming_input(
total, total,
state.generation.clone(), state.generation.clone(),
ctx.gen, ctx.gen,
http_headers.clone(),
))); )));
let reader = RangedHttpSource { let reader = RangedHttpSource {
buf, buf,
@@ -401,6 +408,7 @@ async fn open_ranged_or_streaming_input(
state.loudness_pre_analysis_attenuation_db.clone(), state.loudness_pre_analysis_attenuation_db.clone(),
ctx.cache_id_for_tasks.map(|s| s.to_string()), ctx.cache_id_for_tasks.map(|s| s.to_string()),
ctx.server_id.map(|s| s.to_string()), ctx.server_id.map(|s| s.to_string()),
http_headers,
playback_armed, playback_armed,
)); ));
@@ -16,7 +16,7 @@ use super::analysis_dispatch::{
dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file, dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file,
TrackAnalysisOrigin, TrackAnalysisOrigin,
}; };
use super::engine::{audio_http_client, AudioEngine}; use super::engine::AudioEngine;
use super::helpers::{analysis_cache_track_id, same_playback_target}; use super::helpers::{analysis_cache_track_id, same_playback_target};
use super::state::PreloadedTrack; 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() { if !response.status().is_success() {
emit_preload_cancelled(&app, url, track_id_for_events); emit_preload_cancelled(&app, url, track_id_for_events);
return Ok(()); return Ok(());
@@ -8,7 +8,7 @@ use rodio::Source;
use tauri::{AppHandle, Emitter, State}; use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder; use super::decode::SizedDecoder;
use super::engine::{audio_http_client, AudioEngine}; use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders};
use super::helpers::{ use super::helpers::{
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint, 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, resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
@@ -155,9 +155,10 @@ async fn open_preview_decoder(
state: &AudioEngine, state: &AudioEngine,
app: &AppHandle, app: &AppHandle,
) -> Result<Option<SizedDecoder>, String> { ) -> Result<Option<SizedDecoder>, String> {
let http_headers = PlaybackHttpHeaders::from_app(app, None);
let preview_http = preview_http_client(state); let preview_http = preview_http_client(state);
let response = preview_http let response = http_headers
.get(url) .apply(url, preview_http.get(url))
.send() .send()
.await .await
.map_err(|e| format!("preview: connection failed: {e}"))? .map_err(|e| format!("preview: connection failed: {e}"))?
@@ -194,8 +195,8 @@ async fn open_preview_decoder(
let last = total_u64 let last = total_u64
.saturating_sub(1) .saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64); .min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = preview_http if let Ok(pr) = http_headers
.get(url) .apply(url, preview_http.get(url))
.header(reqwest::header::RANGE, format!("bytes=0-{last}")) .header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send() .send()
.await .await
@@ -257,6 +258,7 @@ async fn open_preview_decoder(
state.loudness_pre_analysis_attenuation_db.clone(), state.loudness_pre_analysis_attenuation_db.clone(),
None, None,
None, None,
http_headers.clone(),
None, None,
playback_armed, playback_armed,
stream_hint.clone(), stream_hint.clone(),
@@ -21,6 +21,7 @@ use futures_util::StreamExt;
use symphonia::core::io::MediaSource; use symphonia::core::io::MediaSource;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use super::super::engine::PlaybackHttpHeaders;
use super::super::state::PreloadedTrack; use super::super::state::PreloadedTrack;
use super::{ use super::{
RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS, 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 /// Bumped after every completed (success or failure) fetch so the read loop
/// can reset its stall deadline while on-demand fetches make progress. /// can reset its stall deadline while on-demand fetches make progress.
progress: AtomicU64, progress: AtomicU64,
http_headers: PlaybackHttpHeaders,
} }
impl OnDemand { impl OnDemand {
@@ -100,6 +102,7 @@ impl OnDemand {
total_size: u64, total_size: u64,
gen_arc: Arc<AtomicU64>, gen_arc: Arc<AtomicU64>,
gen: u64, gen: u64,
http_headers: PlaybackHttpHeaders,
) -> Self { ) -> Self {
OnDemand { OnDemand {
http, http,
@@ -112,6 +115,7 @@ impl OnDemand {
filled: Mutex::new(Vec::new()), filled: Mutex::new(Vec::new()),
inflight: Mutex::new(Vec::new()), inflight: Mutex::new(Vec::new()),
progress: AtomicU64::new(0), progress: AtomicU64::new(0),
http_headers,
} }
} }
@@ -154,6 +158,7 @@ impl OnDemand {
end_inclusive, end_inclusive,
me.gen, me.gen,
&me.gen_arc, &me.gen_arc,
&me.http_headers,
) )
.await; .await;
if let Ok(written) = res { if let Ok(written) = res {
@@ -367,6 +372,7 @@ pub(crate) async fn ranged_http_download_loop<F>(
downloaded_to: &Arc<AtomicUsize>, downloaded_to: &Arc<AtomicUsize>,
gen: u64, gen: u64,
gen_arc: &Arc<AtomicU64>, gen_arc: &Arc<AtomicU64>,
http_headers: &PlaybackHttpHeaders,
mut on_partial: F, mut on_partial: F,
playback_armed: Option<&AtomicBool>, playback_armed: Option<&AtomicBool>,
) -> (usize, RangedHttpLoopOutcome) ) -> (usize, RangedHttpLoopOutcome)
@@ -387,6 +393,7 @@ where
if downloaded > 0 { if downloaded > 0 {
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-")); req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
} }
req = http_headers.apply(url, req);
match req.send().await { match req.send().await {
Ok(r) => r, Ok(r) => r,
Err(err) => { Err(err) => {
@@ -487,6 +494,7 @@ where
} }
/// Fetch `bytes=start-end` into `buf[start..=end]` (inclusive HTTP Range). /// Fetch `bytes=start-end` into `buf[start..=end]` (inclusive HTTP Range).
#[allow(clippy::too_many_arguments)]
async fn ranged_write_http_range( async fn ranged_write_http_range(
http_client: &reqwest::Client, http_client: &reqwest::Client,
url: &str, url: &str,
@@ -495,13 +503,18 @@ async fn ranged_write_http_range(
end_inclusive: u64, end_inclusive: u64,
gen: u64, gen: u64,
gen_arc: &Arc<AtomicU64>, gen_arc: &Arc<AtomicU64>,
http_headers: &PlaybackHttpHeaders,
) -> Result<usize, ()> { ) -> Result<usize, ()> {
if gen_arc.load(Ordering::SeqCst) != gen { if gen_arc.load(Ordering::SeqCst) != gen {
return Err(()); return Err(());
} }
let response = http_client let response = http_headers
.apply(
url,
http_client
.get(url) .get(url)
.header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}")) .header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}")),
)
.send() .send()
.await .await
.map_err(|_| ())?; .map_err(|_| ())?;
@@ -555,6 +568,7 @@ async fn ranged_prefetch_mp4_tail(
playback_armed: Arc<AtomicBool>, playback_armed: Arc<AtomicBool>,
gen: u64, gen: u64,
gen_arc: Arc<AtomicU64>, gen_arc: Arc<AtomicU64>,
http_headers: PlaybackHttpHeaders,
) { ) {
const MIN_TAIL: u64 = 256 * 1024; const MIN_TAIL: u64 = 256 * 1024;
const MAX_TAIL: u64 = 8 * 1024 * 1024; const MAX_TAIL: u64 = 8 * 1024 * 1024;
@@ -573,6 +587,7 @@ async fn ranged_prefetch_mp4_tail(
end_inclusive, end_inclusive,
gen, gen,
&gen_arc, &gen_arc,
&http_headers,
) )
.await .await
{ {
@@ -622,6 +637,7 @@ pub(crate) async fn ranged_download_task(
cache_track_id: Option<String>, cache_track_id: Option<String>,
// Playback server scope for the analysis-cache write key (empty/`None` → legacy ''). // Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
server_id: Option<String>, server_id: Option<String>,
http_headers: PlaybackHttpHeaders,
// When `Some`, ranged playback seeds on completion — defer HTTP backfill for that // When `Some`, ranged playback seeds on completion — defer HTTP backfill for that
// track; `None` for large files where ranged skips seed (needs backfill). // track; `None` for large files where ranged skips seed (needs backfill).
loudness_seed_hold: Option<LoudnessSeedHold>, 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 tail_from_bg = tail_filled_from.clone();
let armed_bg = playback_armed.clone(); let armed_bg = playback_armed.clone();
let gen_bg = gen_arc.clone(); let gen_bg = gen_arc.clone();
let headers_bg = http_headers.clone();
Some(tokio::spawn(async move { Some(tokio::spawn(async move {
ranged_prefetch_mp4_tail( ranged_prefetch_mp4_tail(
client, client,
@@ -716,6 +733,7 @@ pub(crate) async fn ranged_download_task(
armed_bg, armed_bg,
gen, gen,
gen_bg, gen_bg,
headers_bg,
) )
.await; .await;
})) }))
@@ -736,6 +754,7 @@ pub(crate) async fn ranged_download_task(
&downloaded_to, &downloaded_to,
gen, gen,
&gen_arc, &gen_arc,
&http_headers,
on_partial, on_partial,
linear_arm, linear_arm,
) )
@@ -1127,6 +1146,7 @@ mod tests {
&dl, &dl,
1, 1,
&gen_arc, &gen_arc,
&PlaybackHttpHeaders::default(),
|_, _| {}, |_, _| {},
None, None,
) )
@@ -1162,6 +1182,7 @@ mod tests {
&dl, &dl,
1, 1,
&gen_arc, &gen_arc,
&PlaybackHttpHeaders::default(),
|downloaded, total| calls.lock().unwrap().push((downloaded, total)), |downloaded, total| calls.lock().unwrap().push((downloaded, total)),
None, None,
) )
@@ -1190,7 +1211,7 @@ mod tests {
let (buf, dl, gen_arc) = loop_state(1024); let (buf, dl, gen_arc) = loop_state(1024);
let (downloaded, outcome) = 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; .await;
assert_eq!(outcome, RangedHttpLoopOutcome::Aborted); assert_eq!(outcome, RangedHttpLoopOutcome::Aborted);
@@ -1221,7 +1242,7 @@ mod tests {
gen_arc.store(99, Ordering::SeqCst); gen_arc.store(99, Ordering::SeqCst);
let (downloaded, outcome) = 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; .await;
assert_eq!(outcome, RangedHttpLoopOutcome::Superseded); assert_eq!(outcome, RangedHttpLoopOutcome::Superseded);
@@ -1280,7 +1301,7 @@ mod tests {
let (buf, dl, gen_arc) = loop_state(body.len()); let (buf, dl, gen_arc) = loop_state(body.len());
let (downloaded, outcome) = 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; .await;
// Stream finishes via a Range-resumed second request. // Stream finishes via a Range-resumed second request.
@@ -1354,6 +1375,7 @@ mod tests {
total as u64, total as u64,
gen_arc.clone(), gen_arc.clone(),
1, 1,
PlaybackHttpHeaders::default(),
))); )));
let mut src = RangedHttpSource { let mut src = RangedHttpSource {
buf, buf,
@@ -1406,6 +1428,7 @@ mod tests {
2047, 2047,
1, 1,
&gen_arc, &gen_arc,
&PlaybackHttpHeaders::default(),
) )
.await; .await;
@@ -1440,7 +1463,7 @@ mod tests {
let (buf, dl, gen_arc) = loop_state(body.len()); let (buf, dl, gen_arc) = loop_state(body.len());
let (downloaded, outcome) = 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; .await;
// Reconnect server returned 200 instead of 206 → Aborted, downloaded // Reconnect server returned 200 instead of 206 → Aborted, downloaded
@@ -15,6 +15,7 @@ use ringbuf::HeapProd;
use ringbuf::traits::Producer; use ringbuf::traits::Producer;
use tauri::AppHandle; use tauri::AppHandle;
use super::super::engine::PlaybackHttpHeaders;
use super::super::state::PreloadedTrack; use super::super::state::PreloadedTrack;
use super::{ use super::{
maybe_arm_stream_playback, TRACK_STREAM_MAX_RECONNECTS, TRACK_STREAM_PROMOTE_MAX_BYTES, 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>, cache_track_id: Option<String>,
// Playback server scope for the analysis-cache write key (empty/`None` → legacy ''). // Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
server_id: Option<String>, server_id: Option<String>,
http_headers: PlaybackHttpHeaders,
playback_armed: Arc<AtomicBool>, playback_armed: Arc<AtomicBool>,
) { ) {
let mut downloaded: u64 = 0; let mut downloaded: u64 = 0;
@@ -53,6 +55,7 @@ pub(crate) async fn track_download_task(
if downloaded > 0 { if downloaded > 0 {
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-")); req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
} }
req = http_headers.apply(&url, req);
match req.send().await { match req.send().await {
Ok(r) => r, Ok(r) => r,
Err(err) => { Err(err) => {
@@ -9,6 +9,8 @@ publish = false
[dependencies] [dependencies]
tauri = { version = "2" } tauri = { version = "2" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
reqwest = { version = "0.13", default-features = false, features = ["rustls"] }
url = "2"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" libc = "0.2"
@@ -4,6 +4,7 @@
//! macros) and the cross-crate port traits used to break dependency cycles //! macros) and the cross-crate port traits used to break dependency cycles
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates. //! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
pub mod server_http;
pub mod cover_cache_layout; pub mod cover_cache_layout;
pub mod log_sanitize; pub mod log_sanitize;
pub mod media_layout; pub mod media_layout;
@@ -8,12 +8,14 @@ const SENSITIVE_QUERY_KEYS: &[&str] = &[
const SENSITIVE_KV_KEYS: &[&str] = &[ const SENSITIVE_KV_KEYS: &[&str] = &[
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token", "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. /// Sanitize one runtime log line for display and export.
pub fn sanitize_log_line(line: &str) -> String { pub fn sanitize_log_line(line: &str) -> String {
let mut out = redact_bearer_tokens(line); let mut out = redact_bearer_tokens(line);
out = redact_pangolin_headers(&out);
out = redact_sensitive_key_values(&out); out = redact_sensitive_key_values(&out);
out = redact_urls_in_text(&out); out = redact_urls_in_text(&out);
out out
@@ -43,6 +45,37 @@ fn redact_bearer_tokens(line: &str) -> String {
s 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 { fn redact_sensitive_key_values(line: &str) -> String {
let mut out = line.to_string(); let mut out = line.to_string();
for key in SENSITIVE_KV_KEYS { for key in SENSITIVE_KV_KEYS {
@@ -368,6 +401,17 @@ mod tests {
assert!(!out.contains("user:pass")); 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] #[test]
fn stream_log_with_em_dash_does_not_panic() { fn stream_log_with_em_dash_does_not_panic() {
let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")"; 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. //! Auth + retry + HTTP client for Navidrome's native REST API.
//! Used by every other navidrome submodule for `/auth/*` and `/api/*` calls. //! 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. /// 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> { 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 client = reqwest::Client::new();
let resp = client let base = server_url.trim_end_matches('/');
.post(format!("{}/auth/login", server_url)) let login_url = format!("{base}/auth/login");
.json(&serde_json::json!({ "username": username, "password": password })) let mut req = client
.send() .post(&login_url)
.await .json(&serde_json::json!({ "username": username, "password": password }));
.map_err(|e| e.to_string())?; 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())?; let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
data["token"] data["token"]
.as_str() .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()) .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`. /// Payload returned by Navidrome's `/auth/login`.
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
pub struct NdLoginResult { pub struct NdLoginResult {
@@ -2,10 +2,16 @@
//! login (via `navidrome_token`) and then a multipart POST to the relevant //! login (via `navidrome_token`) and then a multipart POST to the relevant
//! `/api/{playlist|radio|artist}/{id}/image` endpoint. //! `/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] #[tauri::command]
pub async fn upload_playlist_cover( pub async fn upload_playlist_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
playlist_id: String, playlist_id: String,
username: String, username: String,
@@ -13,16 +19,23 @@ pub async fn upload_playlist_cover(
file_bytes: Vec<u8>, file_bytes: Vec<u8>,
mime_type: String, mime_type: String,
) -> Result<(), 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) let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg") .file_name("cover.jpg")
.mime_str(&mime_type) .mime_str(&mime_type)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part); let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new() let url = format!("{}/api/playlist/{}/image", server_url, playlist_id);
.post(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)) .header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form) .multipart(form),
)
.send() .send()
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
@@ -33,6 +46,7 @@ pub async fn upload_playlist_cover(
#[tauri::command] #[tauri::command]
pub async fn upload_radio_cover( pub async fn upload_radio_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
radio_id: String, radio_id: String,
username: String, username: String,
@@ -40,16 +54,23 @@ pub async fn upload_radio_cover(
file_bytes: Vec<u8>, file_bytes: Vec<u8>,
mime_type: String, mime_type: String,
) -> Result<(), 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) let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg") .file_name("cover.jpg")
.mime_str(&mime_type) .mime_str(&mime_type)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part); let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new() let url = format!("{}/api/radio/{}/image", server_url, radio_id);
.post(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)) .header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form) .multipart(form),
)
.send() .send()
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
@@ -60,6 +81,7 @@ pub async fn upload_radio_cover(
#[tauri::command] #[tauri::command]
pub async fn upload_artist_image( pub async fn upload_artist_image(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
artist_id: String, artist_id: String,
username: String, username: String,
@@ -67,16 +89,23 @@ pub async fn upload_artist_image(
file_bytes: Vec<u8>, file_bytes: Vec<u8>,
mime_type: String, mime_type: String,
) -> Result<(), 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) let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg") .file_name("cover.jpg")
.mime_str(&mime_type) .mime_str(&mime_type)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part); let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new() let url = format!("{}/api/artist/{}/image", server_url, artist_id);
.post(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)) .header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form) .multipart(form),
)
.send() .send()
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
@@ -87,20 +116,31 @@ pub async fn upload_artist_image(
#[tauri::command] #[tauri::command]
pub async fn delete_radio_cover( pub async fn delete_radio_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
radio_id: String, radio_id: String,
username: String, username: String,
password: String, password: String,
) -> Result<(), String> { ) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?; let reg = http_registry.as_ref();
let resp = reqwest::Client::new() let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
.delete(format!("{}/api/radio/{}/image", server_url, radio_id)) let url = format!("{}/api/radio/{}/image", server_url, radio_id);
.header("X-ND-Authorization", format!("Bearer {}", token)) let resp = nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.delete(&url)
.header("X-ND-Authorization", format!("Bearer {}", token)),
)
.send() .send()
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// 404/503 = no image existed — treat as success // 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())?; resp.error_for_status().map_err(|e| e.to_string())?;
} }
Ok(()) Ok(())
@@ -11,4 +11,4 @@ pub mod probe;
pub mod queries; pub mod queries;
pub mod users; 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 //! payload is forwarded as-is so the frontend can compose any rule the
//! Navidrome version supports without backend changes. //! 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. /// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists.
#[tauri::command] #[tauri::command]
pub async fn nd_list_playlists( pub async fn nd_list_playlists(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
token: String, token: String,
smart: Option<bool>, smart: Option<bool>,
) -> Result<serde_json::Value, String> { ) -> 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 resp = nd_retry(|| {
let client = nd_http_client(); let base = base.clone();
let mut req = client let auth = auth.clone();
.get(format!("{}/api/playlist", server_url)) async move {
.header("X-ND-Authorization", format!("Bearer {}", token)); 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 { if let Some(s) = smart {
req = req.query(&[("smart", s)]); req = req.query(&[("smart", s)]);
} }
req.send() req.send().await
}
}) })
.await?; .await?;
if !resp.status().is_success() { if !resp.status().is_success() {
@@ -31,16 +48,31 @@ pub async fn nd_list_playlists(
/// POST `/api/playlist` — create playlist (supports smart rules payload). /// POST `/api/playlist` — create playlist (supports smart rules payload).
#[tauri::command] #[tauri::command]
pub async fn nd_create_playlist( pub async fn nd_create_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
token: String, token: String,
body: serde_json::Value, body: serde_json::Value,
) -> Result<serde_json::Value, String> { ) -> 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 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() nd_http_client()
.post(format!("{}/api/playlist", server_url)) .post(&url)
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", auth)
.json(&body) .json(&body),
)
.send() .send()
.await
}
}) })
.await?; .await?;
let status = resp.status(); let status = resp.status();
@@ -54,17 +86,32 @@ pub async fn nd_create_playlist(
/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload). /// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload).
#[tauri::command] #[tauri::command]
pub async fn nd_update_playlist( pub async fn nd_update_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
token: String, token: String,
id: String, id: String,
body: serde_json::Value, body: serde_json::Value,
) -> Result<serde_json::Value, 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 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() nd_http_client()
.put(format!("{}/api/playlist/{}", server_url, id)) .put(&url)
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", auth)
.json(&body) .json(&body),
)
.send() .send()
.await
}
}) })
.await?; .await?;
let status = resp.status(); 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). /// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available).
#[tauri::command] #[tauri::command]
pub async fn nd_get_playlist( pub async fn nd_get_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
token: String, token: String,
id: String, id: String,
) -> Result<serde_json::Value, 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 resp = nd_retry(|| {
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client() nd_http_client()
.get(format!("{}/api/playlist/{}", server_url, id)) .get(&url)
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", auth),
)
.send() .send()
.await
}
}) })
.await?; .await?;
let status = resp.status(); let status = resp.status();
@@ -100,15 +161,29 @@ pub async fn nd_get_playlist(
/// DELETE `/api/playlist/{id}` — delete playlist. /// DELETE `/api/playlist/{id}` — delete playlist.
#[tauri::command] #[tauri::command]
pub async fn nd_delete_playlist( pub async fn nd_delete_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
token: String, token: String,
id: String, id: String,
) -> Result<(), 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 resp = nd_retry(|| {
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client() nd_http_client()
.delete(format!("{}/api/playlist/{}", server_url, id)) .delete(&url)
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", auth),
)
.send() .send()
.await
}
}) })
.await?; .await?;
let status = resp.status(); let status = resp.status();
@@ -6,7 +6,7 @@
//! endpoint? — so this stays a free function rather than a client //! endpoint? — so this stays a free function rather than a client
//! struct. The full `nd_list_songs`-style ingest loop lands with PR-3b. //! 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 /// 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 /// 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 /// Spec §6.1 ties the result to the `NavidromeNativeBulk` capability
/// flag. Wider call into the actual ingest path (`nd_list_songs` port) /// flag. Wider call into the actual ingest path (`nd_list_songs` port)
/// is PR-3b's job. /// 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 client = nd_http_client();
let url = format!("{}/api/song?_start=0&_end=1", server_url.trim_end_matches('/')); let url = format!("{}/api/song?_start=0&_end=1", server_url.trim_end_matches('/'));
let resp = client let resp = nd_apply_request(
.get(url) registry,
.header("X-ND-Authorization", format!("Bearer {token}")) server_ref,
&url,
client
.get(&url)
.header("X-ND-Authorization", format!("Bearer {token}")),
)
.send() .send()
.await .await
.map_err(nd_err)?; .map_err(nd_err)?;
@@ -56,7 +66,7 @@ mod tests {
.mount(&server) .mount(&server)
.await; .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); assert!(ok);
} }
@@ -71,7 +81,7 @@ mod tests {
.mount(&server) .mount(&server)
.await; .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); assert!(!ok);
} }
@@ -84,7 +94,7 @@ mod tests {
.mount(&server) .mount(&server)
.await; .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`"); assert!(!ok, "401 reads as `endpoint not available for this caller`");
} }
@@ -97,7 +107,7 @@ mod tests {
.mount(&server) .mount(&server)
.await; .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")); assert!(err.contains("503"));
} }
@@ -111,6 +121,6 @@ mod tests {
.await; .await;
let with_slash = format!("{}/", server.uri()); 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, //! incompletely: songs, role-filtered artist/album lists, libraries,
//! per-user library assignment, and absolute song path resolution. //! 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 /// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated
/// song list. Pure async helper used by the library-side N1 ingest /// song list. Pure async helper used by the library-side N1 ingest
/// loop (spec §6.3, PR-3*); also wrapped by the `#[tauri::command]` /// loop (spec §6.3, PR-3*); also wrapped by the `#[tauri::command]`
/// variant below for existing frontend callers. /// variant below for existing frontend callers.
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_songs_internal( pub async fn nd_list_songs_internal(
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
server_url: &str, server_url: &str,
token: &str, token: &str,
sort: &str, sort: &str,
@@ -20,12 +28,24 @@ pub async fn nd_list_songs_internal(
"{}/api/song?_sort={}&_order={}&_start={}&_end={}", "{}/api/song?_sort={}&_order={}&_start={}&_end={}",
server_url, sort, order, start, end server_url, sort, order, start, end
); );
let auth = format!("Bearer {token}");
let resp = nd_retry(|| { let resp = nd_retry(|| {
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
registry,
server_ref,
&url,
nd_http_client() nd_http_client()
.get(&url) .get(&url)
.header("X-ND-Authorization", format!("Bearer {token}")) .header("X-ND-Authorization", auth),
)
.send() .send()
}).await?; .await
}
})
.await?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status())); 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. /// surface unchanged for existing call sites in the WebView.
#[tauri::command] #[tauri::command]
pub async fn nd_list_songs( pub async fn nd_list_songs(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
token: String, token: String,
sort: String, sort: String,
@@ -43,7 +64,17 @@ pub async fn nd_list_songs(
start: u32, start: u32,
end: u32, end: u32,
) -> Result<serde_json::Value, String> { ) -> 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 /// 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] #[tauri::command]
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn nd_list_artists_by_role( pub async fn nd_list_artists_by_role(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
token: String, token: String,
role: String, role: String,
@@ -79,14 +111,28 @@ pub async fn nd_list_artists_by_role(
end: u32, end: u32,
library_id: Option<String>, library_id: Option<String>,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let mut seed = serde_json::Map::new(); let mut seed = serde_json::Map::new();
seed.insert("role".to_string(), serde_json::Value::String(role.clone())); seed.insert("role".to_string(), serde_json::Value::String(role.clone()));
let filters = nd_build_filters(seed, library_id.as_deref()); let filters = nd_build_filters(seed, library_id.as_deref());
let start_s = start.to_string(); let start_s = start.to_string();
let end_s = end.to_string(); let end_s = end.to_string();
let base = format!("{}/api/artist", server_url);
let resp = nd_retry(|| { 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() nd_http_client()
.get(format!("{}/api/artist", server_url)) .get(&base)
.query(&[ .query(&[
("_filters", filters.as_str()), ("_filters", filters.as_str()),
("_sort", sort.as_str()), ("_sort", sort.as_str()),
@@ -94,9 +140,13 @@ pub async fn nd_list_artists_by_role(
("_start", start_s.as_str()), ("_start", start_s.as_str()),
("_end", end_s.as_str()), ("_end", end_s.as_str()),
]) ])
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", auth),
)
.send() .send()
}).await?; .await
}
})
.await?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status())); return Err(format!("HTTP {}", resp.status()));
} }
@@ -111,6 +161,7 @@ pub async fn nd_list_artists_by_role(
#[tauri::command] #[tauri::command]
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn nd_list_albums_by_artist_role( pub async fn nd_list_albums_by_artist_role(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
token: String, token: String,
artist_id: String, artist_id: String,
@@ -121,15 +172,29 @@ pub async fn nd_list_albums_by_artist_role(
end: u32, end: u32,
library_id: Option<String>, library_id: Option<String>,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let filter_key = format!("role_{}_id", role); let filter_key = format!("role_{}_id", role);
let mut seed = serde_json::Map::new(); let mut seed = serde_json::Map::new();
seed.insert(filter_key, serde_json::Value::String(artist_id.clone())); seed.insert(filter_key, serde_json::Value::String(artist_id.clone()));
let filters = nd_build_filters(seed, library_id.as_deref()); let filters = nd_build_filters(seed, library_id.as_deref());
let start_s = start.to_string(); let start_s = start.to_string();
let end_s = end.to_string(); let end_s = end.to_string();
let base = format!("{}/api/album", server_url);
let resp = nd_retry(|| { 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() nd_http_client()
.get(format!("{}/api/album", server_url)) .get(&base)
.query(&[ .query(&[
("_filters", filters.as_str()), ("_filters", filters.as_str()),
("_sort", sort.as_str()), ("_sort", sort.as_str()),
@@ -137,9 +202,13 @@ pub async fn nd_list_albums_by_artist_role(
("_start", start_s.as_str()), ("_start", start_s.as_str()),
("_end", end_s.as_str()), ("_end", end_s.as_str()),
]) ])
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", auth),
)
.send() .send()
}).await?; .await
}
})
.await?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status())); 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. /// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array.
#[tauri::command] #[tauri::command]
pub async fn nd_list_libraries( pub async fn nd_list_libraries(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
token: String, token: String,
) -> Result<serde_json::Value, 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(|| { let resp = nd_retry(|| {
nd_http_client() let url = url.clone();
.get(format!("{}/api/library", server_url)) let auth = auth.clone();
.header("X-ND-Authorization", format!("Bearer {}", token)) async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client().get(&url).header("X-ND-Authorization", auth),
)
.send() .send()
}).await?; .await
}
})
.await?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status())); 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. /// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400.
#[tauri::command] #[tauri::command]
pub async fn nd_set_user_libraries( pub async fn nd_set_user_libraries(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
token: String, token: String,
id: String, id: String,
library_ids: Vec<i64>, library_ids: Vec<i64>,
) -> Result<(), String> { ) -> Result<(), String> {
let reg = http_registry.as_ref();
let body = serde_json::json!({ "libraryIds": library_ids }); 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 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() nd_http_client()
.put(format!("{}/api/user/{}/library", server_url, id)) .put(&url)
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", auth)
.json(&body) .json(&body),
)
.send() .send()
}).await?; .await
}
})
.await?;
let status = resp.status(); let status = resp.status();
if !status.is_success() { if !status.is_success() {
let text = resp.text().await.unwrap_or_default(); 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. /// it for non-admin users on some configurations.
#[tauri::command] #[tauri::command]
pub async fn nd_get_song_path( pub async fn nd_get_song_path(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
username: String, username: String,
password: String, password: String,
id: String, id: String,
) -> Result<Option<String>, 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 url = format!("{}/api/song/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| { let resp = nd_retry(|| {
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client() nd_http_client()
.get(&url) .get(&url)
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", auth),
)
.send() .send()
.await
}
}) })
.await?; .await?;
if !resp.status().is_success() { if !resp.status().is_success() {
@@ -2,22 +2,39 @@
//! `token` (obtained via `navidrome_login`); admin-only ones return 401/403 //! `token` (obtained via `navidrome_login`); admin-only ones return 401/403
//! when the caller is not an admin. //! 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. /// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin.
#[tauri::command] #[tauri::command]
pub async fn navidrome_login( pub async fn navidrome_login(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
username: String, username: String,
password: String, password: String,
) -> Result<NdLoginResult, String> { ) -> Result<NdLoginResult, String> {
let reg = http_registry.as_ref();
let body = serde_json::json!({ "username": username, "password": password }); let body = serde_json::json!({ "username": username, "password": password });
let login_url = format!("{}/auth/login", server_url.trim_end_matches('/'));
let resp = nd_retry(|| { let resp = nd_retry(|| {
nd_http_client() let login_url = login_url.clone();
.post(format!("{}/auth/login", server_url)) let body = body.clone();
.json(&body) async move {
nd_apply_request(
Some(reg),
None,
&login_url,
nd_http_client().post(&login_url).json(&body),
)
.send() .send()
}).await?; .await
}
})
.await?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(format!("Navidrome login failed: HTTP {}", resp.status())); 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 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 user_id = data["id"].as_str().unwrap_or("").to_string();
let is_admin = data["isAdmin"].as_bool().unwrap_or(false); 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. /// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields.
#[tauri::command] #[tauri::command]
pub async fn nd_list_users( pub async fn nd_list_users(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
token: String, token: String,
) -> Result<serde_json::Value, 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 resp = nd_retry(|| {
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client() nd_http_client()
.get(format!("{}/api/user", server_url)) .get(&url)
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", auth),
)
.send() .send()
}).await?; .await
}
})
.await?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status())); return Err(format!("HTTP {}", resp.status()));
} }
@@ -48,7 +84,9 @@ pub async fn nd_list_users(
/// POST `/api/user` — create a user. /// POST `/api/user` — create a user.
#[tauri::command] #[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_create_user( pub async fn nd_create_user(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
token: String, token: String,
user_name: String, user_name: String,
@@ -57,6 +95,7 @@ pub async fn nd_create_user(
password: String, password: String,
is_admin: bool, is_admin: bool,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let body = serde_json::json!({ let body = serde_json::json!({
"userName": user_name, "userName": user_name,
"name": name, "name": name,
@@ -64,13 +103,27 @@ pub async fn nd_create_user(
"password": password, "password": password,
"isAdmin": is_admin, "isAdmin": is_admin,
}); });
let url = format!("{}/api/user", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| { 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() nd_http_client()
.post(format!("{}/api/user", server_url)) .post(&url)
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", auth)
.json(&body) .json(&body),
)
.send() .send()
}).await?; .await
}
})
.await?;
let status = resp.status(); let status = resp.status();
let text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
if !status.is_success() { if !status.is_success() {
@@ -83,6 +136,7 @@ pub async fn nd_create_user(
#[tauri::command] #[tauri::command]
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn nd_update_user( pub async fn nd_update_user(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
token: String, token: String,
id: String, id: String,
@@ -92,6 +146,7 @@ pub async fn nd_update_user(
password: String, password: String,
is_admin: bool, is_admin: bool,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let mut body = serde_json::json!({ let mut body = serde_json::json!({
"id": id, "id": id,
"userName": user_name, "userName": user_name,
@@ -102,13 +157,27 @@ pub async fn nd_update_user(
if !password.is_empty() { if !password.is_empty() {
body["password"] = serde_json::Value::String(password); 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 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() nd_http_client()
.put(format!("{}/api/user/{}", server_url, id)) .put(&url)
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", auth)
.json(&body) .json(&body),
)
.send() .send()
}).await?; .await
}
})
.await?;
let status = resp.status(); let status = resp.status();
let text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
if !status.is_success() { if !status.is_success() {
@@ -120,16 +189,31 @@ pub async fn nd_update_user(
/// DELETE `/api/user/{id}`. /// DELETE `/api/user/{id}`.
#[tauri::command] #[tauri::command]
pub async fn nd_delete_user( pub async fn nd_delete_user(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String, server_url: String,
token: String, token: String,
id: String, id: String,
) -> Result<(), 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 resp = nd_retry(|| {
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client() nd_http_client()
.delete(format!("{}/api/user/{}", server_url, id)) .delete(&url)
.header("X-ND-Authorization", format!("Bearer {}", token)) .header("X-ND-Authorization", auth),
)
.send() .send()
}).await?; .await
}
})
.await?;
let status = resp.status(); let status = resp.status();
if !status.is_success() { if !status.is_success() {
let text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
@@ -12,6 +12,7 @@ use serde::Deserialize;
use super::auth::SubsonicCredentials; use super::auth::SubsonicCredentials;
use super::error::{flatten_reqwest_error, SubsonicError}; use super::error::{flatten_reqwest_error, SubsonicError};
use super::types::{Album, AlbumSummary, ArtistIndex, ScanStatus, SearchResult, ServerInfo, Song}; 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 /// Protocol level we advertise — pre-OpenSubsonic Subsonic baseline that
/// Navidrome and other servers in the wild support. OpenSubsonic /// Navidrome and other servers in the wild support. OpenSubsonic
@@ -42,6 +43,7 @@ pub struct SubsonicClient {
base_url: String, base_url: String,
credentials: CredentialsMode, credentials: CredentialsMode,
http: reqwest::Client, http: reqwest::Client,
http_context: Option<ServerHttpContext>,
} }
impl SubsonicClient { impl SubsonicClient {
@@ -75,9 +77,28 @@ impl SubsonicClient {
password: password.into(), password: password.into(),
}, },
http, 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 /// Test-/cache-friendly constructor — re-uses the same
/// `SubsonicCredentials` triple on every call. Wiremock tests rely on /// `SubsonicCredentials` triple on every call. Wiremock tests rely on
/// this for deterministic `s=` and `t=` query params; production code /// this for deterministic `s=` and `t=` query params; production code
@@ -95,6 +116,7 @@ impl SubsonicClient {
base_url: url, base_url: url,
credentials: CredentialsMode::Static(credentials), credentials: CredentialsMode::Static(credentials),
http, http,
http_context: None,
} }
} }
@@ -301,10 +323,15 @@ impl SubsonicClient {
let mut query: Vec<(&str, &str)> = auth.to_vec(); let mut query: Vec<(&str, &str)> = auth.to_vec();
query.extend_from_slice(extra); query.extend_from_slice(extra);
let resp = self let mut req = self
.http .http
.get(format!("{}/rest/{method}.view", self.base_url)) .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() .send()
.await .await
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))?; .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()) .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` /// Validate the Subsonic envelope and return the raw `serde_json::Value`
/// at `body_key`. Maps `error.code = 70` to the dedicated `NotFound` /// at `body_key`. Maps `error.code = 70` to the dedicated `NotFound`
/// variant; surfaces every other failed status as `Api { code, message }`. /// variant; surfaces every other failed status as `Api { code, message }`.
@@ -10,7 +10,8 @@ pub mod types;
pub use auth::SubsonicCredentials; pub use auth::SubsonicCredentials;
pub use client::{ 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 stream_url::{build_stream_view_url, rest_base_from_url};
pub use error::SubsonicError; pub use error::SubsonicError;
@@ -11,8 +11,9 @@ use rusqlite::params;
use serde_json::Value; use serde_json::Value;
use tauri::{AppHandle, Emitter, Manager, State}; use tauri::{AppHandle, Emitter, Manager, State};
use psysonic_integration::navidrome::navidrome_token; use psysonic_core::server_http::ServerHttpRegistry;
use psysonic_integration::subsonic::SubsonicClient; use psysonic_integration::navidrome::navidrome_token_with_registry;
use psysonic_integration::subsonic::subsonic_client_with_registry;
use crate::advanced_search; use crate::advanced_search;
use crate::analysis_backfill::{self, LibraryAnalysisBackfillBatchDto, LibraryAnalysisProgressDto}; 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 /// caller falls back to a cached bearer / the Subsonic-only path. Never logs
/// the token or credentials. /// the token or credentials.
async fn navidrome_token_with_retry( async fn navidrome_token_with_retry(
registry: Option<&ServerHttpRegistry>,
base_url: &str, base_url: &str,
username: &str, username: &str,
password: &str, password: &str,
) -> Option<String> { ) -> Option<String> {
const ATTEMPTS: u32 = 3; const ATTEMPTS: u32 = 3;
for attempt in 1..=ATTEMPTS { 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), Ok(tok) => return Some(tok),
Err(_) if attempt < ATTEMPTS => { Err(_) if attempt < ATTEMPTS => {
tokio::time::sleep(Duration::from_millis(250 * attempt as u64)).await; tokio::time::sleep(Duration::from_millis(250 * attempt as u64)).await;
@@ -677,6 +679,7 @@ async fn navidrome_token_with_retry(
#[tauri::command] #[tauri::command]
pub async fn library_sync_bind_session( pub async fn library_sync_bind_session(
runtime: State<'_, LibraryRuntime>, runtime: State<'_, LibraryRuntime>,
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_id: String, server_id: String,
base_url: String, base_url: String,
username: 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 // keep a bearer cached from a prior bind rather than dropping to
// Subsonic-only — a transient miss must not strip an N1-capable server // Subsonic-only — a transient miss must not strip an N1-capable server
// (R7-15 Q3). Non-Navidrome servers stay `None` and sync via Subsonic. // (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 .await
{ {
Some(tok) => Some(tok), 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 // Run the probe + persist capability flags. Failure to probe is a
// bind-time error — caller should fix credentials / URL. // 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 { let navidrome_creds = navidrome_token_cached.map(|tok| NavidromeProbeCredentials {
server_url: subsonic_base_url_from(&runtime, &server_id), server_url: subsonic_base_url_from(&runtime, &server_id),
bearer_token: tok, bearer_token: tok,
@@ -719,6 +733,7 @@ pub async fn library_sync_bind_session(
&runtime.store, &runtime.store,
&subsonic, &subsonic,
navidrome_creds.as_ref(), navidrome_creds.as_ref(),
Some(http_registry.as_ref()),
&server_id, &server_id,
scope, scope,
) )
@@ -872,8 +887,12 @@ async fn library_sync_start_inner(
let job_id_for_task = job_id.clone(); let job_id_for_task = job_id.clone();
let parallelism = ParallelismBudget::resolve(runtime.current_playback_hint()); 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 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.base_url.clone(),
session_clone.username.clone(), session_clone.username.clone(),
session_clone.password.clone(), session_clone.password.clone(),
@@ -895,7 +914,8 @@ async fn library_sync_start_inner(
) )
.with_cancellation(Arc::clone(&cancel_for_task)) .with_cancellation(Arc::clone(&cancel_for_task))
.with_progress(Arc::clone(&progress)) .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() { if let Some(creds) = navidrome_creds.clone() {
runner = runner.with_navidrome_credentials(creds); runner = runner.with_navidrome_credentials(creds);
} }
@@ -919,7 +939,8 @@ async fn library_sync_start_inner(
capability_flags, capability_flags,
) )
.with_cancellation(Arc::clone(&cancel_for_task)) .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 { if tombstone_budget > 0 {
runner = runner.with_tombstone_budget(tombstone_budget); runner = runner.with_tombstone_budget(tombstone_budget);
} }
@@ -1647,7 +1668,7 @@ mod tests {
}))) })))
.mount(&server) .mount(&server)
.await; .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")); 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!({}))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server) .mount(&server)
.await; .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()); assert!(tok.is_none());
} }
} }
@@ -91,6 +91,7 @@ pub async fn probe_and_persist(
store: &crate::store::LibraryStore, store: &crate::store::LibraryStore,
subsonic: &psysonic_integration::subsonic::SubsonicClient, subsonic: &psysonic_integration::subsonic::SubsonicClient,
navidrome: Option<&NavidromeProbeCredentials>, navidrome: Option<&NavidromeProbeCredentials>,
http_registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: &str, server_id: &str,
library_scope: &str, library_scope: &str,
) -> Result<CapabilityProbeResult, psysonic_integration::subsonic::SubsonicError> { ) -> Result<CapabilityProbeResult, psysonic_integration::subsonic::SubsonicError> {
@@ -110,7 +111,7 @@ pub async fn probe_and_persist(
.map_err(psysonic_integration::subsonic::SubsonicError::Transport)? .map_err(psysonic_integration::subsonic::SubsonicError::Transport)?
.unwrap_or(0); .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 // 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 // must not drop a previously-learned NavidromeNativeBulk capability — the
@@ -173,6 +174,8 @@ impl CapabilityProbe {
pub async fn run( pub async fn run(
subsonic: &SubsonicClient, subsonic: &SubsonicClient,
navidrome: Option<&NavidromeProbeCredentials>, navidrome: Option<&NavidromeProbeCredentials>,
http_registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: Option<&str>,
) -> Result<CapabilityProbeResult, SubsonicError> { ) -> Result<CapabilityProbeResult, SubsonicError> {
let server_info = subsonic.server_info().await?; let server_info = subsonic.server_info().await?;
@@ -207,7 +210,14 @@ impl CapabilityProbe {
} }
if let Some(creds) = navidrome { 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(true) => flags.insert(CapabilityFlags::NAVIDROME_NATIVE_BULK),
Ok(false) => {} Ok(false) => {}
Err(_) => { Err(_) => {
@@ -345,7 +355,7 @@ mod tests {
let server = MockServer::start().await; let server = MockServer::start().await;
mount_subsonic_full_navidrome(&server).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 .await
.unwrap(); .unwrap();
assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK)); assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
@@ -372,7 +382,7 @@ mod tests {
.mount(&server) .mount(&server)
.await; .await;
let err = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None) let err = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, SubsonicError::Api { code: 40, .. })); assert!(matches!(err, SubsonicError::Api { code: 40, .. }));
@@ -415,7 +425,7 @@ mod tests {
.mount(&server) .mount(&server)
.await; .await;
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None) let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
.await .await
.unwrap(); .unwrap();
assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK)); assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
@@ -440,7 +450,7 @@ mod tests {
server_url: server.uri(), server_url: server.uri(),
bearer_token: "nd-tok".into(), 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 .await
.unwrap(); .unwrap();
assert!(result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK)); assert!(result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
@@ -461,6 +471,7 @@ mod tests {
&store, &store,
&test_subsonic_client(&server.uri()), &test_subsonic_client(&server.uri()),
None, None,
None,
"s1", "s1",
"", "",
) )
@@ -498,6 +509,7 @@ mod tests {
&store, &store,
&test_subsonic_client(&server.uri()), &test_subsonic_client(&server.uri()),
None, None,
None,
"s1", "s1",
"", "",
) )
@@ -530,6 +542,7 @@ mod tests {
&store, &store,
&test_subsonic_client(&server.uri()), &test_subsonic_client(&server.uri()),
None, None,
None,
"s1", "s1",
"", "",
) )
@@ -584,7 +597,7 @@ mod tests {
let store = LibraryStore::open_in_memory(); let store = LibraryStore::open_in_memory();
let result = 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 .await
.unwrap(); .unwrap();
assert_eq!(result.server_track_count, Some(170_000)); assert_eq!(result.server_track_count, Some(170_000));
@@ -617,6 +630,7 @@ mod tests {
&store, &store,
&test_subsonic_client(&server.uri()), &test_subsonic_client(&server.uri()),
None, None,
None,
"s1", "s1",
"", "",
) )
@@ -647,7 +661,7 @@ mod tests {
mount_subsonic_full_navidrome(&server).await; // scanStatus has no count mount_subsonic_full_navidrome(&server).await; // scanStatus has no count
let result = 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 .await
.unwrap(); .unwrap();
assert_eq!(result.server_track_count, None); assert_eq!(result.server_track_count, None);
@@ -672,7 +686,7 @@ mod tests {
server_url: server.uri(), server_url: server.uri(),
bearer_token: "nd-tok".into(), 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 .await
.unwrap(); .unwrap();
assert!(!result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK)); assert!(!result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
@@ -20,6 +20,7 @@ use std::sync::atomic::Ordering;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use psysonic_core::server_http::ServerHttpRegistry;
use psysonic_integration::navidrome::queries::nd_list_songs_internal; use psysonic_integration::navidrome::queries::nd_list_songs_internal;
use psysonic_integration::subsonic::SubsonicClient; use psysonic_integration::subsonic::SubsonicClient;
use serde_json::Value; use serde_json::Value;
@@ -71,6 +72,7 @@ pub struct DeltaSyncRunner<'a> {
store: &'a LibraryStore, store: &'a LibraryStore,
subsonic: &'a SubsonicClient, subsonic: &'a SubsonicClient,
navidrome: Option<NavidromeProbeCredentials>, navidrome: Option<NavidromeProbeCredentials>,
http_registry: Option<Arc<ServerHttpRegistry>>,
server_id: String, server_id: String,
library_scope: String, library_scope: String,
capability_flags: CapabilityFlags, capability_flags: CapabilityFlags,
@@ -95,6 +97,7 @@ impl<'a> DeltaSyncRunner<'a> {
store, store,
subsonic, subsonic,
navidrome: None, navidrome: None,
http_registry: None,
server_id: server_id.into(), server_id: server_id.into(),
library_scope: library_scope.into(), library_scope: library_scope.into(),
capability_flags, capability_flags,
@@ -111,6 +114,11 @@ impl<'a> DeltaSyncRunner<'a> {
self 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 { pub fn with_cancellation(mut self, flag: Arc<std::sync::atomic::AtomicBool>) -> Self {
self.cancel = Some(flag); self.cancel = Some(flag);
self self
@@ -396,6 +404,8 @@ impl<'a> DeltaSyncRunner<'a> {
self, self,
|| { || {
nd_list_songs_internal( nd_list_songs_internal(
self.http_registry.as_deref(),
Some(&self.server_id),
&creds.server_url, &creds.server_url,
&creds.bearer_token, &creds.bearer_token,
"updated_at", "updated_at",
@@ -12,6 +12,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use psysonic_core::server_http::ServerHttpRegistry;
use psysonic_integration::navidrome::queries::nd_list_songs_internal; use psysonic_integration::navidrome::queries::nd_list_songs_internal;
use psysonic_integration::subsonic::SubsonicClient; use psysonic_integration::subsonic::SubsonicClient;
use serde_json::Value; use serde_json::Value;
@@ -109,6 +110,7 @@ pub struct InitialSyncRunner<'a> {
store: &'a LibraryStore, store: &'a LibraryStore,
subsonic: &'a SubsonicClient, subsonic: &'a SubsonicClient,
navidrome: Option<NavidromeProbeCredentials>, navidrome: Option<NavidromeProbeCredentials>,
http_registry: Option<Arc<ServerHttpRegistry>>,
server_id: String, server_id: String,
library_scope: String, library_scope: String,
capability_flags: CapabilityFlags, capability_flags: CapabilityFlags,
@@ -132,6 +134,7 @@ impl<'a> InitialSyncRunner<'a> {
store, store,
subsonic, subsonic,
navidrome: None, navidrome: None,
http_registry: None,
server_id: server_id.into(), server_id: server_id.into(),
library_scope: library_scope.into(), library_scope: library_scope.into(),
capability_flags, capability_flags,
@@ -154,6 +157,11 @@ impl<'a> InitialSyncRunner<'a> {
self 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 { pub fn with_cancellation(mut self, flag: Arc<AtomicBool>) -> Self {
self.cancel = Some(flag); self.cancel = Some(flag);
self self
@@ -580,6 +588,8 @@ impl<'a> InitialSyncRunner<'a> {
let cancel = self.cancel.clone(); let cancel = self.cancel.clone();
let sleep_enabled = self.sleep_enabled; let sleep_enabled = self.sleep_enabled;
let creds = creds.clone(); 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); let mut queue = LinearPrefetchQueue::new(&budget, batch_size, offset);
loop { loop {
@@ -590,6 +600,8 @@ impl<'a> InitialSyncRunner<'a> {
queue.pump(|| self.check_cancellation(), |off| { queue.pump(|| self.check_cancellation(), |off| {
let creds = creds.clone(); let creds = creds.clone();
let cancel = cancel.clone(); let cancel = cancel.clone();
let http_registry = http_registry.clone();
let server_id = server_id.clone();
tokio::spawn(async move { tokio::spawn(async move {
retry_fetch( retry_fetch(
sleep_enabled, sleep_enabled,
@@ -597,6 +609,8 @@ impl<'a> InitialSyncRunner<'a> {
|| async { || async {
let end = off.saturating_add(batch_size); let end = off.saturating_add(batch_size);
let response = nd_list_songs_internal( let response = nd_list_songs_internal(
http_registry.as_deref(),
Some(&server_id),
&creds.server_url, &creds.server_url,
&creds.bearer_token, &creds.bearer_token,
"id", "id",
@@ -671,6 +685,8 @@ impl<'a> InitialSyncRunner<'a> {
self, self,
|| { || {
nd_list_songs_internal( nd_list_songs_internal(
self.http_registry.as_deref(),
Some(&self.server_id),
&creds.server_url, &creds.server_url,
&creds.bearer_token, &creds.bearer_token,
"id", "id",
@@ -11,6 +11,7 @@
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::Arc; use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use psysonic_integration::subsonic::SubsonicClient; use psysonic_integration::subsonic::SubsonicClient;
use super::bandwidth::{ParallelismBudget, PlaybackHint}; use super::bandwidth::{ParallelismBudget, PlaybackHint};
@@ -45,6 +46,7 @@ pub struct BackgroundScheduler<'a> {
store: &'a LibraryStore, store: &'a LibraryStore,
subsonic: &'a SubsonicClient, subsonic: &'a SubsonicClient,
navidrome: Option<NavidromeProbeCredentials>, navidrome: Option<NavidromeProbeCredentials>,
http_registry: Option<Arc<ServerHttpRegistry>>,
server_id: String, server_id: String,
library_scope: String, library_scope: String,
capability_flags: CapabilityFlags, capability_flags: CapabilityFlags,
@@ -70,6 +72,7 @@ impl<'a> BackgroundScheduler<'a> {
store, store,
subsonic, subsonic,
navidrome: None, navidrome: None,
http_registry: None,
server_id: server_id.into(), server_id: server_id.into(),
library_scope: library_scope.into(), library_scope: library_scope.into(),
capability_flags, capability_flags,
@@ -87,6 +90,11 @@ impl<'a> BackgroundScheduler<'a> {
self 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 { pub fn with_playback_hint(mut self, hint: PlaybackHint) -> Self {
self.playback_hint = hint; self.playback_hint = hint;
self self
@@ -220,7 +228,8 @@ impl<'a> BackgroundScheduler<'a> {
&self.library_scope, &self.library_scope,
self.capability_flags, 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 { if let Some(creds) = &self.navidrome {
runner = runner.with_navidrome_credentials(creds.clone()); 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 psysonic_core::user_agent::subsonic_wire_user_agent;
use crate::file_transfer::apply_server_http_get;
pub fn resolve_hot_cache_root( pub fn resolve_hot_cache_root(
custom_dir: Option<String>, custom_dir: Option<String>,
app: &tauri::AppHandle, app: &tauri::AppHandle,
@@ -340,7 +342,18 @@ pub async fn download_zip(
.build() .build()
.map_err(|e| e.to_string())?; .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() { if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16())); 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_analysis::analysis_runtime::{enqueue_track_analysis, AnalysisBackfillPriority};
use psysonic_audio as audio; use psysonic_audio as audio;
use psysonic_core::user_agent::subsonic_wire_user_agent; 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::downloads::{resolve_hot_cache_root, HotCacheDownloadResult};
use super::offline::enqueue_analysis_seed_from_file; use super::offline::enqueue_analysis_seed_from_file;
@@ -70,7 +73,14 @@ pub async fn download_track_hot_cache(
.build() .build()
.map_err(|e| e.to_string())?; .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() { if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16())); 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 psysonic_library::{repos::TrackRepository, LibraryRuntime};
use tauri::{AppHandle, Manager, State}; 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 crate::{offline_cancel_flags, DownloadSemaphore};
use super::offline::enqueue_analysis_seed_from_file; 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 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() { if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16())); 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::{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 ────────────────────────────────────────────────────── // ─── 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")` /// `cancel`, when supplied, aborts the in-flight stream with `Err("CANCELLED")`
/// (the `.part` file is cleaned up); `None` means the download is not cancellable. /// (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( pub(crate) async fn download_track_to_cache_dir(
cache_dir: &std::path::Path, cache_dir: &std::path::Path,
track_id: &str, track_id: &str,
suffix: &str, suffix: &str,
url: &str, url: &str,
client: &reqwest::Client, client: &reqwest::Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
cancel: Option<&AtomicBool>, cancel: Option<&AtomicBool>,
) -> Result<std::path::PathBuf, String> { ) -> Result<std::path::PathBuf, String> {
tokio::fs::create_dir_all(cache_dir) tokio::fs::create_dir_all(cache_dir)
@@ -48,7 +51,10 @@ pub(crate) async fn download_track_to_cache_dir(
return Ok(file_path); 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() { if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16())); 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 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( let final_path = download_track_to_cache_dir(
&cache_dir, &cache_dir,
&track_id, &track_id,
&suffix, &suffix,
&url, &url,
&client, &client,
http_registry.as_deref(),
Some(&server_id),
cancel_flag.as_deref(), cancel_flag.as_deref(),
) )
.await?; .await?;
@@ -191,7 +202,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap(); let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/stream/track-1", server.uri()); 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 .await
.unwrap(); .unwrap();
assert!(path.exists()); assert!(path.exists());
@@ -211,7 +222,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap(); let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/should-not-be-hit", server.uri()); 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 .await
.unwrap(); .unwrap();
assert_eq!(path, pre_existing); assert_eq!(path, pre_existing);
@@ -232,7 +243,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap(); let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/stream/missing", server.uri()); 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 .await
.unwrap_err(); .unwrap_err();
assert!(err.contains("HTTP 404"), "got {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 client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/track", server.uri()); 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 .await
.unwrap(); .unwrap();
assert!(cache_dir.join("t.mp3").exists()); assert!(cache_dir.join("t.mp3").exists());
@@ -278,7 +289,7 @@ mod tests {
let cancel = AtomicBool::new(true); let cancel = AtomicBool::new(true);
let err = 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 .await
.unwrap_err(); .unwrap_err();
assert_eq!(err, "CANCELLED"); 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()) .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 /// Streams an HTTP response body to `dest_path` in chunks. Never buffers the full
/// file in memory — keeps RAM flat regardless of file size. /// file in memory — keeps RAM flat regardless of file size.
/// ///
@@ -1,11 +1,11 @@
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use tauri::Emitter; use tauri::{Emitter, Manager};
use crate::sync_cancel_flags; 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::{ use super::device::{
build_track_path, get_removable_drives, is_path_on_mounted_volume, SyncBatchResult, build_track_path, get_removable_drives, is_path_on_mounted_volume, SyncBatchResult,
TrackSyncInfo, TrackSyncInfo,
@@ -107,6 +107,7 @@ pub struct SyncDeltaResult {
pub async fn fetch_subsonic_songs( pub async fn fetch_subsonic_songs(
client: &reqwest::Client, client: &reqwest::Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
auth: &SubsonicAuthPayload, auth: &SubsonicAuthPayload,
endpoint: &str, endpoint: &str,
id: &str, id: &str,
@@ -121,7 +122,11 @@ pub async fn fetch_subsonic_songs(
("f", auth.f.as_str()), ("f", auth.f.as_str()),
("id", id), ("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())?; let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
parse_subsonic_songs(&json, endpoint) parse_subsonic_songs(&json, endpoint)
} }
@@ -239,8 +244,12 @@ pub async fn calculate_sync_payload(
deletion_ids: Vec<String>, deletion_ids: Vec<String>,
auth: SubsonicAuthPayload, auth: SubsonicAuthPayload,
target_dir: String, target_dir: String,
app: tauri::AppHandle,
) -> Result<SyncDeltaResult, String> { ) -> Result<SyncDeltaResult, String> {
let client = subsonic_http_client(std::time::Duration::from_secs(30))?; 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_bytes = 0;
let mut add_count = 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(), v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
}; };
let cli = client.clone(); let cli = client.clone();
let reg_for_task = http_registry.clone();
let source_snapshot = source.clone(); let source_snapshot = source.clone();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
let registry = reg_for_task.as_deref();
let mut res_tracks = Vec::new(); let mut res_tracks = Vec::new();
if source.source_type == "album" { 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" { } 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" { } else if source.source_type == "artist" {
let url = format!("{}/getArtist.view", auth_clone.base_url); 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)]; 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 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")) { 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(|| { let arr = root.as_array().cloned().unwrap_or_else(|| {
@@ -282,7 +293,7 @@ pub async fn calculate_sync_payload(
}); });
for al in arr { for al in arr {
if let Some(aid) = al.get("id").and_then(|i| i.as_str()) { 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); 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(), v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
}; };
let cli = client.clone(); let cli = client.clone();
let reg_for_task = http_registry.clone();
del_handles.push(tokio::spawn(async move { del_handles.push(tokio::spawn(async move {
let registry = reg_for_task.as_deref();
let mut res_tracks = Vec::new(); let mut res_tracks = Vec::new();
if source.source_type == "album" { 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" { } 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 res_tracks
})); }));
@@ -437,6 +450,9 @@ pub async fn sync_batch_to_device(
// Shared reqwest client — reused across all downloads. // Shared reqwest client — reused across all downloads.
let client = subsonic_http_client(Duration::from_secs(300))?; 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. // Concurrency limiter: max 2 parallel USB writes.
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(2)); 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 { for track in tracks {
let sem = semaphore.clone(); let sem = semaphore.clone();
let cli = client.clone(); let cli = client.clone();
let reg_for_task = http_registry.clone();
let app2 = app.clone(); let app2 = app.clone();
let job = job_id.clone(); let job = job_id.clone();
let dest = dest_dir.clone(); let dest = dest_dir.clone();
@@ -466,6 +483,7 @@ pub async fn sync_batch_to_device(
handles.push(tokio::spawn(async move { handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.expect("semaphore closed"); 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. // Bail out if cancelled while waiting in the semaphore queue.
if cancel.load(Ordering::Relaxed) { return; } 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) if r.status().is_success() => r,
Ok(r) => { Ok(r) => {
f.fetch_add(1, Ordering::Relaxed); 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)) let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
.unwrap(); .unwrap();
let auth = fake_auth(server.uri()); 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 .await
.unwrap(); .unwrap();
assert_eq!(songs.len(), 2); 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)) let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
.unwrap(); .unwrap();
let auth = fake_auth(server.uri()); 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 // 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. // just assert the function does not panic and propagates an error string.
assert!(result.is_err()); assert!(result.is_err());
@@ -989,7 +1007,7 @@ mod tests {
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5)) let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
.unwrap(); .unwrap();
let auth = fake_auth(server.uri()); 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 .await
.unwrap(); .unwrap();
assert_eq!(songs.len(), 1, "single-object response normalised to 1-element vec"); 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 ───────────────────────────────────────────────────────────── // ─── Device Sync ─────────────────────────────────────────────────────────────
@@ -333,6 +333,8 @@ pub(crate) async fn sync_download_one_track(
suffix: &str, suffix: &str,
url: &str, url: &str,
client: &reqwest::Client, client: &reqwest::Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
) -> Result<bool, String> { ) -> Result<bool, String> {
if dest_path.exists() { if dest_path.exists() {
return Ok(false); return Ok(false);
@@ -342,7 +344,10 @@ pub(crate) async fn sync_download_one_track(
.await .await
.map_err(|e| e.to_string())?; .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() { if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16())); 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 path_str = dest_path.to_string_lossy().to_string();
let client = subsonic_http_client(std::time::Duration::from_secs(300))?; 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) => { Ok(false) => {
let _ = app.emit("device:sync:progress", serde_json::json!({ let _ = app.emit("device:sync:progress", serde_json::json!({
"jobId": job_id, "trackId": track.id, "status": "skipped", "path": path_str, "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 dest = dir.path().join("Album").join("01 - track.flac");
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap(); let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/track", server.uri()); 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 .await
.unwrap(); .unwrap();
assert!(downloaded, "fresh download must report Ok(true)"); 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 client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/should-not-be-hit", server.uri()); 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 .await
.unwrap(); .unwrap();
assert!(!downloaded, "pre-existing file must be reported as skipped"); assert!(!downloaded, "pre-existing file must be reported as skipped");
@@ -692,7 +709,7 @@ mod tests {
let dest = dir.path().join("track.opus"); let dest = dir.path().join("track.opus");
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap(); let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/missing", server.uri()); 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 .await
.unwrap_err(); .unwrap_err();
assert!(err.contains("HTTP 403")); assert!(err.contains("HTTP 403"));
@@ -714,7 +731,7 @@ mod tests {
assert!(!dest.parent().unwrap().exists()); assert!(!dest.parent().unwrap().exists());
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap(); let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/t", server.uri()); 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 .await
.unwrap(); .unwrap();
assert!(dest.exists()); 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. /// `Ok(None)` when the artist carries no MBID tag.
pub async fn fetch_artist_tag_mbid( pub async fn fetch_artist_tag_mbid(
client: &Client, client: &Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
rest_base: &str, rest_base: &str,
username: &str, username: &str,
password: &str, password: &str,
artist_id: &str, artist_id: &str,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
let url = build_artist_info2_url(rest_base, username, password, artist_id); 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 v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
let mbid = v let mbid = v
.get("subsonic-response") .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. /// Single GET → response text; any non-2xx is an error.
async fn http_get_text(client: &Client, url: &str) -> Result<String, String> { async fn http_get_text_scoped(
let resp = client.get(url).send().await.map_err(|e| e.to_string())?; 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(); let status = resp.status();
if !status.is_success() { if !status.is_success() {
return Err(format!("HTTP {status}")); 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 _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 // §23: resolve the tag MBID Rust-side via getArtistInfo2 — unless the cache
// already carries one (skip the Navidrome round-trip). // already carries one (skip the Navidrome round-trip).
let (mbid, mbid_source) = match cached.as_ref().and_then(|r| r.mbid.clone()) { 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())), Some(m) => (m, cached.as_ref().and_then(|r| r.mbid_source.clone())),
None => match external::fetch_artist_tag_mbid( None => match external::fetch_artist_tag_mbid(
client, client,
http_registry.as_deref(),
Some(server_id),
&args.rest_base_url, &args.rest_base_url,
&args.username, &args.username,
&args.password, &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, Ok(b) => b,
Err(e) => { Err(e) => {
eprintln!("[fanart] download failed: {e}"); // transient — don't cache eprintln!("[fanart] download failed: {e}"); // transient — don't cache
+23 -4
View File
@@ -1,4 +1,5 @@
use reqwest::Client; use reqwest::Client;
use psysonic_core::server_http::ServerHttpRegistry;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use url::Url; use url::Url;
@@ -83,8 +84,21 @@ enum FetchAttempt {
Permanent(String), Permanent(String),
} }
async fn fetch_cover_once(client: &Client, url: &str) -> FetchAttempt { async fn fetch_cover_once(
let resp = match client.get(url).send().await { 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, Ok(r) => r,
// Connection reset / timeout / DNS — transient under server load. // Connection reset / timeout / DNS — transient under server load.
Err(e) => return FetchAttempt::Transient(e.to_string()), 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"); let mut last_err = String::from("cover fetch failed");
for attempt in 0..COVER_FETCH_ATTEMPTS { 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::Ok(bytes) => return Ok(bytes),
FetchAttempt::Permanent(e) => return Err(e), FetchAttempt::Permanent(e) => return Err(e),
FetchAttempt::Transient(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) { let source = if let Some(img) = load_image_from_disk(&dir) {
CoverSource::Image(img) CoverSource::Image(img)
} else { } 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), Ok(bytes) => CoverSource::Bytes(bytes),
Err(err) => { Err(err) => {
log_cover_fetch_failure(app, args, &err); log_cover_fetch_failure(app, args, &err);
@@ -532,6 +535,7 @@ async fn download_cover_payload(
client: &Client, client: &Client,
http_sem: &Semaphore, http_sem: &Semaphore,
args: &CoverCacheEnsureArgs, args: &CoverCacheEnsureArgs,
registry: Option<Arc<psysonic_core::server_http::ServerHttpRegistry>>,
) -> Result<Vec<u8>, String> { ) -> Result<Vec<u8>, String> {
let _permit = http_sem let _permit = http_sem
.acquire() .acquire()
@@ -549,7 +553,13 @@ async fn download_cover_payload(
&args.cover_art_id, &args.cover_art_id,
fetch_size, 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( fn spawn_derive_remaining_tiers(
+10 -2
View File
@@ -106,6 +106,7 @@ pub fn run() {
let builder = tauri::Builder::default() let builder = tauri::Builder::default()
.manage(audio_engine) .manage(audio_engine)
.manage(Arc::new(psysonic_core::server_http::ServerHttpRegistry::new()))
.manage(ShortcutMap::default()) .manage(ShortcutMap::default())
.manage(discord::DiscordState::new()) .manage(discord::DiscordState::new())
.manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore) .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( let flags = psysonic_library::sync::capability::CapabilityFlags::new(
flags_bits, 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.base_url.clone(),
session.username.clone(), session.username.clone(),
session.password.clone(), session.password.clone(),
@@ -251,7 +255,8 @@ pub fn run() {
scope.clone(), scope.clone(),
flags, flags,
) )
.with_playback_hint(hint); .with_playback_hint(hint)
.with_http_registry(Some(Arc::clone(&registry)));
if let Some(tok) = session.navidrome_token.clone() { if let Some(tok) = session.navidrome_token.clone() {
sched = sched.with_navidrome_credentials( sched = sched.with_navidrome_credentials(
psysonic_library::sync::capability::NavidromeProbeCredentials { psysonic_library::sync::capability::NavidromeProbeCredentials {
@@ -682,6 +687,9 @@ pub fn run() {
migration_inspect, migration_inspect,
migration_run, migration_run,
resolve_host_addresses, resolve_host_addresses,
server_http_context_sync,
server_http_context_sync_all,
server_http_context_clear,
psysonic_syncfs::sync::batch::calculate_sync_payload, psysonic_syncfs::sync::batch::calculate_sync_payload,
exit_app, exit_app,
cli_publish_player_snapshot, cli_publish_player_snapshot,
+4 -1
View File
@@ -36,7 +36,10 @@ pub(crate) use integration::{
unregister_global_shortcut, unregister_global_shortcut,
}; };
pub(crate) use migration::{migration_inspect, migration_run}; 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, // Discord, Navidrome admin, last.fm + radio-browser + CORS proxy, bandsintown,
// and analysis admin commands now live in their domain crates. invoke_handler! // 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). //! dual-server-address add/edit form (UI hint only — not for connect).
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc;
use psysonic_core::server_http::{ServerHttpContextSyncWire, ServerHttpRegistry};
use tauri::State;
use tokio::net::lookup_host; use tokio::net::lookup_host;
/// Resolve a hostname to a deduped list of IP address strings (IPv4 + IPv6). /// 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) 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 /// Strip a `:port` suffix. Handles `host:port` and `[ipv6]:port`; leaves
/// bracketed IPv6 with no port (`[::1]`) and bare hosts alone. /// bracketed IPv6 with no port (`[::1]`) and bare hosts alone.
fn strip_port(input: &str) -> String { fn strip_port(input: &str) -> String {
+37 -1
View File
@@ -26,7 +26,7 @@ vi.mock('../utils/network/subsonicNetworkGuard', () => ({
})); }));
import axios from 'axios'; import axios from 'axios';
import { pingWithCredentials, ping } from './subsonic'; import { pingWithCredentials, pingWithCredentialsForProfile, ping } from './subsonic';
import { getAlbumInfo2 } from './subsonicAlbumInfo'; import { getAlbumInfo2 } from './subsonicAlbumInfo';
import { getStarred } from './subsonicStarRating'; import { getStarred } from './subsonicStarRating';
import { search } from './subsonicSearch'; import { search } from './subsonicSearch';
@@ -440,3 +440,39 @@ describe('pingWithCredentials — explicit URL/credentials path', () => {
expect(r.openSubsonic).toBe(false); 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.mockReset();
fetchMock.mockResolvedValue(['sonicSimilarity']); fetchMock.mockResolvedValue(['sonicSimilarity']);
reset(); 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 () => { it('probes once, caches the result, then skips on the next poll', async () => {
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062); scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
expect(fetchMock).toHaveBeenCalledTimes(1); 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(); await flush();
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present'); expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present');
+56 -2
View File
@@ -1,6 +1,9 @@
import axios from 'axios'; import axios from 'axios';
import md5 from 'md5'; import md5 from 'md5';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import type { ServerProfile } from '../store/authStoreTypes';
import { headersForServerRequest } from '../utils/server/serverHttpHeaders';
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
import { import {
type InstantMixProbeResult, type InstantMixProbeResult,
type SubsonicServerIdentity, type SubsonicServerIdentity,
@@ -19,6 +22,7 @@ import {
api, api,
apiWithCredentials, apiWithCredentials,
secureRandomSalt, secureRandomSalt,
type ServerHttpHeaderProfile,
} from './subsonicClient'; } from './subsonicClient';
import type { PingWithCredentialsResult, SubsonicSong } from './subsonicTypes'; 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_RANDOM_SIZE = 8;
const INSTANT_MIX_PROBE_SIMILAR_COUNT = 12; const INSTANT_MIX_PROBE_SIMILAR_COUNT = 12;
const INSTANT_MIX_PROBE_MAX_TRACKS = 4; const INSTANT_MIX_PROBE_MAX_TRACKS = 4;
@@ -74,6 +119,7 @@ export async function probeInstantMixWithCredentials(
serverUrl: string, serverUrl: string,
username: string, username: string,
password: string, password: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<InstantMixProbeResult> { ): Promise<InstantMixProbeResult> {
try { try {
const data = await apiWithCredentials<{ randomSongs: { song: SubsonicSong | SubsonicSong[] } }>( const data = await apiWithCredentials<{ randomSongs: { song: SubsonicSong | SubsonicSong[] } }>(
@@ -83,6 +129,7 @@ export async function probeInstantMixWithCredentials(
'getRandomSongs.view', 'getRandomSongs.view',
{ size: INSTANT_MIX_PROBE_RANDOM_SIZE, _t: Date.now() }, { size: INSTANT_MIX_PROBE_RANDOM_SIZE, _t: Date.now() },
12000, 12000,
headerProfile,
); );
const raw = data.randomSongs?.song; const raw = data.randomSongs?.song;
const songs: SubsonicSong[] = !raw ? [] : Array.isArray(raw) ? raw : [raw]; const songs: SubsonicSong[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
@@ -98,6 +145,7 @@ export async function probeInstantMixWithCredentials(
'getSimilarSongs.view', 'getSimilarSongs.view',
{ id: song.id, count: INSTANT_MIX_PROBE_SIMILAR_COUNT }, { id: song.id, count: INSTANT_MIX_PROBE_SIMILAR_COUNT },
12000, 12000,
headerProfile,
); );
const sRaw = simData.similarSongs?.song; const sRaw = simData.similarSongs?.song;
const list: SubsonicSong[] = !sRaw ? [] : Array.isArray(sRaw) ? sRaw : [sRaw]; const list: SubsonicSong[] = !sRaw ? [] : Array.isArray(sRaw) ? sRaw : [sRaw];
@@ -138,6 +186,7 @@ export function scheduleInstantMixProbeForServer(
const ctx = buildCapabilityContext(identity); const ctx = buildCapabilityContext(identity);
const probeIds = neededProbeIds(SERVER_CAPABILITY_CATALOG, ctx); const probeIds = neededProbeIds(SERVER_CAPABILITY_CATALOG, ctx);
const store = useAuthStore.getState(); const store = useAuthStore.getState();
const headerProfile = findServerByIdOrIndexKey(serverId);
if (probeIds.has(PROBE_OPENSUBSONIC_EXTENSIONS)) { if (probeIds.has(PROBE_OPENSUBSONIC_EXTENSIONS)) {
// One `getOpenSubsonicExtensions` fetch answers every extension-gated feature. // One `getOpenSubsonicExtensions` fetch answers every extension-gated feature.
@@ -153,7 +202,12 @@ export function scheduleInstantMixProbeForServer(
const audiomuseStale = audiomuseEligible && (cached === undefined || cached === 'error'); const audiomuseStale = audiomuseEligible && (cached === undefined || cached === 'error');
if (force || listMissing || audiomuseStale) { if (force || listMissing || audiomuseStale) {
if (audiomuseEligible) store.setAudiomusePluginProbe(serverId, 'probing'); 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(); const st = useAuthStore.getState();
if (extensions === null) { if (extensions === null) {
if (audiomuseEligible) st.setAudiomusePluginProbe(serverId, 'error'); if (audiomuseEligible) st.setAudiomusePluginProbe(serverId, 'error');
@@ -170,7 +224,7 @@ export function scheduleInstantMixProbeForServer(
if (probeIds.has(PROBE_LEGACY_INSTANT_MIX)) { if (probeIds.has(PROBE_LEGACY_INSTANT_MIX)) {
const cached = store.instantMixProbeByServer[serverId]; const cached = store.instantMixProbeByServer[serverId];
if (force || cached === undefined || cached === 'error') { 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), useAuthStore.getState().setInstantMixProbe(serverId, result),
); );
} }
+16
View File
@@ -4,10 +4,17 @@ import { version } from '../../package.json';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import type { ServerProfile } from '../store/authStoreTypes'; import type { ServerProfile } from '../store/authStoreTypes';
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint'; import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
import { headersForServerRequest } from '../utils/server/serverHttpHeaders';
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup'; import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup';
export const SUBSONIC_CLIENT = `psysonic/${version}`; 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 { export function secureRandomSalt(): string {
const buf = new Uint8Array(8); const buf = new Uint8Array(8);
crypto.getRandomValues(buf); crypto.getRandomValues(buf);
@@ -32,10 +39,13 @@ export async function apiWithCredentials<T>(
endpoint: string, endpoint: string,
extra: Record<string, unknown> = {}, extra: Record<string, unknown> = {},
timeout = 15000, timeout = 15000,
headerProfile?: ServerHttpHeaderProfile,
): Promise<T> { ): Promise<T> {
const params = { ...getAuthParams(username, password), ...extra }; const params = { ...getAuthParams(username, password), ...extra };
const headers = headerProfile ? headersForServerRequest(headerProfile, serverUrl) : {};
const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, { const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, {
params, params,
headers,
paramsSerializer: { indexes: null }, paramsSerializer: { indexes: null },
timeout, timeout,
}); });
@@ -78,6 +88,7 @@ export async function apiForServer<T>(
endpoint, endpoint,
extra, extra,
timeout, timeout,
server,
); );
} }
@@ -88,8 +99,13 @@ export async function api<T>(
signal?: AbortSignal, signal?: AbortSignal,
): Promise<T> { ): Promise<T> {
const { baseUrl, params } = getClient(); 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}`, { const resp = await axios.get(`${baseUrl}/${endpoint}`, {
params: { ...params, ...extra }, params: { ...params, ...extra },
headers,
paramsSerializer: { indexes: null }, paramsSerializer: { indexes: null },
timeout, timeout,
signal, 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'; import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
export async function getSongWithCredentials( export async function getSongWithCredentials(
@@ -6,6 +6,7 @@ export async function getSongWithCredentials(
username: string, username: string,
password: string, password: string,
id: string, id: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<SubsonicSong | null> { ): Promise<SubsonicSong | null> {
try { try {
const data = await apiWithCredentials<{ song: SubsonicSong }>( const data = await apiWithCredentials<{ song: SubsonicSong }>(
@@ -14,6 +15,8 @@ export async function getSongWithCredentials(
password, password,
'getSong.view', 'getSong.view',
{ id }, { id },
15000,
headerProfile,
); );
return data.song ?? null; return data.song ?? null;
} catch { } catch {
@@ -26,6 +29,7 @@ export async function getAlbumWithCredentials(
username: string, username: string,
password: string, password: string,
id: string, id: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> { ): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
const data = await apiWithCredentials<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>( const data = await apiWithCredentials<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(
serverUrl, serverUrl,
@@ -33,6 +37,8 @@ export async function getAlbumWithCredentials(
password, password,
'getAlbum.view', 'getAlbum.view',
{ id }, { id },
15000,
headerProfile,
); );
const { song, ...album } = data.album; const { song, ...album } = data.album;
return { album, songs: song ?? [] }; return { album, songs: song ?? [] };
@@ -43,6 +49,7 @@ export async function getArtistWithCredentials(
username: string, username: string,
password: string, password: string,
id: string, id: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> { ): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
const data = await apiWithCredentials<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>( const data = await apiWithCredentials<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>(
serverUrl, serverUrl,
@@ -50,6 +57,8 @@ export async function getArtistWithCredentials(
password, password,
'getArtist.view', 'getArtist.view',
{ id }, { id },
15000,
headerProfile,
); );
const { album, ...artist } = data.artist; const { album, ...artist } = data.artist;
return { artist, albums: album ?? [] }; return { artist, albums: album ?? [] };
+11
View File
@@ -69,4 +69,15 @@ describe('fetchOpenSubsonicExtensionsWithCredentials', () => {
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'), fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
).resolves.toBeNull(); ).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 { export interface OpenSubsonicExtension {
name: string; name: string;
@@ -30,6 +30,7 @@ export async function fetchOpenSubsonicExtensionsWithCredentials(
serverUrl: string, serverUrl: string,
username: string, username: string,
password: string, password: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<string[] | null> { ): Promise<string[] | null> {
try { try {
const data = await apiWithCredentials<{ openSubsonicExtensions?: unknown }>( const data = await apiWithCredentials<{ openSubsonicExtensions?: unknown }>(
@@ -39,6 +40,7 @@ export async function fetchOpenSubsonicExtensionsWithCredentials(
'getOpenSubsonicExtensions.view', 'getOpenSubsonicExtensions.view',
{}, {},
12000, 12000,
headerProfile,
); );
return parseOpenSubsonicExtensions(data.openSubsonicExtensions).map(ext => ext.name); return parseOpenSubsonicExtensions(data.openSubsonicExtensions).map(ext => ext.name);
} catch { } catch {
+6
View File
@@ -16,6 +16,12 @@ vi.mock('@/api/subsonic', () => ({
serverVersion: '0.55.0', serverVersion: '0.55.0',
openSubsonic: true, openSubsonic: true,
})), })),
pingWithCredentialsForProfile: vi.fn(async () => ({
ok: true,
type: 'navidrome',
serverVersion: '0.55.0',
openSubsonic: true,
})),
scheduleInstantMixProbeForServer: vi.fn(), scheduleInstantMixProbeForServer: vi.fn(),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`), buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`), buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
@@ -143,3 +143,33 @@ describe('AddServerForm — dual-address behaviour', () => {
expect(arg).not.toHaveProperty('shareUsesLocalUrl'); 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 React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 { showToast } from '../../utils/ui/toast';
import {
DEFAULT_CUSTOM_HEADERS_APPLY_TO,
validateCustomHeaders,
} from '../../utils/server/serverHttpHeaders';
import { import {
decodeServerMagicString, decodeServerMagicString,
encodeServerMagicString, encodeServerMagicString,
@@ -19,6 +23,9 @@ type FormState = {
shareUsesLocalUrl: boolean; shareUsesLocalUrl: boolean;
username: string; username: string;
password: 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. */ /** 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, shareUsesLocalUrl: editingServer.shareUsesLocalUrl ?? false,
username: editingServer.username, username: editingServer.username,
password: editingServer.password, 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: '', name: '',
@@ -70,6 +83,9 @@ export function AddServerForm({
shareUsesLocalUrl: false, shareUsesLocalUrl: false,
username: '', username: '',
password: '', password: '',
customHeaders: [{ name: '', value: '' }],
customHeadersApplyTo: DEFAULT_CUSTOM_HEADERS_APPLY_TO,
customHeadersOpen: false,
}, },
); );
const [magicString, setMagicString] = useState(''); const [magicString, setMagicString] = useState('');
@@ -175,6 +191,20 @@ export function AddServerForm({
return true; 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 submit = async () => {
const ms = magicString.trim(); const ms = magicString.trim();
if (ms) { if (ms) {
@@ -193,6 +223,7 @@ export function AddServerForm({
url: decoded.url, url: decoded.url,
username: decoded.username, username: decoded.username,
password: decoded.password, password: decoded.password,
...customHeadersPayload(),
...(altDecoded ...(altDecoded
? { ? {
alternateUrl: altDecoded, alternateUrl: altDecoded,
@@ -205,6 +236,15 @@ export function AddServerForm({
if (!form.url.trim()) return; if (!form.url.trim()) return;
if (!validateAddresses()) 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(); const altTrimmed = form.alternateUrl.trim();
// If the user clears the second address, strip the share-flag as well so // 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). // we don't leave a dangling preference (spec §5.3 last row).
@@ -213,6 +253,7 @@ export function AddServerForm({
url: form.url.trim(), url: form.url.trim(),
username: form.username.trim(), username: form.username.trim(),
password: form.password, password: form.password,
...customHeadersPayload(),
...(altTrimmed ...(altTrimmed
? { ? {
alternateUrl: altTrimmed, alternateUrl: altTrimmed,
@@ -338,6 +379,108 @@ export function AddServerForm({
)} )}
</div> </div>
</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 && ( {!isEdit && (
<div className="form-group" style={{ marginBottom: '0.75rem' }}> <div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13 }}>{t('login.orMagicString')}</label> <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 { useLibraryIndexSync } from '../../hooks/useLibraryIndexSync';
import ServerLibraryIndexControls from './ServerLibraryIndexControls'; import ServerLibraryIndexControls from './ServerLibraryIndexControls';
import type { ServerProfile } from '../../store/authStoreTypes'; 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 { useDragDrop } from '../../contexts/DragDropContext';
import { type ServerMagicPayload } from '../../utils/server/serverMagicString'; import { type ServerMagicPayload } from '../../utils/server/serverMagicString';
import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '../../utils/server/serverEndpoint'; import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '../../utils/server/serverEndpoint';
@@ -191,6 +195,7 @@ export function ServersTab({
auth.removeServer(server.id); auth.removeServer(server.id);
try { try {
await clearServerHttpContext(server);
await librarySyncClearSession(server.id); await librarySyncClearSession(server.id);
if (purgeLibrary) { if (purgeLibrary) {
await libraryDeleteServerData(server.id); await libraryDeleteServerData(server.id);
@@ -238,7 +243,12 @@ export function ServersTab({
// straight to the legacy ping (which is also the connect-test). // straight to the legacy ping (which is also the connect-test).
if (data.alternateUrl) { if (data.alternateUrl) {
const verify = await verifySameServerEndpoints( 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.username,
data.password, data.password,
); );
@@ -247,7 +257,7 @@ export function ServersTab({
return; return;
} }
} }
const ping = await pingWithCredentials(data.url, data.username, data.password); const ping = await pingWithCredentialsForProfile(data, data.url);
if (ping.ok) { if (ping.ok) {
const id = auth.addServer(data); const id = auth.addServer(data);
const identity = { const identity = {
@@ -259,7 +269,10 @@ export function ServersTab({
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity, true); scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity, true);
setConnStatus(s => ({ ...s, [id]: 'ok' })); setConnStatus(s => ({ ...s, [id]: 'ok' }));
const added = useAuthStore.getState().servers.find(s => s.id === id); const added = useAuthStore.getState().servers.find(s => s.id === id);
if (added) void bootstrapIndexedServer(added); if (added) {
void syncServerHttpContextForProfile(added);
void bootstrapIndexedServer(added);
}
} else { } else {
setConnStatus(s => ({ ...s, [tempId]: 'error' })); setConnStatus(s => ({ ...s, [tempId]: 'error' }));
} }
@@ -323,7 +336,12 @@ export function ServersTab({
if (dualAddressChanged) { if (dualAddressChanged) {
setConnStatus(s => ({ ...s, [id]: 'testing' })); setConnStatus(s => ({ ...s, [id]: 'testing' }));
const verify = await verifySameServerEndpoints( 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.username,
data.password, data.password,
); );
@@ -335,12 +353,13 @@ export function ServersTab({
setEditingServerId(null); setEditingServerId(null);
auth.updateServer(id, data); 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 // 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); invalidateReachableEndpointCache(id);
setConnStatus(s => ({ ...s, [id]: 'testing' })); setConnStatus(s => ({ ...s, [id]: 'testing' }));
try { try {
const ping = await pingWithCredentials(data.url, data.username, data.password); const ping = await pingWithCredentialsForProfile(data, data.url);
if (ping.ok) { if (ping.ok) {
const identity = { const identity = {
type: ping.type, type: ping.type,
+1
View File
@@ -172,6 +172,7 @@ const CONTRIBUTOR_ENTRIES = [
'Niri compositor tiling WM detection (PR #1127)', '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)', '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)', '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', () => ({ vi.mock('@/api/subsonic', () => ({
pingWithCredentials: vi.fn(), pingWithCredentials: vi.fn(),
pingWithCredentialsForProfile: vi.fn(),
scheduleInstantMixProbeForServer: vi.fn(), scheduleInstantMixProbeForServer: vi.fn(),
})); }));
@@ -16,7 +17,7 @@ vi.mock('@/utils/perf/perfFlags', () => ({
usePerfProbeFlags: () => ({ disableBackgroundPolling: false }), usePerfProbeFlags: () => ({ disableBackgroundPolling: false }),
})); }));
import { pingWithCredentials } from '@/api/subsonic'; import { pingWithCredentialsForProfile } from '@/api/subsonic';
import { useDevOfflineBrowseStore } from '@/store/devOfflineBrowseStore'; import { useDevOfflineBrowseStore } from '@/store/devOfflineBrowseStore';
import { useConnectionStatus } from './useConnectionStatus'; import { useConnectionStatus } from './useConnectionStatus';
@@ -24,7 +25,7 @@ beforeEach(() => {
resetAuthStore(); resetAuthStore();
invalidateReachableEndpointCache(); invalidateReachableEndpointCache();
useDevOfflineBrowseStore.getState().setForceOffline(false); useDevOfflineBrowseStore.getState().setForceOffline(false);
vi.mocked(pingWithCredentials).mockReset(); vi.mocked(pingWithCredentialsForProfile).mockReset();
}); });
afterEach(() => { afterEach(() => {
@@ -49,7 +50,7 @@ describe('useConnectionStatus.isLan', () => {
// LAN endpoint answers — alternateUrl is the LAN side here, so a // LAN endpoint answers — alternateUrl is the LAN side here, so a
// primary-url-only check would say "public". We assert it says "local" // primary-url-only check would say "public". We assert it says "local"
// (active endpoint kind). // (active endpoint kind).
vi.mocked(pingWithCredentials).mockImplementation(async url => vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
url === 'http://192.168.0.10' url === 'http://192.168.0.10'
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true } ? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
: { ok: false }, : { ok: false },
@@ -63,7 +64,7 @@ describe('useConnectionStatus.isLan', () => {
it('falls back to public when only the public address answers', async () => { it('falls back to public when only the public address answers', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
seedDualAddressServer(); seedDualAddressServer();
vi.mocked(pingWithCredentials).mockImplementation(async url => vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
url === 'https://music.example.com' url === 'https://music.example.com'
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true } ? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
: { ok: false }, : { ok: false },
@@ -88,7 +89,7 @@ describe('useConnectionStatus.isLan', () => {
seedDualAddressServer(); seedDualAddressServer();
// Don't resolve the ping — the hook is still in the `checking` state. // Don't resolve the ping — the hook is still in the `checking` state.
let _resolve: ((v: PickReachableResult) => void) | null = null; let _resolve: ((v: PickReachableResult) => void) | null = null;
vi.mocked(pingWithCredentials).mockReturnValue( vi.mocked(pingWithCredentialsForProfile).mockReturnValue(
new Promise(r => { new Promise(r => {
_resolve = ((res: PickReachableResult) => { _resolve = ((res: PickReachableResult) => {
if (res.ok) { if (res.ok) {
@@ -116,7 +117,7 @@ describe('useConnectionStatus online event', () => {
it('flushes the reachable-endpoint cache when the browser fires online', async () => { it('flushes the reachable-endpoint cache when the browser fires online', async () => {
seedDualAddressServer(); seedDualAddressServer();
// Initial probe: LAN answers. // Initial probe: LAN answers.
vi.mocked(pingWithCredentials).mockImplementation(async url => vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
url === 'http://192.168.0.10' url === 'http://192.168.0.10'
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true } ? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
: { ok: false }, : { ok: false },
@@ -129,8 +130,8 @@ describe('useConnectionStatus online event', () => {
// fire in this test; we trigger the online event instead. The handler // fire in this test; we trigger the online event instead. The handler
// invalidates the sticky cache so the next probe goes LAN-first and // invalidates the sticky cache so the next probe goes LAN-first and
// flips over to public when LAN refuses. // flips over to public when LAN refuses.
vi.mocked(pingWithCredentials).mockClear(); vi.mocked(pingWithCredentialsForProfile).mockClear();
vi.mocked(pingWithCredentials).mockImplementation(async url => vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
url === 'https://music.example.com' url === 'https://music.example.com'
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true } ? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
: { ok: false }, : { ok: false },
@@ -147,14 +148,14 @@ describe('useConnectionStatus online event', () => {
await waitFor(() => expect(result.current.isLan).toBe(false)); await waitFor(() => expect(result.current.isLan).toBe(false));
// Both endpoints were probed (LAN refused, public answered). // 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', () => { describe('useConnectionStatus DEV offline toggle', () => {
it('does not probe again on mount beyond the polling effect', async () => { it('does not probe again on mount beyond the polling effect', async () => {
seedDualAddressServer(); seedDualAddressServer();
vi.mocked(pingWithCredentials).mockResolvedValue({ vi.mocked(pingWithCredentialsForProfile).mockResolvedValue({
ok: true, ok: true,
type: 'navidrome', type: 'navidrome',
serverVersion: '0.55.0', serverVersion: '0.55.0',
@@ -162,15 +163,15 @@ describe('useConnectionStatus DEV offline toggle', () => {
}); });
renderHook(() => useConnectionStatus()); renderHook(() => useConnectionStatus());
await waitFor(() => expect(vi.mocked(pingWithCredentials).mock.calls.length).toBeGreaterThanOrEqual(1)); await waitFor(() => expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBeGreaterThanOrEqual(1));
const callsAfterMount = vi.mocked(pingWithCredentials).mock.calls.length; const callsAfterMount = vi.mocked(pingWithCredentialsForProfile).mock.calls.length;
await new Promise(r => setTimeout(r, 20)); 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 () => { it('disconnects on force-offline toggle without an extra probe', async () => {
seedDualAddressServer(); seedDualAddressServer();
vi.mocked(pingWithCredentials).mockResolvedValue({ vi.mocked(pingWithCredentialsForProfile).mockResolvedValue({
ok: true, ok: true,
type: 'navidrome', type: 'navidrome',
serverVersion: '0.55.0', serverVersion: '0.55.0',
@@ -179,10 +180,10 @@ describe('useConnectionStatus DEV offline toggle', () => {
const { result } = renderHook(() => useConnectionStatus()); const { result } = renderHook(() => useConnectionStatus());
await waitFor(() => expect(result.current.status).toBe('connected')); 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)); act(() => useDevOfflineBrowseStore.getState().setForceOffline(true));
await waitFor(() => expect(result.current.status).toBe('disconnected')); 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.', shareUsesLocalUrlDesc: 'Wirkt sich auf Orbit-Einladungen und Bibliotheks-Freigaben aus. Standard: öffentliche Adresse.',
serverUsername: 'Benutzername', serverUsername: 'Benutzername',
serverPassword: 'Passwort', 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', addServer: 'Server hinzufügen',
addServerTitle: 'Neuen Server hinzufügen', addServerTitle: 'Neuen Server hinzufügen',
editServer: 'Bearbeiten', editServer: 'Bearbeiten',
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: 'Affects Orbit invites and library shares. Default: public address.', shareUsesLocalUrlDesc: 'Affects Orbit invites and library shares. Default: public address.',
serverUsername: 'Username', serverUsername: 'Username',
serverPassword: 'Password', 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', addServer: 'Add Server',
addServerTitle: 'Add New Server', addServerTitle: 'Add New Server',
editServer: 'Edit', 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.', shareUsesLocalUrlDesc: 'Afecta a las invitaciones de Orbit y a los enlaces de la biblioteca. Por defecto: dirección pública.',
serverUsername: 'Usuario', serverUsername: 'Usuario',
serverPassword: 'Contraseña', 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', addServer: 'Agregar Servidor',
addServerTitle: 'Agregar Nuevo Servidor', addServerTitle: 'Agregar Nuevo Servidor',
editServer: 'Editar', 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.', shareUsesLocalUrlDesc: 'Affecte les invitations Orbit et les partages de bibliothèque. Par défaut : adresse publique.',
serverUsername: 'Nom d\'utilisateur', serverUsername: 'Nom d\'utilisateur',
serverPassword: 'Mot de passe', 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', addServer: 'Ajouter un serveur',
addServerTitle: 'Ajouter un nouveau serveur', addServerTitle: 'Ajouter un nouveau serveur',
editServer: 'Modifier', 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.', shareUsesLocalUrlDesc: 'Érinti az Orbit-meghívókat és a könyvtármegosztásokat. Alapértelmezett: nyilvános cím.',
serverUsername: 'Felhasználónév', serverUsername: 'Felhasználónév',
serverPassword: 'Jelszó', 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', addServer: 'Szerver hozzáadása',
addServerTitle: 'Új szerver hozzáadása', addServerTitle: 'Új szerver hozzáadása',
editServer: 'Szerkesztés', editServer: 'Szerkesztés',
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: 'Orbit 招待とライブラリ共有に影響します。既定: 公開アドレス。', shareUsesLocalUrlDesc: 'Orbit 招待とライブラリ共有に影響します。既定: 公開アドレス。',
serverUsername: 'ユーザー名', serverUsername: 'ユーザー名',
serverPassword: 'パスワード', 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: 'サーバーを追加', addServer: 'サーバーを追加',
addServerTitle: '新しいサーバーを追加', addServerTitle: '新しいサーバーを追加',
editServer: '編集', editServer: '編集',
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: 'Påvirker Orbit-invitasjoner og biblioteksdeling. Standard: offentlig adresse.', shareUsesLocalUrlDesc: 'Påvirker Orbit-invitasjoner og biblioteksdeling. Standard: offentlig adresse.',
serverUsername: 'Brukernavn', serverUsername: 'Brukernavn',
serverPassword: 'Passord', 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', addServer: 'Legg til tjener',
addServerTitle: 'Legg til ny tjener', addServerTitle: 'Legg til ny tjener',
editServer: 'Rediger', editServer: 'Rediger',
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: 'Geldt voor Orbit-uitnodigingen en bibliotheekdelen. Standaard: openbaar adres.', shareUsesLocalUrlDesc: 'Geldt voor Orbit-uitnodigingen en bibliotheekdelen. Standaard: openbaar adres.',
serverUsername: 'Gebruikersnaam', serverUsername: 'Gebruikersnaam',
serverPassword: 'Wachtwoord', 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', addServer: 'Server toevoegen',
addServerTitle: 'Nieuwe server toevoegen', addServerTitle: 'Nieuwe server toevoegen',
editServer: 'Bewerken', editServer: 'Bewerken',
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: 'Afectează invitațiile Orbit și partajările bibliotecii. Implicit: adresa publică.', shareUsesLocalUrlDesc: 'Afectează invitațiile Orbit și partajările bibliotecii. Implicit: adresa publică.',
serverUsername: 'Nume utilizator', serverUsername: 'Nume utilizator',
serverPassword: 'Parolă', 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', addServer: 'Adaugă Server',
addServerTitle: 'Adaugă Server nou', addServerTitle: 'Adaugă Server nou',
editServer: 'Editează', editServer: 'Editează',
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: 'Применяется к приглашениям Orbit и ссылкам на библиотеку. По умолчанию — публичный адрес.', shareUsesLocalUrlDesc: 'Применяется к приглашениям Orbit и ссылкам на библиотеку. По умолчанию — публичный адрес.',
serverUsername: 'Логин', serverUsername: 'Логин',
serverPassword: 'Пароль', 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: 'Добавить сервер', addServer: 'Добавить сервер',
addServerTitle: 'Новый сервер', addServerTitle: 'Новый сервер',
editServer: 'Изменить', editServer: 'Изменить',
+18
View File
@@ -41,6 +41,24 @@ export const settings = {
shareUsesLocalUrlDesc: '影响 Orbit 邀请和资料库分享。默认:公共地址。', shareUsesLocalUrlDesc: '影响 Orbit 邀请和资料库分享。默认:公共地址。',
serverUsername: '用户名', serverUsername: '用户名',
serverPassword: '密码', 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: '添加服务器', addServer: '添加服务器',
addServerTitle: '添加新服务器', addServerTitle: '添加新服务器',
editServer: '编辑', editServer: '编辑',
+2
View File
@@ -20,6 +20,7 @@ import {
DEFAULT_LIBRARY_GRID_MAX_COLUMNS, DEFAULT_LIBRARY_GRID_MAX_COLUMNS,
} from './authStoreDefaults'; } from './authStoreDefaults';
import { computeAuthStoreRehydration } from './authStoreRehydrate'; import { computeAuthStoreRehydration } from './authStoreRehydrate';
import { syncAllServerHttpContexts } from '../utils/server/syncServerHttpContext';
import type { AuthState } from './authStoreTypes'; import type { AuthState } from './authStoreTypes';
import { getCachedConnectBaseUrl } from '../utils/server/serverEndpoint'; import { getCachedConnectBaseUrl } from '../utils/server/serverEndpoint';
import { serverProfileBaseUrl } from '../utils/server/serverBaseUrl'; import { serverProfileBaseUrl } from '../utils/server/serverBaseUrl';
@@ -172,6 +173,7 @@ export const useAuthStore = create<AuthState>()(
onRehydrateStorage: () => (state, error) => { onRehydrateStorage: () => (state, error) => {
if (error || !state) return; if (error || !state) return;
useAuthStore.setState(computeAuthStoreRehydration(state)); useAuthStore.setState(computeAuthStoreRehydration(state));
void syncAllServerHttpContexts(useAuthStore.getState().servers);
}, },
} }
) )
+21
View File
@@ -6,6 +6,23 @@ import type {
} from '../utils/server/subsonicServerIdentity'; } from '../utils/server/subsonicServerIdentity';
import type { PersistedAccount } from '../music-network'; 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 { export interface ServerProfile {
id: string; id: string;
name: string; name: string;
@@ -30,6 +47,10 @@ export interface ServerProfile {
shareUsesLocalUrl?: boolean; shareUsesLocalUrl?: boolean;
username: string; username: string;
password: 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'; 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. // Listing every export the store uses keeps the override stable.
vi.mock('@/api/subsonic', () => ({ vi.mock('@/api/subsonic', () => ({
pingWithCredentials: vi.fn(async () => ({ ok: true })), pingWithCredentials: vi.fn(async () => ({ ok: true })),
pingWithCredentialsForProfile: vi.fn(async () => ({ ok: true })),
scheduleInstantMixProbeForServer: vi.fn(),
})); }));
vi.mock('@/api/subsonicPlayQueue', () => ({ vi.mock('@/api/subsonicPlayQueue', () => ({
savePlayQueue: vi.fn(async () => undefined), savePlayQueue: vi.fn(async () => undefined),
+2
View File
@@ -8,6 +8,7 @@ import { useAuthStore } from '../../store/authStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore'; import { useLibraryIndexStore } from '../../store/libraryIndexStore';
import { ensureConnectUrlResolved } from '../server/serverEndpoint'; import { ensureConnectUrlResolved } from '../server/serverEndpoint';
import { serverIndexKeyForProfile } from '../server/serverIndexKey'; import { serverIndexKeyForProfile } from '../server/serverIndexKey';
import { syncServerHttpContextForProfile } from '../server/syncServerHttpContext';
import { libraryDevEnabled, logLibraryStatus, logLibrarySync, timed } from './libraryDevLog'; import { libraryDevEnabled, logLibraryStatus, logLibrarySync, timed } from './libraryDevLog';
export type BindServerResult = 'bound' | 'offline' | 'error'; export type BindServerResult = 'bound' | 'offline' | 'error';
@@ -49,6 +50,7 @@ export async function bindIndexedServer(server: ServerProfile): Promise<BindServ
}); });
logLibraryStatus(server.id, status, 'bind_session'); logLibraryStatus(server.id, status, 'bind_session');
} }
void syncServerHttpContextForProfile(server);
return 'bound'; return 'bound';
} catch { } catch {
return 'error'; return 'error';
+11
View File
@@ -34,4 +34,15 @@ describe('sanitizeLogLine', () => {
expect(out).toContain('password=REDACTED'); expect(out).toContain('password=REDACTED');
expect(out).not.toContain('sekrit'); 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 = [ const SENSITIVE_KV_KEYS = [
'password', 'passwd', 'token', 'secret', 'api_key', 'apikey', 'password', 'passwd', 'token', 'secret', 'api_key', 'apikey',
'access_token', 'refresh_token', 'authorization', 'auth', '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 { function isIpv4LanLiteral(ip: string): boolean {
const parts = ip.split('.'); const parts = ip.split('.');
if (parts.length !== 4) return false; if (parts.length !== 4) return false;
@@ -175,6 +179,10 @@ function redactBearerTokens(line: string): string {
return s; return s;
} }
function redactPangolinHeaders(line: string): string {
return line.replace(PANGOLIN_HEADER_RE, '$1REDACTED');
}
function redactSensitiveKeyValues(line: string): string { function redactSensitiveKeyValues(line: string): string {
let out = line; let out = line;
for (const key of SENSITIVE_KV_KEYS) { for (const key of SENSITIVE_KV_KEYS) {
@@ -231,5 +239,5 @@ function redactUrlsInText(line: string): string {
} }
export function sanitizeLogLine(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', () => ({ vi.mock('../../api/subsonic', () => ({
pingWithCredentials: vi.fn(), pingWithCredentials: vi.fn(),
pingWithCredentialsForProfile: vi.fn(),
})); }));
import { pingWithCredentials } from '../../api/subsonic'; import { pingWithCredentialsForProfile } from '../../api/subsonic';
import { import {
allNormalizedAddresses, allNormalizedAddresses,
ensureConnectUrlResolved, ensureConnectUrlResolved,
@@ -47,7 +48,7 @@ function pingFail() {
const CONNECT_PROBE_ATTEMPTS = 3; const CONNECT_PROBE_ATTEMPTS = 3;
function mockDualAddressLanFailPublicOk() { 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(); if (url === 'http://192.168.0.10') return pingFail();
return pingOk(); return pingOk();
}); });
@@ -212,7 +213,7 @@ describe('serverAddressEndpoints', () => {
describe('pickReachableBaseUrl', () => { describe('pickReachableBaseUrl', () => {
beforeEach(() => { beforeEach(() => {
invalidateReachableEndpointCache(); invalidateReachableEndpointCache();
vi.mocked(pingWithCredentials).mockReset(); vi.mocked(pingWithCredentialsForProfile).mockReset();
}); });
afterEach(() => { afterEach(() => {
@@ -220,7 +221,7 @@ describe('pickReachableBaseUrl', () => {
}); });
it('returns the single endpoint when it pings ok and caches it', async () => { 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' })); const result = await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (result.ok) { if (result.ok) {
@@ -230,11 +231,11 @@ describe('pickReachableBaseUrl', () => {
expect(result.ping.type).toBe('navidrome'); expect(result.ping.type).toBe('navidrome');
} }
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com'); 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 () => { 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( const result = await pickReachableBaseUrl(
makeProfile({ makeProfile({
url: 'https://music.example.com', url: 'https://music.example.com',
@@ -243,8 +244,8 @@ describe('pickReachableBaseUrl', () => {
); );
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10'); if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10');
expect(pingWithCredentials).toHaveBeenCalledTimes(1); expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
expect(vi.mocked(pingWithCredentials).mock.calls[0]![0]).toBe('http://192.168.0.10'); 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 () => { it('falls through to the public endpoint when LAN ping fails', async () => {
@@ -260,13 +261,13 @@ describe('pickReachableBaseUrl', () => {
const result = await promise; const result = await promise;
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com'); 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'); expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
}); });
it('retries a flaky endpoint before declaring it unreachable', async () => { it('retries a flaky endpoint before declaring it unreachable', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.mocked(pingWithCredentials) vi.mocked(pingWithCredentialsForProfile)
.mockResolvedValueOnce(pingFail()) .mockResolvedValueOnce(pingFail())
.mockResolvedValueOnce(pingOk()); .mockResolvedValueOnce(pingOk());
const promise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' })); const promise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
@@ -274,32 +275,32 @@ describe('pickReachableBaseUrl', () => {
const result = await promise; const result = await promise;
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com'); if (result.ok) expect(result.baseUrl).toBe('https://music.example.com');
expect(pingWithCredentials).toHaveBeenCalledTimes(2); expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(2);
vi.useRealTimers(); vi.useRealTimers();
}); });
it('returns unreachable and clears cache when every endpoint fails', async () => { 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. // Seed a stale cache entry first.
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' })); await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com'); expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
vi.mocked(pingWithCredentials).mockReset(); vi.mocked(pingWithCredentialsForProfile).mockReset();
vi.mocked(pingWithCredentials).mockResolvedValue(pingFail()); vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingFail());
vi.useFakeTimers(); vi.useFakeTimers();
const unreachablePromise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' })); const unreachablePromise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
const result = await unreachablePromise; const result = await unreachablePromise;
expect(result).toEqual({ ok: false, reason: 'unreachable' }); expect(result).toEqual({ ok: false, reason: 'unreachable' });
expect(getCachedConnectBaseUrl('profile-1')).toBeNull(); 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 () => { it('returns unreachable when the profile has no usable url', async () => {
const result = await pickReachableBaseUrl(makeProfile({ url: '' })); const result = await pickReachableBaseUrl(makeProfile({ url: '' }));
expect(result).toEqual({ ok: false, reason: 'unreachable' }); 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 () => { it('tries the cached endpoint first on subsequent calls (sticky)', async () => {
@@ -308,18 +309,18 @@ describe('pickReachableBaseUrl', () => {
alternateUrl: 'http://192.168.0.10', alternateUrl: 'http://192.168.0.10',
}); });
// First call: LAN responds ok, becomes cached. // First call: LAN responds ok, becomes cached.
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile); await pickReachableBaseUrl(profile);
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10'); expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
// Second call: cached URL is tried first; sole ping happens against it. // Second call: cached URL is tried first; sole ping happens against it.
vi.mocked(pingWithCredentials).mockClear(); vi.mocked(pingWithCredentialsForProfile).mockClear();
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
const result = await pickReachableBaseUrl(profile); const result = await pickReachableBaseUrl(profile);
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10'); if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10');
expect(pingWithCredentials).toHaveBeenCalledTimes(1); expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
expect(vi.mocked(pingWithCredentials).mock.calls[0]![0]).toBe('http://192.168.0.10'); 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 () => { 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 // cache write, with last-write-wins potentially clobbering the correct
// LAN sticky a millisecond after it was set. // LAN sticky a millisecond after it was set.
let resolvePing: ((v: ReturnType<typeof pingOk>) => void) | null = null; let resolvePing: ((v: ReturnType<typeof pingOk>) => void) | null = null;
vi.mocked(pingWithCredentials).mockReturnValueOnce( vi.mocked(pingWithCredentialsForProfile).mockReturnValueOnce(
new Promise(r => { new Promise(r => {
resolvePing = r; resolvePing = r;
}), }),
@@ -338,7 +339,7 @@ describe('pickReachableBaseUrl', () => {
const p2 = pickReachableBaseUrl(profile); const p2 = pickReachableBaseUrl(profile);
// Both calls saw a pending probe — only one ping should have been fired. // Both calls saw a pending probe — only one ping should have been fired.
expect(pingWithCredentials).toHaveBeenCalledTimes(1); expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
resolvePing!(pingOk()); resolvePing!(pingOk());
const [r1, r2] = await Promise.all([p1, p2]); 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 () => { it('starts a fresh probe after the in-flight one settles', async () => {
// Once the previous probe resolves, the in-flight slot is freed and // Once the previous probe resolves, the in-flight slot is freed and
// the next call hits the network again (subject to the sticky cache). // 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' })); await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' }));
vi.mocked(pingWithCredentials).mockClear(); vi.mocked(pingWithCredentialsForProfile).mockClear();
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' })); 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 () => { 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', url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10', alternateUrl: 'http://192.168.0.10',
}); });
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile); await pickReachableBaseUrl(profile);
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10'); expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
// LAN now fails; public answers. // LAN now fails; public answers.
vi.mocked(pingWithCredentials).mockClear(); vi.mocked(pingWithCredentialsForProfile).mockClear();
vi.useFakeTimers(); vi.useFakeTimers();
mockDualAddressLanFailPublicOk(); mockDualAddressLanFailPublicOk();
const fallbackPromise = pickReachableBaseUrl(profile); const fallbackPromise = pickReachableBaseUrl(profile);
@@ -388,13 +389,13 @@ describe('pickReachableBaseUrl', () => {
describe('invalidateReachableEndpointCache', () => { describe('invalidateReachableEndpointCache', () => {
beforeEach(() => { beforeEach(() => {
invalidateReachableEndpointCache(); invalidateReachableEndpointCache();
vi.mocked(pingWithCredentials).mockReset(); vi.mocked(pingWithCredentialsForProfile).mockReset();
}); });
it('clears a specific profile', async () => { it('clears a specific profile', async () => {
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await ensureConnectUrlResolved(makeProfile({ id: 'a' })); await ensureConnectUrlResolved(makeProfile({ id: 'a' }));
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await ensureConnectUrlResolved(makeProfile({ id: 'b' })); await ensureConnectUrlResolved(makeProfile({ id: 'b' }));
expect(getCachedConnectBaseUrl('a')).not.toBeNull(); expect(getCachedConnectBaseUrl('a')).not.toBeNull();
expect(getCachedConnectBaseUrl('b')).not.toBeNull(); expect(getCachedConnectBaseUrl('b')).not.toBeNull();
@@ -405,7 +406,7 @@ describe('invalidateReachableEndpointCache', () => {
}); });
it('clears everything when called with no argument', async () => { it('clears everything when called with no argument', async () => {
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await ensureConnectUrlResolved(makeProfile({ id: 'a' })); await ensureConnectUrlResolved(makeProfile({ id: 'a' }));
invalidateReachableEndpointCache(); invalidateReachableEndpointCache();
expect(getCachedConnectBaseUrl('a')).toBeNull(); expect(getCachedConnectBaseUrl('a')).toBeNull();
@@ -415,7 +416,7 @@ describe('invalidateReachableEndpointCache', () => {
describe('subscribeConnectCache — connect-URL flip notifications', () => { describe('subscribeConnectCache — connect-URL flip notifications', () => {
beforeEach(() => { beforeEach(() => {
invalidateReachableEndpointCache(); invalidateReachableEndpointCache();
vi.mocked(pingWithCredentials).mockReset(); vi.mocked(pingWithCredentialsForProfile).mockReset();
}); });
it('notifies when a probe resolves a new endpoint and on a later flip', async () => { 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. // First probe: LAN answers → cache set → one notification.
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile); await pickReachableBaseUrl(profile);
expect(listener).toHaveBeenCalledTimes(1); 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 () => { it('does not notify when the sticky endpoint is unchanged', async () => {
const profile = makeProfile({ url: 'http://192.168.0.10' }); const profile = makeProfile({ url: 'http://192.168.0.10' });
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile); await pickReachableBaseUrl(profile);
const listener = vi.fn(); const listener = vi.fn();
const unsubscribe = subscribeConnectCache(listener); const unsubscribe = subscribeConnectCache(listener);
// Re-probe, same endpoint answers → cache value identical → no notification. // Re-probe, same endpoint answers → cache value identical → no notification.
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile); await pickReachableBaseUrl(profile);
expect(listener).not.toHaveBeenCalled(); 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 () => { 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' })); await pickReachableBaseUrl(makeProfile({ id: 'a' }));
const listener = vi.fn(); 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 { PingWithCredentialsResult } from '../../api/subsonicTypes';
import type { ServerProfile } from '../../store/authStoreTypes'; import type { ServerProfile } from '../../store/authStoreTypes';
import { serverProfileBaseUrl } from './serverBaseUrl'; 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. * proxy TLS flakes) before the connection indicator marks the server down.
*/ */
async function pingWithConnectRetries( async function pingWithConnectRetries(
baseUrl: string, profile: ServerProfile,
username: string, endpointUrl: string,
password: string,
): Promise<PingWithCredentialsResult> { ): Promise<PingWithCredentialsResult> {
let ping = await pingWithCredentials(baseUrl, username, password); let ping = await pingWithCredentialsForProfile(profile, endpointUrl);
if (ping.ok) return ping; if (ping.ok) return ping;
for (let retry = 0; retry < CONNECT_PING_RETRIES; retry++) { for (let retry = 0; retry < CONNECT_PING_RETRIES; retry++) {
await sleepMs(CONNECT_PING_RETRY_DELAY_MS); await sleepMs(CONNECT_PING_RETRY_DELAY_MS);
ping = await pingWithCredentials(baseUrl, username, password); ping = await pingWithCredentialsForProfile(profile, endpointUrl);
if (ping.ok) return ping; if (ping.ok) return ping;
} }
return ping; return ping;
@@ -309,7 +308,7 @@ export async function pickReachableBaseUrl(
: ordered; : ordered;
for (const endpoint of endpoints) { 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) { if (ping.ok) {
const prev = connectCache.get(profile.id); const prev = connectCache.get(profile.id);
connectCache.set(profile.id, endpoint.url); connectCache.set(profile.id, endpoint.url);
+62 -9
View File
@@ -15,9 +15,16 @@
*/ */
import md5 from 'md5'; 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 type { ServerProfile } from '../../store/authStoreTypes';
import { allNormalizedAddresses } from './serverEndpoint'; import { allNormalizedAddresses } from './serverEndpoint';
import { headersForServerRequest } from './serverHttpHeaders';
export type ServerFingerprint = { export type ServerFingerprint = {
ping: { ping: {
@@ -55,6 +62,7 @@ async function fetchPingFingerprint(
baseUrl: string, baseUrl: string,
username: string, username: string,
password: string, password: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<PingFingerprint> { ): Promise<PingFingerprint> {
// Mirrors pingWithCredentials but also extracts envelope `version`. // Mirrors pingWithCredentials but also extracts envelope `version`.
// Using fetch (the bundled axios pulls in extra noise here; one call is fine). // Using fetch (the bundled axios pulls in extra noise here; one call is fine).
@@ -69,9 +77,18 @@ async function fetchPingFingerprint(
f: 'json', f: 'json',
}); });
const url = `${restBaseFromUrl(baseUrl)}/ping.view?${params.toString()}`; 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; let body: Record<string, unknown> | null = null;
try { 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}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const json = (await resp.json()) as Record<string, unknown>; const json = (await resp.json()) as Record<string, unknown>;
body = (json?.['subsonic-response'] as Record<string, unknown>) ?? null; body = (json?.['subsonic-response'] as Record<string, unknown>) ?? null;
@@ -171,12 +188,13 @@ export async function fetchServerFingerprint(
baseUrl: string, baseUrl: string,
username: string, username: string,
password: string, password: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<ServerFingerprint> { ): Promise<ServerFingerprint> {
// Ping is required — but we still build a (mostly-null) fingerprint when // Ping is required — but we still build a (mostly-null) fingerprint when
// it fails so callers can tell the difference between "server unreachable" // it fails so callers can tell the difference between "server unreachable"
// (verify reports `unreachable`) and "server reachable but disagrees" // (verify reports `unreachable`) and "server reachable but disagrees"
// (verify reports `mismatch` / `insufficient`). // (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, // The optional calls only make sense once ping succeeded — without that,
// any subsequent call against the same URL is just wasted bandwidth. // any subsequent call against the same URL is just wasted bandwidth.
@@ -196,10 +214,42 @@ export async function fetchServerFingerprint(
} }
const settled = await Promise.allSettled([ const settled = await Promise.allSettled([
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getMusicFolders.view'), apiWithCredentials<Record<string, unknown>>(
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getUser.view', { username }), baseUrl,
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getLicense.view'), username,
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getIndexes.view'), 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; const [foldersResult, userResult, licenseResult, indexesResult] = settled;
@@ -299,7 +349,10 @@ function musicFoldersEqual(
* `ok: true` nothing to verify. * `ok: true` nothing to verify.
*/ */
export async function verifySameServerEndpoints( export async function verifySameServerEndpoints(
profile: Pick<ServerProfile, 'url' | 'alternateUrl'>, profile: Pick<
ServerProfile,
'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
>,
username: string, username: string,
password: string, password: string,
): Promise<VerifySameServerResult> { ): Promise<VerifySameServerResult> {
@@ -307,7 +360,7 @@ export async function verifySameServerEndpoints(
if (endpoints.length <= 1) return { ok: true }; if (endpoints.length <= 1) return { ok: true };
const fingerprints = await Promise.all( 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). // 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 { markQueueHandoffPending } from '../../store/queueSyncUiState';
import { endOrbitSession, leaveOrbitSession } from '../orbit'; import { endOrbitSession, leaveOrbitSession } from '../orbit';
import { ensureConnectUrlResolved } from './serverEndpoint'; import { ensureConnectUrlResolved } from './serverEndpoint';
import { syncServerHttpContextForProfile } from './syncServerHttpContext';
export async function switchActiveServer(server: ServerProfile): Promise<boolean> { export async function switchActiveServer(server: ServerProfile): Promise<boolean> {
coverTrafficBeginServerSwitch(); coverTrafficBeginServerSwitch();
@@ -56,6 +57,7 @@ export async function switchActiveServer(server: ServerProfile): Promise<boolean
if (oldActiveId && oldActiveId !== server.id) { if (oldActiveId && oldActiveId !== server.id) {
markQueueHandoffPending(); markQueueHandoffPending();
} }
void syncServerHttpContextForProfile(server);
return true; return true;
} catch { } catch {
return false; 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.username,
sharedServer.password, sharedServer.password,
'song-1', 'song-1',
sharedServer,
); );
expect(mocks.getSong).not.toHaveBeenCalled(); expect(mocks.getSong).not.toHaveBeenCalled();
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled(); expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
@@ -147,12 +148,14 @@ describe('share search payload resolution', () => {
sharedServer.username, sharedServer.username,
sharedServer.password, sharedServer.password,
'album-1', 'album-1',
sharedServer,
); );
expect(mocks.getArtistWithCredentials).toHaveBeenCalledWith( expect(mocks.getArtistWithCredentials).toHaveBeenCalledWith(
sharedServer.url, sharedServer.url,
sharedServer.username, sharedServer.username,
sharedServer.password, sharedServer.password,
'artist-1', 'artist-1',
sharedServer,
); );
expect(mocks.getAlbum).not.toHaveBeenCalled(); expect(mocks.getAlbum).not.toHaveBeenCalled();
expect(mocks.getArtist).not.toHaveBeenCalled(); expect(mocks.getArtist).not.toHaveBeenCalled();
@@ -172,6 +175,7 @@ describe('share search payload resolution', () => {
sharedServer.username, sharedServer.username,
sharedServer.password, sharedServer.password,
'composer-1', 'composer-1',
sharedServer,
); );
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled(); expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
}); });
@@ -110,6 +110,7 @@ async function resolveSharedSong(
lookup.server.username, lookup.server.username,
lookup.server.password, lookup.server.password,
id, id,
lookup.server,
); );
} }
@@ -187,6 +188,7 @@ export async function resolveShareSearchAlbum(
lookup.server.username, lookup.server.username,
lookup.server.password, lookup.server.password,
payload.id, payload.id,
lookup.server,
); );
return { type: 'ok', album }; return { type: 'ok', album };
} catch { } catch {
@@ -214,6 +216,7 @@ export async function resolveShareSearchArtist(
lookup.server.username, lookup.server.username,
lookup.server.password, lookup.server.password,
payload.id, payload.id,
lookup.server,
); );
return { type: 'ok', artist }; return { type: 'ok', artist };
} catch { } catch {