mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor: extract psysonic-analysis crate (M2/7)
Moves analysis_cache + analysis_runtime out of the top crate into a new
psysonic-analysis crate, plus the runtime user-agent facade into
psysonic-core. The audio↔analysis dependency cycle is broken via a
PlaybackQueryHandle port registered as Tauri State.
crates/psysonic-analysis/ new lib crate
src/analysis_cache/{mod,store,compute} unchanged behaviour
src/analysis_runtime.rs + enqueue_analysis_seed (was
in lib_commands/cache/offline)
crates/psysonic-core/src/
user_agent.rs subsonic_wire_user_agent +
runtime/default helpers
(was in top lib.rs)
ports.rs PlaybackQueryHandle: closure
wrapper, not Arc<dyn Trait>,
so existing State<AudioEngine>
callsites stay unchanged
The shell setup hook registers the real PlaybackQueryHandle once the
AppHandle is available; the closure captures it and re-resolves
AudioEngine via try_state at each call.
Top crate keeps `crate::analysis_cache`, `crate::analysis_runtime`,
`crate::subsonic_wire_user_agent`, and `crate::submit_analysis_cpu_seed`
working via re-exports — no audio/lib_commands callsite needed editing.
Behaviour preserving. Cargo check + clippy --workspace clean (only
pre-existing warnings carry over).
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "psysonic-analysis"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
psysonic-core = { path = "../psysonic-core" }
|
||||
|
||||
tauri = { version = "2" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "rustls"] }
|
||||
futures-util = "0.3"
|
||||
ebur128 = "0.1"
|
||||
md5 = "0.8"
|
||||
rusqlite = { version = "0.39", features = ["bundled"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||
@@ -0,0 +1,492 @@
|
||||
use std::io::Cursor;
|
||||
use std::time::Instant;
|
||||
|
||||
use ebur128::{EbuR128, Mode as Ebur128Mode};
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::{Decoder, DecoderOptions, CODEC_TYPE_NULL};
|
||||
use symphonia::core::errors::Error as SymphoniaError;
|
||||
use symphonia::core::formats::{FormatOptions, FormatReader};
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::store::{now_unix_ts, AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry};
|
||||
|
||||
pub fn recommended_gain_for_target(integrated_lufs: f64, true_peak: f64, target_lufs: f64) -> f64 {
|
||||
let mut recommended_gain_db = target_lufs - integrated_lufs;
|
||||
if true_peak > 0.0 {
|
||||
let true_peak_dbtp = 20.0 * true_peak.log10();
|
||||
let max_gain_db = -1.0 - true_peak_dbtp;
|
||||
if recommended_gain_db > max_gain_db {
|
||||
recommended_gain_db = max_gain_db;
|
||||
}
|
||||
}
|
||||
recommended_gain_db.clamp(-24.0, 24.0)
|
||||
}
|
||||
|
||||
/// Result of [`seed_from_bytes_execute`] / CPU seed queue: callers use it to avoid redundant UI events.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SeedFromBytesOutcome {
|
||||
/// Wrote waveform (and loudness when PCM decode succeeded).
|
||||
Upserted,
|
||||
/// Same `track_id` + `md5_16kb` already had a non-empty waveform for this algo version.
|
||||
SkippedWaveformCacheHit,
|
||||
/// `AnalysisCache` was not registered on the app handle.
|
||||
SkippedNoAnalysisCache,
|
||||
}
|
||||
|
||||
/// Full Symphonia + (optional) EBU decode for waveform + loudness. Call only from the
|
||||
/// single CPU-seed worker in `lib.rs` (`spawn_blocking`) so at most one heavy decode runs.
|
||||
pub fn seed_from_bytes_execute(
|
||||
app: &tauri::AppHandle,
|
||||
track_id: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<SeedFromBytesOutcome, String> {
|
||||
let started = Instant::now();
|
||||
let Some(cache) = app.try_state::<AnalysisCache>() else {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][waveform] build skip track_id={} reason=no_analysis_cache bytes={}",
|
||||
track_id,
|
||||
bytes.len()
|
||||
);
|
||||
return Ok(SeedFromBytesOutcome::SkippedNoAnalysisCache);
|
||||
};
|
||||
let key = TrackKey {
|
||||
track_id: track_id.to_string(),
|
||||
md5_16kb: md5_first_16kb(bytes),
|
||||
};
|
||||
if let Some(existing) = cache.get_waveform(&key)? {
|
||||
if !existing.bins.is_empty() {
|
||||
if cache.loudness_row_exists_for_key(&key)? {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} bins_len={} elapsed_ms={}",
|
||||
track_id,
|
||||
key.md5_16kb,
|
||||
existing.bins.len(),
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
return Ok(SeedFromBytesOutcome::SkippedWaveformCacheHit);
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[analysis][waveform] waveform cache hit but loudness missing — full re-analysis track_id={} md5_16kb={}",
|
||||
track_id,
|
||||
key.md5_16kb
|
||||
);
|
||||
}
|
||||
}
|
||||
let mib = bytes.len() as f64 / (1024.0 * 1024.0);
|
||||
crate::app_deprintln!(
|
||||
"[analysis] full-track analysis start track_id={} input_mib={:.2} md5_16kb={}",
|
||||
track_id,
|
||||
mib,
|
||||
key.md5_16kb
|
||||
);
|
||||
crate::app_deprintln!(
|
||||
"[analysis] full-track analysis work: Symphonia decodes the entire buffer twice (frame timeline, then PCM peak bins), then EBU R128 integrated loudness + true-peak when that succeeds — CPU-bound; large lossless files often take minutes"
|
||||
);
|
||||
|
||||
let build = (|| -> Result<(bool, usize), String> {
|
||||
cache.touch_track_status(&key, "queued")?;
|
||||
|
||||
let (wf_bins, loudness_opt, used_pcm_decode) = match analyze_loudness_and_waveform(bytes, -16.0, 500) {
|
||||
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins)) => {
|
||||
(
|
||||
bins,
|
||||
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)),
|
||||
true,
|
||||
)
|
||||
}
|
||||
None => (derive_waveform_bins(bytes, 500), None, false),
|
||||
};
|
||||
let bins_len = wf_bins.len();
|
||||
let waveform = WaveformEntry {
|
||||
bins: wf_bins,
|
||||
bin_count: 500,
|
||||
is_partial: false,
|
||||
known_until_sec: 0.0,
|
||||
duration_sec: 0.0,
|
||||
updated_at: now_unix_ts(),
|
||||
};
|
||||
cache.upsert_waveform(&key, &waveform)?;
|
||||
|
||||
if let Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) = loudness_opt {
|
||||
let loudness = LoudnessEntry {
|
||||
integrated_lufs,
|
||||
true_peak,
|
||||
recommended_gain_db,
|
||||
target_lufs,
|
||||
updated_at: now_unix_ts(),
|
||||
};
|
||||
cache.upsert_loudness(&key, &loudness)?;
|
||||
}
|
||||
|
||||
cache.touch_track_status(&key, "ready")?;
|
||||
Ok((used_pcm_decode, bins_len))
|
||||
})();
|
||||
|
||||
let elapsed_ms = started.elapsed().as_millis();
|
||||
match &build {
|
||||
Ok((used_pcm_decode, bins_len)) => {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] full-track analysis done track_id={} elapsed_ms={} decode_path={} bins_len={} ebu_loudness_cached={}",
|
||||
track_id,
|
||||
elapsed_ms,
|
||||
if *used_pcm_decode {
|
||||
"pcm_ebur128"
|
||||
} else {
|
||||
"byte_envelope_no_ebu"
|
||||
},
|
||||
bins_len,
|
||||
*used_pcm_decode
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] full-track analysis failed track_id={} elapsed_ms={} err={}",
|
||||
track_id,
|
||||
elapsed_ms,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match build {
|
||||
Ok(_) => Ok(SeedFromBytesOutcome::Upserted),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn md5_first_16kb(bytes: &[u8]) -> String {
|
||||
let n = bytes.len().min(16 * 1024);
|
||||
format!("{:x}", md5::compute(&bytes[..n]))
|
||||
}
|
||||
|
||||
fn derive_waveform_bins(bytes: &[u8], bin_count: usize) -> Vec<u8> {
|
||||
if bin_count == 0 || bytes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut peak_half = vec![0u8; bin_count];
|
||||
for (i, slot) in peak_half.iter_mut().enumerate() {
|
||||
let start = i * bytes.len() / bin_count;
|
||||
let end = ((i + 1) * bytes.len() / bin_count).max(start + 1).min(bytes.len());
|
||||
let mut peak: u8 = 0;
|
||||
for &b in &bytes[start..end] {
|
||||
let centered = if b >= 128 { b - 128 } else { 128 - b };
|
||||
if centered > peak {
|
||||
peak = centered;
|
||||
}
|
||||
}
|
||||
*slot = ((peak as f32 / 127.0).sqrt().clamp(0.0, 1.0) * 255.0) as u8;
|
||||
}
|
||||
let mut out = peak_half.clone();
|
||||
out.extend_from_slice(&peak_half);
|
||||
out
|
||||
}
|
||||
|
||||
struct PcmScanResult {
|
||||
bins: Vec<u8>,
|
||||
loudness: Option<(f64, f64, f64, f64)>,
|
||||
}
|
||||
|
||||
/// Loudness (EBU R128) plus PCM waveform bins in one decode pass after a frame count.
|
||||
fn analyze_loudness_and_waveform(
|
||||
bytes: &[u8],
|
||||
target_lufs: f64,
|
||||
bin_count: usize,
|
||||
) -> Option<(f64, f64, f64, f64, Vec<u8>)> {
|
||||
if bytes.is_empty() || bin_count == 0 {
|
||||
return None;
|
||||
}
|
||||
let (decoded_frames, timeline_hint) = count_mono_frames_from_audio_bytes(bytes)?;
|
||||
if decoded_frames == 0 {
|
||||
return None;
|
||||
}
|
||||
let scanned = decode_scan_pcm(bytes, bin_count, decoded_frames, timeline_hint, Some(target_lufs))?;
|
||||
let (i, t, r, tgt) = scanned.loudness?;
|
||||
Some((i, t, r, tgt, scanned.bins))
|
||||
}
|
||||
|
||||
/// One-shot Symphonia setup: probe the byte buffer, pick a usable track, and
|
||||
/// build a decoder for it. `timeline_hint` carries `codec_params.n_frames`
|
||||
/// when the container reports total track length.
|
||||
struct DecodeSession {
|
||||
format: Box<dyn FormatReader>,
|
||||
decoder: Box<dyn Decoder>,
|
||||
track_id: u32,
|
||||
timeline_hint: Option<u64>,
|
||||
}
|
||||
|
||||
fn open_decode_session(bytes: &[u8]) -> Option<DecodeSession> {
|
||||
let source = Box::new(Cursor::new(bytes.to_vec()));
|
||||
let mss = MediaSourceStream::new(source, Default::default());
|
||||
let hint = Hint::new();
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
|
||||
.ok()?;
|
||||
let format = probed.format;
|
||||
let track = format
|
||||
.default_track()
|
||||
.filter(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.or_else(|| {
|
||||
format.tracks().iter().find(|t| {
|
||||
t.codec_params.codec != CODEC_TYPE_NULL
|
||||
&& t.codec_params.sample_rate.is_some()
|
||||
&& t.codec_params.channels.is_some()
|
||||
})
|
||||
})
|
||||
.or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?;
|
||||
let track_id = track.id;
|
||||
let timeline_hint = track.codec_params.n_frames.filter(|&n| n > 0);
|
||||
let codec_params = track.codec_params.clone();
|
||||
let decoder = match symphonia::default::get_codecs().make(&codec_params, &DecoderOptions::default()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
crate::app_deprintln!("[analysis] decoder make failed: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(DecodeSession { format, decoder, track_id, timeline_hint })
|
||||
}
|
||||
|
||||
/// Returns `(decoded_mono_frames, container_timeline_frames)` where the second is
|
||||
/// `codec_params.n_frames` when the container reports total track length — used
|
||||
/// as a **fixed** waveform time axis so partial decodes do not remap every bin
|
||||
/// when the buffer grows.
|
||||
fn count_mono_frames_from_audio_bytes(bytes: &[u8]) -> Option<(u64, Option<u64>)> {
|
||||
let DecodeSession { mut format, mut decoder, track_id, timeline_hint } =
|
||||
open_decode_session(bytes)?;
|
||||
|
||||
let mut total: u64 = 0;
|
||||
let mut loop_i: u32 = 0;
|
||||
loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(packet) => packet,
|
||||
Err(_) => break,
|
||||
};
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
Ok(buf) => buf,
|
||||
Err(SymphoniaError::DecodeError(_)) => continue,
|
||||
Err(SymphoniaError::ResetRequired) => break,
|
||||
Err(_) => break,
|
||||
};
|
||||
let spec = *decoded.spec();
|
||||
let n_ch = spec.channels.count();
|
||||
if n_ch == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||||
samples.copy_interleaved_ref(decoded);
|
||||
let n = samples.samples().len();
|
||||
if n < n_ch || n % n_ch != 0 {
|
||||
continue;
|
||||
}
|
||||
total += (n / n_ch) as u64;
|
||||
loop_i = loop_i.wrapping_add(1);
|
||||
if loop_i % 128 == 0 {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
None
|
||||
} else {
|
||||
Some((total, timeline_hint))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_peak_bins(bin_max: &[f32]) -> Vec<u8> {
|
||||
let bin_count = bin_max.len();
|
||||
if bin_count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut sorted: Vec<f32> = bin_max.to_vec();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let p5 = sorted[(sorted.len() * 5 / 100).min(sorted.len().saturating_sub(1))];
|
||||
let p99 = sorted[(sorted.len() * 99 / 100).min(sorted.len().saturating_sub(1))];
|
||||
let range = (p99 - p5).max(1e-8);
|
||||
let mut out = vec![0u8; bin_count];
|
||||
for i in 0..bin_count {
|
||||
let t = ((bin_max[i] - p5) / range).clamp(0.0, 1.0);
|
||||
let shaped = t.powf(0.52);
|
||||
out[i] = (8.0 + shaped * 247.0).min(255.0) as u8;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_scan_pcm(
|
||||
bytes: &[u8],
|
||||
bin_count: usize,
|
||||
decoded_frames: u64,
|
||||
timeline_hint: Option<u64>,
|
||||
loudness_target_lufs: Option<f64>,
|
||||
) -> Option<PcmScanResult> {
|
||||
let DecodeSession { mut format, mut decoder, track_id, .. } = open_decode_session(bytes)?;
|
||||
|
||||
let mut bin_max = vec![0.0f32; bin_count];
|
||||
let mut bin_sum = vec![0.0f32; bin_count];
|
||||
let mut bin_n = vec![0u32; bin_count];
|
||||
let mut ebu: Option<EbuR128> = None;
|
||||
let mut ebu_channels: u32 = 0;
|
||||
let mut sample_peak_abs = 0.0_f64;
|
||||
let mut fed_any_frames = false;
|
||||
let mut sample_idx: u64 = 0;
|
||||
let mut loop_i: u32 = 0;
|
||||
// Fixed timeline from metadata when available; otherwise fall back to decoded
|
||||
// length (full-buffer analysis only — partial byte windows still shift, but
|
||||
// then we usually lack n_frames anyway).
|
||||
let bin_grid_frames = timeline_hint
|
||||
.map(|n| n.max(decoded_frames))
|
||||
.unwrap_or(decoded_frames)
|
||||
.max(1);
|
||||
|
||||
loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(packet) => packet,
|
||||
Err(_) => break,
|
||||
};
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
Ok(buf) => buf,
|
||||
Err(SymphoniaError::DecodeError(_)) => continue,
|
||||
Err(SymphoniaError::ResetRequired) => break,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let spec = *decoded.spec();
|
||||
let n_ch = spec.channels.count();
|
||||
if n_ch == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if loudness_target_lufs.is_some() && ebu.is_none() {
|
||||
let ch = spec.channels.count() as u32;
|
||||
let sr = spec.rate;
|
||||
match EbuR128::new(ch, sr, Ebur128Mode::I | Ebur128Mode::TRUE_PEAK) {
|
||||
Ok(v) => {
|
||||
ebu = Some(v);
|
||||
ebu_channels = ch;
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] EbuR128 init failed: channels={} sample_rate={} err={}",
|
||||
ch,
|
||||
sr,
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||||
samples.copy_interleaved_ref(decoded);
|
||||
let slice = samples.samples();
|
||||
if slice.len() < n_ch || slice.len() % n_ch != 0 {
|
||||
continue;
|
||||
}
|
||||
let frames = slice.len() / n_ch;
|
||||
|
||||
for f in 0..frames {
|
||||
let base = f * n_ch;
|
||||
let mut acc = 0.0f32;
|
||||
for c in 0..n_ch {
|
||||
acc += slice[base + c];
|
||||
}
|
||||
let mono = acc / (n_ch as f32);
|
||||
let mag = mono.abs();
|
||||
if mag.is_finite() {
|
||||
let bin = ((sample_idx * bin_count as u64) / bin_grid_frames) as usize;
|
||||
let bin = bin.min(bin_count.saturating_sub(1));
|
||||
bin_max[bin] = bin_max[bin].max(mag);
|
||||
bin_sum[bin] += mag;
|
||||
bin_n[bin] = bin_n[bin].saturating_add(1);
|
||||
}
|
||||
for c in 0..n_ch {
|
||||
let v = (slice[base + c] as f64).abs();
|
||||
if v.is_finite() && v > sample_peak_abs {
|
||||
sample_peak_abs = v;
|
||||
}
|
||||
}
|
||||
sample_idx += 1;
|
||||
}
|
||||
|
||||
if loudness_target_lufs.is_some() {
|
||||
if let Some(e) = ebu.as_mut() {
|
||||
match e.add_frames_f32(samples.samples()) {
|
||||
Ok(_) => fed_any_frames = true,
|
||||
Err(err) => {
|
||||
crate::app_deprintln!("[analysis] loudness add_frames failed: {}", err);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loop_i = loop_i.wrapping_add(1);
|
||||
if loop_i % 128 == 0 {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
|
||||
let mut bin_mean = vec![0.0f32; bin_count];
|
||||
for i in 0..bin_count {
|
||||
if bin_n[i] > 0 {
|
||||
bin_mean[i] = bin_sum[i] / (bin_n[i] as f32);
|
||||
}
|
||||
}
|
||||
let peak_u8 = normalize_peak_bins(&bin_max);
|
||||
let mean_u8 = normalize_peak_bins(&bin_mean);
|
||||
let mut bins = Vec::with_capacity(peak_u8.len().saturating_mul(2));
|
||||
bins.extend_from_slice(&peak_u8);
|
||||
bins.extend_from_slice(&mean_u8);
|
||||
|
||||
let loudness = if let Some(target_lufs) = loudness_target_lufs {
|
||||
if !fed_any_frames {
|
||||
crate::app_deprintln!("[analysis] loudness failed: no decoded frames");
|
||||
return None;
|
||||
}
|
||||
let Some(ebu) = ebu else {
|
||||
crate::app_deprintln!("[analysis] loudness failed: ebu not initialized");
|
||||
return None;
|
||||
};
|
||||
let integrated_lufs = match ebu.loudness_global() {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
crate::app_deprintln!("[analysis] loudness_global failed: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !integrated_lufs.is_finite() {
|
||||
crate::app_deprintln!("[analysis] loudness failed: integrated_lufs not finite");
|
||||
return None;
|
||||
}
|
||||
let mut true_peak = 0.0_f64;
|
||||
let mut true_peak_ok = true;
|
||||
for ch in 0..ebu_channels {
|
||||
match ebu.true_peak(ch) {
|
||||
Ok(v) if v.is_finite() && v > true_peak => true_peak = v,
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
true_peak_ok = false;
|
||||
crate::app_deprintln!("[analysis] true_peak unavailable: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !true_peak_ok {
|
||||
true_peak = sample_peak_abs;
|
||||
}
|
||||
let recommended_gain_db =
|
||||
recommended_gain_for_target(integrated_lufs, true_peak, target_lufs);
|
||||
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(PcmScanResult { bins, loudness })
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod compute;
|
||||
mod store;
|
||||
|
||||
pub use compute::{recommended_gain_for_target, seed_from_bytes_execute, SeedFromBytesOutcome};
|
||||
pub use store::{AnalysisCache, TrackKey};
|
||||
@@ -0,0 +1,408 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use tauri::Manager;
|
||||
|
||||
pub(super) const WAVEFORM_ALGO_VERSION: i64 = 4;
|
||||
pub(super) const LOUDNESS_ALGO_VERSION: i64 = 1;
|
||||
|
||||
/// Bins in waveform BLOB: `2 * bin_count` bytes (peak u8, then mean-abs u8 per time bin).
|
||||
fn waveform_cache_blob_len_ok(bins: &[u8], bin_count: i64) -> bool {
|
||||
if bin_count <= 0 {
|
||||
return false;
|
||||
}
|
||||
let n = bin_count as usize;
|
||||
bins.len() == n.saturating_mul(2)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackKey {
|
||||
pub track_id: String,
|
||||
pub md5_16kb: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WaveformEntry {
|
||||
pub bins: Vec<u8>,
|
||||
pub bin_count: i64,
|
||||
pub is_partial: bool,
|
||||
pub known_until_sec: f64,
|
||||
pub duration_sec: f64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoudnessEntry {
|
||||
pub integrated_lufs: f64,
|
||||
pub true_peak: f64,
|
||||
pub recommended_gain_db: f64,
|
||||
pub target_lufs: f64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoudnessSnapshot {
|
||||
pub integrated_lufs: f64,
|
||||
pub true_peak: f64,
|
||||
pub recommended_gain_db: f64,
|
||||
pub target_lufs: f64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
pub struct AnalysisCache {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
/// Ranged HTTP seeding uses `stream:<subsonicId>` (see `playback_identity`); backfill
|
||||
/// and IPC often use the bare `<subsonicId>`. Rows may exist under either key.
|
||||
fn track_id_cache_variants(id: &str) -> Vec<String> {
|
||||
let mut out = vec![id.to_string()];
|
||||
if let Some(bare) = id.strip_prefix("stream:") {
|
||||
if !bare.is_empty() {
|
||||
out.push(bare.to_string());
|
||||
}
|
||||
} else {
|
||||
out.push(format!("stream:{id}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn now_unix_ts() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
impl AnalysisCache {
|
||||
pub fn init(app: &tauri::AppHandle) -> Result<Self, String> {
|
||||
let db_path = analysis_db_path(app)?;
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let conn = Connection::open(db_path).map_err(|e| e.to_string())?;
|
||||
configure_connection(&conn).map_err(|e| e.to_string())?;
|
||||
migrate_schema(&conn).map_err(|e| e.to_string())?;
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
}
|
||||
|
||||
/// Remove all `loudness_cache` rows for this logical track (bare id and `stream:` variant).
|
||||
pub fn delete_loudness_for_track_id(&self, track_id: &str) -> Result<u64, String> {
|
||||
if track_id.trim().is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let mut total: u64 = 0;
|
||||
for tid in track_id_cache_variants(track_id) {
|
||||
let n = conn
|
||||
.execute("DELETE FROM loudness_cache WHERE track_id = ?1", params![tid])
|
||||
.map_err(|e| e.to_string())?;
|
||||
total = total.saturating_add(n as u64);
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Remove all cached waveform rows across all tracks/variants.
|
||||
pub fn delete_all_waveforms(&self) -> Result<u64, String> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let n = conn
|
||||
.execute("DELETE FROM waveform_cache", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(n as u64)
|
||||
}
|
||||
|
||||
pub fn touch_track_status(&self, key: &TrackKey, status: &str) -> Result<(), String> {
|
||||
let now = now_unix_ts();
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO analysis_track (
|
||||
track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(track_id, md5_16kb) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
waveform_algo_version = excluded.waveform_algo_version,
|
||||
loudness_algo_version = excluded.loudness_algo_version,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
params![
|
||||
key.track_id,
|
||||
key.md5_16kb,
|
||||
status,
|
||||
WAVEFORM_ALGO_VERSION,
|
||||
LOUDNESS_ALGO_VERSION,
|
||||
now
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_waveform(&self, key: &TrackKey, entry: &WaveformEntry) -> Result<(), String> {
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO waveform_cache (
|
||||
track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(track_id, md5_16kb) DO UPDATE SET
|
||||
bins = excluded.bins,
|
||||
bin_count = excluded.bin_count,
|
||||
is_partial = excluded.is_partial,
|
||||
known_until_sec = excluded.known_until_sec,
|
||||
duration_sec = excluded.duration_sec,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
params![
|
||||
key.track_id,
|
||||
key.md5_16kb,
|
||||
entry.bins,
|
||||
entry.bin_count,
|
||||
if entry.is_partial { 1 } else { 0 },
|
||||
entry.known_until_sec,
|
||||
entry.duration_sec,
|
||||
entry.updated_at
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_loudness(&self, key: &TrackKey, entry: &LoudnessEntry) -> Result<(), String> {
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO loudness_cache (
|
||||
track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(track_id, md5_16kb, target_lufs) DO UPDATE SET
|
||||
integrated_lufs = excluded.integrated_lufs,
|
||||
true_peak = excluded.true_peak,
|
||||
recommended_gain_db = excluded.recommended_gain_db,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
params![
|
||||
key.track_id,
|
||||
key.md5_16kb,
|
||||
entry.integrated_lufs,
|
||||
entry.true_peak,
|
||||
entry.recommended_gain_db,
|
||||
entry.target_lufs,
|
||||
entry.updated_at
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_waveform(&self, key: &TrackKey) -> Result<Option<WaveformEntry>, String> {
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let row = conn
|
||||
.query_row(
|
||||
r#"
|
||||
SELECT w.bins, w.bin_count, w.is_partial, w.known_until_sec, w.duration_sec, w.updated_at
|
||||
FROM waveform_cache w
|
||||
JOIN analysis_track a
|
||||
ON a.track_id = w.track_id
|
||||
AND a.md5_16kb = w.md5_16kb
|
||||
WHERE w.track_id = ?1
|
||||
AND w.md5_16kb = ?2
|
||||
AND a.waveform_algo_version = ?3
|
||||
"#,
|
||||
params![key.track_id, key.md5_16kb, WAVEFORM_ALGO_VERSION],
|
||||
|row| {
|
||||
Ok(WaveformEntry {
|
||||
bins: row.get(0)?,
|
||||
bin_count: row.get(1)?,
|
||||
is_partial: row.get::<_, i64>(2)? != 0,
|
||||
known_until_sec: row.get(3)?,
|
||||
duration_sec: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(row.filter(|e| waveform_cache_blob_len_ok(&e.bins, e.bin_count)))
|
||||
}
|
||||
|
||||
/// True when this exact `(track_id, md5_16kb)` has a loudness row for the current algo version.
|
||||
/// Used after `delete_loudness_for_track_id`: waveform may still be cached, but EBU data was removed.
|
||||
pub fn loudness_row_exists_for_key(&self, key: &TrackKey) -> Result<bool, String> {
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let exists: i64 = conn
|
||||
.query_row(
|
||||
r#"
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM loudness_cache l
|
||||
JOIN analysis_track a
|
||||
ON a.track_id = l.track_id
|
||||
AND a.md5_16kb = l.md5_16kb
|
||||
WHERE l.track_id = ?1
|
||||
AND l.md5_16kb = ?2
|
||||
AND a.loudness_algo_version = ?3
|
||||
)
|
||||
"#,
|
||||
params![key.track_id, key.md5_16kb, LOUDNESS_ALGO_VERSION],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(exists != 0)
|
||||
}
|
||||
|
||||
pub fn get_latest_waveform_for_track(&self, track_id: &str) -> Result<Option<WaveformEntry>, String> {
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
const SQL: &str = r#"
|
||||
SELECT w.bins, w.bin_count, w.is_partial, w.known_until_sec, w.duration_sec, w.updated_at
|
||||
FROM waveform_cache w
|
||||
JOIN analysis_track a
|
||||
ON a.track_id = w.track_id
|
||||
AND a.md5_16kb = w.md5_16kb
|
||||
WHERE w.track_id = ?1
|
||||
AND a.waveform_algo_version = ?2
|
||||
ORDER BY w.updated_at DESC
|
||||
LIMIT 1
|
||||
"#;
|
||||
for tid in track_id_cache_variants(track_id) {
|
||||
let row = conn
|
||||
.query_row(
|
||||
SQL,
|
||||
params![tid, WAVEFORM_ALGO_VERSION],
|
||||
|row| {
|
||||
Ok(WaveformEntry {
|
||||
bins: row.get(0)?,
|
||||
bin_count: row.get(1)?,
|
||||
is_partial: row.get::<_, i64>(2)? != 0,
|
||||
known_until_sec: row.get(3)?,
|
||||
duration_sec: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(e) = row {
|
||||
if waveform_cache_blob_len_ok(&e.bins, e.bin_count) {
|
||||
return Ok(Some(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Both waveform and loudness rows exist — a CPU seed from bytes/file would only
|
||||
/// decode the file to immediately skip with `SkippedWaveformCacheHit`.
|
||||
pub fn cpu_seed_redundant_for_track(&self, track_id: &str) -> Result<bool, String> {
|
||||
Ok(
|
||||
self.get_latest_waveform_for_track(track_id)?.is_some()
|
||||
&& self.get_latest_loudness_for_track(track_id)?.is_some(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_latest_loudness_for_track(&self, track_id: &str) -> Result<Option<LoudnessSnapshot>, String> {
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
const SQL: &str = r#"
|
||||
SELECT l.integrated_lufs, l.true_peak, l.recommended_gain_db, l.target_lufs, l.updated_at
|
||||
FROM loudness_cache l
|
||||
JOIN analysis_track a
|
||||
ON a.track_id = l.track_id
|
||||
AND a.md5_16kb = l.md5_16kb
|
||||
WHERE l.track_id = ?1
|
||||
AND a.loudness_algo_version = ?2
|
||||
ORDER BY l.updated_at DESC
|
||||
LIMIT 1
|
||||
"#;
|
||||
for tid in track_id_cache_variants(track_id) {
|
||||
let row = conn
|
||||
.query_row(
|
||||
SQL,
|
||||
params![tid, LOUDNESS_ALGO_VERSION],
|
||||
|row| {
|
||||
Ok(LoudnessSnapshot {
|
||||
integrated_lufs: row.get(0)?,
|
||||
true_peak: row.get(1)?,
|
||||
recommended_gain_db: row.get(2)?,
|
||||
target_lufs: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if row.is_some() {
|
||||
return Ok(row);
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn analysis_db_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let base = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(base.join("audio-analysis.sqlite"))
|
||||
}
|
||||
|
||||
fn configure_connection(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_schema(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS analysis_track (
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
waveform_algo_version INTEGER NOT NULL,
|
||||
loudness_algo_version INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (track_id, md5_16kb)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS waveform_cache (
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
bins BLOB NOT NULL,
|
||||
bin_count INTEGER NOT NULL,
|
||||
is_partial INTEGER NOT NULL,
|
||||
known_until_sec REAL NOT NULL,
|
||||
duration_sec REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (track_id, md5_16kb)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS loudness_cache (
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
integrated_lufs REAL NOT NULL,
|
||||
true_peak REAL NOT NULL,
|
||||
recommended_gain_db REAL NOT NULL,
|
||||
target_lufs REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (track_id, md5_16kb, target_lufs)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_track_status
|
||||
ON analysis_track(status);
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
use psysonic_core::user_agent::subsonic_wire_user_agent;
|
||||
|
||||
use crate::analysis_cache;
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WaveformUpdatedPayload {
|
||||
pub track_id: String,
|
||||
pub is_partial: bool,
|
||||
}
|
||||
|
||||
// ─── HTTP backfill queue: download tracks + seed analysis cache ──────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AnalysisBackfillEnqueueKind {
|
||||
/// New job at the tail of the queue.
|
||||
NewBack,
|
||||
/// New job for the currently playing track (head).
|
||||
NewFront,
|
||||
/// Same track was already waiting; moved to head with the latest URL.
|
||||
ReorderedFront,
|
||||
/// Low-priority duplicate while the track is already queued or running.
|
||||
DuplicateSkipped,
|
||||
/// High-priority request but that track is already being downloaded+seeded.
|
||||
RunningSkipped,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AnalysisBackfillQueueState {
|
||||
pub deque: VecDeque<(String, String)>,
|
||||
/// Set while this `track_id` is inside `analysis_backfill_download_and_seed` (not in deque).
|
||||
pub in_progress: Option<String>,
|
||||
}
|
||||
|
||||
impl AnalysisBackfillQueueState {
|
||||
fn is_reserved(&self, tid: &str) -> bool {
|
||||
self.in_progress.as_deref() == Some(tid)
|
||||
|| self.deque.iter().any(|(t, _)| t.as_str() == tid)
|
||||
}
|
||||
|
||||
fn try_pop_next(&mut self) -> Option<(String, String)> {
|
||||
let (tid, url) = self.deque.pop_front()?;
|
||||
self.in_progress = Some(tid.clone());
|
||||
Some((tid, url))
|
||||
}
|
||||
|
||||
fn finish_job(&mut self, tid: &str) {
|
||||
if self.in_progress.as_deref() == Some(tid) {
|
||||
self.in_progress = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enqueue(
|
||||
&mut self,
|
||||
tid: String,
|
||||
url: String,
|
||||
high_priority: bool,
|
||||
) -> AnalysisBackfillEnqueueKind {
|
||||
let tref = tid.as_str();
|
||||
if self.is_reserved(tref) {
|
||||
if !high_priority {
|
||||
return AnalysisBackfillEnqueueKind::DuplicateSkipped;
|
||||
}
|
||||
if self.in_progress.as_deref() == Some(tref) {
|
||||
return AnalysisBackfillEnqueueKind::RunningSkipped;
|
||||
}
|
||||
self.deque.retain(|(t, _)| t != &tid);
|
||||
self.deque.push_front((tid, url));
|
||||
return AnalysisBackfillEnqueueKind::ReorderedFront;
|
||||
}
|
||||
if high_priority {
|
||||
self.deque.push_front((tid, url));
|
||||
AnalysisBackfillEnqueueKind::NewFront
|
||||
} else {
|
||||
self.deque.push_back((tid, url));
|
||||
AnalysisBackfillEnqueueKind::NewBack
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prune_queued_not_in(&mut self, keep_track_ids: &HashSet<&str>) -> usize {
|
||||
let before = self.deque.len();
|
||||
self.deque
|
||||
.retain(|(track_id, _)| keep_track_ids.contains(track_id.as_str()));
|
||||
before.saturating_sub(self.deque.len())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AnalysisBackfillShared {
|
||||
pub state: Mutex<AnalysisBackfillQueueState>,
|
||||
wake_tx: tokio::sync::mpsc::UnboundedSender<()>,
|
||||
}
|
||||
|
||||
impl AnalysisBackfillShared {
|
||||
pub fn ping_worker(&self) {
|
||||
let _ = self.wake_tx.send(());
|
||||
}
|
||||
}
|
||||
|
||||
static ANALYSIS_BACKFILL: OnceLock<Arc<AnalysisBackfillShared>> = OnceLock::new();
|
||||
|
||||
/// Lazily spawns the single backfill worker (first caller supplies `AppHandle`).
|
||||
pub fn analysis_backfill_shared(app: &tauri::AppHandle) -> Arc<AnalysisBackfillShared> {
|
||||
ANALYSIS_BACKFILL
|
||||
.get_or_init(|| {
|
||||
let (wake_tx, wake_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let shared = Arc::new(AnalysisBackfillShared {
|
||||
state: Mutex::new(AnalysisBackfillQueueState::default()),
|
||||
wake_tx,
|
||||
});
|
||||
let app = app.clone();
|
||||
let sh = shared.clone();
|
||||
tauri::async_runtime::spawn(analysis_backfill_worker_loop(app, sh, wake_rx));
|
||||
shared
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Decode `bytes` for `track_id` via the cpu-seed queue. Returns `Ok(true)` when
|
||||
/// a loudness row exists in the cache after the seed (cache-hit short-circuits as
|
||||
/// well as fresh decode hits).
|
||||
pub async fn enqueue_analysis_seed(
|
||||
app: &tauri::AppHandle,
|
||||
track_id: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<bool, String> {
|
||||
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
|
||||
if cache.cpu_seed_redundant_for_track(track_id).unwrap_or(false) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
let high = analysis_backfill_is_current_track(app, track_id);
|
||||
let outcome = submit_analysis_cpu_seed(
|
||||
app.clone(),
|
||||
track_id.to_string(),
|
||||
bytes.to_vec(),
|
||||
high,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::app_eprintln!("[analysis] failed to seed {}: {}", track_id, e);
|
||||
e
|
||||
})?;
|
||||
let has_loudness = app
|
||||
.try_state::<analysis_cache::AnalysisCache>()
|
||||
.and_then(|cache| cache.get_latest_loudness_for_track(track_id).ok().flatten())
|
||||
.is_some();
|
||||
crate::app_deprintln!(
|
||||
"[analysis] seed result track_id={} bytes={} has_loudness={} outcome={outcome:?}",
|
||||
track_id,
|
||||
bytes.len(),
|
||||
has_loudness
|
||||
);
|
||||
Ok(has_loudness)
|
||||
}
|
||||
|
||||
async fn analysis_backfill_download_and_seed(
|
||||
app: &tauri::AppHandle,
|
||||
track_id: &str,
|
||||
url: &str,
|
||||
) -> Result<bool, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(subsonic_wire_user_agent())
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let response = client.get(url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||
}
|
||||
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
|
||||
if bytes.is_empty() {
|
||||
return Err("empty response".to_string());
|
||||
}
|
||||
enqueue_analysis_seed(app, track_id, &bytes).await
|
||||
}
|
||||
|
||||
async fn analysis_backfill_worker_loop(
|
||||
app: tauri::AppHandle,
|
||||
shared: Arc<AnalysisBackfillShared>,
|
||||
mut wake_rx: tokio::sync::mpsc::UnboundedReceiver<()>,
|
||||
) {
|
||||
loop {
|
||||
if wake_rx.recv().await.is_none() {
|
||||
break;
|
||||
}
|
||||
while let Some((track_id, url)) = {
|
||||
let mut st = shared
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
st.try_pop_next()
|
||||
} {
|
||||
crate::app_deprintln!("[analysis] backfill worker: start track_id={}", track_id);
|
||||
let result = analysis_backfill_download_and_seed(&app, &track_id, &url).await;
|
||||
match &result {
|
||||
Ok(has_loudness) => crate::app_deprintln!(
|
||||
"[analysis] backfill ready: {} (has_loudness={})",
|
||||
track_id,
|
||||
has_loudness
|
||||
),
|
||||
Err(e) => crate::app_eprintln!("[analysis] backfill failed for {}: {}", track_id, e),
|
||||
}
|
||||
let mut st = shared
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
st.finish_job(&track_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn analysis_backfill_is_current_track(app: &tauri::AppHandle, track_id: &str) -> bool {
|
||||
app.try_state::<psysonic_core::ports::PlaybackQueryHandle>()
|
||||
.is_some_and(|p| p.is_track_currently_playing(track_id))
|
||||
}
|
||||
|
||||
// ─── Full-track waveform + loudness: single CPU worker (mirrors HTTP backfill queue) ─
|
||||
// One `spawn_blocking` decode at a time; current playback is high-priority (front + reorder).
|
||||
// Same `track_id` queued again merges waiters onto one job; while decode runs, same-id
|
||||
// submitters attach to `running` followers so they all get the same outcome.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AnalysisCpuSeedEnqueueKind {
|
||||
NewBack,
|
||||
NewFront,
|
||||
ReorderedFront,
|
||||
RunningFollower,
|
||||
MergedQueued,
|
||||
}
|
||||
|
||||
struct AnalysisCpuSeedJob {
|
||||
track_id: String,
|
||||
bytes: Vec<u8>,
|
||||
waiters: Vec<tokio::sync::oneshot::Sender<Result<analysis_cache::SeedFromBytesOutcome, String>>>,
|
||||
}
|
||||
|
||||
struct AnalysisCpuSeedQueueState {
|
||||
deque: VecDeque<AnalysisCpuSeedJob>,
|
||||
/// Decode in progress — same-id callers wait here for the same outcome.
|
||||
running: Option<(
|
||||
String,
|
||||
Arc<Mutex<Vec<tokio::sync::oneshot::Sender<Result<analysis_cache::SeedFromBytesOutcome, String>>>>>,
|
||||
)>,
|
||||
}
|
||||
|
||||
impl AnalysisCpuSeedQueueState {
|
||||
fn enqueue(
|
||||
&mut self,
|
||||
track_id: String,
|
||||
bytes: Vec<u8>,
|
||||
high_priority: bool,
|
||||
) -> (
|
||||
AnalysisCpuSeedEnqueueKind,
|
||||
tokio::sync::oneshot::Receiver<Result<analysis_cache::SeedFromBytesOutcome, String>>,
|
||||
) {
|
||||
let (done_tx, done_rx) = tokio::sync::oneshot::channel();
|
||||
let tid = track_id.as_str();
|
||||
|
||||
if let Some((rtid, followers)) = &self.running {
|
||||
if rtid == tid {
|
||||
followers
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(done_tx);
|
||||
return (AnalysisCpuSeedEnqueueKind::RunningFollower, done_rx);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pos) = self.deque.iter().position(|j| j.track_id == track_id) {
|
||||
let mut job = self.deque.remove(pos).unwrap();
|
||||
job.bytes = bytes;
|
||||
job.waiters.push(done_tx);
|
||||
let kind = if high_priority {
|
||||
self.deque.push_front(job);
|
||||
AnalysisCpuSeedEnqueueKind::ReorderedFront
|
||||
} else {
|
||||
self.deque.push_back(job);
|
||||
AnalysisCpuSeedEnqueueKind::MergedQueued
|
||||
};
|
||||
return (kind, done_rx);
|
||||
}
|
||||
|
||||
let job = AnalysisCpuSeedJob {
|
||||
track_id: track_id.clone(),
|
||||
bytes,
|
||||
waiters: vec![done_tx],
|
||||
};
|
||||
let kind = if high_priority {
|
||||
self.deque.push_front(job);
|
||||
AnalysisCpuSeedEnqueueKind::NewFront
|
||||
} else {
|
||||
self.deque.push_back(job);
|
||||
AnalysisCpuSeedEnqueueKind::NewBack
|
||||
};
|
||||
(kind, done_rx)
|
||||
}
|
||||
|
||||
fn prune_queued_not_in(&mut self, keep_track_ids: &HashSet<&str>) -> (usize, usize) {
|
||||
let mut kept = VecDeque::with_capacity(self.deque.len());
|
||||
let mut removed_jobs = 0usize;
|
||||
let mut removed_waiters = 0usize;
|
||||
while let Some(job) = self.deque.pop_front() {
|
||||
if keep_track_ids.contains(job.track_id.as_str()) {
|
||||
kept.push_back(job);
|
||||
continue;
|
||||
}
|
||||
removed_jobs += 1;
|
||||
removed_waiters += job.waiters.len();
|
||||
for tx in job.waiters {
|
||||
let _ = tx.send(Err(
|
||||
"cpu-seed pruned: track no longer in playback queue".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
self.deque = kept;
|
||||
(removed_jobs, removed_waiters)
|
||||
}
|
||||
}
|
||||
|
||||
struct AnalysisCpuSeedShared {
|
||||
state: Mutex<AnalysisCpuSeedQueueState>,
|
||||
wake_tx: tokio::sync::mpsc::UnboundedSender<()>,
|
||||
}
|
||||
|
||||
impl Default for AnalysisCpuSeedQueueState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
deque: VecDeque::new(),
|
||||
running: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AnalysisCpuSeedShared {
|
||||
fn ping_worker(&self) {
|
||||
let _ = self.wake_tx.send(());
|
||||
}
|
||||
}
|
||||
|
||||
static ANALYSIS_CPU_SEED: OnceLock<Arc<AnalysisCpuSeedShared>> = OnceLock::new();
|
||||
|
||||
fn analysis_cpu_seed_shared(app: &tauri::AppHandle) -> Arc<AnalysisCpuSeedShared> {
|
||||
ANALYSIS_CPU_SEED
|
||||
.get_or_init(|| {
|
||||
let (wake_tx, wake_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let shared = Arc::new(AnalysisCpuSeedShared {
|
||||
state: Mutex::new(AnalysisCpuSeedQueueState::default()),
|
||||
wake_tx,
|
||||
});
|
||||
let app = app.clone();
|
||||
let sh = shared.clone();
|
||||
tauri::async_runtime::spawn(analysis_cpu_seed_worker_loop(app, sh, wake_rx));
|
||||
shared
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// HTTP backfill + CPU seed queue sizes (debug log only — `app_deprintln!`).
|
||||
fn emit_analysis_queue_snapshot_line() {
|
||||
let http = if let Some(arc) = ANALYSIS_BACKFILL.get() {
|
||||
let st = arc.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
format!(
|
||||
"http_backfill={{queued:{} download_active:{:?}}}",
|
||||
st.deque.len(),
|
||||
st.in_progress.as_deref()
|
||||
)
|
||||
} else {
|
||||
"http_backfill={{not_started}}".to_string()
|
||||
};
|
||||
|
||||
let cpu = if let Some(arc) = ANALYSIS_CPU_SEED.get() {
|
||||
let st = arc.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let queued_jobs = st.deque.len();
|
||||
let pending_in_queued_jobs: usize = st.deque.iter().map(|j| j.waiters.len()).sum();
|
||||
let (decoding_tid, decoding_extra_waiters) = match &st.running {
|
||||
Some((tid, fl)) => (
|
||||
Some(tid.as_str()),
|
||||
fl.lock().map(|g| g.len()).unwrap_or(0),
|
||||
),
|
||||
None => (None, 0usize),
|
||||
};
|
||||
format!(
|
||||
"cpu_seed={{queued_jobs:{} pending_channels_in_queue:{} decoding_tid:{:?} extra_waiters_same_id:{}}}",
|
||||
queued_jobs,
|
||||
pending_in_queued_jobs,
|
||||
decoding_tid,
|
||||
decoding_extra_waiters
|
||||
)
|
||||
} else {
|
||||
"cpu_seed={{not_started}}".to_string()
|
||||
};
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[analysis] queue_snapshot interval_s=60 note=queues_in_memory_cleared_on_app_restart | {http} | {cpu}"
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn analysis_queue_snapshot_loop() {
|
||||
emit_analysis_queue_snapshot_line();
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
emit_analysis_queue_snapshot_line();
|
||||
}
|
||||
}
|
||||
|
||||
async fn analysis_cpu_seed_worker_loop(
|
||||
app: tauri::AppHandle,
|
||||
shared: Arc<AnalysisCpuSeedShared>,
|
||||
mut wake_rx: tokio::sync::mpsc::UnboundedReceiver<()>,
|
||||
) {
|
||||
loop {
|
||||
if wake_rx.recv().await.is_none() {
|
||||
break;
|
||||
}
|
||||
loop {
|
||||
let (job, followers) = {
|
||||
let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(j) = st.deque.pop_front() else {
|
||||
break;
|
||||
};
|
||||
let fl = Arc::new(Mutex::new(Vec::new()));
|
||||
st.running = Some((j.track_id.clone(), fl.clone()));
|
||||
(j, fl)
|
||||
};
|
||||
let tid_log = job.track_id.clone();
|
||||
let app2 = app.clone();
|
||||
let tid = job.track_id.clone();
|
||||
let bytes = job.bytes;
|
||||
let outcome = tokio::task::spawn_blocking(move || {
|
||||
analysis_cache::seed_from_bytes_execute(&app2, &tid, &bytes)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("cpu-seed spawn_blocking: {e}")));
|
||||
|
||||
let mut extra = followers
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.drain(..)
|
||||
.collect::<Vec<_>>();
|
||||
for tx in job.waiters {
|
||||
let _ = tx.send(outcome.clone());
|
||||
}
|
||||
for tx in extra.drain(..) {
|
||||
let _ = tx.send(outcome.clone());
|
||||
}
|
||||
|
||||
{
|
||||
let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
st.running = None;
|
||||
}
|
||||
let ok = outcome.as_ref().map(|o| *o == analysis_cache::SeedFromBytesOutcome::Upserted).unwrap_or(false);
|
||||
crate::app_deprintln!(
|
||||
"[analysis] cpu-seed worker: done track_id={} upserted={}",
|
||||
tid_log,
|
||||
ok
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prune queued items in both analysis queues (HTTP backfill + CPU seed) whose
|
||||
/// track ids are not in `keep_track_ids`. Items that are *currently running* are
|
||||
/// untouched; only queued items are removed. Pruned CPU-seed waiters get an Err
|
||||
/// indicating the prune.
|
||||
///
|
||||
/// Returns `(http_removed, cpu_removed_jobs, cpu_removed_waiters)`. Either
|
||||
/// queue may not have been initialized yet — those slots return 0.
|
||||
pub fn prune_analysis_queues(
|
||||
keep_track_ids: &HashSet<&str>,
|
||||
) -> Result<(usize, usize, usize), String> {
|
||||
let http_removed = if let Some(shared) = ANALYSIS_BACKFILL.get() {
|
||||
let mut st = shared
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "analysis backfill lock poisoned".to_string())?;
|
||||
st.prune_queued_not_in(keep_track_ids)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let (cpu_removed_jobs, cpu_removed_waiters) = if let Some(shared) = ANALYSIS_CPU_SEED.get() {
|
||||
let mut st = shared
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "analysis cpu-seed lock poisoned".to_string())?;
|
||||
st.prune_queued_not_in(keep_track_ids)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
|
||||
Ok((http_removed, cpu_removed_jobs, cpu_removed_waiters))
|
||||
}
|
||||
|
||||
/// Submit full-buffer analysis; serializes with other producers. `high_priority` mirrors
|
||||
/// HTTP backfill head insertion for the currently playing track.
|
||||
///
|
||||
/// Emits `analysis:waveform-updated` when analysis **wrote** new waveform data (`Upserted`).
|
||||
/// Cache-hit skips (`SkippedWaveformCacheHit`) omit the event so the frontend does not
|
||||
/// re-run loudness refresh / waveform IPC for rows that were already current.
|
||||
pub async fn submit_analysis_cpu_seed(
|
||||
app: tauri::AppHandle,
|
||||
track_id: String,
|
||||
bytes: Vec<u8>,
|
||||
high_priority: bool,
|
||||
) -> Result<analysis_cache::SeedFromBytesOutcome, String> {
|
||||
let shared = analysis_cpu_seed_shared(&app);
|
||||
let rx = {
|
||||
let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (kind, rx) = st.enqueue(track_id.clone(), bytes, high_priority);
|
||||
crate::app_deprintln!("[analysis] cpu-seed submit: kind={kind:?} high_priority={high_priority}");
|
||||
drop(st);
|
||||
shared.ping_worker();
|
||||
rx
|
||||
};
|
||||
let outcome = match rx.await {
|
||||
Ok(res) => res?,
|
||||
Err(_) => return Err("cpu-seed: result channel dropped".to_string()),
|
||||
};
|
||||
if matches!(outcome, analysis_cache::SeedFromBytesOutcome::Upserted) {
|
||||
let _ = app.emit(
|
||||
"analysis:waveform-updated",
|
||||
WaveformUpdatedPayload {
|
||||
track_id: track_id.clone(),
|
||||
is_partial: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! `psysonic-analysis` — loudness/waveform analysis cache and the runtime
|
||||
//! that drives HTTP backfill + CPU-seed work queues.
|
||||
//!
|
||||
//! Submodules mirror the original layout in the top crate:
|
||||
//! - `analysis_cache` — SQLite-backed loudness/waveform store + compute helpers
|
||||
//! - `analysis_runtime` — backfill queue, CPU-seed queue, queue snapshot loop
|
||||
|
||||
pub mod analysis_cache;
|
||||
pub mod analysis_runtime;
|
||||
|
||||
// Re-export logging facade so submodules can write `crate::app_eprintln!()`
|
||||
// the same way they did when they lived in the top crate.
|
||||
pub use psysonic_core::{app_deprintln, app_eprintln, logging};
|
||||
@@ -6,3 +6,4 @@
|
||||
|
||||
pub mod logging;
|
||||
pub mod ports;
|
||||
pub mod user_agent;
|
||||
|
||||
@@ -1,49 +1,36 @@
|
||||
//! Cross-crate port traits.
|
||||
//! Cross-crate port handles.
|
||||
//!
|
||||
//! These traits exist purely to break dependency cycles that would otherwise
|
||||
//! force `psysonic-audio` ↔ `psysonic-analysis` to depend on each other in
|
||||
//! both directions. Implementers register themselves as Tauri State on app
|
||||
//! bootstrap; consumers look them up via `app.try_state::<Arc<dyn _>>()`.
|
||||
//! Exists to break the one back-edge in the audio↔analysis dependency:
|
||||
//! `psysonic-analysis` needs to ask "is this track currently playing?", but
|
||||
//! must not depend on `psysonic-audio` (which has the real dep on analysis,
|
||||
//! not the other way around).
|
||||
//!
|
||||
//! Layout:
|
||||
//! - `AnalysisOrchestrator` — implemented in `psysonic-analysis`, called from
|
||||
//! `psysonic-audio` to enqueue full-buffer loudness/waveform analysis.
|
||||
//! - `PlaybackQuery` — implemented in `psysonic-audio` on `AudioEngine`,
|
||||
//! called from `psysonic-analysis` to ask "is this track currently playing?".
|
||||
//! Implementation note: ports are exposed as **closure handles** rather than
|
||||
//! `Arc<dyn Trait>` — this avoids forcing every existing `State<AudioEngine>`
|
||||
//! callsite to switch to `State<Arc<AudioEngine>>` (which Tauri State requires
|
||||
//! for trait-object registration). The shell crate creates the handle by
|
||||
//! capturing an `AppHandle` and looking up the audio engine at call time.
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
/// Read-only queries about the live playback session. Implemented on
|
||||
/// `AudioEngine` in `psysonic-audio`.
|
||||
///
|
||||
/// Registered as `Arc<dyn PlaybackQuery>` in Tauri State so non-audio crates
|
||||
/// can query without taking a hard dep on the audio crate.
|
||||
pub trait PlaybackQuery: Send + Sync + 'static {
|
||||
/// `true` if `track_id` is the track currently being decoded/played.
|
||||
fn is_track_currently_playing(&self, track_id: &str) -> bool;
|
||||
/// "Is this track currently being decoded/played?" — implementation lives in
|
||||
/// `psysonic-audio` (`AudioEngine::analysis_track_id_is_current_playback`).
|
||||
/// The shell crate registers an instance constructed via [`PlaybackQueryHandle::new`]
|
||||
/// as Tauri State; consumers in `psysonic-analysis` look it up.
|
||||
#[derive(Clone)]
|
||||
pub struct PlaybackQueryHandle {
|
||||
inner: Arc<dyn Fn(&str) -> bool + Send + Sync + 'static>,
|
||||
}
|
||||
|
||||
/// Triggers full-buffer analysis on already-captured track bytes. Implemented
|
||||
/// on `AnalysisRuntime` in `psysonic-analysis`.
|
||||
///
|
||||
/// Registered as `Arc<dyn AnalysisOrchestrator>` in Tauri State so audio
|
||||
/// callsites (`stream::ranged_http`, `stream::track_stream`, `helpers`,
|
||||
/// `play_input`, `preload_commands`) can submit without depending on
|
||||
/// `psysonic-analysis`.
|
||||
pub trait AnalysisOrchestrator: Send + Sync + 'static {
|
||||
/// Enqueue a CPU-seed analysis pass for `track_id` over the given byte
|
||||
/// buffer. `high_priority` mirrors the HTTP-backfill head-insertion
|
||||
/// behaviour for the currently playing track.
|
||||
///
|
||||
/// Errors are stringified — audio callers only care about pass/fail.
|
||||
fn submit_cpu_seed<'a>(
|
||||
&'a self,
|
||||
app: AppHandle,
|
||||
track_id: String,
|
||||
bytes: Vec<u8>,
|
||||
high_priority: bool,
|
||||
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
|
||||
impl PlaybackQueryHandle {
|
||||
pub fn new<F>(f: F) -> Self
|
||||
where
|
||||
F: Fn(&str) -> bool + Send + Sync + 'static,
|
||||
{
|
||||
Self { inner: Arc::new(f) }
|
||||
}
|
||||
|
||||
pub fn is_track_currently_playing(&self, track_id: &str) -> bool {
|
||||
(self.inner)(track_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Process-global outbound User-Agent for Rust-side HTTP.
|
||||
//!
|
||||
//! Initialised to `psysonic/<version>` (workspace package version) and then
|
||||
//! overridden from the main WebView's `navigator.userAgent` once the frontend
|
||||
//! reports it during startup. Every Rust HTTP client (`reqwest::Client`,
|
||||
//! handcrafted `header::USER_AGENT`) reads the current value via
|
||||
//! [`subsonic_wire_user_agent`] so a single switch keeps server-side
|
||||
//! request-fingerprints consistent.
|
||||
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
pub fn default_subsonic_wire_user_agent() -> String {
|
||||
format!("psysonic/{}", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
pub fn runtime_subsonic_wire_user_agent() -> &'static RwLock<String> {
|
||||
static UA: OnceLock<RwLock<String>> = OnceLock::new();
|
||||
UA.get_or_init(|| RwLock::new(default_subsonic_wire_user_agent()))
|
||||
}
|
||||
|
||||
pub fn subsonic_wire_user_agent() -> String {
|
||||
runtime_subsonic_wire_user_agent()
|
||||
.read()
|
||||
.map(|ua| ua.clone())
|
||||
.unwrap_or_else(|_| default_subsonic_wire_user_agent())
|
||||
}
|
||||
Reference in New Issue
Block a user