diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 493caedf..40992890 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1,5 +1,5 @@ use std::io::{Cursor, Read, Seek, SeekFrom}; -use std::sync::{Arc, Mutex, OnceLock, TryLockError}; +use std::sync::{Arc, Mutex, OnceLock, RwLock, TryLockError}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering}; use std::time::{Duration, Instant}; #[cfg(unix)] @@ -1980,7 +1980,7 @@ pub struct AudioEngine { pub current: Arc>, /// Monotonically incremented on each audio_play (non-chain) / audio_stop call. pub generation: Arc, - pub http_client: reqwest::Client, + pub http_client: Arc>, pub eq_gains: Arc<[AtomicU32; 10]>, pub eq_enabled: Arc, pub eq_pre_gain: Arc, @@ -2240,12 +2240,14 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { fadeout_samples: None, })), generation: Arc::new(AtomicU64::new(0)), - http_client: reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .use_rustls_tls() - .user_agent(format!("psysonic/{}", env!("CARGO_PKG_VERSION"))) - .build() - .unwrap_or_default(), + http_client: Arc::new(RwLock::new( + reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .use_rustls_tls() + .user_agent(crate::subsonic_wire_user_agent()) + .build() + .unwrap_or_default(), + )), eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))), eq_enabled: Arc::new(AtomicBool::new(false)), eq_pre_gain: Arc::new(AtomicU32::new(0f32.to_bits())), @@ -2267,6 +2269,26 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { (engine, thread) } +fn audio_http_client(state: &AudioEngine) -> reqwest::Client { + state + .http_client + .read() + .map(|c| c.clone()) + .unwrap_or_default() +} + +pub fn refresh_http_user_agent(state: &AudioEngine, ua: &str) { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .use_rustls_tls() + .user_agent(ua) + .build() + .unwrap_or_default(); + if let Ok(mut slot) = state.http_client.write() { + *slot = client; + } +} + // ─── Event payloads ─────────────────────────────────────────────────────────── #[derive(Clone, Serialize)] @@ -2355,7 +2377,7 @@ async fn fetch_data( return Ok(Some(data)); } - let response = state.http_client.get(url).send().await.map_err(|e| e.to_string())?; + let response = audio_http_client(&state).get(url).send().await.map_err(|e| e.to_string())?; let status = response.status(); let ct = response.headers() .get(reqwest::header::CONTENT_TYPE) @@ -2581,7 +2603,7 @@ pub async fn audio_play( tag: "local-file", } } else if manual && !stream_cache_hit && !preloaded_hit && !is_local { - let response = state.http_client.get(&url).send().await.map_err(|e| e.to_string())?; + let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?; if !response.status().is_success() { if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); // superseded @@ -2619,7 +2641,7 @@ pub async fn audio_play( tokio::spawn(ranged_download_task( gen, state.generation.clone(), - state.http_client.clone(), + audio_http_client(&state), url.clone(), response, buf.clone(), @@ -2656,7 +2678,7 @@ pub async fn audio_play( tokio::spawn(track_download_task( gen, state.generation.clone(), - state.http_client.clone(), + audio_http_client(&state), url.clone(), response, prod, @@ -3016,7 +3038,7 @@ pub async fn audio_chain_preload( if let Some(path) = url.strip_prefix("psysonic-local://") { tokio::fs::read(path).await.map_err(|e| e.to_string())? } else { - let resp = state.http_client.get(&url).send().await + let resp = audio_http_client(&state).get(&url).send().await .map_err(|e| e.to_string())?; if !resp.status().is_success() { return Ok(()); // silently fail — audio_play will retry @@ -3318,7 +3340,7 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu gen, state.generation.clone(), None, // task performs its own fresh GET - state.http_client.clone(), + audio_http_client(&state), url, new_prod, flags.clone(), @@ -3498,7 +3520,7 @@ pub fn audio_update_replay_gain( /// Proxy: fetches https://autoeq.app/entries via Rust to bypass WebView CORS restrictions. #[tauri::command] pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result { - state.http_client + audio_http_client(&state) .get("https://autoeq.app/entries") .send().await.map_err(|e| e.to_string())? .text().await.map_err(|e| e.to_string()) @@ -3532,7 +3554,7 @@ pub async fn autoeq_fetch_profile( }; for url in &candidates { - let resp = state.http_client.get(url).send().await.map_err(|e| e.to_string())?; + let resp = audio_http_client(&state).get(url).send().await.map_err(|e| e.to_string())?; if resp.status().is_success() { return resp.text().await.map_err(|e| e.to_string()); } @@ -3575,7 +3597,7 @@ pub async fn audio_preload( let data: Vec = if let Some(path) = url.strip_prefix("psysonic-local://") { tokio::fs::read(path).await.map_err(|e| e.to_string())? } else { - let response = state.http_client.get(&url).send().await.map_err(|e| e.to_string())?; + let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?; if !response.status().is_success() { return Ok(()); } @@ -3613,7 +3635,7 @@ pub async fn audio_play_radio( if let Some(old) = state.fading_out_sink.lock().unwrap().take() { old.stop(); } // ── Open initial HTTP connection ────────────────────────────────────────── - let response = state.http_client + let response = audio_http_client(&state) .get(&url) .header("Icy-MetaData", "1") .send() @@ -3653,7 +3675,7 @@ pub async fn audio_play_radio( gen, state.generation.clone(), Some(response), - state.http_client.clone(), + audio_http_client(&state), url.clone(), prod, flags.clone(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d51ca553..8bbbc8bb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,7 +9,7 @@ pub(crate) mod logging; mod taskbar_win; use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; use std::sync::atomic::{AtomicBool, Ordering}; use tauri::{ @@ -30,6 +30,25 @@ type ShortcutMap = Mutex>; /// The frontend queues more tasks than this; Rust is the real throttle. const MAX_DL_CONCURRENCY: usize = 4; +fn default_subsonic_wire_user_agent() -> String { + format!("psysonic/{}", env!("CARGO_PKG_VERSION")) +} + +fn runtime_subsonic_wire_user_agent() -> &'static RwLock { + static UA: OnceLock> = OnceLock::new(); + UA.get_or_init(|| RwLock::new(default_subsonic_wire_user_agent())) +} + +/// Unified outbound User-Agent for all Rust-side HTTP requests. +/// It is initialized with `psysonic/` and then overridden from +/// the main WebView `navigator.userAgent` at app startup. +pub(crate) fn subsonic_wire_user_agent() -> String { + runtime_subsonic_wire_user_agent() + .read() + .map(|ua| ua.clone()) + .unwrap_or_else(|_| default_subsonic_wire_user_agent()) +} + /// Shared semaphore that caps simultaneous `download_track_offline` executions. type DownloadSemaphore = Arc; @@ -134,6 +153,30 @@ fn export_runtime_logs(path: String) -> Result { crate::logging::export_logs_to_file(&path) } +#[tauri::command] +fn set_subsonic_wire_user_agent( + user_agent: String, + window_label: String, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + if window_label != "main" { + return Ok(()); + } + let ua = user_agent.trim(); + if ua.is_empty() { + return Err("user agent is empty".to_string()); + } + let mut guard = runtime_subsonic_wire_user_agent() + .write() + .map_err(|_| "user agent state poisoned".to_string())?; + guard.clear(); + guard.push_str(ua); + drop(guard); + + crate::audio::refresh_http_user_agent(&app_handle.state::(), ua); + Ok(()) +} + /// Authenticate with Navidrome's own REST API and return a Bearer token. async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result { @@ -449,7 +492,7 @@ async fn search_radio_browser(query: String, offset: u32) -> Result Result, S let offset_s = offset.to_string(); let resp = client .get("https://de1.api.radio-browser.info/json/stations/topvote") - .header("User-Agent", "psysonic/1.0") + .header("User-Agent", subsonic_wire_user_agent()) .query(&[("limit", limit_s.as_str()), ("offset", offset_s.as_str())]) .send() .await @@ -487,12 +530,12 @@ async fn get_top_radio_stations(offset: u32) -> Result, S #[tauri::command] async fn fetch_url_bytes(url: String) -> Result<(Vec, String), String> { let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) .timeout(std::time::Duration::from_secs(10)) .build() .map_err(|e| e.to_string())?; let resp = client .get(&url) - .header("User-Agent", "psysonic/1.0") .send() .await .map_err(|e| e.to_string())? @@ -517,12 +560,12 @@ async fn fetch_url_bytes(url: String) -> Result<(Vec, String), String> { #[tauri::command] async fn fetch_json_url(url: String) -> Result { let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) .timeout(std::time::Duration::from_secs(10)) .build() .map_err(|e| e.to_string())?; let resp = client .get(&url) - .header("User-Agent", "psysonic/1.0") .header("Accept", "application/json") .send() .await @@ -581,7 +624,6 @@ async fn resolve_playlist_url(client: &reqwest::Client, url: &str) -> Option Result { use futures_util::StreamExt; let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) .timeout(std::time::Duration::from_secs(15)) .build() .map_err(|e| e.to_string())?; @@ -627,7 +670,6 @@ async fn fetch_icy_metadata(url: String) -> Result { let resp = client .get(&url) .header("Icy-MetaData", "1") - .header("User-Agent", "psysonic/1.0") .send() .await .map_err(|e| e.to_string())?; @@ -762,14 +804,14 @@ async fn lastfm_request( client .get("https://ws.audioscrobbler.com/2.0/") .query(&map) - .header("User-Agent", "psysonic/1.13.0") + .header("User-Agent", subsonic_wire_user_agent()) .send() .await } else { client .post("https://ws.audioscrobbler.com/2.0/") .form(&map) - .header("User-Agent", "psysonic/1.13.0") + .header("User-Agent", subsonic_wire_user_agent()) .send() .await }.map_err(|e| e.to_string())?; @@ -957,6 +999,7 @@ async fn download_track_offline( let _permit = dl_sem.acquire().await.map_err(|e| e.to_string())?; let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) .timeout(std::time::Duration::from_secs(120)) .build() .map_err(|e| e.to_string())?; @@ -1136,6 +1179,7 @@ async fn download_update(url: String, filename: String, app: tauri::AppHandle) - let part_path = dest_dir.join(format!("{}.part", filename)); let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) .connect_timeout(Duration::from_secs(30)) .timeout(Duration::from_secs(3600)) .build() @@ -1198,6 +1242,7 @@ async fn download_update(url: String, filename: String, app: tauri::AppHandle) - #[tauri::command] async fn fetch_netease_lyrics(artist: String, title: String) -> Result, String> { let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) .timeout(std::time::Duration::from_secs(8)) .build() .map_err(|e| e.to_string())?; @@ -1206,7 +1251,6 @@ async fn fetch_netease_lyrics(artist: String, title: String) -> Result Result Result { let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) .timeout(std::time::Duration::from_secs(30)) .build() .map_err(|e| e.to_string())?; @@ -2419,6 +2466,7 @@ async fn sync_batch_to_device( // Shared reqwest client — reused across all downloads. let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) .timeout(Duration::from_secs(300)) .build() .map_err(|e| e.to_string())?; @@ -3355,6 +3403,7 @@ pub fn run() { set_linux_webkit_smooth_scrolling, set_logging_mode, export_runtime_logs, + set_subsonic_wire_user_agent, no_compositing_mode, is_tiling_wm_cmd, open_mini_player, diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 47437720..7b44615e 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -9,6 +9,8 @@ import { type SubsonicServerIdentity, } from '../utils/subsonicServerIdentity'; +const SUBSONIC_CLIENT = `psysonic/${version}`; + // ─── Secure random salt ──────────────────────────────────────── function secureRandomSalt(): string { const buf = new Uint8Array(8); @@ -20,7 +22,7 @@ function secureRandomSalt(): string { function getAuthParams(username: string, password: string) { const salt = secureRandomSalt(); const token = md5(password + salt); - return { u: username, t: token, s: salt, v: '1.16.1', c: `psysonic/${version}`, f: 'json' }; + return { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' }; } export function getClient() { @@ -287,7 +289,7 @@ export async function pingWithCredentials( const salt = secureRandomSalt(); const token = md5(password + salt); const resp = await axios.get(`${base}/rest/ping.view`, { - params: { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' }, + params: { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' }, paramsSerializer: { indexes: null }, timeout: 15000, }); @@ -944,7 +946,7 @@ export function buildStreamUrl(id: string): string { const p = new URLSearchParams({ id, u: server?.username ?? '', - t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json', + t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json', }); return `${baseUrl}/rest/stream.view?${p.toString()}`; } @@ -964,7 +966,7 @@ export function buildCoverArtUrl(id: string, size = 256): string { const p = new URLSearchParams({ id, size: String(size), u: server?.username ?? '', - t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json', + t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json', }); return `${baseUrl}/rest/getCoverArt.view?${p.toString()}`; } @@ -978,7 +980,7 @@ export function buildDownloadUrl(id: string): string { const p = new URLSearchParams({ id, u: server?.username ?? '', - t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json', + t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json', }); return `${baseUrl}/rest/download.view?${p.toString()}`; } diff --git a/src/main.tsx b/src/main.tsx index 1b0e4e89..656a4a30 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react'; import ReactDOM from 'react-dom/client'; +import { invoke } from '@tauri-apps/api/core'; import { getCurrentWindow } from '@tauri-apps/api/window'; import App from './App'; import './i18n'; @@ -15,6 +16,19 @@ try { (window as any).__PSY_WINDOW_LABEL__ = 'main'; } +// Sync backend HTTP User-Agent from the main webview once at startup. +try { + const windowLabel = (window as any).__PSY_WINDOW_LABEL__ ?? 'main'; + if (windowLabel === 'main') { + const ua = window.navigator.userAgent?.trim(); + if (ua) { + void invoke('set_subsonic_wire_user_agent', { userAgent: ua, windowLabel }); + } + } +} catch { + // Ignore in non-Tauri runtimes. +} + ReactDOM.createRoot(document.getElementById('root')!).render(