refactor: move analysis admin commands into psysonic-analysis (M6/7)

Reframed M6 from "extract psysonic-commands" to "place each domain's
Tauri commands in its own domain crate" — the original psysonic-commands
proposal would have been a thin shell with no clear domain ownership
because each prior milestone already kept its own commands inline:

  audio_*_commands           in psysonic-audio
  cache/sync commands        in psysonic-syncfs
  navidrome/discord/etc      in psysonic-integration

The leftover analysis admin commands (7 of them) logically belong to
the analysis domain. So they move there:

  src/lib_commands/app_api/analysis.rs   →
    crates/psysonic-analysis/src/commands.rs

  WaveformCachePayload + LoudnessCachePayload  moved out of top-crate
                                               lib.rs into commands.rs

PlaybackQueryHandle gets a second closure (`should_defer_backfill`) so
analysis_enqueue_seed_from_url can ask "is a ranged playback already
going to seed this track?" without depending on psysonic-audio.

Top crate keeps the shell-flavored commands (window/tray/mini-player,
greet/exit_app, mpris/global-shortcuts/check_dir_accessible, perf,
cli_bridge) for M7 to clean up.

Behaviour preserving. Cargo check + clippy --workspace clean.
This commit is contained in:
Psychotoxical
2026-05-09 14:00:33 +02:00
parent 98d8ea6353
commit 97f06459f3
5 changed files with 98 additions and 73 deletions
+25 -38
View File
@@ -38,27 +38,6 @@ const MAX_DL_CONCURRENCY: usize = 4;
/// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux).
type MprisControls = Mutex<Option<souvlaki::MediaControls>>;
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct WaveformCachePayload {
bins: Vec<u8>,
bin_count: i64,
is_partial: bool,
known_until_sec: f64,
duration_sec: f64,
updated_at: i64,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct LoudnessCachePayload {
integrated_lufs: f64,
true_peak: f64,
recommended_gain_db: f64,
target_lufs: f64,
updated_at: i64,
}
pub fn run() {
// Linux: second `psysonic --player …` forwards over D-Bus before heavy startup.
#[cfg(target_os = "linux")]
@@ -125,17 +104,25 @@ pub fn run() {
}
// ── Playback-query port (analysis → audio back-edge) ──────────
// Replace the placeholder registered above with a real handle
// that has access to the AppHandle, so analysis_runtime can ask
// AudioEngine if a track is currently playing.
// Two closures, each capturing an AppHandle, so analysis_runtime
// can ask AudioEngine playback questions without depending on the
// audio crate.
{
let app_for_query = app.handle().clone();
let real_handle = psysonic_core::ports::PlaybackQueryHandle::new(move |track_id| {
app_for_query
.try_state::<crate::audio::AudioEngine>()
.is_some_and(|e| crate::audio::analysis_track_id_is_current_playback(&e, track_id))
});
app.manage(real_handle);
let app_is_playing = app.handle().clone();
let app_defer = app.handle().clone();
let handle = psysonic_core::ports::PlaybackQueryHandle::new(
move |track_id| {
app_is_playing
.try_state::<crate::audio::AudioEngine>()
.is_some_and(|e| crate::audio::analysis_track_id_is_current_playback(&e, track_id))
},
move |track_id| {
app_defer
.try_state::<crate::audio::AudioEngine>()
.is_some_and(|e| crate::audio::ranged_loudness_backfill_should_defer(&e, track_id))
},
);
app.manage(handle);
}
// Periodic analysis queue sizes (debug logging mode only).
@@ -421,13 +408,13 @@ pub fn run() {
psysonic_integration::remote::fetch_json_url,
psysonic_integration::remote::fetch_icy_metadata,
psysonic_integration::remote::resolve_stream_url,
analysis_get_waveform,
analysis_get_waveform_for_track,
analysis_get_loudness_for_track,
analysis_delete_loudness_for_track,
analysis_delete_all_waveforms,
analysis_enqueue_seed_from_url,
analysis_prune_pending_to_track_ids,
psysonic_analysis::commands::analysis_get_waveform,
psysonic_analysis::commands::analysis_get_waveform_for_track,
psysonic_analysis::commands::analysis_get_loudness_for_track,
psysonic_analysis::commands::analysis_delete_loudness_for_track,
psysonic_analysis::commands::analysis_delete_all_waveforms,
psysonic_analysis::commands::analysis_enqueue_seed_from_url,
psysonic_analysis::commands::analysis_prune_pending_to_track_ids,
psysonic_syncfs::cache::offline::download_track_offline,
psysonic_syncfs::cache::offline::delete_offline_track,
psysonic_syncfs::cache::offline::get_offline_cache_size,
@@ -1,235 +0,0 @@
use std::collections::HashSet;
use tauri::Manager;
use crate::analysis_cache;
use crate::analysis_runtime::{
analysis_backfill_is_current_track, analysis_backfill_shared, prune_analysis_queues,
AnalysisBackfillEnqueueKind,
};
use crate::{LoudnessCachePayload, WaveformCachePayload};
#[tauri::command]
pub(crate) fn analysis_get_waveform(
track_id: String,
md5_16kb: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Option<WaveformCachePayload>, String> {
let key = analysis_cache::TrackKey {
track_id: track_id.clone(),
md5_16kb: md5_16kb.clone(),
};
let row = cache.get_waveform(&key)?;
match &row {
Some(v) => {
crate::app_deprintln!(
"[analysis][waveform] db hit (exact key) track_id={} md5_16kb={} bins_len={} bin_count={} updated_at={}",
track_id,
md5_16kb,
v.bins.len(),
v.bin_count,
v.updated_at
);
}
None => {
crate::app_deprintln!(
"[analysis][waveform] db miss (exact key) track_id={} md5_16kb={}",
track_id,
md5_16kb
);
}
}
Ok(row.map(|v| WaveformCachePayload {
bins: v.bins,
bin_count: v.bin_count,
is_partial: v.is_partial,
known_until_sec: v.known_until_sec,
duration_sec: v.duration_sec,
updated_at: v.updated_at,
}))
}
#[tauri::command]
pub(crate) fn analysis_get_waveform_for_track(
track_id: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Option<WaveformCachePayload>, String> {
let row = cache.get_latest_waveform_for_track(&track_id)?;
match &row {
Some(v) => {
crate::app_deprintln!(
"[analysis][waveform] db hit track_id={} bins_len={} bin_count={} updated_at={}",
track_id,
v.bins.len(),
v.bin_count,
v.updated_at
);
}
None => {
crate::app_deprintln!(
"[analysis][waveform] db miss track_id={}",
track_id
);
}
}
Ok(row.map(|v| WaveformCachePayload {
bins: v.bins,
bin_count: v.bin_count,
is_partial: v.is_partial,
known_until_sec: v.known_until_sec,
duration_sec: v.duration_sec,
updated_at: v.updated_at,
}))
}
#[tauri::command]
pub(crate) fn analysis_get_loudness_for_track(
track_id: String,
target_lufs: Option<f64>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Option<LoudnessCachePayload>, String> {
let row = cache.get_latest_loudness_for_track(&track_id)?;
Ok(row.map(|v| {
let requested_target = target_lufs.unwrap_or(v.target_lufs).clamp(-30.0, -8.0);
let recommended_gain_db = analysis_cache::recommended_gain_for_target(
v.integrated_lufs,
v.true_peak,
requested_target,
);
LoudnessCachePayload {
integrated_lufs: v.integrated_lufs,
true_peak: v.true_peak,
recommended_gain_db,
target_lufs: requested_target,
updated_at: v.updated_at,
}}))
}
#[tauri::command]
pub(crate) fn analysis_delete_loudness_for_track(
track_id: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<u64, String> {
cache.delete_loudness_for_track_id(&track_id)
}
#[tauri::command]
pub(crate) fn analysis_delete_all_waveforms(
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<u64, String> {
cache.delete_all_waveforms()
}
#[tauri::command]
pub(crate) fn analysis_enqueue_seed_from_url(
track_id: String,
url: String,
force: Option<bool>,
app: tauri::AppHandle,
) -> Result<(), String> {
if track_id.trim().is_empty() || url.trim().is_empty() {
return Ok(());
}
let force = force.unwrap_or(false);
if !force {
if let Some(engine) = app.try_state::<crate::audio::AudioEngine>() {
if crate::audio::ranged_loudness_backfill_should_defer(&engine, &track_id) {
crate::app_deprintln!(
"[analysis] backfill skip track_id={} reason=ranged_playback_will_seed",
track_id
);
return Ok(());
}
}
}
if !force {
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
if cache.get_latest_loudness_for_track(&track_id)?.is_some() {
crate::app_deprintln!(
"[analysis] backfill skip (already cached): {}",
track_id
);
return Ok(());
}
}
}
let tid_log = track_id.clone();
let high_priority = analysis_backfill_is_current_track(&app, &track_id);
let shared = analysis_backfill_shared(&app);
let kind = {
let mut st = shared
.state
.lock()
.map_err(|_| "analysis backfill lock poisoned".to_string())?;
st.enqueue(track_id, url, high_priority)
};
match kind {
AnalysisBackfillEnqueueKind::NewBack | AnalysisBackfillEnqueueKind::NewFront => {
shared.ping_worker();
crate::app_deprintln!(
"[analysis] backfill enqueued: track_id={} position={}",
tid_log,
if high_priority { "front" } else { "back" }
);
}
AnalysisBackfillEnqueueKind::ReorderedFront => {
shared.ping_worker();
crate::app_deprintln!(
"[analysis] backfill bumped to front (current track) track_id={}",
tid_log
);
}
AnalysisBackfillEnqueueKind::DuplicateSkipped | AnalysisBackfillEnqueueKind::RunningSkipped => {}
}
Ok(())
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AnalysisPrunePendingResult {
pub keep_count: usize,
pub http_removed: usize,
pub cpu_removed_jobs: usize,
pub cpu_removed_waiters: usize,
}
/// Prunes pending analysis work for tracks no longer present in the playback queue.
///
/// Keeps currently-running jobs untouched; only queued (not-yet-started) jobs are removed.
#[tauri::command]
pub(crate) fn analysis_prune_pending_to_track_ids(
track_ids: Vec<String>,
) -> Result<AnalysisPrunePendingResult, String> {
let mut normalized: Vec<String> = Vec::with_capacity(track_ids.len());
let mut seen = HashSet::new();
for raw in track_ids {
let tid = raw.trim();
if tid.is_empty() {
continue;
}
if seen.insert(tid.to_string()) {
normalized.push(tid.to_string());
}
}
let keep_track_ids: HashSet<&str> = normalized.iter().map(|s| s.as_str()).collect();
let (http_removed, cpu_removed_jobs, cpu_removed_waiters) =
prune_analysis_queues(&keep_track_ids)?;
if http_removed > 0 || cpu_removed_jobs > 0 {
crate::app_deprintln!(
"[analysis] pruned pending queues keep={} removed_http={} removed_cpu_jobs={} removed_cpu_waiters={}",
keep_track_ids.len(),
http_removed,
cpu_removed_jobs,
cpu_removed_waiters
);
}
Ok(AnalysisPrunePendingResult {
keep_count: keep_track_ids.len(),
http_removed,
cpu_removed_jobs,
cpu_removed_waiters,
})
}
+3 -9
View File
@@ -1,4 +1,3 @@
mod analysis;
mod cli_bridge;
mod core;
mod integration;
@@ -20,12 +19,7 @@ pub(crate) use integration::{
check_dir_accessible, mpris_set_metadata, mpris_set_playback, register_global_shortcut,
unregister_global_shortcut,
};
pub(crate) use analysis::{
analysis_delete_all_waveforms, analysis_delete_loudness_for_track,
analysis_enqueue_seed_from_url, analysis_get_loudness_for_track, analysis_get_waveform,
analysis_get_waveform_for_track, analysis_prune_pending_to_track_ids,
};
// Discord, Navidrome admin, last.fm + radio-browser + CORS proxy, bandsintown
// now live in `psysonic_integration`. invoke_handler! in lib.rs registers
// them with their full paths so Tauri's `__cmd__*` macros resolve correctly.
// Discord, Navidrome admin, last.fm + radio-browser + CORS proxy, bandsintown,
// and analysis admin commands now live in their domain crates. invoke_handler!
// in lib.rs registers them with full paths so Tauri's `__cmd__*` macros resolve.