mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
This commit is contained in:
@@ -74,6 +74,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
* Psysonic is now available in **Hungarian (Magyar)** — pick it from the language menu on the Settings and Login screens.
|
* Psysonic is now available in **Hungarian (Magyar)** — pick it from the language menu on the Settings and Login screens.
|
||||||
|
|
||||||
|
### Custom HTTP headers for gated servers
|
||||||
|
|
||||||
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1156](https://github.com/Psychotoxical/psysonic/pull/1156)**, closes [#1095](https://github.com/Psychotoxical/psysonic/issues/1095)
|
||||||
|
|
||||||
|
* Per-server **custom HTTP headers** in Settings → Servers for reverse-proxy gates (Cloudflare Access, Pangolin, and similar): add name/value pairs, choose whether they apply to the local URL, public URL, or both on dual-address profiles.
|
||||||
|
* Headers attach to every user-server HTTP path — library sync, playback, covers, offline download, Navidrome admin, capability probes, and share-link preview — without putting secrets in invite links or magic strings.
|
||||||
|
* Gate header values are redacted from application logs.
|
||||||
|
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
|
|||||||
Generated
+2
@@ -4219,8 +4219,10 @@ name = "psysonic-core"
|
|||||||
version = "1.49.0-dev"
|
version = "1.49.0-dev"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"tauri",
|
"tauri",
|
||||||
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex, OnceLock};
|
|||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
use psysonic_core::ports::PlaybackQueryHandle;
|
use psysonic_core::ports::PlaybackQueryHandle;
|
||||||
|
use psysonic_core::server_http::{apply_optional_registry_headers, ServerHttpRegistry};
|
||||||
use psysonic_core::user_agent::subsonic_wire_user_agent;
|
use psysonic_core::user_agent::subsonic_wire_user_agent;
|
||||||
use psysonic_core::track_enrichment::TrackEnrichmentOutcome;
|
use psysonic_core::track_enrichment::TrackEnrichmentOutcome;
|
||||||
|
|
||||||
@@ -679,10 +680,22 @@ fn analysis_http_client() -> &'static reqwest::Client {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn analysis_backfill_download_bytes(url: &str) -> Result<(Vec<u8>, u64), String> {
|
async fn analysis_backfill_download_bytes(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
server_id: &str,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<(Vec<u8>, u64), String> {
|
||||||
let fetch_started = std::time::Instant::now();
|
let fetch_started = std::time::Instant::now();
|
||||||
let response = analysis_http_client()
|
let registry = app
|
||||||
.get(url)
|
.try_state::<Arc<ServerHttpRegistry>>()
|
||||||
|
.map(|s| Arc::clone(&*s));
|
||||||
|
let request = apply_optional_registry_headers(
|
||||||
|
registry.as_deref(),
|
||||||
|
Some(server_id),
|
||||||
|
url,
|
||||||
|
analysis_http_client().get(url),
|
||||||
|
);
|
||||||
|
let response = request
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -781,7 +794,7 @@ async fn spawn_backfill_slots(app: &tauri::AppHandle, shared: &Arc<AnalysisBackf
|
|||||||
let app = app.clone();
|
let app = app.clone();
|
||||||
let shared = shared.clone();
|
let shared = shared.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let download_result = analysis_backfill_download_bytes(&url).await;
|
let download_result = analysis_backfill_download_bytes(&app, &server_id, &url).await;
|
||||||
{
|
{
|
||||||
let mut st = shared
|
let mut st = shared
|
||||||
.state
|
.state
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use rodio::Source;
|
|||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
use super::decode::build_source;
|
use super::decode::build_source;
|
||||||
use super::engine::{audio_http_client, AudioEngine};
|
use super::engine::AudioEngine;
|
||||||
use super::helpers::*;
|
use super::helpers::*;
|
||||||
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
|
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
|
||||||
use super::play_input::{select_play_input, url_format_hint, PlayInputContext};
|
use super::play_input::{select_play_input, url_format_hint, PlayInputContext};
|
||||||
@@ -597,7 +597,14 @@ pub async fn audio_chain_preload(
|
|||||||
} else if let Some(path) = url.strip_prefix("psysonic-local://") {
|
} else if let Some(path) = url.strip_prefix("psysonic-local://") {
|
||||||
tokio::fs::read(path).await.map_err(|e| e.to_string())?
|
tokio::fs::read(path).await.map_err(|e| e.to_string())?
|
||||||
} else {
|
} else {
|
||||||
let resp = audio_http_client(&state).get(&url).send().await
|
let resp = crate::engine::playback_scoped_get(
|
||||||
|
&state,
|
||||||
|
&app,
|
||||||
|
&url,
|
||||||
|
server_id.as_deref(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Ok(()); // silently fail — audio_play will retry
|
return Ok(()); // silently fail — audio_play will retry
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex, RwLock};
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use rodio::Player;
|
use rodio::Player;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
|
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
|
||||||
|
|
||||||
@@ -570,3 +571,83 @@ pub fn refresh_http_user_agent(state: &AudioEngine, ua: &str) {
|
|||||||
*slot = client;
|
*slot = client;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_playback_request_headers(
|
||||||
|
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||||
|
server_id: Option<&str>,
|
||||||
|
url: &str,
|
||||||
|
req: reqwest::RequestBuilder,
|
||||||
|
) -> reqwest::RequestBuilder {
|
||||||
|
if let Some(reg) = registry {
|
||||||
|
if let Some(sid) = server_id.filter(|s| !s.is_empty()) {
|
||||||
|
return reg.apply_for_http_url(sid, url, req);
|
||||||
|
}
|
||||||
|
if let Some(ctx) = reg.get_for_server_url(url) {
|
||||||
|
return psysonic_core::server_http::apply_server_headers_for_http_url(req, &ctx, url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom HTTP headers for reverse-proxy gates — cloned into background download tasks.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub(crate) struct PlaybackHttpHeaders {
|
||||||
|
registry: Option<Arc<psysonic_core::server_http::ServerHttpRegistry>>,
|
||||||
|
server_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackHttpHeaders {
|
||||||
|
pub fn from_app(app: &tauri::AppHandle, server_id: Option<&str>) -> Self {
|
||||||
|
Self {
|
||||||
|
registry: app
|
||||||
|
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||||
|
.map(|s| Arc::clone(&*s)),
|
||||||
|
server_id: server_id.filter(|s| !s.is_empty()).map(str::to_string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply(&self, url: &str, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||||
|
apply_playback_request_headers(
|
||||||
|
self.registry.as_deref(),
|
||||||
|
self.server_id.as_deref(),
|
||||||
|
url,
|
||||||
|
req,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn scoped_http_get(
|
||||||
|
state: &AudioEngine,
|
||||||
|
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||||
|
server_id: Option<&str>,
|
||||||
|
url: &str,
|
||||||
|
) -> reqwest::RequestBuilder {
|
||||||
|
apply_playback_request_headers(
|
||||||
|
registry,
|
||||||
|
server_id,
|
||||||
|
url,
|
||||||
|
audio_http_client(state).get(url),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve registry + server id for playback/preload HTTP GETs.
|
||||||
|
pub(crate) fn playback_scoped_get(
|
||||||
|
state: &AudioEngine,
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
url: &str,
|
||||||
|
server_id: Option<&str>,
|
||||||
|
) -> reqwest::RequestBuilder {
|
||||||
|
let registry = app
|
||||||
|
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||||
|
.map(|s| Arc::clone(&*s));
|
||||||
|
let sid = server_id
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.or_else(|| state.current_playback_server_id.lock().unwrap().clone());
|
||||||
|
scoped_http_get(
|
||||||
|
state,
|
||||||
|
registry.as_deref(),
|
||||||
|
sid.as_deref(),
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -708,7 +708,10 @@ pub(crate) async fn fetch_data(
|
|||||||
return Ok(Some(data));
|
return Ok(Some(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = crate::engine::audio_http_client(state).get(url).send().await.map_err(|e| e.to_string())?;
|
let response = crate::engine::playback_scoped_get(state, app, url, None)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let ct = response.headers()
|
let ct = response.headers()
|
||||||
.get(reqwest::header::CONTENT_TYPE)
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use super::analysis_dispatch::{
|
|||||||
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
|
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
|
||||||
TrackAnalysisOrigin,
|
TrackAnalysisOrigin,
|
||||||
};
|
};
|
||||||
use super::engine::{audio_http_client, AudioEngine};
|
use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders};
|
||||||
use super::helpers::{
|
use super::helpers::{
|
||||||
content_type_to_hint, fetch_data, format_hint_from_content_disposition,
|
content_type_to_hint, fetch_data, format_hint_from_content_disposition,
|
||||||
normalize_stream_suffix_for_hint, sniff_stream_format_extension,
|
normalize_stream_suffix_for_hint, sniff_stream_format_extension,
|
||||||
@@ -217,7 +217,12 @@ async fn open_ranged_or_streaming_input(
|
|||||||
state: &State<'_, AudioEngine>,
|
state: &State<'_, AudioEngine>,
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
) -> Result<Option<PlayInput>, String> {
|
) -> Result<Option<PlayInput>, String> {
|
||||||
let response = audio_http_client(state).get(ctx.url).send().await.map_err(|e| e.to_string())?;
|
let http_headers = PlaybackHttpHeaders::from_app(app, ctx.server_id);
|
||||||
|
let response = http_headers
|
||||||
|
.apply(ctx.url, audio_http_client(state).get(ctx.url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
if state.generation.load(Ordering::SeqCst) != ctx.gen {
|
if state.generation.load(Ordering::SeqCst) != ctx.gen {
|
||||||
return Ok(None); // superseded
|
return Ok(None); // superseded
|
||||||
@@ -256,8 +261,8 @@ async fn open_ranged_or_streaming_input(
|
|||||||
let last = total_u64
|
let last = total_u64
|
||||||
.saturating_sub(1)
|
.saturating_sub(1)
|
||||||
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
|
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
|
||||||
if let Ok(pr) = audio_http_client(state)
|
if let Ok(pr) = http_headers
|
||||||
.get(ctx.url)
|
.apply(ctx.url, audio_http_client(state).get(ctx.url))
|
||||||
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
|
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -328,6 +333,7 @@ async fn open_ranged_or_streaming_input(
|
|||||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||||
ctx.cache_id_for_tasks.map(|s| s.to_string()),
|
ctx.cache_id_for_tasks.map(|s| s.to_string()),
|
||||||
ctx.server_id.map(|s| s.to_string()),
|
ctx.server_id.map(|s| s.to_string()),
|
||||||
|
http_headers.clone(),
|
||||||
loudness_hold_for_defer,
|
loudness_hold_for_defer,
|
||||||
playback_armed,
|
playback_armed,
|
||||||
stream_hint.clone(),
|
stream_hint.clone(),
|
||||||
@@ -347,6 +353,7 @@ async fn open_ranged_or_streaming_input(
|
|||||||
total,
|
total,
|
||||||
state.generation.clone(),
|
state.generation.clone(),
|
||||||
ctx.gen,
|
ctx.gen,
|
||||||
|
http_headers.clone(),
|
||||||
)));
|
)));
|
||||||
let reader = RangedHttpSource {
|
let reader = RangedHttpSource {
|
||||||
buf,
|
buf,
|
||||||
@@ -401,6 +408,7 @@ async fn open_ranged_or_streaming_input(
|
|||||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||||
ctx.cache_id_for_tasks.map(|s| s.to_string()),
|
ctx.cache_id_for_tasks.map(|s| s.to_string()),
|
||||||
ctx.server_id.map(|s| s.to_string()),
|
ctx.server_id.map(|s| s.to_string()),
|
||||||
|
http_headers,
|
||||||
playback_armed,
|
playback_armed,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use super::analysis_dispatch::{
|
|||||||
dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file,
|
dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file,
|
||||||
TrackAnalysisOrigin,
|
TrackAnalysisOrigin,
|
||||||
};
|
};
|
||||||
use super::engine::{audio_http_client, AudioEngine};
|
use super::engine::AudioEngine;
|
||||||
use super::helpers::{analysis_cache_track_id, same_playback_target};
|
use super::helpers::{analysis_cache_track_id, same_playback_target};
|
||||||
use super::state::PreloadedTrack;
|
use super::state::PreloadedTrack;
|
||||||
|
|
||||||
@@ -197,7 +197,15 @@ pub async fn audio_preload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
|
let response = crate::engine::playback_scoped_get(
|
||||||
|
&state,
|
||||||
|
&app,
|
||||||
|
&url,
|
||||||
|
server_id.as_deref(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use rodio::Source;
|
|||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
use super::decode::SizedDecoder;
|
use super::decode::SizedDecoder;
|
||||||
use super::engine::{audio_http_client, AudioEngine};
|
use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders};
|
||||||
use super::helpers::{
|
use super::helpers::{
|
||||||
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
|
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
|
||||||
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
|
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
|
||||||
@@ -155,9 +155,10 @@ async fn open_preview_decoder(
|
|||||||
state: &AudioEngine,
|
state: &AudioEngine,
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
) -> Result<Option<SizedDecoder>, String> {
|
) -> Result<Option<SizedDecoder>, String> {
|
||||||
|
let http_headers = PlaybackHttpHeaders::from_app(app, None);
|
||||||
let preview_http = preview_http_client(state);
|
let preview_http = preview_http_client(state);
|
||||||
let response = preview_http
|
let response = http_headers
|
||||||
.get(url)
|
.apply(url, preview_http.get(url))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("preview: connection failed: {e}"))?
|
.map_err(|e| format!("preview: connection failed: {e}"))?
|
||||||
@@ -194,8 +195,8 @@ async fn open_preview_decoder(
|
|||||||
let last = total_u64
|
let last = total_u64
|
||||||
.saturating_sub(1)
|
.saturating_sub(1)
|
||||||
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
|
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
|
||||||
if let Ok(pr) = preview_http
|
if let Ok(pr) = http_headers
|
||||||
.get(url)
|
.apply(url, preview_http.get(url))
|
||||||
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
|
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -257,6 +258,7 @@ async fn open_preview_decoder(
|
|||||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
http_headers.clone(),
|
||||||
None,
|
None,
|
||||||
playback_armed,
|
playback_armed,
|
||||||
stream_hint.clone(),
|
stream_hint.clone(),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use futures_util::StreamExt;
|
|||||||
use symphonia::core::io::MediaSource;
|
use symphonia::core::io::MediaSource;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
|
use super::super::engine::PlaybackHttpHeaders;
|
||||||
use super::super::state::PreloadedTrack;
|
use super::super::state::PreloadedTrack;
|
||||||
use super::{
|
use super::{
|
||||||
RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS,
|
RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS,
|
||||||
@@ -88,6 +89,7 @@ pub(crate) struct OnDemand {
|
|||||||
/// Bumped after every completed (success or failure) fetch so the read loop
|
/// Bumped after every completed (success or failure) fetch so the read loop
|
||||||
/// can reset its stall deadline while on-demand fetches make progress.
|
/// can reset its stall deadline while on-demand fetches make progress.
|
||||||
progress: AtomicU64,
|
progress: AtomicU64,
|
||||||
|
http_headers: PlaybackHttpHeaders,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OnDemand {
|
impl OnDemand {
|
||||||
@@ -100,6 +102,7 @@ impl OnDemand {
|
|||||||
total_size: u64,
|
total_size: u64,
|
||||||
gen_arc: Arc<AtomicU64>,
|
gen_arc: Arc<AtomicU64>,
|
||||||
gen: u64,
|
gen: u64,
|
||||||
|
http_headers: PlaybackHttpHeaders,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
OnDemand {
|
OnDemand {
|
||||||
http,
|
http,
|
||||||
@@ -112,6 +115,7 @@ impl OnDemand {
|
|||||||
filled: Mutex::new(Vec::new()),
|
filled: Mutex::new(Vec::new()),
|
||||||
inflight: Mutex::new(Vec::new()),
|
inflight: Mutex::new(Vec::new()),
|
||||||
progress: AtomicU64::new(0),
|
progress: AtomicU64::new(0),
|
||||||
|
http_headers,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +158,7 @@ impl OnDemand {
|
|||||||
end_inclusive,
|
end_inclusive,
|
||||||
me.gen,
|
me.gen,
|
||||||
&me.gen_arc,
|
&me.gen_arc,
|
||||||
|
&me.http_headers,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if let Ok(written) = res {
|
if let Ok(written) = res {
|
||||||
@@ -367,6 +372,7 @@ pub(crate) async fn ranged_http_download_loop<F>(
|
|||||||
downloaded_to: &Arc<AtomicUsize>,
|
downloaded_to: &Arc<AtomicUsize>,
|
||||||
gen: u64,
|
gen: u64,
|
||||||
gen_arc: &Arc<AtomicU64>,
|
gen_arc: &Arc<AtomicU64>,
|
||||||
|
http_headers: &PlaybackHttpHeaders,
|
||||||
mut on_partial: F,
|
mut on_partial: F,
|
||||||
playback_armed: Option<&AtomicBool>,
|
playback_armed: Option<&AtomicBool>,
|
||||||
) -> (usize, RangedHttpLoopOutcome)
|
) -> (usize, RangedHttpLoopOutcome)
|
||||||
@@ -387,6 +393,7 @@ where
|
|||||||
if downloaded > 0 {
|
if downloaded > 0 {
|
||||||
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
|
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
|
||||||
}
|
}
|
||||||
|
req = http_headers.apply(url, req);
|
||||||
match req.send().await {
|
match req.send().await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -487,6 +494,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch `bytes=start-end` into `buf[start..=end]` (inclusive HTTP Range).
|
/// Fetch `bytes=start-end` into `buf[start..=end]` (inclusive HTTP Range).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn ranged_write_http_range(
|
async fn ranged_write_http_range(
|
||||||
http_client: &reqwest::Client,
|
http_client: &reqwest::Client,
|
||||||
url: &str,
|
url: &str,
|
||||||
@@ -495,13 +503,18 @@ async fn ranged_write_http_range(
|
|||||||
end_inclusive: u64,
|
end_inclusive: u64,
|
||||||
gen: u64,
|
gen: u64,
|
||||||
gen_arc: &Arc<AtomicU64>,
|
gen_arc: &Arc<AtomicU64>,
|
||||||
|
http_headers: &PlaybackHttpHeaders,
|
||||||
) -> Result<usize, ()> {
|
) -> Result<usize, ()> {
|
||||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||||
return Err(());
|
return Err(());
|
||||||
}
|
}
|
||||||
let response = http_client
|
let response = http_headers
|
||||||
.get(url)
|
.apply(
|
||||||
.header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}"))
|
url,
|
||||||
|
http_client
|
||||||
|
.get(url)
|
||||||
|
.header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}")),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ())?;
|
.map_err(|_| ())?;
|
||||||
@@ -555,6 +568,7 @@ async fn ranged_prefetch_mp4_tail(
|
|||||||
playback_armed: Arc<AtomicBool>,
|
playback_armed: Arc<AtomicBool>,
|
||||||
gen: u64,
|
gen: u64,
|
||||||
gen_arc: Arc<AtomicU64>,
|
gen_arc: Arc<AtomicU64>,
|
||||||
|
http_headers: PlaybackHttpHeaders,
|
||||||
) {
|
) {
|
||||||
const MIN_TAIL: u64 = 256 * 1024;
|
const MIN_TAIL: u64 = 256 * 1024;
|
||||||
const MAX_TAIL: u64 = 8 * 1024 * 1024;
|
const MAX_TAIL: u64 = 8 * 1024 * 1024;
|
||||||
@@ -573,6 +587,7 @@ async fn ranged_prefetch_mp4_tail(
|
|||||||
end_inclusive,
|
end_inclusive,
|
||||||
gen,
|
gen,
|
||||||
&gen_arc,
|
&gen_arc,
|
||||||
|
&http_headers,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -622,6 +637,7 @@ pub(crate) async fn ranged_download_task(
|
|||||||
cache_track_id: Option<String>,
|
cache_track_id: Option<String>,
|
||||||
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
|
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
|
||||||
server_id: Option<String>,
|
server_id: Option<String>,
|
||||||
|
http_headers: PlaybackHttpHeaders,
|
||||||
// When `Some`, ranged playback seeds on completion — defer HTTP backfill for that
|
// When `Some`, ranged playback seeds on completion — defer HTTP backfill for that
|
||||||
// track; `None` for large files where ranged skips seed (needs backfill).
|
// track; `None` for large files where ranged skips seed (needs backfill).
|
||||||
loudness_seed_hold: Option<LoudnessSeedHold>,
|
loudness_seed_hold: Option<LoudnessSeedHold>,
|
||||||
@@ -705,6 +721,7 @@ pub(crate) async fn ranged_download_task(
|
|||||||
let tail_from_bg = tail_filled_from.clone();
|
let tail_from_bg = tail_filled_from.clone();
|
||||||
let armed_bg = playback_armed.clone();
|
let armed_bg = playback_armed.clone();
|
||||||
let gen_bg = gen_arc.clone();
|
let gen_bg = gen_arc.clone();
|
||||||
|
let headers_bg = http_headers.clone();
|
||||||
Some(tokio::spawn(async move {
|
Some(tokio::spawn(async move {
|
||||||
ranged_prefetch_mp4_tail(
|
ranged_prefetch_mp4_tail(
|
||||||
client,
|
client,
|
||||||
@@ -716,6 +733,7 @@ pub(crate) async fn ranged_download_task(
|
|||||||
armed_bg,
|
armed_bg,
|
||||||
gen,
|
gen,
|
||||||
gen_bg,
|
gen_bg,
|
||||||
|
headers_bg,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}))
|
}))
|
||||||
@@ -736,6 +754,7 @@ pub(crate) async fn ranged_download_task(
|
|||||||
&downloaded_to,
|
&downloaded_to,
|
||||||
gen,
|
gen,
|
||||||
&gen_arc,
|
&gen_arc,
|
||||||
|
&http_headers,
|
||||||
on_partial,
|
on_partial,
|
||||||
linear_arm,
|
linear_arm,
|
||||||
)
|
)
|
||||||
@@ -1127,6 +1146,7 @@ mod tests {
|
|||||||
&dl,
|
&dl,
|
||||||
1,
|
1,
|
||||||
&gen_arc,
|
&gen_arc,
|
||||||
|
&PlaybackHttpHeaders::default(),
|
||||||
|_, _| {},
|
|_, _| {},
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -1162,6 +1182,7 @@ mod tests {
|
|||||||
&dl,
|
&dl,
|
||||||
1,
|
1,
|
||||||
&gen_arc,
|
&gen_arc,
|
||||||
|
&PlaybackHttpHeaders::default(),
|
||||||
|downloaded, total| calls.lock().unwrap().push((downloaded, total)),
|
|downloaded, total| calls.lock().unwrap().push((downloaded, total)),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -1190,7 +1211,7 @@ mod tests {
|
|||||||
let (buf, dl, gen_arc) = loop_state(1024);
|
let (buf, dl, gen_arc) = loop_state(1024);
|
||||||
|
|
||||||
let (downloaded, outcome) =
|
let (downloaded, outcome) =
|
||||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
|
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert_eq!(outcome, RangedHttpLoopOutcome::Aborted);
|
assert_eq!(outcome, RangedHttpLoopOutcome::Aborted);
|
||||||
@@ -1221,7 +1242,7 @@ mod tests {
|
|||||||
gen_arc.store(99, Ordering::SeqCst);
|
gen_arc.store(99, Ordering::SeqCst);
|
||||||
|
|
||||||
let (downloaded, outcome) =
|
let (downloaded, outcome) =
|
||||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
|
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert_eq!(outcome, RangedHttpLoopOutcome::Superseded);
|
assert_eq!(outcome, RangedHttpLoopOutcome::Superseded);
|
||||||
@@ -1280,7 +1301,7 @@ mod tests {
|
|||||||
let (buf, dl, gen_arc) = loop_state(body.len());
|
let (buf, dl, gen_arc) = loop_state(body.len());
|
||||||
|
|
||||||
let (downloaded, outcome) =
|
let (downloaded, outcome) =
|
||||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
|
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Stream finishes via a Range-resumed second request.
|
// Stream finishes via a Range-resumed second request.
|
||||||
@@ -1354,6 +1375,7 @@ mod tests {
|
|||||||
total as u64,
|
total as u64,
|
||||||
gen_arc.clone(),
|
gen_arc.clone(),
|
||||||
1,
|
1,
|
||||||
|
PlaybackHttpHeaders::default(),
|
||||||
)));
|
)));
|
||||||
let mut src = RangedHttpSource {
|
let mut src = RangedHttpSource {
|
||||||
buf,
|
buf,
|
||||||
@@ -1406,6 +1428,7 @@ mod tests {
|
|||||||
2047,
|
2047,
|
||||||
1,
|
1,
|
||||||
&gen_arc,
|
&gen_arc,
|
||||||
|
&PlaybackHttpHeaders::default(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -1440,7 +1463,7 @@ mod tests {
|
|||||||
let (buf, dl, gen_arc) = loop_state(body.len());
|
let (buf, dl, gen_arc) = loop_state(body.len());
|
||||||
|
|
||||||
let (downloaded, outcome) =
|
let (downloaded, outcome) =
|
||||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
|
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Reconnect server returned 200 instead of 206 → Aborted, downloaded
|
// Reconnect server returned 200 instead of 206 → Aborted, downloaded
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use ringbuf::HeapProd;
|
|||||||
use ringbuf::traits::Producer;
|
use ringbuf::traits::Producer;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
use super::super::engine::PlaybackHttpHeaders;
|
||||||
use super::super::state::PreloadedTrack;
|
use super::super::state::PreloadedTrack;
|
||||||
use super::{
|
use super::{
|
||||||
maybe_arm_stream_playback, TRACK_STREAM_MAX_RECONNECTS, TRACK_STREAM_PROMOTE_MAX_BYTES,
|
maybe_arm_stream_playback, TRACK_STREAM_MAX_RECONNECTS, TRACK_STREAM_PROMOTE_MAX_BYTES,
|
||||||
@@ -37,6 +38,7 @@ pub(crate) async fn track_download_task(
|
|||||||
cache_track_id: Option<String>,
|
cache_track_id: Option<String>,
|
||||||
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
|
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
|
||||||
server_id: Option<String>,
|
server_id: Option<String>,
|
||||||
|
http_headers: PlaybackHttpHeaders,
|
||||||
playback_armed: Arc<AtomicBool>,
|
playback_armed: Arc<AtomicBool>,
|
||||||
) {
|
) {
|
||||||
let mut downloaded: u64 = 0;
|
let mut downloaded: u64 = 0;
|
||||||
@@ -53,6 +55,7 @@ pub(crate) async fn track_download_task(
|
|||||||
if downloaded > 0 {
|
if downloaded > 0 {
|
||||||
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
|
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
|
||||||
}
|
}
|
||||||
|
req = http_headers.apply(&url, req);
|
||||||
match req.send().await {
|
match req.send().await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ publish = false
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2" }
|
tauri = { version = "2" }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["rustls"] }
|
||||||
|
url = "2"
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! macros) and the cross-crate port traits used to break dependency cycles
|
//! macros) and the cross-crate port traits used to break dependency cycles
|
||||||
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
|
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
|
||||||
|
|
||||||
|
pub mod server_http;
|
||||||
pub mod cover_cache_layout;
|
pub mod cover_cache_layout;
|
||||||
pub mod log_sanitize;
|
pub mod log_sanitize;
|
||||||
pub mod media_layout;
|
pub mod media_layout;
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ const SENSITIVE_QUERY_KEYS: &[&str] = &[
|
|||||||
|
|
||||||
const SENSITIVE_KV_KEYS: &[&str] = &[
|
const SENSITIVE_KV_KEYS: &[&str] = &[
|
||||||
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token",
|
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token",
|
||||||
"refresh_token", "authorization", "auth",
|
"refresh_token", "authorization", "auth", "cookie", "x-api-key",
|
||||||
|
"cf-access-client-secret", "cf-access-client-id", "x-auth-token",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Sanitize one runtime log line for display and export.
|
/// Sanitize one runtime log line for display and export.
|
||||||
pub fn sanitize_log_line(line: &str) -> String {
|
pub fn sanitize_log_line(line: &str) -> String {
|
||||||
let mut out = redact_bearer_tokens(line);
|
let mut out = redact_bearer_tokens(line);
|
||||||
|
out = redact_pangolin_headers(&out);
|
||||||
out = redact_sensitive_key_values(&out);
|
out = redact_sensitive_key_values(&out);
|
||||||
out = redact_urls_in_text(&out);
|
out = redact_urls_in_text(&out);
|
||||||
out
|
out
|
||||||
@@ -43,6 +45,37 @@ fn redact_bearer_tokens(line: &str) -> String {
|
|||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn redact_pangolin_headers(line: &str) -> String {
|
||||||
|
let lower = line.to_ascii_lowercase();
|
||||||
|
let mut out = line.to_string();
|
||||||
|
let mut search_from = 0;
|
||||||
|
while let Some(rel) = lower[search_from..].find("x-pangolin-") {
|
||||||
|
let idx = search_from + rel;
|
||||||
|
let after_prefix = &lower[idx..];
|
||||||
|
let Some(sep_rel) = after_prefix.find([':', '=']) else {
|
||||||
|
search_from = idx + 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let sep_idx = idx + sep_rel;
|
||||||
|
let val_start = sep_idx + 1;
|
||||||
|
let slice = &out[val_start..];
|
||||||
|
let trimmed = slice.trim_start();
|
||||||
|
let ws = slice.len().saturating_sub(trimmed.len());
|
||||||
|
let val_start = val_start + ws;
|
||||||
|
let end = trimmed
|
||||||
|
.find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')')
|
||||||
|
.unwrap_or(trimmed.len());
|
||||||
|
if end > 0 {
|
||||||
|
out.replace_range(val_start..val_start + end, "REDACTED");
|
||||||
|
}
|
||||||
|
search_from = val_start + "REDACTED".len();
|
||||||
|
if search_from >= out.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn redact_sensitive_key_values(line: &str) -> String {
|
fn redact_sensitive_key_values(line: &str) -> String {
|
||||||
let mut out = line.to_string();
|
let mut out = line.to_string();
|
||||||
for key in SENSITIVE_KV_KEYS {
|
for key in SENSITIVE_KV_KEYS {
|
||||||
@@ -368,6 +401,17 @@ mod tests {
|
|||||||
assert!(!out.contains("user:pass"));
|
assert!(!out.contains("user:pass"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redacts_reverse_proxy_gate_headers() {
|
||||||
|
let line = "req CF-Access-Client-Secret: gate-secret Authorization: Bearer tok123 x-pangolin-auth: pangolin-key";
|
||||||
|
let out = sanitize_log_line(line);
|
||||||
|
assert!(out.contains("CF-Access-Client-Secret: REDACTED"));
|
||||||
|
assert!(!out.contains("gate-secret"));
|
||||||
|
assert!(!out.contains("tok123"));
|
||||||
|
assert!(out.contains("x-pangolin-auth: REDACTED"));
|
||||||
|
assert!(!out.contains("pangolin-key"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stream_log_with_em_dash_does_not_panic() {
|
fn stream_log_with_em_dash_does_not_panic() {
|
||||||
let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")";
|
let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")";
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
//! Per-server custom HTTP headers for reverse-proxy gates (Pangolin, Cloudflare Access).
|
||||||
|
//! Registry is keyed by index key; app server UUID aliases resolve via `ref_to_key`.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||||
|
use reqwest::RequestBuilder;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum EndpointKind {
|
||||||
|
Local,
|
||||||
|
Public,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum CustomHeadersApplyTo {
|
||||||
|
Local,
|
||||||
|
#[default]
|
||||||
|
Public,
|
||||||
|
Both,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ServerHttpEndpointWire {
|
||||||
|
pub url: String,
|
||||||
|
pub kind: EndpointKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CustomHeaderEntryWire {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ServerHttpContextSyncWire {
|
||||||
|
#[serde(rename = "serverId")]
|
||||||
|
pub server_id: String,
|
||||||
|
#[serde(rename = "appServerId")]
|
||||||
|
pub app_server_id: String,
|
||||||
|
pub endpoints: Vec<ServerHttpEndpointWire>,
|
||||||
|
#[serde(rename = "customHeaders", default)]
|
||||||
|
pub custom_headers: Vec<CustomHeaderEntryWire>,
|
||||||
|
#[serde(rename = "customHeadersApplyTo", default)]
|
||||||
|
pub custom_headers_apply_to: Option<CustomHeadersApplyTo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ServerHttpContext {
|
||||||
|
pub endpoints: Vec<(String, EndpointKind)>,
|
||||||
|
pub headers: Vec<(String, String)>,
|
||||||
|
pub apply_to: CustomHeadersApplyTo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ServerHttpContextSyncWire> for ServerHttpContext {
|
||||||
|
fn from(w: ServerHttpContextSyncWire) -> Self {
|
||||||
|
Self {
|
||||||
|
endpoints: w
|
||||||
|
.endpoints
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| (normalize_server_base_url(&e.url), e.kind))
|
||||||
|
.collect(),
|
||||||
|
headers: w
|
||||||
|
.custom_headers
|
||||||
|
.into_iter()
|
||||||
|
.map(|h| (h.name.trim().to_string(), h.value))
|
||||||
|
.filter(|(n, _)| !n.is_empty())
|
||||||
|
.collect(),
|
||||||
|
apply_to: w.custom_headers_apply_to.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_server_base_url(raw: &str) -> String {
|
||||||
|
let trimmed = raw.trim().trim_end_matches('/');
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||||
|
trimmed.to_string()
|
||||||
|
} else {
|
||||||
|
format!("http://{trimmed}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip `/rest/…`, `/api/…`, `/auth/…`, and query from a full HTTP URL to match TS `requestBaseUrlFromHttpUrl`.
|
||||||
|
pub fn request_base_url_from_http_url(raw_url: &str) -> String {
|
||||||
|
let trimmed = raw_url.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||||
|
trimmed.to_string()
|
||||||
|
} else {
|
||||||
|
format!("http://{trimmed}")
|
||||||
|
};
|
||||||
|
let Ok(mut parsed) = url::Url::parse(&with_scheme) else {
|
||||||
|
return normalize_server_base_url(trimmed);
|
||||||
|
};
|
||||||
|
parsed.set_query(None);
|
||||||
|
parsed.set_fragment(None);
|
||||||
|
let mut path = parsed.path().to_string();
|
||||||
|
if let Some(idx) = path.find("/rest/") {
|
||||||
|
path.truncate(idx);
|
||||||
|
} else if path.ends_with("/rest") {
|
||||||
|
path.truncate(path.len().saturating_sub("/rest".len()));
|
||||||
|
} else {
|
||||||
|
for seg in ["/api/", "/auth/"] {
|
||||||
|
if let Some(idx) = path.find(seg) {
|
||||||
|
path.truncate(idx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while path.ends_with('/') && path.len() > 1 {
|
||||||
|
path.pop();
|
||||||
|
}
|
||||||
|
parsed.set_path(if path.is_empty() { "/" } else { &path });
|
||||||
|
let host = parsed.host_str().unwrap_or_default();
|
||||||
|
if host.is_empty() {
|
||||||
|
return normalize_server_base_url(trimmed);
|
||||||
|
}
|
||||||
|
let mut out = format!("{}://{}", parsed.scheme(), host);
|
||||||
|
if let Some(port) = parsed.port() {
|
||||||
|
out.push(':');
|
||||||
|
out.push_str(&port.to_string());
|
||||||
|
}
|
||||||
|
if !path.is_empty() && path != "/" {
|
||||||
|
out.push_str(&path);
|
||||||
|
}
|
||||||
|
normalize_server_base_url(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn headers_for_request_base_url(ctx: &ServerHttpContext, request_base_url: &str) -> HeaderMap {
|
||||||
|
let mut map = HeaderMap::new();
|
||||||
|
if ctx.headers.is_empty() {
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
let normalized = normalize_server_base_url(request_base_url);
|
||||||
|
let Some((_, kind)) = ctx.endpoints.iter().find(|(u, _)| *u == normalized) else {
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
let apply = match ctx.apply_to {
|
||||||
|
CustomHeadersApplyTo::Both => true,
|
||||||
|
CustomHeadersApplyTo::Public => *kind == EndpointKind::Public,
|
||||||
|
CustomHeadersApplyTo::Local => *kind == EndpointKind::Local,
|
||||||
|
};
|
||||||
|
if !apply {
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
for (name, value) in &ctx.headers {
|
||||||
|
let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(header_value) = HeaderValue::from_str(value) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
map.insert(header_name, header_value);
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_server_headers(
|
||||||
|
builder: RequestBuilder,
|
||||||
|
ctx: &ServerHttpContext,
|
||||||
|
request_base_url: &str,
|
||||||
|
) -> RequestBuilder {
|
||||||
|
let map = headers_for_request_base_url(ctx, request_base_url);
|
||||||
|
if map.is_empty() {
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
builder.headers(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_server_headers_for_http_url(
|
||||||
|
builder: RequestBuilder,
|
||||||
|
ctx: &ServerHttpContext,
|
||||||
|
full_http_url: &str,
|
||||||
|
) -> RequestBuilder {
|
||||||
|
let base = request_base_url_from_http_url(full_http_url);
|
||||||
|
apply_server_headers(builder, ctx, &base)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ServerHttpRegistry {
|
||||||
|
contexts: Mutex<HashMap<String, Arc<ServerHttpContext>>>,
|
||||||
|
ref_to_key: Mutex<HashMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerHttpRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sync(&self, wire: ServerHttpContextSyncWire) {
|
||||||
|
let index_key = wire.server_id.clone();
|
||||||
|
let app_id = wire.app_server_id.clone();
|
||||||
|
let ctx = Arc::new(ServerHttpContext::from(wire));
|
||||||
|
if ctx.headers.is_empty() {
|
||||||
|
self.remove(&index_key, &app_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut contexts = self.contexts.lock().unwrap();
|
||||||
|
contexts.insert(index_key.clone(), Arc::clone(&ctx));
|
||||||
|
}
|
||||||
|
let mut refs = self.ref_to_key.lock().unwrap();
|
||||||
|
refs.insert(index_key.clone(), index_key.clone());
|
||||||
|
refs.insert(app_id, index_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sync_all(&self, entries: Vec<ServerHttpContextSyncWire>) {
|
||||||
|
let mut new_contexts = HashMap::new();
|
||||||
|
let mut new_refs = HashMap::new();
|
||||||
|
for wire in entries {
|
||||||
|
let index_key = wire.server_id.clone();
|
||||||
|
let app_id = wire.app_server_id.clone();
|
||||||
|
let ctx = Arc::new(ServerHttpContext::from(wire));
|
||||||
|
if ctx.headers.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
new_contexts.insert(index_key.clone(), Arc::clone(&ctx));
|
||||||
|
new_refs.insert(index_key.clone(), index_key.clone());
|
||||||
|
new_refs.insert(app_id, index_key);
|
||||||
|
}
|
||||||
|
*self.contexts.lock().unwrap() = new_contexts;
|
||||||
|
*self.ref_to_key.lock().unwrap() = new_refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&self, index_key: &str, app_server_id: &str) {
|
||||||
|
self.contexts.lock().unwrap().remove(index_key);
|
||||||
|
let mut refs = self.ref_to_key.lock().unwrap();
|
||||||
|
refs.remove(index_key);
|
||||||
|
refs.remove(app_server_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, index_key: &str) -> Option<Arc<ServerHttpContext>> {
|
||||||
|
self.contexts.lock().unwrap().get(index_key).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_for_server_ref(&self, server_ref: &str) -> Option<Arc<ServerHttpContext>> {
|
||||||
|
if server_ref.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let key = {
|
||||||
|
let refs = self.ref_to_key.lock().unwrap();
|
||||||
|
refs.get(server_ref).cloned()
|
||||||
|
};
|
||||||
|
if let Some(k) = key {
|
||||||
|
return self.get(&k);
|
||||||
|
}
|
||||||
|
self.get(server_ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fallback when only a server base URL is known (Navidrome invoke paths).
|
||||||
|
pub fn get_for_server_url(&self, server_url: &str) -> Option<Arc<ServerHttpContext>> {
|
||||||
|
let base = request_base_url_from_http_url(server_url);
|
||||||
|
if base.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let contexts = self.contexts.lock().unwrap();
|
||||||
|
for ctx in contexts.values() {
|
||||||
|
if ctx.endpoints.iter().any(|(u, _)| *u == base) {
|
||||||
|
return Some(Arc::clone(ctx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_for_http_url(
|
||||||
|
&self,
|
||||||
|
server_ref: &str,
|
||||||
|
full_http_url: &str,
|
||||||
|
builder: RequestBuilder,
|
||||||
|
) -> RequestBuilder {
|
||||||
|
let Some(ctx) = self.get_for_server_ref(server_ref) else {
|
||||||
|
return builder;
|
||||||
|
};
|
||||||
|
apply_server_headers_for_http_url(builder, &ctx, full_http_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_for_base_url(
|
||||||
|
&self,
|
||||||
|
server_ref: &str,
|
||||||
|
request_base_url: &str,
|
||||||
|
builder: RequestBuilder,
|
||||||
|
) -> RequestBuilder {
|
||||||
|
let Some(ctx) = self.get_for_server_ref(server_ref) else {
|
||||||
|
return builder;
|
||||||
|
};
|
||||||
|
apply_server_headers(builder, &ctx, request_base_url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply custom headers when `registry` is present — prefers `server_ref`, falls back to URL match.
|
||||||
|
pub fn apply_optional_registry_headers(
|
||||||
|
registry: Option<&ServerHttpRegistry>,
|
||||||
|
server_ref: Option<&str>,
|
||||||
|
full_http_url: &str,
|
||||||
|
builder: RequestBuilder,
|
||||||
|
) -> RequestBuilder {
|
||||||
|
if let Some(reg) = registry {
|
||||||
|
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
|
||||||
|
return reg.apply_for_http_url(sid, full_http_url, builder);
|
||||||
|
}
|
||||||
|
if let Some(ctx) = reg.get_for_server_url(full_http_url) {
|
||||||
|
return apply_server_headers_for_http_url(builder, &ctx, full_http_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_base_url_strips_rest_and_query() {
|
||||||
|
let url = "https://music.example/rest/stream.view?id=1&u=x";
|
||||||
|
assert_eq!(
|
||||||
|
request_base_url_from_http_url(url),
|
||||||
|
"https://music.example"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn headers_apply_public_only_on_public_endpoint() {
|
||||||
|
let ctx = ServerHttpContext {
|
||||||
|
endpoints: vec![
|
||||||
|
("http://192.168.0.10".into(), EndpointKind::Local),
|
||||||
|
("https://music.example".into(), EndpointKind::Public),
|
||||||
|
],
|
||||||
|
headers: vec![("X-Gate".into(), "secret".into())],
|
||||||
|
apply_to: CustomHeadersApplyTo::Public,
|
||||||
|
};
|
||||||
|
let lan = headers_for_request_base_url(&ctx, "http://192.168.0.10");
|
||||||
|
assert!(lan.is_empty());
|
||||||
|
let pub_ = headers_for_request_base_url(&ctx, "https://music.example");
|
||||||
|
assert_eq!(pub_.get("X-Gate").map(|v| v.to_str().ok()), Some(Some("secret")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_resolves_app_id_alias() {
|
||||||
|
let reg = ServerHttpRegistry::new();
|
||||||
|
reg.sync(ServerHttpContextSyncWire {
|
||||||
|
server_id: "music.example".into(),
|
||||||
|
app_server_id: "uuid-1".into(),
|
||||||
|
endpoints: vec![ServerHttpEndpointWire {
|
||||||
|
url: "https://music.example".into(),
|
||||||
|
kind: EndpointKind::Public,
|
||||||
|
}],
|
||||||
|
custom_headers: vec![CustomHeaderEntryWire {
|
||||||
|
name: "X-Gate".into(),
|
||||||
|
value: "tok".into(),
|
||||||
|
}],
|
||||||
|
custom_headers_apply_to: Some(CustomHeadersApplyTo::Public),
|
||||||
|
});
|
||||||
|
assert!(reg.get("music.example").is_some());
|
||||||
|
assert!(reg.get_for_server_ref("uuid-1").is_some());
|
||||||
|
assert!(reg.get("uuid-1").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,31 @@
|
|||||||
//! Auth + retry + HTTP client for Navidrome's native REST API.
|
//! Auth + retry + HTTP client for Navidrome's native REST API.
|
||||||
//! Used by every other navidrome submodule for `/auth/*` and `/api/*` calls.
|
//! Used by every other navidrome submodule for `/auth/*` and `/api/*` calls.
|
||||||
|
|
||||||
|
use psysonic_core::server_http::{apply_server_headers_for_http_url, apply_optional_registry_headers, ServerHttpRegistry};
|
||||||
|
|
||||||
/// Authenticate with Navidrome's own REST API and return a Bearer token.
|
/// Authenticate with Navidrome's own REST API and return a Bearer token.
|
||||||
pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
|
pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
|
||||||
|
navidrome_token_with_registry(None, server_url, username, password).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn navidrome_token_with_registry(
|
||||||
|
registry: Option<&ServerHttpRegistry>,
|
||||||
|
server_url: &str,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let resp = client
|
let base = server_url.trim_end_matches('/');
|
||||||
.post(format!("{}/auth/login", server_url))
|
let login_url = format!("{base}/auth/login");
|
||||||
.json(&serde_json::json!({ "username": username, "password": password }))
|
let mut req = client
|
||||||
.send()
|
.post(&login_url)
|
||||||
.await
|
.json(&serde_json::json!({ "username": username, "password": password }));
|
||||||
.map_err(|e| e.to_string())?;
|
if let Some(reg) = registry {
|
||||||
|
if let Some(ctx) = reg.get_for_server_url(server_url) {
|
||||||
|
req = apply_server_headers_for_http_url(req, &ctx, &login_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let resp = req.send().await.map_err(|e| e.to_string())?;
|
||||||
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
data["token"]
|
data["token"]
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -17,6 +33,16 @@ pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -
|
|||||||
.ok_or_else(|| "Navidrome auth: no token in response".to_string())
|
.ok_or_else(|| "Navidrome auth: no token in response".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach gate headers for Navidrome `/auth/*` and `/api/*` requests.
|
||||||
|
pub fn nd_apply_request(
|
||||||
|
registry: Option<&ServerHttpRegistry>,
|
||||||
|
server_ref: Option<&str>,
|
||||||
|
full_url: &str,
|
||||||
|
builder: reqwest::RequestBuilder,
|
||||||
|
) -> reqwest::RequestBuilder {
|
||||||
|
apply_optional_registry_headers(registry, server_ref, full_url, builder)
|
||||||
|
}
|
||||||
|
|
||||||
/// Payload returned by Navidrome's `/auth/login`.
|
/// Payload returned by Navidrome's `/auth/login`.
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub struct NdLoginResult {
|
pub struct NdLoginResult {
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
//! login (via `navidrome_token`) and then a multipart POST to the relevant
|
//! login (via `navidrome_token`) and then a multipart POST to the relevant
|
||||||
//! `/api/{playlist|radio|artist}/{id}/image` endpoint.
|
//! `/api/{playlist|radio|artist}/{id}/image` endpoint.
|
||||||
|
|
||||||
use super::client::navidrome_token;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use psysonic_core::server_http::ServerHttpRegistry;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use super::client::{navidrome_token_with_registry, nd_apply_request, nd_http_client};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn upload_playlist_cover(
|
pub async fn upload_playlist_cover(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
playlist_id: String,
|
playlist_id: String,
|
||||||
username: String,
|
username: String,
|
||||||
@@ -13,26 +19,34 @@ pub async fn upload_playlist_cover(
|
|||||||
file_bytes: Vec<u8>,
|
file_bytes: Vec<u8>,
|
||||||
mime_type: String,
|
mime_type: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
let reg = http_registry.as_ref();
|
||||||
|
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
|
||||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||||
.file_name("cover.jpg")
|
.file_name("cover.jpg")
|
||||||
.mime_str(&mime_type)
|
.mime_str(&mime_type)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let form = reqwest::multipart::Form::new().part("image", part);
|
let form = reqwest::multipart::Form::new().part("image", part);
|
||||||
reqwest::Client::new()
|
let url = format!("{}/api/playlist/{}/image", server_url, playlist_id);
|
||||||
.post(format!("{}/api/playlist/{}/image", server_url, playlist_id))
|
nd_apply_request(
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
Some(reg),
|
||||||
.multipart(form)
|
None,
|
||||||
.send()
|
&url,
|
||||||
.await
|
nd_http_client()
|
||||||
.map_err(|e| e.to_string())?
|
.post(&url)
|
||||||
.error_for_status()
|
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||||
.map_err(|e| e.to_string())?;
|
.multipart(form),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn upload_radio_cover(
|
pub async fn upload_radio_cover(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
radio_id: String,
|
radio_id: String,
|
||||||
username: String,
|
username: String,
|
||||||
@@ -40,26 +54,34 @@ pub async fn upload_radio_cover(
|
|||||||
file_bytes: Vec<u8>,
|
file_bytes: Vec<u8>,
|
||||||
mime_type: String,
|
mime_type: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
let reg = http_registry.as_ref();
|
||||||
|
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
|
||||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||||
.file_name("cover.jpg")
|
.file_name("cover.jpg")
|
||||||
.mime_str(&mime_type)
|
.mime_str(&mime_type)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let form = reqwest::multipart::Form::new().part("image", part);
|
let form = reqwest::multipart::Form::new().part("image", part);
|
||||||
reqwest::Client::new()
|
let url = format!("{}/api/radio/{}/image", server_url, radio_id);
|
||||||
.post(format!("{}/api/radio/{}/image", server_url, radio_id))
|
nd_apply_request(
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
Some(reg),
|
||||||
.multipart(form)
|
None,
|
||||||
.send()
|
&url,
|
||||||
.await
|
nd_http_client()
|
||||||
.map_err(|e| e.to_string())?
|
.post(&url)
|
||||||
.error_for_status()
|
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||||
.map_err(|e| e.to_string())?;
|
.multipart(form),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn upload_artist_image(
|
pub async fn upload_artist_image(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
artist_id: String,
|
artist_id: String,
|
||||||
username: String,
|
username: String,
|
||||||
@@ -67,40 +89,58 @@ pub async fn upload_artist_image(
|
|||||||
file_bytes: Vec<u8>,
|
file_bytes: Vec<u8>,
|
||||||
mime_type: String,
|
mime_type: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
let reg = http_registry.as_ref();
|
||||||
|
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
|
||||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||||
.file_name("cover.jpg")
|
.file_name("cover.jpg")
|
||||||
.mime_str(&mime_type)
|
.mime_str(&mime_type)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let form = reqwest::multipart::Form::new().part("image", part);
|
let form = reqwest::multipart::Form::new().part("image", part);
|
||||||
reqwest::Client::new()
|
let url = format!("{}/api/artist/{}/image", server_url, artist_id);
|
||||||
.post(format!("{}/api/artist/{}/image", server_url, artist_id))
|
nd_apply_request(
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
Some(reg),
|
||||||
.multipart(form)
|
None,
|
||||||
.send()
|
&url,
|
||||||
.await
|
nd_http_client()
|
||||||
.map_err(|e| e.to_string())?
|
.post(&url)
|
||||||
.error_for_status()
|
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||||
.map_err(|e| e.to_string())?;
|
.multipart(form),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_radio_cover(
|
pub async fn delete_radio_cover(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
radio_id: String,
|
radio_id: String,
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
let reg = http_registry.as_ref();
|
||||||
let resp = reqwest::Client::new()
|
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
|
||||||
.delete(format!("{}/api/radio/{}/image", server_url, radio_id))
|
let url = format!("{}/api/radio/{}/image", server_url, radio_id);
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
let resp = nd_apply_request(
|
||||||
.send()
|
Some(reg),
|
||||||
.await
|
None,
|
||||||
.map_err(|e| e.to_string())?;
|
&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
|
// 404/503 = no image existed — treat as success
|
||||||
if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE {
|
if !resp.status().is_success()
|
||||||
|
&& resp.status() != reqwest::StatusCode::NOT_FOUND
|
||||||
|
&& resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE
|
||||||
|
{
|
||||||
resp.error_for_status().map_err(|e| e.to_string())?;
|
resp.error_for_status().map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ pub mod probe;
|
|||||||
pub mod queries;
|
pub mod queries;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|
||||||
pub use client::navidrome_token;
|
pub use client::{navidrome_token, navidrome_token_with_registry, nd_apply_request};
|
||||||
|
|||||||
@@ -2,24 +2,41 @@
|
|||||||
//! payload is forwarded as-is so the frontend can compose any rule the
|
//! payload is forwarded as-is so the frontend can compose any rule the
|
||||||
//! Navidrome version supports without backend changes.
|
//! Navidrome version supports without backend changes.
|
||||||
|
|
||||||
use super::client::{nd_err, nd_http_client, nd_retry};
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use psysonic_core::server_http::ServerHttpRegistry;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry};
|
||||||
|
|
||||||
/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists.
|
/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn nd_list_playlists(
|
pub async fn nd_list_playlists(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
token: String,
|
token: String,
|
||||||
smart: Option<bool>,
|
smart: Option<bool>,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let reg = http_registry.as_ref();
|
||||||
|
let base = format!("{}/api/playlist", server_url);
|
||||||
|
let auth = format!("Bearer {}", token);
|
||||||
let resp = nd_retry(|| {
|
let resp = nd_retry(|| {
|
||||||
let client = nd_http_client();
|
let base = base.clone();
|
||||||
let mut req = client
|
let auth = auth.clone();
|
||||||
.get(format!("{}/api/playlist", server_url))
|
async move {
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token));
|
let mut req = nd_apply_request(
|
||||||
if let Some(s) = smart {
|
Some(reg),
|
||||||
req = req.query(&[("smart", s)]);
|
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?;
|
.await?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
@@ -31,16 +48,31 @@ pub async fn nd_list_playlists(
|
|||||||
/// POST `/api/playlist` — create playlist (supports smart rules payload).
|
/// POST `/api/playlist` — create playlist (supports smart rules payload).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn nd_create_playlist(
|
pub async fn nd_create_playlist(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
token: String,
|
token: String,
|
||||||
body: serde_json::Value,
|
body: serde_json::Value,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let reg = http_registry.as_ref();
|
||||||
|
let url = format!("{}/api/playlist", server_url);
|
||||||
|
let auth = format!("Bearer {}", token);
|
||||||
let resp = nd_retry(|| {
|
let resp = nd_retry(|| {
|
||||||
nd_http_client()
|
let url = url.clone();
|
||||||
.post(format!("{}/api/playlist", server_url))
|
let auth = auth.clone();
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
let body = body.clone();
|
||||||
.json(&body)
|
async move {
|
||||||
|
nd_apply_request(
|
||||||
|
Some(reg),
|
||||||
|
None,
|
||||||
|
&url,
|
||||||
|
nd_http_client()
|
||||||
|
.post(&url)
|
||||||
|
.header("X-ND-Authorization", auth)
|
||||||
|
.json(&body),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
|
.await
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
@@ -54,17 +86,32 @@ pub async fn nd_create_playlist(
|
|||||||
/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload).
|
/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn nd_update_playlist(
|
pub async fn nd_update_playlist(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
token: String,
|
token: String,
|
||||||
id: String,
|
id: String,
|
||||||
body: serde_json::Value,
|
body: serde_json::Value,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let reg = http_registry.as_ref();
|
||||||
|
let url = format!("{}/api/playlist/{}", server_url, id);
|
||||||
|
let auth = format!("Bearer {}", token);
|
||||||
let resp = nd_retry(|| {
|
let resp = nd_retry(|| {
|
||||||
nd_http_client()
|
let url = url.clone();
|
||||||
.put(format!("{}/api/playlist/{}", server_url, id))
|
let auth = auth.clone();
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
let body = body.clone();
|
||||||
.json(&body)
|
async move {
|
||||||
|
nd_apply_request(
|
||||||
|
Some(reg),
|
||||||
|
None,
|
||||||
|
&url,
|
||||||
|
nd_http_client()
|
||||||
|
.put(&url)
|
||||||
|
.header("X-ND-Authorization", auth)
|
||||||
|
.json(&body),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
|
.await
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
@@ -78,15 +125,29 @@ pub async fn nd_update_playlist(
|
|||||||
/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available).
|
/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn nd_get_playlist(
|
pub async fn nd_get_playlist(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
token: String,
|
token: String,
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let reg = http_registry.as_ref();
|
||||||
|
let url = format!("{}/api/playlist/{}", server_url, id);
|
||||||
|
let auth = format!("Bearer {}", token);
|
||||||
let resp = nd_retry(|| {
|
let resp = nd_retry(|| {
|
||||||
nd_http_client()
|
let url = url.clone();
|
||||||
.get(format!("{}/api/playlist/{}", server_url, id))
|
let auth = auth.clone();
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
async move {
|
||||||
|
nd_apply_request(
|
||||||
|
Some(reg),
|
||||||
|
None,
|
||||||
|
&url,
|
||||||
|
nd_http_client()
|
||||||
|
.get(&url)
|
||||||
|
.header("X-ND-Authorization", auth),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
|
.await
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
@@ -100,15 +161,29 @@ pub async fn nd_get_playlist(
|
|||||||
/// DELETE `/api/playlist/{id}` — delete playlist.
|
/// DELETE `/api/playlist/{id}` — delete playlist.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn nd_delete_playlist(
|
pub async fn nd_delete_playlist(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
token: String,
|
token: String,
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
let reg = http_registry.as_ref();
|
||||||
|
let url = format!("{}/api/playlist/{}", server_url, id);
|
||||||
|
let auth = format!("Bearer {}", token);
|
||||||
let resp = nd_retry(|| {
|
let resp = nd_retry(|| {
|
||||||
nd_http_client()
|
let url = url.clone();
|
||||||
.delete(format!("{}/api/playlist/{}", server_url, id))
|
let auth = auth.clone();
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
async move {
|
||||||
|
nd_apply_request(
|
||||||
|
Some(reg),
|
||||||
|
None,
|
||||||
|
&url,
|
||||||
|
nd_http_client()
|
||||||
|
.delete(&url)
|
||||||
|
.header("X-ND-Authorization", auth),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
|
.await
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! endpoint? — so this stays a free function rather than a client
|
//! endpoint? — so this stays a free function rather than a client
|
||||||
//! struct. The full `nd_list_songs`-style ingest loop lands with PR-3b.
|
//! struct. The full `nd_list_songs`-style ingest loop lands with PR-3b.
|
||||||
|
|
||||||
use super::client::{nd_err, nd_http_client};
|
use super::client::{nd_apply_request, nd_err, nd_http_client};
|
||||||
|
|
||||||
/// Returns `Ok(true)` when `GET /api/song?_start=0&_end=1` answers with
|
/// Returns `Ok(true)` when `GET /api/song?_start=0&_end=1` answers with
|
||||||
/// a 2xx status, `Ok(false)` for 4xx (auth ok but endpoint missing or
|
/// a 2xx status, `Ok(false)` for 4xx (auth ok but endpoint missing or
|
||||||
@@ -16,15 +16,25 @@ use super::client::{nd_err, nd_http_client};
|
|||||||
/// Spec §6.1 ties the result to the `NavidromeNativeBulk` capability
|
/// Spec §6.1 ties the result to the `NavidromeNativeBulk` capability
|
||||||
/// flag. Wider call into the actual ingest path (`nd_list_songs` port)
|
/// flag. Wider call into the actual ingest path (`nd_list_songs` port)
|
||||||
/// is PR-3b's job.
|
/// is PR-3b's job.
|
||||||
pub async fn native_bulk_available(server_url: &str, token: &str) -> Result<bool, String> {
|
pub async fn native_bulk_available(
|
||||||
|
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||||
|
server_ref: Option<&str>,
|
||||||
|
server_url: &str,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<bool, String> {
|
||||||
let client = nd_http_client();
|
let client = nd_http_client();
|
||||||
let url = format!("{}/api/song?_start=0&_end=1", server_url.trim_end_matches('/'));
|
let url = format!("{}/api/song?_start=0&_end=1", server_url.trim_end_matches('/'));
|
||||||
let resp = client
|
let resp = nd_apply_request(
|
||||||
.get(url)
|
registry,
|
||||||
.header("X-ND-Authorization", format!("Bearer {token}"))
|
server_ref,
|
||||||
.send()
|
&url,
|
||||||
.await
|
client
|
||||||
.map_err(nd_err)?;
|
.get(&url)
|
||||||
|
.header("X-ND-Authorization", format!("Bearer {token}")),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(nd_err)?;
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
@@ -56,7 +66,7 @@ mod tests {
|
|||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let ok = native_bulk_available(&server.uri(), "tok-123").await.unwrap();
|
let ok = native_bulk_available(None, None, &server.uri(), "tok-123").await.unwrap();
|
||||||
assert!(ok);
|
assert!(ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +81,7 @@ mod tests {
|
|||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let ok = native_bulk_available(&server.uri(), "tok").await.unwrap();
|
let ok = native_bulk_available(None, None, &server.uri(), "tok").await.unwrap();
|
||||||
assert!(!ok);
|
assert!(!ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +94,7 @@ mod tests {
|
|||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let ok = native_bulk_available(&server.uri(), "bad").await.unwrap();
|
let ok = native_bulk_available(None, None, &server.uri(), "bad").await.unwrap();
|
||||||
assert!(!ok, "401 reads as `endpoint not available for this caller`");
|
assert!(!ok, "401 reads as `endpoint not available for this caller`");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +107,7 @@ mod tests {
|
|||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let err = native_bulk_available(&server.uri(), "tok").await.unwrap_err();
|
let err = native_bulk_available(None, None, &server.uri(), "tok").await.unwrap_err();
|
||||||
assert!(err.contains("503"));
|
assert!(err.contains("503"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +121,6 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
let with_slash = format!("{}/", server.uri());
|
let with_slash = format!("{}/", server.uri());
|
||||||
assert!(native_bulk_available(&with_slash, "tok").await.unwrap());
|
assert!(native_bulk_available(None, None, &with_slash, "tok").await.unwrap());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,21 @@
|
|||||||
//! incompletely: songs, role-filtered artist/album lists, libraries,
|
//! incompletely: songs, role-filtered artist/album lists, libraries,
|
||||||
//! per-user library assignment, and absolute song path resolution.
|
//! per-user library assignment, and absolute song path resolution.
|
||||||
|
|
||||||
use super::client::{navidrome_token, nd_err, nd_http_client, nd_retry};
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use psysonic_core::server_http::ServerHttpRegistry;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use super::client::{navidrome_token_with_registry, nd_apply_request, nd_err, nd_http_client, nd_retry};
|
||||||
|
|
||||||
/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated
|
/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated
|
||||||
/// song list. Pure async helper used by the library-side N1 ingest
|
/// song list. Pure async helper used by the library-side N1 ingest
|
||||||
/// loop (spec §6.3, PR-3*); also wrapped by the `#[tauri::command]`
|
/// loop (spec §6.3, PR-3*); also wrapped by the `#[tauri::command]`
|
||||||
/// variant below for existing frontend callers.
|
/// variant below for existing frontend callers.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn nd_list_songs_internal(
|
pub async fn nd_list_songs_internal(
|
||||||
|
registry: Option<&ServerHttpRegistry>,
|
||||||
|
server_ref: Option<&str>,
|
||||||
server_url: &str,
|
server_url: &str,
|
||||||
token: &str,
|
token: &str,
|
||||||
sort: &str,
|
sort: &str,
|
||||||
@@ -20,12 +28,24 @@ pub async fn nd_list_songs_internal(
|
|||||||
"{}/api/song?_sort={}&_order={}&_start={}&_end={}",
|
"{}/api/song?_sort={}&_order={}&_start={}&_end={}",
|
||||||
server_url, sort, order, start, end
|
server_url, sort, order, start, end
|
||||||
);
|
);
|
||||||
|
let auth = format!("Bearer {token}");
|
||||||
let resp = nd_retry(|| {
|
let resp = nd_retry(|| {
|
||||||
nd_http_client()
|
let url = url.clone();
|
||||||
.get(&url)
|
let auth = auth.clone();
|
||||||
.header("X-ND-Authorization", format!("Bearer {token}"))
|
async move {
|
||||||
|
nd_apply_request(
|
||||||
|
registry,
|
||||||
|
server_ref,
|
||||||
|
&url,
|
||||||
|
nd_http_client()
|
||||||
|
.get(&url)
|
||||||
|
.header("X-ND-Authorization", auth),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
}).await?;
|
.await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(format!("HTTP {}", resp.status()));
|
return Err(format!("HTTP {}", resp.status()));
|
||||||
}
|
}
|
||||||
@@ -36,6 +56,7 @@ pub async fn nd_list_songs_internal(
|
|||||||
/// surface unchanged for existing call sites in the WebView.
|
/// surface unchanged for existing call sites in the WebView.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn nd_list_songs(
|
pub async fn nd_list_songs(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
token: String,
|
token: String,
|
||||||
sort: String,
|
sort: String,
|
||||||
@@ -43,7 +64,17 @@ pub async fn nd_list_songs(
|
|||||||
start: u32,
|
start: u32,
|
||||||
end: u32,
|
end: u32,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
nd_list_songs_internal(&server_url, &token, &sort, &order, start, end).await
|
nd_list_songs_internal(
|
||||||
|
Some(http_registry.as_ref()),
|
||||||
|
None,
|
||||||
|
&server_url,
|
||||||
|
&token,
|
||||||
|
&sort,
|
||||||
|
&order,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the `_filters` JSON for native-API list calls. Optionally narrows the
|
/// Build the `_filters` JSON for native-API list calls. Optionally narrows the
|
||||||
@@ -70,6 +101,7 @@ fn nd_build_filters(seed: serde_json::Map<String, serde_json::Value>, library_id
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn nd_list_artists_by_role(
|
pub async fn nd_list_artists_by_role(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
token: String,
|
token: String,
|
||||||
role: String,
|
role: String,
|
||||||
@@ -79,24 +111,42 @@ pub async fn nd_list_artists_by_role(
|
|||||||
end: u32,
|
end: u32,
|
||||||
library_id: Option<String>,
|
library_id: Option<String>,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let reg = http_registry.as_ref();
|
||||||
let mut seed = serde_json::Map::new();
|
let mut seed = serde_json::Map::new();
|
||||||
seed.insert("role".to_string(), serde_json::Value::String(role.clone()));
|
seed.insert("role".to_string(), serde_json::Value::String(role.clone()));
|
||||||
let filters = nd_build_filters(seed, library_id.as_deref());
|
let filters = nd_build_filters(seed, library_id.as_deref());
|
||||||
let start_s = start.to_string();
|
let start_s = start.to_string();
|
||||||
let end_s = end.to_string();
|
let end_s = end.to_string();
|
||||||
|
let base = format!("{}/api/artist", server_url);
|
||||||
let resp = nd_retry(|| {
|
let resp = nd_retry(|| {
|
||||||
nd_http_client()
|
let base = base.clone();
|
||||||
.get(format!("{}/api/artist", server_url))
|
let filters = filters.clone();
|
||||||
.query(&[
|
let sort = sort.clone();
|
||||||
("_filters", filters.as_str()),
|
let order = order.clone();
|
||||||
("_sort", sort.as_str()),
|
let start_s = start_s.clone();
|
||||||
("_order", order.as_str()),
|
let end_s = end_s.clone();
|
||||||
("_start", start_s.as_str()),
|
let auth = format!("Bearer {}", token);
|
||||||
("_end", end_s.as_str()),
|
async move {
|
||||||
])
|
nd_apply_request(
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
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()
|
.send()
|
||||||
}).await?;
|
.await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(format!("HTTP {}", resp.status()));
|
return Err(format!("HTTP {}", resp.status()));
|
||||||
}
|
}
|
||||||
@@ -111,6 +161,7 @@ pub async fn nd_list_artists_by_role(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn nd_list_albums_by_artist_role(
|
pub async fn nd_list_albums_by_artist_role(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
token: String,
|
token: String,
|
||||||
artist_id: String,
|
artist_id: String,
|
||||||
@@ -121,25 +172,43 @@ pub async fn nd_list_albums_by_artist_role(
|
|||||||
end: u32,
|
end: u32,
|
||||||
library_id: Option<String>,
|
library_id: Option<String>,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let reg = http_registry.as_ref();
|
||||||
let filter_key = format!("role_{}_id", role);
|
let filter_key = format!("role_{}_id", role);
|
||||||
let mut seed = serde_json::Map::new();
|
let mut seed = serde_json::Map::new();
|
||||||
seed.insert(filter_key, serde_json::Value::String(artist_id.clone()));
|
seed.insert(filter_key, serde_json::Value::String(artist_id.clone()));
|
||||||
let filters = nd_build_filters(seed, library_id.as_deref());
|
let filters = nd_build_filters(seed, library_id.as_deref());
|
||||||
let start_s = start.to_string();
|
let start_s = start.to_string();
|
||||||
let end_s = end.to_string();
|
let end_s = end.to_string();
|
||||||
|
let base = format!("{}/api/album", server_url);
|
||||||
let resp = nd_retry(|| {
|
let resp = nd_retry(|| {
|
||||||
nd_http_client()
|
let base = base.clone();
|
||||||
.get(format!("{}/api/album", server_url))
|
let filters = filters.clone();
|
||||||
.query(&[
|
let sort = sort.clone();
|
||||||
("_filters", filters.as_str()),
|
let order = order.clone();
|
||||||
("_sort", sort.as_str()),
|
let start_s = start_s.clone();
|
||||||
("_order", order.as_str()),
|
let end_s = end_s.clone();
|
||||||
("_start", start_s.as_str()),
|
let auth = format!("Bearer {}", token);
|
||||||
("_end", end_s.as_str()),
|
async move {
|
||||||
])
|
nd_apply_request(
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
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()
|
.send()
|
||||||
}).await?;
|
.await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(format!("HTTP {}", resp.status()));
|
return Err(format!("HTTP {}", resp.status()));
|
||||||
}
|
}
|
||||||
@@ -149,15 +218,28 @@ pub async fn nd_list_albums_by_artist_role(
|
|||||||
/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array.
|
/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn nd_list_libraries(
|
pub async fn nd_list_libraries(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
token: String,
|
token: String,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let reg = http_registry.as_ref();
|
||||||
|
let url = format!("{}/api/library", server_url);
|
||||||
|
let auth = format!("Bearer {}", token);
|
||||||
let resp = nd_retry(|| {
|
let resp = nd_retry(|| {
|
||||||
nd_http_client()
|
let url = url.clone();
|
||||||
.get(format!("{}/api/library", server_url))
|
let auth = auth.clone();
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
async move {
|
||||||
|
nd_apply_request(
|
||||||
|
Some(reg),
|
||||||
|
None,
|
||||||
|
&url,
|
||||||
|
nd_http_client().get(&url).header("X-ND-Authorization", auth),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
}).await?;
|
.await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(format!("HTTP {}", resp.status()));
|
return Err(format!("HTTP {}", resp.status()));
|
||||||
}
|
}
|
||||||
@@ -168,19 +250,35 @@ pub async fn nd_list_libraries(
|
|||||||
/// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400.
|
/// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn nd_set_user_libraries(
|
pub async fn nd_set_user_libraries(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
token: String,
|
token: String,
|
||||||
id: String,
|
id: String,
|
||||||
library_ids: Vec<i64>,
|
library_ids: Vec<i64>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
let reg = http_registry.as_ref();
|
||||||
let body = serde_json::json!({ "libraryIds": library_ids });
|
let body = serde_json::json!({ "libraryIds": library_ids });
|
||||||
|
let url = format!("{}/api/user/{}/library", server_url, id);
|
||||||
|
let auth = format!("Bearer {}", token);
|
||||||
let resp = nd_retry(|| {
|
let resp = nd_retry(|| {
|
||||||
nd_http_client()
|
let url = url.clone();
|
||||||
.put(format!("{}/api/user/{}/library", server_url, id))
|
let auth = auth.clone();
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
let body = body.clone();
|
||||||
.json(&body)
|
async move {
|
||||||
|
nd_apply_request(
|
||||||
|
Some(reg),
|
||||||
|
None,
|
||||||
|
&url,
|
||||||
|
nd_http_client()
|
||||||
|
.put(&url)
|
||||||
|
.header("X-ND-Authorization", auth)
|
||||||
|
.json(&body),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
}).await?;
|
.await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let text = resp.text().await.unwrap_or_default();
|
let text = resp.text().await.unwrap_or_default();
|
||||||
@@ -202,18 +300,31 @@ pub async fn nd_set_user_libraries(
|
|||||||
/// it for non-admin users on some configurations.
|
/// it for non-admin users on some configurations.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn nd_get_song_path(
|
pub async fn nd_get_song_path(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, String> {
|
||||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
let reg = http_registry.as_ref();
|
||||||
|
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
|
||||||
let url = format!("{}/api/song/{}", server_url, id);
|
let url = format!("{}/api/song/{}", server_url, id);
|
||||||
|
let auth = format!("Bearer {}", token);
|
||||||
let resp = nd_retry(|| {
|
let resp = nd_retry(|| {
|
||||||
nd_http_client()
|
let url = url.clone();
|
||||||
.get(&url)
|
let auth = auth.clone();
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
async move {
|
||||||
|
nd_apply_request(
|
||||||
|
Some(reg),
|
||||||
|
None,
|
||||||
|
&url,
|
||||||
|
nd_http_client()
|
||||||
|
.get(&url)
|
||||||
|
.header("X-ND-Authorization", auth),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
|
.await
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
|
|||||||
@@ -2,22 +2,39 @@
|
|||||||
//! `token` (obtained via `navidrome_login`); admin-only ones return 401/403
|
//! `token` (obtained via `navidrome_login`); admin-only ones return 401/403
|
||||||
//! when the caller is not an admin.
|
//! when the caller is not an admin.
|
||||||
|
|
||||||
use super::client::{nd_err, nd_http_client, nd_retry, NdLoginResult};
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use psysonic_core::server_http::ServerHttpRegistry;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry, NdLoginResult};
|
||||||
|
|
||||||
/// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin.
|
/// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn navidrome_login(
|
pub async fn navidrome_login(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
) -> Result<NdLoginResult, String> {
|
) -> Result<NdLoginResult, String> {
|
||||||
|
let reg = http_registry.as_ref();
|
||||||
let body = serde_json::json!({ "username": username, "password": password });
|
let body = serde_json::json!({ "username": username, "password": password });
|
||||||
|
let login_url = format!("{}/auth/login", server_url.trim_end_matches('/'));
|
||||||
let resp = nd_retry(|| {
|
let resp = nd_retry(|| {
|
||||||
nd_http_client()
|
let login_url = login_url.clone();
|
||||||
.post(format!("{}/auth/login", server_url))
|
let body = body.clone();
|
||||||
.json(&body)
|
async move {
|
||||||
|
nd_apply_request(
|
||||||
|
Some(reg),
|
||||||
|
None,
|
||||||
|
&login_url,
|
||||||
|
nd_http_client().post(&login_url).json(&body),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
}).await?;
|
.await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(format!("Navidrome login failed: HTTP {}", resp.status()));
|
return Err(format!("Navidrome login failed: HTTP {}", resp.status()));
|
||||||
}
|
}
|
||||||
@@ -25,21 +42,40 @@ pub async fn navidrome_login(
|
|||||||
let token = data["token"].as_str().ok_or("no token in response")?.to_string();
|
let token = data["token"].as_str().ok_or("no token in response")?.to_string();
|
||||||
let user_id = data["id"].as_str().unwrap_or("").to_string();
|
let user_id = data["id"].as_str().unwrap_or("").to_string();
|
||||||
let is_admin = data["isAdmin"].as_bool().unwrap_or(false);
|
let is_admin = data["isAdmin"].as_bool().unwrap_or(false);
|
||||||
Ok(NdLoginResult { token, user_id, is_admin })
|
Ok(NdLoginResult {
|
||||||
|
token,
|
||||||
|
user_id,
|
||||||
|
is_admin,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields.
|
/// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn nd_list_users(
|
pub async fn nd_list_users(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
token: String,
|
token: String,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let reg = http_registry.as_ref();
|
||||||
|
let url = format!("{}/api/user", server_url);
|
||||||
|
let auth = format!("Bearer {}", token);
|
||||||
let resp = nd_retry(|| {
|
let resp = nd_retry(|| {
|
||||||
nd_http_client()
|
let url = url.clone();
|
||||||
.get(format!("{}/api/user", server_url))
|
let auth = auth.clone();
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
async move {
|
||||||
|
nd_apply_request(
|
||||||
|
Some(reg),
|
||||||
|
None,
|
||||||
|
&url,
|
||||||
|
nd_http_client()
|
||||||
|
.get(&url)
|
||||||
|
.header("X-ND-Authorization", auth),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
}).await?;
|
.await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(format!("HTTP {}", resp.status()));
|
return Err(format!("HTTP {}", resp.status()));
|
||||||
}
|
}
|
||||||
@@ -48,7 +84,9 @@ pub async fn nd_list_users(
|
|||||||
|
|
||||||
/// POST `/api/user` — create a user.
|
/// POST `/api/user` — create a user.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn nd_create_user(
|
pub async fn nd_create_user(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
token: String,
|
token: String,
|
||||||
user_name: String,
|
user_name: String,
|
||||||
@@ -57,6 +95,7 @@ pub async fn nd_create_user(
|
|||||||
password: String,
|
password: String,
|
||||||
is_admin: bool,
|
is_admin: bool,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let reg = http_registry.as_ref();
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"userName": user_name,
|
"userName": user_name,
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -64,13 +103,27 @@ pub async fn nd_create_user(
|
|||||||
"password": password,
|
"password": password,
|
||||||
"isAdmin": is_admin,
|
"isAdmin": is_admin,
|
||||||
});
|
});
|
||||||
|
let url = format!("{}/api/user", server_url);
|
||||||
|
let auth = format!("Bearer {}", token);
|
||||||
let resp = nd_retry(|| {
|
let resp = nd_retry(|| {
|
||||||
nd_http_client()
|
let url = url.clone();
|
||||||
.post(format!("{}/api/user", server_url))
|
let auth = auth.clone();
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
let body = body.clone();
|
||||||
.json(&body)
|
async move {
|
||||||
|
nd_apply_request(
|
||||||
|
Some(reg),
|
||||||
|
None,
|
||||||
|
&url,
|
||||||
|
nd_http_client()
|
||||||
|
.post(&url)
|
||||||
|
.header("X-ND-Authorization", auth)
|
||||||
|
.json(&body),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
}).await?;
|
.await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let text = resp.text().await.unwrap_or_default();
|
let text = resp.text().await.unwrap_or_default();
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
@@ -83,6 +136,7 @@ pub async fn nd_create_user(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn nd_update_user(
|
pub async fn nd_update_user(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
token: String,
|
token: String,
|
||||||
id: String,
|
id: String,
|
||||||
@@ -92,6 +146,7 @@ pub async fn nd_update_user(
|
|||||||
password: String,
|
password: String,
|
||||||
is_admin: bool,
|
is_admin: bool,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let reg = http_registry.as_ref();
|
||||||
let mut body = serde_json::json!({
|
let mut body = serde_json::json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"userName": user_name,
|
"userName": user_name,
|
||||||
@@ -102,13 +157,27 @@ pub async fn nd_update_user(
|
|||||||
if !password.is_empty() {
|
if !password.is_empty() {
|
||||||
body["password"] = serde_json::Value::String(password);
|
body["password"] = serde_json::Value::String(password);
|
||||||
}
|
}
|
||||||
|
let url = format!("{}/api/user/{}", server_url, id);
|
||||||
|
let auth = format!("Bearer {}", token);
|
||||||
let resp = nd_retry(|| {
|
let resp = nd_retry(|| {
|
||||||
nd_http_client()
|
let url = url.clone();
|
||||||
.put(format!("{}/api/user/{}", server_url, id))
|
let auth = auth.clone();
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
let body = body.clone();
|
||||||
.json(&body)
|
async move {
|
||||||
|
nd_apply_request(
|
||||||
|
Some(reg),
|
||||||
|
None,
|
||||||
|
&url,
|
||||||
|
nd_http_client()
|
||||||
|
.put(&url)
|
||||||
|
.header("X-ND-Authorization", auth)
|
||||||
|
.json(&body),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
}).await?;
|
.await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let text = resp.text().await.unwrap_or_default();
|
let text = resp.text().await.unwrap_or_default();
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
@@ -120,16 +189,31 @@ pub async fn nd_update_user(
|
|||||||
/// DELETE `/api/user/{id}`.
|
/// DELETE `/api/user/{id}`.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn nd_delete_user(
|
pub async fn nd_delete_user(
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
token: String,
|
token: String,
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
let reg = http_registry.as_ref();
|
||||||
|
let url = format!("{}/api/user/{}", server_url, id);
|
||||||
|
let auth = format!("Bearer {}", token);
|
||||||
let resp = nd_retry(|| {
|
let resp = nd_retry(|| {
|
||||||
nd_http_client()
|
let url = url.clone();
|
||||||
.delete(format!("{}/api/user/{}", server_url, id))
|
let auth = auth.clone();
|
||||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
async move {
|
||||||
|
nd_apply_request(
|
||||||
|
Some(reg),
|
||||||
|
None,
|
||||||
|
&url,
|
||||||
|
nd_http_client()
|
||||||
|
.delete(&url)
|
||||||
|
.header("X-ND-Authorization", auth),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
}).await?;
|
.await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let text = resp.text().await.unwrap_or_default();
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use serde::Deserialize;
|
|||||||
use super::auth::SubsonicCredentials;
|
use super::auth::SubsonicCredentials;
|
||||||
use super::error::{flatten_reqwest_error, SubsonicError};
|
use super::error::{flatten_reqwest_error, SubsonicError};
|
||||||
use super::types::{Album, AlbumSummary, ArtistIndex, ScanStatus, SearchResult, ServerInfo, Song};
|
use super::types::{Album, AlbumSummary, ArtistIndex, ScanStatus, SearchResult, ServerInfo, Song};
|
||||||
|
use psysonic_core::server_http::{apply_server_headers, ServerHttpContext};
|
||||||
|
|
||||||
/// Protocol level we advertise — pre-OpenSubsonic Subsonic baseline that
|
/// Protocol level we advertise — pre-OpenSubsonic Subsonic baseline that
|
||||||
/// Navidrome and other servers in the wild support. OpenSubsonic
|
/// Navidrome and other servers in the wild support. OpenSubsonic
|
||||||
@@ -42,6 +43,7 @@ pub struct SubsonicClient {
|
|||||||
base_url: String,
|
base_url: String,
|
||||||
credentials: CredentialsMode,
|
credentials: CredentialsMode,
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
|
http_context: Option<ServerHttpContext>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SubsonicClient {
|
impl SubsonicClient {
|
||||||
@@ -75,9 +77,28 @@ impl SubsonicClient {
|
|||||||
password: password.into(),
|
password: password.into(),
|
||||||
},
|
},
|
||||||
http,
|
http,
|
||||||
|
http_context: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_http_context(mut self, ctx: ServerHttpContext) -> Self {
|
||||||
|
self.http_context = Some(ctx);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production helper — attach registry context when present for `server_ref`
|
||||||
|
/// (app server id or index key).
|
||||||
|
pub fn with_registry(
|
||||||
|
self,
|
||||||
|
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||||
|
server_ref: &str,
|
||||||
|
) -> Self {
|
||||||
|
registry
|
||||||
|
.and_then(|r| r.get_for_server_ref(server_ref))
|
||||||
|
.map(|ctx| self.clone().with_http_context((*ctx).clone()))
|
||||||
|
.unwrap_or(self)
|
||||||
|
}
|
||||||
|
|
||||||
/// Test-/cache-friendly constructor — re-uses the same
|
/// Test-/cache-friendly constructor — re-uses the same
|
||||||
/// `SubsonicCredentials` triple on every call. Wiremock tests rely on
|
/// `SubsonicCredentials` triple on every call. Wiremock tests rely on
|
||||||
/// this for deterministic `s=` and `t=` query params; production code
|
/// this for deterministic `s=` and `t=` query params; production code
|
||||||
@@ -95,6 +116,7 @@ impl SubsonicClient {
|
|||||||
base_url: url,
|
base_url: url,
|
||||||
credentials: CredentialsMode::Static(credentials),
|
credentials: CredentialsMode::Static(credentials),
|
||||||
http,
|
http,
|
||||||
|
http_context: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,10 +323,15 @@ impl SubsonicClient {
|
|||||||
let mut query: Vec<(&str, &str)> = auth.to_vec();
|
let mut query: Vec<(&str, &str)> = auth.to_vec();
|
||||||
query.extend_from_slice(extra);
|
query.extend_from_slice(extra);
|
||||||
|
|
||||||
let resp = self
|
let mut req = self
|
||||||
.http
|
.http
|
||||||
.get(format!("{}/rest/{method}.view", self.base_url))
|
.get(format!("{}/rest/{method}.view", self.base_url))
|
||||||
.query(&query)
|
.query(&query);
|
||||||
|
if let Some(ctx) = &self.http_context {
|
||||||
|
req = apply_server_headers(req, ctx, &self.base_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = req
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))?;
|
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))?;
|
||||||
@@ -331,6 +358,16 @@ fn default_http_client() -> reqwest::Client {
|
|||||||
.unwrap_or_else(|_| reqwest::Client::new())
|
.unwrap_or_else(|_| reqwest::Client::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn subsonic_client_with_registry(
|
||||||
|
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||||
|
server_ref: &str,
|
||||||
|
base_url: impl Into<String>,
|
||||||
|
username: impl Into<String>,
|
||||||
|
password: impl Into<String>,
|
||||||
|
) -> SubsonicClient {
|
||||||
|
SubsonicClient::new(base_url, username, password).with_registry(registry, server_ref)
|
||||||
|
}
|
||||||
|
|
||||||
/// Validate the Subsonic envelope and return the raw `serde_json::Value`
|
/// Validate the Subsonic envelope and return the raw `serde_json::Value`
|
||||||
/// at `body_key`. Maps `error.code = 70` to the dedicated `NotFound`
|
/// at `body_key`. Maps `error.code = 70` to the dedicated `NotFound`
|
||||||
/// variant; surfaces every other failed status as `Api { code, message }`.
|
/// variant; surfaces every other failed status as `Api { code, message }`.
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ pub mod types;
|
|||||||
|
|
||||||
pub use auth::SubsonicCredentials;
|
pub use auth::SubsonicCredentials;
|
||||||
pub use client::{
|
pub use client::{
|
||||||
fingerprint_sample, SubsonicClient, SUBSONIC_API_VERSION, SUBSONIC_CLIENT_ID,
|
fingerprint_sample, subsonic_client_with_registry, SubsonicClient, SUBSONIC_API_VERSION,
|
||||||
|
SUBSONIC_CLIENT_ID,
|
||||||
};
|
};
|
||||||
pub use stream_url::{build_stream_view_url, rest_base_from_url};
|
pub use stream_url::{build_stream_view_url, rest_base_from_url};
|
||||||
pub use error::SubsonicError;
|
pub use error::SubsonicError;
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ use rusqlite::params;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tauri::{AppHandle, Emitter, Manager, State};
|
use tauri::{AppHandle, Emitter, Manager, State};
|
||||||
|
|
||||||
use psysonic_integration::navidrome::navidrome_token;
|
use psysonic_core::server_http::ServerHttpRegistry;
|
||||||
use psysonic_integration::subsonic::SubsonicClient;
|
use psysonic_integration::navidrome::navidrome_token_with_registry;
|
||||||
|
use psysonic_integration::subsonic::subsonic_client_with_registry;
|
||||||
|
|
||||||
use crate::advanced_search;
|
use crate::advanced_search;
|
||||||
use crate::analysis_backfill::{self, LibraryAnalysisBackfillBatchDto, LibraryAnalysisProgressDto};
|
use crate::analysis_backfill::{self, LibraryAnalysisBackfillBatchDto, LibraryAnalysisProgressDto};
|
||||||
@@ -657,13 +658,14 @@ fn normalize_base_url(raw: &str) -> String {
|
|||||||
/// caller falls back to a cached bearer / the Subsonic-only path. Never logs
|
/// caller falls back to a cached bearer / the Subsonic-only path. Never logs
|
||||||
/// the token or credentials.
|
/// the token or credentials.
|
||||||
async fn navidrome_token_with_retry(
|
async fn navidrome_token_with_retry(
|
||||||
|
registry: Option<&ServerHttpRegistry>,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
const ATTEMPTS: u32 = 3;
|
const ATTEMPTS: u32 = 3;
|
||||||
for attempt in 1..=ATTEMPTS {
|
for attempt in 1..=ATTEMPTS {
|
||||||
match navidrome_token(base_url, username, password).await {
|
match navidrome_token_with_registry(registry, base_url, username, password).await {
|
||||||
Ok(tok) => return Some(tok),
|
Ok(tok) => return Some(tok),
|
||||||
Err(_) if attempt < ATTEMPTS => {
|
Err(_) if attempt < ATTEMPTS => {
|
||||||
tokio::time::sleep(Duration::from_millis(250 * attempt as u64)).await;
|
tokio::time::sleep(Duration::from_millis(250 * attempt as u64)).await;
|
||||||
@@ -677,6 +679,7 @@ async fn navidrome_token_with_retry(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn library_sync_bind_session(
|
pub async fn library_sync_bind_session(
|
||||||
runtime: State<'_, LibraryRuntime>,
|
runtime: State<'_, LibraryRuntime>,
|
||||||
|
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
username: String,
|
username: String,
|
||||||
@@ -690,8 +693,13 @@ pub async fn library_sync_bind_session(
|
|||||||
// keep a bearer cached from a prior bind rather than dropping to
|
// keep a bearer cached from a prior bind rather than dropping to
|
||||||
// Subsonic-only — a transient miss must not strip an N1-capable server
|
// Subsonic-only — a transient miss must not strip an N1-capable server
|
||||||
// (R7-15 Q3). Non-Navidrome servers stay `None` and sync via Subsonic.
|
// (R7-15 Q3). Non-Navidrome servers stay `None` and sync via Subsonic.
|
||||||
let navidrome_token_cached = match navidrome_token_with_retry(&base_url, &username, &password)
|
let navidrome_token_cached = match navidrome_token_with_retry(
|
||||||
.await
|
Some(http_registry.as_ref()),
|
||||||
|
&base_url,
|
||||||
|
&username,
|
||||||
|
&password,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
Some(tok) => Some(tok),
|
Some(tok) => Some(tok),
|
||||||
None => runtime.get_session(&server_id).and_then(|s| s.navidrome_token),
|
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
|
// Run the probe + persist capability flags. Failure to probe is a
|
||||||
// bind-time error — caller should fix credentials / URL.
|
// bind-time error — caller should fix credentials / URL.
|
||||||
let subsonic = SubsonicClient::new(base_url, username, password);
|
let subsonic = subsonic_client_with_registry(
|
||||||
|
Some(http_registry.as_ref()),
|
||||||
|
&server_id,
|
||||||
|
base_url,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
);
|
||||||
let navidrome_creds = navidrome_token_cached.map(|tok| NavidromeProbeCredentials {
|
let navidrome_creds = navidrome_token_cached.map(|tok| NavidromeProbeCredentials {
|
||||||
server_url: subsonic_base_url_from(&runtime, &server_id),
|
server_url: subsonic_base_url_from(&runtime, &server_id),
|
||||||
bearer_token: tok,
|
bearer_token: tok,
|
||||||
@@ -719,6 +733,7 @@ pub async fn library_sync_bind_session(
|
|||||||
&runtime.store,
|
&runtime.store,
|
||||||
&subsonic,
|
&subsonic,
|
||||||
navidrome_creds.as_ref(),
|
navidrome_creds.as_ref(),
|
||||||
|
Some(http_registry.as_ref()),
|
||||||
&server_id,
|
&server_id,
|
||||||
scope,
|
scope,
|
||||||
)
|
)
|
||||||
@@ -872,8 +887,12 @@ async fn library_sync_start_inner(
|
|||||||
let job_id_for_task = job_id.clone();
|
let job_id_for_task = job_id.clone();
|
||||||
let parallelism = ParallelismBudget::resolve(runtime.current_playback_hint());
|
let parallelism = ParallelismBudget::resolve(runtime.current_playback_hint());
|
||||||
|
|
||||||
|
let app_for_runner = app.clone();
|
||||||
let runner_handle: tokio::task::JoinHandle<Result<(), String>> = tokio::task::spawn(async move {
|
let runner_handle: tokio::task::JoinHandle<Result<(), String>> = tokio::task::spawn(async move {
|
||||||
let subsonic = SubsonicClient::new(
|
let registry = app_for_runner.state::<Arc<ServerHttpRegistry>>();
|
||||||
|
let subsonic = subsonic_client_with_registry(
|
||||||
|
Some(registry.as_ref()),
|
||||||
|
&session_clone.server_id,
|
||||||
session_clone.base_url.clone(),
|
session_clone.base_url.clone(),
|
||||||
session_clone.username.clone(),
|
session_clone.username.clone(),
|
||||||
session_clone.password.clone(),
|
session_clone.password.clone(),
|
||||||
@@ -895,7 +914,8 @@ async fn library_sync_start_inner(
|
|||||||
)
|
)
|
||||||
.with_cancellation(Arc::clone(&cancel_for_task))
|
.with_cancellation(Arc::clone(&cancel_for_task))
|
||||||
.with_progress(Arc::clone(&progress))
|
.with_progress(Arc::clone(&progress))
|
||||||
.with_parallelism_budget(parallelism);
|
.with_parallelism_budget(parallelism)
|
||||||
|
.with_http_registry(Some(Arc::clone(®istry)));
|
||||||
if let Some(creds) = navidrome_creds.clone() {
|
if let Some(creds) = navidrome_creds.clone() {
|
||||||
runner = runner.with_navidrome_credentials(creds);
|
runner = runner.with_navidrome_credentials(creds);
|
||||||
}
|
}
|
||||||
@@ -919,7 +939,8 @@ async fn library_sync_start_inner(
|
|||||||
capability_flags,
|
capability_flags,
|
||||||
)
|
)
|
||||||
.with_cancellation(Arc::clone(&cancel_for_task))
|
.with_cancellation(Arc::clone(&cancel_for_task))
|
||||||
.with_progress(Arc::clone(&progress));
|
.with_progress(Arc::clone(&progress))
|
||||||
|
.with_http_registry(Some(Arc::clone(®istry)));
|
||||||
if tombstone_budget > 0 {
|
if tombstone_budget > 0 {
|
||||||
runner = runner.with_tombstone_budget(tombstone_budget);
|
runner = runner.with_tombstone_budget(tombstone_budget);
|
||||||
}
|
}
|
||||||
@@ -1647,7 +1668,7 @@ mod tests {
|
|||||||
})))
|
})))
|
||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
let tok = navidrome_token_with_retry(&server.uri(), "user", "pw").await;
|
let tok = navidrome_token_with_retry(None, &server.uri(), "user", "pw").await;
|
||||||
assert_eq!(tok.as_deref(), Some("nd-tok"));
|
assert_eq!(tok.as_deref(), Some("nd-tok"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1664,7 +1685,7 @@ mod tests {
|
|||||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
|
||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
let tok = navidrome_token_with_retry(&server.uri(), "user", "pw").await;
|
let tok = navidrome_token_with_retry(None, &server.uri(), "user", "pw").await;
|
||||||
assert!(tok.is_none());
|
assert!(tok.is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ pub async fn probe_and_persist(
|
|||||||
store: &crate::store::LibraryStore,
|
store: &crate::store::LibraryStore,
|
||||||
subsonic: &psysonic_integration::subsonic::SubsonicClient,
|
subsonic: &psysonic_integration::subsonic::SubsonicClient,
|
||||||
navidrome: Option<&NavidromeProbeCredentials>,
|
navidrome: Option<&NavidromeProbeCredentials>,
|
||||||
|
http_registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||||
server_id: &str,
|
server_id: &str,
|
||||||
library_scope: &str,
|
library_scope: &str,
|
||||||
) -> Result<CapabilityProbeResult, psysonic_integration::subsonic::SubsonicError> {
|
) -> Result<CapabilityProbeResult, psysonic_integration::subsonic::SubsonicError> {
|
||||||
@@ -110,7 +111,7 @@ pub async fn probe_and_persist(
|
|||||||
.map_err(psysonic_integration::subsonic::SubsonicError::Transport)?
|
.map_err(psysonic_integration::subsonic::SubsonicError::Transport)?
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let mut result = CapabilityProbe::run(subsonic, navidrome).await?;
|
let mut result = CapabilityProbe::run(subsonic, navidrome, http_registry, Some(server_id)).await?;
|
||||||
|
|
||||||
// R7-15 Q3: a probe run without a Navidrome bearer can't test N1, so it
|
// R7-15 Q3: a probe run without a Navidrome bearer can't test N1, so it
|
||||||
// must not drop a previously-learned NavidromeNativeBulk capability — the
|
// must not drop a previously-learned NavidromeNativeBulk capability — the
|
||||||
@@ -173,6 +174,8 @@ impl CapabilityProbe {
|
|||||||
pub async fn run(
|
pub async fn run(
|
||||||
subsonic: &SubsonicClient,
|
subsonic: &SubsonicClient,
|
||||||
navidrome: Option<&NavidromeProbeCredentials>,
|
navidrome: Option<&NavidromeProbeCredentials>,
|
||||||
|
http_registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||||
|
server_id: Option<&str>,
|
||||||
) -> Result<CapabilityProbeResult, SubsonicError> {
|
) -> Result<CapabilityProbeResult, SubsonicError> {
|
||||||
let server_info = subsonic.server_info().await?;
|
let server_info = subsonic.server_info().await?;
|
||||||
|
|
||||||
@@ -207,7 +210,14 @@ impl CapabilityProbe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(creds) = navidrome {
|
if let Some(creds) = navidrome {
|
||||||
match native_bulk_available(&creds.server_url, &creds.bearer_token).await {
|
match native_bulk_available(
|
||||||
|
http_registry,
|
||||||
|
server_id,
|
||||||
|
&creds.server_url,
|
||||||
|
&creds.bearer_token,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(true) => flags.insert(CapabilityFlags::NAVIDROME_NATIVE_BULK),
|
Ok(true) => flags.insert(CapabilityFlags::NAVIDROME_NATIVE_BULK),
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -345,7 +355,7 @@ mod tests {
|
|||||||
let server = MockServer::start().await;
|
let server = MockServer::start().await;
|
||||||
mount_subsonic_full_navidrome(&server).await;
|
mount_subsonic_full_navidrome(&server).await;
|
||||||
|
|
||||||
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None)
|
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
|
assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
|
||||||
@@ -372,7 +382,7 @@ mod tests {
|
|||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let err = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None)
|
let err = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, SubsonicError::Api { code: 40, .. }));
|
assert!(matches!(err, SubsonicError::Api { code: 40, .. }));
|
||||||
@@ -415,7 +425,7 @@ mod tests {
|
|||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None)
|
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
|
assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
|
||||||
@@ -440,7 +450,7 @@ mod tests {
|
|||||||
server_url: server.uri(),
|
server_url: server.uri(),
|
||||||
bearer_token: "nd-tok".into(),
|
bearer_token: "nd-tok".into(),
|
||||||
};
|
};
|
||||||
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav))
|
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav), None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
|
assert!(result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
|
||||||
@@ -461,6 +471,7 @@ mod tests {
|
|||||||
&store,
|
&store,
|
||||||
&test_subsonic_client(&server.uri()),
|
&test_subsonic_client(&server.uri()),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
"s1",
|
"s1",
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
@@ -498,6 +509,7 @@ mod tests {
|
|||||||
&store,
|
&store,
|
||||||
&test_subsonic_client(&server.uri()),
|
&test_subsonic_client(&server.uri()),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
"s1",
|
"s1",
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
@@ -530,6 +542,7 @@ mod tests {
|
|||||||
&store,
|
&store,
|
||||||
&test_subsonic_client(&server.uri()),
|
&test_subsonic_client(&server.uri()),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
"s1",
|
"s1",
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
@@ -584,7 +597,7 @@ mod tests {
|
|||||||
|
|
||||||
let store = LibraryStore::open_in_memory();
|
let store = LibraryStore::open_in_memory();
|
||||||
let result =
|
let result =
|
||||||
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, "s1", "")
|
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, None, "s1", "")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(result.server_track_count, Some(170_000));
|
assert_eq!(result.server_track_count, Some(170_000));
|
||||||
@@ -617,6 +630,7 @@ mod tests {
|
|||||||
&store,
|
&store,
|
||||||
&test_subsonic_client(&server.uri()),
|
&test_subsonic_client(&server.uri()),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
"s1",
|
"s1",
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
@@ -647,7 +661,7 @@ mod tests {
|
|||||||
mount_subsonic_full_navidrome(&server).await; // scanStatus has no count
|
mount_subsonic_full_navidrome(&server).await; // scanStatus has no count
|
||||||
|
|
||||||
let result =
|
let result =
|
||||||
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, "s1", "")
|
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, None, "s1", "")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(result.server_track_count, None);
|
assert_eq!(result.server_track_count, None);
|
||||||
@@ -672,7 +686,7 @@ mod tests {
|
|||||||
server_url: server.uri(),
|
server_url: server.uri(),
|
||||||
bearer_token: "nd-tok".into(),
|
bearer_token: "nd-tok".into(),
|
||||||
};
|
};
|
||||||
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav))
|
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav), None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
|
assert!(!result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use std::sync::atomic::Ordering;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use psysonic_core::server_http::ServerHttpRegistry;
|
||||||
use psysonic_integration::navidrome::queries::nd_list_songs_internal;
|
use psysonic_integration::navidrome::queries::nd_list_songs_internal;
|
||||||
use psysonic_integration::subsonic::SubsonicClient;
|
use psysonic_integration::subsonic::SubsonicClient;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -71,6 +72,7 @@ pub struct DeltaSyncRunner<'a> {
|
|||||||
store: &'a LibraryStore,
|
store: &'a LibraryStore,
|
||||||
subsonic: &'a SubsonicClient,
|
subsonic: &'a SubsonicClient,
|
||||||
navidrome: Option<NavidromeProbeCredentials>,
|
navidrome: Option<NavidromeProbeCredentials>,
|
||||||
|
http_registry: Option<Arc<ServerHttpRegistry>>,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
library_scope: String,
|
library_scope: String,
|
||||||
capability_flags: CapabilityFlags,
|
capability_flags: CapabilityFlags,
|
||||||
@@ -95,6 +97,7 @@ impl<'a> DeltaSyncRunner<'a> {
|
|||||||
store,
|
store,
|
||||||
subsonic,
|
subsonic,
|
||||||
navidrome: None,
|
navidrome: None,
|
||||||
|
http_registry: None,
|
||||||
server_id: server_id.into(),
|
server_id: server_id.into(),
|
||||||
library_scope: library_scope.into(),
|
library_scope: library_scope.into(),
|
||||||
capability_flags,
|
capability_flags,
|
||||||
@@ -111,6 +114,11 @@ impl<'a> DeltaSyncRunner<'a> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_http_registry(mut self, registry: Option<Arc<ServerHttpRegistry>>) -> Self {
|
||||||
|
self.http_registry = registry;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_cancellation(mut self, flag: Arc<std::sync::atomic::AtomicBool>) -> Self {
|
pub fn with_cancellation(mut self, flag: Arc<std::sync::atomic::AtomicBool>) -> Self {
|
||||||
self.cancel = Some(flag);
|
self.cancel = Some(flag);
|
||||||
self
|
self
|
||||||
@@ -396,6 +404,8 @@ impl<'a> DeltaSyncRunner<'a> {
|
|||||||
self,
|
self,
|
||||||
|| {
|
|| {
|
||||||
nd_list_songs_internal(
|
nd_list_songs_internal(
|
||||||
|
self.http_registry.as_deref(),
|
||||||
|
Some(&self.server_id),
|
||||||
&creds.server_url,
|
&creds.server_url,
|
||||||
&creds.bearer_token,
|
&creds.bearer_token,
|
||||||
"updated_at",
|
"updated_at",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use psysonic_core::server_http::ServerHttpRegistry;
|
||||||
use psysonic_integration::navidrome::queries::nd_list_songs_internal;
|
use psysonic_integration::navidrome::queries::nd_list_songs_internal;
|
||||||
use psysonic_integration::subsonic::SubsonicClient;
|
use psysonic_integration::subsonic::SubsonicClient;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -109,6 +110,7 @@ pub struct InitialSyncRunner<'a> {
|
|||||||
store: &'a LibraryStore,
|
store: &'a LibraryStore,
|
||||||
subsonic: &'a SubsonicClient,
|
subsonic: &'a SubsonicClient,
|
||||||
navidrome: Option<NavidromeProbeCredentials>,
|
navidrome: Option<NavidromeProbeCredentials>,
|
||||||
|
http_registry: Option<Arc<ServerHttpRegistry>>,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
library_scope: String,
|
library_scope: String,
|
||||||
capability_flags: CapabilityFlags,
|
capability_flags: CapabilityFlags,
|
||||||
@@ -132,6 +134,7 @@ impl<'a> InitialSyncRunner<'a> {
|
|||||||
store,
|
store,
|
||||||
subsonic,
|
subsonic,
|
||||||
navidrome: None,
|
navidrome: None,
|
||||||
|
http_registry: None,
|
||||||
server_id: server_id.into(),
|
server_id: server_id.into(),
|
||||||
library_scope: library_scope.into(),
|
library_scope: library_scope.into(),
|
||||||
capability_flags,
|
capability_flags,
|
||||||
@@ -154,6 +157,11 @@ impl<'a> InitialSyncRunner<'a> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_http_registry(mut self, registry: Option<Arc<ServerHttpRegistry>>) -> Self {
|
||||||
|
self.http_registry = registry;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_cancellation(mut self, flag: Arc<AtomicBool>) -> Self {
|
pub fn with_cancellation(mut self, flag: Arc<AtomicBool>) -> Self {
|
||||||
self.cancel = Some(flag);
|
self.cancel = Some(flag);
|
||||||
self
|
self
|
||||||
@@ -580,6 +588,8 @@ impl<'a> InitialSyncRunner<'a> {
|
|||||||
let cancel = self.cancel.clone();
|
let cancel = self.cancel.clone();
|
||||||
let sleep_enabled = self.sleep_enabled;
|
let sleep_enabled = self.sleep_enabled;
|
||||||
let creds = creds.clone();
|
let creds = creds.clone();
|
||||||
|
let http_registry = self.http_registry.clone();
|
||||||
|
let server_id = self.server_id.clone();
|
||||||
let mut queue = LinearPrefetchQueue::new(&budget, batch_size, offset);
|
let mut queue = LinearPrefetchQueue::new(&budget, batch_size, offset);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -590,6 +600,8 @@ impl<'a> InitialSyncRunner<'a> {
|
|||||||
queue.pump(|| self.check_cancellation(), |off| {
|
queue.pump(|| self.check_cancellation(), |off| {
|
||||||
let creds = creds.clone();
|
let creds = creds.clone();
|
||||||
let cancel = cancel.clone();
|
let cancel = cancel.clone();
|
||||||
|
let http_registry = http_registry.clone();
|
||||||
|
let server_id = server_id.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
retry_fetch(
|
retry_fetch(
|
||||||
sleep_enabled,
|
sleep_enabled,
|
||||||
@@ -597,6 +609,8 @@ impl<'a> InitialSyncRunner<'a> {
|
|||||||
|| async {
|
|| async {
|
||||||
let end = off.saturating_add(batch_size);
|
let end = off.saturating_add(batch_size);
|
||||||
let response = nd_list_songs_internal(
|
let response = nd_list_songs_internal(
|
||||||
|
http_registry.as_deref(),
|
||||||
|
Some(&server_id),
|
||||||
&creds.server_url,
|
&creds.server_url,
|
||||||
&creds.bearer_token,
|
&creds.bearer_token,
|
||||||
"id",
|
"id",
|
||||||
@@ -671,6 +685,8 @@ impl<'a> InitialSyncRunner<'a> {
|
|||||||
self,
|
self,
|
||||||
|| {
|
|| {
|
||||||
nd_list_songs_internal(
|
nd_list_songs_internal(
|
||||||
|
self.http_registry.as_deref(),
|
||||||
|
Some(&self.server_id),
|
||||||
&creds.server_url,
|
&creds.server_url,
|
||||||
&creds.bearer_token,
|
&creds.bearer_token,
|
||||||
"id",
|
"id",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use psysonic_core::server_http::ServerHttpRegistry;
|
||||||
use psysonic_integration::subsonic::SubsonicClient;
|
use psysonic_integration::subsonic::SubsonicClient;
|
||||||
|
|
||||||
use super::bandwidth::{ParallelismBudget, PlaybackHint};
|
use super::bandwidth::{ParallelismBudget, PlaybackHint};
|
||||||
@@ -45,6 +46,7 @@ pub struct BackgroundScheduler<'a> {
|
|||||||
store: &'a LibraryStore,
|
store: &'a LibraryStore,
|
||||||
subsonic: &'a SubsonicClient,
|
subsonic: &'a SubsonicClient,
|
||||||
navidrome: Option<NavidromeProbeCredentials>,
|
navidrome: Option<NavidromeProbeCredentials>,
|
||||||
|
http_registry: Option<Arc<ServerHttpRegistry>>,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
library_scope: String,
|
library_scope: String,
|
||||||
capability_flags: CapabilityFlags,
|
capability_flags: CapabilityFlags,
|
||||||
@@ -70,6 +72,7 @@ impl<'a> BackgroundScheduler<'a> {
|
|||||||
store,
|
store,
|
||||||
subsonic,
|
subsonic,
|
||||||
navidrome: None,
|
navidrome: None,
|
||||||
|
http_registry: None,
|
||||||
server_id: server_id.into(),
|
server_id: server_id.into(),
|
||||||
library_scope: library_scope.into(),
|
library_scope: library_scope.into(),
|
||||||
capability_flags,
|
capability_flags,
|
||||||
@@ -87,6 +90,11 @@ impl<'a> BackgroundScheduler<'a> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_http_registry(mut self, registry: Option<Arc<ServerHttpRegistry>>) -> Self {
|
||||||
|
self.http_registry = registry;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_playback_hint(mut self, hint: PlaybackHint) -> Self {
|
pub fn with_playback_hint(mut self, hint: PlaybackHint) -> Self {
|
||||||
self.playback_hint = hint;
|
self.playback_hint = hint;
|
||||||
self
|
self
|
||||||
@@ -220,7 +228,8 @@ impl<'a> BackgroundScheduler<'a> {
|
|||||||
&self.library_scope,
|
&self.library_scope,
|
||||||
self.capability_flags,
|
self.capability_flags,
|
||||||
)
|
)
|
||||||
.with_progress(Arc::clone(&self.progress));
|
.with_progress(Arc::clone(&self.progress))
|
||||||
|
.with_http_registry(self.http_registry.clone());
|
||||||
if let Some(creds) = &self.navidrome {
|
if let Some(creds) = &self.navidrome {
|
||||||
runner = runner.with_navidrome_credentials(creds.clone());
|
runner = runner.with_navidrome_credentials(creds.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -2,6 +2,8 @@ use tauri::{Emitter, Manager};
|
|||||||
|
|
||||||
use psysonic_core::user_agent::subsonic_wire_user_agent;
|
use psysonic_core::user_agent::subsonic_wire_user_agent;
|
||||||
|
|
||||||
|
use crate::file_transfer::apply_server_http_get;
|
||||||
|
|
||||||
pub fn resolve_hot_cache_root(
|
pub fn resolve_hot_cache_root(
|
||||||
custom_dir: Option<String>,
|
custom_dir: Option<String>,
|
||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
@@ -340,7 +342,18 @@ pub async fn download_zip(
|
|||||||
.build()
|
.build()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
let http_registry = app
|
||||||
|
.try_state::<std::sync::Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||||
|
.map(|s| std::sync::Arc::clone(&*s));
|
||||||
|
let response = apply_server_http_get(
|
||||||
|
&client,
|
||||||
|
http_registry.as_deref(),
|
||||||
|
None,
|
||||||
|
&url,
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -1,8 +1,11 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use psysonic_analysis::analysis_runtime::{enqueue_track_analysis, AnalysisBackfillPriority};
|
use psysonic_analysis::analysis_runtime::{enqueue_track_analysis, AnalysisBackfillPriority};
|
||||||
use psysonic_audio as audio;
|
use psysonic_audio as audio;
|
||||||
use psysonic_core::user_agent::subsonic_wire_user_agent;
|
use psysonic_core::user_agent::subsonic_wire_user_agent;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
use crate::file_transfer::stream_to_file;
|
use crate::file_transfer::{apply_server_http_get, stream_to_file};
|
||||||
use super::downloads::{resolve_hot_cache_root, HotCacheDownloadResult};
|
use super::downloads::{resolve_hot_cache_root, HotCacheDownloadResult};
|
||||||
use super::offline::enqueue_analysis_seed_from_file;
|
use super::offline::enqueue_analysis_seed_from_file;
|
||||||
|
|
||||||
@@ -70,7 +73,14 @@ pub async fn download_track_hot_cache(
|
|||||||
.build()
|
.build()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
let http_registry = app
|
||||||
|
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||||
|
.map(|s| Arc::clone(&*s));
|
||||||
|
|
||||||
|
let response = apply_server_http_get(&client, http_registry.as_deref(), Some(&server_id), &url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -22,7 +22,7 @@ use psysonic_library::repos::TrackRow;
|
|||||||
use psysonic_library::{repos::TrackRepository, LibraryRuntime};
|
use psysonic_library::{repos::TrackRepository, LibraryRuntime};
|
||||||
use tauri::{AppHandle, Manager, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
|
||||||
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
|
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
|
||||||
use crate::{offline_cancel_flags, DownloadSemaphore};
|
use crate::{offline_cancel_flags, DownloadSemaphore};
|
||||||
|
|
||||||
use super::offline::enqueue_analysis_seed_from_file;
|
use super::offline::enqueue_analysis_seed_from_file;
|
||||||
@@ -359,7 +359,18 @@ pub async fn download_track_local(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let client = subsonic_http_client(std::time::Duration::from_secs(120))?;
|
let client = subsonic_http_client(std::time::Duration::from_secs(120))?;
|
||||||
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
let http_registry = app
|
||||||
|
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||||
|
.map(|s| Arc::clone(&*s));
|
||||||
|
let response = apply_server_http_get(
|
||||||
|
&client,
|
||||||
|
http_registry.as_deref(),
|
||||||
|
Some(&server_index_key),
|
||||||
|
&url,
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-7
@@ -9,7 +9,7 @@ use psysonic_analysis::analysis_runtime::{
|
|||||||
};
|
};
|
||||||
use crate::{offline_cancel_flags, DownloadSemaphore};
|
use crate::{offline_cancel_flags, DownloadSemaphore};
|
||||||
|
|
||||||
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
|
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
|
||||||
|
|
||||||
// ─── Offline Track Cache ──────────────────────────────────────────────────────
|
// ─── Offline Track Cache ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -31,12 +31,15 @@ pub async fn enqueue_analysis_seed_from_file(
|
|||||||
///
|
///
|
||||||
/// `cancel`, when supplied, aborts the in-flight stream with `Err("CANCELLED")`
|
/// `cancel`, when supplied, aborts the in-flight stream with `Err("CANCELLED")`
|
||||||
/// (the `.part` file is cleaned up); `None` means the download is not cancellable.
|
/// (the `.part` file is cleaned up); `None` means the download is not cancellable.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) async fn download_track_to_cache_dir(
|
pub(crate) async fn download_track_to_cache_dir(
|
||||||
cache_dir: &std::path::Path,
|
cache_dir: &std::path::Path,
|
||||||
track_id: &str,
|
track_id: &str,
|
||||||
suffix: &str,
|
suffix: &str,
|
||||||
url: &str,
|
url: &str,
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
|
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||||
|
server_ref: Option<&str>,
|
||||||
cancel: Option<&AtomicBool>,
|
cancel: Option<&AtomicBool>,
|
||||||
) -> Result<std::path::PathBuf, String> {
|
) -> Result<std::path::PathBuf, String> {
|
||||||
tokio::fs::create_dir_all(cache_dir)
|
tokio::fs::create_dir_all(cache_dir)
|
||||||
@@ -48,7 +51,10 @@ pub(crate) async fn download_track_to_cache_dir(
|
|||||||
return Ok(file_path);
|
return Ok(file_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = client.get(url).send().await.map_err(|e| e.to_string())?;
|
let response = apply_server_http_get(client, registry, server_ref, url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||||
}
|
}
|
||||||
@@ -129,12 +135,17 @@ pub async fn download_track_offline(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let client = subsonic_http_client(std::time::Duration::from_secs(120))?;
|
let client = subsonic_http_client(std::time::Duration::from_secs(120))?;
|
||||||
|
let http_registry = app
|
||||||
|
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||||
|
.map(|s| Arc::clone(&*s));
|
||||||
let final_path = download_track_to_cache_dir(
|
let final_path = download_track_to_cache_dir(
|
||||||
&cache_dir,
|
&cache_dir,
|
||||||
&track_id,
|
&track_id,
|
||||||
&suffix,
|
&suffix,
|
||||||
&url,
|
&url,
|
||||||
&client,
|
&client,
|
||||||
|
http_registry.as_deref(),
|
||||||
|
Some(&server_id),
|
||||||
cancel_flag.as_deref(),
|
cancel_flag.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -191,7 +202,7 @@ mod tests {
|
|||||||
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
||||||
let url = format!("{}/stream/track-1", server.uri());
|
let url = format!("{}/stream/track-1", server.uri());
|
||||||
|
|
||||||
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None)
|
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(path.exists());
|
assert!(path.exists());
|
||||||
@@ -211,7 +222,7 @@ mod tests {
|
|||||||
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
||||||
let url = format!("{}/should-not-be-hit", server.uri());
|
let url = format!("{}/should-not-be-hit", server.uri());
|
||||||
|
|
||||||
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None)
|
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(path, pre_existing);
|
assert_eq!(path, pre_existing);
|
||||||
@@ -232,7 +243,7 @@ mod tests {
|
|||||||
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
||||||
let url = format!("{}/stream/missing", server.uri());
|
let url = format!("{}/stream/missing", server.uri());
|
||||||
|
|
||||||
let err = download_track_to_cache_dir(&cache_dir, "missing", "flac", &url, &client, None)
|
let err = download_track_to_cache_dir(&cache_dir, "missing", "flac", &url, &client, None, None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(err.contains("HTTP 404"), "got {err}");
|
assert!(err.contains("HTTP 404"), "got {err}");
|
||||||
@@ -256,7 +267,7 @@ mod tests {
|
|||||||
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
||||||
let url = format!("{}/track", server.uri());
|
let url = format!("{}/track", server.uri());
|
||||||
|
|
||||||
download_track_to_cache_dir(&cache_dir, "t", "mp3", &url, &client, None)
|
download_track_to_cache_dir(&cache_dir, "t", "mp3", &url, &client, None, None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(cache_dir.join("t.mp3").exists());
|
assert!(cache_dir.join("t.mp3").exists());
|
||||||
@@ -278,7 +289,7 @@ mod tests {
|
|||||||
|
|
||||||
let cancel = AtomicBool::new(true);
|
let cancel = AtomicBool::new(true);
|
||||||
let err =
|
let err =
|
||||||
download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, Some(&cancel))
|
download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, Some(&cancel))
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert_eq!(err, "CANCELLED");
|
assert_eq!(err, "CANCELLED");
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ pub fn subsonic_http_client(timeout: Duration) -> Result<reqwest::Client, String
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn apply_server_http_get(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||||
|
server_ref: Option<&str>,
|
||||||
|
url: &str,
|
||||||
|
) -> reqwest::RequestBuilder {
|
||||||
|
psysonic_core::server_http::apply_optional_registry_headers(
|
||||||
|
registry,
|
||||||
|
server_ref,
|
||||||
|
url,
|
||||||
|
client.get(url),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Streams an HTTP response body to `dest_path` in chunks. Never buffers the full
|
/// Streams an HTTP response body to `dest_path` in chunks. Never buffers the full
|
||||||
/// file in memory — keeps RAM flat regardless of file size.
|
/// file in memory — keeps RAM flat regardless of file size.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use tauri::Emitter;
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
use crate::sync_cancel_flags;
|
use crate::sync_cancel_flags;
|
||||||
|
|
||||||
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
|
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
|
||||||
use super::device::{
|
use super::device::{
|
||||||
build_track_path, get_removable_drives, is_path_on_mounted_volume, SyncBatchResult,
|
build_track_path, get_removable_drives, is_path_on_mounted_volume, SyncBatchResult,
|
||||||
TrackSyncInfo,
|
TrackSyncInfo,
|
||||||
@@ -107,6 +107,7 @@ pub struct SyncDeltaResult {
|
|||||||
|
|
||||||
pub async fn fetch_subsonic_songs(
|
pub async fn fetch_subsonic_songs(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
|
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||||
auth: &SubsonicAuthPayload,
|
auth: &SubsonicAuthPayload,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -121,7 +122,11 @@ pub async fn fetch_subsonic_songs(
|
|||||||
("f", auth.f.as_str()),
|
("f", auth.f.as_str()),
|
||||||
("id", id),
|
("id", id),
|
||||||
];
|
];
|
||||||
let res = client.get(&url).query(&query).send().await.map_err(|e| e.to_string())?;
|
let res = apply_server_http_get(client, registry, None, &url)
|
||||||
|
.query(&query)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
|
let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
|
||||||
parse_subsonic_songs(&json, endpoint)
|
parse_subsonic_songs(&json, endpoint)
|
||||||
}
|
}
|
||||||
@@ -239,8 +244,12 @@ pub async fn calculate_sync_payload(
|
|||||||
deletion_ids: Vec<String>,
|
deletion_ids: Vec<String>,
|
||||||
auth: SubsonicAuthPayload,
|
auth: SubsonicAuthPayload,
|
||||||
target_dir: String,
|
target_dir: String,
|
||||||
|
app: tauri::AppHandle,
|
||||||
) -> Result<SyncDeltaResult, String> {
|
) -> Result<SyncDeltaResult, String> {
|
||||||
let client = subsonic_http_client(std::time::Duration::from_secs(30))?;
|
let client = subsonic_http_client(std::time::Duration::from_secs(30))?;
|
||||||
|
let http_registry = app
|
||||||
|
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||||
|
.map(|s| Arc::clone(&*s));
|
||||||
|
|
||||||
let mut add_bytes = 0;
|
let mut add_bytes = 0;
|
||||||
let mut add_count = 0;
|
let mut add_count = 0;
|
||||||
@@ -264,17 +273,19 @@ pub async fn calculate_sync_payload(
|
|||||||
v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
|
v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
|
||||||
};
|
};
|
||||||
let cli = client.clone();
|
let cli = client.clone();
|
||||||
|
let reg_for_task = http_registry.clone();
|
||||||
let source_snapshot = source.clone();
|
let source_snapshot = source.clone();
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
|
let registry = reg_for_task.as_deref();
|
||||||
let mut res_tracks = Vec::new();
|
let mut res_tracks = Vec::new();
|
||||||
if source.source_type == "album" {
|
if source.source_type == "album" {
|
||||||
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
|
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
|
||||||
} else if source.source_type == "playlist" {
|
} else if source.source_type == "playlist" {
|
||||||
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
|
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
|
||||||
} else if source.source_type == "artist" {
|
} else if source.source_type == "artist" {
|
||||||
let url = format!("{}/getArtist.view", auth_clone.base_url);
|
let url = format!("{}/getArtist.view", auth_clone.base_url);
|
||||||
let query = vec![("u", auth_clone.u.as_str()), ("t", auth_clone.t.as_str()), ("s", auth_clone.s.as_str()), ("v", auth_clone.v.as_str()), ("c", auth_clone.c.as_str()), ("f", auth_clone.f.as_str()), ("id", &source.id)];
|
let query = vec![("u", auth_clone.u.as_str()), ("t", auth_clone.t.as_str()), ("s", auth_clone.s.as_str()), ("v", auth_clone.v.as_str()), ("c", auth_clone.c.as_str()), ("f", auth_clone.f.as_str()), ("id", &source.id)];
|
||||||
if let Ok(re) = cli.get(&url).query(&query).send().await {
|
if let Ok(re) = apply_server_http_get(&cli, registry, None, &url).query(&query).send().await {
|
||||||
if let Ok(js) = re.json::<serde_json::Value>().await {
|
if let Ok(js) = re.json::<serde_json::Value>().await {
|
||||||
if let Some(root) = js.get("subsonic-response").and_then(|r| r.get("artist")).and_then(|a| a.get("album")) {
|
if let Some(root) = js.get("subsonic-response").and_then(|r| r.get("artist")).and_then(|a| a.get("album")) {
|
||||||
let arr = root.as_array().cloned().unwrap_or_else(|| {
|
let arr = root.as_array().cloned().unwrap_or_else(|| {
|
||||||
@@ -282,7 +293,7 @@ pub async fn calculate_sync_payload(
|
|||||||
});
|
});
|
||||||
for al in arr {
|
for al in arr {
|
||||||
if let Some(aid) = al.get("id").and_then(|i| i.as_str()) {
|
if let Some(aid) = al.get("id").and_then(|i| i.as_str()) {
|
||||||
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", aid).await {
|
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", aid).await {
|
||||||
res_tracks.extend(ts);
|
res_tracks.extend(ts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,12 +314,14 @@ pub async fn calculate_sync_payload(
|
|||||||
v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
|
v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
|
||||||
};
|
};
|
||||||
let cli = client.clone();
|
let cli = client.clone();
|
||||||
|
let reg_for_task = http_registry.clone();
|
||||||
del_handles.push(tokio::spawn(async move {
|
del_handles.push(tokio::spawn(async move {
|
||||||
|
let registry = reg_for_task.as_deref();
|
||||||
let mut res_tracks = Vec::new();
|
let mut res_tracks = Vec::new();
|
||||||
if source.source_type == "album" {
|
if source.source_type == "album" {
|
||||||
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
|
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
|
||||||
} else if source.source_type == "playlist" {
|
} else if source.source_type == "playlist" {
|
||||||
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
|
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
|
||||||
}
|
}
|
||||||
res_tracks
|
res_tracks
|
||||||
}));
|
}));
|
||||||
@@ -437,6 +450,9 @@ pub async fn sync_batch_to_device(
|
|||||||
|
|
||||||
// Shared reqwest client — reused across all downloads.
|
// Shared reqwest client — reused across all downloads.
|
||||||
let client = subsonic_http_client(Duration::from_secs(300))?;
|
let client = subsonic_http_client(Duration::from_secs(300))?;
|
||||||
|
let http_registry = app
|
||||||
|
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||||
|
.map(|s| Arc::clone(&*s));
|
||||||
|
|
||||||
// Concurrency limiter: max 2 parallel USB writes.
|
// Concurrency limiter: max 2 parallel USB writes.
|
||||||
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(2));
|
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(2));
|
||||||
@@ -455,6 +471,7 @@ pub async fn sync_batch_to_device(
|
|||||||
for track in tracks {
|
for track in tracks {
|
||||||
let sem = semaphore.clone();
|
let sem = semaphore.clone();
|
||||||
let cli = client.clone();
|
let cli = client.clone();
|
||||||
|
let reg_for_task = http_registry.clone();
|
||||||
let app2 = app.clone();
|
let app2 = app.clone();
|
||||||
let job = job_id.clone();
|
let job = job_id.clone();
|
||||||
let dest = dest_dir.clone();
|
let dest = dest_dir.clone();
|
||||||
@@ -466,6 +483,7 @@ pub async fn sync_batch_to_device(
|
|||||||
|
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
let _permit = sem.acquire().await.expect("semaphore closed");
|
let _permit = sem.acquire().await.expect("semaphore closed");
|
||||||
|
let registry = reg_for_task.as_deref();
|
||||||
|
|
||||||
// Bail out if cancelled while waiting in the semaphore queue.
|
// Bail out if cancelled while waiting in the semaphore queue.
|
||||||
if cancel.load(Ordering::Relaxed) { return; }
|
if cancel.load(Ordering::Relaxed) { return; }
|
||||||
@@ -492,7 +510,7 @@ pub async fn sync_batch_to_device(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = match cli.get(&track.url).send().await {
|
let response = match apply_server_http_get(&cli, registry, None, &track.url).send().await {
|
||||||
Ok(r) if r.status().is_success() => r,
|
Ok(r) if r.status().is_success() => r,
|
||||||
Ok(r) => {
|
Ok(r) => {
|
||||||
f.fetch_add(1, Ordering::Relaxed);
|
f.fetch_add(1, Ordering::Relaxed);
|
||||||
@@ -819,7 +837,7 @@ mod tests {
|
|||||||
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
|
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let auth = fake_auth(server.uri());
|
let auth = fake_auth(server.uri());
|
||||||
let songs = fetch_subsonic_songs(&client, &auth, "getAlbum.view", "album-42")
|
let songs = fetch_subsonic_songs(&client, None, &auth, "getAlbum.view", "album-42")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(songs.len(), 2);
|
assert_eq!(songs.len(), 2);
|
||||||
@@ -838,7 +856,7 @@ mod tests {
|
|||||||
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
|
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let auth = fake_auth(server.uri());
|
let auth = fake_auth(server.uri());
|
||||||
let result = fetch_subsonic_songs(&client, &auth, "getAlbum.view", "missing").await;
|
let result = fetch_subsonic_songs(&client, None, &auth, "getAlbum.view", "missing").await;
|
||||||
// 404 with HTML/empty body fails the JSON parse, surfacing as an Err — we
|
// 404 with HTML/empty body fails the JSON parse, surfacing as an Err — we
|
||||||
// just assert the function does not panic and propagates an error string.
|
// just assert the function does not panic and propagates an error string.
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
@@ -989,7 +1007,7 @@ mod tests {
|
|||||||
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
|
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let auth = fake_auth(server.uri());
|
let auth = fake_auth(server.uri());
|
||||||
let songs = fetch_subsonic_songs(&client, &auth, "getPlaylist.view", "p1")
|
let songs = fetch_subsonic_songs(&client, None, &auth, "getPlaylist.view", "p1")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(songs.len(), 1, "single-object response normalised to 1-element vec");
|
assert_eq!(songs.len(), 1, "single-object response normalised to 1-element vec");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use tauri::Emitter;
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
|
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
|
||||||
|
|
||||||
// ─── Device Sync ─────────────────────────────────────────────────────────────
|
// ─── Device Sync ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -333,6 +333,8 @@ pub(crate) async fn sync_download_one_track(
|
|||||||
suffix: &str,
|
suffix: &str,
|
||||||
url: &str,
|
url: &str,
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
|
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||||
|
server_ref: Option<&str>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
if dest_path.exists() {
|
if dest_path.exists() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -342,7 +344,10 @@ pub(crate) async fn sync_download_one_track(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
let response = client.get(url).send().await.map_err(|e| e.to_string())?;
|
let response = apply_server_http_get(client, registry, server_ref, url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||||
}
|
}
|
||||||
@@ -366,7 +371,19 @@ pub async fn sync_track_to_device(
|
|||||||
let path_str = dest_path.to_string_lossy().to_string();
|
let path_str = dest_path.to_string_lossy().to_string();
|
||||||
|
|
||||||
let client = subsonic_http_client(std::time::Duration::from_secs(300))?;
|
let client = subsonic_http_client(std::time::Duration::from_secs(300))?;
|
||||||
match sync_download_one_track(&dest_path, &track.suffix, &track.url, &client).await {
|
let http_registry = app
|
||||||
|
.try_state::<std::sync::Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||||
|
.map(|s| std::sync::Arc::clone(&*s));
|
||||||
|
match sync_download_one_track(
|
||||||
|
&dest_path,
|
||||||
|
&track.suffix,
|
||||||
|
&track.url,
|
||||||
|
&client,
|
||||||
|
http_registry.as_deref(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
let _ = app.emit("device:sync:progress", serde_json::json!({
|
let _ = app.emit("device:sync:progress", serde_json::json!({
|
||||||
"jobId": job_id, "trackId": track.id, "status": "skipped", "path": path_str,
|
"jobId": job_id, "trackId": track.id, "status": "skipped", "path": path_str,
|
||||||
@@ -656,7 +673,7 @@ mod tests {
|
|||||||
let dest = dir.path().join("Album").join("01 - track.flac");
|
let dest = dir.path().join("Album").join("01 - track.flac");
|
||||||
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
||||||
let url = format!("{}/track", server.uri());
|
let url = format!("{}/track", server.uri());
|
||||||
let downloaded = sync_download_one_track(&dest, "flac", &url, &client)
|
let downloaded = sync_download_one_track(&dest, "flac", &url, &client, None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(downloaded, "fresh download must report Ok(true)");
|
assert!(downloaded, "fresh download must report Ok(true)");
|
||||||
@@ -672,7 +689,7 @@ mod tests {
|
|||||||
|
|
||||||
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
||||||
let url = format!("{}/should-not-be-hit", server.uri());
|
let url = format!("{}/should-not-be-hit", server.uri());
|
||||||
let downloaded = sync_download_one_track(&dest, "mp3", &url, &client)
|
let downloaded = sync_download_one_track(&dest, "mp3", &url, &client, None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!downloaded, "pre-existing file must be reported as skipped");
|
assert!(!downloaded, "pre-existing file must be reported as skipped");
|
||||||
@@ -692,7 +709,7 @@ mod tests {
|
|||||||
let dest = dir.path().join("track.opus");
|
let dest = dir.path().join("track.opus");
|
||||||
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
||||||
let url = format!("{}/missing", server.uri());
|
let url = format!("{}/missing", server.uri());
|
||||||
let err = sync_download_one_track(&dest, "opus", &url, &client)
|
let err = sync_download_one_track(&dest, "opus", &url, &client, None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(err.contains("HTTP 403"));
|
assert!(err.contains("HTTP 403"));
|
||||||
@@ -714,7 +731,7 @@ mod tests {
|
|||||||
assert!(!dest.parent().unwrap().exists());
|
assert!(!dest.parent().unwrap().exists());
|
||||||
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
||||||
let url = format!("{}/t", server.uri());
|
let url = format!("{}/t", server.uri());
|
||||||
sync_download_one_track(&dest, "mp3", &url, &client)
|
sync_download_one_track(&dest, "mp3", &url, &client, None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(dest.exists());
|
assert!(dest.exists());
|
||||||
|
|||||||
@@ -63,13 +63,15 @@ pub fn build_fanart_url(mbid: &str, api_key: &str, client_key: Option<&str>) ->
|
|||||||
/// `Ok(None)` when the artist carries no MBID tag.
|
/// `Ok(None)` when the artist carries no MBID tag.
|
||||||
pub async fn fetch_artist_tag_mbid(
|
pub async fn fetch_artist_tag_mbid(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
|
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||||
|
server_ref: Option<&str>,
|
||||||
rest_base: &str,
|
rest_base: &str,
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
artist_id: &str,
|
artist_id: &str,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, String> {
|
||||||
let url = build_artist_info2_url(rest_base, username, password, artist_id);
|
let url = build_artist_info2_url(rest_base, username, password, artist_id);
|
||||||
let body = http_get_text(client, &url).await?;
|
let body = http_get_text_scoped(client, registry, server_ref, &url).await?;
|
||||||
let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||||
let mbid = v
|
let mbid = v
|
||||||
.get("subsonic-response")
|
.get("subsonic-response")
|
||||||
@@ -218,8 +220,21 @@ fn classify_mb_releases(v: &serde_json::Value) -> MbResolution {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Single GET → response text; any non-2xx is an error.
|
/// Single GET → response text; any non-2xx is an error.
|
||||||
async fn http_get_text(client: &Client, url: &str) -> Result<String, String> {
|
async fn http_get_text_scoped(
|
||||||
let resp = client.get(url).send().await.map_err(|e| e.to_string())?;
|
client: &Client,
|
||||||
|
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||||
|
server_ref: Option<&str>,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let resp = psysonic_core::server_http::apply_optional_registry_headers(
|
||||||
|
registry,
|
||||||
|
server_ref,
|
||||||
|
url,
|
||||||
|
client.get(url),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
return Err(format!("HTTP {status}"));
|
return Err(format!("HTTP {status}"));
|
||||||
|
|||||||
@@ -242,12 +242,18 @@ pub(super) async fn try_external_fanart(
|
|||||||
|
|
||||||
let _permit = fanart_sem.clone().acquire_owned().await.ok()?;
|
let _permit = fanart_sem.clone().acquire_owned().await.ok()?;
|
||||||
|
|
||||||
|
let http_registry = app
|
||||||
|
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||||
|
.map(|s| Arc::clone(&*s));
|
||||||
|
|
||||||
// §23: resolve the tag MBID Rust-side via getArtistInfo2 — unless the cache
|
// §23: resolve the tag MBID Rust-side via getArtistInfo2 — unless the cache
|
||||||
// already carries one (skip the Navidrome round-trip).
|
// already carries one (skip the Navidrome round-trip).
|
||||||
let (mbid, mbid_source) = match cached.as_ref().and_then(|r| r.mbid.clone()) {
|
let (mbid, mbid_source) = match cached.as_ref().and_then(|r| r.mbid.clone()) {
|
||||||
Some(m) => (m, cached.as_ref().and_then(|r| r.mbid_source.clone())),
|
Some(m) => (m, cached.as_ref().and_then(|r| r.mbid_source.clone())),
|
||||||
None => match external::fetch_artist_tag_mbid(
|
None => match external::fetch_artist_tag_mbid(
|
||||||
client,
|
client,
|
||||||
|
http_registry.as_deref(),
|
||||||
|
Some(server_id),
|
||||||
&args.rest_base_url,
|
&args.rest_base_url,
|
||||||
&args.username,
|
&args.username,
|
||||||
&args.password,
|
&args.password,
|
||||||
@@ -344,7 +350,7 @@ pub(super) async fn try_external_fanart(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let bytes = match fetch::fetch_cover_bytes(client, &img_url).await {
|
let bytes = match fetch::fetch_cover_bytes(client, &img_url, None, None).await {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[fanart] download failed: {e}"); // transient — don't cache
|
eprintln!("[fanart] download failed: {e}"); // transient — don't cache
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
use psysonic_core::server_http::ServerHttpRegistry;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
@@ -83,8 +84,21 @@ enum FetchAttempt {
|
|||||||
Permanent(String),
|
Permanent(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_cover_once(client: &Client, url: &str) -> FetchAttempt {
|
async fn fetch_cover_once(
|
||||||
let resp = match client.get(url).send().await {
|
client: &Client,
|
||||||
|
url: &str,
|
||||||
|
registry: Option<&ServerHttpRegistry>,
|
||||||
|
server_ref: Option<&str>,
|
||||||
|
) -> FetchAttempt {
|
||||||
|
let mut req = client.get(url);
|
||||||
|
if let Some(reg) = registry {
|
||||||
|
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
|
||||||
|
req = reg.apply_for_http_url(sid, url, req);
|
||||||
|
} else if let Some(ctx) = reg.get_for_server_url(url) {
|
||||||
|
req = psysonic_core::server_http::apply_server_headers_for_http_url(req, &ctx, url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let resp = match req.send().await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
// Connection reset / timeout / DNS — transient under server load.
|
// Connection reset / timeout / DNS — transient under server load.
|
||||||
Err(e) => return FetchAttempt::Transient(e.to_string()),
|
Err(e) => return FetchAttempt::Transient(e.to_string()),
|
||||||
@@ -104,10 +118,15 @@ async fn fetch_cover_once(client: &Client, url: &str) -> FetchAttempt {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fetch_cover_bytes(client: &Client, url: &str) -> Result<Vec<u8>, String> {
|
pub async fn fetch_cover_bytes(
|
||||||
|
client: &Client,
|
||||||
|
url: &str,
|
||||||
|
registry: Option<&ServerHttpRegistry>,
|
||||||
|
server_ref: Option<&str>,
|
||||||
|
) -> Result<Vec<u8>, String> {
|
||||||
let mut last_err = String::from("cover fetch failed");
|
let mut last_err = String::from("cover fetch failed");
|
||||||
for attempt in 0..COVER_FETCH_ATTEMPTS {
|
for attempt in 0..COVER_FETCH_ATTEMPTS {
|
||||||
match fetch_cover_once(client, url).await {
|
match fetch_cover_once(client, url, registry, server_ref).await {
|
||||||
FetchAttempt::Ok(bytes) => return Ok(bytes),
|
FetchAttempt::Ok(bytes) => return Ok(bytes),
|
||||||
FetchAttempt::Permanent(e) => return Err(e),
|
FetchAttempt::Permanent(e) => return Err(e),
|
||||||
FetchAttempt::Transient(e) => {
|
FetchAttempt::Transient(e) => {
|
||||||
|
|||||||
@@ -354,7 +354,10 @@ impl CoverCacheState {
|
|||||||
let source = if let Some(img) = load_image_from_disk(&dir) {
|
let source = if let Some(img) = load_image_from_disk(&dir) {
|
||||||
CoverSource::Image(img)
|
CoverSource::Image(img)
|
||||||
} else {
|
} else {
|
||||||
match download_cover_payload(&dir, &client, &http_sem, args).await {
|
let http_registry = app
|
||||||
|
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||||
|
.map(|s| Arc::clone(&*s));
|
||||||
|
match download_cover_payload(&dir, &client, &http_sem, args, http_registry).await {
|
||||||
Ok(bytes) => CoverSource::Bytes(bytes),
|
Ok(bytes) => CoverSource::Bytes(bytes),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log_cover_fetch_failure(app, args, &err);
|
log_cover_fetch_failure(app, args, &err);
|
||||||
@@ -532,6 +535,7 @@ async fn download_cover_payload(
|
|||||||
client: &Client,
|
client: &Client,
|
||||||
http_sem: &Semaphore,
|
http_sem: &Semaphore,
|
||||||
args: &CoverCacheEnsureArgs,
|
args: &CoverCacheEnsureArgs,
|
||||||
|
registry: Option<Arc<psysonic_core::server_http::ServerHttpRegistry>>,
|
||||||
) -> Result<Vec<u8>, String> {
|
) -> Result<Vec<u8>, String> {
|
||||||
let _permit = http_sem
|
let _permit = http_sem
|
||||||
.acquire()
|
.acquire()
|
||||||
@@ -549,7 +553,13 @@ async fn download_cover_payload(
|
|||||||
&args.cover_art_id,
|
&args.cover_art_id,
|
||||||
fetch_size,
|
fetch_size,
|
||||||
);
|
);
|
||||||
fetch::fetch_cover_bytes(client, &url).await
|
fetch::fetch_cover_bytes(
|
||||||
|
client,
|
||||||
|
&url,
|
||||||
|
registry.as_deref(),
|
||||||
|
Some(args.server_index_key.as_str()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_derive_remaining_tiers(
|
fn spawn_derive_remaining_tiers(
|
||||||
|
|||||||
+10
-2
@@ -106,6 +106,7 @@ pub fn run() {
|
|||||||
|
|
||||||
let builder = tauri::Builder::default()
|
let builder = tauri::Builder::default()
|
||||||
.manage(audio_engine)
|
.manage(audio_engine)
|
||||||
|
.manage(Arc::new(psysonic_core::server_http::ServerHttpRegistry::new()))
|
||||||
.manage(ShortcutMap::default())
|
.manage(ShortcutMap::default())
|
||||||
.manage(discord::DiscordState::new())
|
.manage(discord::DiscordState::new())
|
||||||
.manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore)
|
.manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore)
|
||||||
@@ -238,7 +239,10 @@ pub fn run() {
|
|||||||
let flags = psysonic_library::sync::capability::CapabilityFlags::new(
|
let flags = psysonic_library::sync::capability::CapabilityFlags::new(
|
||||||
flags_bits,
|
flags_bits,
|
||||||
);
|
);
|
||||||
let subsonic = psysonic_integration::subsonic::SubsonicClient::new(
|
let registry = app_for_sched.state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>();
|
||||||
|
let subsonic = psysonic_integration::subsonic::subsonic_client_with_registry(
|
||||||
|
Some(registry.as_ref()),
|
||||||
|
&session.server_id,
|
||||||
session.base_url.clone(),
|
session.base_url.clone(),
|
||||||
session.username.clone(),
|
session.username.clone(),
|
||||||
session.password.clone(),
|
session.password.clone(),
|
||||||
@@ -251,7 +255,8 @@ pub fn run() {
|
|||||||
scope.clone(),
|
scope.clone(),
|
||||||
flags,
|
flags,
|
||||||
)
|
)
|
||||||
.with_playback_hint(hint);
|
.with_playback_hint(hint)
|
||||||
|
.with_http_registry(Some(Arc::clone(®istry)));
|
||||||
if let Some(tok) = session.navidrome_token.clone() {
|
if let Some(tok) = session.navidrome_token.clone() {
|
||||||
sched = sched.with_navidrome_credentials(
|
sched = sched.with_navidrome_credentials(
|
||||||
psysonic_library::sync::capability::NavidromeProbeCredentials {
|
psysonic_library::sync::capability::NavidromeProbeCredentials {
|
||||||
@@ -682,6 +687,9 @@ pub fn run() {
|
|||||||
migration_inspect,
|
migration_inspect,
|
||||||
migration_run,
|
migration_run,
|
||||||
resolve_host_addresses,
|
resolve_host_addresses,
|
||||||
|
server_http_context_sync,
|
||||||
|
server_http_context_sync_all,
|
||||||
|
server_http_context_clear,
|
||||||
psysonic_syncfs::sync::batch::calculate_sync_payload,
|
psysonic_syncfs::sync::batch::calculate_sync_payload,
|
||||||
exit_app,
|
exit_app,
|
||||||
cli_publish_player_snapshot,
|
cli_publish_player_snapshot,
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ pub(crate) use integration::{
|
|||||||
unregister_global_shortcut,
|
unregister_global_shortcut,
|
||||||
};
|
};
|
||||||
pub(crate) use migration::{migration_inspect, migration_run};
|
pub(crate) use migration::{migration_inspect, migration_run};
|
||||||
pub(crate) use network::resolve_host_addresses;
|
pub(crate) use network::{
|
||||||
|
resolve_host_addresses, server_http_context_clear, server_http_context_sync,
|
||||||
|
server_http_context_sync_all,
|
||||||
|
};
|
||||||
|
|
||||||
// Discord, Navidrome admin, last.fm + radio-browser + CORS proxy, bandsintown,
|
// Discord, Navidrome admin, last.fm + radio-browser + CORS proxy, bandsintown,
|
||||||
// and analysis admin commands now live in their domain crates. invoke_handler!
|
// and analysis admin commands now live in their domain crates. invoke_handler!
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
//! dual-server-address add/edit form (UI hint only — not for connect).
|
//! dual-server-address add/edit form (UI hint only — not for connect).
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use psysonic_core::server_http::{ServerHttpContextSyncWire, ServerHttpRegistry};
|
||||||
|
use tauri::State;
|
||||||
use tokio::net::lookup_host;
|
use tokio::net::lookup_host;
|
||||||
|
|
||||||
/// Resolve a hostname to a deduped list of IP address strings (IPv4 + IPv6).
|
/// Resolve a hostname to a deduped list of IP address strings (IPv4 + IPv6).
|
||||||
@@ -58,6 +63,34 @@ pub(crate) async fn resolve_host_addresses(hostname: String) -> Result<Vec<Strin
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn server_http_context_sync(
|
||||||
|
registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
|
wire: ServerHttpContextSyncWire,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
registry.sync(wire);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn server_http_context_sync_all(
|
||||||
|
registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
|
entries: Vec<ServerHttpContextSyncWire>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
registry.sync_all(entries);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn server_http_context_clear(
|
||||||
|
registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||||
|
server_id: String,
|
||||||
|
app_server_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
registry.remove(&server_id, &app_server_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Strip a `:port` suffix. Handles `host:port` and `[ipv6]:port`; leaves
|
/// Strip a `:port` suffix. Handles `host:port` and `[ipv6]:port`; leaves
|
||||||
/// bracketed IPv6 with no port (`[::1]`) and bare hosts alone.
|
/// bracketed IPv6 with no port (`[::1]`) and bare hosts alone.
|
||||||
fn strip_port(input: &str) -> String {
|
fn strip_port(input: &str) -> String {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { pingWithCredentials, ping } from './subsonic';
|
import { pingWithCredentials, pingWithCredentialsForProfile, ping } from './subsonic';
|
||||||
import { getAlbumInfo2 } from './subsonicAlbumInfo';
|
import { getAlbumInfo2 } from './subsonicAlbumInfo';
|
||||||
import { getStarred } from './subsonicStarRating';
|
import { getStarred } from './subsonicStarRating';
|
||||||
import { search } from './subsonicSearch';
|
import { search } from './subsonicSearch';
|
||||||
@@ -440,3 +440,39 @@ describe('pingWithCredentials — explicit URL/credentials path', () => {
|
|||||||
expect(r.openSubsonic).toBe(false);
|
expect(r.openSubsonic).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('pingWithCredentialsForProfile — custom gate headers', () => {
|
||||||
|
it('sends resolved custom headers on the ping request', async () => {
|
||||||
|
vi.mocked(axios.get).mockResolvedValue(okResponse({ type: 'navidrome' }));
|
||||||
|
await pingWithCredentialsForProfile(
|
||||||
|
{
|
||||||
|
url: 'https://music.example.com',
|
||||||
|
alternateUrl: 'http://192.168.0.10:4533',
|
||||||
|
username: 'u',
|
||||||
|
password: 'p',
|
||||||
|
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||||
|
customHeadersApplyTo: 'public',
|
||||||
|
},
|
||||||
|
'https://music.example.com',
|
||||||
|
);
|
||||||
|
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
|
||||||
|
expect(config.headers?.['CF-Access-Client-Secret']).toBe('gate-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits gate headers when probing the LAN endpoint with applyTo=public', async () => {
|
||||||
|
vi.mocked(axios.get).mockResolvedValue(okResponse({}));
|
||||||
|
await pingWithCredentialsForProfile(
|
||||||
|
{
|
||||||
|
url: 'https://music.example.com',
|
||||||
|
alternateUrl: 'http://192.168.0.10:4533',
|
||||||
|
username: 'u',
|
||||||
|
password: 'p',
|
||||||
|
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||||
|
customHeadersApplyTo: 'public',
|
||||||
|
},
|
||||||
|
'http://192.168.0.10:4533',
|
||||||
|
);
|
||||||
|
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
|
||||||
|
expect(config.headers?.['CF-Access-Client-Secret']).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,11 +31,26 @@ describe('scheduleInstantMixProbeForServer (idempotency)', () => {
|
|||||||
fetchMock.mockReset();
|
fetchMock.mockReset();
|
||||||
fetchMock.mockResolvedValue(['sonicSimilarity']);
|
fetchMock.mockResolvedValue(['sonicSimilarity']);
|
||||||
reset();
|
reset();
|
||||||
|
useAuthStore.setState({
|
||||||
|
servers: [{
|
||||||
|
id: SID,
|
||||||
|
name: 'Probe',
|
||||||
|
url: 'https://music.example.com',
|
||||||
|
username: 'u',
|
||||||
|
password: 'p',
|
||||||
|
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||||
|
customHeadersApplyTo: 'public',
|
||||||
|
}],
|
||||||
|
} as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('probes once, caches the result, then skips on the next poll', async () => {
|
it('probes once, caches the result, then skips on the next poll', async () => {
|
||||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock.mock.calls[0]?.[3]).toMatchObject({
|
||||||
|
url: 'https://music.example.com',
|
||||||
|
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||||
|
});
|
||||||
await flush();
|
await flush();
|
||||||
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present');
|
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present');
|
||||||
|
|
||||||
|
|||||||
+56
-2
@@ -1,6 +1,9 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import md5 from 'md5';
|
import md5 from 'md5';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import type { ServerProfile } from '../store/authStoreTypes';
|
||||||
|
import { headersForServerRequest } from '../utils/server/serverHttpHeaders';
|
||||||
|
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
|
||||||
import {
|
import {
|
||||||
type InstantMixProbeResult,
|
type InstantMixProbeResult,
|
||||||
type SubsonicServerIdentity,
|
type SubsonicServerIdentity,
|
||||||
@@ -19,6 +22,7 @@ import {
|
|||||||
api,
|
api,
|
||||||
apiWithCredentials,
|
apiWithCredentials,
|
||||||
secureRandomSalt,
|
secureRandomSalt,
|
||||||
|
type ServerHttpHeaderProfile,
|
||||||
} from './subsonicClient';
|
} from './subsonicClient';
|
||||||
import type { PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
|
import type { PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
|
||||||
|
|
||||||
@@ -61,6 +65,47 @@ export async function pingWithCredentials(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Profile-aware ping for connect probe — attaches custom headers per endpoint. */
|
||||||
|
export async function pingWithCredentialsForProfile(
|
||||||
|
profile: Pick<
|
||||||
|
ServerProfile,
|
||||||
|
'url' | 'alternateUrl' | 'username' | 'password' | 'customHeaders' | 'customHeadersApplyTo'
|
||||||
|
>,
|
||||||
|
endpointBaseUrl: string,
|
||||||
|
): Promise<PingWithCredentialsResult> {
|
||||||
|
try {
|
||||||
|
const base = endpointBaseUrl.startsWith('http')
|
||||||
|
? endpointBaseUrl.replace(/\/$/, '')
|
||||||
|
: `http://${endpointBaseUrl.replace(/\/$/, '')}`;
|
||||||
|
const salt = secureRandomSalt();
|
||||||
|
const token = md5(profile.password + salt);
|
||||||
|
const resp = await axios.get(`${base}/rest/ping.view`, {
|
||||||
|
params: {
|
||||||
|
u: profile.username,
|
||||||
|
t: token,
|
||||||
|
s: salt,
|
||||||
|
v: '1.16.1',
|
||||||
|
c: SUBSONIC_CLIENT,
|
||||||
|
f: 'json',
|
||||||
|
},
|
||||||
|
headers: headersForServerRequest(profile, endpointBaseUrl),
|
||||||
|
paramsSerializer: { indexes: null },
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
const data = resp.data?.['subsonic-response'];
|
||||||
|
const ok = data?.status === 'ok';
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
type: typeof data?.type === 'string' ? data.type : undefined,
|
||||||
|
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
|
||||||
|
openSubsonic: data?.openSubsonic === true,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[psysonic] pingWithCredentialsForProfile failed:', endpointBaseUrl, err);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const INSTANT_MIX_PROBE_RANDOM_SIZE = 8;
|
const INSTANT_MIX_PROBE_RANDOM_SIZE = 8;
|
||||||
const INSTANT_MIX_PROBE_SIMILAR_COUNT = 12;
|
const INSTANT_MIX_PROBE_SIMILAR_COUNT = 12;
|
||||||
const INSTANT_MIX_PROBE_MAX_TRACKS = 4;
|
const INSTANT_MIX_PROBE_MAX_TRACKS = 4;
|
||||||
@@ -74,6 +119,7 @@ export async function probeInstantMixWithCredentials(
|
|||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
headerProfile?: ServerHttpHeaderProfile,
|
||||||
): Promise<InstantMixProbeResult> {
|
): Promise<InstantMixProbeResult> {
|
||||||
try {
|
try {
|
||||||
const data = await apiWithCredentials<{ randomSongs: { song: SubsonicSong | SubsonicSong[] } }>(
|
const data = await apiWithCredentials<{ randomSongs: { song: SubsonicSong | SubsonicSong[] } }>(
|
||||||
@@ -83,6 +129,7 @@ export async function probeInstantMixWithCredentials(
|
|||||||
'getRandomSongs.view',
|
'getRandomSongs.view',
|
||||||
{ size: INSTANT_MIX_PROBE_RANDOM_SIZE, _t: Date.now() },
|
{ size: INSTANT_MIX_PROBE_RANDOM_SIZE, _t: Date.now() },
|
||||||
12000,
|
12000,
|
||||||
|
headerProfile,
|
||||||
);
|
);
|
||||||
const raw = data.randomSongs?.song;
|
const raw = data.randomSongs?.song;
|
||||||
const songs: SubsonicSong[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
|
const songs: SubsonicSong[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
|
||||||
@@ -98,6 +145,7 @@ export async function probeInstantMixWithCredentials(
|
|||||||
'getSimilarSongs.view',
|
'getSimilarSongs.view',
|
||||||
{ id: song.id, count: INSTANT_MIX_PROBE_SIMILAR_COUNT },
|
{ id: song.id, count: INSTANT_MIX_PROBE_SIMILAR_COUNT },
|
||||||
12000,
|
12000,
|
||||||
|
headerProfile,
|
||||||
);
|
);
|
||||||
const sRaw = simData.similarSongs?.song;
|
const sRaw = simData.similarSongs?.song;
|
||||||
const list: SubsonicSong[] = !sRaw ? [] : Array.isArray(sRaw) ? sRaw : [sRaw];
|
const list: SubsonicSong[] = !sRaw ? [] : Array.isArray(sRaw) ? sRaw : [sRaw];
|
||||||
@@ -138,6 +186,7 @@ export function scheduleInstantMixProbeForServer(
|
|||||||
const ctx = buildCapabilityContext(identity);
|
const ctx = buildCapabilityContext(identity);
|
||||||
const probeIds = neededProbeIds(SERVER_CAPABILITY_CATALOG, ctx);
|
const probeIds = neededProbeIds(SERVER_CAPABILITY_CATALOG, ctx);
|
||||||
const store = useAuthStore.getState();
|
const store = useAuthStore.getState();
|
||||||
|
const headerProfile = findServerByIdOrIndexKey(serverId);
|
||||||
|
|
||||||
if (probeIds.has(PROBE_OPENSUBSONIC_EXTENSIONS)) {
|
if (probeIds.has(PROBE_OPENSUBSONIC_EXTENSIONS)) {
|
||||||
// One `getOpenSubsonicExtensions` fetch answers every extension-gated feature.
|
// One `getOpenSubsonicExtensions` fetch answers every extension-gated feature.
|
||||||
@@ -153,7 +202,12 @@ export function scheduleInstantMixProbeForServer(
|
|||||||
const audiomuseStale = audiomuseEligible && (cached === undefined || cached === 'error');
|
const audiomuseStale = audiomuseEligible && (cached === undefined || cached === 'error');
|
||||||
if (force || listMissing || audiomuseStale) {
|
if (force || listMissing || audiomuseStale) {
|
||||||
if (audiomuseEligible) store.setAudiomusePluginProbe(serverId, 'probing');
|
if (audiomuseEligible) store.setAudiomusePluginProbe(serverId, 'probing');
|
||||||
void fetchOpenSubsonicExtensionsWithCredentials(serverUrl, username, password).then(extensions => {
|
void fetchOpenSubsonicExtensionsWithCredentials(
|
||||||
|
serverUrl,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
headerProfile,
|
||||||
|
).then(extensions => {
|
||||||
const st = useAuthStore.getState();
|
const st = useAuthStore.getState();
|
||||||
if (extensions === null) {
|
if (extensions === null) {
|
||||||
if (audiomuseEligible) st.setAudiomusePluginProbe(serverId, 'error');
|
if (audiomuseEligible) st.setAudiomusePluginProbe(serverId, 'error');
|
||||||
@@ -170,7 +224,7 @@ export function scheduleInstantMixProbeForServer(
|
|||||||
if (probeIds.has(PROBE_LEGACY_INSTANT_MIX)) {
|
if (probeIds.has(PROBE_LEGACY_INSTANT_MIX)) {
|
||||||
const cached = store.instantMixProbeByServer[serverId];
|
const cached = store.instantMixProbeByServer[serverId];
|
||||||
if (force || cached === undefined || cached === 'error') {
|
if (force || cached === undefined || cached === 'error') {
|
||||||
void probeInstantMixWithCredentials(serverUrl, username, password).then(result =>
|
void probeInstantMixWithCredentials(serverUrl, username, password, headerProfile).then(result =>
|
||||||
useAuthStore.getState().setInstantMixProbe(serverId, result),
|
useAuthStore.getState().setInstantMixProbe(serverId, result),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ import { version } from '../../package.json';
|
|||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import type { ServerProfile } from '../store/authStoreTypes';
|
import type { ServerProfile } from '../store/authStoreTypes';
|
||||||
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
|
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
|
||||||
|
import { headersForServerRequest } from '../utils/server/serverHttpHeaders';
|
||||||
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup';
|
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup';
|
||||||
|
|
||||||
export const SUBSONIC_CLIENT = `psysonic/${version}`;
|
export const SUBSONIC_CLIENT = `psysonic/${version}`;
|
||||||
|
|
||||||
|
/** Subset of `ServerProfile` needed to attach gate headers on credential-based REST calls. */
|
||||||
|
export type ServerHttpHeaderProfile = Pick<
|
||||||
|
ServerProfile,
|
||||||
|
'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
|
||||||
|
>;
|
||||||
|
|
||||||
export function secureRandomSalt(): string {
|
export function secureRandomSalt(): string {
|
||||||
const buf = new Uint8Array(8);
|
const buf = new Uint8Array(8);
|
||||||
crypto.getRandomValues(buf);
|
crypto.getRandomValues(buf);
|
||||||
@@ -32,10 +39,13 @@ export async function apiWithCredentials<T>(
|
|||||||
endpoint: string,
|
endpoint: string,
|
||||||
extra: Record<string, unknown> = {},
|
extra: Record<string, unknown> = {},
|
||||||
timeout = 15000,
|
timeout = 15000,
|
||||||
|
headerProfile?: ServerHttpHeaderProfile,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const params = { ...getAuthParams(username, password), ...extra };
|
const params = { ...getAuthParams(username, password), ...extra };
|
||||||
|
const headers = headerProfile ? headersForServerRequest(headerProfile, serverUrl) : {};
|
||||||
const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, {
|
const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, {
|
||||||
params,
|
params,
|
||||||
|
headers,
|
||||||
paramsSerializer: { indexes: null },
|
paramsSerializer: { indexes: null },
|
||||||
timeout,
|
timeout,
|
||||||
});
|
});
|
||||||
@@ -78,6 +88,7 @@ export async function apiForServer<T>(
|
|||||||
endpoint,
|
endpoint,
|
||||||
extra,
|
extra,
|
||||||
timeout,
|
timeout,
|
||||||
|
server,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +99,13 @@ export async function api<T>(
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { baseUrl, params } = getClient();
|
const { baseUrl, params } = getClient();
|
||||||
|
const server = useAuthStore.getState().getActiveServer();
|
||||||
|
const connectBase = useAuthStore.getState().getBaseUrl();
|
||||||
|
const headers =
|
||||||
|
server && connectBase ? headersForServerRequest(server, connectBase) : {};
|
||||||
const resp = await axios.get(`${baseUrl}/${endpoint}`, {
|
const resp = await axios.get(`${baseUrl}/${endpoint}`, {
|
||||||
params: { ...params, ...extra },
|
params: { ...params, ...extra },
|
||||||
|
headers,
|
||||||
paramsSerializer: { indexes: null },
|
paramsSerializer: { indexes: null },
|
||||||
timeout,
|
timeout,
|
||||||
signal,
|
signal,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiWithCredentials } from './subsonicClient';
|
import { apiWithCredentials, type ServerHttpHeaderProfile } from './subsonicClient';
|
||||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
|
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
|
||||||
|
|
||||||
export async function getSongWithCredentials(
|
export async function getSongWithCredentials(
|
||||||
@@ -6,6 +6,7 @@ export async function getSongWithCredentials(
|
|||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
id: string,
|
id: string,
|
||||||
|
headerProfile?: ServerHttpHeaderProfile,
|
||||||
): Promise<SubsonicSong | null> {
|
): Promise<SubsonicSong | null> {
|
||||||
try {
|
try {
|
||||||
const data = await apiWithCredentials<{ song: SubsonicSong }>(
|
const data = await apiWithCredentials<{ song: SubsonicSong }>(
|
||||||
@@ -14,6 +15,8 @@ export async function getSongWithCredentials(
|
|||||||
password,
|
password,
|
||||||
'getSong.view',
|
'getSong.view',
|
||||||
{ id },
|
{ id },
|
||||||
|
15000,
|
||||||
|
headerProfile,
|
||||||
);
|
);
|
||||||
return data.song ?? null;
|
return data.song ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -26,6 +29,7 @@ export async function getAlbumWithCredentials(
|
|||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
id: string,
|
id: string,
|
||||||
|
headerProfile?: ServerHttpHeaderProfile,
|
||||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||||
const data = await apiWithCredentials<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(
|
const data = await apiWithCredentials<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(
|
||||||
serverUrl,
|
serverUrl,
|
||||||
@@ -33,6 +37,8 @@ export async function getAlbumWithCredentials(
|
|||||||
password,
|
password,
|
||||||
'getAlbum.view',
|
'getAlbum.view',
|
||||||
{ id },
|
{ id },
|
||||||
|
15000,
|
||||||
|
headerProfile,
|
||||||
);
|
);
|
||||||
const { song, ...album } = data.album;
|
const { song, ...album } = data.album;
|
||||||
return { album, songs: song ?? [] };
|
return { album, songs: song ?? [] };
|
||||||
@@ -43,6 +49,7 @@ export async function getArtistWithCredentials(
|
|||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
id: string,
|
id: string,
|
||||||
|
headerProfile?: ServerHttpHeaderProfile,
|
||||||
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
||||||
const data = await apiWithCredentials<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>(
|
const data = await apiWithCredentials<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>(
|
||||||
serverUrl,
|
serverUrl,
|
||||||
@@ -50,6 +57,8 @@ export async function getArtistWithCredentials(
|
|||||||
password,
|
password,
|
||||||
'getArtist.view',
|
'getArtist.view',
|
||||||
{ id },
|
{ id },
|
||||||
|
15000,
|
||||||
|
headerProfile,
|
||||||
);
|
);
|
||||||
const { album, ...artist } = data.artist;
|
const { album, ...artist } = data.artist;
|
||||||
return { artist, albums: album ?? [] };
|
return { artist, albums: album ?? [] };
|
||||||
|
|||||||
@@ -69,4 +69,15 @@ describe('fetchOpenSubsonicExtensionsWithCredentials', () => {
|
|||||||
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
|
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
|
||||||
).resolves.toBeNull();
|
).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends custom gate headers when a header profile is supplied', async () => {
|
||||||
|
vi.mocked(axios.get).mockResolvedValue(okExtensions([]));
|
||||||
|
await fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p', {
|
||||||
|
url: 'https://music.test',
|
||||||
|
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||||
|
customHeadersApplyTo: 'public',
|
||||||
|
});
|
||||||
|
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
|
||||||
|
expect(config.headers?.['CF-Access-Client-Secret']).toBe('gate-secret');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiWithCredentials } from './subsonicClient';
|
import { apiWithCredentials, type ServerHttpHeaderProfile } from './subsonicClient';
|
||||||
|
|
||||||
export interface OpenSubsonicExtension {
|
export interface OpenSubsonicExtension {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -30,6 +30,7 @@ export async function fetchOpenSubsonicExtensionsWithCredentials(
|
|||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
headerProfile?: ServerHttpHeaderProfile,
|
||||||
): Promise<string[] | null> {
|
): Promise<string[] | null> {
|
||||||
try {
|
try {
|
||||||
const data = await apiWithCredentials<{ openSubsonicExtensions?: unknown }>(
|
const data = await apiWithCredentials<{ openSubsonicExtensions?: unknown }>(
|
||||||
@@ -39,6 +40,7 @@ export async function fetchOpenSubsonicExtensionsWithCredentials(
|
|||||||
'getOpenSubsonicExtensions.view',
|
'getOpenSubsonicExtensions.view',
|
||||||
{},
|
{},
|
||||||
12000,
|
12000,
|
||||||
|
headerProfile,
|
||||||
);
|
);
|
||||||
return parseOpenSubsonicExtensions(data.openSubsonicExtensions).map(ext => ext.name);
|
return parseOpenSubsonicExtensions(data.openSubsonicExtensions).map(ext => ext.name);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ vi.mock('@/api/subsonic', () => ({
|
|||||||
serverVersion: '0.55.0',
|
serverVersion: '0.55.0',
|
||||||
openSubsonic: true,
|
openSubsonic: true,
|
||||||
})),
|
})),
|
||||||
|
pingWithCredentialsForProfile: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: 'navidrome',
|
||||||
|
serverVersion: '0.55.0',
|
||||||
|
openSubsonic: true,
|
||||||
|
})),
|
||||||
scheduleInstantMixProbeForServer: vi.fn(),
|
scheduleInstantMixProbeForServer: vi.fn(),
|
||||||
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||||
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
||||||
|
|||||||
@@ -143,3 +143,33 @@ describe('AddServerForm — dual-address behaviour', () => {
|
|||||||
expect(arg).not.toHaveProperty('shareUsesLocalUrl');
|
expect(arg).not.toHaveProperty('shareUsesLocalUrl');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('AddServerForm — custom HTTP headers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes configured custom headers on save', async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
renderWithProviders(<AddServerForm onSave={onSave} onCancel={vi.fn()} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const inputs = screen.getAllByRole('textbox');
|
||||||
|
await user.type(inputs[1]!, 'https://music.example.com');
|
||||||
|
await user.type(inputs[3]!, 'tester');
|
||||||
|
await user.type(screen.getByPlaceholderText('••••••••'), 'pw');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /custom http headers/i }));
|
||||||
|
const headerNameInputs = screen.getAllByPlaceholderText(/header name/i);
|
||||||
|
const headerValueInputs = screen.getAllByPlaceholderText(/header value/i);
|
||||||
|
await user.type(headerNameInputs[0]!, 'CF-Access-Client-Secret');
|
||||||
|
await user.type(headerValueInputs[0]!, 'gate-secret');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Add' }));
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1);
|
||||||
|
const arg = onSave.mock.calls[0]![0];
|
||||||
|
expect(arg.customHeaders).toEqual([{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }]);
|
||||||
|
expect(arg.customHeadersApplyTo).toBe('public');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
import type { CustomHeaderEntry, CustomHeadersApplyTo, ServerProfile } from '../../store/authStoreTypes';
|
||||||
import { showToast } from '../../utils/ui/toast';
|
import { showToast } from '../../utils/ui/toast';
|
||||||
|
import {
|
||||||
|
DEFAULT_CUSTOM_HEADERS_APPLY_TO,
|
||||||
|
validateCustomHeaders,
|
||||||
|
} from '../../utils/server/serverHttpHeaders';
|
||||||
import {
|
import {
|
||||||
decodeServerMagicString,
|
decodeServerMagicString,
|
||||||
encodeServerMagicString,
|
encodeServerMagicString,
|
||||||
@@ -19,6 +23,9 @@ type FormState = {
|
|||||||
shareUsesLocalUrl: boolean;
|
shareUsesLocalUrl: boolean;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
customHeaders: CustomHeaderEntry[];
|
||||||
|
customHeadersApplyTo: CustomHeadersApplyTo;
|
||||||
|
customHeadersOpen: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Hostname for the DNS-based form hint, or null when the input is a literal IP. */
|
/** Hostname for the DNS-based form hint, or null when the input is a literal IP. */
|
||||||
@@ -62,6 +69,12 @@ export function AddServerForm({
|
|||||||
shareUsesLocalUrl: editingServer.shareUsesLocalUrl ?? false,
|
shareUsesLocalUrl: editingServer.shareUsesLocalUrl ?? false,
|
||||||
username: editingServer.username,
|
username: editingServer.username,
|
||||||
password: editingServer.password,
|
password: editingServer.password,
|
||||||
|
customHeaders: editingServer.customHeaders?.length
|
||||||
|
? editingServer.customHeaders.map(h => ({ ...h }))
|
||||||
|
: [{ name: '', value: '' }],
|
||||||
|
customHeadersApplyTo:
|
||||||
|
editingServer.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO,
|
||||||
|
customHeadersOpen: Boolean(editingServer.customHeaders?.length),
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -70,6 +83,9 @@ export function AddServerForm({
|
|||||||
shareUsesLocalUrl: false,
|
shareUsesLocalUrl: false,
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
customHeaders: [{ name: '', value: '' }],
|
||||||
|
customHeadersApplyTo: DEFAULT_CUSTOM_HEADERS_APPLY_TO,
|
||||||
|
customHeadersOpen: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const [magicString, setMagicString] = useState('');
|
const [magicString, setMagicString] = useState('');
|
||||||
@@ -175,6 +191,20 @@ export function AddServerForm({
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const customHeadersPayload = (): Pick<
|
||||||
|
ServerProfile,
|
||||||
|
'customHeaders' | 'customHeadersApplyTo'
|
||||||
|
> => {
|
||||||
|
const rows = form.customHeaders
|
||||||
|
.map(h => ({ name: h.name.trim(), value: h.value }))
|
||||||
|
.filter(h => h.name || h.value);
|
||||||
|
if (!rows.length) return {};
|
||||||
|
return {
|
||||||
|
customHeaders: rows,
|
||||||
|
customHeadersApplyTo: form.customHeadersApplyTo,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
const ms = magicString.trim();
|
const ms = magicString.trim();
|
||||||
if (ms) {
|
if (ms) {
|
||||||
@@ -193,6 +223,7 @@ export function AddServerForm({
|
|||||||
url: decoded.url,
|
url: decoded.url,
|
||||||
username: decoded.username,
|
username: decoded.username,
|
||||||
password: decoded.password,
|
password: decoded.password,
|
||||||
|
...customHeadersPayload(),
|
||||||
...(altDecoded
|
...(altDecoded
|
||||||
? {
|
? {
|
||||||
alternateUrl: altDecoded,
|
alternateUrl: altDecoded,
|
||||||
@@ -205,6 +236,15 @@ export function AddServerForm({
|
|||||||
if (!form.url.trim()) return;
|
if (!form.url.trim()) return;
|
||||||
if (!validateAddresses()) return;
|
if (!validateAddresses()) return;
|
||||||
|
|
||||||
|
const headerValidation = validateCustomHeaders(
|
||||||
|
form.customHeaders.filter(h => h.name.trim() || h.value),
|
||||||
|
);
|
||||||
|
if (!headerValidation.ok) {
|
||||||
|
const first = headerValidation.fieldErrors[0];
|
||||||
|
showToast(t(first.messageKey, { defaultValue: first.messageKey }), 5000, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const altTrimmed = form.alternateUrl.trim();
|
const altTrimmed = form.alternateUrl.trim();
|
||||||
// If the user clears the second address, strip the share-flag as well so
|
// If the user clears the second address, strip the share-flag as well so
|
||||||
// we don't leave a dangling preference (spec §5.3 last row).
|
// we don't leave a dangling preference (spec §5.3 last row).
|
||||||
@@ -213,6 +253,7 @@ export function AddServerForm({
|
|||||||
url: form.url.trim(),
|
url: form.url.trim(),
|
||||||
username: form.username.trim(),
|
username: form.username.trim(),
|
||||||
password: form.password,
|
password: form.password,
|
||||||
|
...customHeadersPayload(),
|
||||||
...(altTrimmed
|
...(altTrimmed
|
||||||
? {
|
? {
|
||||||
alternateUrl: altTrimmed,
|
alternateUrl: altTrimmed,
|
||||||
@@ -338,6 +379,108 @@ export function AddServerForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 13, padding: '4px 0' }}
|
||||||
|
onClick={() => setForm(f => ({ ...f, customHeadersOpen: !f.customHeadersOpen }))}
|
||||||
|
>
|
||||||
|
{form.customHeadersOpen ? '▾' : '▸'} {t('settings.customHeadersTitle')}
|
||||||
|
</button>
|
||||||
|
{form.customHeadersOpen && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<p style={{ fontSize: 11, opacity: 0.75, margin: '0 0 8px' }}>
|
||||||
|
{t('settings.customHeadersHelp')}
|
||||||
|
</p>
|
||||||
|
{form.customHeaders.map((row, index) => (
|
||||||
|
<div key={index} className="form-row" style={{ marginBottom: 6, gap: 8 }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
value={row.name}
|
||||||
|
onChange={e => {
|
||||||
|
const name = e.target.value;
|
||||||
|
setForm(f => {
|
||||||
|
const customHeaders = f.customHeaders.map((h, i) =>
|
||||||
|
i === index ? { ...h, name } : h,
|
||||||
|
);
|
||||||
|
return { ...f, customHeaders };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder={t('settings.customHeadersNamePlaceholder')}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={row.value}
|
||||||
|
onChange={e => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setForm(f => ({
|
||||||
|
...f,
|
||||||
|
customHeaders: f.customHeaders.map((h, i) =>
|
||||||
|
i === index ? { ...h, value } : h,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder={t('settings.customHeadersValuePlaceholder')}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
aria-label={t('settings.customHeadersRemoveRow')}
|
||||||
|
onClick={() =>
|
||||||
|
setForm(f => ({
|
||||||
|
...f,
|
||||||
|
customHeaders:
|
||||||
|
f.customHeaders.length <= 1
|
||||||
|
? [{ name: '', value: '' }]
|
||||||
|
: f.customHeaders.filter((_, i) => i !== index),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 12, marginBottom: 8 }}
|
||||||
|
onClick={() =>
|
||||||
|
setForm(f => ({
|
||||||
|
...f,
|
||||||
|
customHeaders: [...f.customHeaders, { name: '', value: '' }],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('settings.customHeadersAddRow')}
|
||||||
|
</button>
|
||||||
|
<fieldset
|
||||||
|
disabled={!form.customHeaders.some(h => h.name.trim() || h.value)}
|
||||||
|
style={{ border: 'none', padding: 0, margin: 0 }}
|
||||||
|
>
|
||||||
|
<legend style={{ fontSize: 12, marginBottom: 4 }}>{t('settings.customHeadersApplyTo')}</legend>
|
||||||
|
{(['public', 'local', 'both'] as const).map(kind => (
|
||||||
|
<label key={kind} style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="customHeadersApplyTo"
|
||||||
|
checked={form.customHeadersApplyTo === kind}
|
||||||
|
onChange={() => setForm(f => ({ ...f, customHeadersApplyTo: kind }))}
|
||||||
|
/>{' '}
|
||||||
|
{t(`settings.customHeadersApplyTo_${kind}`)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
<p style={{ fontSize: 11, opacity: 0.65, marginTop: 6 }}>
|
||||||
|
{t('settings.customHeadersNotInShare')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{!isEdit && (
|
{!isEdit && (
|
||||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||||
<label style={{ fontSize: 13 }}>{t('login.orMagicString')}</label>
|
<label style={{ fontSize: 13 }}>{t('login.orMagicString')}</label>
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import { bootstrapIndexedServer } from '../../utils/library/librarySession';
|
|||||||
import { useLibraryIndexSync } from '../../hooks/useLibraryIndexSync';
|
import { useLibraryIndexSync } from '../../hooks/useLibraryIndexSync';
|
||||||
import ServerLibraryIndexControls from './ServerLibraryIndexControls';
|
import ServerLibraryIndexControls from './ServerLibraryIndexControls';
|
||||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic';
|
import { pingWithCredentialsForProfile, scheduleInstantMixProbeForServer } from '../../api/subsonic';
|
||||||
|
import {
|
||||||
|
clearServerHttpContext,
|
||||||
|
syncServerHttpContextForProfile,
|
||||||
|
} from '../../utils/server/syncServerHttpContext';
|
||||||
import { useDragDrop } from '../../contexts/DragDropContext';
|
import { useDragDrop } from '../../contexts/DragDropContext';
|
||||||
import { type ServerMagicPayload } from '../../utils/server/serverMagicString';
|
import { type ServerMagicPayload } from '../../utils/server/serverMagicString';
|
||||||
import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '../../utils/server/serverEndpoint';
|
import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '../../utils/server/serverEndpoint';
|
||||||
@@ -191,6 +195,7 @@ export function ServersTab({
|
|||||||
|
|
||||||
auth.removeServer(server.id);
|
auth.removeServer(server.id);
|
||||||
try {
|
try {
|
||||||
|
await clearServerHttpContext(server);
|
||||||
await librarySyncClearSession(server.id);
|
await librarySyncClearSession(server.id);
|
||||||
if (purgeLibrary) {
|
if (purgeLibrary) {
|
||||||
await libraryDeleteServerData(server.id);
|
await libraryDeleteServerData(server.id);
|
||||||
@@ -238,7 +243,12 @@ export function ServersTab({
|
|||||||
// straight to the legacy ping (which is also the connect-test).
|
// straight to the legacy ping (which is also the connect-test).
|
||||||
if (data.alternateUrl) {
|
if (data.alternateUrl) {
|
||||||
const verify = await verifySameServerEndpoints(
|
const verify = await verifySameServerEndpoints(
|
||||||
{ url: data.url, alternateUrl: data.alternateUrl },
|
{
|
||||||
|
url: data.url,
|
||||||
|
alternateUrl: data.alternateUrl,
|
||||||
|
customHeaders: data.customHeaders,
|
||||||
|
customHeadersApplyTo: data.customHeadersApplyTo,
|
||||||
|
},
|
||||||
data.username,
|
data.username,
|
||||||
data.password,
|
data.password,
|
||||||
);
|
);
|
||||||
@@ -247,7 +257,7 @@ export function ServersTab({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const ping = await pingWithCredentials(data.url, data.username, data.password);
|
const ping = await pingWithCredentialsForProfile(data, data.url);
|
||||||
if (ping.ok) {
|
if (ping.ok) {
|
||||||
const id = auth.addServer(data);
|
const id = auth.addServer(data);
|
||||||
const identity = {
|
const identity = {
|
||||||
@@ -259,7 +269,10 @@ export function ServersTab({
|
|||||||
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity, true);
|
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity, true);
|
||||||
setConnStatus(s => ({ ...s, [id]: 'ok' }));
|
setConnStatus(s => ({ ...s, [id]: 'ok' }));
|
||||||
const added = useAuthStore.getState().servers.find(s => s.id === id);
|
const added = useAuthStore.getState().servers.find(s => s.id === id);
|
||||||
if (added) void bootstrapIndexedServer(added);
|
if (added) {
|
||||||
|
void syncServerHttpContextForProfile(added);
|
||||||
|
void bootstrapIndexedServer(added);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
|
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
|
||||||
}
|
}
|
||||||
@@ -323,7 +336,12 @@ export function ServersTab({
|
|||||||
if (dualAddressChanged) {
|
if (dualAddressChanged) {
|
||||||
setConnStatus(s => ({ ...s, [id]: 'testing' }));
|
setConnStatus(s => ({ ...s, [id]: 'testing' }));
|
||||||
const verify = await verifySameServerEndpoints(
|
const verify = await verifySameServerEndpoints(
|
||||||
{ url: data.url, alternateUrl: data.alternateUrl },
|
{
|
||||||
|
url: data.url,
|
||||||
|
alternateUrl: data.alternateUrl,
|
||||||
|
customHeaders: data.customHeaders,
|
||||||
|
customHeadersApplyTo: data.customHeadersApplyTo,
|
||||||
|
},
|
||||||
data.username,
|
data.username,
|
||||||
data.password,
|
data.password,
|
||||||
);
|
);
|
||||||
@@ -335,12 +353,13 @@ export function ServersTab({
|
|||||||
|
|
||||||
setEditingServerId(null);
|
setEditingServerId(null);
|
||||||
auth.updateServer(id, data);
|
auth.updateServer(id, data);
|
||||||
|
const updated = useAuthStore.getState().servers.find(s => s.id === id);
|
||||||
|
if (updated) void syncServerHttpContextForProfile(updated);
|
||||||
// Profile edited → any cached sticky connect URL for this id may now be
|
// Profile edited → any cached sticky connect URL for this id may now be
|
||||||
// stale (credentials may have changed, alternate may have been added).
|
|
||||||
invalidateReachableEndpointCache(id);
|
invalidateReachableEndpointCache(id);
|
||||||
setConnStatus(s => ({ ...s, [id]: 'testing' }));
|
setConnStatus(s => ({ ...s, [id]: 'testing' }));
|
||||||
try {
|
try {
|
||||||
const ping = await pingWithCredentials(data.url, data.username, data.password);
|
const ping = await pingWithCredentialsForProfile(data, data.url);
|
||||||
if (ping.ok) {
|
if (ping.ok) {
|
||||||
const identity = {
|
const identity = {
|
||||||
type: ping.type,
|
type: ping.type,
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ const CONTRIBUTOR_ENTRIES = [
|
|||||||
'Niri compositor tiling WM detection (PR #1127)',
|
'Niri compositor tiling WM detection (PR #1127)',
|
||||||
'Local library index: Navidrome ignored-articles artist/composer letter buckets (name_sort + server ignoredArticles), idempotent migration with safe open/swap and poisoned-lock recovery (PR #1145)',
|
'Local library index: Navidrome ignored-articles artist/composer letter buckets (name_sort + server ignoredArticles), idempotent migration with safe open/swap and poisoned-lock recovery (PR #1145)',
|
||||||
'All Albums browse: compilation and favorites filters in slice mode — skip redundant client comp filter, route favorites through getStarred2, pre-index page scan, offline isCompilation from tracks (PR #1151)',
|
'All Albums browse: compilation and favorites filters in slice mode — skip redundant client comp filter, route favorites through getStarred2, pre-index page scan, offline isCompilation from tracks (PR #1151)',
|
||||||
|
'Custom HTTP headers for reverse-proxy gates — per-server header editor, TS/Rust registry sync, full playback/sync/cover path coverage, log redaction (PR #1156)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
|
|
||||||
vi.mock('@/api/subsonic', () => ({
|
vi.mock('@/api/subsonic', () => ({
|
||||||
pingWithCredentials: vi.fn(),
|
pingWithCredentials: vi.fn(),
|
||||||
|
pingWithCredentialsForProfile: vi.fn(),
|
||||||
scheduleInstantMixProbeForServer: vi.fn(),
|
scheduleInstantMixProbeForServer: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ vi.mock('@/utils/perf/perfFlags', () => ({
|
|||||||
usePerfProbeFlags: () => ({ disableBackgroundPolling: false }),
|
usePerfProbeFlags: () => ({ disableBackgroundPolling: false }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { pingWithCredentials } from '@/api/subsonic';
|
import { pingWithCredentialsForProfile } from '@/api/subsonic';
|
||||||
import { useDevOfflineBrowseStore } from '@/store/devOfflineBrowseStore';
|
import { useDevOfflineBrowseStore } from '@/store/devOfflineBrowseStore';
|
||||||
import { useConnectionStatus } from './useConnectionStatus';
|
import { useConnectionStatus } from './useConnectionStatus';
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ beforeEach(() => {
|
|||||||
resetAuthStore();
|
resetAuthStore();
|
||||||
invalidateReachableEndpointCache();
|
invalidateReachableEndpointCache();
|
||||||
useDevOfflineBrowseStore.getState().setForceOffline(false);
|
useDevOfflineBrowseStore.getState().setForceOffline(false);
|
||||||
vi.mocked(pingWithCredentials).mockReset();
|
vi.mocked(pingWithCredentialsForProfile).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -49,7 +50,7 @@ describe('useConnectionStatus.isLan', () => {
|
|||||||
// LAN endpoint answers — alternateUrl is the LAN side here, so a
|
// LAN endpoint answers — alternateUrl is the LAN side here, so a
|
||||||
// primary-url-only check would say "public". We assert it says "local"
|
// primary-url-only check would say "public". We assert it says "local"
|
||||||
// (active endpoint kind).
|
// (active endpoint kind).
|
||||||
vi.mocked(pingWithCredentials).mockImplementation(async url =>
|
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
|
||||||
url === 'http://192.168.0.10'
|
url === 'http://192.168.0.10'
|
||||||
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
|
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
|
||||||
: { ok: false },
|
: { ok: false },
|
||||||
@@ -63,7 +64,7 @@ describe('useConnectionStatus.isLan', () => {
|
|||||||
it('falls back to public when only the public address answers', async () => {
|
it('falls back to public when only the public address answers', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
seedDualAddressServer();
|
seedDualAddressServer();
|
||||||
vi.mocked(pingWithCredentials).mockImplementation(async url =>
|
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
|
||||||
url === 'https://music.example.com'
|
url === 'https://music.example.com'
|
||||||
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
|
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
|
||||||
: { ok: false },
|
: { ok: false },
|
||||||
@@ -88,7 +89,7 @@ describe('useConnectionStatus.isLan', () => {
|
|||||||
seedDualAddressServer();
|
seedDualAddressServer();
|
||||||
// Don't resolve the ping — the hook is still in the `checking` state.
|
// Don't resolve the ping — the hook is still in the `checking` state.
|
||||||
let _resolve: ((v: PickReachableResult) => void) | null = null;
|
let _resolve: ((v: PickReachableResult) => void) | null = null;
|
||||||
vi.mocked(pingWithCredentials).mockReturnValue(
|
vi.mocked(pingWithCredentialsForProfile).mockReturnValue(
|
||||||
new Promise(r => {
|
new Promise(r => {
|
||||||
_resolve = ((res: PickReachableResult) => {
|
_resolve = ((res: PickReachableResult) => {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -116,7 +117,7 @@ describe('useConnectionStatus online event', () => {
|
|||||||
it('flushes the reachable-endpoint cache when the browser fires online', async () => {
|
it('flushes the reachable-endpoint cache when the browser fires online', async () => {
|
||||||
seedDualAddressServer();
|
seedDualAddressServer();
|
||||||
// Initial probe: LAN answers.
|
// Initial probe: LAN answers.
|
||||||
vi.mocked(pingWithCredentials).mockImplementation(async url =>
|
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
|
||||||
url === 'http://192.168.0.10'
|
url === 'http://192.168.0.10'
|
||||||
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
|
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
|
||||||
: { ok: false },
|
: { ok: false },
|
||||||
@@ -129,8 +130,8 @@ describe('useConnectionStatus online event', () => {
|
|||||||
// fire in this test; we trigger the online event instead. The handler
|
// fire in this test; we trigger the online event instead. The handler
|
||||||
// invalidates the sticky cache so the next probe goes LAN-first and
|
// invalidates the sticky cache so the next probe goes LAN-first and
|
||||||
// flips over to public when LAN refuses.
|
// flips over to public when LAN refuses.
|
||||||
vi.mocked(pingWithCredentials).mockClear();
|
vi.mocked(pingWithCredentialsForProfile).mockClear();
|
||||||
vi.mocked(pingWithCredentials).mockImplementation(async url =>
|
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
|
||||||
url === 'https://music.example.com'
|
url === 'https://music.example.com'
|
||||||
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
|
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
|
||||||
: { ok: false },
|
: { ok: false },
|
||||||
@@ -147,14 +148,14 @@ describe('useConnectionStatus online event', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(result.current.isLan).toBe(false));
|
await waitFor(() => expect(result.current.isLan).toBe(false));
|
||||||
// Both endpoints were probed (LAN refused, public answered).
|
// Both endpoints were probed (LAN refused, public answered).
|
||||||
expect(vi.mocked(pingWithCredentials).mock.calls.length).toBeGreaterThanOrEqual(2);
|
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('useConnectionStatus DEV offline toggle', () => {
|
describe('useConnectionStatus DEV offline toggle', () => {
|
||||||
it('does not probe again on mount beyond the polling effect', async () => {
|
it('does not probe again on mount beyond the polling effect', async () => {
|
||||||
seedDualAddressServer();
|
seedDualAddressServer();
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValue({
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
type: 'navidrome',
|
type: 'navidrome',
|
||||||
serverVersion: '0.55.0',
|
serverVersion: '0.55.0',
|
||||||
@@ -162,15 +163,15 @@ describe('useConnectionStatus DEV offline toggle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
renderHook(() => useConnectionStatus());
|
renderHook(() => useConnectionStatus());
|
||||||
await waitFor(() => expect(vi.mocked(pingWithCredentials).mock.calls.length).toBeGreaterThanOrEqual(1));
|
await waitFor(() => expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBeGreaterThanOrEqual(1));
|
||||||
const callsAfterMount = vi.mocked(pingWithCredentials).mock.calls.length;
|
const callsAfterMount = vi.mocked(pingWithCredentialsForProfile).mock.calls.length;
|
||||||
await new Promise(r => setTimeout(r, 20));
|
await new Promise(r => setTimeout(r, 20));
|
||||||
expect(vi.mocked(pingWithCredentials).mock.calls.length).toBe(callsAfterMount);
|
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBe(callsAfterMount);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disconnects on force-offline toggle without an extra probe', async () => {
|
it('disconnects on force-offline toggle without an extra probe', async () => {
|
||||||
seedDualAddressServer();
|
seedDualAddressServer();
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValue({
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
type: 'navidrome',
|
type: 'navidrome',
|
||||||
serverVersion: '0.55.0',
|
serverVersion: '0.55.0',
|
||||||
@@ -179,10 +180,10 @@ describe('useConnectionStatus DEV offline toggle', () => {
|
|||||||
|
|
||||||
const { result } = renderHook(() => useConnectionStatus());
|
const { result } = renderHook(() => useConnectionStatus());
|
||||||
await waitFor(() => expect(result.current.status).toBe('connected'));
|
await waitFor(() => expect(result.current.status).toBe('connected'));
|
||||||
const callsBeforeToggle = vi.mocked(pingWithCredentials).mock.calls.length;
|
const callsBeforeToggle = vi.mocked(pingWithCredentialsForProfile).mock.calls.length;
|
||||||
|
|
||||||
act(() => useDevOfflineBrowseStore.getState().setForceOffline(true));
|
act(() => useDevOfflineBrowseStore.getState().setForceOffline(true));
|
||||||
await waitFor(() => expect(result.current.status).toBe('disconnected'));
|
await waitFor(() => expect(result.current.status).toBe('disconnected'));
|
||||||
expect(vi.mocked(pingWithCredentials).mock.calls.length).toBe(callsBeforeToggle);
|
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBe(callsBeforeToggle);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ export const settings = {
|
|||||||
shareUsesLocalUrlDesc: 'Wirkt sich auf Orbit-Einladungen und Bibliotheks-Freigaben aus. Standard: öffentliche Adresse.',
|
shareUsesLocalUrlDesc: 'Wirkt sich auf Orbit-Einladungen und Bibliotheks-Freigaben aus. Standard: öffentliche Adresse.',
|
||||||
serverUsername: 'Benutzername',
|
serverUsername: 'Benutzername',
|
||||||
serverPassword: 'Passwort',
|
serverPassword: 'Passwort',
|
||||||
|
customHeadersTitle: 'Benutzerdefinierte HTTP-Header',
|
||||||
|
customHeadersHelp: 'Für Reverse-Proxy-Gates (Pangolin, Cloudflare Access). Werden nur an deinen Server gesendet — nicht an Drittanbieter.',
|
||||||
|
customHeadersNamePlaceholder: 'Header-Name',
|
||||||
|
customHeadersValuePlaceholder: 'Header-Wert',
|
||||||
|
customHeadersAddRow: 'Header hinzufügen',
|
||||||
|
customHeadersRemoveRow: 'Header entfernen',
|
||||||
|
customHeadersApplyTo: 'Header anwenden auf',
|
||||||
|
customHeadersApplyTo_public: 'Öffentliche Adresse',
|
||||||
|
customHeadersApplyTo_local: 'Lokale Adresse',
|
||||||
|
customHeadersApplyTo_both: 'Beide Adressen',
|
||||||
|
customHeadersNotInShare: 'Benutzerdefinierte Header sind nicht in Freigabe-Einladungen enthalten — auf jedem Gerät erneut konfigurieren.',
|
||||||
|
'customHeadersValidation.tooMany': 'Zu viele benutzerdefinierte Header (max. 16).',
|
||||||
|
'customHeadersValidation.nameRequired': 'Header-Name ist erforderlich.',
|
||||||
|
'customHeadersValidation.nameTooLong': 'Header-Name ist zu lang.',
|
||||||
|
'customHeadersValidation.valueTooLong': 'Header-Wert ist zu lang.',
|
||||||
|
'customHeadersValidation.crlf': 'Header-Namen und -Werte dürfen keine Zeilenumbrüche enthalten.',
|
||||||
|
'customHeadersValidation.blocked': 'Dieser Header-Name ist nicht erlaubt.',
|
||||||
|
'customHeadersValidation.duplicate': 'Doppelter Header-Name.',
|
||||||
addServer: 'Server hinzufügen',
|
addServer: 'Server hinzufügen',
|
||||||
addServerTitle: 'Neuen Server hinzufügen',
|
addServerTitle: 'Neuen Server hinzufügen',
|
||||||
editServer: 'Bearbeiten',
|
editServer: 'Bearbeiten',
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ export const settings = {
|
|||||||
shareUsesLocalUrlDesc: 'Affects Orbit invites and library shares. Default: public address.',
|
shareUsesLocalUrlDesc: 'Affects Orbit invites and library shares. Default: public address.',
|
||||||
serverUsername: 'Username',
|
serverUsername: 'Username',
|
||||||
serverPassword: 'Password',
|
serverPassword: 'Password',
|
||||||
|
customHeadersTitle: 'Custom HTTP headers',
|
||||||
|
customHeadersHelp: 'For reverse-proxy gates (Pangolin, Cloudflare Access). Sent only to your server — not to third-party services.',
|
||||||
|
customHeadersNamePlaceholder: 'Header name',
|
||||||
|
customHeadersValuePlaceholder: 'Header value',
|
||||||
|
customHeadersAddRow: 'Add header',
|
||||||
|
customHeadersRemoveRow: 'Remove header',
|
||||||
|
customHeadersApplyTo: 'Apply headers to',
|
||||||
|
customHeadersApplyTo_public: 'Public address',
|
||||||
|
customHeadersApplyTo_local: 'Local address',
|
||||||
|
customHeadersApplyTo_both: 'Both addresses',
|
||||||
|
customHeadersNotInShare: 'Custom headers are not included in share invites — configure them again on each device.',
|
||||||
|
'customHeadersValidation.tooMany': 'Too many custom headers (max 16).',
|
||||||
|
'customHeadersValidation.nameRequired': 'Header name is required.',
|
||||||
|
'customHeadersValidation.nameTooLong': 'Header name is too long.',
|
||||||
|
'customHeadersValidation.valueTooLong': 'Header value is too long.',
|
||||||
|
'customHeadersValidation.crlf': 'Header names and values cannot contain line breaks.',
|
||||||
|
'customHeadersValidation.blocked': 'This header name is not allowed.',
|
||||||
|
'customHeadersValidation.duplicate': 'Duplicate header name.',
|
||||||
addServer: 'Add Server',
|
addServer: 'Add Server',
|
||||||
addServerTitle: 'Add New Server',
|
addServerTitle: 'Add New Server',
|
||||||
editServer: 'Edit',
|
editServer: 'Edit',
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ export const settings = {
|
|||||||
shareUsesLocalUrlDesc: 'Afecta a las invitaciones de Orbit y a los enlaces de la biblioteca. Por defecto: dirección pública.',
|
shareUsesLocalUrlDesc: 'Afecta a las invitaciones de Orbit y a los enlaces de la biblioteca. Por defecto: dirección pública.',
|
||||||
serverUsername: 'Usuario',
|
serverUsername: 'Usuario',
|
||||||
serverPassword: 'Contraseña',
|
serverPassword: 'Contraseña',
|
||||||
|
customHeadersTitle: 'Encabezados HTTP personalizados',
|
||||||
|
customHeadersHelp: 'Para puertas de reverse proxy (Pangolin, Cloudflare Access). Se envían solo a tu servidor — no a servicios de terceros.',
|
||||||
|
customHeadersNamePlaceholder: 'Nombre del encabezado',
|
||||||
|
customHeadersValuePlaceholder: 'Valor del encabezado',
|
||||||
|
customHeadersAddRow: 'Añadir encabezado',
|
||||||
|
customHeadersRemoveRow: 'Eliminar encabezado',
|
||||||
|
customHeadersApplyTo: 'Aplicar encabezados a',
|
||||||
|
customHeadersApplyTo_public: 'Dirección pública',
|
||||||
|
customHeadersApplyTo_local: 'Dirección local',
|
||||||
|
customHeadersApplyTo_both: 'Ambas direcciones',
|
||||||
|
customHeadersNotInShare: 'Los encabezados personalizados no se incluyen en las invitaciones — configúralos de nuevo en cada dispositivo.',
|
||||||
|
'customHeadersValidation.tooMany': 'Demasiados encabezados personalizados (máx. 16).',
|
||||||
|
'customHeadersValidation.nameRequired': 'El nombre del encabezado es obligatorio.',
|
||||||
|
'customHeadersValidation.nameTooLong': 'El nombre del encabezado es demasiado largo.',
|
||||||
|
'customHeadersValidation.valueTooLong': 'El valor del encabezado es demasiado largo.',
|
||||||
|
'customHeadersValidation.crlf': 'Los nombres y valores no pueden contener saltos de línea.',
|
||||||
|
'customHeadersValidation.blocked': 'Este nombre de encabezado no está permitido.',
|
||||||
|
'customHeadersValidation.duplicate': 'Nombre de encabezado duplicado.',
|
||||||
addServer: 'Agregar Servidor',
|
addServer: 'Agregar Servidor',
|
||||||
addServerTitle: 'Agregar Nuevo Servidor',
|
addServerTitle: 'Agregar Nuevo Servidor',
|
||||||
editServer: 'Editar',
|
editServer: 'Editar',
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ export const settings = {
|
|||||||
shareUsesLocalUrlDesc: 'Affecte les invitations Orbit et les partages de bibliothèque. Par défaut : adresse publique.',
|
shareUsesLocalUrlDesc: 'Affecte les invitations Orbit et les partages de bibliothèque. Par défaut : adresse publique.',
|
||||||
serverUsername: 'Nom d\'utilisateur',
|
serverUsername: 'Nom d\'utilisateur',
|
||||||
serverPassword: 'Mot de passe',
|
serverPassword: 'Mot de passe',
|
||||||
|
customHeadersTitle: 'En-têtes HTTP personnalisés',
|
||||||
|
customHeadersHelp: 'Pour les passerelles reverse proxy (Pangolin, Cloudflare Access). Envoyés uniquement à votre serveur — pas aux services tiers.',
|
||||||
|
customHeadersNamePlaceholder: 'Nom de l’en-tête',
|
||||||
|
customHeadersValuePlaceholder: 'Valeur de l’en-tête',
|
||||||
|
customHeadersAddRow: 'Ajouter un en-tête',
|
||||||
|
customHeadersRemoveRow: 'Supprimer l’en-tête',
|
||||||
|
customHeadersApplyTo: 'Appliquer les en-têtes à',
|
||||||
|
customHeadersApplyTo_public: 'Adresse publique',
|
||||||
|
customHeadersApplyTo_local: 'Adresse locale',
|
||||||
|
customHeadersApplyTo_both: 'Les deux adresses',
|
||||||
|
customHeadersNotInShare: 'Les en-têtes personnalisés ne sont pas inclus dans les invitations — configurez-les à nouveau sur chaque appareil.',
|
||||||
|
'customHeadersValidation.tooMany': 'Trop d’en-têtes personnalisés (max. 16).',
|
||||||
|
'customHeadersValidation.nameRequired': 'Le nom de l’en-tête est requis.',
|
||||||
|
'customHeadersValidation.nameTooLong': 'Le nom de l’en-tête est trop long.',
|
||||||
|
'customHeadersValidation.valueTooLong': 'La valeur de l’en-tête est trop longue.',
|
||||||
|
'customHeadersValidation.crlf': 'Les noms et valeurs ne peuvent pas contenir de saut de ligne.',
|
||||||
|
'customHeadersValidation.blocked': 'Ce nom d’en-tête n’est pas autorisé.',
|
||||||
|
'customHeadersValidation.duplicate': 'Nom d’en-tête en double.',
|
||||||
addServer: 'Ajouter un serveur',
|
addServer: 'Ajouter un serveur',
|
||||||
addServerTitle: 'Ajouter un nouveau serveur',
|
addServerTitle: 'Ajouter un nouveau serveur',
|
||||||
editServer: 'Modifier',
|
editServer: 'Modifier',
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ export const settings = {
|
|||||||
shareUsesLocalUrlDesc: 'Érinti az Orbit-meghívókat és a könyvtármegosztásokat. Alapértelmezett: nyilvános cím.',
|
shareUsesLocalUrlDesc: 'Érinti az Orbit-meghívókat és a könyvtármegosztásokat. Alapértelmezett: nyilvános cím.',
|
||||||
serverUsername: 'Felhasználónév',
|
serverUsername: 'Felhasználónév',
|
||||||
serverPassword: 'Jelszó',
|
serverPassword: 'Jelszó',
|
||||||
|
customHeadersTitle: 'Egyéni HTTP fejlécek',
|
||||||
|
customHeadersHelp: 'Reverse-proxy kapukhoz (Pangolin, Cloudflare Access). Csak a szerveredhez kerülnek — harmadik fél szolgáltatásaihoz nem.',
|
||||||
|
customHeadersNamePlaceholder: 'Fejléc neve',
|
||||||
|
customHeadersValuePlaceholder: 'Fejléc értéke',
|
||||||
|
customHeadersAddRow: 'Fejléc hozzáadása',
|
||||||
|
customHeadersRemoveRow: 'Fejléc eltávolítása',
|
||||||
|
customHeadersApplyTo: 'Fejlécek alkalmazása',
|
||||||
|
customHeadersApplyTo_public: 'Nyilvános cím',
|
||||||
|
customHeadersApplyTo_local: 'Helyi cím',
|
||||||
|
customHeadersApplyTo_both: 'Mindkét cím',
|
||||||
|
customHeadersNotInShare: 'Az egyéni fejlécek nem szerepelnek a meghívókban — minden eszközön külön kell beállítani.',
|
||||||
|
'customHeadersValidation.tooMany': 'Túl sok egyéni fejléc (max. 16).',
|
||||||
|
'customHeadersValidation.nameRequired': 'A fejléc neve kötelező.',
|
||||||
|
'customHeadersValidation.nameTooLong': 'A fejléc neve túl hosszú.',
|
||||||
|
'customHeadersValidation.valueTooLong': 'A fejléc értéke túl hosszú.',
|
||||||
|
'customHeadersValidation.crlf': 'A név és az érték nem tartalmazhat sortörést.',
|
||||||
|
'customHeadersValidation.blocked': 'Ez a fejlécnév nem engedélyezett.',
|
||||||
|
'customHeadersValidation.duplicate': 'Ismétlődő fejlécnév.',
|
||||||
addServer: 'Szerver hozzáadása',
|
addServer: 'Szerver hozzáadása',
|
||||||
addServerTitle: 'Új szerver hozzáadása',
|
addServerTitle: 'Új szerver hozzáadása',
|
||||||
editServer: 'Szerkesztés',
|
editServer: 'Szerkesztés',
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ export const settings = {
|
|||||||
shareUsesLocalUrlDesc: 'Orbit 招待とライブラリ共有に影響します。既定: 公開アドレス。',
|
shareUsesLocalUrlDesc: 'Orbit 招待とライブラリ共有に影響します。既定: 公開アドレス。',
|
||||||
serverUsername: 'ユーザー名',
|
serverUsername: 'ユーザー名',
|
||||||
serverPassword: 'パスワード',
|
serverPassword: 'パスワード',
|
||||||
|
customHeadersTitle: 'カスタム HTTP ヘッダー',
|
||||||
|
customHeadersHelp: 'リバースプロキシゲート用(Pangolin、Cloudflare Access)。サーバーへの送信のみ — 第三者サービスには送信しません。',
|
||||||
|
customHeadersNamePlaceholder: 'ヘッダー名',
|
||||||
|
customHeadersValuePlaceholder: 'ヘッダー値',
|
||||||
|
customHeadersAddRow: 'ヘッダーを追加',
|
||||||
|
customHeadersRemoveRow: 'ヘッダーを削除',
|
||||||
|
customHeadersApplyTo: 'ヘッダーの適用先',
|
||||||
|
customHeadersApplyTo_public: '公開アドレス',
|
||||||
|
customHeadersApplyTo_local: 'ローカルアドレス',
|
||||||
|
customHeadersApplyTo_both: '両方のアドレス',
|
||||||
|
customHeadersNotInShare: 'カスタムヘッダーは招待リンクに含まれません — 各デバイスで再度設定してください。',
|
||||||
|
'customHeadersValidation.tooMany': 'カスタムヘッダーが多すぎます(最大 16)。',
|
||||||
|
'customHeadersValidation.nameRequired': 'ヘッダー名は必須です。',
|
||||||
|
'customHeadersValidation.nameTooLong': 'ヘッダー名が長すぎます。',
|
||||||
|
'customHeadersValidation.valueTooLong': 'ヘッダー値が長すぎます。',
|
||||||
|
'customHeadersValidation.crlf': 'ヘッダー名と値に改行を含めることはできません。',
|
||||||
|
'customHeadersValidation.blocked': 'このヘッダー名は使用できません。',
|
||||||
|
'customHeadersValidation.duplicate': 'ヘッダー名が重複しています。',
|
||||||
addServer: 'サーバーを追加',
|
addServer: 'サーバーを追加',
|
||||||
addServerTitle: '新しいサーバーを追加',
|
addServerTitle: '新しいサーバーを追加',
|
||||||
editServer: '編集',
|
editServer: '編集',
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ export const settings = {
|
|||||||
shareUsesLocalUrlDesc: 'Påvirker Orbit-invitasjoner og biblioteksdeling. Standard: offentlig adresse.',
|
shareUsesLocalUrlDesc: 'Påvirker Orbit-invitasjoner og biblioteksdeling. Standard: offentlig adresse.',
|
||||||
serverUsername: 'Brukernavn',
|
serverUsername: 'Brukernavn',
|
||||||
serverPassword: 'Passord',
|
serverPassword: 'Passord',
|
||||||
|
customHeadersTitle: 'Egendefinerte HTTP-headere',
|
||||||
|
customHeadersHelp: 'For reverse-proxy-gates (Pangolin, Cloudflare Access). Sendes bare til serveren din — ikke til tredjepartstjenester.',
|
||||||
|
customHeadersNamePlaceholder: 'Headernavn',
|
||||||
|
customHeadersValuePlaceholder: 'Headerverdi',
|
||||||
|
customHeadersAddRow: 'Legg til header',
|
||||||
|
customHeadersRemoveRow: 'Fjern header',
|
||||||
|
customHeadersApplyTo: 'Bruk headere på',
|
||||||
|
customHeadersApplyTo_public: 'Offentlig adresse',
|
||||||
|
customHeadersApplyTo_local: 'Lokal adresse',
|
||||||
|
customHeadersApplyTo_both: 'Begge adresser',
|
||||||
|
customHeadersNotInShare: 'Egendefinerte headere er ikke inkludert i invitasjoner — konfigurer dem på nytt på hver enhet.',
|
||||||
|
'customHeadersValidation.tooMany': 'For mange egendefinerte headere (maks. 16).',
|
||||||
|
'customHeadersValidation.nameRequired': 'Headernavn er påkrevd.',
|
||||||
|
'customHeadersValidation.nameTooLong': 'Headernavnet er for langt.',
|
||||||
|
'customHeadersValidation.valueTooLong': 'Headerverdien er for lang.',
|
||||||
|
'customHeadersValidation.crlf': 'Headernavn og -verdier kan ikke inneholde linjeskift.',
|
||||||
|
'customHeadersValidation.blocked': 'Dette headernavnet er ikke tillatt.',
|
||||||
|
'customHeadersValidation.duplicate': 'Duplikat headernavn.',
|
||||||
addServer: 'Legg til tjener',
|
addServer: 'Legg til tjener',
|
||||||
addServerTitle: 'Legg til ny tjener',
|
addServerTitle: 'Legg til ny tjener',
|
||||||
editServer: 'Rediger',
|
editServer: 'Rediger',
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ export const settings = {
|
|||||||
shareUsesLocalUrlDesc: 'Geldt voor Orbit-uitnodigingen en bibliotheekdelen. Standaard: openbaar adres.',
|
shareUsesLocalUrlDesc: 'Geldt voor Orbit-uitnodigingen en bibliotheekdelen. Standaard: openbaar adres.',
|
||||||
serverUsername: 'Gebruikersnaam',
|
serverUsername: 'Gebruikersnaam',
|
||||||
serverPassword: 'Wachtwoord',
|
serverPassword: 'Wachtwoord',
|
||||||
|
customHeadersTitle: 'Aangepaste HTTP-headers',
|
||||||
|
customHeadersHelp: 'Voor reverse-proxy-gates (Pangolin, Cloudflare Access). Alleen naar je server gestuurd — niet naar diensten van derden.',
|
||||||
|
customHeadersNamePlaceholder: 'Headernaam',
|
||||||
|
customHeadersValuePlaceholder: 'Headervalue',
|
||||||
|
customHeadersAddRow: 'Header toevoegen',
|
||||||
|
customHeadersRemoveRow: 'Header verwijderen',
|
||||||
|
customHeadersApplyTo: 'Headers toepassen op',
|
||||||
|
customHeadersApplyTo_public: 'Openbaar adres',
|
||||||
|
customHeadersApplyTo_local: 'Lokaal adres',
|
||||||
|
customHeadersApplyTo_both: 'Beide adressen',
|
||||||
|
customHeadersNotInShare: 'Aangepaste headers worden niet opgenomen in uitnodigingen — configureer ze opnieuw op elk apparaat.',
|
||||||
|
'customHeadersValidation.tooMany': 'Te veel aangepaste headers (max. 16).',
|
||||||
|
'customHeadersValidation.nameRequired': 'Headernaam is verplicht.',
|
||||||
|
'customHeadersValidation.nameTooLong': 'Headernaam is te lang.',
|
||||||
|
'customHeadersValidation.valueTooLong': 'Headervalue is te lang.',
|
||||||
|
'customHeadersValidation.crlf': 'Headernamen en -waarden mogen geen regeleinden bevatten.',
|
||||||
|
'customHeadersValidation.blocked': 'Deze headernaam is niet toegestaan.',
|
||||||
|
'customHeadersValidation.duplicate': 'Dubbele headernaam.',
|
||||||
addServer: 'Server toevoegen',
|
addServer: 'Server toevoegen',
|
||||||
addServerTitle: 'Nieuwe server toevoegen',
|
addServerTitle: 'Nieuwe server toevoegen',
|
||||||
editServer: 'Bewerken',
|
editServer: 'Bewerken',
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ export const settings = {
|
|||||||
shareUsesLocalUrlDesc: 'Afectează invitațiile Orbit și partajările bibliotecii. Implicit: adresa publică.',
|
shareUsesLocalUrlDesc: 'Afectează invitațiile Orbit și partajările bibliotecii. Implicit: adresa publică.',
|
||||||
serverUsername: 'Nume utilizator',
|
serverUsername: 'Nume utilizator',
|
||||||
serverPassword: 'Parolă',
|
serverPassword: 'Parolă',
|
||||||
|
customHeadersTitle: 'Antete HTTP personalizate',
|
||||||
|
customHeadersHelp: 'Pentru porți reverse proxy (Pangolin, Cloudflare Access). Trimise doar către serverul tău — nu către servicii terțe.',
|
||||||
|
customHeadersNamePlaceholder: 'Nume antet',
|
||||||
|
customHeadersValuePlaceholder: 'Valoare antet',
|
||||||
|
customHeadersAddRow: 'Adaugă antet',
|
||||||
|
customHeadersRemoveRow: 'Elimină antet',
|
||||||
|
customHeadersApplyTo: 'Aplică antetele la',
|
||||||
|
customHeadersApplyTo_public: 'Adresă publică',
|
||||||
|
customHeadersApplyTo_local: 'Adresă locală',
|
||||||
|
customHeadersApplyTo_both: 'Ambele adrese',
|
||||||
|
customHeadersNotInShare: 'Antetele personalizate nu sunt incluse în invitații — configurează-le din nou pe fiecare dispozitiv.',
|
||||||
|
'customHeadersValidation.tooMany': 'Prea multe antete personalizate (max. 16).',
|
||||||
|
'customHeadersValidation.nameRequired': 'Numele antetului este obligatoriu.',
|
||||||
|
'customHeadersValidation.nameTooLong': 'Numele antetului este prea lung.',
|
||||||
|
'customHeadersValidation.valueTooLong': 'Valoarea antetului este prea lungă.',
|
||||||
|
'customHeadersValidation.crlf': 'Numele și valorile nu pot conține linii noi.',
|
||||||
|
'customHeadersValidation.blocked': 'Acest nume de antet nu este permis.',
|
||||||
|
'customHeadersValidation.duplicate': 'Nume de antet duplicat.',
|
||||||
addServer: 'Adaugă Server',
|
addServer: 'Adaugă Server',
|
||||||
addServerTitle: 'Adaugă Server nou',
|
addServerTitle: 'Adaugă Server nou',
|
||||||
editServer: 'Editează',
|
editServer: 'Editează',
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ export const settings = {
|
|||||||
shareUsesLocalUrlDesc: 'Применяется к приглашениям Orbit и ссылкам на библиотеку. По умолчанию — публичный адрес.',
|
shareUsesLocalUrlDesc: 'Применяется к приглашениям Orbit и ссылкам на библиотеку. По умолчанию — публичный адрес.',
|
||||||
serverUsername: 'Логин',
|
serverUsername: 'Логин',
|
||||||
serverPassword: 'Пароль',
|
serverPassword: 'Пароль',
|
||||||
|
customHeadersTitle: 'Пользовательские HTTP-заголовки',
|
||||||
|
customHeadersHelp: 'Для reverse-proxy (Pangolin, Cloudflare Access). Отправляются только на ваш сервер — не сторонним сервисам.',
|
||||||
|
customHeadersNamePlaceholder: 'Имя заголовка',
|
||||||
|
customHeadersValuePlaceholder: 'Значение заголовка',
|
||||||
|
customHeadersAddRow: 'Добавить заголовок',
|
||||||
|
customHeadersRemoveRow: 'Удалить заголовок',
|
||||||
|
customHeadersApplyTo: 'Применять заголовки к',
|
||||||
|
customHeadersApplyTo_public: 'Публичному адресу',
|
||||||
|
customHeadersApplyTo_local: 'Локальному адресу',
|
||||||
|
customHeadersApplyTo_both: 'Оба адреса',
|
||||||
|
customHeadersNotInShare: 'Заголовки не входят в приглашения — настройте их на каждом устройстве отдельно.',
|
||||||
|
'customHeadersValidation.tooMany': 'Слишком много заголовков (макс. 16).',
|
||||||
|
'customHeadersValidation.nameRequired': 'Укажите имя заголовка.',
|
||||||
|
'customHeadersValidation.nameTooLong': 'Имя заголовка слишком длинное.',
|
||||||
|
'customHeadersValidation.valueTooLong': 'Значение заголовка слишком длинное.',
|
||||||
|
'customHeadersValidation.crlf': 'Имя и значение не могут содержать переводы строк.',
|
||||||
|
'customHeadersValidation.blocked': 'Это имя заголовка запрещено.',
|
||||||
|
'customHeadersValidation.duplicate': 'Дублирующееся имя заголовка.',
|
||||||
addServer: 'Добавить сервер',
|
addServer: 'Добавить сервер',
|
||||||
addServerTitle: 'Новый сервер',
|
addServerTitle: 'Новый сервер',
|
||||||
editServer: 'Изменить',
|
editServer: 'Изменить',
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ export const settings = {
|
|||||||
shareUsesLocalUrlDesc: '影响 Orbit 邀请和资料库分享。默认:公共地址。',
|
shareUsesLocalUrlDesc: '影响 Orbit 邀请和资料库分享。默认:公共地址。',
|
||||||
serverUsername: '用户名',
|
serverUsername: '用户名',
|
||||||
serverPassword: '密码',
|
serverPassword: '密码',
|
||||||
|
customHeadersTitle: '自定义 HTTP 标头',
|
||||||
|
customHeadersHelp: '用于反向代理网关(Pangolin、Cloudflare Access)。仅发送到你的服务器 — 不会发送给第三方服务。',
|
||||||
|
customHeadersNamePlaceholder: '标头名称',
|
||||||
|
customHeadersValuePlaceholder: '标头值',
|
||||||
|
customHeadersAddRow: '添加标头',
|
||||||
|
customHeadersRemoveRow: '删除标头',
|
||||||
|
customHeadersApplyTo: '将标头应用于',
|
||||||
|
customHeadersApplyTo_public: '公共地址',
|
||||||
|
customHeadersApplyTo_local: '本地地址',
|
||||||
|
customHeadersApplyTo_both: '两个地址',
|
||||||
|
customHeadersNotInShare: '自定义标头不会包含在分享邀请中 — 请在每台设备上重新配置。',
|
||||||
|
'customHeadersValidation.tooMany': '自定义标头过多(最多 16 个)。',
|
||||||
|
'customHeadersValidation.nameRequired': '标头名称不能为空。',
|
||||||
|
'customHeadersValidation.nameTooLong': '标头名称过长。',
|
||||||
|
'customHeadersValidation.valueTooLong': '标头值过长。',
|
||||||
|
'customHeadersValidation.crlf': '标头名称和值不能包含换行符。',
|
||||||
|
'customHeadersValidation.blocked': '不允许使用此标头名称。',
|
||||||
|
'customHeadersValidation.duplicate': '标头名称重复。',
|
||||||
addServer: '添加服务器',
|
addServer: '添加服务器',
|
||||||
addServerTitle: '添加新服务器',
|
addServerTitle: '添加新服务器',
|
||||||
editServer: '编辑',
|
editServer: '编辑',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
DEFAULT_LIBRARY_GRID_MAX_COLUMNS,
|
DEFAULT_LIBRARY_GRID_MAX_COLUMNS,
|
||||||
} from './authStoreDefaults';
|
} from './authStoreDefaults';
|
||||||
import { computeAuthStoreRehydration } from './authStoreRehydrate';
|
import { computeAuthStoreRehydration } from './authStoreRehydrate';
|
||||||
|
import { syncAllServerHttpContexts } from '../utils/server/syncServerHttpContext';
|
||||||
import type { AuthState } from './authStoreTypes';
|
import type { AuthState } from './authStoreTypes';
|
||||||
import { getCachedConnectBaseUrl } from '../utils/server/serverEndpoint';
|
import { getCachedConnectBaseUrl } from '../utils/server/serverEndpoint';
|
||||||
import { serverProfileBaseUrl } from '../utils/server/serverBaseUrl';
|
import { serverProfileBaseUrl } from '../utils/server/serverBaseUrl';
|
||||||
@@ -172,6 +173,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
onRehydrateStorage: () => (state, error) => {
|
onRehydrateStorage: () => (state, error) => {
|
||||||
if (error || !state) return;
|
if (error || !state) return;
|
||||||
useAuthStore.setState(computeAuthStoreRehydration(state));
|
useAuthStore.setState(computeAuthStoreRehydration(state));
|
||||||
|
void syncAllServerHttpContexts(useAuthStore.getState().servers);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,23 @@ import type {
|
|||||||
} from '../utils/server/subsonicServerIdentity';
|
} from '../utils/server/subsonicServerIdentity';
|
||||||
import type { PersistedAccount } from '../music-network';
|
import type { PersistedAccount } from '../music-network';
|
||||||
|
|
||||||
|
export type CustomHeaderEntry = {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomHeadersApplyTo = 'local' | 'public' | 'both';
|
||||||
|
|
||||||
|
export type CustomHeadersFieldError = {
|
||||||
|
index: number;
|
||||||
|
field: 'name' | 'value';
|
||||||
|
messageKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomHeadersValidationResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; fieldErrors: CustomHeadersFieldError[]; formMessage?: string };
|
||||||
|
|
||||||
export interface ServerProfile {
|
export interface ServerProfile {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -30,6 +47,10 @@ export interface ServerProfile {
|
|||||||
shareUsesLocalUrl?: boolean;
|
shareUsesLocalUrl?: boolean;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
/** Optional HTTP headers for reverse-proxy gates (Pangolin, Cloudflare Access). */
|
||||||
|
customHeaders?: CustomHeaderEntry[];
|
||||||
|
/** Which profile endpoint(s) receive `customHeaders`. Default when absent: `'public'`. */
|
||||||
|
customHeadersApplyTo?: CustomHeadersApplyTo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SeekbarStyle = 'truewave' | 'pseudowave' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
|
export type SeekbarStyle = 'truewave' | 'pseudowave' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
// Listing every export the store uses keeps the override stable.
|
// Listing every export the store uses keeps the override stable.
|
||||||
vi.mock('@/api/subsonic', () => ({
|
vi.mock('@/api/subsonic', () => ({
|
||||||
pingWithCredentials: vi.fn(async () => ({ ok: true })),
|
pingWithCredentials: vi.fn(async () => ({ ok: true })),
|
||||||
|
pingWithCredentialsForProfile: vi.fn(async () => ({ ok: true })),
|
||||||
|
scheduleInstantMixProbeForServer: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock('@/api/subsonicPlayQueue', () => ({
|
vi.mock('@/api/subsonicPlayQueue', () => ({
|
||||||
savePlayQueue: vi.fn(async () => undefined),
|
savePlayQueue: vi.fn(async () => undefined),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useAuthStore } from '../../store/authStore';
|
|||||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||||
import { ensureConnectUrlResolved } from '../server/serverEndpoint';
|
import { ensureConnectUrlResolved } from '../server/serverEndpoint';
|
||||||
import { serverIndexKeyForProfile } from '../server/serverIndexKey';
|
import { serverIndexKeyForProfile } from '../server/serverIndexKey';
|
||||||
|
import { syncServerHttpContextForProfile } from '../server/syncServerHttpContext';
|
||||||
import { libraryDevEnabled, logLibraryStatus, logLibrarySync, timed } from './libraryDevLog';
|
import { libraryDevEnabled, logLibraryStatus, logLibrarySync, timed } from './libraryDevLog';
|
||||||
|
|
||||||
export type BindServerResult = 'bound' | 'offline' | 'error';
|
export type BindServerResult = 'bound' | 'offline' | 'error';
|
||||||
@@ -49,6 +50,7 @@ export async function bindIndexedServer(server: ServerProfile): Promise<BindServ
|
|||||||
});
|
});
|
||||||
logLibraryStatus(server.id, status, 'bind_session');
|
logLibraryStatus(server.id, status, 'bind_session');
|
||||||
}
|
}
|
||||||
|
void syncServerHttpContextForProfile(server);
|
||||||
return 'bound';
|
return 'bound';
|
||||||
} catch {
|
} catch {
|
||||||
return 'error';
|
return 'error';
|
||||||
|
|||||||
@@ -34,4 +34,15 @@ describe('sanitizeLogLine', () => {
|
|||||||
expect(out).toContain('password=REDACTED');
|
expect(out).toContain('password=REDACTED');
|
||||||
expect(out).not.toContain('sekrit');
|
expect(out).not.toContain('sekrit');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('redacts reverse-proxy gate header values', () => {
|
||||||
|
const line = 'req CF-Access-Client-Secret: gate-secret Authorization: Bearer tok123 x-pangolin-auth: pangolin-key';
|
||||||
|
const out = sanitizeLogLine(line);
|
||||||
|
expect(out).toContain('CF-Access-Client-Secret: REDACTED');
|
||||||
|
expect(out).not.toContain('gate-secret');
|
||||||
|
expect(out).not.toContain('tok123');
|
||||||
|
expect(out).toContain('x-pangolin-auth: REDACTED');
|
||||||
|
expect(out).not.toContain('pangolin-key');
|
||||||
|
expect(out).not.toMatch(/Authorization:\s*Bearer\s+\S/i);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ const SENSITIVE_QUERY_KEYS = new Set([
|
|||||||
const SENSITIVE_KV_KEYS = [
|
const SENSITIVE_KV_KEYS = [
|
||||||
'password', 'passwd', 'token', 'secret', 'api_key', 'apikey',
|
'password', 'passwd', 'token', 'secret', 'api_key', 'apikey',
|
||||||
'access_token', 'refresh_token', 'authorization', 'auth',
|
'access_token', 'refresh_token', 'authorization', 'auth',
|
||||||
|
'cookie', 'x-api-key', 'cf-access-client-secret', 'cf-access-client-id', 'x-auth-token',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Gate / reverse-proxy header names — redact any `x-pangolin-*` prefix. */
|
||||||
|
const PANGOLIN_HEADER_RE = /(\bx-pangolin-[a-z0-9-]+\s*[:=]\s*)(\S+)/gi;
|
||||||
|
|
||||||
function isIpv4LanLiteral(ip: string): boolean {
|
function isIpv4LanLiteral(ip: string): boolean {
|
||||||
const parts = ip.split('.');
|
const parts = ip.split('.');
|
||||||
if (parts.length !== 4) return false;
|
if (parts.length !== 4) return false;
|
||||||
@@ -175,6 +179,10 @@ function redactBearerTokens(line: string): string {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function redactPangolinHeaders(line: string): string {
|
||||||
|
return line.replace(PANGOLIN_HEADER_RE, '$1REDACTED');
|
||||||
|
}
|
||||||
|
|
||||||
function redactSensitiveKeyValues(line: string): string {
|
function redactSensitiveKeyValues(line: string): string {
|
||||||
let out = line;
|
let out = line;
|
||||||
for (const key of SENSITIVE_KV_KEYS) {
|
for (const key of SENSITIVE_KV_KEYS) {
|
||||||
@@ -231,5 +239,5 @@ function redactUrlsInText(line: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeLogLine(line: string): string {
|
export function sanitizeLogLine(line: string): string {
|
||||||
return redactUrlsInText(redactSensitiveKeyValues(redactBearerTokens(line)));
|
return redactUrlsInText(redactSensitiveKeyValues(redactPangolinHeaders(redactBearerTokens(line))));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
vi.mock('../../api/subsonic', () => ({
|
vi.mock('../../api/subsonic', () => ({
|
||||||
pingWithCredentials: vi.fn(),
|
pingWithCredentials: vi.fn(),
|
||||||
|
pingWithCredentialsForProfile: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { pingWithCredentials } from '../../api/subsonic';
|
import { pingWithCredentialsForProfile } from '../../api/subsonic';
|
||||||
import {
|
import {
|
||||||
allNormalizedAddresses,
|
allNormalizedAddresses,
|
||||||
ensureConnectUrlResolved,
|
ensureConnectUrlResolved,
|
||||||
@@ -47,7 +48,7 @@ function pingFail() {
|
|||||||
const CONNECT_PROBE_ATTEMPTS = 3;
|
const CONNECT_PROBE_ATTEMPTS = 3;
|
||||||
|
|
||||||
function mockDualAddressLanFailPublicOk() {
|
function mockDualAddressLanFailPublicOk() {
|
||||||
vi.mocked(pingWithCredentials).mockImplementation(async (url: string) => {
|
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url: string) => {
|
||||||
if (url === 'http://192.168.0.10') return pingFail();
|
if (url === 'http://192.168.0.10') return pingFail();
|
||||||
return pingOk();
|
return pingOk();
|
||||||
});
|
});
|
||||||
@@ -212,7 +213,7 @@ describe('serverAddressEndpoints', () => {
|
|||||||
describe('pickReachableBaseUrl', () => {
|
describe('pickReachableBaseUrl', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
invalidateReachableEndpointCache();
|
invalidateReachableEndpointCache();
|
||||||
vi.mocked(pingWithCredentials).mockReset();
|
vi.mocked(pingWithCredentialsForProfile).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -220,7 +221,7 @@ describe('pickReachableBaseUrl', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns the single endpoint when it pings ok and caches it', async () => {
|
it('returns the single endpoint when it pings ok and caches it', async () => {
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValue(pingOk());
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingOk());
|
||||||
const result = await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
|
const result = await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
@@ -230,11 +231,11 @@ describe('pickReachableBaseUrl', () => {
|
|||||||
expect(result.ping.type).toBe('navidrome');
|
expect(result.ping.type).toBe('navidrome');
|
||||||
}
|
}
|
||||||
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
|
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
|
||||||
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
|
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefers the LAN endpoint even when alternateUrl is the LAN one', async () => {
|
it('prefers the LAN endpoint even when alternateUrl is the LAN one', async () => {
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValue(pingOk());
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingOk());
|
||||||
const result = await pickReachableBaseUrl(
|
const result = await pickReachableBaseUrl(
|
||||||
makeProfile({
|
makeProfile({
|
||||||
url: 'https://music.example.com',
|
url: 'https://music.example.com',
|
||||||
@@ -243,8 +244,8 @@ describe('pickReachableBaseUrl', () => {
|
|||||||
);
|
);
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10');
|
if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10');
|
||||||
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
|
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
|
||||||
expect(vi.mocked(pingWithCredentials).mock.calls[0]![0]).toBe('http://192.168.0.10');
|
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls[0]![1]).toBe('http://192.168.0.10');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls through to the public endpoint when LAN ping fails', async () => {
|
it('falls through to the public endpoint when LAN ping fails', async () => {
|
||||||
@@ -260,13 +261,13 @@ describe('pickReachableBaseUrl', () => {
|
|||||||
const result = await promise;
|
const result = await promise;
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com');
|
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com');
|
||||||
expect(pingWithCredentials).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS + 1);
|
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS + 1);
|
||||||
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
|
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retries a flaky endpoint before declaring it unreachable', async () => {
|
it('retries a flaky endpoint before declaring it unreachable', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.mocked(pingWithCredentials)
|
vi.mocked(pingWithCredentialsForProfile)
|
||||||
.mockResolvedValueOnce(pingFail())
|
.mockResolvedValueOnce(pingFail())
|
||||||
.mockResolvedValueOnce(pingOk());
|
.mockResolvedValueOnce(pingOk());
|
||||||
const promise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
|
const promise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
|
||||||
@@ -274,32 +275,32 @@ describe('pickReachableBaseUrl', () => {
|
|||||||
const result = await promise;
|
const result = await promise;
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com');
|
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com');
|
||||||
expect(pingWithCredentials).toHaveBeenCalledTimes(2);
|
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(2);
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns unreachable and clears cache when every endpoint fails', async () => {
|
it('returns unreachable and clears cache when every endpoint fails', async () => {
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValue(pingFail());
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingFail());
|
||||||
// Seed a stale cache entry first.
|
// Seed a stale cache entry first.
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||||
await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
|
await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
|
||||||
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
|
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
|
||||||
|
|
||||||
vi.mocked(pingWithCredentials).mockReset();
|
vi.mocked(pingWithCredentialsForProfile).mockReset();
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValue(pingFail());
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingFail());
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const unreachablePromise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
|
const unreachablePromise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
const result = await unreachablePromise;
|
const result = await unreachablePromise;
|
||||||
expect(result).toEqual({ ok: false, reason: 'unreachable' });
|
expect(result).toEqual({ ok: false, reason: 'unreachable' });
|
||||||
expect(getCachedConnectBaseUrl('profile-1')).toBeNull();
|
expect(getCachedConnectBaseUrl('profile-1')).toBeNull();
|
||||||
expect(pingWithCredentials).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS);
|
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns unreachable when the profile has no usable url', async () => {
|
it('returns unreachable when the profile has no usable url', async () => {
|
||||||
const result = await pickReachableBaseUrl(makeProfile({ url: '' }));
|
const result = await pickReachableBaseUrl(makeProfile({ url: '' }));
|
||||||
expect(result).toEqual({ ok: false, reason: 'unreachable' });
|
expect(result).toEqual({ ok: false, reason: 'unreachable' });
|
||||||
expect(pingWithCredentials).not.toHaveBeenCalled();
|
expect(pingWithCredentialsForProfile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tries the cached endpoint first on subsequent calls (sticky)', async () => {
|
it('tries the cached endpoint first on subsequent calls (sticky)', async () => {
|
||||||
@@ -308,18 +309,18 @@ describe('pickReachableBaseUrl', () => {
|
|||||||
alternateUrl: 'http://192.168.0.10',
|
alternateUrl: 'http://192.168.0.10',
|
||||||
});
|
});
|
||||||
// First call: LAN responds ok, becomes cached.
|
// First call: LAN responds ok, becomes cached.
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||||
await pickReachableBaseUrl(profile);
|
await pickReachableBaseUrl(profile);
|
||||||
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
|
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
|
||||||
|
|
||||||
// Second call: cached URL is tried first; sole ping happens against it.
|
// Second call: cached URL is tried first; sole ping happens against it.
|
||||||
vi.mocked(pingWithCredentials).mockClear();
|
vi.mocked(pingWithCredentialsForProfile).mockClear();
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||||
const result = await pickReachableBaseUrl(profile);
|
const result = await pickReachableBaseUrl(profile);
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10');
|
if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10');
|
||||||
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
|
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
|
||||||
expect(vi.mocked(pingWithCredentials).mock.calls[0]![0]).toBe('http://192.168.0.10');
|
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls[0]![1]).toBe('http://192.168.0.10');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dedupes concurrent calls for the same profile (single shared probe)', async () => {
|
it('dedupes concurrent calls for the same profile (single shared probe)', async () => {
|
||||||
@@ -328,7 +329,7 @@ describe('pickReachableBaseUrl', () => {
|
|||||||
// cache write, with last-write-wins potentially clobbering the correct
|
// cache write, with last-write-wins potentially clobbering the correct
|
||||||
// LAN sticky a millisecond after it was set.
|
// LAN sticky a millisecond after it was set.
|
||||||
let resolvePing: ((v: ReturnType<typeof pingOk>) => void) | null = null;
|
let resolvePing: ((v: ReturnType<typeof pingOk>) => void) | null = null;
|
||||||
vi.mocked(pingWithCredentials).mockReturnValueOnce(
|
vi.mocked(pingWithCredentialsForProfile).mockReturnValueOnce(
|
||||||
new Promise(r => {
|
new Promise(r => {
|
||||||
resolvePing = r;
|
resolvePing = r;
|
||||||
}),
|
}),
|
||||||
@@ -338,7 +339,7 @@ describe('pickReachableBaseUrl', () => {
|
|||||||
const p2 = pickReachableBaseUrl(profile);
|
const p2 = pickReachableBaseUrl(profile);
|
||||||
|
|
||||||
// Both calls saw a pending probe — only one ping should have been fired.
|
// Both calls saw a pending probe — only one ping should have been fired.
|
||||||
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
|
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
resolvePing!(pingOk());
|
resolvePing!(pingOk());
|
||||||
const [r1, r2] = await Promise.all([p1, p2]);
|
const [r1, r2] = await Promise.all([p1, p2]);
|
||||||
@@ -354,13 +355,13 @@ describe('pickReachableBaseUrl', () => {
|
|||||||
it('starts a fresh probe after the in-flight one settles', async () => {
|
it('starts a fresh probe after the in-flight one settles', async () => {
|
||||||
// Once the previous probe resolves, the in-flight slot is freed and
|
// Once the previous probe resolves, the in-flight slot is freed and
|
||||||
// the next call hits the network again (subject to the sticky cache).
|
// the next call hits the network again (subject to the sticky cache).
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||||
await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' }));
|
await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' }));
|
||||||
|
|
||||||
vi.mocked(pingWithCredentials).mockClear();
|
vi.mocked(pingWithCredentialsForProfile).mockClear();
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||||
await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' }));
|
await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' }));
|
||||||
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
|
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the natural order if the cached endpoint stops answering', async () => {
|
it('falls back to the natural order if the cached endpoint stops answering', async () => {
|
||||||
@@ -368,12 +369,12 @@ describe('pickReachableBaseUrl', () => {
|
|||||||
url: 'https://music.example.com',
|
url: 'https://music.example.com',
|
||||||
alternateUrl: 'http://192.168.0.10',
|
alternateUrl: 'http://192.168.0.10',
|
||||||
});
|
});
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||||
await pickReachableBaseUrl(profile);
|
await pickReachableBaseUrl(profile);
|
||||||
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
|
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
|
||||||
|
|
||||||
// LAN now fails; public answers.
|
// LAN now fails; public answers.
|
||||||
vi.mocked(pingWithCredentials).mockClear();
|
vi.mocked(pingWithCredentialsForProfile).mockClear();
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
mockDualAddressLanFailPublicOk();
|
mockDualAddressLanFailPublicOk();
|
||||||
const fallbackPromise = pickReachableBaseUrl(profile);
|
const fallbackPromise = pickReachableBaseUrl(profile);
|
||||||
@@ -388,13 +389,13 @@ describe('pickReachableBaseUrl', () => {
|
|||||||
describe('invalidateReachableEndpointCache', () => {
|
describe('invalidateReachableEndpointCache', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
invalidateReachableEndpointCache();
|
invalidateReachableEndpointCache();
|
||||||
vi.mocked(pingWithCredentials).mockReset();
|
vi.mocked(pingWithCredentialsForProfile).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears a specific profile', async () => {
|
it('clears a specific profile', async () => {
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||||
await ensureConnectUrlResolved(makeProfile({ id: 'a' }));
|
await ensureConnectUrlResolved(makeProfile({ id: 'a' }));
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||||
await ensureConnectUrlResolved(makeProfile({ id: 'b' }));
|
await ensureConnectUrlResolved(makeProfile({ id: 'b' }));
|
||||||
expect(getCachedConnectBaseUrl('a')).not.toBeNull();
|
expect(getCachedConnectBaseUrl('a')).not.toBeNull();
|
||||||
expect(getCachedConnectBaseUrl('b')).not.toBeNull();
|
expect(getCachedConnectBaseUrl('b')).not.toBeNull();
|
||||||
@@ -405,7 +406,7 @@ describe('invalidateReachableEndpointCache', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('clears everything when called with no argument', async () => {
|
it('clears everything when called with no argument', async () => {
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||||
await ensureConnectUrlResolved(makeProfile({ id: 'a' }));
|
await ensureConnectUrlResolved(makeProfile({ id: 'a' }));
|
||||||
invalidateReachableEndpointCache();
|
invalidateReachableEndpointCache();
|
||||||
expect(getCachedConnectBaseUrl('a')).toBeNull();
|
expect(getCachedConnectBaseUrl('a')).toBeNull();
|
||||||
@@ -415,7 +416,7 @@ describe('invalidateReachableEndpointCache', () => {
|
|||||||
describe('subscribeConnectCache — connect-URL flip notifications', () => {
|
describe('subscribeConnectCache — connect-URL flip notifications', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
invalidateReachableEndpointCache();
|
invalidateReachableEndpointCache();
|
||||||
vi.mocked(pingWithCredentials).mockReset();
|
vi.mocked(pingWithCredentialsForProfile).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('notifies when a probe resolves a new endpoint and on a later flip', async () => {
|
it('notifies when a probe resolves a new endpoint and on a later flip', async () => {
|
||||||
@@ -427,7 +428,7 @@ describe('subscribeConnectCache — connect-URL flip notifications', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// First probe: LAN answers → cache set → one notification.
|
// First probe: LAN answers → cache set → one notification.
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||||
await pickReachableBaseUrl(profile);
|
await pickReachableBaseUrl(profile);
|
||||||
expect(listener).toHaveBeenCalledTimes(1);
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
@@ -444,13 +445,13 @@ describe('subscribeConnectCache — connect-URL flip notifications', () => {
|
|||||||
|
|
||||||
it('does not notify when the sticky endpoint is unchanged', async () => {
|
it('does not notify when the sticky endpoint is unchanged', async () => {
|
||||||
const profile = makeProfile({ url: 'http://192.168.0.10' });
|
const profile = makeProfile({ url: 'http://192.168.0.10' });
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||||
await pickReachableBaseUrl(profile);
|
await pickReachableBaseUrl(profile);
|
||||||
|
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
const unsubscribe = subscribeConnectCache(listener);
|
const unsubscribe = subscribeConnectCache(listener);
|
||||||
// Re-probe, same endpoint answers → cache value identical → no notification.
|
// Re-probe, same endpoint answers → cache value identical → no notification.
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||||
await pickReachableBaseUrl(profile);
|
await pickReachableBaseUrl(profile);
|
||||||
expect(listener).not.toHaveBeenCalled();
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
|
||||||
@@ -458,7 +459,7 @@ describe('subscribeConnectCache — connect-URL flip notifications', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('notifies on explicit cache invalidation when an entry existed', async () => {
|
it('notifies on explicit cache invalidation when an entry existed', async () => {
|
||||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||||
await pickReachableBaseUrl(makeProfile({ id: 'a' }));
|
await pickReachableBaseUrl(makeProfile({ id: 'a' }));
|
||||||
|
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { pingWithCredentials } from '../../api/subsonic';
|
import { pingWithCredentialsForProfile } from '../../api/subsonic';
|
||||||
import type { PingWithCredentialsResult } from '../../api/subsonicTypes';
|
import type { PingWithCredentialsResult } from '../../api/subsonicTypes';
|
||||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||||
import { serverProfileBaseUrl } from './serverBaseUrl';
|
import { serverProfileBaseUrl } from './serverBaseUrl';
|
||||||
@@ -261,15 +261,14 @@ function sleepMs(ms: number): Promise<void> {
|
|||||||
* proxy TLS flakes) before the connection indicator marks the server down.
|
* proxy TLS flakes) before the connection indicator marks the server down.
|
||||||
*/
|
*/
|
||||||
async function pingWithConnectRetries(
|
async function pingWithConnectRetries(
|
||||||
baseUrl: string,
|
profile: ServerProfile,
|
||||||
username: string,
|
endpointUrl: string,
|
||||||
password: string,
|
|
||||||
): Promise<PingWithCredentialsResult> {
|
): Promise<PingWithCredentialsResult> {
|
||||||
let ping = await pingWithCredentials(baseUrl, username, password);
|
let ping = await pingWithCredentialsForProfile(profile, endpointUrl);
|
||||||
if (ping.ok) return ping;
|
if (ping.ok) return ping;
|
||||||
for (let retry = 0; retry < CONNECT_PING_RETRIES; retry++) {
|
for (let retry = 0; retry < CONNECT_PING_RETRIES; retry++) {
|
||||||
await sleepMs(CONNECT_PING_RETRY_DELAY_MS);
|
await sleepMs(CONNECT_PING_RETRY_DELAY_MS);
|
||||||
ping = await pingWithCredentials(baseUrl, username, password);
|
ping = await pingWithCredentialsForProfile(profile, endpointUrl);
|
||||||
if (ping.ok) return ping;
|
if (ping.ok) return ping;
|
||||||
}
|
}
|
||||||
return ping;
|
return ping;
|
||||||
@@ -309,7 +308,7 @@ export async function pickReachableBaseUrl(
|
|||||||
: ordered;
|
: ordered;
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
for (const endpoint of endpoints) {
|
||||||
const ping = await pingWithConnectRetries(endpoint.url, profile.username, profile.password);
|
const ping = await pingWithConnectRetries(profile, endpoint.url);
|
||||||
if (ping.ok) {
|
if (ping.ok) {
|
||||||
const prev = connectCache.get(profile.id);
|
const prev = connectCache.get(profile.id);
|
||||||
connectCache.set(profile.id, endpoint.url);
|
connectCache.set(profile.id, endpoint.url);
|
||||||
|
|||||||
@@ -15,9 +15,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import md5 from 'md5';
|
import md5 from 'md5';
|
||||||
import { apiWithCredentials, restBaseFromUrl, secureRandomSalt, SUBSONIC_CLIENT } from '../../api/subsonicClient';
|
import {
|
||||||
|
apiWithCredentials,
|
||||||
|
restBaseFromUrl,
|
||||||
|
secureRandomSalt,
|
||||||
|
SUBSONIC_CLIENT,
|
||||||
|
type ServerHttpHeaderProfile,
|
||||||
|
} from '../../api/subsonicClient';
|
||||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||||
import { allNormalizedAddresses } from './serverEndpoint';
|
import { allNormalizedAddresses } from './serverEndpoint';
|
||||||
|
import { headersForServerRequest } from './serverHttpHeaders';
|
||||||
|
|
||||||
export type ServerFingerprint = {
|
export type ServerFingerprint = {
|
||||||
ping: {
|
ping: {
|
||||||
@@ -55,6 +62,7 @@ async function fetchPingFingerprint(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
headerProfile?: ServerHttpHeaderProfile,
|
||||||
): Promise<PingFingerprint> {
|
): Promise<PingFingerprint> {
|
||||||
// Mirrors pingWithCredentials but also extracts envelope `version`.
|
// Mirrors pingWithCredentials but also extracts envelope `version`.
|
||||||
// Using fetch (the bundled axios pulls in extra noise here; one call is fine).
|
// Using fetch (the bundled axios pulls in extra noise here; one call is fine).
|
||||||
@@ -69,9 +77,18 @@ async function fetchPingFingerprint(
|
|||||||
f: 'json',
|
f: 'json',
|
||||||
});
|
});
|
||||||
const url = `${restBaseFromUrl(baseUrl)}/ping.view?${params.toString()}`;
|
const url = `${restBaseFromUrl(baseUrl)}/ping.view?${params.toString()}`;
|
||||||
|
const profileForHeaders: ServerHttpHeaderProfile = headerProfile ?? {
|
||||||
|
url: baseUrl,
|
||||||
|
alternateUrl: undefined,
|
||||||
|
customHeaders: undefined,
|
||||||
|
customHeadersApplyTo: undefined,
|
||||||
|
};
|
||||||
let body: Record<string, unknown> | null = null;
|
let body: Record<string, unknown> | null = null;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url, { method: 'GET' });
|
const resp = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: headersForServerRequest(profileForHeaders, baseUrl),
|
||||||
|
});
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
const json = (await resp.json()) as Record<string, unknown>;
|
const json = (await resp.json()) as Record<string, unknown>;
|
||||||
body = (json?.['subsonic-response'] as Record<string, unknown>) ?? null;
|
body = (json?.['subsonic-response'] as Record<string, unknown>) ?? null;
|
||||||
@@ -171,12 +188,13 @@ export async function fetchServerFingerprint(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
headerProfile?: ServerHttpHeaderProfile,
|
||||||
): Promise<ServerFingerprint> {
|
): Promise<ServerFingerprint> {
|
||||||
// Ping is required — but we still build a (mostly-null) fingerprint when
|
// Ping is required — but we still build a (mostly-null) fingerprint when
|
||||||
// it fails so callers can tell the difference between "server unreachable"
|
// it fails so callers can tell the difference between "server unreachable"
|
||||||
// (verify reports `unreachable`) and "server reachable but disagrees"
|
// (verify reports `unreachable`) and "server reachable but disagrees"
|
||||||
// (verify reports `mismatch` / `insufficient`).
|
// (verify reports `mismatch` / `insufficient`).
|
||||||
const ping = await fetchPingFingerprint(baseUrl, username, password);
|
const ping = await fetchPingFingerprint(baseUrl, username, password, headerProfile);
|
||||||
|
|
||||||
// The optional calls only make sense once ping succeeded — without that,
|
// The optional calls only make sense once ping succeeded — without that,
|
||||||
// any subsequent call against the same URL is just wasted bandwidth.
|
// any subsequent call against the same URL is just wasted bandwidth.
|
||||||
@@ -196,10 +214,42 @@ export async function fetchServerFingerprint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const settled = await Promise.allSettled([
|
const settled = await Promise.allSettled([
|
||||||
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getMusicFolders.view'),
|
apiWithCredentials<Record<string, unknown>>(
|
||||||
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getUser.view', { username }),
|
baseUrl,
|
||||||
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getLicense.view'),
|
username,
|
||||||
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getIndexes.view'),
|
password,
|
||||||
|
'getMusicFolders.view',
|
||||||
|
{},
|
||||||
|
15000,
|
||||||
|
headerProfile,
|
||||||
|
),
|
||||||
|
apiWithCredentials<Record<string, unknown>>(
|
||||||
|
baseUrl,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
'getUser.view',
|
||||||
|
{ username },
|
||||||
|
15000,
|
||||||
|
headerProfile,
|
||||||
|
),
|
||||||
|
apiWithCredentials<Record<string, unknown>>(
|
||||||
|
baseUrl,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
'getLicense.view',
|
||||||
|
{},
|
||||||
|
15000,
|
||||||
|
headerProfile,
|
||||||
|
),
|
||||||
|
apiWithCredentials<Record<string, unknown>>(
|
||||||
|
baseUrl,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
'getIndexes.view',
|
||||||
|
{},
|
||||||
|
15000,
|
||||||
|
headerProfile,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [foldersResult, userResult, licenseResult, indexesResult] = settled;
|
const [foldersResult, userResult, licenseResult, indexesResult] = settled;
|
||||||
@@ -299,7 +349,10 @@ function musicFoldersEqual(
|
|||||||
* `ok: true` — nothing to verify.
|
* `ok: true` — nothing to verify.
|
||||||
*/
|
*/
|
||||||
export async function verifySameServerEndpoints(
|
export async function verifySameServerEndpoints(
|
||||||
profile: Pick<ServerProfile, 'url' | 'alternateUrl'>,
|
profile: Pick<
|
||||||
|
ServerProfile,
|
||||||
|
'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
|
||||||
|
>,
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<VerifySameServerResult> {
|
): Promise<VerifySameServerResult> {
|
||||||
@@ -307,7 +360,7 @@ export async function verifySameServerEndpoints(
|
|||||||
if (endpoints.length <= 1) return { ok: true };
|
if (endpoints.length <= 1) return { ok: true };
|
||||||
|
|
||||||
const fingerprints = await Promise.all(
|
const fingerprints = await Promise.all(
|
||||||
endpoints.map(baseUrl => fetchServerFingerprint(baseUrl, username, password)),
|
endpoints.map(baseUrl => fetchServerFingerprint(baseUrl, username, password, profile)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// If any ping failed → unreachable (with the offending host for the UI).
|
// If any ping failed → unreachable (with the offending host for the UI).
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
headersForServerRequest,
|
||||||
|
requestBaseUrlFromHttpUrl,
|
||||||
|
validateCustomHeaders,
|
||||||
|
} from './serverHttpHeaders';
|
||||||
|
|
||||||
|
describe('requestBaseUrlFromHttpUrl', () => {
|
||||||
|
it('strips /rest/ path and query from stream URLs', () => {
|
||||||
|
expect(
|
||||||
|
requestBaseUrlFromHttpUrl(
|
||||||
|
'https://music.example.com/rest/stream.view?id=1&u=x&t=y&s=z',
|
||||||
|
),
|
||||||
|
).toBe('https://music.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips /api/ Navidrome paths', () => {
|
||||||
|
expect(requestBaseUrlFromHttpUrl('https://nd.local/api/album')).toBe('https://nd.local');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('headersForServerRequest', () => {
|
||||||
|
const profile = {
|
||||||
|
url: 'https://music.example.com',
|
||||||
|
alternateUrl: 'http://192.168.1.10:4533',
|
||||||
|
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'secret' }],
|
||||||
|
customHeadersApplyTo: 'public' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('applies headers on public endpoint only when applyTo is public', () => {
|
||||||
|
expect(headersForServerRequest(profile, 'https://music.example.com')).toEqual({
|
||||||
|
'CF-Access-Client-Secret': 'secret',
|
||||||
|
});
|
||||||
|
expect(headersForServerRequest(profile, 'http://192.168.1.10:4533')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for foreign base URL', () => {
|
||||||
|
expect(headersForServerRequest(profile, 'https://other.example.com')).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateCustomHeaders', () => {
|
||||||
|
it('rejects blocked header names', () => {
|
||||||
|
const result = validateCustomHeaders([{ name: 'Host', value: 'x' }]);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid rows', () => {
|
||||||
|
expect(
|
||||||
|
validateCustomHeaders([{ name: 'X-Custom', value: 'ok' }]),
|
||||||
|
).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type {
|
||||||
|
CustomHeaderEntry,
|
||||||
|
CustomHeadersApplyTo,
|
||||||
|
CustomHeadersFieldError,
|
||||||
|
CustomHeadersValidationResult,
|
||||||
|
ServerProfile,
|
||||||
|
} from '../../store/authStoreTypes';
|
||||||
|
import { serverIndexKeyForProfile } from './serverIndexKey';
|
||||||
|
import { normalizeServerBaseUrl, serverAddressEndpoints, type ServerEndpointKind } from './serverEndpoint';
|
||||||
|
|
||||||
|
export const DEFAULT_CUSTOM_HEADERS_APPLY_TO: CustomHeadersApplyTo = 'public';
|
||||||
|
|
||||||
|
export const CUSTOM_HEADER_NAME_BLOCKLIST = new Set([
|
||||||
|
'host',
|
||||||
|
'content-length',
|
||||||
|
'transfer-encoding',
|
||||||
|
'connection',
|
||||||
|
'cookie',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MAX_CUSTOM_HEADERS = 16;
|
||||||
|
const MAX_HEADER_NAME_LEN = 256;
|
||||||
|
const MAX_HEADER_VALUE_LEN = 8192;
|
||||||
|
|
||||||
|
export function normalizeHeaderName(name: string): string {
|
||||||
|
return name.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestBaseUrlFromHttpUrl(rawUrl: string): string {
|
||||||
|
const trimmed = rawUrl.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed.startsWith('http') ? trimmed : `http://${trimmed}`);
|
||||||
|
parsed.search = '';
|
||||||
|
parsed.hash = '';
|
||||||
|
let path = parsed.pathname;
|
||||||
|
const restIdx = path.indexOf('/rest/');
|
||||||
|
if (restIdx >= 0 || path.endsWith('/rest')) {
|
||||||
|
path = path.slice(0, restIdx >= 0 ? restIdx : path.length - '/rest'.length);
|
||||||
|
} else {
|
||||||
|
for (const seg of ['/api/', '/auth/'] as const) {
|
||||||
|
const idx = path.indexOf(seg);
|
||||||
|
if (idx >= 0) {
|
||||||
|
path = path.slice(0, idx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (path.endsWith('/') && path.length > 1) path = path.replace(/\/+$/, '');
|
||||||
|
parsed.pathname = path || '/';
|
||||||
|
const origin = `${parsed.protocol}//${parsed.host}${path === '/' ? '' : path}`;
|
||||||
|
return normalizeServerBaseUrl(origin);
|
||||||
|
} catch {
|
||||||
|
return normalizeServerBaseUrl(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCustomHeaders(
|
||||||
|
headers: CustomHeaderEntry[] | undefined,
|
||||||
|
): CustomHeadersValidationResult {
|
||||||
|
if (!headers?.length) return { ok: true };
|
||||||
|
if (headers.length > MAX_CUSTOM_HEADERS) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
fieldErrors: [{ index: 0, field: 'name', messageKey: 'settings.customHeadersValidation.tooMany' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const fieldErrors: CustomHeadersFieldError[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
headers.forEach((row, index) => {
|
||||||
|
const name = normalizeHeaderName(row.name);
|
||||||
|
const value = row.value;
|
||||||
|
if (!name) {
|
||||||
|
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.nameRequired' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (name.length > MAX_HEADER_NAME_LEN) {
|
||||||
|
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.nameTooLong' });
|
||||||
|
}
|
||||||
|
if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) {
|
||||||
|
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.crlf' });
|
||||||
|
}
|
||||||
|
if (CUSTOM_HEADER_NAME_BLOCKLIST.has(name.toLowerCase())) {
|
||||||
|
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.blocked' });
|
||||||
|
}
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
if (seen.has(key)) {
|
||||||
|
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.duplicate' });
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
if (value.length > MAX_HEADER_VALUE_LEN) {
|
||||||
|
fieldErrors.push({ index, field: 'value', messageKey: 'settings.customHeadersValidation.valueTooLong' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (fieldErrors.length) return { ok: false, fieldErrors };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function headersRecord(entries: CustomHeaderEntry[]): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const row of entries) {
|
||||||
|
const name = normalizeHeaderName(row.name);
|
||||||
|
if (!name) continue;
|
||||||
|
out[name] = row.value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function headersForServerRequest(
|
||||||
|
profile: Pick<
|
||||||
|
ServerProfile,
|
||||||
|
'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
|
||||||
|
>,
|
||||||
|
requestBaseUrl: string,
|
||||||
|
): Record<string, string> {
|
||||||
|
if (!profile.customHeaders?.length) return {};
|
||||||
|
const normalized = normalizeServerBaseUrl(requestBaseUrl);
|
||||||
|
const endpoint = serverAddressEndpoints(profile).find(e => e.url === normalized);
|
||||||
|
if (!endpoint) return {};
|
||||||
|
const apply = profile.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO;
|
||||||
|
if (apply === 'both' || apply === endpoint.kind) {
|
||||||
|
return headersRecord(profile.customHeaders);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerHttpEndpointWire = {
|
||||||
|
url: string;
|
||||||
|
kind: ServerEndpointKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Payload for Rust registry sync — endpoint kinds from TS dual-address layer. */
|
||||||
|
export function serverHttpContextWireForProfile(
|
||||||
|
server: Pick<
|
||||||
|
ServerProfile,
|
||||||
|
'id' | 'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
|
||||||
|
>,
|
||||||
|
): {
|
||||||
|
serverId: string;
|
||||||
|
appServerId: string;
|
||||||
|
endpoints: ServerHttpEndpointWire[];
|
||||||
|
customHeaders: CustomHeaderEntry[];
|
||||||
|
customHeadersApplyTo: CustomHeadersApplyTo;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
serverId: serverIndexKeyForProfile(server),
|
||||||
|
appServerId: server.id,
|
||||||
|
endpoints: serverAddressEndpoints(server).map(e => ({ url: e.url, kind: e.kind })),
|
||||||
|
customHeaders: server.customHeaders ?? [],
|
||||||
|
customHeadersApplyTo: server.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { flushPlayQueueForServer } from '../../store/queueSync';
|
|||||||
import { markQueueHandoffPending } from '../../store/queueSyncUiState';
|
import { markQueueHandoffPending } from '../../store/queueSyncUiState';
|
||||||
import { endOrbitSession, leaveOrbitSession } from '../orbit';
|
import { endOrbitSession, leaveOrbitSession } from '../orbit';
|
||||||
import { ensureConnectUrlResolved } from './serverEndpoint';
|
import { ensureConnectUrlResolved } from './serverEndpoint';
|
||||||
|
import { syncServerHttpContextForProfile } from './syncServerHttpContext';
|
||||||
|
|
||||||
export async function switchActiveServer(server: ServerProfile): Promise<boolean> {
|
export async function switchActiveServer(server: ServerProfile): Promise<boolean> {
|
||||||
coverTrafficBeginServerSwitch();
|
coverTrafficBeginServerSwitch();
|
||||||
@@ -56,6 +57,7 @@ export async function switchActiveServer(server: ServerProfile): Promise<boolean
|
|||||||
if (oldActiveId && oldActiveId !== server.id) {
|
if (oldActiveId && oldActiveId !== server.id) {
|
||||||
markQueueHandoffPending();
|
markQueueHandoffPending();
|
||||||
}
|
}
|
||||||
|
void syncServerHttpContextForProfile(server);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const invokeMock = vi.hoisted(() => vi.fn(async () => undefined));
|
||||||
|
|
||||||
|
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }));
|
||||||
|
|
||||||
|
import { syncServerHttpContextForProfile } from './syncServerHttpContext';
|
||||||
|
|
||||||
|
const server = {
|
||||||
|
id: 'app-uuid-1',
|
||||||
|
name: 'Gated',
|
||||||
|
url: 'https://music.example.com',
|
||||||
|
username: 'u',
|
||||||
|
password: 'p',
|
||||||
|
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'secret' }],
|
||||||
|
customHeadersApplyTo: 'public' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('syncServerHttpContextForProfile', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
invokeMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the wire payload under the wire key for Tauri struct args', async () => {
|
||||||
|
await syncServerHttpContextForProfile(server);
|
||||||
|
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith('server_http_context_sync', {
|
||||||
|
wire: expect.objectContaining({
|
||||||
|
serverId: expect.any(String),
|
||||||
|
appServerId: 'app-uuid-1',
|
||||||
|
customHeaders: server.customHeaders,
|
||||||
|
customHeadersApplyTo: 'public',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||||
|
import { serverHttpContextWireForProfile } from './serverHttpHeaders';
|
||||||
|
import { serverIndexKeyForProfile } from './serverIndexKey';
|
||||||
|
|
||||||
|
export async function syncServerHttpContextForProfile(server: ServerProfile): Promise<void> {
|
||||||
|
const wire = serverHttpContextWireForProfile(server);
|
||||||
|
await invoke('server_http_context_sync', { wire });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncAllServerHttpContexts(servers: ServerProfile[]): Promise<void> {
|
||||||
|
if (servers.length === 0) return;
|
||||||
|
await invoke('server_http_context_sync_all', {
|
||||||
|
entries: servers.map(s => serverHttpContextWireForProfile(s)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearServerHttpContext(server: Pick<ServerProfile, 'id' | 'url'>): Promise<void> {
|
||||||
|
const indexKey = serverIndexKeyForProfile(server);
|
||||||
|
await invoke('server_http_context_clear', { serverId: indexKey, appServerId: server.id });
|
||||||
|
}
|
||||||
@@ -133,6 +133,7 @@ describe('share search payload resolution', () => {
|
|||||||
sharedServer.username,
|
sharedServer.username,
|
||||||
sharedServer.password,
|
sharedServer.password,
|
||||||
'song-1',
|
'song-1',
|
||||||
|
sharedServer,
|
||||||
);
|
);
|
||||||
expect(mocks.getSong).not.toHaveBeenCalled();
|
expect(mocks.getSong).not.toHaveBeenCalled();
|
||||||
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
|
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
|
||||||
@@ -147,12 +148,14 @@ describe('share search payload resolution', () => {
|
|||||||
sharedServer.username,
|
sharedServer.username,
|
||||||
sharedServer.password,
|
sharedServer.password,
|
||||||
'album-1',
|
'album-1',
|
||||||
|
sharedServer,
|
||||||
);
|
);
|
||||||
expect(mocks.getArtistWithCredentials).toHaveBeenCalledWith(
|
expect(mocks.getArtistWithCredentials).toHaveBeenCalledWith(
|
||||||
sharedServer.url,
|
sharedServer.url,
|
||||||
sharedServer.username,
|
sharedServer.username,
|
||||||
sharedServer.password,
|
sharedServer.password,
|
||||||
'artist-1',
|
'artist-1',
|
||||||
|
sharedServer,
|
||||||
);
|
);
|
||||||
expect(mocks.getAlbum).not.toHaveBeenCalled();
|
expect(mocks.getAlbum).not.toHaveBeenCalled();
|
||||||
expect(mocks.getArtist).not.toHaveBeenCalled();
|
expect(mocks.getArtist).not.toHaveBeenCalled();
|
||||||
@@ -172,6 +175,7 @@ describe('share search payload resolution', () => {
|
|||||||
sharedServer.username,
|
sharedServer.username,
|
||||||
sharedServer.password,
|
sharedServer.password,
|
||||||
'composer-1',
|
'composer-1',
|
||||||
|
sharedServer,
|
||||||
);
|
);
|
||||||
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
|
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ async function resolveSharedSong(
|
|||||||
lookup.server.username,
|
lookup.server.username,
|
||||||
lookup.server.password,
|
lookup.server.password,
|
||||||
id,
|
id,
|
||||||
|
lookup.server,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +188,7 @@ export async function resolveShareSearchAlbum(
|
|||||||
lookup.server.username,
|
lookup.server.username,
|
||||||
lookup.server.password,
|
lookup.server.password,
|
||||||
payload.id,
|
payload.id,
|
||||||
|
lookup.server,
|
||||||
);
|
);
|
||||||
return { type: 'ok', album };
|
return { type: 'ok', album };
|
||||||
} catch {
|
} catch {
|
||||||
@@ -214,6 +216,7 @@ export async function resolveShareSearchArtist(
|
|||||||
lookup.server.username,
|
lookup.server.username,
|
||||||
lookup.server.password,
|
lookup.server.password,
|
||||||
payload.id,
|
payload.id,
|
||||||
|
lookup.server,
|
||||||
);
|
);
|
||||||
return { type: 'ok', artist };
|
return { type: 'ok', artist };
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user