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
+18
View File
@@ -3608,6 +3608,7 @@ dependencies = [
"libc", "libc",
"lofty", "lofty",
"md5", "md5",
"psysonic-analysis",
"psysonic-core", "psysonic-core",
"reqwest", "reqwest",
"ringbuf", "ringbuf",
@@ -3638,6 +3639,23 @@ dependencies = [
"zbus 5.15.0", "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]] [[package]]
name = "psysonic-core" name = "psysonic-core"
version = "1.46.0-dev" version = "1.46.0-dev"
+1
View File
@@ -31,6 +31,7 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
psysonic-core = { path = "crates/psysonic-core" } psysonic-core = { path = "crates/psysonic-core" }
psysonic-analysis = { path = "crates/psysonic-analysis" }
tauri = { version = "2", features = ["tray-icon", "image-png"] } tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
@@ -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"] }
@@ -3,21 +3,21 @@ use std::sync::{Arc, Mutex, OnceLock};
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
use super::analysis_cache; use psysonic_core::user_agent::subsonic_wire_user_agent;
use super::lib_commands::*;
use super::subsonic_wire_user_agent; use crate::analysis_cache;
#[derive(Clone, serde::Serialize)] #[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct WaveformUpdatedPayload { pub struct WaveformUpdatedPayload {
pub(crate) track_id: String, pub track_id: String,
pub(crate) is_partial: bool, pub is_partial: bool,
} }
// ─── HTTP backfill queue: download tracks + seed analysis cache ────────────── // ─── HTTP backfill queue: download tracks + seed analysis cache ──────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AnalysisBackfillEnqueueKind { pub enum AnalysisBackfillEnqueueKind {
/// New job at the tail of the queue. /// New job at the tail of the queue.
NewBack, NewBack,
/// New job for the currently playing track (head). /// New job for the currently playing track (head).
@@ -31,10 +31,10 @@ pub(crate) enum AnalysisBackfillEnqueueKind {
} }
#[derive(Default)] #[derive(Default)]
pub(crate) struct AnalysisBackfillQueueState { pub struct AnalysisBackfillQueueState {
pub(crate) deque: VecDeque<(String, String)>, pub deque: VecDeque<(String, String)>,
/// Set while this `track_id` is inside `analysis_backfill_download_and_seed` (not in deque). /// Set while this `track_id` is inside `analysis_backfill_download_and_seed` (not in deque).
pub(crate) in_progress: Option<String>, pub in_progress: Option<String>,
} }
impl AnalysisBackfillQueueState { impl AnalysisBackfillQueueState {
@@ -55,7 +55,7 @@ impl AnalysisBackfillQueueState {
} }
} }
pub(crate) fn enqueue( pub fn enqueue(
&mut self, &mut self,
tid: String, tid: String,
url: 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(); let before = self.deque.len();
self.deque self.deque
.retain(|(track_id, _)| keep_track_ids.contains(track_id.as_str())); .retain(|(track_id, _)| keep_track_ids.contains(track_id.as_str()));
@@ -90,13 +90,13 @@ impl AnalysisBackfillQueueState {
} }
} }
pub(crate) struct AnalysisBackfillShared { pub struct AnalysisBackfillShared {
pub(crate) state: Mutex<AnalysisBackfillQueueState>, pub state: Mutex<AnalysisBackfillQueueState>,
wake_tx: tokio::sync::mpsc::UnboundedSender<()>, wake_tx: tokio::sync::mpsc::UnboundedSender<()>,
} }
impl AnalysisBackfillShared { impl AnalysisBackfillShared {
pub(crate) fn ping_worker(&self) { pub fn ping_worker(&self) {
let _ = self.wake_tx.send(()); let _ = self.wake_tx.send(());
} }
} }
@@ -104,7 +104,7 @@ impl AnalysisBackfillShared {
static ANALYSIS_BACKFILL: OnceLock<Arc<AnalysisBackfillShared>> = OnceLock::new(); static ANALYSIS_BACKFILL: OnceLock<Arc<AnalysisBackfillShared>> = OnceLock::new();
/// Lazily spawns the single backfill worker (first caller supplies `AppHandle`). /// Lazily spawns the single backfill worker (first caller supplies `AppHandle`).
pub(crate) fn analysis_backfill_shared(app: &tauri::AppHandle) -> Arc<AnalysisBackfillShared> { pub fn analysis_backfill_shared(app: &tauri::AppHandle) -> Arc<AnalysisBackfillShared> {
ANALYSIS_BACKFILL ANALYSIS_BACKFILL
.get_or_init(|| { .get_or_init(|| {
let (wake_tx, wake_rx) = tokio::sync::mpsc::unbounded_channel(); let (wake_tx, wake_rx) = tokio::sync::mpsc::unbounded_channel();
@@ -120,6 +120,44 @@ pub(crate) fn analysis_backfill_shared(app: &tauri::AppHandle) -> Arc<AnalysisBa
.clone() .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( async fn analysis_backfill_download_and_seed(
app: &tauri::AppHandle, app: &tauri::AppHandle,
track_id: &str, 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 { pub fn analysis_backfill_is_current_track(app: &tauri::AppHandle, track_id: &str) -> bool {
app.try_state::<crate::audio::AudioEngine>() app.try_state::<psysonic_core::ports::PlaybackQueryHandle>()
.is_some_and(|e| crate::audio::analysis_track_id_is_current_playback(&e, track_id)) .is_some_and(|p| p.is_track_currently_playing(track_id))
} }
// ─── Full-track waveform + loudness: single CPU worker (mirrors HTTP backfill queue) ─ // ─── 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. // submitters attach to `running` followers so they all get the same outcome.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AnalysisCpuSeedEnqueueKind { pub enum AnalysisCpuSeedEnqueueKind {
NewBack, NewBack,
NewFront, NewFront,
ReorderedFront, 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(); emit_analysis_queue_snapshot_line();
loop { loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await; 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 /// Returns `(http_removed, cpu_removed_jobs, cpu_removed_waiters)`. Either
/// queue may not have been initialized yet — those slots return 0. /// 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>, keep_track_ids: &HashSet<&str>,
) -> Result<(usize, usize, usize), String> { ) -> Result<(usize, usize, usize), String> {
let http_removed = if let Some(shared) = ANALYSIS_BACKFILL.get() { 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`). /// Emits `analysis:waveform-updated` when analysis **wrote** new waveform data (`Upserted`).
/// Cache-hit skips (`SkippedWaveformCacheHit`) omit the event so the frontend does not /// Cache-hit skips (`SkippedWaveformCacheHit`) omit the event so the frontend does not
/// re-run loudness refresh / waveform IPC for rows that were already current. /// 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, app: tauri::AppHandle,
track_id: String, track_id: String,
bytes: Vec<u8>, bytes: Vec<u8>,
@@ -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 logging;
pub mod ports; 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 //! Exists to break the one back-edge in the audio↔analysis dependency:
//! force `psysonic-audio` ↔ `psysonic-analysis` to depend on each other in //! `psysonic-analysis` needs to ask "is this track currently playing?", but
//! both directions. Implementers register themselves as Tauri State on app //! must not depend on `psysonic-audio` (which has the real dep on analysis,
//! bootstrap; consumers look them up via `app.try_state::<Arc<dyn _>>()`. //! not the other way around).
//! //!
//! Layout: //! Implementation note: ports are exposed as **closure handles** rather than
//! - `AnalysisOrchestrator` — implemented in `psysonic-analysis`, called from //! `Arc<dyn Trait>` — this avoids forcing every existing `State<AudioEngine>`
//! `psysonic-audio` to enqueue full-buffer loudness/waveform analysis. //! callsite to switch to `State<Arc<AudioEngine>>` (which Tauri State requires
//! - `PlaybackQuery` — implemented in `psysonic-audio` on `AudioEngine`, //! for trait-object registration). The shell crate creates the handle by
//! called from `psysonic-analysis` to ask "is this track currently playing?". //! capturing an `AppHandle` and looking up the audio engine at call time.
use std::future::Future; use std::sync::Arc;
use std::pin::Pin;
use tauri::AppHandle; /// "Is this track currently being decoded/played?" — implementation lives in
/// `psysonic-audio` (`AudioEngine::analysis_track_id_is_current_playback`).
/// Read-only queries about the live playback session. Implemented on /// The shell crate registers an instance constructed via [`PlaybackQueryHandle::new`]
/// `AudioEngine` in `psysonic-audio`. /// as Tauri State; consumers in `psysonic-analysis` look it up.
/// #[derive(Clone)]
/// Registered as `Arc<dyn PlaybackQuery>` in Tauri State so non-audio crates pub struct PlaybackQueryHandle {
/// can query without taking a hard dep on the audio crate. inner: Arc<dyn Fn(&str) -> bool + Send + Sync + 'static>,
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;
} }
/// Triggers full-buffer analysis on already-captured track bytes. Implemented impl PlaybackQueryHandle {
/// on `AnalysisRuntime` in `psysonic-analysis`. pub fn new<F>(f: F) -> Self
/// where
/// Registered as `Arc<dyn AnalysisOrchestrator>` in Tauri State so audio F: Fn(&str) -> bool + Send + Sync + 'static,
/// callsites (`stream::ranged_http`, `stream::track_stream`, `helpers`, {
/// `play_input`, `preload_commands`) can submit without depending on Self { inner: Arc::new(f) }
/// `psysonic-analysis`. }
pub trait AnalysisOrchestrator: Send + Sync + 'static {
/// Enqueue a CPU-seed analysis pass for `track_id` over the given byte pub fn is_track_currently_playing(&self, track_id: &str) -> bool {
/// buffer. `high_priority` mirrors the HTTP-backfill head-insertion (self.inner)(track_id)
/// 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>>;
} }
@@ -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())
}
+23 -24
View File
@@ -2,23 +2,27 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod audio; mod audio;
mod analysis_cache;
mod analysis_runtime;
pub mod cli; pub mod cli;
mod discord; mod discord;
mod lib_commands; mod lib_commands;
pub use psysonic_core::logging; pub use psysonic_core::logging;
pub use psysonic_core::{app_eprintln, app_deprintln}; 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")] #[cfg(target_os = "windows")]
mod taskbar_win; mod taskbar_win;
mod tray_runtime; mod tray_runtime;
pub(crate) use analysis_runtime::*;
pub(crate) use tray_runtime::*; pub(crate) use tray_runtime::*;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock, RwLock}; use std::sync::{Arc, Mutex, OnceLock};
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
@@ -32,25 +36,6 @@ type ShortcutMap = Mutex<HashMap<String, String>>;
/// The frontend queues more tasks than this; Rust is the real throttle. /// The frontend queues more tasks than this; Rust is the real throttle.
const MAX_DL_CONCURRENCY: usize = 4; 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<String> {
static UA: OnceLock<RwLock<String>> = 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/<version>` 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. /// Shared semaphore that caps simultaneous `download_track_offline` executions.
type DownloadSemaphore = Arc<tokio::sync::Semaphore>; type DownloadSemaphore = Arc<tokio::sync::Semaphore>;
@@ -151,8 +136,22 @@ pub fn run() {
app.manage(cache); 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::<crate::audio::AudioEngine>()
.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). // 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 ───────────────────────────────── // ── Custom title bar on Linux ─────────────────────────────────
// Remove OS window decorations on all Linux so the React TitleBar // Remove OS window decorations on all Linux so the React TitleBar
+2 -1
View File
@@ -1,9 +1,10 @@
use crate::analysis_runtime::enqueue_analysis_seed;
use crate::audio; use crate::audio;
use crate::subsonic_wire_user_agent; use crate::subsonic_wire_user_agent;
use super::super::file_transfer::stream_to_file; use super::super::file_transfer::stream_to_file;
use super::downloads::{resolve_hot_cache_root, HotCacheDownloadResult}; 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] #[tauri::command]
pub(crate) async fn download_track_hot_cache( pub(crate) async fn download_track_hot_cache(
-2
View File
@@ -15,5 +15,3 @@ pub(crate) use downloads::{
check_arch_linux, download_update, download_zip, fetch_netease_lyrics, get_embedded_lyrics, check_arch_linux, download_update, download_zip, fetch_netease_lyrics, get_embedded_lyrics,
open_folder, open_folder,
}; };
// Internal helper consumed by analysis_runtime (used as analysis_runtime depends on it):
pub(crate) use offline::enqueue_analysis_seed;
+1 -32
View File
@@ -1,44 +1,13 @@
use tauri::Manager; use tauri::Manager;
use crate::analysis_cache; 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 crate::DownloadSemaphore;
use super::super::file_transfer::{finalize_streamed_download, subsonic_http_client}; use super::super::file_transfer::{finalize_streamed_download, subsonic_http_client};
// ─── Offline Track Cache ────────────────────────────────────────────────────── // ─── Offline Track Cache ──────────────────────────────────────────────────────
pub(crate) 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)
}
pub(crate) async fn enqueue_analysis_seed_from_file( pub(crate) async fn enqueue_analysis_seed_from_file(
app: &tauri::AppHandle, app: &tauri::AppHandle,
track_id: &str, track_id: &str,