mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(logging): add runtime log levels and debug log export (#241)
Add a System setting for Off/Normal/Debug logging, apply readable local timestamps to backend logs, and enable exporting buffered runtime logs to a file when debug mode is active. Co-authored-by: Maxim Isaev <im@friclub.ru>
This commit is contained in:
committed by
GitHub
parent
fa21379dbb
commit
3b3833007b
+75
-95
@@ -636,7 +636,7 @@ impl Read for AudioStreamReader {
|
|||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
if std::time::Instant::now() >= self.deadline {
|
if std::time::Instant::now() >= self.deadline {
|
||||||
eprintln!(
|
crate::app_eprintln!(
|
||||||
"[{}] AudioStreamReader: {}s without data → EOF",
|
"[{}] AudioStreamReader: {}s without data → EOF",
|
||||||
self.source_tag,
|
self.source_tag,
|
||||||
RADIO_READ_TIMEOUT_SECS
|
RADIO_READ_TIMEOUT_SECS
|
||||||
@@ -892,7 +892,7 @@ async fn radio_download_task(
|
|||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
None => {
|
None => {
|
||||||
if reconnect_count >= MAX_CONSECUTIVE_FAILURES {
|
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;
|
break 'outer;
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
@@ -904,15 +904,15 @@ async fn radio_download_task(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(r) if r.status().is_success() => {
|
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
|
r
|
||||||
}
|
}
|
||||||
Ok(r) => {
|
Ok(r) => {
|
||||||
eprintln!("[radio] reconnect: HTTP {} — giving up", r.status());
|
crate::app_eprintln!("[radio] reconnect: HTTP {} — giving up", r.status());
|
||||||
break 'outer;
|
break 'outer;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[radio] reconnect error: {e} — giving up");
|
crate::app_eprintln!("[radio] reconnect error: {e} — giving up");
|
||||||
break 'outer;
|
break 'outer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -942,7 +942,7 @@ async fn radio_download_task(
|
|||||||
let fill_pct = ((1.0
|
let fill_pct = ((1.0
|
||||||
- prod.free_len() as f32 / RADIO_BUF_CAPACITY as f32)
|
- prod.free_len() as f32 / RADIO_BUF_CAPACITY as f32)
|
||||||
* 100.0) as u32;
|
* 100.0) as u32;
|
||||||
eprintln!(
|
crate::app_eprintln!(
|
||||||
"[radio] hard pause: {fill_pct}% full, \
|
"[radio] hard pause: {fill_pct}% full, \
|
||||||
paused >{RADIO_HARD_PAUSE_SECS}s → disconnecting"
|
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(ref mut interceptor) = icy {
|
||||||
if let Some(meta) = interceptor.process(&chunk, &mut audio_scratch) {
|
if let Some(meta) = interceptor.process(&chunk, &mut audio_scratch) {
|
||||||
let label = if meta.is_ad { "[Ad]" } else { "" };
|
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);
|
let _ = app.emit("radio:metadata", &meta);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -989,12 +989,12 @@ async fn radio_download_task(
|
|||||||
}
|
}
|
||||||
Some(Err(e)) => {
|
Some(Err(e)) => {
|
||||||
reconnect_count += 1;
|
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;
|
break 'inner;
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
reconnect_count += 1;
|
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;
|
break 'inner;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1007,7 +1007,7 @@ async fn radio_download_task(
|
|||||||
// within a few seconds rather than minutes.
|
// within a few seconds rather than minutes.
|
||||||
} // 'outer
|
} // '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.
|
/// One-shot HTTP downloader for track streaming starts.
|
||||||
@@ -1044,7 +1044,7 @@ async fn track_download_task(
|
|||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
||||||
eprintln!(
|
crate::app_eprintln!(
|
||||||
"[audio] streaming reconnect failed after {} attempts: {}",
|
"[audio] streaming reconnect failed after {} attempts: {}",
|
||||||
reconnects, err
|
reconnects, err
|
||||||
);
|
);
|
||||||
@@ -1058,7 +1058,7 @@ async fn track_download_task(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if downloaded > 0 && response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
|
if downloaded > 0 && response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
|
||||||
eprintln!(
|
crate::app_eprintln!(
|
||||||
"[audio] streaming reconnect returned {}, expected 206 for range resume",
|
"[audio] streaming reconnect returned {}, expected 206 for range resume",
|
||||||
response.status()
|
response.status()
|
||||||
);
|
);
|
||||||
@@ -1066,7 +1066,7 @@ async fn track_download_task(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if downloaded == 0 && !response.status().is_success() {
|
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);
|
done.store(true, Ordering::SeqCst);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1081,7 +1081,7 @@ async fn track_download_task(
|
|||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
||||||
eprintln!(
|
crate::app_eprintln!(
|
||||||
"[audio] streaming download error after {} reconnects: {}",
|
"[audio] streaming download error after {} reconnects: {}",
|
||||||
reconnects, e
|
reconnects, e
|
||||||
);
|
);
|
||||||
@@ -1089,7 +1089,7 @@ async fn track_download_task(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reconnects += 1;
|
reconnects += 1;
|
||||||
eprintln!(
|
crate::app_eprintln!(
|
||||||
"[audio] streaming download error (attempt {}/{}): {} — reconnecting",
|
"[audio] streaming download error (attempt {}/{}): {} — reconnecting",
|
||||||
reconnects,
|
reconnects,
|
||||||
TRACK_STREAM_MAX_RECONNECTS,
|
TRACK_STREAM_MAX_RECONNECTS,
|
||||||
@@ -1155,9 +1155,7 @@ async fn ranged_download_task(
|
|||||||
let mut downloaded: usize = 0;
|
let mut downloaded: usize = 0;
|
||||||
let mut reconnects: u32 = 0;
|
let mut reconnects: u32 = 0;
|
||||||
let mut next_response: Option<reqwest::Response> = Some(initial_response);
|
let mut next_response: Option<reqwest::Response> = Some(initial_response);
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
let dl_started = Instant::now();
|
let dl_started = Instant::now();
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
let mut next_progress_mb: usize = 1;
|
let mut next_progress_mb: usize = 1;
|
||||||
|
|
||||||
'outer: loop {
|
'outer: loop {
|
||||||
@@ -1172,7 +1170,7 @@ async fn ranged_download_task(
|
|||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
||||||
eprintln!(
|
crate::app_eprintln!(
|
||||||
"[audio] ranged reconnect failed after {} attempts: {}",
|
"[audio] ranged reconnect failed after {} attempts: {}",
|
||||||
reconnects, err
|
reconnects, err
|
||||||
);
|
);
|
||||||
@@ -1185,14 +1183,14 @@ async fn ranged_download_task(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if downloaded > 0 && response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
|
if downloaded > 0 && response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
|
||||||
eprintln!(
|
crate::app_eprintln!(
|
||||||
"[audio] ranged reconnect returned {}, expected 206",
|
"[audio] ranged reconnect returned {}, expected 206",
|
||||||
response.status()
|
response.status()
|
||||||
);
|
);
|
||||||
break 'outer;
|
break 'outer;
|
||||||
}
|
}
|
||||||
if downloaded == 0 && !response.status().is_success() {
|
if downloaded == 0 && !response.status().is_success() {
|
||||||
eprintln!("[audio] ranged HTTP {}", response.status());
|
crate::app_eprintln!("[audio] ranged HTTP {}", response.status());
|
||||||
break 'outer;
|
break 'outer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1206,14 +1204,14 @@ async fn ranged_download_task(
|
|||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
||||||
eprintln!(
|
crate::app_eprintln!(
|
||||||
"[audio] ranged dl error after {} reconnects: {}",
|
"[audio] ranged dl error after {} reconnects: {}",
|
||||||
reconnects, e
|
reconnects, e
|
||||||
);
|
);
|
||||||
break 'outer;
|
break 'outer;
|
||||||
}
|
}
|
||||||
reconnects += 1;
|
reconnects += 1;
|
||||||
eprintln!(
|
crate::app_eprintln!(
|
||||||
"[audio] ranged dl error (attempt {}/{}): {} — reconnecting",
|
"[audio] ranged dl error (attempt {}/{}): {} — reconnecting",
|
||||||
reconnects, TRACK_STREAM_MAX_RECONNECTS, e
|
reconnects, TRACK_STREAM_MAX_RECONNECTS, e
|
||||||
);
|
);
|
||||||
@@ -1233,19 +1231,16 @@ async fn ranged_download_task(
|
|||||||
}
|
}
|
||||||
downloaded += n;
|
downloaded += n;
|
||||||
downloaded_to.store(downloaded, Ordering::SeqCst);
|
downloaded_to.store(downloaded, Ordering::SeqCst);
|
||||||
#[cfg(debug_assertions)]
|
let mb = downloaded / (1024 * 1024);
|
||||||
{
|
if mb >= next_progress_mb {
|
||||||
let mb = downloaded / (1024 * 1024);
|
let pct = (downloaded as f64 / total_size as f64 * 100.0) as u32;
|
||||||
if mb >= next_progress_mb {
|
crate::app_deprintln!(
|
||||||
let pct = (downloaded as f64 / total_size as f64 * 100.0) as u32;
|
"[stream] dl progress: {} MB / {} MB ({}%)",
|
||||||
eprintln!(
|
mb,
|
||||||
"[stream] dl progress: {} MB / {} MB ({}%)",
|
total_size / (1024 * 1024),
|
||||||
mb,
|
pct
|
||||||
total_size / (1024 * 1024),
|
);
|
||||||
pct
|
next_progress_mb = mb + 1;
|
||||||
);
|
|
||||||
next_progress_mb = mb + 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if downloaded >= total_size {
|
if downloaded >= total_size {
|
||||||
break;
|
break;
|
||||||
@@ -1257,8 +1252,7 @@ async fn ranged_download_task(
|
|||||||
|
|
||||||
done.store(true, Ordering::SeqCst);
|
done.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
crate::app_deprintln!(
|
||||||
eprintln!(
|
|
||||||
"[stream] dl done: {} / {} bytes in {:.2}s ({} reconnects)",
|
"[stream] dl done: {} / {} bytes in {:.2}s ({} reconnects)",
|
||||||
downloaded,
|
downloaded,
|
||||||
total_size,
|
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 {
|
if downloaded == total_size && total_size > 0 && total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES {
|
||||||
let data = buf.lock().unwrap().clone();
|
let data = buf.lock().unwrap().clone();
|
||||||
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data });
|
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||||
#[cfg(debug_assertions)]
|
crate::app_deprintln!("[stream] promoted to stream_completed_cache for replay");
|
||||||
eprintln!("[stream] promoted to stream_completed_cache for replay");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1326,9 +1319,8 @@ impl MediaSource for SizedCursorSource {
|
|||||||
// Implements Iterator<Item = i16> + Source — identical interface to
|
// Implements Iterator<Item = i16> + Source — identical interface to
|
||||||
// rodio::Decoder, so the rest of the source chain is unchanged.
|
// rodio::Decoder, so the rest of the source chain is unchanged.
|
||||||
|
|
||||||
/// Dev-build only: format codec parameters into a human-readable line for
|
/// Debug logging: codec parameters in human-readable form to verify whether
|
||||||
/// the terminal so you can verify whether playback is genuinely lossless.
|
/// playback is genuinely lossless.
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
fn log_codec_resolution(
|
fn log_codec_resolution(
|
||||||
tag: &str,
|
tag: &str,
|
||||||
params: &symphonia::core::codecs::CodecParameters,
|
params: &symphonia::core::codecs::CodecParameters,
|
||||||
@@ -1352,7 +1344,7 @@ fn log_codec_resolution(
|
|||||||
"flac" | "alac" | "wavpack" | "monkeys-audio" | "tta" | "shorten"
|
"flac" | "alac" | "wavpack" | "monkeys-audio" | "tta" | "shorten"
|
||||||
);
|
);
|
||||||
let kind = if lossless { "LOSSLESS" } else { "lossy" };
|
let kind = if lossless { "LOSSLESS" } else { "lossy" };
|
||||||
eprintln!(
|
crate::app_deprintln!(
|
||||||
"[stream] {tag}: codec={codec_name} ({kind}) {bits} {rate} {ch} container={}",
|
"[stream] {tag}: codec={codec_name} ({kind}) {bits} {rate} {ch} container={}",
|
||||||
container_hint.unwrap_or("?")
|
container_hint.unwrap_or("?")
|
||||||
);
|
);
|
||||||
@@ -1418,7 +1410,7 @@ impl SizedDecoder {
|
|||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
let hint_str = format_hint.unwrap_or("unknown");
|
let hint_str = format_hint.unwrap_or("unknown");
|
||||||
// Always print the raw Symphonia error to the terminal for diagnosis.
|
// 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") {
|
if e.to_string().to_lowercase().contains("unsupported") {
|
||||||
format!("unsupported format: .{hint_str} files cannot be played (no demuxer)")
|
format!("unsupported format: .{hint_str} files cannot be played (no demuxer)")
|
||||||
} else {
|
} else {
|
||||||
@@ -1437,7 +1429,7 @@ impl SizedDecoder {
|
|||||||
&& t.codec_params.sample_rate.is_some()
|
&& t.codec_params.sample_rate.is_some()
|
||||||
})
|
})
|
||||||
.ok_or_else(|| {
|
.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()
|
"no playable audio track found in file".to_string()
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -1446,13 +1438,12 @@ impl SizedDecoder {
|
|||||||
.zip(track.codec_params.n_frames)
|
.zip(track.codec_params.n_frames)
|
||||||
.map(|(base, frames)| base.calc_time(frames));
|
.map(|(base, frames)| base.calc_time(frames));
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
log_codec_resolution("bytes", &track.codec_params, format_hint);
|
log_codec_resolution("bytes", &track.codec_params, format_hint);
|
||||||
|
|
||||||
let mut decoder = psysonic_codec_registry()
|
let mut decoder = psysonic_codec_registry()
|
||||||
.make(&track.codec_params, &DecoderOptions::default())
|
.make(&track.codec_params, &DecoderOptions::default())
|
||||||
.map_err(|e| {
|
.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") {
|
if e.to_string().to_lowercase().contains("unsupported") {
|
||||||
"unsupported codec: no decoder available for this audio format".to_string()
|
"unsupported codec: no decoder available for this audio format".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -1473,25 +1464,25 @@ impl SizedDecoder {
|
|||||||
break decoder.last_decoded();
|
break decoder.last_decoded();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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}"));
|
return Err(format!("could not read audio data: {e}"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if packet.track_id() != track_id {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
match decoder.decode(&packet) {
|
match decoder.decode(&packet) {
|
||||||
Ok(decoded) => break decoded,
|
Ok(decoded) => break decoded,
|
||||||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||||||
decode_errors += 1;
|
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 {
|
if decode_errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||||||
return Err("too many consecutive decode errors during init — file may be corrupt".into());
|
return Err("too many consecutive decode errors during init — file may be corrupt".into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[psysonic] fatal decode error: {e}");
|
crate::app_eprintln!("[psysonic] fatal decode error: {e}");
|
||||||
return Err(format!("audio 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)
|
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||||
.ok_or_else(|| format!("{source_tag}: no audio track found"))?;
|
.ok_or_else(|| format!("{source_tag}: no audio track found"))?;
|
||||||
let track_id = track.id;
|
let track_id = track.id;
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
log_codec_resolution(source_tag, &track.codec_params, format_hint);
|
log_codec_resolution(source_tag, &track.codec_params, format_hint);
|
||||||
// Live streams have no known total frame count → total_duration = None.
|
// Live streams have no known total frame count → total_duration = None.
|
||||||
let total_duration = None;
|
let total_duration = None;
|
||||||
@@ -1552,7 +1542,7 @@ impl SizedDecoder {
|
|||||||
Ok(d) => break d,
|
Ok(d) => break d,
|
||||||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||||||
errors += 1;
|
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 {
|
if errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||||||
return Err(format!("{source_tag}: too many consecutive decode errors"));
|
return Err(format!("{source_tag}: too many consecutive decode errors"));
|
||||||
}
|
}
|
||||||
@@ -1631,18 +1621,16 @@ impl Iterator for SizedDecoder {
|
|||||||
let _ = msg;
|
let _ = msg;
|
||||||
self.consecutive_decode_errors += 1;
|
self.consecutive_decode_errors += 1;
|
||||||
// Log sparingly: first drop, then every 10th to avoid spam.
|
// Log sparingly: first drop, then every 10th to avoid spam.
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
if self.consecutive_decode_errors == 1
|
if self.consecutive_decode_errors == 1
|
||||||
|| self.consecutive_decode_errors % 10 == 0
|
|| self.consecutive_decode_errors % 10 == 0
|
||||||
{
|
{
|
||||||
eprintln!(
|
crate::app_deprintln!(
|
||||||
"[psysonic] dropped corrupt frame #{}: {msg}",
|
"[psysonic] dropped corrupt frame #{}: {msg}",
|
||||||
self.consecutive_decode_errors
|
self.consecutive_decode_errors
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if self.consecutive_decode_errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
if self.consecutive_decode_errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||||||
#[cfg(debug_assertions)]
|
crate::app_deprintln!(
|
||||||
eprintln!(
|
|
||||||
"[psysonic] {MAX_CONSECUTIVE_DECODE_ERRORS} consecutive decode \
|
"[psysonic] {MAX_CONSECUTIVE_DECODE_ERRORS} consecutive decode \
|
||||||
failures — stream appears unrecoverable, stopping"
|
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)) =
|
if let Ok((stream, handle)) =
|
||||||
rodio::OutputStream::try_from_device_config(&device, config)
|
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);
|
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)) =
|
if let Ok((stream, handle)) =
|
||||||
rodio::OutputStream::try_from_device_config(&device, config)
|
rodio::OutputStream::try_from_device_config(&device, config)
|
||||||
{
|
{
|
||||||
eprintln!(
|
crate::app_eprintln!(
|
||||||
"[psysonic] audio stream opened at {} Hz (highest, wanted {})",
|
"[psysonic] audio stream opened at {} Hz (highest, wanted {})",
|
||||||
rate, desired_rate
|
rate, desired_rate
|
||||||
);
|
);
|
||||||
@@ -2144,13 +2132,13 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
|
|||||||
.default_output_config()
|
.default_output_config()
|
||||||
.map(|c| c.sample_rate().0)
|
.map(|c| c.sample_rate().0)
|
||||||
.unwrap_or(44100);
|
.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);
|
return (stream, handle, rate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Last resort: system default.
|
// 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()
|
let (stream, handle) = rodio::OutputStream::try_default()
|
||||||
.expect("cannot open any audio output device");
|
.expect("cannot open any audio output device");
|
||||||
let rate = rodio::cpal::default_host()
|
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())?;
|
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()
|
||||||
let status = response.status();
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
let ct = response.headers()
|
.and_then(|v| v.to_str().ok())
|
||||||
.get(reqwest::header::CONTENT_TYPE)
|
.unwrap_or("-");
|
||||||
.and_then(|v| v.to_str().ok())
|
let server_hdr = response.headers()
|
||||||
.unwrap_or("-");
|
.get("server")
|
||||||
let server_hdr = response.headers()
|
.and_then(|v| v.to_str().ok())
|
||||||
.get("server")
|
.unwrap_or("-");
|
||||||
.and_then(|v| v.to_str().ok())
|
// Strip auth params from URL before logging.
|
||||||
.unwrap_or("-");
|
let safe_url = url.split('?').next().unwrap_or(url);
|
||||||
// Strip auth params from URL before logging.
|
crate::app_deprintln!(
|
||||||
let safe_url = url.split('?').next().unwrap_or(url);
|
"[audio] fetch {} → {} | content-type: {} | server: {}",
|
||||||
eprintln!(
|
safe_url, status, ct, server_hdr
|
||||||
"[audio] fetch {} → {} | content-type: {} | server: {}",
|
);
|
||||||
safe_url, status, ct, server_hdr
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
if state.generation.load(Ordering::SeqCst) != gen {
|
if state.generation.load(Ordering::SeqCst) != gen {
|
||||||
return Ok(None); // superseded
|
return Ok(None); // superseded
|
||||||
@@ -2584,8 +2569,7 @@ pub async fn audio_play(
|
|||||||
.extension()
|
.extension()
|
||||||
.and_then(|e| e.to_str())
|
.and_then(|e| e.to_str())
|
||||||
.map(|s| s.to_lowercase());
|
.map(|s| s.to_lowercase());
|
||||||
#[cfg(debug_assertions)]
|
crate::app_deprintln!(
|
||||||
eprintln!(
|
|
||||||
"[stream] LocalFileSource selected — size={} KB, hint={:?}",
|
"[stream] LocalFileSource selected — size={} KB, hint={:?}",
|
||||||
len / 1024,
|
len / 1024,
|
||||||
local_hint
|
local_hint
|
||||||
@@ -2624,8 +2608,7 @@ pub async fn audio_play(
|
|||||||
|
|
||||||
if let (true, Some(total)) = (supports_range, total_size) {
|
if let (true, Some(total)) = (supports_range, total_size) {
|
||||||
let total_usize = total as usize;
|
let total_usize = total as usize;
|
||||||
#[cfg(debug_assertions)]
|
crate::app_deprintln!(
|
||||||
eprintln!(
|
|
||||||
"[stream] RangedHttpSource selected — total={} KB, hint={:?}",
|
"[stream] RangedHttpSource selected — total={} KB, hint={:?}",
|
||||||
total_usize / 1024,
|
total_usize / 1024,
|
||||||
stream_hint
|
stream_hint
|
||||||
@@ -2659,8 +2642,7 @@ pub async fn audio_play(
|
|||||||
tag: "ranged-stream",
|
tag: "ranged-stream",
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
#[cfg(debug_assertions)]
|
crate::app_deprintln!(
|
||||||
eprintln!(
|
|
||||||
"[stream] legacy AudioStreamReader (non-seekable) — accept-ranges={}, content-length={:?}",
|
"[stream] legacy AudioStreamReader (non-seekable) — accept-ranges={}, content-length={:?}",
|
||||||
supports_range, total_size
|
supports_range, total_size
|
||||||
);
|
);
|
||||||
@@ -2848,7 +2830,7 @@ pub async fn audio_play(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
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 next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
|
||||||
let stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
let stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||||
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
|
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",
|
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
|
||||||
next_rate, stream_rate
|
next_rate, stream_rate
|
||||||
);
|
);
|
||||||
@@ -3187,7 +3169,7 @@ fn spawn_progress_task(
|
|||||||
// Radio (dur == 0): stream exhausted / connection dropped → stop.
|
// Radio (dur == 0): stream exhausted / connection dropped → stop.
|
||||||
let cur_dur = current_arc.lock().unwrap().duration_secs;
|
let cur_dur = current_arc.lock().unwrap().duration_secs;
|
||||||
if cur_dur <= 0.0 {
|
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);
|
gen_counter.fetch_add(1, Ordering::SeqCst);
|
||||||
app.emit("audio:ended", ()).ok();
|
app.emit("audio:ended", ()).ok();
|
||||||
break;
|
break;
|
||||||
@@ -3349,7 +3331,7 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu
|
|||||||
rs.flags.is_paused.store(false, Ordering::Release);
|
rs.flags.is_paused.store(false, Ordering::Release);
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
// 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).
|
// (which can consume the ring buffer to EOF for forward seeks → next song).
|
||||||
if !state.current_is_seekable.load(Ordering::SeqCst) {
|
if !state.current_is_seekable.load(Ordering::SeqCst) {
|
||||||
#[cfg(debug_assertions)]
|
crate::app_deprintln!("[seek] rejected → not-seekable source (legacy stream)");
|
||||||
eprintln!("[seek] rejected → not-seekable source (legacy stream)");
|
|
||||||
return Err("source is not seekable".into());
|
return Err("source is not seekable".into());
|
||||||
}
|
}
|
||||||
#[cfg(debug_assertions)]
|
crate::app_deprintln!("[seek] target={:.2}s", seconds);
|
||||||
eprintln!("[seek] target={:.2}s", seconds);
|
|
||||||
|
|
||||||
let lock_current_with_timeout = |timeout_ms: u64| {
|
let lock_current_with_timeout = |timeout_ms: u64| {
|
||||||
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
|
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 {
|
if pinned_miss_count < 3 {
|
||||||
continue;
|
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;
|
pinned_miss_count = 0;
|
||||||
*selected_device.lock().unwrap() = None;
|
*selected_device.lock().unwrap() = None;
|
||||||
|
|
||||||
@@ -4088,7 +4068,7 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
|||||||
}).await.unwrap_or(None);
|
}).await.unwrap_or(None);
|
||||||
|
|
||||||
let Some(handle) = new_handle else {
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+58
-58
@@ -169,14 +169,14 @@ pub fn try_completions_dispatch(args: &[String]) -> Option<i32> {
|
|||||||
Some(0)
|
Some(0)
|
||||||
}
|
}
|
||||||
Some(x) => {
|
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)
|
Some(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_completions_install_help(program: &str) {
|
fn print_completions_install_help(program: &str) {
|
||||||
eprintln!(
|
crate::app_eprintln!(
|
||||||
"Psysonic embeds bash/zsh completion scripts in this binary.\n\
|
"Psysonic embeds bash/zsh completion scripts in this binary.\n\
|
||||||
\n\
|
\n\
|
||||||
Bash — try once in this shell:\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) {
|
pub fn print_help(program: &str) {
|
||||||
let version = env!("CARGO_PKG_VERSION");
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
eprintln!("Psysonic {version}\n");
|
crate::app_eprintln!("Psysonic {version}\n");
|
||||||
eprintln!("── Start ──");
|
crate::app_eprintln!("── Start ──");
|
||||||
eprintln!(" {program}");
|
crate::app_eprintln!(" {program}");
|
||||||
eprintln!(" {program} --version | -V Print version and exit.");
|
crate::app_eprintln!(" {program} --version | -V Print version and exit.");
|
||||||
eprintln!(" {program} --help | -h Show this help.\n");
|
crate::app_eprintln!(" {program} --help | -h Show this help.\n");
|
||||||
eprintln!("── Shell completion (scripts are embedded in the binary) ──");
|
crate::app_eprintln!("── Shell completion (scripts are embedded in the binary) ──");
|
||||||
eprintln!(" {program} completions How to enable tab completion in bash / zsh.");
|
crate::app_eprintln!(" {program} completions How to enable tab completion in bash / zsh.");
|
||||||
eprintln!(" {program} completions bash Print bash completion script (stdout).");
|
crate::app_eprintln!(" {program} completions bash Print bash completion script (stdout).");
|
||||||
eprintln!(" {program} completions zsh Print zsh _psysonic script (stdout).\n");
|
crate::app_eprintln!(" {program} completions zsh Print zsh _psysonic script (stdout).\n");
|
||||||
eprintln!("── Snapshot (saved play state / queue) ──");
|
crate::app_eprintln!("── Snapshot (saved play state / queue) ──");
|
||||||
eprintln!(" Reads a JSON file written by the running app. Open the main window at least once.");
|
crate::app_eprintln!(" Reads a JSON file written by the running app. Open the main window at least once.");
|
||||||
eprintln!(" {program} --info Human-readable summary.");
|
crate::app_eprintln!(" {program} --info Human-readable summary.");
|
||||||
eprintln!(" {program} --info --json One JSON object on stdout.");
|
crate::app_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.");
|
crate::app_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");
|
crate::app_eprintln!(" Windows / macOS: no D-Bus check; an empty or missing file means the UI has not");
|
||||||
eprintln!(" published a snapshot yet.\n");
|
crate::app_eprintln!(" published a snapshot yet.\n");
|
||||||
eprintln!("── Remote commands (--player …) ──");
|
crate::app_eprintln!("── Remote commands (--player …) ──");
|
||||||
eprintln!(" Require the main Psysonic process. Same flags on Linux, Windows, and macOS.");
|
crate::app_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.");
|
crate::app_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");
|
crate::app_eprintln!(" Windows / macOS: handled via single-instance (a helper process may run briefly).\n");
|
||||||
eprintln!(" Global flags (place before --player when needed):");
|
crate::app_eprintln!(" Global flags (place before --player when needed):");
|
||||||
eprintln!(" --quiet | -q Suppress \"OK: …\" lines (stderr errors are always shown).");
|
crate::app_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.");
|
crate::app_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");
|
crate::app_eprintln!(" Use {program} -q --player seek -5 so the seek delta is not parsed as a flag.\n");
|
||||||
eprintln!(" Playback");
|
crate::app_eprintln!(" Playback");
|
||||||
eprintln!(" {program} [--quiet|-q] --player next | prev | play | pause | stop");
|
crate::app_eprintln!(" {program} [--quiet|-q] --player next | prev | play | pause | stop");
|
||||||
eprintln!(" {program} [--quiet|-q] --player play <id> Track, album, or artist id (artist → shuffled library).");
|
crate::app_eprintln!(" {program} [--quiet|-q] --player play <id> Track, album, or artist id (artist → shuffled library).");
|
||||||
eprintln!(" {program} [--quiet|-q] --player seek <seconds> Integer delta, e.g. 15 or -10");
|
crate::app_eprintln!(" {program} [--quiet|-q] --player seek <seconds> Integer delta, e.g. 15 or -10");
|
||||||
eprintln!(" {program} [--quiet|-q] --player volume <0-100> Absolute volume percent.");
|
crate::app_eprintln!(" {program} [--quiet|-q] --player volume <0-100> Absolute volume percent.");
|
||||||
eprintln!(" {program} [--quiet|-q] --player shuffle Shuffle the current queue.");
|
crate::app_eprintln!(" {program} [--quiet|-q] --player shuffle Shuffle the current queue.");
|
||||||
eprintln!(" {program} [--quiet|-q] --player repeat off|all|one");
|
crate::app_eprintln!(" {program} [--quiet|-q] --player repeat off|all|one");
|
||||||
eprintln!(" {program} [--quiet|-q] --player mute | unmute");
|
crate::app_eprintln!(" {program} [--quiet|-q] --player mute | unmute");
|
||||||
eprintln!(" {program} [--quiet|-q] --player star | unstar Current track (Subsonic star).");
|
crate::app_eprintln!(" {program} [--quiet|-q] --player star | unstar Current track (Subsonic star).");
|
||||||
eprintln!(" {program} [--quiet|-q] --player rating <0-5> Set song rating (0 clears).");
|
crate::app_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");
|
crate::app_eprintln!(" {program} [--quiet|-q] --player reload Restart audio for the current track or reload server queue.\n");
|
||||||
eprintln!(" Audio output");
|
crate::app_eprintln!(" Audio output");
|
||||||
eprintln!(" {program} [--json] --player audio-device list");
|
crate::app_eprintln!(" {program} [--json] --player audio-device list");
|
||||||
eprintln!(" {program} --player audio-device set <device-id|default>\n");
|
crate::app_eprintln!(" {program} --player audio-device set <device-id|default>\n");
|
||||||
eprintln!(" Music library (Subsonic music folders for the active server)");
|
crate::app_eprintln!(" Music library (Subsonic music folders for the active server)");
|
||||||
eprintln!(" {program} [--json] --player library list");
|
crate::app_eprintln!(" {program} [--json] --player library list");
|
||||||
eprintln!(" {program} --player library set all | <folder-id>\n");
|
crate::app_eprintln!(" {program} --player library set all | <folder-id>\n");
|
||||||
eprintln!(" Servers (saved profiles — same as the in-app server switcher)");
|
crate::app_eprintln!(" Servers (saved profiles — same as the in-app server switcher)");
|
||||||
eprintln!(" {program} [--json] --player server list");
|
crate::app_eprintln!(" {program} [--json] --player server list");
|
||||||
eprintln!(" {program} --player server set <server-id>\n");
|
crate::app_eprintln!(" {program} --player server set <server-id>\n");
|
||||||
eprintln!(" Search (active server; respects library folder filter)");
|
crate::app_eprintln!(" Search (active server; respects library folder filter)");
|
||||||
eprintln!(" {program} [--json] --player search track <query…>");
|
crate::app_eprintln!(" {program} [--json] --player search track <query…>");
|
||||||
eprintln!(" {program} [--json] --player search album <query…>");
|
crate::app_eprintln!(" {program} [--json] --player search album <query…>");
|
||||||
eprintln!(" {program} [--json] --player search artist <query…>\n");
|
crate::app_eprintln!(" {program} [--json] --player search artist <query…>\n");
|
||||||
eprintln!(" Instant mix (from the track that is currently loaded)");
|
crate::app_eprintln!(" Instant mix (from the track that is currently loaded)");
|
||||||
eprintln!(" {program} --player mix append");
|
crate::app_eprintln!(" {program} --player mix append");
|
||||||
eprintln!(" {program} --player mix new\n");
|
crate::app_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!("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`.
|
/// 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() {
|
match linux_is_primary_instance_running() {
|
||||||
Ok(true) => {}
|
Ok(true) => {}
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
eprintln!("NOT OK: Psysonic is not running");
|
crate::app_eprintln!("NOT OK: Psysonic is not running");
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("NOT OK: {e}");
|
crate::app_eprintln!("NOT OK: {e}");
|
||||||
std::process::exit(1);
|
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 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);
|
let empty = v.is_null() || v.as_object().map(|m| m.is_empty()).unwrap_or(true);
|
||||||
if empty {
|
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);
|
std::process::exit(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,7 +608,7 @@ pub fn run_info_and_exit(args: &[String]) -> ! {
|
|||||||
match serde_json::to_string(&v) {
|
match serde_json::to_string(&v) {
|
||||||
Ok(line) => println!("{line}"),
|
Ok(line) => println!("{line}"),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("NOT OK: {e}");
|
crate::app_eprintln!("NOT OK: {e}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-8
@@ -4,6 +4,7 @@
|
|||||||
mod audio;
|
mod audio;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
mod discord;
|
mod discord;
|
||||||
|
pub(crate) mod logging;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
mod taskbar_win;
|
mod taskbar_win;
|
||||||
|
|
||||||
@@ -123,6 +124,16 @@ fn set_linux_webkit_smooth_scrolling(enabled: bool, app_handle: tauri::AppHandle
|
|||||||
Ok(())
|
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<usize, String> {
|
||||||
|
crate::logging::export_logs_to_file(&path)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Authenticate with Navidrome's own REST API and return a Bearer token.
|
/// Authenticate with Navidrome's own REST API and return a Bearer token.
|
||||||
async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
|
async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
|
||||||
@@ -2667,11 +2678,11 @@ fn try_build_tray_icon(app: &tauri::AppHandle) -> Option<TrayIcon> {
|
|||||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build_tray_icon(&app))) {
|
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build_tray_icon(&app))) {
|
||||||
Ok(Ok(tray)) => Some(tray),
|
Ok(Ok(tray)) => Some(tray),
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
eprintln!("[Psysonic] System tray unavailable: {e}");
|
crate::app_eprintln!("[Psysonic] System tray unavailable: {e}");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
eprintln!(
|
crate::app_eprintln!(
|
||||||
"[Psysonic] System tray unavailable — missing libayatana-appindicator3 or libappindicator3 \
|
"[Psysonic] System tray unavailable — missing libayatana-appindicator3 or libappindicator3 \
|
||||||
(install the distro package or set LD_LIBRARY_PATH)"
|
(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::Forwarded) => std::process::exit(0),
|
||||||
Ok(crate::cli::LinuxPlayerForwardResult::ContinueStartup) => {}
|
Ok(crate::cli::LinuxPlayerForwardResult::ContinueStartup) => {}
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
eprintln!("NOT OK: {msg}");
|
crate::app_eprintln!("NOT OK: {msg}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3180,7 +3191,7 @@ pub fn run() {
|
|||||||
.map(|v| !v.is_empty())
|
.map(|v| !v.is_empty())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !dbus_ok {
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3196,7 +3207,7 @@ pub fn run() {
|
|||||||
.and_then(|w| w.hwnd().ok())
|
.and_then(|w| w.hwnd().ok())
|
||||||
.map(|h| h.0 as *mut std::ffi::c_void);
|
.map(|h| h.0 as *mut std::ffi::c_void);
|
||||||
if h.is_none() {
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
h
|
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)
|
Some(controls)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[Psysonic] Could not create media controls: {e:?}");
|
crate::app_eprintln!("[Psysonic] Could not create media controls: {e:?}");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3284,7 +3295,7 @@ pub fn run() {
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
if let Err(e) = build_mini_player_window(app.handle(), false) {
|
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,
|
cli_publish_search_results,
|
||||||
set_window_decorations,
|
set_window_decorations,
|
||||||
set_linux_webkit_smooth_scrolling,
|
set_linux_webkit_smooth_scrolling,
|
||||||
|
set_logging_mode,
|
||||||
|
export_runtime_logs,
|
||||||
no_compositing_mode,
|
no_compositing_mode,
|
||||||
is_tiling_wm_cmd,
|
is_tiling_wm_cmd,
|
||||||
open_mini_player,
|
open_mini_player,
|
||||||
|
|||||||
@@ -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<VecDeque<String>> {
|
||||||
|
static LOG_BUFFER: OnceLock<Mutex<VecDeque<String>>> = OnceLock::new();
|
||||||
|
LOG_BUFFER.get_or_init(|| Mutex::new(VecDeque::with_capacity(LOG_BUFFER_MAX_LINES)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_logging_mode(mode: &str) -> Option<LoggingMode> {
|
||||||
|
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<usize, String> {
|
||||||
|
let snapshot = {
|
||||||
|
let buf = log_buffer().lock().unwrap();
|
||||||
|
if buf.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
let mut s = buf.iter().cloned().collect::<Vec<_>>().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);
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
}
|
||||||
@@ -203,11 +203,11 @@ pub fn init(app: &AppHandle, hwnd_raw: isize) {
|
|||||||
&TaskbarList, None, CLSCTX_INPROC_SERVER,
|
&TaskbarList, None, CLSCTX_INPROC_SERVER,
|
||||||
) {
|
) {
|
||||||
Ok(t) => t,
|
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() {
|
if let Err(e) = taskbar.HrInit() {
|
||||||
eprintln!("[psysonic] taskbar: HrInit failed: {e}");
|
crate::app_eprintln!("[psysonic] taskbar: HrInit failed: {e}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ pub fn init(app: &AppHandle, hwnd_raw: isize) {
|
|||||||
|
|
||||||
let mut buttons = make_buttons(h_prev, h_play, h_next);
|
let mut buttons = make_buttons(h_prev, h_play, h_next);
|
||||||
if let Err(e) = taskbar.ThumbBarAddButtons(hwnd, &mut buttons) {
|
if let Err(e) = taskbar.ThumbBarAddButtons(hwnd, &mut buttons) {
|
||||||
eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}");
|
crate::app_eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@ pub fn init(app: &AppHandle, hwnd_raw: isize) {
|
|||||||
|
|
||||||
let data = Box::into_raw(Box::new(SubclassData { app: app.clone() }));
|
let data = Box::into_raw(Box::new(SubclassData { app: app.clone() }));
|
||||||
if !SetWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID, data as usize).as_bool() {
|
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));
|
drop(Box::from_raw(data));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,8 +268,7 @@ pub fn update_taskbar_icon(is_playing: bool) {
|
|||||||
|
|
||||||
let mut btns = [btn];
|
let mut btns = [btn];
|
||||||
if let Err(e) = taskbar.ThumbBarUpdateButtons(hwnd, &mut btns) {
|
if let Err(e) = taskbar.ThumbBarUpdateButtons(hwnd, &mut btns) {
|
||||||
#[cfg(debug_assertions)]
|
crate::app_deprintln!("[psysonic] taskbar: ThumbBarUpdateButtons failed: {e}");
|
||||||
eprintln!("[psysonic] taskbar: ThumbBarUpdateButtons failed: {e}");
|
|
||||||
let _ = e;
|
let _ = e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ function AppShell() {
|
|||||||
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
|
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
|
||||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||||
const linuxWebkitKineticScroll = useAuthStore(s => s.linuxWebkitKineticScroll);
|
const linuxWebkitKineticScroll = useAuthStore(s => s.linuxWebkitKineticScroll);
|
||||||
|
const loggingMode = useAuthStore(s => s.loggingMode);
|
||||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
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(() => {});
|
invoke('set_linux_webkit_smooth_scrolling', { enabled: linuxWebkitKineticScroll }).catch(() => {});
|
||||||
}, [linuxWebkitKineticScroll]);
|
}, [linuxWebkitKineticScroll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoke('set_logging_mode', { mode: loggingMode }).catch(() => {});
|
||||||
|
}, [loggingMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn || !activeServerId) return;
|
if (!isLoggedIn || !activeServerId) return;
|
||||||
const serverAtStart = activeServerId;
|
const serverAtStart = activeServerId;
|
||||||
|
|||||||
@@ -682,6 +682,14 @@ export const deTranslation = {
|
|||||||
shortcutNativeFullscreen: 'Nativer Vollbildmodus',
|
shortcutNativeFullscreen: 'Nativer Vollbildmodus',
|
||||||
shortcutOpenMiniPlayer: 'Mini-Player öffnen',
|
shortcutOpenMiniPlayer: 'Mini-Player öffnen',
|
||||||
tabSystem: 'System',
|
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',
|
tabGeneral: 'Allgemein',
|
||||||
ratingsSectionTitle: 'Bewertungen',
|
ratingsSectionTitle: 'Bewertungen',
|
||||||
ratingsSkipStarTitle: 'Skip → 1 Stern',
|
ratingsSkipStarTitle: 'Skip → 1 Stern',
|
||||||
|
|||||||
@@ -666,6 +666,14 @@ export const enTranslation = {
|
|||||||
tabServer: 'Server',
|
tabServer: 'Server',
|
||||||
tabUsers: 'Users',
|
tabUsers: 'Users',
|
||||||
tabSystem: 'System',
|
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',
|
tabGeneral: 'General',
|
||||||
ratingsSectionTitle: 'Ratings',
|
ratingsSectionTitle: 'Ratings',
|
||||||
ratingsSkipStarTitle: 'Skip for 1 star',
|
ratingsSkipStarTitle: 'Skip for 1 star',
|
||||||
|
|||||||
@@ -657,6 +657,14 @@ export const esTranslation = {
|
|||||||
tabServer: 'Servidor',
|
tabServer: 'Servidor',
|
||||||
tabUsers: 'Usuarios',
|
tabUsers: 'Usuarios',
|
||||||
tabSystem: 'Sistema',
|
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',
|
tabGeneral: 'General',
|
||||||
ratingsSectionTitle: 'Calificaciones',
|
ratingsSectionTitle: 'Calificaciones',
|
||||||
ratingsSkipStarTitle: 'Saltar para 1 estrella',
|
ratingsSkipStarTitle: 'Saltar para 1 estrella',
|
||||||
|
|||||||
@@ -670,6 +670,14 @@ export const frTranslation = {
|
|||||||
shortcutNativeFullscreen: 'Plein écran natif',
|
shortcutNativeFullscreen: 'Plein écran natif',
|
||||||
shortcutOpenMiniPlayer: 'Ouvrir le mini-lecteur',
|
shortcutOpenMiniPlayer: 'Ouvrir le mini-lecteur',
|
||||||
tabSystem: 'Système',
|
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',
|
tabGeneral: 'Général',
|
||||||
ratingsSectionTitle: 'Notes',
|
ratingsSectionTitle: 'Notes',
|
||||||
ratingsSkipStarTitle: 'Passer pour 1 étoile',
|
ratingsSkipStarTitle: 'Passer pour 1 étoile',
|
||||||
|
|||||||
@@ -651,6 +651,14 @@ export const nbTranslation = {
|
|||||||
tabServer: 'Tjener',
|
tabServer: 'Tjener',
|
||||||
tabUsers: 'Brukere',
|
tabUsers: 'Brukere',
|
||||||
tabSystem: 'System',
|
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',
|
tabGeneral: 'Generelt',
|
||||||
ratingsSectionTitle: 'Vurderinger',
|
ratingsSectionTitle: 'Vurderinger',
|
||||||
ratingsSkipStarTitle: 'Hopp for 1 stjerne',
|
ratingsSkipStarTitle: 'Hopp for 1 stjerne',
|
||||||
|
|||||||
@@ -669,6 +669,14 @@ export const nlTranslation = {
|
|||||||
shortcutNativeFullscreen: 'Systeemvolledig scherm',
|
shortcutNativeFullscreen: 'Systeemvolledig scherm',
|
||||||
shortcutOpenMiniPlayer: 'Mini-speler openen',
|
shortcutOpenMiniPlayer: 'Mini-speler openen',
|
||||||
tabSystem: 'Systeem',
|
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',
|
tabGeneral: 'Algemeen',
|
||||||
ratingsSectionTitle: 'Beoordelingen',
|
ratingsSectionTitle: 'Beoordelingen',
|
||||||
ratingsSkipStarTitle: 'Overslaan voor 1 ster',
|
ratingsSkipStarTitle: 'Overslaan voor 1 ster',
|
||||||
|
|||||||
@@ -679,6 +679,14 @@ export const ruTranslation = {
|
|||||||
tabServer: 'Сервер',
|
tabServer: 'Сервер',
|
||||||
tabUsers: 'Пользователи',
|
tabUsers: 'Пользователи',
|
||||||
tabSystem: 'Система',
|
tabSystem: 'Система',
|
||||||
|
loggingTitle: 'Логирование',
|
||||||
|
loggingModeDesc: 'Управляет подробностью логов backend в терминале.',
|
||||||
|
loggingModeOff: 'Выключено',
|
||||||
|
loggingModeNormal: 'Обычное',
|
||||||
|
loggingModeDebug: 'Дебаг',
|
||||||
|
loggingExport: 'Экспорт логов',
|
||||||
|
loggingExportSuccess: 'Логи экспортированы ({{count}} строк).',
|
||||||
|
loggingExportError: 'Не удалось экспортировать логи.',
|
||||||
tabGeneral: 'Общие',
|
tabGeneral: 'Общие',
|
||||||
ratingsSectionTitle: 'Рейтинги',
|
ratingsSectionTitle: 'Рейтинги',
|
||||||
ratingsSkipStarTitle: 'Скипнуть для 1 звезды',
|
ratingsSkipStarTitle: 'Скипнуть для 1 звезды',
|
||||||
|
|||||||
@@ -647,6 +647,14 @@ export const zhTranslation = {
|
|||||||
tabServer: '服务器',
|
tabServer: '服务器',
|
||||||
tabUsers: '用户',
|
tabUsers: '用户',
|
||||||
tabSystem: '系统',
|
tabSystem: '系统',
|
||||||
|
loggingTitle: '日志记录',
|
||||||
|
loggingModeDesc: '控制终端中后端日志的详细程度。',
|
||||||
|
loggingModeOff: '关闭',
|
||||||
|
loggingModeNormal: '普通',
|
||||||
|
loggingModeDebug: '调试',
|
||||||
|
loggingExport: '导出日志',
|
||||||
|
loggingExportSuccess: '日志已导出({{count}} 行)。',
|
||||||
|
loggingExportError: '无法导出日志。',
|
||||||
tabGeneral: '通用',
|
tabGeneral: '通用',
|
||||||
ratingsSectionTitle: '评分',
|
ratingsSectionTitle: '评分',
|
||||||
ratingsSkipStarTitle: '跳过以评 1 星',
|
ratingsSkipStarTitle: '跳过以评 1 星',
|
||||||
|
|||||||
+48
-3
@@ -22,7 +22,7 @@ import LastfmIcon from '../components/LastfmIcon';
|
|||||||
import CustomSelect from '../components/CustomSelect';
|
import CustomSelect from '../components/CustomSelect';
|
||||||
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
|
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
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 { SeekbarPreview } from '../components/WaveformSeek';
|
||||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
||||||
import { useThemeStore } from '../store/themeStore';
|
import { useThemeStore } from '../store/themeStore';
|
||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
type NdUser, type NdLibrary,
|
type NdUser, type NdLibrary,
|
||||||
} from '../api/navidromeAdmin';
|
} from '../api/navidromeAdmin';
|
||||||
import { switchActiveServer } from '../utils/switchActiveServer';
|
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 ConfirmModal from '../components/ConfirmModal';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import Equalizer from '../components/Equalizer';
|
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<number>('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 }[] = [
|
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||||
{ id: 'general', label: t('settings.tabGeneral'), icon: <AppWindow size={15} /> },
|
{ id: 'general', label: t('settings.tabGeneral'), icon: <AppWindow size={15} /> },
|
||||||
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
|
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
|
||||||
@@ -2927,7 +2944,35 @@ export default function Settings() {
|
|||||||
|
|
||||||
{activeTab === 'system' && (
|
{activeTab === 'system' && (
|
||||||
<>
|
<>
|
||||||
<BackupSection />
|
<BackupSection />
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<Sliders size={18} />
|
||||||
|
<h2>{t('settings.loggingTitle')}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="settings-card">
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||||
|
{t('settings.loggingModeDesc')}
|
||||||
|
</div>
|
||||||
|
<CustomSelect
|
||||||
|
value={auth.loggingMode}
|
||||||
|
onChange={(v) => 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' && (
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
<button className="btn btn-surface" onClick={exportRuntimeLogs}>
|
||||||
|
<Download size={14} />
|
||||||
|
{t('settings.loggingExport')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<div className="settings-section-header">
|
<div className="settings-section-header">
|
||||||
<Info size={18} />
|
<Info size={18} />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface ServerProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
|
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 type LyricsSourceId = 'server' | 'lrclib' | 'netease';
|
||||||
export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; }
|
export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; }
|
||||||
@@ -71,6 +72,8 @@ interface AuthState {
|
|||||||
preloadMiniPlayer: boolean;
|
preloadMiniPlayer: boolean;
|
||||||
/** Linux WebKitGTK: smooth wheel on when true; off only after explicit opt-out in Settings. */
|
/** Linux WebKitGTK: smooth wheel on when true; off only after explicit opt-out in Settings. */
|
||||||
linuxWebkitKineticScroll: boolean;
|
linuxWebkitKineticScroll: boolean;
|
||||||
|
/** Runtime backend logging level. */
|
||||||
|
loggingMode: LoggingMode;
|
||||||
nowPlayingEnabled: boolean;
|
nowPlayingEnabled: boolean;
|
||||||
lyricsServerFirst: boolean;
|
lyricsServerFirst: boolean;
|
||||||
enableNeteaselyrics: boolean;
|
enableNeteaselyrics: boolean;
|
||||||
@@ -217,6 +220,7 @@ interface AuthState {
|
|||||||
setUseCustomTitlebar: (v: boolean) => void;
|
setUseCustomTitlebar: (v: boolean) => void;
|
||||||
setPreloadMiniPlayer: (v: boolean) => void;
|
setPreloadMiniPlayer: (v: boolean) => void;
|
||||||
setLinuxWebkitKineticScroll: (v: boolean) => void;
|
setLinuxWebkitKineticScroll: (v: boolean) => void;
|
||||||
|
setLoggingMode: (v: LoggingMode) => void;
|
||||||
setNowPlayingEnabled: (v: boolean) => void;
|
setNowPlayingEnabled: (v: boolean) => void;
|
||||||
setLyricsServerFirst: (v: boolean) => void;
|
setLyricsServerFirst: (v: boolean) => void;
|
||||||
setEnableNeteaselyrics: (v: boolean) => void;
|
setEnableNeteaselyrics: (v: boolean) => void;
|
||||||
@@ -324,6 +328,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
useCustomTitlebar: false,
|
useCustomTitlebar: false,
|
||||||
preloadMiniPlayer: false,
|
preloadMiniPlayer: false,
|
||||||
linuxWebkitKineticScroll: true,
|
linuxWebkitKineticScroll: true,
|
||||||
|
loggingMode: 'normal',
|
||||||
nowPlayingEnabled: false,
|
nowPlayingEnabled: false,
|
||||||
lyricsServerFirst: true,
|
lyricsServerFirst: true,
|
||||||
enableNeteaselyrics: false,
|
enableNeteaselyrics: false,
|
||||||
@@ -455,6 +460,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
|
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
|
||||||
setPreloadMiniPlayer: (v) => set({ preloadMiniPlayer: v }),
|
setPreloadMiniPlayer: (v) => set({ preloadMiniPlayer: v }),
|
||||||
setLinuxWebkitKineticScroll: (v) => set({ linuxWebkitKineticScroll: v }),
|
setLinuxWebkitKineticScroll: (v) => set({ linuxWebkitKineticScroll: v }),
|
||||||
|
setLoggingMode: (v) => set({ loggingMode: v }),
|
||||||
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
|
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
|
||||||
setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }),
|
setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }),
|
||||||
setEnableNeteaselyrics: (v: boolean) => set({ enableNeteaselyrics: v }),
|
setEnableNeteaselyrics: (v: boolean) => set({ enableNeteaselyrics: v }),
|
||||||
|
|||||||
Reference in New Issue
Block a user