diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 313f88c1..493caedf 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -636,7 +636,7 @@ impl Read for AudioStreamReader { return Ok(0); } if std::time::Instant::now() >= self.deadline { - eprintln!( + crate::app_eprintln!( "[{}] AudioStreamReader: {}s without data → EOF", self.source_tag, RADIO_READ_TIMEOUT_SECS @@ -892,7 +892,7 @@ async fn radio_download_task( Some(r) => r, None => { if reconnect_count >= MAX_CONSECUTIVE_FAILURES { - eprintln!("[radio] {MAX_CONSECUTIVE_FAILURES} consecutive failures — giving up"); + crate::app_eprintln!("[radio] {MAX_CONSECUTIVE_FAILURES} consecutive failures — giving up"); break 'outer; } tokio::time::sleep(Duration::from_millis(500)).await; @@ -904,15 +904,15 @@ async fn radio_download_task( .await { Ok(r) if r.status().is_success() => { - eprintln!("[radio] reconnected ({bytes_total} B so far)"); + crate::app_eprintln!("[radio] reconnected ({bytes_total} B so far)"); r } Ok(r) => { - eprintln!("[radio] reconnect: HTTP {} — giving up", r.status()); + crate::app_eprintln!("[radio] reconnect: HTTP {} — giving up", r.status()); break 'outer; } Err(e) => { - eprintln!("[radio] reconnect error: {e} — giving up"); + crate::app_eprintln!("[radio] reconnect error: {e} — giving up"); break 'outer; } } @@ -942,7 +942,7 @@ async fn radio_download_task( let fill_pct = ((1.0 - prod.free_len() as f32 / RADIO_BUF_CAPACITY as f32) * 100.0) as u32; - eprintln!( + crate::app_eprintln!( "[radio] hard pause: {fill_pct}% full, \ paused >{RADIO_HARD_PAUSE_SECS}s → disconnecting" ); @@ -968,7 +968,7 @@ async fn radio_download_task( if let Some(ref mut interceptor) = icy { if let Some(meta) = interceptor.process(&chunk, &mut audio_scratch) { let label = if meta.is_ad { "[Ad]" } else { "" }; - eprintln!("[radio] ICY StreamTitle: {}{}", label, meta.title); + crate::app_eprintln!("[radio] ICY StreamTitle: {}{}", label, meta.title); let _ = app.emit("radio:metadata", &meta); } } else { @@ -989,12 +989,12 @@ async fn radio_download_task( } Some(Err(e)) => { reconnect_count += 1; - eprintln!("[radio] stream error: {e} → reconnecting (consecutive #{reconnect_count})"); + crate::app_eprintln!("[radio] stream error: {e} → reconnecting (consecutive #{reconnect_count})"); break 'inner; } None => { reconnect_count += 1; - eprintln!("[radio] stream ended cleanly → reconnecting (consecutive #{reconnect_count})"); + crate::app_eprintln!("[radio] stream ended cleanly → reconnecting (consecutive #{reconnect_count})"); break 'inner; } } @@ -1007,7 +1007,7 @@ async fn radio_download_task( // within a few seconds rather than minutes. } // 'outer - eprintln!("[radio] download task done ({bytes_total} B total)"); + crate::app_eprintln!("[radio] download task done ({bytes_total} B total)"); } /// One-shot HTTP downloader for track streaming starts. @@ -1044,7 +1044,7 @@ async fn track_download_task( Ok(r) => r, Err(err) => { if reconnects >= TRACK_STREAM_MAX_RECONNECTS { - eprintln!( + crate::app_eprintln!( "[audio] streaming reconnect failed after {} attempts: {}", reconnects, err ); @@ -1058,7 +1058,7 @@ async fn track_download_task( } }; if downloaded > 0 && response.status() != reqwest::StatusCode::PARTIAL_CONTENT { - eprintln!( + crate::app_eprintln!( "[audio] streaming reconnect returned {}, expected 206 for range resume", response.status() ); @@ -1066,7 +1066,7 @@ async fn track_download_task( return; } if downloaded == 0 && !response.status().is_success() { - eprintln!("[audio] streaming HTTP {}", response.status()); + crate::app_eprintln!("[audio] streaming HTTP {}", response.status()); done.store(true, Ordering::SeqCst); return; } @@ -1081,7 +1081,7 @@ async fn track_download_task( Ok(c) => c, Err(e) => { if reconnects >= TRACK_STREAM_MAX_RECONNECTS { - eprintln!( + crate::app_eprintln!( "[audio] streaming download error after {} reconnects: {}", reconnects, e ); @@ -1089,7 +1089,7 @@ async fn track_download_task( return; } reconnects += 1; - eprintln!( + crate::app_eprintln!( "[audio] streaming download error (attempt {}/{}): {} — reconnecting", reconnects, TRACK_STREAM_MAX_RECONNECTS, @@ -1155,9 +1155,7 @@ async fn ranged_download_task( let mut downloaded: usize = 0; let mut reconnects: u32 = 0; let mut next_response: Option = Some(initial_response); - #[cfg(debug_assertions)] let dl_started = Instant::now(); - #[cfg(debug_assertions)] let mut next_progress_mb: usize = 1; 'outer: loop { @@ -1172,7 +1170,7 @@ async fn ranged_download_task( Ok(r) => r, Err(err) => { if reconnects >= TRACK_STREAM_MAX_RECONNECTS { - eprintln!( + crate::app_eprintln!( "[audio] ranged reconnect failed after {} attempts: {}", reconnects, err ); @@ -1185,14 +1183,14 @@ async fn ranged_download_task( } }; if downloaded > 0 && response.status() != reqwest::StatusCode::PARTIAL_CONTENT { - eprintln!( + crate::app_eprintln!( "[audio] ranged reconnect returned {}, expected 206", response.status() ); break 'outer; } if downloaded == 0 && !response.status().is_success() { - eprintln!("[audio] ranged HTTP {}", response.status()); + crate::app_eprintln!("[audio] ranged HTTP {}", response.status()); break 'outer; } @@ -1206,14 +1204,14 @@ async fn ranged_download_task( Ok(c) => c, Err(e) => { if reconnects >= TRACK_STREAM_MAX_RECONNECTS { - eprintln!( + crate::app_eprintln!( "[audio] ranged dl error after {} reconnects: {}", reconnects, e ); break 'outer; } reconnects += 1; - eprintln!( + crate::app_eprintln!( "[audio] ranged dl error (attempt {}/{}): {} — reconnecting", reconnects, TRACK_STREAM_MAX_RECONNECTS, e ); @@ -1233,19 +1231,16 @@ async fn ranged_download_task( } downloaded += n; downloaded_to.store(downloaded, Ordering::SeqCst); - #[cfg(debug_assertions)] - { - let mb = downloaded / (1024 * 1024); - if mb >= next_progress_mb { - let pct = (downloaded as f64 / total_size as f64 * 100.0) as u32; - eprintln!( - "[stream] dl progress: {} MB / {} MB ({}%)", - mb, - total_size / (1024 * 1024), - pct - ); - next_progress_mb = mb + 1; - } + let mb = downloaded / (1024 * 1024); + if mb >= next_progress_mb { + let pct = (downloaded as f64 / total_size as f64 * 100.0) as u32; + crate::app_deprintln!( + "[stream] dl progress: {} MB / {} MB ({}%)", + mb, + total_size / (1024 * 1024), + pct + ); + next_progress_mb = mb + 1; } if downloaded >= total_size { break; @@ -1257,8 +1252,7 @@ async fn ranged_download_task( done.store(true, Ordering::SeqCst); - #[cfg(debug_assertions)] - eprintln!( + crate::app_deprintln!( "[stream] dl done: {} / {} bytes in {:.2}s ({} reconnects)", downloaded, total_size, @@ -1269,8 +1263,7 @@ async fn ranged_download_task( if downloaded == total_size && total_size > 0 && total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES { let data = buf.lock().unwrap().clone(); *promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data }); - #[cfg(debug_assertions)] - eprintln!("[stream] promoted to stream_completed_cache for replay"); + crate::app_deprintln!("[stream] promoted to stream_completed_cache for replay"); } } @@ -1326,9 +1319,8 @@ impl MediaSource for SizedCursorSource { // Implements Iterator + Source — identical interface to // rodio::Decoder, so the rest of the source chain is unchanged. -/// Dev-build only: format codec parameters into a human-readable line for -/// the terminal so you can verify whether playback is genuinely lossless. -#[cfg(debug_assertions)] +/// Debug logging: codec parameters in human-readable form to verify whether +/// playback is genuinely lossless. fn log_codec_resolution( tag: &str, params: &symphonia::core::codecs::CodecParameters, @@ -1352,7 +1344,7 @@ fn log_codec_resolution( "flac" | "alac" | "wavpack" | "monkeys-audio" | "tta" | "shorten" ); let kind = if lossless { "LOSSLESS" } else { "lossy" }; - eprintln!( + crate::app_deprintln!( "[stream] {tag}: codec={codec_name} ({kind}) {bits} {rate} {ch} container={}", container_hint.unwrap_or("?") ); @@ -1418,7 +1410,7 @@ impl SizedDecoder { .map_err(|e| { let hint_str = format_hint.unwrap_or("unknown"); // Always print the raw Symphonia error to the terminal for diagnosis. - eprintln!("[psysonic] probe failed (hint={hint_str}): {e}"); + crate::app_eprintln!("[psysonic] probe failed (hint={hint_str}): {e}"); if e.to_string().to_lowercase().contains("unsupported") { format!("unsupported format: .{hint_str} files cannot be played (no demuxer)") } else { @@ -1437,7 +1429,7 @@ impl SizedDecoder { && t.codec_params.sample_rate.is_some() }) .ok_or_else(|| { - eprintln!("[psysonic] no audio track found among {} tracks", probed.format.tracks().len()); + crate::app_eprintln!("[psysonic] no audio track found among {} tracks", probed.format.tracks().len()); "no playable audio track found in file".to_string() })?; @@ -1446,13 +1438,12 @@ impl SizedDecoder { .zip(track.codec_params.n_frames) .map(|(base, frames)| base.calc_time(frames)); - #[cfg(debug_assertions)] log_codec_resolution("bytes", &track.codec_params, format_hint); let mut decoder = psysonic_codec_registry() .make(&track.codec_params, &DecoderOptions::default()) .map_err(|e| { - eprintln!("[psysonic] codec init failed: {e}"); + crate::app_eprintln!("[psysonic] codec init failed: {e}"); if e.to_string().to_lowercase().contains("unsupported") { "unsupported codec: no decoder available for this audio format".to_string() } else { @@ -1473,25 +1464,25 @@ impl SizedDecoder { break decoder.last_decoded(); } Err(e) => { - eprintln!("[psysonic] next_packet error: {e}"); + crate::app_eprintln!("[psysonic] next_packet error: {e}"); return Err(format!("could not read audio data: {e}")); } }; if packet.track_id() != track_id { - eprintln!("[psysonic] skipping packet for track {} (want {})", packet.track_id(), track_id); + crate::app_eprintln!("[psysonic] skipping packet for track {} (want {})", packet.track_id(), track_id); continue; } match decoder.decode(&packet) { Ok(decoded) => break decoded, Err(symphonia::core::errors::Error::DecodeError(ref msg)) => { decode_errors += 1; - eprintln!("[psysonic] init: dropped corrupt frame #{decode_errors}: {msg}"); + crate::app_eprintln!("[psysonic] init: dropped corrupt frame #{decode_errors}: {msg}"); if decode_errors >= MAX_CONSECUTIVE_DECODE_ERRORS { return Err("too many consecutive decode errors during init — file may be corrupt".into()); } } Err(e) => { - eprintln!("[psysonic] fatal decode error: {e}"); + crate::app_eprintln!("[psysonic] fatal decode error: {e}"); return Err(format!("audio decode error: {e}")); } } @@ -1533,7 +1524,6 @@ impl SizedDecoder { .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) .ok_or_else(|| format!("{source_tag}: no audio track found"))?; let track_id = track.id; - #[cfg(debug_assertions)] log_codec_resolution(source_tag, &track.codec_params, format_hint); // Live streams have no known total frame count → total_duration = None. let total_duration = None; @@ -1552,7 +1542,7 @@ impl SizedDecoder { Ok(d) => break d, Err(symphonia::core::errors::Error::DecodeError(ref msg)) => { errors += 1; - eprintln!("[psysonic] {source_tag} init: dropped corrupt frame #{errors}: {msg}"); + crate::app_eprintln!("[psysonic] {source_tag} init: dropped corrupt frame #{errors}: {msg}"); if errors >= MAX_CONSECUTIVE_DECODE_ERRORS { return Err(format!("{source_tag}: too many consecutive decode errors")); } @@ -1631,18 +1621,16 @@ impl Iterator for SizedDecoder { let _ = msg; self.consecutive_decode_errors += 1; // Log sparingly: first drop, then every 10th to avoid spam. - #[cfg(debug_assertions)] if self.consecutive_decode_errors == 1 || self.consecutive_decode_errors % 10 == 0 { - eprintln!( + crate::app_deprintln!( "[psysonic] dropped corrupt frame #{}: {msg}", self.consecutive_decode_errors ); } if self.consecutive_decode_errors >= MAX_CONSECUTIVE_DECODE_ERRORS { - #[cfg(debug_assertions)] - eprintln!( + crate::app_deprintln!( "[psysonic] {MAX_CONSECUTIVE_DECODE_ERRORS} consecutive decode \ failures — stream appears unrecoverable, stopping" ); @@ -2112,7 +2100,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) if let Ok((stream, handle)) = rodio::OutputStream::try_from_device_config(&device, config) { - eprintln!("[psysonic] audio stream opened at {} Hz (exact)", desired_rate); + crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (exact)", desired_rate); return (stream, handle, desired_rate); } } @@ -2128,7 +2116,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) if let Ok((stream, handle)) = rodio::OutputStream::try_from_device_config(&device, config) { - eprintln!( + crate::app_eprintln!( "[psysonic] audio stream opened at {} Hz (highest, wanted {})", rate, desired_rate ); @@ -2144,13 +2132,13 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) .default_output_config() .map(|c| c.sample_rate().0) .unwrap_or(44100); - eprintln!("[psysonic] audio stream opened at {} Hz (device default)", rate); + crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (device default)", rate); return (stream, handle, rate); } } // 4. Last resort: system default. - eprintln!("[psysonic] audio stream falling back to system default"); + crate::app_eprintln!("[psysonic] audio stream falling back to system default"); let (stream, handle) = rodio::OutputStream::try_default() .expect("cannot open any audio output device"); let rate = rodio::cpal::default_host() @@ -2368,24 +2356,21 @@ async fn fetch_data( } let response = state.http_client.get(url).send().await.map_err(|e| e.to_string())?; - #[cfg(debug_assertions)] - { - let status = response.status(); - let ct = response.headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("-"); - let server_hdr = response.headers() - .get("server") - .and_then(|v| v.to_str().ok()) - .unwrap_or("-"); - // Strip auth params from URL before logging. - let safe_url = url.split('?').next().unwrap_or(url); - eprintln!( - "[audio] fetch {} → {} | content-type: {} | server: {}", - safe_url, status, ct, server_hdr - ); - } + let status = response.status(); + let ct = response.headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("-"); + let server_hdr = response.headers() + .get("server") + .and_then(|v| v.to_str().ok()) + .unwrap_or("-"); + // Strip auth params from URL before logging. + let safe_url = url.split('?').next().unwrap_or(url); + crate::app_deprintln!( + "[audio] fetch {} → {} | content-type: {} | server: {}", + safe_url, status, ct, server_hdr + ); if !response.status().is_success() { if state.generation.load(Ordering::SeqCst) != gen { return Ok(None); // superseded @@ -2584,8 +2569,7 @@ pub async fn audio_play( .extension() .and_then(|e| e.to_str()) .map(|s| s.to_lowercase()); - #[cfg(debug_assertions)] - eprintln!( + crate::app_deprintln!( "[stream] LocalFileSource selected — size={} KB, hint={:?}", len / 1024, local_hint @@ -2624,8 +2608,7 @@ pub async fn audio_play( if let (true, Some(total)) = (supports_range, total_size) { let total_usize = total as usize; - #[cfg(debug_assertions)] - eprintln!( + crate::app_deprintln!( "[stream] RangedHttpSource selected — total={} KB, hint={:?}", total_usize / 1024, stream_hint @@ -2659,8 +2642,7 @@ pub async fn audio_play( tag: "ranged-stream", } } else { - #[cfg(debug_assertions)] - eprintln!( + crate::app_deprintln!( "[stream] legacy AudioStreamReader (non-seekable) — accept-ranges={}, content-length={:?}", supports_range, total_size ); @@ -2848,7 +2830,7 @@ pub async fn audio_play( } } Err(_) => { - eprintln!("[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz"); + crate::app_eprintln!("[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz"); } } } @@ -3100,7 +3082,7 @@ pub async fn audio_chain_preload( let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 }; let stream_rate = state.stream_sample_rate.load(Ordering::Relaxed); if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate { - eprintln!( + crate::app_eprintln!( "[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz", next_rate, stream_rate ); @@ -3187,7 +3169,7 @@ fn spawn_progress_task( // Radio (dur == 0): stream exhausted / connection dropped → stop. let cur_dur = current_arc.lock().unwrap().duration_secs; if cur_dur <= 0.0 { - eprintln!("[radio] current_done fired → emitting audio:ended (dur=0)"); + crate::app_eprintln!("[radio] current_done fired → emitting audio:ended (dur=0)"); gen_counter.fetch_add(1, Ordering::SeqCst); app.emit("audio:ended", ()).ok(); break; @@ -3349,7 +3331,7 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu rs.flags.is_paused.store(false, Ordering::Release); } } else { - eprintln!("[radio] resume: AudioStreamReader gone — skipping reconnect"); + crate::app_eprintln!("[radio] resume: AudioStreamReader gone — skipping reconnect"); } } @@ -3409,12 +3391,10 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str // restart-fallback engages instead of rolling the dice on the format reader // (which can consume the ring buffer to EOF for forward seeks → next song). if !state.current_is_seekable.load(Ordering::SeqCst) { - #[cfg(debug_assertions)] - eprintln!("[seek] rejected → not-seekable source (legacy stream)"); + crate::app_deprintln!("[seek] rejected → not-seekable source (legacy stream)"); return Err("source is not seekable".into()); } - #[cfg(debug_assertions)] - eprintln!("[seek] target={:.2}s", seconds); + crate::app_deprintln!("[seek] target={:.2}s", seconds); let lock_current_with_timeout = |timeout_ms: u64| { let deadline = Instant::now() + Duration::from_millis(timeout_ms); @@ -4033,7 +4013,7 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) { if pinned_miss_count < 3 { continue; } - eprintln!("[psysonic] device-watcher: pinned device '{dev_name}' disconnected, falling back to system default"); + crate::app_eprintln!("[psysonic] device-watcher: pinned device '{dev_name}' disconnected, falling back to system default"); pinned_miss_count = 0; *selected_device.lock().unwrap() = None; @@ -4088,7 +4068,7 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) { }).await.unwrap_or(None); let Some(handle) = new_handle else { - eprintln!("[psysonic] device-watcher: stream reopen timed out"); + crate::app_eprintln!("[psysonic] device-watcher: stream reopen timed out"); continue; }; diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index 587e731f..97e497bd 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -169,14 +169,14 @@ pub fn try_completions_dispatch(args: &[String]) -> Option { Some(0) } Some(x) => { - eprintln!("NOT OK: unknown completions subcommand {x:?} (expected: bash, zsh, help)"); + crate::app_eprintln!("NOT OK: unknown completions subcommand {x:?} (expected: bash, zsh, help)"); Some(2) } } } fn print_completions_install_help(program: &str) { - eprintln!( + crate::app_eprintln!( "Psysonic embeds bash/zsh completion scripts in this binary.\n\ \n\ Bash — try once in this shell:\n\ @@ -213,58 +213,58 @@ pub fn write_cli_snapshot(payload: &Value) -> Result<(), String> { pub fn print_help(program: &str) { let version = env!("CARGO_PKG_VERSION"); - eprintln!("Psysonic {version}\n"); - eprintln!("── Start ──"); - eprintln!(" {program}"); - eprintln!(" {program} --version | -V Print version and exit."); - eprintln!(" {program} --help | -h Show this help.\n"); - eprintln!("── Shell completion (scripts are embedded in the binary) ──"); - eprintln!(" {program} completions How to enable tab completion in bash / zsh."); - eprintln!(" {program} completions bash Print bash completion script (stdout)."); - eprintln!(" {program} completions zsh Print zsh _psysonic script (stdout).\n"); - eprintln!("── Snapshot (saved play state / queue) ──"); - eprintln!(" Reads a JSON file written by the running app. Open the main window at least once."); - eprintln!(" {program} --info Human-readable summary."); - eprintln!(" {program} --info --json One JSON object on stdout."); - eprintln!(" Linux: exits with an error if the primary instance is not on the session D-Bus."); - eprintln!(" Windows / macOS: no D-Bus check; an empty or missing file means the UI has not"); - eprintln!(" published a snapshot yet.\n"); - eprintln!("── Remote commands (--player …) ──"); - eprintln!(" Require the main Psysonic process. Same flags on Linux, Windows, and macOS."); - eprintln!(" Linux: a second CLI process can forward over D-Bus without opening another window."); - eprintln!(" Windows / macOS: handled via single-instance (a helper process may run briefly).\n"); - eprintln!(" Global flags (place before --player when needed):"); - eprintln!(" --quiet | -q Suppress \"OK: …\" lines (stderr errors are always shown)."); - eprintln!(" --json With `audio-device list`, `library list`, `server list`, or `search`: JSON on stdout."); - eprintln!(" Use {program} -q --player seek -5 so the seek delta is not parsed as a flag.\n"); - eprintln!(" Playback"); - eprintln!(" {program} [--quiet|-q] --player next | prev | play | pause | stop"); - eprintln!(" {program} [--quiet|-q] --player play Track, album, or artist id (artist → shuffled library)."); - eprintln!(" {program} [--quiet|-q] --player seek Integer delta, e.g. 15 or -10"); - eprintln!(" {program} [--quiet|-q] --player volume <0-100> Absolute volume percent."); - eprintln!(" {program} [--quiet|-q] --player shuffle Shuffle the current queue."); - eprintln!(" {program} [--quiet|-q] --player repeat off|all|one"); - eprintln!(" {program} [--quiet|-q] --player mute | unmute"); - eprintln!(" {program} [--quiet|-q] --player star | unstar Current track (Subsonic star)."); - eprintln!(" {program} [--quiet|-q] --player rating <0-5> Set song rating (0 clears)."); - eprintln!(" {program} [--quiet|-q] --player reload Restart audio for the current track or reload server queue.\n"); - eprintln!(" Audio output"); - eprintln!(" {program} [--json] --player audio-device list"); - eprintln!(" {program} --player audio-device set \n"); - eprintln!(" Music library (Subsonic music folders for the active server)"); - eprintln!(" {program} [--json] --player library list"); - eprintln!(" {program} --player library set all | \n"); - eprintln!(" Servers (saved profiles — same as the in-app server switcher)"); - eprintln!(" {program} [--json] --player server list"); - eprintln!(" {program} --player server set \n"); - eprintln!(" Search (active server; respects library folder filter)"); - eprintln!(" {program} [--json] --player search track "); - eprintln!(" {program} [--json] --player search album "); - eprintln!(" {program} [--json] --player search artist \n"); - eprintln!(" Instant mix (from the track that is currently loaded)"); - eprintln!(" {program} --player mix append"); - eprintln!(" {program} --player mix new\n"); - eprintln!("Exit: 0 on success. Errors print \"NOT OK: …\" on stderr with a non-zero status."); + crate::app_eprintln!("Psysonic {version}\n"); + crate::app_eprintln!("── Start ──"); + crate::app_eprintln!(" {program}"); + crate::app_eprintln!(" {program} --version | -V Print version and exit."); + crate::app_eprintln!(" {program} --help | -h Show this help.\n"); + crate::app_eprintln!("── Shell completion (scripts are embedded in the binary) ──"); + crate::app_eprintln!(" {program} completions How to enable tab completion in bash / zsh."); + crate::app_eprintln!(" {program} completions bash Print bash completion script (stdout)."); + crate::app_eprintln!(" {program} completions zsh Print zsh _psysonic script (stdout).\n"); + crate::app_eprintln!("── Snapshot (saved play state / queue) ──"); + crate::app_eprintln!(" Reads a JSON file written by the running app. Open the main window at least once."); + crate::app_eprintln!(" {program} --info Human-readable summary."); + crate::app_eprintln!(" {program} --info --json One JSON object on stdout."); + crate::app_eprintln!(" Linux: exits with an error if the primary instance is not on the session D-Bus."); + crate::app_eprintln!(" Windows / macOS: no D-Bus check; an empty or missing file means the UI has not"); + crate::app_eprintln!(" published a snapshot yet.\n"); + crate::app_eprintln!("── Remote commands (--player …) ──"); + crate::app_eprintln!(" Require the main Psysonic process. Same flags on Linux, Windows, and macOS."); + crate::app_eprintln!(" Linux: a second CLI process can forward over D-Bus without opening another window."); + crate::app_eprintln!(" Windows / macOS: handled via single-instance (a helper process may run briefly).\n"); + crate::app_eprintln!(" Global flags (place before --player when needed):"); + crate::app_eprintln!(" --quiet | -q Suppress \"OK: …\" lines (stderr errors are always shown)."); + crate::app_eprintln!(" --json With `audio-device list`, `library list`, `server list`, or `search`: JSON on stdout."); + crate::app_eprintln!(" Use {program} -q --player seek -5 so the seek delta is not parsed as a flag.\n"); + crate::app_eprintln!(" Playback"); + crate::app_eprintln!(" {program} [--quiet|-q] --player next | prev | play | pause | stop"); + crate::app_eprintln!(" {program} [--quiet|-q] --player play Track, album, or artist id (artist → shuffled library)."); + crate::app_eprintln!(" {program} [--quiet|-q] --player seek Integer delta, e.g. 15 or -10"); + crate::app_eprintln!(" {program} [--quiet|-q] --player volume <0-100> Absolute volume percent."); + crate::app_eprintln!(" {program} [--quiet|-q] --player shuffle Shuffle the current queue."); + crate::app_eprintln!(" {program} [--quiet|-q] --player repeat off|all|one"); + crate::app_eprintln!(" {program} [--quiet|-q] --player mute | unmute"); + crate::app_eprintln!(" {program} [--quiet|-q] --player star | unstar Current track (Subsonic star)."); + crate::app_eprintln!(" {program} [--quiet|-q] --player rating <0-5> Set song rating (0 clears)."); + crate::app_eprintln!(" {program} [--quiet|-q] --player reload Restart audio for the current track or reload server queue.\n"); + crate::app_eprintln!(" Audio output"); + crate::app_eprintln!(" {program} [--json] --player audio-device list"); + crate::app_eprintln!(" {program} --player audio-device set \n"); + crate::app_eprintln!(" Music library (Subsonic music folders for the active server)"); + crate::app_eprintln!(" {program} [--json] --player library list"); + crate::app_eprintln!(" {program} --player library set all | \n"); + crate::app_eprintln!(" Servers (saved profiles — same as the in-app server switcher)"); + crate::app_eprintln!(" {program} [--json] --player server list"); + crate::app_eprintln!(" {program} --player server set \n"); + crate::app_eprintln!(" Search (active server; respects library folder filter)"); + crate::app_eprintln!(" {program} [--json] --player search track "); + crate::app_eprintln!(" {program} [--json] --player search album "); + crate::app_eprintln!(" {program} [--json] --player search artist \n"); + crate::app_eprintln!(" Instant mix (from the track that is currently loaded)"); + crate::app_eprintln!(" {program} --player mix append"); + crate::app_eprintln!(" {program} --player mix new\n"); + crate::app_eprintln!("Exit: 0 on success. Errors print \"NOT OK: …\" on stderr with a non-zero status."); } /// Wait for the webview to write `psysonic-cli-library.json` after `cli:library-list`. @@ -585,11 +585,11 @@ pub fn run_info_and_exit(args: &[String]) -> ! { match linux_is_primary_instance_running() { Ok(true) => {} Ok(false) => { - eprintln!("NOT OK: Psysonic is not running"); + crate::app_eprintln!("NOT OK: Psysonic is not running"); std::process::exit(2); } Err(e) => { - eprintln!("NOT OK: {e}"); + crate::app_eprintln!("NOT OK: {e}"); std::process::exit(1); } } @@ -600,7 +600,7 @@ pub fn run_info_and_exit(args: &[String]) -> ! { let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null); let empty = v.is_null() || v.as_object().map(|m| m.is_empty()).unwrap_or(true); if empty { - eprintln!("NOT OK: no CLI snapshot yet — wait until the main window has loaded."); + crate::app_eprintln!("NOT OK: no CLI snapshot yet — wait until the main window has loaded."); std::process::exit(3); } @@ -608,7 +608,7 @@ pub fn run_info_and_exit(args: &[String]) -> ! { match serde_json::to_string(&v) { Ok(line) => println!("{line}"), Err(e) => { - eprintln!("NOT OK: {e}"); + crate::app_eprintln!("NOT OK: {e}"); std::process::exit(1); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ee52fd46..d51ca553 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod audio; pub mod cli; mod discord; +pub(crate) mod logging; #[cfg(target_os = "windows")] mod taskbar_win; @@ -123,6 +124,16 @@ fn set_linux_webkit_smooth_scrolling(enabled: bool, app_handle: tauri::AppHandle Ok(()) } +#[tauri::command] +fn set_logging_mode(mode: String) -> Result<(), String> { + crate::logging::set_logging_mode_from_str(&mode) +} + +#[tauri::command] +fn export_runtime_logs(path: String) -> Result { + crate::logging::export_logs_to_file(&path) +} + /// Authenticate with Navidrome's own REST API and return a Bearer token. async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result { @@ -2667,11 +2678,11 @@ fn try_build_tray_icon(app: &tauri::AppHandle) -> Option { match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build_tray_icon(&app))) { Ok(Ok(tray)) => Some(tray), Ok(Err(e)) => { - eprintln!("[Psysonic] System tray unavailable: {e}"); + crate::app_eprintln!("[Psysonic] System tray unavailable: {e}"); None } Err(_) => { - eprintln!( + crate::app_eprintln!( "[Psysonic] System tray unavailable — missing libayatana-appindicator3 or libappindicator3 \ (install the distro package or set LD_LIBRARY_PATH)" ); @@ -3106,7 +3117,7 @@ pub fn run() { Ok(crate::cli::LinuxPlayerForwardResult::Forwarded) => std::process::exit(0), Ok(crate::cli::LinuxPlayerForwardResult::ContinueStartup) => {} Err(msg) => { - eprintln!("NOT OK: {msg}"); + crate::app_eprintln!("NOT OK: {msg}"); std::process::exit(1); } } @@ -3180,7 +3191,7 @@ pub fn run() { .map(|v| !v.is_empty()) .unwrap_or(false); if !dbus_ok { - eprintln!("[Psysonic] No D-Bus session — MPRIS media controls disabled"); + crate::app_eprintln!("[Psysonic] No D-Bus session — MPRIS media controls disabled"); return None; } } @@ -3196,7 +3207,7 @@ pub fn run() { .and_then(|w| w.hwnd().ok()) .map(|h| h.0 as *mut std::ffi::c_void); if h.is_none() { - eprintln!("[Psysonic] Could not get HWND — Windows media controls disabled"); + crate::app_eprintln!("[Psysonic] Could not get HWND — Windows media controls disabled"); return None; } h @@ -3241,12 +3252,12 @@ pub fn run() { _ => {} } }) { - eprintln!("[Psysonic] Failed to attach media controls: {e:?}"); + crate::app_eprintln!("[Psysonic] Failed to attach media controls: {e:?}"); } Some(controls) } Err(e) => { - eprintln!("[Psysonic] Could not create media controls: {e:?}"); + crate::app_eprintln!("[Psysonic] Could not create media controls: {e:?}"); None } } @@ -3284,7 +3295,7 @@ pub fn run() { #[cfg(target_os = "windows")] { if let Err(e) = build_mini_player_window(app.handle(), false) { - eprintln!("[psysonic] Failed to pre-create mini window: {e}"); + crate::app_eprintln!("[psysonic] Failed to pre-create mini window: {e}"); } } @@ -3342,6 +3353,8 @@ pub fn run() { cli_publish_search_results, set_window_decorations, set_linux_webkit_smooth_scrolling, + set_logging_mode, + export_runtime_logs, no_compositing_mode, is_tiling_wm_cmd, open_mini_player, diff --git a/src-tauri/src/logging.rs b/src-tauri/src/logging.rs new file mode 100644 index 00000000..d72ac4d3 --- /dev/null +++ b/src-tauri/src/logging.rs @@ -0,0 +1,171 @@ +#[cfg(unix)] +use libc; +use std::collections::VecDeque; +use std::sync::{Mutex, OnceLock}; +use std::sync::atomic::{AtomicU8, Ordering}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum LoggingMode { + Off = 0, + Normal = 1, + Debug = 2, +} + +static LOGGING_MODE: AtomicU8 = AtomicU8::new(LoggingMode::Normal as u8); +const LOG_BUFFER_MAX_LINES: usize = 20_000; + +fn log_buffer() -> &'static Mutex> { + static LOG_BUFFER: OnceLock>> = OnceLock::new(); + LOG_BUFFER.get_or_init(|| Mutex::new(VecDeque::with_capacity(LOG_BUFFER_MAX_LINES))) +} + +fn parse_logging_mode(mode: &str) -> Option { + match mode.trim().to_ascii_lowercase().as_str() { + "off" => Some(LoggingMode::Off), + "normal" => Some(LoggingMode::Normal), + "debug" => Some(LoggingMode::Debug), + _ => None, + } +} + +pub fn set_logging_mode_from_str(mode: &str) -> Result<(), String> { + let parsed = parse_logging_mode(mode) + .ok_or_else(|| "invalid logging mode (expected: off | normal | debug)".to_string())?; + LOGGING_MODE.store(parsed as u8, Ordering::Release); + Ok(()) +} + +fn current_mode() -> LoggingMode { + match LOGGING_MODE.load(Ordering::Acquire) { + 0 => LoggingMode::Off, + 2 => LoggingMode::Debug, + _ => LoggingMode::Normal, + } +} + +pub fn should_log_normal() -> bool { + !matches!(current_mode(), LoggingMode::Off) +} + +pub fn should_log_debug() -> bool { + matches!(current_mode(), LoggingMode::Debug) +} + +pub fn append_log_line(line: String) { + let mut buf = log_buffer().lock().unwrap(); + if buf.len() >= LOG_BUFFER_MAX_LINES { + buf.pop_front(); + } + buf.push_back(line); +} + +pub fn export_logs_to_file(path: &str) -> Result { + let snapshot = { + let buf = log_buffer().lock().unwrap(); + if buf.is_empty() { + String::new() + } else { + let mut s = buf.iter().cloned().collect::>().join("\n"); + s.push('\n'); + s + } + }; + std::fs::write(path, snapshot).map_err(|e| e.to_string())?; + let lines = { + let buf = log_buffer().lock().unwrap(); + buf.len() + }; + Ok(lines) +} + +pub(crate) fn log_timestamp_local() -> String { + let now = ::std::time::SystemTime::now() + .duration_since(::std::time::UNIX_EPOCH) + .unwrap_or_default(); + let millis = now.subsec_millis(); + + #[cfg(unix)] + { + use std::ffi::CStr; + let secs: libc::time_t = now.as_secs() as libc::time_t; + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + let mut date_buf: [libc::c_char; 64] = [0; 64]; + let mut tz_buf: [libc::c_char; 16] = [0; 16]; + let date_fmt = b"%Y-%m-%d %H:%M:%S\0"; + let tz_fmt = b"%z\0"; + + unsafe { + if libc::localtime_r(&secs as *const libc::time_t, &mut tm as *mut libc::tm).is_null() { + return format!("{}.{:03}", now.as_secs(), millis); + } + let date_ok = libc::strftime( + date_buf.as_mut_ptr(), + date_buf.len(), + date_fmt.as_ptr().cast(), + &tm as *const libc::tm, + ); + if date_ok == 0 { + return format!("{}.{:03}", now.as_secs(), millis); + } + let tz_ok = libc::strftime( + tz_buf.as_mut_ptr(), + tz_buf.len(), + tz_fmt.as_ptr().cast(), + &tm as *const libc::tm, + ); + + let date = CStr::from_ptr(date_buf.as_ptr()).to_string_lossy(); + if tz_ok == 0 { + return format!("{}.{:03}", date, millis); + } + let tz = CStr::from_ptr(tz_buf.as_ptr()).to_string_lossy(); + return format!("{}.{:03} {}", date, millis, tz); + } + } + + #[cfg(not(unix))] + { + format!("{}.{:03}", now.as_secs(), millis) + } +} + +#[macro_export] +macro_rules! app_eprintln { + () => {{ + if $crate::logging::should_log_normal() { + let ts = $crate::logging::log_timestamp_local(); + let line = format!("[{}]", ts); + $crate::logging::append_log_line(line.clone()); + ::std::eprintln!("{}", line); + } + }}; + ($($arg:tt)*) => {{ + if $crate::logging::should_log_normal() { + let ts = $crate::logging::log_timestamp_local(); + let line = format!("[{}] {}", ts, format_args!($($arg)*)); + $crate::logging::append_log_line(line.clone()); + ::std::eprintln!("{}", line); + } + }}; +} + +#[macro_export] +macro_rules! app_deprintln { + () => {{ + if $crate::logging::should_log_debug() { + let ts = $crate::logging::log_timestamp_local(); + let line = format!("[{}]", ts); + $crate::logging::append_log_line(line.clone()); + ::std::eprintln!("{}", line); + } + }}; + ($($arg:tt)*) => {{ + if $crate::logging::should_log_debug() { + let ts = $crate::logging::log_timestamp_local(); + let line = format!("[{}] {}", ts, format_args!($($arg)*)); + $crate::logging::append_log_line(line.clone()); + ::std::eprintln!("{}", line); + } + }}; +} diff --git a/src-tauri/src/taskbar_win.rs b/src-tauri/src/taskbar_win.rs index b4d847ac..dd5a57d2 100644 --- a/src-tauri/src/taskbar_win.rs +++ b/src-tauri/src/taskbar_win.rs @@ -203,11 +203,11 @@ pub fn init(app: &AppHandle, hwnd_raw: isize) { &TaskbarList, None, CLSCTX_INPROC_SERVER, ) { Ok(t) => t, - Err(e) => { eprintln!("[psysonic] taskbar: CoCreateInstance failed: {e}"); return; } + Err(e) => { crate::app_eprintln!("[psysonic] taskbar: CoCreateInstance failed: {e}"); return; } }; if let Err(e) = taskbar.HrInit() { - eprintln!("[psysonic] taskbar: HrInit failed: {e}"); + crate::app_eprintln!("[psysonic] taskbar: HrInit failed: {e}"); return; } @@ -224,7 +224,7 @@ pub fn init(app: &AppHandle, hwnd_raw: isize) { let mut buttons = make_buttons(h_prev, h_play, h_next); if let Err(e) = taskbar.ThumbBarAddButtons(hwnd, &mut buttons) { - eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}"); + crate::app_eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}"); return; } @@ -234,7 +234,7 @@ pub fn init(app: &AppHandle, hwnd_raw: isize) { let data = Box::into_raw(Box::new(SubclassData { app: app.clone() })); if !SetWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID, data as usize).as_bool() { - eprintln!("[psysonic] taskbar: SetWindowSubclass failed"); + crate::app_eprintln!("[psysonic] taskbar: SetWindowSubclass failed"); drop(Box::from_raw(data)); } } @@ -268,8 +268,7 @@ pub fn update_taskbar_icon(is_playing: bool) { let mut btns = [btn]; if let Err(e) = taskbar.ThumbBarUpdateButtons(hwnd, &mut btns) { - #[cfg(debug_assertions)] - eprintln!("[psysonic] taskbar: ThumbBarUpdateButtons failed: {e}"); + crate::app_deprintln!("[psysonic] taskbar: ThumbBarUpdateButtons failed: {e}"); let _ = e; } } diff --git a/src/App.tsx b/src/App.tsx index 8789969b..dec5a283 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -147,6 +147,7 @@ function AppShell() { const setMusicFolders = useAuthStore(s => s.setMusicFolders); const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); const linuxWebkitKineticScroll = useAuthStore(s => s.linuxWebkitKineticScroll); + const loggingMode = useAuthStore(s => s.loggingMode); const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); const offlineAlbums = useOfflineStore(s => s.albums); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); @@ -176,6 +177,10 @@ function AppShell() { invoke('set_linux_webkit_smooth_scrolling', { enabled: linuxWebkitKineticScroll }).catch(() => {}); }, [linuxWebkitKineticScroll]); + useEffect(() => { + invoke('set_logging_mode', { mode: loggingMode }).catch(() => {}); + }, [loggingMode]); + useEffect(() => { if (!isLoggedIn || !activeServerId) return; const serverAtStart = activeServerId; diff --git a/src/locales/de.ts b/src/locales/de.ts index db69174f..904be52f 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -682,6 +682,14 @@ export const deTranslation = { shortcutNativeFullscreen: 'Nativer Vollbildmodus', shortcutOpenMiniPlayer: 'Mini-Player öffnen', tabSystem: 'System', + loggingTitle: 'Protokollierung', + loggingModeDesc: 'Steuert die Ausführlichkeit der Backend-Protokolle im Terminal.', + loggingModeOff: 'Aus', + loggingModeNormal: 'Normal', + loggingModeDebug: 'Debug', + loggingExport: 'Protokolle exportieren', + loggingExportSuccess: 'Protokolle exportiert ({{count}} Zeilen).', + loggingExportError: 'Protokolle konnten nicht exportiert werden.', tabGeneral: 'Allgemein', ratingsSectionTitle: 'Bewertungen', ratingsSkipStarTitle: 'Skip → 1 Stern', diff --git a/src/locales/en.ts b/src/locales/en.ts index d5788fb3..3ac27695 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -666,6 +666,14 @@ export const enTranslation = { tabServer: 'Server', tabUsers: 'Users', tabSystem: 'System', + loggingTitle: 'Logging', + loggingModeDesc: 'Controls backend log verbosity in the terminal.', + loggingModeOff: 'Off', + loggingModeNormal: 'Normal', + loggingModeDebug: 'Debug', + loggingExport: 'Export logs', + loggingExportSuccess: 'Logs exported ({{count}} lines).', + loggingExportError: 'Could not export logs.', tabGeneral: 'General', ratingsSectionTitle: 'Ratings', ratingsSkipStarTitle: 'Skip for 1 star', diff --git a/src/locales/es.ts b/src/locales/es.ts index 1d7de27e..c07cc962 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -657,6 +657,14 @@ export const esTranslation = { tabServer: 'Servidor', tabUsers: 'Usuarios', tabSystem: 'Sistema', + loggingTitle: 'Registro', + loggingModeDesc: 'Controla la verbosidad de los logs del backend en la terminal.', + loggingModeOff: 'Apagado', + loggingModeNormal: 'Normal', + loggingModeDebug: 'Depuración', + loggingExport: 'Exportar logs', + loggingExportSuccess: 'Logs exportados ({{count}} líneas).', + loggingExportError: 'No se pudieron exportar los logs.', tabGeneral: 'General', ratingsSectionTitle: 'Calificaciones', ratingsSkipStarTitle: 'Saltar para 1 estrella', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 351684b6..375b08a8 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -670,6 +670,14 @@ export const frTranslation = { shortcutNativeFullscreen: 'Plein écran natif', shortcutOpenMiniPlayer: 'Ouvrir le mini-lecteur', tabSystem: 'Système', + loggingTitle: 'Journalisation', + loggingModeDesc: 'Contrôle le niveau de verbosité des journaux backend dans le terminal.', + loggingModeOff: 'Désactivé', + loggingModeNormal: 'Normal', + loggingModeDebug: 'Débogage', + loggingExport: 'Exporter les journaux', + loggingExportSuccess: 'Journaux exportés ({{count}} lignes).', + loggingExportError: 'Impossible d\'exporter les journaux.', tabGeneral: 'Général', ratingsSectionTitle: 'Notes', ratingsSkipStarTitle: 'Passer pour 1 étoile', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index f5d55d00..22fff46a 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -651,6 +651,14 @@ export const nbTranslation = { tabServer: 'Tjener', tabUsers: 'Brukere', tabSystem: 'System', + loggingTitle: 'Loggføring', + loggingModeDesc: 'Styrer hvor detaljert backend-loggene i terminalen er.', + loggingModeOff: 'Av', + loggingModeNormal: 'Normal', + loggingModeDebug: 'Debug', + loggingExport: 'Eksporter logger', + loggingExportSuccess: 'Logger eksportert ({{count}} linjer).', + loggingExportError: 'Kunne ikke eksportere logger.', tabGeneral: 'Generelt', ratingsSectionTitle: 'Vurderinger', ratingsSkipStarTitle: 'Hopp for 1 stjerne', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 7e9fb520..9d857322 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -669,6 +669,14 @@ export const nlTranslation = { shortcutNativeFullscreen: 'Systeemvolledig scherm', shortcutOpenMiniPlayer: 'Mini-speler openen', tabSystem: 'Systeem', + loggingTitle: 'Logboek', + loggingModeDesc: 'Bepaalt de uitgebreidheid van backendlogs in de terminal.', + loggingModeOff: 'Uit', + loggingModeNormal: 'Normaal', + loggingModeDebug: 'Debug', + loggingExport: 'Logs exporteren', + loggingExportSuccess: 'Logs geëxporteerd ({{count}} regels).', + loggingExportError: 'Kon logs niet exporteren.', tabGeneral: 'Algemeen', ratingsSectionTitle: 'Beoordelingen', ratingsSkipStarTitle: 'Overslaan voor 1 ster', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 89e69734..633dcb14 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -679,6 +679,14 @@ export const ruTranslation = { tabServer: 'Сервер', tabUsers: 'Пользователи', tabSystem: 'Система', + loggingTitle: 'Логирование', + loggingModeDesc: 'Управляет подробностью логов backend в терминале.', + loggingModeOff: 'Выключено', + loggingModeNormal: 'Обычное', + loggingModeDebug: 'Дебаг', + loggingExport: 'Экспорт логов', + loggingExportSuccess: 'Логи экспортированы ({{count}} строк).', + loggingExportError: 'Не удалось экспортировать логи.', tabGeneral: 'Общие', ratingsSectionTitle: 'Рейтинги', ratingsSkipStarTitle: 'Скипнуть для 1 звезды', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index bae60a32..fa0d3009 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -647,6 +647,14 @@ export const zhTranslation = { tabServer: '服务器', tabUsers: '用户', tabSystem: '系统', + loggingTitle: '日志记录', + loggingModeDesc: '控制终端中后端日志的详细程度。', + loggingModeOff: '关闭', + loggingModeNormal: '普通', + loggingModeDebug: '调试', + loggingExport: '导出日志', + loggingExportSuccess: '日志已导出({{count}} 行)。', + loggingExportError: '无法导出日志。', tabGeneral: '通用', ratingsSectionTitle: '评分', ratingsSkipStarTitle: '跳过以评 1 星', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 9d01d7c2..ed2a6f2c 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -22,7 +22,7 @@ import LastfmIcon from '../components/LastfmIcon'; import CustomSelect from '../components/CustomSelect'; import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker'; import { useShallow } from 'zustand/react/shallow'; -import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig } from '../store/authStore'; +import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig, type LoggingMode } from '../store/authStore'; import { SeekbarPreview } from '../components/WaveformSeek'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; import { useThemeStore } from '../store/themeStore'; @@ -40,7 +40,7 @@ import { type NdUser, type NdLibrary, } from '../api/navidromeAdmin'; import { switchActiveServer } from '../utils/switchActiveServer'; -import { open as openDialog } from '@tauri-apps/plugin-dialog'; +import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'; import ConfirmModal from '../components/ConfirmModal'; import { Trans, useTranslation } from 'react-i18next'; import Equalizer from '../components/Equalizer'; @@ -1157,6 +1157,23 @@ export default function Settings() { } }; + const exportRuntimeLogs = async () => { + const suggestedName = `psysonic-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.log`; + const selected = await saveDialog({ + defaultPath: suggestedName, + filters: [{ name: 'Log files', extensions: ['log', 'txt'] }], + title: t('settings.loggingExport'), + }); + if (!selected || Array.isArray(selected)) return; + try { + const lines = await invoke('export_runtime_logs', { path: selected }); + showToast(t('settings.loggingExportSuccess', { count: lines }), 3500, 'info'); + } catch (e) { + console.error(e); + showToast(t('settings.loggingExportError'), 4500, 'error'); + } + }; + const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ { id: 'general', label: t('settings.tabGeneral'), icon: }, { id: 'server', label: t('settings.tabServer'), icon: }, @@ -2927,7 +2944,35 @@ export default function Settings() { {activeTab === 'system' && ( <> - + +
+
+ +

{t('settings.loggingTitle')}

+
+
+
+ {t('settings.loggingModeDesc')} +
+ auth.setLoggingMode(v as LoggingMode)} + options={[ + { value: 'off', label: t('settings.loggingModeOff') }, + { value: 'normal', label: t('settings.loggingModeNormal') }, + { value: 'debug', label: t('settings.loggingModeDebug') }, + ]} + /> + {auth.loggingMode === 'debug' && ( +
+ +
+ )} +
+
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 3d03cc8a..9484138e 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -19,6 +19,7 @@ export interface ServerProfile { } export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape'; +export type LoggingMode = 'off' | 'normal' | 'debug'; export type LyricsSourceId = 'server' | 'lrclib' | 'netease'; export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; } @@ -71,6 +72,8 @@ interface AuthState { preloadMiniPlayer: boolean; /** Linux WebKitGTK: smooth wheel on when true; off only after explicit opt-out in Settings. */ linuxWebkitKineticScroll: boolean; + /** Runtime backend logging level. */ + loggingMode: LoggingMode; nowPlayingEnabled: boolean; lyricsServerFirst: boolean; enableNeteaselyrics: boolean; @@ -217,6 +220,7 @@ interface AuthState { setUseCustomTitlebar: (v: boolean) => void; setPreloadMiniPlayer: (v: boolean) => void; setLinuxWebkitKineticScroll: (v: boolean) => void; + setLoggingMode: (v: LoggingMode) => void; setNowPlayingEnabled: (v: boolean) => void; setLyricsServerFirst: (v: boolean) => void; setEnableNeteaselyrics: (v: boolean) => void; @@ -324,6 +328,7 @@ export const useAuthStore = create()( useCustomTitlebar: false, preloadMiniPlayer: false, linuxWebkitKineticScroll: true, + loggingMode: 'normal', nowPlayingEnabled: false, lyricsServerFirst: true, enableNeteaselyrics: false, @@ -455,6 +460,7 @@ export const useAuthStore = create()( setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }), setPreloadMiniPlayer: (v) => set({ preloadMiniPlayer: v }), setLinuxWebkitKineticScroll: (v) => set({ linuxWebkitKineticScroll: v }), + setLoggingMode: (v) => set({ loggingMode: v }), setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }), setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }), setEnableNeteaselyrics: (v: boolean) => set({ enableNeteaselyrics: v }),