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:
Psychotoxical
2026-05-09 13:25:04 +02:00
parent 7718ac3ee5
commit ff456dd823
15 changed files with 195 additions and 124 deletions
@@ -6,3 +6,4 @@
pub mod logging;
pub mod ports;
pub mod user_agent;
+29 -42
View File
@@ -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())
}