mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
This commit is contained in:
@@ -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(®istry)));
|
||||
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(®istry)));
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user