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
@@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex, OnceLock};
use tauri::{Emitter, Manager};
use psysonic_core::ports::PlaybackQueryHandle;
use psysonic_core::server_http::{apply_optional_registry_headers, ServerHttpRegistry};
use psysonic_core::user_agent::subsonic_wire_user_agent;
use psysonic_core::track_enrichment::TrackEnrichmentOutcome;
@@ -679,10 +680,22 @@ fn analysis_http_client() -> &'static reqwest::Client {
})
}
async fn analysis_backfill_download_bytes(url: &str) -> Result<(Vec<u8>, u64), String> {
async fn analysis_backfill_download_bytes(
app: &tauri::AppHandle,
server_id: &str,
url: &str,
) -> Result<(Vec<u8>, u64), String> {
let fetch_started = std::time::Instant::now();
let response = analysis_http_client()
.get(url)
let registry = app
.try_state::<Arc<ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let request = apply_optional_registry_headers(
registry.as_deref(),
Some(server_id),
url,
analysis_http_client().get(url),
);
let response = request
.send()
.await
.map_err(|e| e.to_string())?;
@@ -781,7 +794,7 @@ async fn spawn_backfill_slots(app: &tauri::AppHandle, shared: &Arc<AnalysisBackf
let app = app.clone();
let shared = shared.clone();
tauri::async_runtime::spawn(async move {
let download_result = analysis_backfill_download_bytes(&url).await;
let download_result = analysis_backfill_download_bytes(&app, &server_id, &url).await;
{
let mut st = shared
.state
@@ -11,7 +11,7 @@ use rodio::Source;
use tauri::{AppHandle, Emitter, State};
use super::decode::build_source;
use super::engine::{audio_http_client, AudioEngine};
use super::engine::AudioEngine;
use super::helpers::*;
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
use super::play_input::{select_play_input, url_format_hint, PlayInputContext};
@@ -597,7 +597,14 @@ pub async fn audio_chain_preload(
} else if let Some(path) = url.strip_prefix("psysonic-local://") {
tokio::fs::read(path).await.map_err(|e| e.to_string())?
} else {
let resp = audio_http_client(&state).get(&url).send().await
let resp = crate::engine::playback_scoped_get(
&state,
&app,
&url,
server_id.as_deref(),
)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Ok(()); // silently fail — audio_play will retry
@@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use rodio::Player;
use tauri::Manager;
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
@@ -570,3 +571,83 @@ pub fn refresh_http_user_agent(state: &AudioEngine, ua: &str) {
*slot = client;
}
}
pub(crate) fn apply_playback_request_headers(
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: Option<&str>,
url: &str,
req: reqwest::RequestBuilder,
) -> reqwest::RequestBuilder {
if let Some(reg) = registry {
if let Some(sid) = server_id.filter(|s| !s.is_empty()) {
return reg.apply_for_http_url(sid, url, req);
}
if let Some(ctx) = reg.get_for_server_url(url) {
return psysonic_core::server_http::apply_server_headers_for_http_url(req, &ctx, url);
}
}
req
}
/// Custom HTTP headers for reverse-proxy gates — cloned into background download tasks.
#[derive(Clone, Default)]
pub(crate) struct PlaybackHttpHeaders {
registry: Option<Arc<psysonic_core::server_http::ServerHttpRegistry>>,
server_id: Option<String>,
}
impl PlaybackHttpHeaders {
pub fn from_app(app: &tauri::AppHandle, server_id: Option<&str>) -> Self {
Self {
registry: app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s)),
server_id: server_id.filter(|s| !s.is_empty()).map(str::to_string),
}
}
pub fn apply(&self, url: &str, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
apply_playback_request_headers(
self.registry.as_deref(),
self.server_id.as_deref(),
url,
req,
)
}
}
pub(crate) fn scoped_http_get(
state: &AudioEngine,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: Option<&str>,
url: &str,
) -> reqwest::RequestBuilder {
apply_playback_request_headers(
registry,
server_id,
url,
audio_http_client(state).get(url),
)
}
/// Resolve registry + server id for playback/preload HTTP GETs.
pub(crate) fn playback_scoped_get(
state: &AudioEngine,
app: &tauri::AppHandle,
url: &str,
server_id: Option<&str>,
) -> reqwest::RequestBuilder {
let registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let sid = server_id
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| state.current_playback_server_id.lock().unwrap().clone());
scoped_http_get(
state,
registry.as_deref(),
sid.as_deref(),
url,
)
}
@@ -708,7 +708,10 @@ pub(crate) async fn fetch_data(
return Ok(Some(data));
}
let response = crate::engine::audio_http_client(state).get(url).send().await.map_err(|e| e.to_string())?;
let response = crate::engine::playback_scoped_get(state, app, url, None)
.send()
.await
.map_err(|e| e.to_string())?;
let status = response.status();
let ct = response.headers()
.get(reqwest::header::CONTENT_TYPE)
@@ -15,7 +15,7 @@ use super::analysis_dispatch::{
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
TrackAnalysisOrigin,
};
use super::engine::{audio_http_client, AudioEngine};
use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders};
use super::helpers::{
content_type_to_hint, fetch_data, format_hint_from_content_disposition,
normalize_stream_suffix_for_hint, sniff_stream_format_extension,
@@ -217,7 +217,12 @@ async fn open_ranged_or_streaming_input(
state: &State<'_, AudioEngine>,
app: &AppHandle,
) -> Result<Option<PlayInput>, String> {
let response = audio_http_client(state).get(ctx.url).send().await.map_err(|e| e.to_string())?;
let http_headers = PlaybackHttpHeaders::from_app(app, ctx.server_id);
let response = http_headers
.apply(ctx.url, audio_http_client(state).get(ctx.url))
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
if state.generation.load(Ordering::SeqCst) != ctx.gen {
return Ok(None); // superseded
@@ -256,8 +261,8 @@ async fn open_ranged_or_streaming_input(
let last = total_u64
.saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = audio_http_client(state)
.get(ctx.url)
if let Ok(pr) = http_headers
.apply(ctx.url, audio_http_client(state).get(ctx.url))
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send()
.await
@@ -328,6 +333,7 @@ async fn open_ranged_or_streaming_input(
state.loudness_pre_analysis_attenuation_db.clone(),
ctx.cache_id_for_tasks.map(|s| s.to_string()),
ctx.server_id.map(|s| s.to_string()),
http_headers.clone(),
loudness_hold_for_defer,
playback_armed,
stream_hint.clone(),
@@ -347,6 +353,7 @@ async fn open_ranged_or_streaming_input(
total,
state.generation.clone(),
ctx.gen,
http_headers.clone(),
)));
let reader = RangedHttpSource {
buf,
@@ -401,6 +408,7 @@ async fn open_ranged_or_streaming_input(
state.loudness_pre_analysis_attenuation_db.clone(),
ctx.cache_id_for_tasks.map(|s| s.to_string()),
ctx.server_id.map(|s| s.to_string()),
http_headers,
playback_armed,
));
@@ -16,7 +16,7 @@ use super::analysis_dispatch::{
dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file,
TrackAnalysisOrigin,
};
use super::engine::{audio_http_client, AudioEngine};
use super::engine::AudioEngine;
use super::helpers::{analysis_cache_track_id, same_playback_target};
use super::state::PreloadedTrack;
@@ -197,7 +197,15 @@ pub async fn audio_preload(
}
}
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
let response = crate::engine::playback_scoped_get(
&state,
&app,
&url,
server_id.as_deref(),
)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
emit_preload_cancelled(&app, url, track_id_for_events);
return Ok(());
@@ -8,7 +8,7 @@ use rodio::Source;
use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder;
use super::engine::{audio_http_client, AudioEngine};
use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders};
use super::helpers::{
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
@@ -155,9 +155,10 @@ async fn open_preview_decoder(
state: &AudioEngine,
app: &AppHandle,
) -> Result<Option<SizedDecoder>, String> {
let http_headers = PlaybackHttpHeaders::from_app(app, None);
let preview_http = preview_http_client(state);
let response = preview_http
.get(url)
let response = http_headers
.apply(url, preview_http.get(url))
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
@@ -194,8 +195,8 @@ async fn open_preview_decoder(
let last = total_u64
.saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = preview_http
.get(url)
if let Ok(pr) = http_headers
.apply(url, preview_http.get(url))
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send()
.await
@@ -257,6 +258,7 @@ async fn open_preview_decoder(
state.loudness_pre_analysis_attenuation_db.clone(),
None,
None,
http_headers.clone(),
None,
playback_armed,
stream_hint.clone(),
@@ -21,6 +21,7 @@ use futures_util::StreamExt;
use symphonia::core::io::MediaSource;
use tauri::{AppHandle, Emitter};
use super::super::engine::PlaybackHttpHeaders;
use super::super::state::PreloadedTrack;
use super::{
RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS,
@@ -88,6 +89,7 @@ pub(crate) struct OnDemand {
/// Bumped after every completed (success or failure) fetch so the read loop
/// can reset its stall deadline while on-demand fetches make progress.
progress: AtomicU64,
http_headers: PlaybackHttpHeaders,
}
impl OnDemand {
@@ -100,6 +102,7 @@ impl OnDemand {
total_size: u64,
gen_arc: Arc<AtomicU64>,
gen: u64,
http_headers: PlaybackHttpHeaders,
) -> Self {
OnDemand {
http,
@@ -112,6 +115,7 @@ impl OnDemand {
filled: Mutex::new(Vec::new()),
inflight: Mutex::new(Vec::new()),
progress: AtomicU64::new(0),
http_headers,
}
}
@@ -154,6 +158,7 @@ impl OnDemand {
end_inclusive,
me.gen,
&me.gen_arc,
&me.http_headers,
)
.await;
if let Ok(written) = res {
@@ -367,6 +372,7 @@ pub(crate) async fn ranged_http_download_loop<F>(
downloaded_to: &Arc<AtomicUsize>,
gen: u64,
gen_arc: &Arc<AtomicU64>,
http_headers: &PlaybackHttpHeaders,
mut on_partial: F,
playback_armed: Option<&AtomicBool>,
) -> (usize, RangedHttpLoopOutcome)
@@ -387,6 +393,7 @@ where
if downloaded > 0 {
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
}
req = http_headers.apply(url, req);
match req.send().await {
Ok(r) => r,
Err(err) => {
@@ -487,6 +494,7 @@ where
}
/// Fetch `bytes=start-end` into `buf[start..=end]` (inclusive HTTP Range).
#[allow(clippy::too_many_arguments)]
async fn ranged_write_http_range(
http_client: &reqwest::Client,
url: &str,
@@ -495,13 +503,18 @@ async fn ranged_write_http_range(
end_inclusive: u64,
gen: u64,
gen_arc: &Arc<AtomicU64>,
http_headers: &PlaybackHttpHeaders,
) -> Result<usize, ()> {
if gen_arc.load(Ordering::SeqCst) != gen {
return Err(());
}
let response = http_client
.get(url)
.header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}"))
let response = http_headers
.apply(
url,
http_client
.get(url)
.header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}")),
)
.send()
.await
.map_err(|_| ())?;
@@ -555,6 +568,7 @@ async fn ranged_prefetch_mp4_tail(
playback_armed: Arc<AtomicBool>,
gen: u64,
gen_arc: Arc<AtomicU64>,
http_headers: PlaybackHttpHeaders,
) {
const MIN_TAIL: u64 = 256 * 1024;
const MAX_TAIL: u64 = 8 * 1024 * 1024;
@@ -573,6 +587,7 @@ async fn ranged_prefetch_mp4_tail(
end_inclusive,
gen,
&gen_arc,
&http_headers,
)
.await
{
@@ -622,6 +637,7 @@ pub(crate) async fn ranged_download_task(
cache_track_id: Option<String>,
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
server_id: Option<String>,
http_headers: PlaybackHttpHeaders,
// When `Some`, ranged playback seeds on completion — defer HTTP backfill for that
// track; `None` for large files where ranged skips seed (needs backfill).
loudness_seed_hold: Option<LoudnessSeedHold>,
@@ -705,6 +721,7 @@ pub(crate) async fn ranged_download_task(
let tail_from_bg = tail_filled_from.clone();
let armed_bg = playback_armed.clone();
let gen_bg = gen_arc.clone();
let headers_bg = http_headers.clone();
Some(tokio::spawn(async move {
ranged_prefetch_mp4_tail(
client,
@@ -716,6 +733,7 @@ pub(crate) async fn ranged_download_task(
armed_bg,
gen,
gen_bg,
headers_bg,
)
.await;
}))
@@ -736,6 +754,7 @@ pub(crate) async fn ranged_download_task(
&downloaded_to,
gen,
&gen_arc,
&http_headers,
on_partial,
linear_arm,
)
@@ -1127,6 +1146,7 @@ mod tests {
&dl,
1,
&gen_arc,
&PlaybackHttpHeaders::default(),
|_, _| {},
None,
)
@@ -1162,6 +1182,7 @@ mod tests {
&dl,
1,
&gen_arc,
&PlaybackHttpHeaders::default(),
|downloaded, total| calls.lock().unwrap().push((downloaded, total)),
None,
)
@@ -1190,7 +1211,7 @@ mod tests {
let (buf, dl, gen_arc) = loop_state(1024);
let (downloaded, outcome) =
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
.await;
assert_eq!(outcome, RangedHttpLoopOutcome::Aborted);
@@ -1221,7 +1242,7 @@ mod tests {
gen_arc.store(99, Ordering::SeqCst);
let (downloaded, outcome) =
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
.await;
assert_eq!(outcome, RangedHttpLoopOutcome::Superseded);
@@ -1280,7 +1301,7 @@ mod tests {
let (buf, dl, gen_arc) = loop_state(body.len());
let (downloaded, outcome) =
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
.await;
// Stream finishes via a Range-resumed second request.
@@ -1354,6 +1375,7 @@ mod tests {
total as u64,
gen_arc.clone(),
1,
PlaybackHttpHeaders::default(),
)));
let mut src = RangedHttpSource {
buf,
@@ -1406,6 +1428,7 @@ mod tests {
2047,
1,
&gen_arc,
&PlaybackHttpHeaders::default(),
)
.await;
@@ -1440,7 +1463,7 @@ mod tests {
let (buf, dl, gen_arc) = loop_state(body.len());
let (downloaded, outcome) =
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
.await;
// Reconnect server returned 200 instead of 206 → Aborted, downloaded
@@ -15,6 +15,7 @@ use ringbuf::HeapProd;
use ringbuf::traits::Producer;
use tauri::AppHandle;
use super::super::engine::PlaybackHttpHeaders;
use super::super::state::PreloadedTrack;
use super::{
maybe_arm_stream_playback, TRACK_STREAM_MAX_RECONNECTS, TRACK_STREAM_PROMOTE_MAX_BYTES,
@@ -37,6 +38,7 @@ pub(crate) async fn track_download_task(
cache_track_id: Option<String>,
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
server_id: Option<String>,
http_headers: PlaybackHttpHeaders,
playback_armed: Arc<AtomicBool>,
) {
let mut downloaded: u64 = 0;
@@ -53,6 +55,7 @@ pub(crate) async fn track_download_task(
if downloaded > 0 {
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
}
req = http_headers.apply(&url, req);
match req.send().await {
Ok(r) => r,
Err(err) => {
@@ -9,6 +9,8 @@ publish = false
[dependencies]
tauri = { version = "2" }
serde = { version = "1", features = ["derive"] }
reqwest = { version = "0.13", default-features = false, features = ["rustls"] }
url = "2"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -4,6 +4,7 @@
//! macros) and the cross-crate port traits used to break dependency cycles
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
pub mod server_http;
pub mod cover_cache_layout;
pub mod log_sanitize;
pub mod media_layout;
@@ -8,12 +8,14 @@ const SENSITIVE_QUERY_KEYS: &[&str] = &[
const SENSITIVE_KV_KEYS: &[&str] = &[
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token",
"refresh_token", "authorization", "auth",
"refresh_token", "authorization", "auth", "cookie", "x-api-key",
"cf-access-client-secret", "cf-access-client-id", "x-auth-token",
];
/// Sanitize one runtime log line for display and export.
pub fn sanitize_log_line(line: &str) -> String {
let mut out = redact_bearer_tokens(line);
out = redact_pangolin_headers(&out);
out = redact_sensitive_key_values(&out);
out = redact_urls_in_text(&out);
out
@@ -43,6 +45,37 @@ fn redact_bearer_tokens(line: &str) -> String {
s
}
fn redact_pangolin_headers(line: &str) -> String {
let lower = line.to_ascii_lowercase();
let mut out = line.to_string();
let mut search_from = 0;
while let Some(rel) = lower[search_from..].find("x-pangolin-") {
let idx = search_from + rel;
let after_prefix = &lower[idx..];
let Some(sep_rel) = after_prefix.find([':', '=']) else {
search_from = idx + 1;
continue;
};
let sep_idx = idx + sep_rel;
let val_start = sep_idx + 1;
let slice = &out[val_start..];
let trimmed = slice.trim_start();
let ws = slice.len().saturating_sub(trimmed.len());
let val_start = val_start + ws;
let end = trimmed
.find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')')
.unwrap_or(trimmed.len());
if end > 0 {
out.replace_range(val_start..val_start + end, "REDACTED");
}
search_from = val_start + "REDACTED".len();
if search_from >= out.len() {
break;
}
}
out
}
fn redact_sensitive_key_values(line: &str) -> String {
let mut out = line.to_string();
for key in SENSITIVE_KV_KEYS {
@@ -368,6 +401,17 @@ mod tests {
assert!(!out.contains("user:pass"));
}
#[test]
fn redacts_reverse_proxy_gate_headers() {
let line = "req CF-Access-Client-Secret: gate-secret Authorization: Bearer tok123 x-pangolin-auth: pangolin-key";
let out = sanitize_log_line(line);
assert!(out.contains("CF-Access-Client-Secret: REDACTED"));
assert!(!out.contains("gate-secret"));
assert!(!out.contains("tok123"));
assert!(out.contains("x-pangolin-auth: REDACTED"));
assert!(!out.contains("pangolin-key"));
}
#[test]
fn stream_log_with_em_dash_does_not_panic() {
let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")";
@@ -0,0 +1,366 @@
//! Per-server custom HTTP headers for reverse-proxy gates (Pangolin, Cloudflare Access).
//! Registry is keyed by index key; app server UUID aliases resolve via `ref_to_key`.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::RequestBuilder;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EndpointKind {
Local,
Public,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CustomHeadersApplyTo {
Local,
#[default]
Public,
Both,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ServerHttpEndpointWire {
pub url: String,
pub kind: EndpointKind,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CustomHeaderEntryWire {
pub name: String,
pub value: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ServerHttpContextSyncWire {
#[serde(rename = "serverId")]
pub server_id: String,
#[serde(rename = "appServerId")]
pub app_server_id: String,
pub endpoints: Vec<ServerHttpEndpointWire>,
#[serde(rename = "customHeaders", default)]
pub custom_headers: Vec<CustomHeaderEntryWire>,
#[serde(rename = "customHeadersApplyTo", default)]
pub custom_headers_apply_to: Option<CustomHeadersApplyTo>,
}
#[derive(Clone, Debug)]
pub struct ServerHttpContext {
pub endpoints: Vec<(String, EndpointKind)>,
pub headers: Vec<(String, String)>,
pub apply_to: CustomHeadersApplyTo,
}
impl From<ServerHttpContextSyncWire> for ServerHttpContext {
fn from(w: ServerHttpContextSyncWire) -> Self {
Self {
endpoints: w
.endpoints
.into_iter()
.map(|e| (normalize_server_base_url(&e.url), e.kind))
.collect(),
headers: w
.custom_headers
.into_iter()
.map(|h| (h.name.trim().to_string(), h.value))
.filter(|(n, _)| !n.is_empty())
.collect(),
apply_to: w.custom_headers_apply_to.unwrap_or_default(),
}
}
}
fn normalize_server_base_url(raw: &str) -> String {
let trimmed = raw.trim().trim_end_matches('/');
if trimmed.is_empty() {
return String::new();
}
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
trimmed.to_string()
} else {
format!("http://{trimmed}")
}
}
/// Strip `/rest/…`, `/api/…`, `/auth/…`, and query from a full HTTP URL to match TS `requestBaseUrlFromHttpUrl`.
pub fn request_base_url_from_http_url(raw_url: &str) -> String {
let trimmed = raw_url.trim();
if trimmed.is_empty() {
return String::new();
}
let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
trimmed.to_string()
} else {
format!("http://{trimmed}")
};
let Ok(mut parsed) = url::Url::parse(&with_scheme) else {
return normalize_server_base_url(trimmed);
};
parsed.set_query(None);
parsed.set_fragment(None);
let mut path = parsed.path().to_string();
if let Some(idx) = path.find("/rest/") {
path.truncate(idx);
} else if path.ends_with("/rest") {
path.truncate(path.len().saturating_sub("/rest".len()));
} else {
for seg in ["/api/", "/auth/"] {
if let Some(idx) = path.find(seg) {
path.truncate(idx);
break;
}
}
}
while path.ends_with('/') && path.len() > 1 {
path.pop();
}
parsed.set_path(if path.is_empty() { "/" } else { &path });
let host = parsed.host_str().unwrap_or_default();
if host.is_empty() {
return normalize_server_base_url(trimmed);
}
let mut out = format!("{}://{}", parsed.scheme(), host);
if let Some(port) = parsed.port() {
out.push(':');
out.push_str(&port.to_string());
}
if !path.is_empty() && path != "/" {
out.push_str(&path);
}
normalize_server_base_url(&out)
}
pub fn headers_for_request_base_url(ctx: &ServerHttpContext, request_base_url: &str) -> HeaderMap {
let mut map = HeaderMap::new();
if ctx.headers.is_empty() {
return map;
}
let normalized = normalize_server_base_url(request_base_url);
let Some((_, kind)) = ctx.endpoints.iter().find(|(u, _)| *u == normalized) else {
return map;
};
let apply = match ctx.apply_to {
CustomHeadersApplyTo::Both => true,
CustomHeadersApplyTo::Public => *kind == EndpointKind::Public,
CustomHeadersApplyTo::Local => *kind == EndpointKind::Local,
};
if !apply {
return map;
}
for (name, value) in &ctx.headers {
let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else {
continue;
};
let Ok(header_value) = HeaderValue::from_str(value) else {
continue;
};
map.insert(header_name, header_value);
}
map
}
pub fn apply_server_headers(
builder: RequestBuilder,
ctx: &ServerHttpContext,
request_base_url: &str,
) -> RequestBuilder {
let map = headers_for_request_base_url(ctx, request_base_url);
if map.is_empty() {
return builder;
}
builder.headers(map)
}
pub fn apply_server_headers_for_http_url(
builder: RequestBuilder,
ctx: &ServerHttpContext,
full_http_url: &str,
) -> RequestBuilder {
let base = request_base_url_from_http_url(full_http_url);
apply_server_headers(builder, ctx, &base)
}
#[derive(Default)]
pub struct ServerHttpRegistry {
contexts: Mutex<HashMap<String, Arc<ServerHttpContext>>>,
ref_to_key: Mutex<HashMap<String, String>>,
}
impl ServerHttpRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn sync(&self, wire: ServerHttpContextSyncWire) {
let index_key = wire.server_id.clone();
let app_id = wire.app_server_id.clone();
let ctx = Arc::new(ServerHttpContext::from(wire));
if ctx.headers.is_empty() {
self.remove(&index_key, &app_id);
return;
}
{
let mut contexts = self.contexts.lock().unwrap();
contexts.insert(index_key.clone(), Arc::clone(&ctx));
}
let mut refs = self.ref_to_key.lock().unwrap();
refs.insert(index_key.clone(), index_key.clone());
refs.insert(app_id, index_key);
}
pub fn sync_all(&self, entries: Vec<ServerHttpContextSyncWire>) {
let mut new_contexts = HashMap::new();
let mut new_refs = HashMap::new();
for wire in entries {
let index_key = wire.server_id.clone();
let app_id = wire.app_server_id.clone();
let ctx = Arc::new(ServerHttpContext::from(wire));
if ctx.headers.is_empty() {
continue;
}
new_contexts.insert(index_key.clone(), Arc::clone(&ctx));
new_refs.insert(index_key.clone(), index_key.clone());
new_refs.insert(app_id, index_key);
}
*self.contexts.lock().unwrap() = new_contexts;
*self.ref_to_key.lock().unwrap() = new_refs;
}
pub fn remove(&self, index_key: &str, app_server_id: &str) {
self.contexts.lock().unwrap().remove(index_key);
let mut refs = self.ref_to_key.lock().unwrap();
refs.remove(index_key);
refs.remove(app_server_id);
}
pub fn get(&self, index_key: &str) -> Option<Arc<ServerHttpContext>> {
self.contexts.lock().unwrap().get(index_key).cloned()
}
pub fn get_for_server_ref(&self, server_ref: &str) -> Option<Arc<ServerHttpContext>> {
if server_ref.is_empty() {
return None;
}
let key = {
let refs = self.ref_to_key.lock().unwrap();
refs.get(server_ref).cloned()
};
if let Some(k) = key {
return self.get(&k);
}
self.get(server_ref)
}
/// Fallback when only a server base URL is known (Navidrome invoke paths).
pub fn get_for_server_url(&self, server_url: &str) -> Option<Arc<ServerHttpContext>> {
let base = request_base_url_from_http_url(server_url);
if base.is_empty() {
return None;
}
let contexts = self.contexts.lock().unwrap();
for ctx in contexts.values() {
if ctx.endpoints.iter().any(|(u, _)| *u == base) {
return Some(Arc::clone(ctx));
}
}
None
}
pub fn apply_for_http_url(
&self,
server_ref: &str,
full_http_url: &str,
builder: RequestBuilder,
) -> RequestBuilder {
let Some(ctx) = self.get_for_server_ref(server_ref) else {
return builder;
};
apply_server_headers_for_http_url(builder, &ctx, full_http_url)
}
pub fn apply_for_base_url(
&self,
server_ref: &str,
request_base_url: &str,
builder: RequestBuilder,
) -> RequestBuilder {
let Some(ctx) = self.get_for_server_ref(server_ref) else {
return builder;
};
apply_server_headers(builder, &ctx, request_base_url)
}
}
/// Apply custom headers when `registry` is present — prefers `server_ref`, falls back to URL match.
pub fn apply_optional_registry_headers(
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
full_http_url: &str,
builder: RequestBuilder,
) -> RequestBuilder {
if let Some(reg) = registry {
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
return reg.apply_for_http_url(sid, full_http_url, builder);
}
if let Some(ctx) = reg.get_for_server_url(full_http_url) {
return apply_server_headers_for_http_url(builder, &ctx, full_http_url);
}
}
builder
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_base_url_strips_rest_and_query() {
let url = "https://music.example/rest/stream.view?id=1&u=x";
assert_eq!(
request_base_url_from_http_url(url),
"https://music.example"
);
}
#[test]
fn headers_apply_public_only_on_public_endpoint() {
let ctx = ServerHttpContext {
endpoints: vec![
("http://192.168.0.10".into(), EndpointKind::Local),
("https://music.example".into(), EndpointKind::Public),
],
headers: vec![("X-Gate".into(), "secret".into())],
apply_to: CustomHeadersApplyTo::Public,
};
let lan = headers_for_request_base_url(&ctx, "http://192.168.0.10");
assert!(lan.is_empty());
let pub_ = headers_for_request_base_url(&ctx, "https://music.example");
assert_eq!(pub_.get("X-Gate").map(|v| v.to_str().ok()), Some(Some("secret")));
}
#[test]
fn registry_resolves_app_id_alias() {
let reg = ServerHttpRegistry::new();
reg.sync(ServerHttpContextSyncWire {
server_id: "music.example".into(),
app_server_id: "uuid-1".into(),
endpoints: vec![ServerHttpEndpointWire {
url: "https://music.example".into(),
kind: EndpointKind::Public,
}],
custom_headers: vec![CustomHeaderEntryWire {
name: "X-Gate".into(),
value: "tok".into(),
}],
custom_headers_apply_to: Some(CustomHeadersApplyTo::Public),
});
assert!(reg.get("music.example").is_some());
assert!(reg.get_for_server_ref("uuid-1").is_some());
assert!(reg.get("uuid-1").is_none());
}
}
@@ -1,15 +1,31 @@
//! Auth + retry + HTTP client for Navidrome's native REST API.
//! Used by every other navidrome submodule for `/auth/*` and `/api/*` calls.
use psysonic_core::server_http::{apply_server_headers_for_http_url, apply_optional_registry_headers, ServerHttpRegistry};
/// Authenticate with Navidrome's own REST API and return a Bearer token.
pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
navidrome_token_with_registry(None, server_url, username, password).await
}
pub async fn navidrome_token_with_registry(
registry: Option<&ServerHttpRegistry>,
server_url: &str,
username: &str,
password: &str,
) -> Result<String, String> {
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/auth/login", server_url))
.json(&serde_json::json!({ "username": username, "password": password }))
.send()
.await
.map_err(|e| e.to_string())?;
let base = server_url.trim_end_matches('/');
let login_url = format!("{base}/auth/login");
let mut req = client
.post(&login_url)
.json(&serde_json::json!({ "username": username, "password": password }));
if let Some(reg) = registry {
if let Some(ctx) = reg.get_for_server_url(server_url) {
req = apply_server_headers_for_http_url(req, &ctx, &login_url);
}
}
let resp = req.send().await.map_err(|e| e.to_string())?;
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
data["token"]
.as_str()
@@ -17,6 +33,16 @@ pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -
.ok_or_else(|| "Navidrome auth: no token in response".to_string())
}
/// Attach gate headers for Navidrome `/auth/*` and `/api/*` requests.
pub fn nd_apply_request(
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
full_url: &str,
builder: reqwest::RequestBuilder,
) -> reqwest::RequestBuilder {
apply_optional_registry_headers(registry, server_ref, full_url, builder)
}
/// Payload returned by Navidrome's `/auth/login`.
#[derive(serde::Serialize)]
pub struct NdLoginResult {
@@ -2,10 +2,16 @@
//! login (via `navidrome_token`) and then a multipart POST to the relevant
//! `/api/{playlist|radio|artist}/{id}/image` endpoint.
use super::client::navidrome_token;
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use tauri::State;
use super::client::{navidrome_token_with_registry, nd_apply_request, nd_http_client};
#[tauri::command]
pub async fn upload_playlist_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
playlist_id: String,
username: String,
@@ -13,26 +19,34 @@ pub async fn upload_playlist_cover(
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/playlist/{}/image", server_url, playlist_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let url = format!("{}/api/playlist/{}/image", server_url, playlist_id);
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form),
)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn upload_radio_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
radio_id: String,
username: String,
@@ -40,26 +54,34 @@ pub async fn upload_radio_cover(
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/radio/{}/image", server_url, radio_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let url = format!("{}/api/radio/{}/image", server_url, radio_id);
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form),
)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn upload_artist_image(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
artist_id: String,
username: String,
@@ -67,40 +89,58 @@ pub async fn upload_artist_image(
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/artist/{}/image", server_url, artist_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let url = format!("{}/api/artist/{}/image", server_url, artist_id);
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form),
)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn delete_radio_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
radio_id: String,
username: String,
password: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let resp = reqwest::Client::new()
.delete(format!("{}/api/radio/{}/image", server_url, radio_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let url = format!("{}/api/radio/{}/image", server_url, radio_id);
let resp = nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.delete(&url)
.header("X-ND-Authorization", format!("Bearer {}", token)),
)
.send()
.await
.map_err(|e| e.to_string())?;
// 404/503 = no image existed — treat as success
if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE {
if !resp.status().is_success()
&& resp.status() != reqwest::StatusCode::NOT_FOUND
&& resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE
{
resp.error_for_status().map_err(|e| e.to_string())?;
}
Ok(())
@@ -11,4 +11,4 @@ pub mod probe;
pub mod queries;
pub mod users;
pub use client::navidrome_token;
pub use client::{navidrome_token, navidrome_token_with_registry, nd_apply_request};
@@ -2,24 +2,41 @@
//! payload is forwarded as-is so the frontend can compose any rule the
//! Navidrome version supports without backend changes.
use super::client::{nd_err, nd_http_client, nd_retry};
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use tauri::State;
use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry};
/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists.
#[tauri::command]
pub async fn nd_list_playlists(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
smart: Option<bool>,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let base = format!("{}/api/playlist", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
let client = nd_http_client();
let mut req = client
.get(format!("{}/api/playlist", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token));
if let Some(s) = smart {
req = req.query(&[("smart", s)]);
let base = base.clone();
let auth = auth.clone();
async move {
let mut req = nd_apply_request(
Some(reg),
None,
&base,
nd_http_client()
.get(&base)
.header("X-ND-Authorization", auth),
);
if let Some(s) = smart {
req = req.query(&[("smart", s)]);
}
req.send().await
}
req.send()
})
.await?;
if !resp.status().is_success() {
@@ -31,16 +48,31 @@ pub async fn nd_list_playlists(
/// POST `/api/playlist` — create playlist (supports smart rules payload).
#[tauri::command]
pub async fn nd_create_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
body: serde_json::Value,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/playlist", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.post(format!("{}/api/playlist", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
.await
}
})
.await?;
let status = resp.status();
@@ -54,17 +86,32 @@ pub async fn nd_create_playlist(
/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload).
#[tauri::command]
pub async fn nd_update_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
body: serde_json::Value,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/playlist/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.put(format!("{}/api/playlist/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.put(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
.await
}
})
.await?;
let status = resp.status();
@@ -78,15 +125,29 @@ pub async fn nd_update_playlist(
/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available).
#[tauri::command]
pub async fn nd_get_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/playlist/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/playlist/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.get(&url)
.header("X-ND-Authorization", auth),
)
.send()
.await
}
})
.await?;
let status = resp.status();
@@ -100,15 +161,29 @@ pub async fn nd_get_playlist(
/// DELETE `/api/playlist/{id}` — delete playlist.
#[tauri::command]
pub async fn nd_delete_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
) -> Result<(), String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/playlist/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.delete(format!("{}/api/playlist/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.delete(&url)
.header("X-ND-Authorization", auth),
)
.send()
.await
}
})
.await?;
let status = resp.status();
@@ -6,7 +6,7 @@
//! endpoint? — so this stays a free function rather than a client
//! struct. The full `nd_list_songs`-style ingest loop lands with PR-3b.
use super::client::{nd_err, nd_http_client};
use super::client::{nd_apply_request, nd_err, nd_http_client};
/// Returns `Ok(true)` when `GET /api/song?_start=0&_end=1` answers with
/// a 2xx status, `Ok(false)` for 4xx (auth ok but endpoint missing or
@@ -16,15 +16,25 @@ use super::client::{nd_err, nd_http_client};
/// Spec §6.1 ties the result to the `NavidromeNativeBulk` capability
/// flag. Wider call into the actual ingest path (`nd_list_songs` port)
/// is PR-3b's job.
pub async fn native_bulk_available(server_url: &str, token: &str) -> Result<bool, String> {
pub async fn native_bulk_available(
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
server_url: &str,
token: &str,
) -> Result<bool, String> {
let client = nd_http_client();
let url = format!("{}/api/song?_start=0&_end=1", server_url.trim_end_matches('/'));
let resp = client
.get(url)
.header("X-ND-Authorization", format!("Bearer {token}"))
.send()
.await
.map_err(nd_err)?;
let resp = nd_apply_request(
registry,
server_ref,
&url,
client
.get(&url)
.header("X-ND-Authorization", format!("Bearer {token}")),
)
.send()
.await
.map_err(nd_err)?;
let status = resp.status();
if status.is_success() {
@@ -56,7 +66,7 @@ mod tests {
.mount(&server)
.await;
let ok = native_bulk_available(&server.uri(), "tok-123").await.unwrap();
let ok = native_bulk_available(None, None, &server.uri(), "tok-123").await.unwrap();
assert!(ok);
}
@@ -71,7 +81,7 @@ mod tests {
.mount(&server)
.await;
let ok = native_bulk_available(&server.uri(), "tok").await.unwrap();
let ok = native_bulk_available(None, None, &server.uri(), "tok").await.unwrap();
assert!(!ok);
}
@@ -84,7 +94,7 @@ mod tests {
.mount(&server)
.await;
let ok = native_bulk_available(&server.uri(), "bad").await.unwrap();
let ok = native_bulk_available(None, None, &server.uri(), "bad").await.unwrap();
assert!(!ok, "401 reads as `endpoint not available for this caller`");
}
@@ -97,7 +107,7 @@ mod tests {
.mount(&server)
.await;
let err = native_bulk_available(&server.uri(), "tok").await.unwrap_err();
let err = native_bulk_available(None, None, &server.uri(), "tok").await.unwrap_err();
assert!(err.contains("503"));
}
@@ -111,6 +121,6 @@ mod tests {
.await;
let with_slash = format!("{}/", server.uri());
assert!(native_bulk_available(&with_slash, "tok").await.unwrap());
assert!(native_bulk_available(None, None, &with_slash, "tok").await.unwrap());
}
}
@@ -2,13 +2,21 @@
//! incompletely: songs, role-filtered artist/album lists, libraries,
//! per-user library assignment, and absolute song path resolution.
use super::client::{navidrome_token, nd_err, nd_http_client, nd_retry};
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use tauri::State;
use super::client::{navidrome_token_with_registry, nd_apply_request, nd_err, nd_http_client, nd_retry};
/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated
/// song list. Pure async helper used by the library-side N1 ingest
/// loop (spec §6.3, PR-3*); also wrapped by the `#[tauri::command]`
/// variant below for existing frontend callers.
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_songs_internal(
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
server_url: &str,
token: &str,
sort: &str,
@@ -20,12 +28,24 @@ pub async fn nd_list_songs_internal(
"{}/api/song?_sort={}&_order={}&_start={}&_end={}",
server_url, sort, order, start, end
);
let auth = format!("Bearer {token}");
let resp = nd_retry(|| {
nd_http_client()
.get(&url)
.header("X-ND-Authorization", format!("Bearer {token}"))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
registry,
server_ref,
&url,
nd_http_client()
.get(&url)
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -36,6 +56,7 @@ pub async fn nd_list_songs_internal(
/// surface unchanged for existing call sites in the WebView.
#[tauri::command]
pub async fn nd_list_songs(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
sort: String,
@@ -43,7 +64,17 @@ pub async fn nd_list_songs(
start: u32,
end: u32,
) -> Result<serde_json::Value, String> {
nd_list_songs_internal(&server_url, &token, &sort, &order, start, end).await
nd_list_songs_internal(
Some(http_registry.as_ref()),
None,
&server_url,
&token,
&sort,
&order,
start,
end,
)
.await
}
/// Build the `_filters` JSON for native-API list calls. Optionally narrows the
@@ -70,6 +101,7 @@ fn nd_build_filters(seed: serde_json::Map<String, serde_json::Value>, library_id
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_artists_by_role(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
role: String,
@@ -79,24 +111,42 @@ pub async fn nd_list_artists_by_role(
end: u32,
library_id: Option<String>,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let mut seed = serde_json::Map::new();
seed.insert("role".to_string(), serde_json::Value::String(role.clone()));
let filters = nd_build_filters(seed, library_id.as_deref());
let start_s = start.to_string();
let end_s = end.to_string();
let base = format!("{}/api/artist", server_url);
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/artist", server_url))
.query(&[
("_filters", filters.as_str()),
("_sort", sort.as_str()),
("_order", order.as_str()),
("_start", start_s.as_str()),
("_end", end_s.as_str()),
])
.header("X-ND-Authorization", format!("Bearer {}", token))
let base = base.clone();
let filters = filters.clone();
let sort = sort.clone();
let order = order.clone();
let start_s = start_s.clone();
let end_s = end_s.clone();
let auth = format!("Bearer {}", token);
async move {
nd_apply_request(
Some(reg),
None,
&base,
nd_http_client()
.get(&base)
.query(&[
("_filters", filters.as_str()),
("_sort", sort.as_str()),
("_order", order.as_str()),
("_start", start_s.as_str()),
("_end", end_s.as_str()),
])
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -111,6 +161,7 @@ pub async fn nd_list_artists_by_role(
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_albums_by_artist_role(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
artist_id: String,
@@ -121,25 +172,43 @@ pub async fn nd_list_albums_by_artist_role(
end: u32,
library_id: Option<String>,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let filter_key = format!("role_{}_id", role);
let mut seed = serde_json::Map::new();
seed.insert(filter_key, serde_json::Value::String(artist_id.clone()));
let filters = nd_build_filters(seed, library_id.as_deref());
let start_s = start.to_string();
let end_s = end.to_string();
let base = format!("{}/api/album", server_url);
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/album", server_url))
.query(&[
("_filters", filters.as_str()),
("_sort", sort.as_str()),
("_order", order.as_str()),
("_start", start_s.as_str()),
("_end", end_s.as_str()),
])
.header("X-ND-Authorization", format!("Bearer {}", token))
let base = base.clone();
let filters = filters.clone();
let sort = sort.clone();
let order = order.clone();
let start_s = start_s.clone();
let end_s = end_s.clone();
let auth = format!("Bearer {}", token);
async move {
nd_apply_request(
Some(reg),
None,
&base,
nd_http_client()
.get(&base)
.query(&[
("_filters", filters.as_str()),
("_sort", sort.as_str()),
("_order", order.as_str()),
("_start", start_s.as_str()),
("_end", end_s.as_str()),
])
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -149,15 +218,28 @@ pub async fn nd_list_albums_by_artist_role(
/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array.
#[tauri::command]
pub async fn nd_list_libraries(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/library", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/library", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client().get(&url).header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -168,19 +250,35 @@ pub async fn nd_list_libraries(
/// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400.
#[tauri::command]
pub async fn nd_set_user_libraries(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
library_ids: Vec<i64>,
) -> Result<(), String> {
let reg = http_registry.as_ref();
let body = serde_json::json!({ "libraryIds": library_ids });
let url = format!("{}/api/user/{}/library", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.put(format!("{}/api/user/{}/library", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.put(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
}).await?;
.await
}
})
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
@@ -202,18 +300,31 @@ pub async fn nd_set_user_libraries(
/// it for non-admin users on some configurations.
#[tauri::command]
pub async fn nd_get_song_path(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
username: String,
password: String,
id: String,
) -> Result<Option<String>, String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let url = format!("{}/api/song/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.get(&url)
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.get(&url)
.header("X-ND-Authorization", auth),
)
.send()
.await
}
})
.await?;
if !resp.status().is_success() {
@@ -2,22 +2,39 @@
//! `token` (obtained via `navidrome_login`); admin-only ones return 401/403
//! when the caller is not an admin.
use super::client::{nd_err, nd_http_client, nd_retry, NdLoginResult};
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use tauri::State;
use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry, NdLoginResult};
/// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin.
#[tauri::command]
pub async fn navidrome_login(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
username: String,
password: String,
) -> Result<NdLoginResult, String> {
let reg = http_registry.as_ref();
let body = serde_json::json!({ "username": username, "password": password });
let login_url = format!("{}/auth/login", server_url.trim_end_matches('/'));
let resp = nd_retry(|| {
nd_http_client()
.post(format!("{}/auth/login", server_url))
.json(&body)
let login_url = login_url.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&login_url,
nd_http_client().post(&login_url).json(&body),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("Navidrome login failed: HTTP {}", resp.status()));
}
@@ -25,21 +42,40 @@ pub async fn navidrome_login(
let token = data["token"].as_str().ok_or("no token in response")?.to_string();
let user_id = data["id"].as_str().unwrap_or("").to_string();
let is_admin = data["isAdmin"].as_bool().unwrap_or(false);
Ok(NdLoginResult { token, user_id, is_admin })
Ok(NdLoginResult {
token,
user_id,
is_admin,
})
}
/// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields.
#[tauri::command]
pub async fn nd_list_users(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/user", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/user", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.get(&url)
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -48,7 +84,9 @@ pub async fn nd_list_users(
/// POST `/api/user` — create a user.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_create_user(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
user_name: String,
@@ -57,6 +95,7 @@ pub async fn nd_create_user(
password: String,
is_admin: bool,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let body = serde_json::json!({
"userName": user_name,
"name": name,
@@ -64,13 +103,27 @@ pub async fn nd_create_user(
"password": password,
"isAdmin": is_admin,
});
let url = format!("{}/api/user", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.post(format!("{}/api/user", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
}).await?;
.await
}
})
.await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
@@ -83,6 +136,7 @@ pub async fn nd_create_user(
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_update_user(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
@@ -92,6 +146,7 @@ pub async fn nd_update_user(
password: String,
is_admin: bool,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let mut body = serde_json::json!({
"id": id,
"userName": user_name,
@@ -102,13 +157,27 @@ pub async fn nd_update_user(
if !password.is_empty() {
body["password"] = serde_json::Value::String(password);
}
let url = format!("{}/api/user/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.put(format!("{}/api/user/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.put(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
}).await?;
.await
}
})
.await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
@@ -120,16 +189,31 @@ pub async fn nd_update_user(
/// DELETE `/api/user/{id}`.
#[tauri::command]
pub async fn nd_delete_user(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
) -> Result<(), String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/user/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.delete(format!("{}/api/user/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.delete(&url)
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
@@ -12,6 +12,7 @@ use serde::Deserialize;
use super::auth::SubsonicCredentials;
use super::error::{flatten_reqwest_error, SubsonicError};
use super::types::{Album, AlbumSummary, ArtistIndex, ScanStatus, SearchResult, ServerInfo, Song};
use psysonic_core::server_http::{apply_server_headers, ServerHttpContext};
/// Protocol level we advertise — pre-OpenSubsonic Subsonic baseline that
/// Navidrome and other servers in the wild support. OpenSubsonic
@@ -42,6 +43,7 @@ pub struct SubsonicClient {
base_url: String,
credentials: CredentialsMode,
http: reqwest::Client,
http_context: Option<ServerHttpContext>,
}
impl SubsonicClient {
@@ -75,9 +77,28 @@ impl SubsonicClient {
password: password.into(),
},
http,
http_context: None,
}
}
pub fn with_http_context(mut self, ctx: ServerHttpContext) -> Self {
self.http_context = Some(ctx);
self
}
/// Production helper — attach registry context when present for `server_ref`
/// (app server id or index key).
pub fn with_registry(
self,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: &str,
) -> Self {
registry
.and_then(|r| r.get_for_server_ref(server_ref))
.map(|ctx| self.clone().with_http_context((*ctx).clone()))
.unwrap_or(self)
}
/// Test-/cache-friendly constructor — re-uses the same
/// `SubsonicCredentials` triple on every call. Wiremock tests rely on
/// this for deterministic `s=` and `t=` query params; production code
@@ -95,6 +116,7 @@ impl SubsonicClient {
base_url: url,
credentials: CredentialsMode::Static(credentials),
http,
http_context: None,
}
}
@@ -301,10 +323,15 @@ impl SubsonicClient {
let mut query: Vec<(&str, &str)> = auth.to_vec();
query.extend_from_slice(extra);
let resp = self
let mut req = self
.http
.get(format!("{}/rest/{method}.view", self.base_url))
.query(&query)
.query(&query);
if let Some(ctx) = &self.http_context {
req = apply_server_headers(req, ctx, &self.base_url);
}
let resp = req
.send()
.await
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))?;
@@ -331,6 +358,16 @@ fn default_http_client() -> reqwest::Client {
.unwrap_or_else(|_| reqwest::Client::new())
}
pub fn subsonic_client_with_registry(
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: &str,
base_url: impl Into<String>,
username: impl Into<String>,
password: impl Into<String>,
) -> SubsonicClient {
SubsonicClient::new(base_url, username, password).with_registry(registry, server_ref)
}
/// Validate the Subsonic envelope and return the raw `serde_json::Value`
/// at `body_key`. Maps `error.code = 70` to the dedicated `NotFound`
/// variant; surfaces every other failed status as `Api { code, message }`.
@@ -10,7 +10,8 @@ pub mod types;
pub use auth::SubsonicCredentials;
pub use client::{
fingerprint_sample, SubsonicClient, SUBSONIC_API_VERSION, SUBSONIC_CLIENT_ID,
fingerprint_sample, subsonic_client_with_registry, SubsonicClient, SUBSONIC_API_VERSION,
SUBSONIC_CLIENT_ID,
};
pub use stream_url::{build_stream_view_url, rest_base_from_url};
pub use error::SubsonicError;
@@ -11,8 +11,9 @@ use rusqlite::params;
use serde_json::Value;
use tauri::{AppHandle, Emitter, Manager, State};
use psysonic_integration::navidrome::navidrome_token;
use psysonic_integration::subsonic::SubsonicClient;
use psysonic_core::server_http::ServerHttpRegistry;
use psysonic_integration::navidrome::navidrome_token_with_registry;
use psysonic_integration::subsonic::subsonic_client_with_registry;
use crate::advanced_search;
use crate::analysis_backfill::{self, LibraryAnalysisBackfillBatchDto, LibraryAnalysisProgressDto};
@@ -657,13 +658,14 @@ fn normalize_base_url(raw: &str) -> String {
/// caller falls back to a cached bearer / the Subsonic-only path. Never logs
/// the token or credentials.
async fn navidrome_token_with_retry(
registry: Option<&ServerHttpRegistry>,
base_url: &str,
username: &str,
password: &str,
) -> Option<String> {
const ATTEMPTS: u32 = 3;
for attempt in 1..=ATTEMPTS {
match navidrome_token(base_url, username, password).await {
match navidrome_token_with_registry(registry, base_url, username, password).await {
Ok(tok) => return Some(tok),
Err(_) if attempt < ATTEMPTS => {
tokio::time::sleep(Duration::from_millis(250 * attempt as u64)).await;
@@ -677,6 +679,7 @@ async fn navidrome_token_with_retry(
#[tauri::command]
pub async fn library_sync_bind_session(
runtime: State<'_, LibraryRuntime>,
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_id: String,
base_url: String,
username: String,
@@ -690,8 +693,13 @@ pub async fn library_sync_bind_session(
// keep a bearer cached from a prior bind rather than dropping to
// Subsonic-only — a transient miss must not strip an N1-capable server
// (R7-15 Q3). Non-Navidrome servers stay `None` and sync via Subsonic.
let navidrome_token_cached = match navidrome_token_with_retry(&base_url, &username, &password)
.await
let navidrome_token_cached = match navidrome_token_with_retry(
Some(http_registry.as_ref()),
&base_url,
&username,
&password,
)
.await
{
Some(tok) => Some(tok),
None => runtime.get_session(&server_id).and_then(|s| s.navidrome_token),
@@ -709,7 +717,13 @@ pub async fn library_sync_bind_session(
// Run the probe + persist capability flags. Failure to probe is a
// bind-time error — caller should fix credentials / URL.
let subsonic = SubsonicClient::new(base_url, username, password);
let subsonic = subsonic_client_with_registry(
Some(http_registry.as_ref()),
&server_id,
base_url,
username,
password,
);
let navidrome_creds = navidrome_token_cached.map(|tok| NavidromeProbeCredentials {
server_url: subsonic_base_url_from(&runtime, &server_id),
bearer_token: tok,
@@ -719,6 +733,7 @@ pub async fn library_sync_bind_session(
&runtime.store,
&subsonic,
navidrome_creds.as_ref(),
Some(http_registry.as_ref()),
&server_id,
scope,
)
@@ -872,8 +887,12 @@ async fn library_sync_start_inner(
let job_id_for_task = job_id.clone();
let parallelism = ParallelismBudget::resolve(runtime.current_playback_hint());
let app_for_runner = app.clone();
let runner_handle: tokio::task::JoinHandle<Result<(), String>> = tokio::task::spawn(async move {
let subsonic = SubsonicClient::new(
let registry = app_for_runner.state::<Arc<ServerHttpRegistry>>();
let subsonic = subsonic_client_with_registry(
Some(registry.as_ref()),
&session_clone.server_id,
session_clone.base_url.clone(),
session_clone.username.clone(),
session_clone.password.clone(),
@@ -895,7 +914,8 @@ async fn library_sync_start_inner(
)
.with_cancellation(Arc::clone(&cancel_for_task))
.with_progress(Arc::clone(&progress))
.with_parallelism_budget(parallelism);
.with_parallelism_budget(parallelism)
.with_http_registry(Some(Arc::clone(&registry)));
if let Some(creds) = navidrome_creds.clone() {
runner = runner.with_navidrome_credentials(creds);
}
@@ -919,7 +939,8 @@ async fn library_sync_start_inner(
capability_flags,
)
.with_cancellation(Arc::clone(&cancel_for_task))
.with_progress(Arc::clone(&progress));
.with_progress(Arc::clone(&progress))
.with_http_registry(Some(Arc::clone(&registry)));
if tombstone_budget > 0 {
runner = runner.with_tombstone_budget(tombstone_budget);
}
@@ -1647,7 +1668,7 @@ mod tests {
})))
.mount(&server)
.await;
let tok = navidrome_token_with_retry(&server.uri(), "user", "pw").await;
let tok = navidrome_token_with_retry(None, &server.uri(), "user", "pw").await;
assert_eq!(tok.as_deref(), Some("nd-tok"));
}
@@ -1664,7 +1685,7 @@ mod tests {
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
let tok = navidrome_token_with_retry(&server.uri(), "user", "pw").await;
let tok = navidrome_token_with_retry(None, &server.uri(), "user", "pw").await;
assert!(tok.is_none());
}
}
@@ -91,6 +91,7 @@ pub async fn probe_and_persist(
store: &crate::store::LibraryStore,
subsonic: &psysonic_integration::subsonic::SubsonicClient,
navidrome: Option<&NavidromeProbeCredentials>,
http_registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: &str,
library_scope: &str,
) -> Result<CapabilityProbeResult, psysonic_integration::subsonic::SubsonicError> {
@@ -110,7 +111,7 @@ pub async fn probe_and_persist(
.map_err(psysonic_integration::subsonic::SubsonicError::Transport)?
.unwrap_or(0);
let mut result = CapabilityProbe::run(subsonic, navidrome).await?;
let mut result = CapabilityProbe::run(subsonic, navidrome, http_registry, Some(server_id)).await?;
// R7-15 Q3: a probe run without a Navidrome bearer can't test N1, so it
// must not drop a previously-learned NavidromeNativeBulk capability — the
@@ -173,6 +174,8 @@ impl CapabilityProbe {
pub async fn run(
subsonic: &SubsonicClient,
navidrome: Option<&NavidromeProbeCredentials>,
http_registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: Option<&str>,
) -> Result<CapabilityProbeResult, SubsonicError> {
let server_info = subsonic.server_info().await?;
@@ -207,7 +210,14 @@ impl CapabilityProbe {
}
if let Some(creds) = navidrome {
match native_bulk_available(&creds.server_url, &creds.bearer_token).await {
match native_bulk_available(
http_registry,
server_id,
&creds.server_url,
&creds.bearer_token,
)
.await
{
Ok(true) => flags.insert(CapabilityFlags::NAVIDROME_NATIVE_BULK),
Ok(false) => {}
Err(_) => {
@@ -345,7 +355,7 @@ mod tests {
let server = MockServer::start().await;
mount_subsonic_full_navidrome(&server).await;
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None)
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
.await
.unwrap();
assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
@@ -372,7 +382,7 @@ mod tests {
.mount(&server)
.await;
let err = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None)
let err = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
.await
.unwrap_err();
assert!(matches!(err, SubsonicError::Api { code: 40, .. }));
@@ -415,7 +425,7 @@ mod tests {
.mount(&server)
.await;
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None)
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
.await
.unwrap();
assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
@@ -440,7 +450,7 @@ mod tests {
server_url: server.uri(),
bearer_token: "nd-tok".into(),
};
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav))
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav), None, None)
.await
.unwrap();
assert!(result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
@@ -461,6 +471,7 @@ mod tests {
&store,
&test_subsonic_client(&server.uri()),
None,
None,
"s1",
"",
)
@@ -498,6 +509,7 @@ mod tests {
&store,
&test_subsonic_client(&server.uri()),
None,
None,
"s1",
"",
)
@@ -530,6 +542,7 @@ mod tests {
&store,
&test_subsonic_client(&server.uri()),
None,
None,
"s1",
"",
)
@@ -584,7 +597,7 @@ mod tests {
let store = LibraryStore::open_in_memory();
let result =
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, "s1", "")
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, None, "s1", "")
.await
.unwrap();
assert_eq!(result.server_track_count, Some(170_000));
@@ -617,6 +630,7 @@ mod tests {
&store,
&test_subsonic_client(&server.uri()),
None,
None,
"s1",
"",
)
@@ -647,7 +661,7 @@ mod tests {
mount_subsonic_full_navidrome(&server).await; // scanStatus has no count
let result =
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, "s1", "")
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, None, "s1", "")
.await
.unwrap();
assert_eq!(result.server_track_count, None);
@@ -672,7 +686,7 @@ mod tests {
server_url: server.uri(),
bearer_token: "nd-tok".into(),
};
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav))
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav), None, None)
.await
.unwrap();
assert!(!result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
@@ -20,6 +20,7 @@ use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use psysonic_core::server_http::ServerHttpRegistry;
use psysonic_integration::navidrome::queries::nd_list_songs_internal;
use psysonic_integration::subsonic::SubsonicClient;
use serde_json::Value;
@@ -71,6 +72,7 @@ pub struct DeltaSyncRunner<'a> {
store: &'a LibraryStore,
subsonic: &'a SubsonicClient,
navidrome: Option<NavidromeProbeCredentials>,
http_registry: Option<Arc<ServerHttpRegistry>>,
server_id: String,
library_scope: String,
capability_flags: CapabilityFlags,
@@ -95,6 +97,7 @@ impl<'a> DeltaSyncRunner<'a> {
store,
subsonic,
navidrome: None,
http_registry: None,
server_id: server_id.into(),
library_scope: library_scope.into(),
capability_flags,
@@ -111,6 +114,11 @@ impl<'a> DeltaSyncRunner<'a> {
self
}
pub fn with_http_registry(mut self, registry: Option<Arc<ServerHttpRegistry>>) -> Self {
self.http_registry = registry;
self
}
pub fn with_cancellation(mut self, flag: Arc<std::sync::atomic::AtomicBool>) -> Self {
self.cancel = Some(flag);
self
@@ -396,6 +404,8 @@ impl<'a> DeltaSyncRunner<'a> {
self,
|| {
nd_list_songs_internal(
self.http_registry.as_deref(),
Some(&self.server_id),
&creds.server_url,
&creds.bearer_token,
"updated_at",
@@ -12,6 +12,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use psysonic_core::server_http::ServerHttpRegistry;
use psysonic_integration::navidrome::queries::nd_list_songs_internal;
use psysonic_integration::subsonic::SubsonicClient;
use serde_json::Value;
@@ -109,6 +110,7 @@ pub struct InitialSyncRunner<'a> {
store: &'a LibraryStore,
subsonic: &'a SubsonicClient,
navidrome: Option<NavidromeProbeCredentials>,
http_registry: Option<Arc<ServerHttpRegistry>>,
server_id: String,
library_scope: String,
capability_flags: CapabilityFlags,
@@ -132,6 +134,7 @@ impl<'a> InitialSyncRunner<'a> {
store,
subsonic,
navidrome: None,
http_registry: None,
server_id: server_id.into(),
library_scope: library_scope.into(),
capability_flags,
@@ -154,6 +157,11 @@ impl<'a> InitialSyncRunner<'a> {
self
}
pub fn with_http_registry(mut self, registry: Option<Arc<ServerHttpRegistry>>) -> Self {
self.http_registry = registry;
self
}
pub fn with_cancellation(mut self, flag: Arc<AtomicBool>) -> Self {
self.cancel = Some(flag);
self
@@ -580,6 +588,8 @@ impl<'a> InitialSyncRunner<'a> {
let cancel = self.cancel.clone();
let sleep_enabled = self.sleep_enabled;
let creds = creds.clone();
let http_registry = self.http_registry.clone();
let server_id = self.server_id.clone();
let mut queue = LinearPrefetchQueue::new(&budget, batch_size, offset);
loop {
@@ -590,6 +600,8 @@ impl<'a> InitialSyncRunner<'a> {
queue.pump(|| self.check_cancellation(), |off| {
let creds = creds.clone();
let cancel = cancel.clone();
let http_registry = http_registry.clone();
let server_id = server_id.clone();
tokio::spawn(async move {
retry_fetch(
sleep_enabled,
@@ -597,6 +609,8 @@ impl<'a> InitialSyncRunner<'a> {
|| async {
let end = off.saturating_add(batch_size);
let response = nd_list_songs_internal(
http_registry.as_deref(),
Some(&server_id),
&creds.server_url,
&creds.bearer_token,
"id",
@@ -671,6 +685,8 @@ impl<'a> InitialSyncRunner<'a> {
self,
|| {
nd_list_songs_internal(
self.http_registry.as_deref(),
Some(&self.server_id),
&creds.server_url,
&creds.bearer_token,
"id",
@@ -11,6 +11,7 @@
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use psysonic_integration::subsonic::SubsonicClient;
use super::bandwidth::{ParallelismBudget, PlaybackHint};
@@ -45,6 +46,7 @@ pub struct BackgroundScheduler<'a> {
store: &'a LibraryStore,
subsonic: &'a SubsonicClient,
navidrome: Option<NavidromeProbeCredentials>,
http_registry: Option<Arc<ServerHttpRegistry>>,
server_id: String,
library_scope: String,
capability_flags: CapabilityFlags,
@@ -70,6 +72,7 @@ impl<'a> BackgroundScheduler<'a> {
store,
subsonic,
navidrome: None,
http_registry: None,
server_id: server_id.into(),
library_scope: library_scope.into(),
capability_flags,
@@ -87,6 +90,11 @@ impl<'a> BackgroundScheduler<'a> {
self
}
pub fn with_http_registry(mut self, registry: Option<Arc<ServerHttpRegistry>>) -> Self {
self.http_registry = registry;
self
}
pub fn with_playback_hint(mut self, hint: PlaybackHint) -> Self {
self.playback_hint = hint;
self
@@ -220,7 +228,8 @@ impl<'a> BackgroundScheduler<'a> {
&self.library_scope,
self.capability_flags,
)
.with_progress(Arc::clone(&self.progress));
.with_progress(Arc::clone(&self.progress))
.with_http_registry(self.http_registry.clone());
if let Some(creds) = &self.navidrome {
runner = runner.with_navidrome_credentials(creds.clone());
}
+14 -1
View File
@@ -2,6 +2,8 @@ use tauri::{Emitter, Manager};
use psysonic_core::user_agent::subsonic_wire_user_agent;
use crate::file_transfer::apply_server_http_get;
pub fn resolve_hot_cache_root(
custom_dir: Option<String>,
app: &tauri::AppHandle,
@@ -340,7 +342,18 @@ pub async fn download_zip(
.build()
.map_err(|e| e.to_string())?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
let http_registry = app
.try_state::<std::sync::Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| std::sync::Arc::clone(&*s));
let response = apply_server_http_get(
&client,
http_registry.as_deref(),
None,
&url,
)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
+12 -2
View File
@@ -1,8 +1,11 @@
use std::sync::Arc;
use psysonic_analysis::analysis_runtime::{enqueue_track_analysis, AnalysisBackfillPriority};
use psysonic_audio as audio;
use psysonic_core::user_agent::subsonic_wire_user_agent;
use tauri::Manager;
use crate::file_transfer::stream_to_file;
use crate::file_transfer::{apply_server_http_get, stream_to_file};
use super::downloads::{resolve_hot_cache_root, HotCacheDownloadResult};
use super::offline::enqueue_analysis_seed_from_file;
@@ -70,7 +73,14 @@ pub async fn download_track_hot_cache(
.build()
.map_err(|e| e.to_string())?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let response = apply_server_http_get(&client, http_registry.as_deref(), Some(&server_id), &url)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
+13 -2
View File
@@ -22,7 +22,7 @@ use psysonic_library::repos::TrackRow;
use psysonic_library::{repos::TrackRepository, LibraryRuntime};
use tauri::{AppHandle, Manager, State};
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
use crate::{offline_cancel_flags, DownloadSemaphore};
use super::offline::enqueue_analysis_seed_from_file;
@@ -359,7 +359,18 @@ pub async fn download_track_local(
}
let client = subsonic_http_client(std::time::Duration::from_secs(120))?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let response = apply_server_http_get(
&client,
http_registry.as_deref(),
Some(&server_index_key),
&url,
)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
+18 -7
View File
@@ -9,7 +9,7 @@ use psysonic_analysis::analysis_runtime::{
};
use crate::{offline_cancel_flags, DownloadSemaphore};
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
// ─── Offline Track Cache ──────────────────────────────────────────────────────
@@ -31,12 +31,15 @@ pub async fn enqueue_analysis_seed_from_file(
///
/// `cancel`, when supplied, aborts the in-flight stream with `Err("CANCELLED")`
/// (the `.part` file is cleaned up); `None` means the download is not cancellable.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn download_track_to_cache_dir(
cache_dir: &std::path::Path,
track_id: &str,
suffix: &str,
url: &str,
client: &reqwest::Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
cancel: Option<&AtomicBool>,
) -> Result<std::path::PathBuf, String> {
tokio::fs::create_dir_all(cache_dir)
@@ -48,7 +51,10 @@ pub(crate) async fn download_track_to_cache_dir(
return Ok(file_path);
}
let response = client.get(url).send().await.map_err(|e| e.to_string())?;
let response = apply_server_http_get(client, registry, server_ref, url)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
@@ -129,12 +135,17 @@ pub async fn download_track_offline(
}
let client = subsonic_http_client(std::time::Duration::from_secs(120))?;
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let final_path = download_track_to_cache_dir(
&cache_dir,
&track_id,
&suffix,
&url,
&client,
http_registry.as_deref(),
Some(&server_id),
cancel_flag.as_deref(),
)
.await?;
@@ -191,7 +202,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/stream/track-1", server.uri());
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None)
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, None)
.await
.unwrap();
assert!(path.exists());
@@ -211,7 +222,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/should-not-be-hit", server.uri());
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None)
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, None)
.await
.unwrap();
assert_eq!(path, pre_existing);
@@ -232,7 +243,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/stream/missing", server.uri());
let err = download_track_to_cache_dir(&cache_dir, "missing", "flac", &url, &client, None)
let err = download_track_to_cache_dir(&cache_dir, "missing", "flac", &url, &client, None, None, None)
.await
.unwrap_err();
assert!(err.contains("HTTP 404"), "got {err}");
@@ -256,7 +267,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/track", server.uri());
download_track_to_cache_dir(&cache_dir, "t", "mp3", &url, &client, None)
download_track_to_cache_dir(&cache_dir, "t", "mp3", &url, &client, None, None, None)
.await
.unwrap();
assert!(cache_dir.join("t.mp3").exists());
@@ -278,7 +289,7 @@ mod tests {
let cancel = AtomicBool::new(true);
let err =
download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, Some(&cancel))
download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, Some(&cancel))
.await
.unwrap_err();
assert_eq!(err, "CANCELLED");
@@ -13,6 +13,20 @@ pub fn subsonic_http_client(timeout: Duration) -> Result<reqwest::Client, String
.map_err(|e| e.to_string())
}
pub fn apply_server_http_get(
client: &reqwest::Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
url: &str,
) -> reqwest::RequestBuilder {
psysonic_core::server_http::apply_optional_registry_headers(
registry,
server_ref,
url,
client.get(url),
)
}
/// Streams an HTTP response body to `dest_path` in chunks. Never buffers the full
/// file in memory — keeps RAM flat regardless of file size.
///
@@ -1,11 +1,11 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::Emitter;
use tauri::{Emitter, Manager};
use crate::sync_cancel_flags;
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
use super::device::{
build_track_path, get_removable_drives, is_path_on_mounted_volume, SyncBatchResult,
TrackSyncInfo,
@@ -107,6 +107,7 @@ pub struct SyncDeltaResult {
pub async fn fetch_subsonic_songs(
client: &reqwest::Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
auth: &SubsonicAuthPayload,
endpoint: &str,
id: &str,
@@ -121,7 +122,11 @@ pub async fn fetch_subsonic_songs(
("f", auth.f.as_str()),
("id", id),
];
let res = client.get(&url).query(&query).send().await.map_err(|e| e.to_string())?;
let res = apply_server_http_get(client, registry, None, &url)
.query(&query)
.send()
.await
.map_err(|e| e.to_string())?;
let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
parse_subsonic_songs(&json, endpoint)
}
@@ -239,8 +244,12 @@ pub async fn calculate_sync_payload(
deletion_ids: Vec<String>,
auth: SubsonicAuthPayload,
target_dir: String,
app: tauri::AppHandle,
) -> Result<SyncDeltaResult, String> {
let client = subsonic_http_client(std::time::Duration::from_secs(30))?;
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let mut add_bytes = 0;
let mut add_count = 0;
@@ -264,17 +273,19 @@ pub async fn calculate_sync_payload(
v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
};
let cli = client.clone();
let reg_for_task = http_registry.clone();
let source_snapshot = source.clone();
let handle = tokio::spawn(async move {
let registry = reg_for_task.as_deref();
let mut res_tracks = Vec::new();
if source.source_type == "album" {
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
} else if source.source_type == "playlist" {
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
} else if source.source_type == "artist" {
let url = format!("{}/getArtist.view", auth_clone.base_url);
let query = vec![("u", auth_clone.u.as_str()), ("t", auth_clone.t.as_str()), ("s", auth_clone.s.as_str()), ("v", auth_clone.v.as_str()), ("c", auth_clone.c.as_str()), ("f", auth_clone.f.as_str()), ("id", &source.id)];
if let Ok(re) = cli.get(&url).query(&query).send().await {
if let Ok(re) = apply_server_http_get(&cli, registry, None, &url).query(&query).send().await {
if let Ok(js) = re.json::<serde_json::Value>().await {
if let Some(root) = js.get("subsonic-response").and_then(|r| r.get("artist")).and_then(|a| a.get("album")) {
let arr = root.as_array().cloned().unwrap_or_else(|| {
@@ -282,7 +293,7 @@ pub async fn calculate_sync_payload(
});
for al in arr {
if let Some(aid) = al.get("id").and_then(|i| i.as_str()) {
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", aid).await {
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", aid).await {
res_tracks.extend(ts);
}
}
@@ -303,12 +314,14 @@ pub async fn calculate_sync_payload(
v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
};
let cli = client.clone();
let reg_for_task = http_registry.clone();
del_handles.push(tokio::spawn(async move {
let registry = reg_for_task.as_deref();
let mut res_tracks = Vec::new();
if source.source_type == "album" {
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
} else if source.source_type == "playlist" {
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
}
res_tracks
}));
@@ -437,6 +450,9 @@ pub async fn sync_batch_to_device(
// Shared reqwest client — reused across all downloads.
let client = subsonic_http_client(Duration::from_secs(300))?;
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
// Concurrency limiter: max 2 parallel USB writes.
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(2));
@@ -455,6 +471,7 @@ pub async fn sync_batch_to_device(
for track in tracks {
let sem = semaphore.clone();
let cli = client.clone();
let reg_for_task = http_registry.clone();
let app2 = app.clone();
let job = job_id.clone();
let dest = dest_dir.clone();
@@ -466,6 +483,7 @@ pub async fn sync_batch_to_device(
handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.expect("semaphore closed");
let registry = reg_for_task.as_deref();
// Bail out if cancelled while waiting in the semaphore queue.
if cancel.load(Ordering::Relaxed) { return; }
@@ -492,7 +510,7 @@ pub async fn sync_batch_to_device(
}
}
let response = match cli.get(&track.url).send().await {
let response = match apply_server_http_get(&cli, registry, None, &track.url).send().await {
Ok(r) if r.status().is_success() => r,
Ok(r) => {
f.fetch_add(1, Ordering::Relaxed);
@@ -819,7 +837,7 @@ mod tests {
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
.unwrap();
let auth = fake_auth(server.uri());
let songs = fetch_subsonic_songs(&client, &auth, "getAlbum.view", "album-42")
let songs = fetch_subsonic_songs(&client, None, &auth, "getAlbum.view", "album-42")
.await
.unwrap();
assert_eq!(songs.len(), 2);
@@ -838,7 +856,7 @@ mod tests {
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
.unwrap();
let auth = fake_auth(server.uri());
let result = fetch_subsonic_songs(&client, &auth, "getAlbum.view", "missing").await;
let result = fetch_subsonic_songs(&client, None, &auth, "getAlbum.view", "missing").await;
// 404 with HTML/empty body fails the JSON parse, surfacing as an Err — we
// just assert the function does not panic and propagates an error string.
assert!(result.is_err());
@@ -989,7 +1007,7 @@ mod tests {
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
.unwrap();
let auth = fake_auth(server.uri());
let songs = fetch_subsonic_songs(&client, &auth, "getPlaylist.view", "p1")
let songs = fetch_subsonic_songs(&client, None, &auth, "getPlaylist.view", "p1")
.await
.unwrap();
assert_eq!(songs.len(), 1, "single-object response normalised to 1-element vec");
@@ -1,6 +1,6 @@
use tauri::Emitter;
use tauri::{Emitter, Manager};
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
// ─── Device Sync ─────────────────────────────────────────────────────────────
@@ -333,6 +333,8 @@ pub(crate) async fn sync_download_one_track(
suffix: &str,
url: &str,
client: &reqwest::Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
) -> Result<bool, String> {
if dest_path.exists() {
return Ok(false);
@@ -342,7 +344,10 @@ pub(crate) async fn sync_download_one_track(
.await
.map_err(|e| e.to_string())?;
}
let response = client.get(url).send().await.map_err(|e| e.to_string())?;
let response = apply_server_http_get(client, registry, server_ref, url)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
@@ -366,7 +371,19 @@ pub async fn sync_track_to_device(
let path_str = dest_path.to_string_lossy().to_string();
let client = subsonic_http_client(std::time::Duration::from_secs(300))?;
match sync_download_one_track(&dest_path, &track.suffix, &track.url, &client).await {
let http_registry = app
.try_state::<std::sync::Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| std::sync::Arc::clone(&*s));
match sync_download_one_track(
&dest_path,
&track.suffix,
&track.url,
&client,
http_registry.as_deref(),
None,
)
.await
{
Ok(false) => {
let _ = app.emit("device:sync:progress", serde_json::json!({
"jobId": job_id, "trackId": track.id, "status": "skipped", "path": path_str,
@@ -656,7 +673,7 @@ mod tests {
let dest = dir.path().join("Album").join("01 - track.flac");
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/track", server.uri());
let downloaded = sync_download_one_track(&dest, "flac", &url, &client)
let downloaded = sync_download_one_track(&dest, "flac", &url, &client, None, None)
.await
.unwrap();
assert!(downloaded, "fresh download must report Ok(true)");
@@ -672,7 +689,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/should-not-be-hit", server.uri());
let downloaded = sync_download_one_track(&dest, "mp3", &url, &client)
let downloaded = sync_download_one_track(&dest, "mp3", &url, &client, None, None)
.await
.unwrap();
assert!(!downloaded, "pre-existing file must be reported as skipped");
@@ -692,7 +709,7 @@ mod tests {
let dest = dir.path().join("track.opus");
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/missing", server.uri());
let err = sync_download_one_track(&dest, "opus", &url, &client)
let err = sync_download_one_track(&dest, "opus", &url, &client, None, None)
.await
.unwrap_err();
assert!(err.contains("HTTP 403"));
@@ -714,7 +731,7 @@ mod tests {
assert!(!dest.parent().unwrap().exists());
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/t", server.uri());
sync_download_one_track(&dest, "mp3", &url, &client)
sync_download_one_track(&dest, "mp3", &url, &client, None, None)
.await
.unwrap();
assert!(dest.exists());