mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(enrichment): oximedia BPM/mood facts, mood search, and queue display (#863)
* feat(enrichment): oximedia BPM/mood facts, mood search, and queue UI Run client-side oximedia analysis after CPU seed and persist BPM, mood JSON, and searchable mood_tag facts. Add product mood groups (joy/sadness/dance/work/ romance) with Advanced Search filter on the local index, queue BPM/mood display, migration 008 mood_tag index, and refreshed licenses for oximedia crates. * fix(enrichment): keep mood_groups module comment in English * feat(search): virtual mood groups, anger filter, and Advanced Search UX Expand mood search via overlapping virtual groups (tag expansion only), add anger/Злость group, skip album/artist shortcuts for track-only filters, and simplify mood search UI (songs-only, hide type tabs). Fix CustomSelect spurious scrollbar on short option lists. * feat(analysis): unified track analysis plan and enqueue path Add TrackAnalysisPlan (waveform, LUFS, enrichment) with a single enqueue_track_analysis entry for all byte-backed triggers. Run enrichment when cache is full but library facts are missing; route playback, cache, and backfill through the planner. Fix browseTextSearch LocalSearchOpts tsc gap and remove obsolete read_seed_bytes_if_needed helper. * fix(analysis): wire playback dispatch, preload enrichment, and UI refresh Route stream, gapless, preload, and local-file playback through analysis_dispatch so BPM/mood enrichment runs when waveform/LUFS are already cached. Fix audio_preload cache-hit and hot-cache paths, emit preload-cancelled for retry, and add analysis:enrichment-updated plus content_cache_coverage key resolution. * fix(audio): preload local files from disk and stop analysis retry loop Seed hot/offline next tracks via LocalFilePlayback (512 MiB) instead of copying into the RAM preload slot. Keep bytePreloadingId set after preload-ready so progress ticks do not re-invoke audio_preload every second. * fix(enrichment): clippy, album bpm filter routing, and queue mood display Clippy-clean analysis_dispatch and engine imports; restrict track-derived album routing to mood_group/mood_tag only so bpm is skipped on album queries. Log enrichment plan errors with retry-all plan; filter queue mood labels to oximedia ids. * fix(enrichment): simplify mood_tag backfill branch in plan_track_enrichment Remove empty if-block; keep same behaviour when backfill fails and moods row exists. * docs: CHANGELOG and credits for track enrichment PR #863 * docs(credits): track enrichment PR #863 contributor line * chore(enrichment): clippy-clean plan branch and trim dead exports Collapse mood_tag backfill if for clippy; remove unused moodGroupById and OXIMEDIA_MOOD_LABELS re-exports; stop poll when server BPM is already known. * fix(enrichment): close R2/S1–S3 limits and Song Info BPM fallback Return TrackEnrichmentOutcome::Failed on oximedia errors so retries are not masked as complete; extract mood Advanced Search SQL, unify top-3 mood tag selection in mood_groups with TS invariant tests, and show measured BPM in Song Info when tag BPM is missing or zero. * fix(enrichment): restore offline coverage and show mood in Song Info Add unit tests for offline download cancel/clear registry after the analysis seed refactor dropped read_seed_bytes coverage; show localized mood labels in Song Info when library enrichment facts exist. * fix(enrichment): soft mood scoring and unblock offline cancel tests Replace oximedia quadrant happy/excited mapping with valence/arousal soft scores across all mood tags for display, storage, and backfill; fix offline cancel unit tests that deadlocked by calling clear while holding the global offline_cancel_flags mutex. * fix(enrichment): dedupe joy cluster and cap mood display at two labels Never show happy and excited together; pick one tag per V/A cluster, tighten oximedia recalibration, and limit queue/Song Info to two moods that pass a relative score floor. * fix(enrichment): disable oximedia mood labels in UI and search tags Oximedia 0.1.7 mood is a spectral energy heuristic, not independent mood weights; valence correlates with loud/bright audio and false-labels metal and lyrical tracks as happy. Hide queue/Song Info mood and stop writing mood_tag facts until a reliable detector lands; keep V/A/moods JSON stored. * fix(enrichment): disable oximedia mood analysis and add BPM advanced search Stop planning, running, and storing oximedia mood facts; purge accumulated mood rows via migration 009. Hide mood filters in Advanced Search, expose BPM range filter with dual-storage resolution, and show a BPM column in song results when that filter is active. * feat(search): analysis BPM priority, source tooltip, and filter UX Prefer analysis track_fact over file tags for BPM resolution; show source in list tooltips. Validate BPM range on blur, add clear button, fix double tooltip. * fix(enrichment): prefer analysis BPM in Song Info and queue tech row Show measured track_fact BPM before file tags until analysis completes; pick the highest-confidence analysis fact when several exist.
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
//! Unified playback → track analysis dispatch.
|
||||
//!
|
||||
//! Stream completion, hot/offline files, gapless chain, preload, and in-memory
|
||||
//! replay all funnel through here before [`psysonic_analysis::analysis_runtime::enqueue_track_analysis`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::engine::{analysis_track_id_is_current_playback, AudioEngine};
|
||||
use crate::helpers::{analysis_cache_track_id, current_playback_server_id_str};
|
||||
use crate::state::ChainedInfo;
|
||||
use crate::stream::{LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES, TRACK_STREAM_PROMOTE_MAX_BYTES};
|
||||
|
||||
/// Where playback obtained the bytes — used for logging and size caps only.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum TrackAnalysisOrigin {
|
||||
InMemoryReplay,
|
||||
StreamDownloadComplete,
|
||||
LocalFilePlayback,
|
||||
StreamSpillFile,
|
||||
PrefetchOrCacheFile,
|
||||
GaplessChainReady,
|
||||
GaplessTransition,
|
||||
}
|
||||
|
||||
fn max_bytes_for_origin(origin: TrackAnalysisOrigin) -> usize {
|
||||
match origin {
|
||||
TrackAnalysisOrigin::LocalFilePlayback => LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES,
|
||||
_ => TRACK_STREAM_PROMOTE_MAX_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
/// Playback server scope: explicit IPC value, else pinned engine scope.
|
||||
pub(crate) fn resolve_analysis_server_id(
|
||||
explicit: Option<&str>,
|
||||
engine: Option<&AudioEngine>,
|
||||
) -> String {
|
||||
explicit
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
engine
|
||||
.map(current_playback_server_id_str)
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_high_priority(
|
||||
app: &AppHandle,
|
||||
engine: Option<&AudioEngine>,
|
||||
track_id: &str,
|
||||
explicit: Option<bool>,
|
||||
) -> bool {
|
||||
explicit.unwrap_or_else(|| {
|
||||
psysonic_analysis::analysis_runtime::analysis_backfill_is_current_track(app, track_id)
|
||||
|| engine.is_some_and(|e| analysis_track_id_is_current_playback(e, track_id))
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve `(server_id, high_priority)` when the caller has live engine state.
|
||||
pub(crate) fn prepare_playback_analysis(
|
||||
app: &AppHandle,
|
||||
engine: &AudioEngine,
|
||||
explicit_server_id: Option<&str>,
|
||||
track_id: &str,
|
||||
high_priority: Option<bool>,
|
||||
) -> (String, bool) {
|
||||
(
|
||||
resolve_analysis_server_id(explicit_server_id, Some(engine)),
|
||||
resolve_high_priority(app, Some(engine), track_id, high_priority),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_server_id_for_app(
|
||||
app: &AppHandle,
|
||||
explicit: Option<&str>,
|
||||
) -> String {
|
||||
let engine = app.try_state::<AudioEngine>();
|
||||
resolve_analysis_server_id(explicit, engine.as_deref())
|
||||
}
|
||||
|
||||
pub(crate) fn high_priority_for_app(
|
||||
app: &AppHandle,
|
||||
track_id: &str,
|
||||
explicit: Option<bool>,
|
||||
) -> bool {
|
||||
let engine = app.try_state::<AudioEngine>();
|
||||
resolve_high_priority(app, engine.as_deref(), track_id, explicit)
|
||||
}
|
||||
|
||||
/// Gapless boundary: chained track became audible — run unified analysis if needed.
|
||||
pub(crate) fn spawn_gapless_transition_analysis(app: &AppHandle, info: &ChainedInfo) {
|
||||
let track_id = analysis_cache_track_id(
|
||||
info.analysis_track_id.as_deref(),
|
||||
&info.url,
|
||||
);
|
||||
let Some(track_id) = track_id else {
|
||||
return;
|
||||
};
|
||||
let engine = app.state::<AudioEngine>();
|
||||
let (sid, high) = prepare_playback_analysis(
|
||||
app,
|
||||
&engine,
|
||||
info.server_id.as_deref(),
|
||||
&track_id,
|
||||
Some(true),
|
||||
);
|
||||
let bytes = (*info.raw_bytes).clone();
|
||||
spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::GaplessTransition,
|
||||
sid,
|
||||
track_id,
|
||||
bytes,
|
||||
high,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Byte-backed analysis — the single audio-side entry before the analysis crate planner.
|
||||
pub(crate) async fn dispatch_track_analysis_bytes(
|
||||
app: &AppHandle,
|
||||
origin: TrackAnalysisOrigin,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
bytes: Vec<u8>,
|
||||
high_priority: bool,
|
||||
) -> Result<(), String> {
|
||||
let track_id = track_id.trim();
|
||||
if track_id.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if bytes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let max = max_bytes_for_origin(origin);
|
||||
if bytes.len() > max {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][dispatch] skip origin={origin:?} track_id={track_id} bytes={} max={max}",
|
||||
bytes.len(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[analysis][dispatch] origin={origin:?} track_id={track_id} server_id={} size_mib={:.2} high={high_priority}",
|
||||
if server_id.is_empty() { "''" } else { server_id },
|
||||
bytes.len() as f64 / (1024.0 * 1024.0),
|
||||
);
|
||||
psysonic_analysis::analysis_runtime::enqueue_track_analysis(
|
||||
app,
|
||||
server_id,
|
||||
track_id,
|
||||
&bytes,
|
||||
high_priority,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Non-blocking wrapper with optional play-generation supersede guard.
|
||||
pub(crate) fn spawn_track_analysis_bytes(
|
||||
app: AppHandle,
|
||||
origin: TrackAnalysisOrigin,
|
||||
server_id: String,
|
||||
track_id: String,
|
||||
bytes: Vec<u8>,
|
||||
high_priority: bool,
|
||||
generation_guard: Option<(u64, Arc<AtomicU64>)>,
|
||||
) {
|
||||
if track_id.trim().is_empty() || bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
if let Some((gen, gen_arc)) = generation_guard {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Err(e) = dispatch_track_analysis_bytes(
|
||||
&app,
|
||||
origin,
|
||||
&server_id,
|
||||
&track_id,
|
||||
bytes,
|
||||
high_priority,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!(
|
||||
"[analysis][dispatch] failed origin={origin:?} track_id={track_id}: {e}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_track_analysis_file(
|
||||
app: AppHandle,
|
||||
origin: TrackAnalysisOrigin,
|
||||
server_id: String,
|
||||
track_id: String,
|
||||
file_path: PathBuf,
|
||||
high_priority: bool,
|
||||
generation_guard: Option<(u64, Arc<AtomicU64>)>,
|
||||
) {
|
||||
if track_id.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
if let Some((gen, gen_arc)) = &generation_guard {
|
||||
if gen_arc.load(Ordering::SeqCst) != *gen {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let bytes = match tokio::fs::read(&file_path).await {
|
||||
Ok(b) if !b.is_empty() => b,
|
||||
_ => return,
|
||||
};
|
||||
if let Some((gen, gen_arc)) = generation_guard {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Err(e) = dispatch_track_analysis_bytes(
|
||||
&app,
|
||||
origin,
|
||||
&server_id,
|
||||
&track_id,
|
||||
bytes,
|
||||
high_priority,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!(
|
||||
"[analysis][dispatch] file failed origin={origin:?} track_id={track_id}: {e}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -437,6 +437,7 @@ pub async fn audio_play(
|
||||
}
|
||||
|
||||
// ── Progress + ended detection ────────────────────────────────────────────
|
||||
let analysis_app = app.clone();
|
||||
spawn_progress_task(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
@@ -446,6 +447,7 @@ pub async fn audio_play(
|
||||
state.crossfade_secs.clone(),
|
||||
done_flag,
|
||||
app,
|
||||
Some(analysis_app),
|
||||
state.samples_played.clone(),
|
||||
state.current_sample_rate.clone(),
|
||||
state.current_channels.clone(),
|
||||
@@ -480,6 +482,7 @@ pub async fn audio_chain_preload(
|
||||
fallback_db: f32,
|
||||
hi_res_enabled: bool,
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -542,6 +545,27 @@ pub async fn audio_chain_preload(
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let analysis_server_id = server_id.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
|
||||
if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) {
|
||||
let (sid, high) = crate::analysis_dispatch::prepare_playback_analysis(
|
||||
&app,
|
||||
&state,
|
||||
analysis_server_id,
|
||||
&track_id,
|
||||
Some(false),
|
||||
);
|
||||
let bytes = (*raw_bytes).clone();
|
||||
crate::analysis_dispatch::spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
crate::analysis_dispatch::TrackAnalysisOrigin::GaplessChainReady,
|
||||
sid,
|
||||
track_id,
|
||||
bytes,
|
||||
high,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
// Only `gain_linear` is needed — `effective_volume` is intentionally NOT
|
||||
// applied to the Sink here. `audio_chain_preload` runs ~30 s before the
|
||||
@@ -623,6 +647,8 @@ pub async fn audio_chain_preload(
|
||||
|
||||
*state.chained_info.lock().unwrap() = Some(ChainedInfo {
|
||||
url,
|
||||
analysis_track_id: logical_trim,
|
||||
server_id: analysis_server_id.map(str::to_string),
|
||||
raw_bytes,
|
||||
duration_secs,
|
||||
replay_gain_linear: gain_linear,
|
||||
|
||||
@@ -245,6 +245,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
// Inform the frontend of the new duration (keeps seekbar range correct).
|
||||
app.emit("audio:playing", ps.built.duration_secs).ok();
|
||||
|
||||
let analysis_app = app.clone();
|
||||
spawn_progress_task(
|
||||
gen,
|
||||
engine.generation.clone(),
|
||||
@@ -254,6 +255,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
engine.crossfade_secs.clone(),
|
||||
done_flag,
|
||||
app.clone(),
|
||||
Some(analysis_app),
|
||||
engine.samples_played.clone(),
|
||||
engine.current_sample_rate.clone(),
|
||||
engine.current_channels.clone(),
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rodio::Player;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
|
||||
|
||||
@@ -459,7 +458,3 @@ pub fn refresh_http_user_agent(state: &AudioEngine, ua: &str) {
|
||||
*slot = client;
|
||||
}
|
||||
}
|
||||
pub(crate) fn analysis_seed_high_priority_for_track(app: &AppHandle, track_id: &str) -> bool {
|
||||
app.try_state::<AudioEngine>()
|
||||
.is_some_and(|e| analysis_track_id_is_current_playback(&e, track_id))
|
||||
}
|
||||
|
||||
@@ -747,118 +747,6 @@ pub(crate) async fn fetch_data(
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
/// When playback uses full track bytes already in RAM (gapless `reuse_chained_bytes`,
|
||||
/// `preloaded`, or `stream_completed_cache` via `fetch_data`), the `psysonic-local`
|
||||
/// disk-read seed path never runs. Submit the same full-buffer analysis via the cpu-seed queue so waveform /
|
||||
/// loudness SQLite can fill **offline** without `analysis_enqueue_seed_from_url` HTTP.
|
||||
pub(crate) fn spawn_analysis_seed_from_in_memory_bytes(
|
||||
app: &AppHandle,
|
||||
server_id: Option<&str>,
|
||||
cache_track_id: Option<&str>,
|
||||
gen: u64,
|
||||
gen_arc: &Arc<AtomicU64>,
|
||||
bytes: &[u8],
|
||||
) {
|
||||
let Some(track_id) = cache_track_id.map(str::trim).filter(|s| !s.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
if bytes.is_empty() || bytes.len() > crate::stream::TRACK_STREAM_PROMOTE_MAX_BYTES {
|
||||
return;
|
||||
}
|
||||
let track_id = track_id.to_string();
|
||||
let server_id = server_id.unwrap_or("").to_string();
|
||||
let bytes = bytes.to_vec();
|
||||
let app = app.clone();
|
||||
let gen_arc = gen_arc.clone();
|
||||
crate::app_deprintln!(
|
||||
"[stream] in-memory play path: scheduling full-track analysis track_id={} size_mib={:.2}",
|
||||
track_id,
|
||||
bytes.len() as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
|
||||
tokio::spawn(async move {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), server_id, track_id.clone(), bytes, high).await {
|
||||
crate::app_eprintln!(
|
||||
"[analysis] in-memory play path seed failed for {}: {}",
|
||||
track_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Full-track analysis for a completed ranged stream spilled to disk (> RAM promote cap).
|
||||
pub(crate) fn spawn_analysis_seed_from_spill_file(
|
||||
app: &AppHandle,
|
||||
server_id: Option<&str>,
|
||||
track_id: &str,
|
||||
spill_path: std::path::PathBuf,
|
||||
gen: u64,
|
||||
gen_arc: &Arc<AtomicU64>,
|
||||
) {
|
||||
let track_id = track_id.trim().to_string();
|
||||
if track_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let server_id = server_id.unwrap_or("").to_string();
|
||||
let app = app.clone();
|
||||
let gen_arc = gen_arc.clone();
|
||||
let max_bytes = crate::stream::LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES;
|
||||
tokio::spawn(async move {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
let bytes = match tokio::fs::read(&spill_path).await {
|
||||
Ok(b) if b.is_empty() => return,
|
||||
Ok(b) if b.len() > max_bytes => {
|
||||
crate::app_deprintln!(
|
||||
"[stream] spill analysis skip track_id={} bytes={} max={}",
|
||||
track_id,
|
||||
b.len(),
|
||||
max_bytes
|
||||
);
|
||||
return;
|
||||
}
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!(
|
||||
"[stream] spill analysis read failed track_id={}: {}",
|
||||
track_id,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[stream] spill path: scheduling full-track analysis track_id={} size_mib={:.2}",
|
||||
track_id,
|
||||
bytes.len() as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
|
||||
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(
|
||||
app,
|
||||
server_id,
|
||||
track_id.clone(),
|
||||
bytes,
|
||||
high,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!(
|
||||
"[analysis] spill path seed failed for {}: {}",
|
||||
track_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// -1 dB headroom applied at full scale to prevent inter-sample clipping.
|
||||
/// Modern masters are often at 0 dBFS; the EQ biquad chain and resampler
|
||||
/// can produce inter-sample peaks slightly above ±1.0 → audible distortion.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
pub use psysonic_core::{app_deprintln, app_eprintln, logging};
|
||||
|
||||
pub mod autoeq_commands;
|
||||
mod analysis_dispatch;
|
||||
mod codec;
|
||||
pub mod commands;
|
||||
mod decode;
|
||||
|
||||
@@ -9,19 +9,23 @@ use std::time::Duration;
|
||||
use ringbuf::traits::Split;
|
||||
use ringbuf::{HeapCons, HeapRb};
|
||||
use symphonia::core::io::MediaSource;
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::analysis_dispatch::{
|
||||
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
|
||||
TrackAnalysisOrigin,
|
||||
};
|
||||
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::helpers::{
|
||||
content_type_to_hint, fetch_data, format_hint_from_content_disposition,
|
||||
normalize_stream_suffix_for_hint, resolve_playback_format_hint, sniff_stream_format_extension,
|
||||
spawn_analysis_seed_from_in_memory_bytes, same_playback_target,
|
||||
same_playback_target,
|
||||
STREAM_FORMAT_SNIFF_PROBE_BYTES,
|
||||
};
|
||||
use super::stream::{
|
||||
ranged_download_task, track_download_task, AudioStreamReader,
|
||||
LocalFileSource, RangedHttpSource, LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES,
|
||||
LocalFileSource, RangedHttpSource,
|
||||
TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_BUF_CAPACITY, TRACK_STREAM_MIN_BUF_CAPACITY,
|
||||
};
|
||||
|
||||
@@ -62,6 +66,33 @@ pub(super) struct PlayInputContext<'a> {
|
||||
pub reuse_chained_bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn spawn_playback_analysis_bytes(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
ctx: &PlayInputContext<'_>,
|
||||
origin: TrackAnalysisOrigin,
|
||||
bytes: Vec<u8>,
|
||||
) {
|
||||
let Some(track_id) = ctx
|
||||
.cache_id_for_tasks
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (sid, high) =
|
||||
prepare_playback_analysis(app, state, ctx.server_id, track_id, None);
|
||||
spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
origin,
|
||||
sid,
|
||||
track_id.to_string(),
|
||||
bytes,
|
||||
high,
|
||||
Some((ctx.gen, state.generation.clone())),
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolves the play input for `audio_play` honouring (in priority order):
|
||||
/// 1. Reused chained bytes — manual skip onto pre-chained track.
|
||||
/// 2. `psysonic-local://` files — open as seekable LocalFileSource.
|
||||
@@ -77,14 +108,23 @@ pub(super) async fn select_play_input(
|
||||
app: &AppHandle,
|
||||
) -> Result<Option<PlayInput>, String> {
|
||||
if let Some(d) = ctx.reuse_chained_bytes {
|
||||
spawn_analysis_seed_from_in_memory_bytes(
|
||||
app,
|
||||
ctx.server_id,
|
||||
ctx.cache_id_for_tasks,
|
||||
ctx.gen,
|
||||
&state.generation,
|
||||
&d,
|
||||
);
|
||||
if let Some(track_id) = ctx
|
||||
.cache_id_for_tasks
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
let (sid, high) =
|
||||
prepare_playback_analysis(app, state, ctx.server_id, track_id, None);
|
||||
spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::InMemoryReplay,
|
||||
sid,
|
||||
track_id.to_string(),
|
||||
d.clone(),
|
||||
high,
|
||||
Some((ctx.gen, state.generation.clone())),
|
||||
);
|
||||
}
|
||||
return Ok(Some(PlayInput::Bytes(d)));
|
||||
}
|
||||
|
||||
@@ -114,13 +154,12 @@ pub(super) async fn select_play_input(
|
||||
Some(d) => d,
|
||||
None => return Ok(None), // superseded while downloading
|
||||
};
|
||||
spawn_analysis_seed_from_in_memory_bytes(
|
||||
spawn_playback_analysis_bytes(
|
||||
app,
|
||||
ctx.server_id,
|
||||
ctx.cache_id_for_tasks,
|
||||
ctx.gen,
|
||||
&state.generation,
|
||||
&data,
|
||||
state,
|
||||
&ctx,
|
||||
TrackAnalysisOrigin::InMemoryReplay,
|
||||
data.clone(),
|
||||
);
|
||||
Ok(Some(PlayInput::Bytes(data)))
|
||||
}
|
||||
@@ -146,57 +185,17 @@ fn open_local_file_input(
|
||||
local_hint
|
||||
);
|
||||
if let Some(seed_id) = ctx.cache_id_for_tasks {
|
||||
let skip_cpu_seed = app
|
||||
.try_state::<psysonic_analysis::analysis_cache::AnalysisCache>()
|
||||
.map(|c| {
|
||||
c.cpu_seed_redundant_for_track(ctx.server_id.unwrap_or(""), seed_id)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !skip_cpu_seed {
|
||||
let path_owned = std::path::PathBuf::from(path);
|
||||
let app_seed = app.clone();
|
||||
let gen_seed = ctx.gen;
|
||||
let gen_arc_seed = state.generation.clone();
|
||||
let seed_id = seed_id.to_string();
|
||||
let seed_server = ctx.server_id.unwrap_or("").to_string();
|
||||
tokio::spawn(async move {
|
||||
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
|
||||
return;
|
||||
}
|
||||
let data = match tokio::fs::read(&path_owned).await {
|
||||
Ok(d) => d,
|
||||
Err(_) => return,
|
||||
};
|
||||
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
|
||||
return;
|
||||
}
|
||||
if data.is_empty() || data.len() > LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES {
|
||||
crate::app_deprintln!(
|
||||
"[stream] psysonic-local: skip analysis seed track_id={} bytes={} (over {} MiB cap)",
|
||||
seed_id,
|
||||
data.len(),
|
||||
LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES / (1024 * 1024)
|
||||
);
|
||||
return;
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[stream] psysonic-local: file read complete track_id={} size_mib={:.2} — full-track analysis (cpu-seed queue)",
|
||||
seed_id,
|
||||
data.len() as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
let high = crate::engine::analysis_seed_high_priority_for_track(&app_seed, &seed_id);
|
||||
if let Err(e) =
|
||||
psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app_seed.clone(), seed_server.clone(), seed_id.clone(), data, high).await
|
||||
{
|
||||
crate::app_eprintln!(
|
||||
"[analysis] local-file seed failed for {}: {}",
|
||||
seed_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
let (sid, high) =
|
||||
prepare_playback_analysis(app, state, ctx.server_id, seed_id, None);
|
||||
spawn_track_analysis_file(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::LocalFilePlayback,
|
||||
sid,
|
||||
seed_id.to_string(),
|
||||
std::path::PathBuf::from(path),
|
||||
high,
|
||||
Some((ctx.gen, state.generation.clone())),
|
||||
);
|
||||
}
|
||||
let reader = LocalFileSource { file, len };
|
||||
Ok(PlayInput::SeekableMedia {
|
||||
@@ -762,14 +761,22 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
effective_hint
|
||||
);
|
||||
}
|
||||
spawn_analysis_seed_from_in_memory_bytes(
|
||||
app,
|
||||
server_id,
|
||||
cache_id_for_tasks,
|
||||
gen,
|
||||
&state.generation,
|
||||
&data,
|
||||
);
|
||||
if let Some(track_id) = cache_id_for_tasks
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
let (sid, high) =
|
||||
prepare_playback_analysis(app, state, server_id, track_id, None);
|
||||
spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::StreamDownloadComplete,
|
||||
sid,
|
||||
track_id.to_string(),
|
||||
data.clone(),
|
||||
high,
|
||||
Some((gen, state.generation.clone())),
|
||||
);
|
||||
}
|
||||
match build_source_from_play_input(
|
||||
PlayInput::Bytes(data.clone()),
|
||||
state,
|
||||
|
||||
@@ -3,15 +3,115 @@
|
||||
//! (which constructs the gapless source chain) and `audio_play` (which
|
||||
//! starts playback). All three live in this audio submodule.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::analysis_dispatch::{
|
||||
dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file,
|
||||
TrackAnalysisOrigin,
|
||||
};
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::helpers::{analysis_cache_track_id, same_playback_target};
|
||||
use super::state::PreloadedTrack;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PreloadEventPayload {
|
||||
url: String,
|
||||
track_id: Option<String>,
|
||||
}
|
||||
|
||||
async fn seed_preload_analysis_bytes(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
url: &str,
|
||||
data: &[u8],
|
||||
analysis_track_id: Option<&str>,
|
||||
server_id: Option<&str>,
|
||||
) {
|
||||
let Some(track_id) = analysis_cache_track_id(analysis_track_id, url) else {
|
||||
return;
|
||||
};
|
||||
let (sid, high) = prepare_playback_analysis(
|
||||
app,
|
||||
state,
|
||||
server_id,
|
||||
&track_id,
|
||||
// Next-track prefetch — never steal CPU from the audible track.
|
||||
Some(false),
|
||||
);
|
||||
if let Err(e) = dispatch_track_analysis_bytes(
|
||||
app,
|
||||
TrackAnalysisOrigin::PrefetchOrCacheFile,
|
||||
&sid,
|
||||
&track_id,
|
||||
data.to_vec(),
|
||||
high,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!("[analysis] preload seed failed for {track_id}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_preload_analysis_file(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
url: &str,
|
||||
file_path: PathBuf,
|
||||
analysis_track_id: Option<&str>,
|
||||
server_id: Option<&str>,
|
||||
) {
|
||||
let Some(track_id) = analysis_cache_track_id(analysis_track_id, url) else {
|
||||
return;
|
||||
};
|
||||
let (sid, high) = prepare_playback_analysis(
|
||||
app,
|
||||
state,
|
||||
server_id,
|
||||
&track_id,
|
||||
Some(false),
|
||||
);
|
||||
crate::app_deprintln!(
|
||||
"[stream] audio_preload: local file analysis track_id={} path={}",
|
||||
track_id,
|
||||
file_path.display()
|
||||
);
|
||||
spawn_track_analysis_file(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::LocalFilePlayback,
|
||||
sid,
|
||||
track_id,
|
||||
file_path,
|
||||
high,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_preload_ready(app: &AppHandle, url: String, track_id: Option<String>) {
|
||||
let _ = app.emit(
|
||||
"audio:preload-ready",
|
||||
PreloadEventPayload {
|
||||
url,
|
||||
track_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_preload_cancelled(app: &AppHandle, url: String, track_id: Option<String>) {
|
||||
let _ = app.emit(
|
||||
"audio:preload-cancelled",
|
||||
PreloadEventPayload {
|
||||
url,
|
||||
track_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn audio_preload(
|
||||
url: String,
|
||||
@@ -21,49 +121,96 @@ pub async fn audio_preload(
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
let logical_trim = analysis_track_id
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let track_id_for_events = logical_trim.clone();
|
||||
|
||||
let is_local = url.starts_with("psysonic-local://");
|
||||
|
||||
// Hot/offline cache: playback reads from disk — seed analysis from the file
|
||||
// (512 MiB cap) without copying into the RAM preload slot.
|
||||
if is_local {
|
||||
let path = PathBuf::from(url.strip_prefix("psysonic-local://").unwrap());
|
||||
if !path.is_file() {
|
||||
crate::app_deprintln!(
|
||||
"[stream] audio_preload: local file missing path={}",
|
||||
path.display()
|
||||
);
|
||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
}
|
||||
seed_preload_analysis_file(
|
||||
&app,
|
||||
&state,
|
||||
&url,
|
||||
path,
|
||||
logical_trim.as_deref(),
|
||||
server_id.as_deref(),
|
||||
);
|
||||
emit_preload_ready(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Remote URL — reuse in-memory bytes when a prior HTTP preload finished.
|
||||
{
|
||||
let preloaded = state.preloaded.lock().unwrap();
|
||||
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
|
||||
let _ = app.emit("audio:preload-ready", url.clone());
|
||||
let cached = {
|
||||
let preloaded = state.preloaded.lock().unwrap();
|
||||
preloaded
|
||||
.as_ref()
|
||||
.filter(|p| same_playback_target(&p.url, &url))
|
||||
.map(|p| p.data.clone())
|
||||
};
|
||||
if let Some(data) = cached {
|
||||
if !data.is_empty() {
|
||||
seed_preload_analysis_bytes(
|
||||
&app,
|
||||
&state,
|
||||
&url,
|
||||
&data,
|
||||
logical_trim.as_deref(),
|
||||
server_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let _ = duration_hint; // kept in API for compatibility
|
||||
|
||||
// Throttle: wait 8 s before starting the background download so it does not
|
||||
// compete with the decode + sink-feed work of the just-started current track.
|
||||
// If the user skips during the wait the generation counter changes and we abort.
|
||||
let gen_snapshot = state.generation.load(Ordering::Relaxed);
|
||||
tokio::time::sleep(Duration::from_secs(8)).await;
|
||||
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
|
||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
}
|
||||
let data: Vec<u8> = if let Some(path) = url.strip_prefix("psysonic-local://") {
|
||||
tokio::fs::read(path).await.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
response.bytes().await.map_err(|e| e.to_string())?.into()
|
||||
};
|
||||
let _ = duration_hint; // kept in API for compatibility
|
||||
let logical_trim = analysis_track_id
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) {
|
||||
crate::app_deprintln!(
|
||||
"[stream] audio_preload: bytes ready track_id={} size_mib={:.2} — invoking full-track analysis",
|
||||
track_id,
|
||||
data.len() as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
let high = crate::engine::analysis_track_id_is_current_playback(&state, &track_id);
|
||||
let sid = server_id.clone().unwrap_or_default();
|
||||
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), sid, track_id.clone(), data.clone(), high).await {
|
||||
crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e);
|
||||
}
|
||||
|
||||
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
}
|
||||
let data: Vec<u8> = response.bytes().await.map_err(|e| e.to_string())?.into();
|
||||
|
||||
if !data.is_empty() {
|
||||
seed_preload_analysis_bytes(
|
||||
&app,
|
||||
&state,
|
||||
&url,
|
||||
&data,
|
||||
logical_trim.as_deref(),
|
||||
server_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let url_for_emit = url.clone();
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||
let _ = app.emit("audio:preload-ready", url_for_emit);
|
||||
emit_preload_ready(&app, url_for_emit, track_id_for_events);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
|
||||
crossfade_secs_arc: Arc<AtomicU32>,
|
||||
initial_done: Arc<AtomicBool>,
|
||||
emitter: E,
|
||||
analysis_app: Option<AppHandle>,
|
||||
samples_played: Arc<AtomicU64>,
|
||||
sample_rate_arc: Arc<AtomicU32>,
|
||||
channels_arc: Arc<AtomicU32>,
|
||||
@@ -134,6 +135,10 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
|
||||
|
||||
let chained = chained_arc.lock().unwrap().take();
|
||||
if let Some(info) = chained {
|
||||
if let Some(app) = analysis_app.clone() {
|
||||
crate::analysis_dispatch::spawn_gapless_transition_analysis(&app, &info);
|
||||
}
|
||||
|
||||
// Swap to the chained source's done flag.
|
||||
current_done = info.source_done;
|
||||
|
||||
@@ -381,6 +386,7 @@ mod tests {
|
||||
self.crossfade_secs.clone(),
|
||||
self.done.clone(),
|
||||
emitter,
|
||||
None,
|
||||
self.samples_played.clone(),
|
||||
self.sample_rate.clone(),
|
||||
self.channels.clone(),
|
||||
@@ -518,6 +524,8 @@ mod tests {
|
||||
let chained_samples = Arc::new(AtomicU64::new(0));
|
||||
*h.chained.lock().unwrap() = Some(ChainedInfo {
|
||||
url: chain_url.clone(),
|
||||
analysis_track_id: None,
|
||||
server_id: None,
|
||||
raw_bytes: Arc::new(Vec::new()),
|
||||
duration_secs: 200.0,
|
||||
replay_gain_linear: 1.0,
|
||||
|
||||
@@ -185,6 +185,7 @@ pub async fn audio_play_radio(
|
||||
state.crossfade_secs.clone(),
|
||||
done_flag,
|
||||
app,
|
||||
None,
|
||||
state.samples_played.clone(),
|
||||
state.current_sample_rate.clone(),
|
||||
state.current_channels.clone(),
|
||||
|
||||
@@ -18,6 +18,10 @@ pub(crate) struct StreamCompletedSpill {
|
||||
pub(crate) struct ChainedInfo {
|
||||
/// The URL that was chained — used by audio_play to detect a pre-chain hit.
|
||||
pub(crate) url: String,
|
||||
/// Subsonic track id for analysis dispatch (from `audio_chain_preload`).
|
||||
pub(crate) analysis_track_id: Option<String>,
|
||||
/// Playback server scope for analysis writes.
|
||||
pub(crate) server_id: Option<String>,
|
||||
/// Raw file bytes (shared with the chained decoder). Lets manual skip reuse
|
||||
/// them instead of re-downloading after dropping the Sink queue.
|
||||
pub(crate) raw_bytes: Arc<Vec<u8>>,
|
||||
|
||||
@@ -26,9 +26,11 @@ use super::{
|
||||
RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS,
|
||||
TRACK_STREAM_PROMOTE_MAX_BYTES,
|
||||
};
|
||||
use crate::helpers::{
|
||||
install_stream_completed_spill, spawn_analysis_seed_from_spill_file, write_stream_spill_file,
|
||||
use crate::analysis_dispatch::{
|
||||
dispatch_track_analysis_bytes, high_priority_for_app, resolve_server_id_for_app,
|
||||
spawn_track_analysis_file, TrackAnalysisOrigin,
|
||||
};
|
||||
use crate::helpers::{install_stream_completed_spill, write_stream_spill_file};
|
||||
use crate::state::StreamCompletedSpill;
|
||||
|
||||
/// Clears `AudioEngine::ranged_loudness_seed_hold` only if it still matches this play.
|
||||
@@ -644,10 +646,19 @@ pub(crate) async fn ranged_download_task(
|
||||
);
|
||||
}
|
||||
if let Some(track_id) = cache_track_id {
|
||||
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
|
||||
let sid = server_id.clone().unwrap_or_default();
|
||||
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), sid, track_id.clone(), data.clone(), high).await {
|
||||
crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e);
|
||||
let sid = resolve_server_id_for_app(&app, server_id.as_deref());
|
||||
let high = high_priority_for_app(&app, &track_id, None);
|
||||
if let Err(e) = dispatch_track_analysis_bytes(
|
||||
&app,
|
||||
TrackAnalysisOrigin::StreamDownloadComplete,
|
||||
&sid,
|
||||
&track_id,
|
||||
data.clone(),
|
||||
high,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!("[analysis] ranged seed failed for {track_id}: {e}");
|
||||
}
|
||||
}
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
@@ -679,13 +690,16 @@ pub(crate) async fn ranged_download_task(
|
||||
return;
|
||||
}
|
||||
install_stream_completed_spill(&spill_cache_slot, url, path.clone());
|
||||
spawn_analysis_seed_from_spill_file(
|
||||
&app,
|
||||
server_id.as_deref(),
|
||||
&track_id,
|
||||
let sid = resolve_server_id_for_app(&app, server_id.as_deref());
|
||||
let high = high_priority_for_app(&app, &track_id, None);
|
||||
spawn_track_analysis_file(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::StreamSpillFile,
|
||||
sid,
|
||||
track_id,
|
||||
path,
|
||||
gen,
|
||||
&gen_arc,
|
||||
high,
|
||||
Some((gen, gen_arc.clone())),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -167,12 +167,22 @@ pub(crate) async fn track_download_task(
|
||||
track_id,
|
||||
capture.len() as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
|
||||
let sid = server_id.clone().unwrap_or_default();
|
||||
if let Err(e) =
|
||||
psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), sid, track_id.clone(), capture.clone(), high).await
|
||||
let sid = crate::analysis_dispatch::resolve_server_id_for_app(
|
||||
&app,
|
||||
server_id.as_deref(),
|
||||
);
|
||||
let high = crate::analysis_dispatch::high_priority_for_app(&app, &track_id, None);
|
||||
if let Err(e) = crate::analysis_dispatch::dispatch_track_analysis_bytes(
|
||||
&app,
|
||||
crate::analysis_dispatch::TrackAnalysisOrigin::StreamDownloadComplete,
|
||||
&sid,
|
||||
&track_id,
|
||||
capture.clone(),
|
||||
high,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e);
|
||||
crate::app_eprintln!("[analysis] track seed failed for {track_id}: {e}");
|
||||
}
|
||||
}
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
|
||||
Reference in New Issue
Block a user