diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index db4d0fe8..aa31bd23 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3608,6 +3608,7 @@ dependencies = [ "libc", "lofty", "md5", + "psysonic-analysis", "psysonic-core", "reqwest", "ringbuf", @@ -3638,6 +3639,23 @@ dependencies = [ "zbus 5.15.0", ] +[[package]] +name = "psysonic-analysis" +version = "1.46.0-dev" +dependencies = [ + "ebur128", + "futures-util", + "md5", + "psysonic-core", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "symphonia", + "tauri", + "tokio", +] + [[package]] name = "psysonic-core" version = "1.46.0-dev" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9fa0fbee..b67f5db7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,6 +31,7 @@ tauri-build = { version = "2", features = [] } [dependencies] psysonic-core = { path = "crates/psysonic-core" } +psysonic-analysis = { path = "crates/psysonic-analysis" } tauri = { version = "2", features = ["tray-icon", "image-png"] } tauri-plugin-single-instance = "2" tauri-plugin-shell = "2" diff --git a/src-tauri/crates/psysonic-analysis/Cargo.toml b/src-tauri/crates/psysonic-analysis/Cargo.toml new file mode 100644 index 00000000..376d5424 --- /dev/null +++ b/src-tauri/crates/psysonic-analysis/Cargo.toml @@ -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"] } diff --git a/src-tauri/src/analysis_cache/compute.rs b/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs similarity index 100% rename from src-tauri/src/analysis_cache/compute.rs rename to src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs diff --git a/src-tauri/src/analysis_cache/mod.rs b/src-tauri/crates/psysonic-analysis/src/analysis_cache/mod.rs similarity index 100% rename from src-tauri/src/analysis_cache/mod.rs rename to src-tauri/crates/psysonic-analysis/src/analysis_cache/mod.rs diff --git a/src-tauri/src/analysis_cache/store.rs b/src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs similarity index 100% rename from src-tauri/src/analysis_cache/store.rs rename to src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs diff --git a/src-tauri/src/analysis_runtime.rs b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs similarity index 87% rename from src-tauri/src/analysis_runtime.rs rename to src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs index afa262c3..455e312f 100644 --- a/src-tauri/src/analysis_runtime.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs @@ -3,21 +3,21 @@ use std::sync::{Arc, Mutex, OnceLock}; use tauri::{Emitter, Manager}; -use super::analysis_cache; -use super::lib_commands::*; -use super::subsonic_wire_user_agent; +use psysonic_core::user_agent::subsonic_wire_user_agent; + +use crate::analysis_cache; #[derive(Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct WaveformUpdatedPayload { - pub(crate) track_id: String, - pub(crate) is_partial: bool, +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(crate) enum AnalysisBackfillEnqueueKind { +pub enum AnalysisBackfillEnqueueKind { /// New job at the tail of the queue. NewBack, /// New job for the currently playing track (head). @@ -31,10 +31,10 @@ pub(crate) enum AnalysisBackfillEnqueueKind { } #[derive(Default)] -pub(crate) struct AnalysisBackfillQueueState { - pub(crate) deque: VecDeque<(String, String)>, +pub struct AnalysisBackfillQueueState { + pub deque: VecDeque<(String, String)>, /// Set while this `track_id` is inside `analysis_backfill_download_and_seed` (not in deque). - pub(crate) in_progress: Option, + pub in_progress: Option, } impl AnalysisBackfillQueueState { @@ -55,7 +55,7 @@ impl AnalysisBackfillQueueState { } } - pub(crate) fn enqueue( + pub fn enqueue( &mut self, tid: String, url: String, @@ -82,7 +82,7 @@ impl AnalysisBackfillQueueState { } } - pub(crate) fn prune_queued_not_in(&mut self, keep_track_ids: &HashSet<&str>) -> usize { + 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())); @@ -90,13 +90,13 @@ impl AnalysisBackfillQueueState { } } -pub(crate) struct AnalysisBackfillShared { - pub(crate) state: Mutex, +pub struct AnalysisBackfillShared { + pub state: Mutex, wake_tx: tokio::sync::mpsc::UnboundedSender<()>, } impl AnalysisBackfillShared { - pub(crate) fn ping_worker(&self) { + pub fn ping_worker(&self) { let _ = self.wake_tx.send(()); } } @@ -104,7 +104,7 @@ impl AnalysisBackfillShared { static ANALYSIS_BACKFILL: OnceLock> = OnceLock::new(); /// Lazily spawns the single backfill worker (first caller supplies `AppHandle`). -pub(crate) fn analysis_backfill_shared(app: &tauri::AppHandle) -> Arc { +pub fn analysis_backfill_shared(app: &tauri::AppHandle) -> Arc { ANALYSIS_BACKFILL .get_or_init(|| { let (wake_tx, wake_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -120,6 +120,44 @@ pub(crate) fn analysis_backfill_shared(app: &tauri::AppHandle) -> Arc Result { + if let Some(cache) = app.try_state::() { + 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::() + .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, @@ -176,9 +214,9 @@ async fn analysis_backfill_worker_loop( } } -pub(crate) fn analysis_backfill_is_current_track(app: &tauri::AppHandle, track_id: &str) -> bool { - app.try_state::() - .is_some_and(|e| crate::audio::analysis_track_id_is_current_playback(&e, track_id)) +pub fn analysis_backfill_is_current_track(app: &tauri::AppHandle, track_id: &str) -> bool { + app.try_state::() + .is_some_and(|p| p.is_track_currently_playing(track_id)) } // ─── Full-track waveform + loudness: single CPU worker (mirrors HTTP backfill queue) ─ @@ -187,7 +225,7 @@ pub(crate) fn analysis_backfill_is_current_track(app: &tauri::AppHandle, track_i // submitters attach to `running` followers so they all get the same outcome. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum AnalysisCpuSeedEnqueueKind { +pub enum AnalysisCpuSeedEnqueueKind { NewBack, NewFront, ReorderedFront, @@ -362,7 +400,7 @@ fn emit_analysis_queue_snapshot_line() { ); } -pub(crate) async fn analysis_queue_snapshot_loop() { +pub async fn analysis_queue_snapshot_loop() { emit_analysis_queue_snapshot_line(); loop { tokio::time::sleep(std::time::Duration::from_secs(60)).await; @@ -432,7 +470,7 @@ async fn analysis_cpu_seed_worker_loop( /// /// Returns `(http_removed, cpu_removed_jobs, cpu_removed_waiters)`. Either /// queue may not have been initialized yet — those slots return 0. -pub(crate) fn prune_analysis_queues( +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() { @@ -464,7 +502,7 @@ pub(crate) fn prune_analysis_queues( /// 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(crate) async fn submit_analysis_cpu_seed( +pub async fn submit_analysis_cpu_seed( app: tauri::AppHandle, track_id: String, bytes: Vec, diff --git a/src-tauri/crates/psysonic-analysis/src/lib.rs b/src-tauri/crates/psysonic-analysis/src/lib.rs new file mode 100644 index 00000000..0f1e8a23 --- /dev/null +++ b/src-tauri/crates/psysonic-analysis/src/lib.rs @@ -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}; diff --git a/src-tauri/crates/psysonic-core/src/lib.rs b/src-tauri/crates/psysonic-core/src/lib.rs index 3f47b202..0e8f7a1c 100644 --- a/src-tauri/crates/psysonic-core/src/lib.rs +++ b/src-tauri/crates/psysonic-core/src/lib.rs @@ -6,3 +6,4 @@ pub mod logging; pub mod ports; +pub mod user_agent; diff --git a/src-tauri/crates/psysonic-core/src/ports.rs b/src-tauri/crates/psysonic-core/src/ports.rs index 424f1da2..c3dce590 100644 --- a/src-tauri/crates/psysonic-core/src/ports.rs +++ b/src-tauri/crates/psysonic-core/src/ports.rs @@ -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::>()`. +//! 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` — this avoids forcing every existing `State` +//! callsite to switch to `State>` (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` 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 bool + Send + Sync + 'static>, } -/// Triggers full-buffer analysis on already-captured track bytes. Implemented -/// on `AnalysisRuntime` in `psysonic-analysis`. -/// -/// Registered as `Arc` 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, - high_priority: bool, - ) -> Pin> + Send + 'a>>; +impl PlaybackQueryHandle { + pub fn new(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) + } } diff --git a/src-tauri/crates/psysonic-core/src/user_agent.rs b/src-tauri/crates/psysonic-core/src/user_agent.rs new file mode 100644 index 00000000..f0e9fb0f --- /dev/null +++ b/src-tauri/crates/psysonic-core/src/user_agent.rs @@ -0,0 +1,26 @@ +//! Process-global outbound User-Agent for Rust-side HTTP. +//! +//! Initialised to `psysonic/` (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 { + static UA: OnceLock> = 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()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3ba44831..f117d51e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,23 +2,27 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod audio; -mod analysis_cache; -mod analysis_runtime; pub mod cli; mod discord; mod lib_commands; pub use psysonic_core::logging; pub use psysonic_core::{app_eprintln, app_deprintln}; +pub use psysonic_core::user_agent::{ + default_subsonic_wire_user_agent, runtime_subsonic_wire_user_agent, subsonic_wire_user_agent, +}; +pub use psysonic_analysis::{analysis_cache, analysis_runtime}; +// `crate::submit_analysis_cpu_seed` shorthand kept so the audio modules don't +// have to spell out `psysonic_analysis::analysis_runtime::...` until M3. +pub(crate) use psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed; #[cfg(target_os = "windows")] mod taskbar_win; mod tray_runtime; -pub(crate) use analysis_runtime::*; pub(crate) use tray_runtime::*; use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::sync::{Arc, Mutex, OnceLock}; use std::sync::atomic::AtomicBool; use tauri::{Emitter, Manager}; @@ -32,25 +36,6 @@ type ShortcutMap = Mutex>; /// The frontend queues more tasks than this; Rust is the real throttle. const MAX_DL_CONCURRENCY: usize = 4; -fn default_subsonic_wire_user_agent() -> String { - format!("psysonic/{}", env!("CARGO_PKG_VERSION")) -} - -fn runtime_subsonic_wire_user_agent() -> &'static RwLock { - static UA: OnceLock> = OnceLock::new(); - UA.get_or_init(|| RwLock::new(default_subsonic_wire_user_agent())) -} - -/// Unified outbound User-Agent for all Rust-side HTTP requests. -/// It is initialized with `psysonic/` and then overridden from -/// the main WebView `navigator.userAgent` at app startup. -pub(crate) fn subsonic_wire_user_agent() -> String { - runtime_subsonic_wire_user_agent() - .read() - .map(|ua| ua.clone()) - .unwrap_or_else(|_| default_subsonic_wire_user_agent()) -} - /// Shared semaphore that caps simultaneous `download_track_offline` executions. type DownloadSemaphore = Arc; @@ -151,8 +136,22 @@ pub fn run() { app.manage(cache); } + // ── 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. + { + let app_for_query = app.handle().clone(); + let real_handle = psysonic_core::ports::PlaybackQueryHandle::new(move |track_id| { + app_for_query + .try_state::() + .is_some_and(|e| crate::audio::analysis_track_id_is_current_playback(&e, track_id)) + }); + app.manage(real_handle); + } + // Periodic analysis queue sizes (debug logging mode only). - tauri::async_runtime::spawn(analysis_queue_snapshot_loop()); + tauri::async_runtime::spawn(psysonic_analysis::analysis_runtime::analysis_queue_snapshot_loop()); // ── Custom title bar on Linux ───────────────────────────────── // Remove OS window decorations on all Linux so the React TitleBar diff --git a/src-tauri/src/lib_commands/cache/hot.rs b/src-tauri/src/lib_commands/cache/hot.rs index 4b8886aa..bd6dabc9 100644 --- a/src-tauri/src/lib_commands/cache/hot.rs +++ b/src-tauri/src/lib_commands/cache/hot.rs @@ -1,9 +1,10 @@ +use crate::analysis_runtime::enqueue_analysis_seed; use crate::audio; use crate::subsonic_wire_user_agent; use super::super::file_transfer::stream_to_file; use super::downloads::{resolve_hot_cache_root, HotCacheDownloadResult}; -use super::offline::{enqueue_analysis_seed, enqueue_analysis_seed_from_file}; +use super::offline::enqueue_analysis_seed_from_file; #[tauri::command] pub(crate) async fn download_track_hot_cache( diff --git a/src-tauri/src/lib_commands/cache/mod.rs b/src-tauri/src/lib_commands/cache/mod.rs index 9902244f..a7626778 100644 --- a/src-tauri/src/lib_commands/cache/mod.rs +++ b/src-tauri/src/lib_commands/cache/mod.rs @@ -15,5 +15,3 @@ pub(crate) use downloads::{ check_arch_linux, download_update, download_zip, fetch_netease_lyrics, get_embedded_lyrics, open_folder, }; -// Internal helper consumed by analysis_runtime (used as analysis_runtime depends on it): -pub(crate) use offline::enqueue_analysis_seed; diff --git a/src-tauri/src/lib_commands/cache/offline.rs b/src-tauri/src/lib_commands/cache/offline.rs index 1d84762d..f25685b0 100644 --- a/src-tauri/src/lib_commands/cache/offline.rs +++ b/src-tauri/src/lib_commands/cache/offline.rs @@ -1,44 +1,13 @@ use tauri::Manager; use crate::analysis_cache; -use crate::analysis_runtime::{analysis_backfill_is_current_track, submit_analysis_cpu_seed}; +use crate::analysis_runtime::enqueue_analysis_seed; use crate::DownloadSemaphore; use super::super::file_transfer::{finalize_streamed_download, subsonic_http_client}; // ─── Offline Track Cache ────────────────────────────────────────────────────── -pub(crate) async fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result { - if let Some(cache) = app.try_state::() { - 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::() - .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) -} - pub(crate) async fn enqueue_analysis_seed_from_file( app: &tauri::AppHandle, track_id: &str,