The track preview modal could hold a machine at 25% GPU. Three separate causes, each measured in isolation with a kill-style ladder and, once the GPU-engine counter proved to be clock-relative (a lighter workload read HIGHER), with clock-immune process CPU-seconds. Idle drain. The scrim's backdrop-filter is re-executed on every presented frame -- a backdrop filter is never cached -- so its cost is presents/sec times window area. Two infinite animations kept the modal presenting at the display's refresh rate forever: the eq capsule's bars animated `height` (a layout property, so style/layout/paint on the main thread every vsync) and the cover glow's halo-breathe kept a blur(48px) copy recompositing. Either one alone kept the pipeline hot, which is why killing one changed nothing. The glow is deleted outright at Kyle's request; the eq bars now scale with a stepped transform whose three delays are multiples of the step interval, so all three jump on the same instants and one present serves them all. Playing steady state: 7.2-10.5% of a core down to 1.4-2.2%. Paused: 0.2%. Scrolling. The animations turned out to be innocent here -- reverting them moved the number not at all. The cost was per-step work: re-decoding the full artwork for the background wash, a main-thread accent scan, a new cover mount, three times a second. The wash is now a 96x58 canvas with the blur/saturate/brightness baked in at draw time and cached per artwork, and an overlay preloader walks the manifest once at open -- nearest to the active card first, capped at 60 -- filling the decoded-image, wash and accent caches ahead of the user. A rapid-nav jump-cut was tried and rejected on sight; the animations stay full at every rate. Backend. preview_resolve swept the clip directory on every call, and any landing outside the +/-1 prefetch window paid a full Deezer mint, download, validate, write and loudness measure. The fetch-store-measure core is factored out and a new preview_prewarm walks the session's track ids at open, paced 250ms on top of the existing Deezer throttle and starting 1.5s in so the first clip owns the network, halted by a generation guard on dispose. A burst landing on a never-visited track is now audible in 154ms off local disk. The sweep is throttled to ten minutes, and dispose deletes prewarmed files instead of orphaning them until the hourly reap. The debug overlay's GPU row was summing the counters machine-wide, so it read roughly double the app's share with Discord and DWM folded in. It now reports `app X% - sys Y%`, the app figure restricted to our process tree, matching Task Manager's per-process rows.
1087 lines
43 KiB
Rust
1087 lines
43 KiB
Rust
//! Preview playback backend — scrollable-track-previews Phase 2.
|
||
//!
|
||
//! A wholly separate audio path from the ordinary player: its own `Player`
|
||
//! instance, its own event channel (`soulstream:preview`), its own bounded
|
||
//! on-disk clip cache. Nothing here touches the queue, history, stream
|
||
//! caches, or the Soulseek resolver — that isolation is the feature's
|
||
//! non-negotiable boundary.
|
||
//!
|
||
//! The resolver contract mirrors `src/lib/preview/resolver.js`: stable track
|
||
//! id in, `{ ok, lease | failure }` out, with the failure classes Phase 0
|
||
//! actually observed. Signed URLs never cross the IPC boundary — the
|
||
//! renderer receives an opaque lease id; bytes live in a temp file the
|
||
//! renderer never sees either.
|
||
|
||
use std::collections::HashMap;
|
||
use std::path::PathBuf;
|
||
use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering};
|
||
use std::sync::{Arc, Mutex};
|
||
use std::time::{Duration, Instant};
|
||
|
||
use serde::Serialize;
|
||
use tauri::{Emitter, Manager, State};
|
||
|
||
use player::{Player, PlayerEvent};
|
||
|
||
use crate::pipeline::LibraryState;
|
||
use crate::playback::PlayerState;
|
||
|
||
/// Position events at ~10/s: the modal's canvas scrubber wants smoother
|
||
/// motion than the main bar's 200 ms cadence, and one small window is cheap.
|
||
const PREVIEW_POSITION_INTERVAL: Duration = Duration::from_millis(100);
|
||
|
||
/// Fade-engine tick. ~15 ms keeps a 250 ms ramp at 16+ steps — audibly
|
||
/// smooth — while each step is one cheap volume command to the sink.
|
||
const FADE_TICK: Duration = Duration::from_millis(15);
|
||
|
||
/// The preview voices' resting volume before the renderer speaks. Since
|
||
/// 2026-07-23 the renderer mirrors the APP volume (mute → 0) via the
|
||
/// `volume` control at session open and live while the modal is up, so
|
||
/// this default only covers the instant before that first set arrives.
|
||
const DEFAULT_TARGET_VOL: f32 = 0.7;
|
||
|
||
/// Lease-lifetime constants, mirroring `src/lib/preview/lease.js` — the two
|
||
/// sides MUST agree or the renderer would trust a lease the backend already
|
||
/// considers dead. Values are ADRs backed by the Phase 0 soak.
|
||
const SAFETY_MARGIN_MS: i64 = 20_000;
|
||
const FALLBACK_SAFE_MS: i64 = 60_000;
|
||
const MAX_SAFE_MS: i64 = 30 * 60_000;
|
||
|
||
/// Clip files older than this are swept on init and periodically from the
|
||
/// resolve path — a preview session never legitimately needs an hour-old clip.
|
||
const CLIP_MAX_AGE: Duration = Duration::from_secs(60 * 60);
|
||
|
||
/// The resolve-path sweep runs at most this often. Sweeping on every resolve
|
||
/// was a directory walk + stat per step of a scrolling session for files
|
||
/// that age out hourly.
|
||
const SWEEP_MIN_INTERVAL: Duration = Duration::from_secs(10 * 60);
|
||
|
||
/// Pace between prewarm downloads. Rides ON TOP of the metadata crate's own
|
||
/// Deezer throttle; this keeps the walker from ever competing with a live
|
||
/// step's resolve for the gate.
|
||
const PREWARM_SPACING: Duration = Duration::from_millis(250);
|
||
|
||
/// The walker starts after the session's opening resolve (and its ±1
|
||
/// neighbour prefetches) have had the network to themselves.
|
||
const PREWARM_INITIAL_DELAY: Duration = Duration::from_millis(1500);
|
||
|
||
/// Hard cap on one prewarm call, mirroring the frontend preloader's cap — a
|
||
/// 500-item manifest warms its head, not the whole tail.
|
||
const PREWARM_CAP: usize = 60;
|
||
|
||
/// Re-resolving a track whose clip bytes are already on disk skips the
|
||
/// network entirely within this window (the bytes are immutable audio — only
|
||
/// the signed URL goes stale, and a local file needs no URL). This is what
|
||
/// makes stepping BACK to a just-played track as fast as a prefetched
|
||
/// forward step. Kept well under CLIP_MAX_AGE so the sweep can never delete
|
||
/// a file this cache would still hand out.
|
||
const CLIP_REUSE_TTL: Duration = Duration::from_secs(15 * 60);
|
||
|
||
/// Two voices, not one: a 250 ms equal-power crossfade between track
|
||
/// previews (user decision 2026-07-23) needs the outgoing and incoming clips
|
||
/// SOUNDING at once, which a single `Player` cannot do. `active` names the
|
||
/// voice whose events reach the UI; the other is either idle or a dying
|
||
/// fade-out tail. Shadow volumes exist because `Player` has no volume
|
||
/// getter and an interrupted fade must hand its partial level to the next
|
||
/// ramp — restarting from the target would audibly bump.
|
||
pub struct PreviewState {
|
||
voices: [Arc<Player>; 2],
|
||
active: Arc<AtomicUsize>,
|
||
/// f32 bits; what fades ramp to and steady playback sits at.
|
||
target_vol: AtomicU32,
|
||
/// f32 bits per voice; the last volume actually commanded.
|
||
shadow_vols: Arc<[AtomicU32; 2]>,
|
||
/// Bumping this orphans every in-flight fade thread (generation guard —
|
||
/// same discipline as the renderer's session reducer).
|
||
fade_gen: Arc<AtomicU64>,
|
||
/// Per-voice counter bumped by the event forwarders on that voice's
|
||
/// Playing events — i.e. a clip actually STARTED SOUNDING there. The
|
||
/// crossfade waits on this before starting its ramps: ramping on the
|
||
/// load command would fade the outgoing into the incoming clip's decode
|
||
/// latency (the mock-derived "ramp on audible, not on play()" trap).
|
||
audible_marks: Arc<[AtomicU64; 2]>,
|
||
/// f32 bits per voice; the loudness-normalization gain (LINEAR) of the
|
||
/// clip currently on that voice. The voice's effective volume is
|
||
/// `target_vol * clip_gain` — the same "gain layered under the user
|
||
/// volume" the main player uses, so Deezer clips match the user's
|
||
/// −14 LUFS library instead of playing at raw master level.
|
||
clip_gains: Arc<[AtomicU32; 2]>,
|
||
/// opaque lease id → (clip file, normalization gain LINEAR). Entries die
|
||
/// with the session (dispose) or the sweep; map is small (active + pre).
|
||
leases: Mutex<HashMap<String, (PathBuf, f32)>>,
|
||
/// track id → (clip file, gain LINEAR, fetched at): the CLIP_REUSE_TTL
|
||
/// byte cache. Several leases may point at one file; dispose clears both.
|
||
recent_clips: Mutex<HashMap<u64, (PathBuf, f32, Instant)>>,
|
||
clip_dir: PathBuf,
|
||
lease_counter: AtomicU64,
|
||
/// Generation guard for the session prewarm walker: bumping it (a new
|
||
/// prewarm call, or dispose) halts any walker still in flight.
|
||
prewarm_gen: Arc<AtomicU64>,
|
||
/// The resolve-path clip sweep runs at most once per SWEEP_MIN_INTERVAL —
|
||
/// it used to walk the clip directory on EVERY resolve.
|
||
last_sweep: Mutex<Instant>,
|
||
}
|
||
|
||
impl PreviewState {
|
||
fn active_idx(&self) -> usize {
|
||
self.active.load(Ordering::Relaxed)
|
||
}
|
||
fn target(&self) -> f32 {
|
||
f32::from_bits(self.target_vol.load(Ordering::Relaxed))
|
||
}
|
||
fn clip_gain(&self, idx: usize) -> f32 {
|
||
f32::from_bits(self.clip_gains[idx].load(Ordering::Relaxed))
|
||
}
|
||
fn set_clip_gain(&self, idx: usize, g: f32) {
|
||
self.clip_gains[idx].store(g.to_bits(), Ordering::Relaxed);
|
||
}
|
||
/// The voice's steady volume: app-volume mirror × the clip's loudness gain.
|
||
fn effective_target(&self, idx: usize) -> f32 {
|
||
self.target() * self.clip_gain(idx)
|
||
}
|
||
fn set_vol(&self, idx: usize, v: f32) {
|
||
self.voices[idx].set_volume(v);
|
||
self.shadow_vols[idx].store(v.to_bits(), Ordering::Relaxed);
|
||
}
|
||
fn shadow(&self, idx: usize) -> f32 {
|
||
f32::from_bits(self.shadow_vols[idx].load(Ordering::Relaxed))
|
||
}
|
||
/// Cancel any running fade and return the new generation.
|
||
fn bump_fades(&self) -> u64 {
|
||
self.fade_gen.fetch_add(1, Ordering::Relaxed) + 1
|
||
}
|
||
}
|
||
|
||
/// The preview voices' current resting volume — diagnostics readout, so the
|
||
/// debug overlay can prove the renderer's app-volume mirror actually lands.
|
||
pub fn target_volume(state: &PreviewState) -> f32 {
|
||
state.target()
|
||
}
|
||
|
||
/// The active clip's loudness-normalization gain in dB (0 = untouched),
|
||
/// for the debug overlay — proves preview normalization is working, the way
|
||
/// the main player's NORM row does.
|
||
pub fn active_norm_db(state: &PreviewState) -> f32 {
|
||
20.0 * state.clip_gain(state.active_idx()).max(1e-6).log10()
|
||
}
|
||
|
||
/// How long a crossfade waits for the incoming voice to become audible
|
||
/// before ramping anyway. Local-file decode start is tens of ms; hitting
|
||
/// this means the clip is broken (its error event fails the item) and the
|
||
/// outgoing should still fade out rather than play forever.
|
||
const AUDIBLE_WAIT_MAX: Duration = Duration::from_millis(2_000);
|
||
|
||
/// One fade thread: equal-power ramp (incoming rides sin, outgoing cos, so
|
||
/// summed acoustic power stays level — a linear fade dips in the middle).
|
||
/// `audible_gate` (marks array, voice, snapshot) holds BOTH ramps until that
|
||
/// voice's mark moves past the snapshot — the incoming clip is genuinely
|
||
/// sounding — so the outgoing keeps playing at level through decode startup
|
||
/// and the cross is a true overlap. Generation-guarded at every tick; at
|
||
/// completion the outgoing voice is STOPPED, not just silent, so a decayed
|
||
/// clip can never linger.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn spawn_fade(
|
||
gen: u64,
|
||
gen_ctr: Arc<AtomicU64>,
|
||
voices: [Arc<Player>; 2],
|
||
shadows: Arc<[AtomicU32; 2]>,
|
||
fade_in: Option<(usize, f32)>,
|
||
fade_out: Option<(usize, f32)>,
|
||
duration_ms: u64,
|
||
audible_gate: Option<(Arc<[AtomicU64; 2]>, usize, u64)>,
|
||
) {
|
||
let set = move |shadows: &[AtomicU32; 2], voices: &[Arc<Player>; 2], idx: usize, v: f32| {
|
||
voices[idx].set_volume(v);
|
||
shadows[idx].store(v.to_bits(), Ordering::Relaxed);
|
||
};
|
||
std::thread::Builder::new()
|
||
.name("preview-fade".into())
|
||
.spawn(move || {
|
||
if let Some((marks, idx, snapshot)) = &audible_gate {
|
||
let wait0 = Instant::now();
|
||
while marks[*idx].load(Ordering::Relaxed) == *snapshot
|
||
&& wait0.elapsed() < AUDIBLE_WAIT_MAX
|
||
{
|
||
if gen_ctr.load(Ordering::Relaxed) != gen {
|
||
return; // a newer command owns the voices now
|
||
}
|
||
std::thread::sleep(FADE_TICK);
|
||
}
|
||
}
|
||
let t0 = Instant::now();
|
||
loop {
|
||
if gen_ctr.load(Ordering::Relaxed) != gen {
|
||
return; // a newer command owns the voices now
|
||
}
|
||
let t = (t0.elapsed().as_millis() as f32 / duration_ms.max(1) as f32).min(1.0);
|
||
let phase = t * std::f32::consts::FRAC_PI_2;
|
||
if let Some((idx, target)) = fade_in {
|
||
set(&shadows, &voices, idx, target * phase.sin());
|
||
}
|
||
if let Some((idx, from)) = fade_out {
|
||
set(&shadows, &voices, idx, from * phase.cos());
|
||
}
|
||
if t >= 1.0 {
|
||
break;
|
||
}
|
||
std::thread::sleep(FADE_TICK);
|
||
}
|
||
if gen_ctr.load(Ordering::Relaxed) != gen {
|
||
return;
|
||
}
|
||
if let Some((idx, _)) = fade_out {
|
||
voices[idx].stop();
|
||
}
|
||
})
|
||
.expect("spawn preview fade");
|
||
}
|
||
|
||
// ------------------------------------------------------------------ init --
|
||
|
||
pub fn init(app: &tauri::AppHandle) {
|
||
// Same configured output device as the main player, so previews follow
|
||
// the user's routing. (Device CHANGES while a preview session is open
|
||
// are part of the Phase 2 focus matrix, handled at the coordinator.)
|
||
let output_device = crate::config::load(app).audio_output_device;
|
||
let (p0, events0) = Player::spawn_with_device(output_device.clone());
|
||
let (p1, events1) = Player::spawn_with_device(output_device);
|
||
|
||
let clip_dir = app
|
||
.path()
|
||
.app_cache_dir()
|
||
.map(|d| d.join("preview-clips"))
|
||
.unwrap_or_else(|_| std::env::temp_dir().join("soulstream-preview-clips"));
|
||
let _ = std::fs::create_dir_all(&clip_dir);
|
||
sweep_old_clips(&clip_dir);
|
||
|
||
let active = Arc::new(AtomicUsize::new(0));
|
||
let audible_marks: Arc<[AtomicU64; 2]> = Arc::new([AtomicU64::new(0), AtomicU64::new(0)]);
|
||
|
||
app.manage(PreviewState {
|
||
voices: [Arc::new(p0), Arc::new(p1)],
|
||
active: active.clone(),
|
||
target_vol: AtomicU32::new(DEFAULT_TARGET_VOL.to_bits()),
|
||
shadow_vols: Arc::new([
|
||
AtomicU32::new(DEFAULT_TARGET_VOL.to_bits()),
|
||
AtomicU32::new(DEFAULT_TARGET_VOL.to_bits()),
|
||
]),
|
||
fade_gen: Arc::new(AtomicU64::new(0)),
|
||
audible_marks: audible_marks.clone(),
|
||
clip_gains: Arc::new([
|
||
AtomicU32::new(1.0f32.to_bits()),
|
||
AtomicU32::new(1.0f32.to_bits()),
|
||
]),
|
||
leases: Mutex::new(HashMap::new()),
|
||
recent_clips: Mutex::new(HashMap::new()),
|
||
clip_dir,
|
||
lease_counter: AtomicU64::new(1),
|
||
prewarm_gen: Arc::new(AtomicU64::new(0)),
|
||
last_sweep: Mutex::new(Instant::now()),
|
||
});
|
||
|
||
// One forwarder per voice. Only the ACTIVE voice's events reach the UI:
|
||
// after a crossfade handover the dying voice keeps decoding for its fade
|
||
// tail, and its Stopped/Ended must not masquerade as the new clip's.
|
||
for (idx, events) in [(0usize, events0), (1usize, events1)] {
|
||
let handle = app.clone();
|
||
let active = active.clone();
|
||
let audible_marks = audible_marks.clone();
|
||
std::thread::Builder::new()
|
||
.name(format!("preview-events-{idx}"))
|
||
.spawn(move || {
|
||
let mut last_position = Instant::now() - PREVIEW_POSITION_INTERVAL;
|
||
for ev in events {
|
||
// Audible-mark bump happens BEFORE the active filter: it
|
||
// is a per-voice fact the crossfade gate must see even if
|
||
// a later command flipped active away. Playing ONLY — a
|
||
// rapid step can land while this voice still runs an
|
||
// orphaned fade tail, whose Position stream would open
|
||
// the gate before the NEW clip sounds; Playing is emitted
|
||
// exactly once per play start, so it names the new clip.
|
||
if matches!(&ev, PlayerEvent::Playing) {
|
||
audible_marks[idx].fetch_add(1, Ordering::Relaxed);
|
||
}
|
||
if active.load(Ordering::Relaxed) != idx {
|
||
continue;
|
||
}
|
||
let ui = match ev {
|
||
PlayerEvent::Buffering => PreviewUiEvent::Buffering,
|
||
PlayerEvent::Playing => PreviewUiEvent::Playing,
|
||
PlayerEvent::Paused => PreviewUiEvent::Paused,
|
||
PlayerEvent::Stopped => PreviewUiEvent::Stopped,
|
||
PlayerEvent::TrackEnded { .. } => PreviewUiEvent::Ended,
|
||
PlayerEvent::Position { secs, duration } => {
|
||
let now = Instant::now();
|
||
if now.duration_since(last_position) < PREVIEW_POSITION_INTERVAL {
|
||
continue;
|
||
}
|
||
last_position = now;
|
||
PreviewUiEvent::Position { secs, duration }
|
||
}
|
||
PlayerEvent::Error(message) => PreviewUiEvent::Error { message },
|
||
};
|
||
let _ = handle.emit("soulstream:preview", ui);
|
||
}
|
||
})
|
||
.expect("spawn preview event forwarder");
|
||
}
|
||
}
|
||
|
||
#[derive(Serialize, Clone)]
|
||
#[serde(tag = "kind", rename_all = "kebab-case")]
|
||
enum PreviewUiEvent {
|
||
Buffering,
|
||
Playing,
|
||
Paused,
|
||
Stopped,
|
||
Ended,
|
||
Position { secs: f64, duration: Option<f64> },
|
||
Error { message: String },
|
||
}
|
||
|
||
fn sweep_old_clips(dir: &std::path::Path) {
|
||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||
return;
|
||
};
|
||
for entry in entries.flatten() {
|
||
let stale = entry
|
||
.metadata()
|
||
.and_then(|m| m.modified())
|
||
.map(|t| t.elapsed().unwrap_or_default() > CLIP_MAX_AGE)
|
||
.unwrap_or(true);
|
||
if stale {
|
||
let _ = std::fs::remove_file(entry.path());
|
||
}
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------- resolver ---
|
||
|
||
#[derive(Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct PreviewLeaseDto {
|
||
pub opaque_lease_id: String,
|
||
pub safe_for_ms: i64,
|
||
pub byte_length: usize,
|
||
pub mime_type: String,
|
||
/// Hint only; the transport reports the decoder's real duration.
|
||
pub clip_duration_ms: i64,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct PreviewResolveResult {
|
||
pub ok: bool,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub lease: Option<PreviewLeaseDto>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub failure: Option<&'static str>,
|
||
pub retryable: bool,
|
||
}
|
||
|
||
fn fail(failure: &'static str, retryable: bool) -> PreviewResolveResult {
|
||
PreviewResolveResult {
|
||
ok: false,
|
||
lease: None,
|
||
failure: Some(failure),
|
||
retryable,
|
||
}
|
||
}
|
||
|
||
/// Resolve a stable Deezer track id into a playable lease. Every call mints
|
||
/// fresh — /track responses carry per-request signed URLs (Phase 0), so a
|
||
/// cached body is a stale lease by construction. The single-retry cap on
|
||
/// failures lives with the caller (renderer), matching the fixture resolver.
|
||
/// Mint a lease over an already-downloaded clip file, if one is fresh enough.
|
||
/// The bytes are immutable; only the signed URL a lease normally rides on can
|
||
/// go stale, and a local file has no URL — so the reuse window is ours to
|
||
/// pick (CLIP_REUSE_TTL). safe_for_ms gets the short fallback: a re-resolve
|
||
/// after it lapses lands right back here and is equally cheap.
|
||
fn mint_from_recent_clip(state: &PreviewState, track_id: u64) -> Option<PreviewResolveResult> {
|
||
let (path, gain, byte_length) = {
|
||
let clips = state.recent_clips.lock().expect("preview clip lock");
|
||
let (path, gain, fetched_at) = clips.get(&track_id)?;
|
||
if fetched_at.elapsed() > CLIP_REUSE_TTL {
|
||
return None;
|
||
}
|
||
let len = std::fs::metadata(path).ok()?.len() as usize;
|
||
(path.clone(), *gain, len)
|
||
};
|
||
let nonce = state.lease_counter.fetch_add(1, Ordering::Relaxed);
|
||
let lease_id = format!("pv-{track_id}-{nonce}");
|
||
state
|
||
.leases
|
||
.lock()
|
||
.expect("preview lease lock")
|
||
.insert(lease_id.clone(), (path, gain));
|
||
Some(PreviewResolveResult {
|
||
ok: true,
|
||
lease: Some(PreviewLeaseDto {
|
||
opaque_lease_id: lease_id,
|
||
safe_for_ms: FALLBACK_SAFE_MS,
|
||
byte_length,
|
||
mime_type: "audio/mpeg".into(),
|
||
clip_duration_ms: 30_000,
|
||
}),
|
||
failure: None,
|
||
retryable: false,
|
||
})
|
||
}
|
||
|
||
/// Loudness-normalization gain (LINEAR) for a freshly written preview clip:
|
||
/// measure it with the same BS.1770 meter the main player uses and bring it
|
||
/// to −14 LUFS, so Deezer's hot masters match the user's normalized library.
|
||
/// 1.0 (no change) when normalization is off or the clip can't be measured.
|
||
fn measure_clip_gain(app: &tauri::AppHandle, path: &std::path::Path) -> f32 {
|
||
if !crate::config::volume_normalization(&crate::config::load(app)) {
|
||
return 1.0;
|
||
}
|
||
match player::analyze_loudness(
|
||
player::AnalysisSource::Local(path.to_path_buf()),
|
||
&|| false,
|
||
&mut |_, _, _| {},
|
||
) {
|
||
player::Analysis::Complete { lufs, peak } => {
|
||
10f64.powf(crate::loudness::normalization_gain_db(lufs, peak) / 20.0) as f32
|
||
}
|
||
// A clip that did not decode all the way through is measured over an
|
||
// unknown fraction of itself; play it as mastered rather than at a
|
||
// gain derived from part of it.
|
||
player::Analysis::Incomplete(_) => 1.0,
|
||
}
|
||
}
|
||
|
||
/// A clip fetched, validated, stored on disk and loudness-measured — the
|
||
/// shared core of the live resolve and the session prewarm. Registers the
|
||
/// clip in `recent_clips`; minting a lease over it is the caller's business.
|
||
struct StoredClip {
|
||
path: PathBuf,
|
||
gain: f32,
|
||
byte_length: usize,
|
||
safe_for_ms: i64,
|
||
}
|
||
|
||
async fn fetch_store_clip(
|
||
app: &tauri::AppHandle,
|
||
library: &LibraryState,
|
||
state: &PreviewState,
|
||
track_id: u64,
|
||
) -> Result<StoredClip, PreviewResolveResult> {
|
||
let Some(body) = library.metadata.deezer_track_uncached(track_id).await else {
|
||
return Err(fail("network", true));
|
||
};
|
||
if let Some(err) = body.get("error") {
|
||
let class = err.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||
return Err(match class {
|
||
// Deezer's 200-with-error-body shapes, from Phase 0.
|
||
"DataException" | "InvalidQueryException" => fail("not_found", false),
|
||
_ => fail("temporarily_failed", true),
|
||
});
|
||
}
|
||
let preview_url = body
|
||
.get("preview")
|
||
.and_then(|p| p.as_str())
|
||
.unwrap_or("")
|
||
.to_string();
|
||
if preview_url.is_empty() {
|
||
return Err(fail("not_provided", false));
|
||
}
|
||
|
||
let media = match metadata::fetch_preview_media(&preview_url).await {
|
||
Ok(m) => m,
|
||
Err(metadata::PreviewFetchError::Status(403)) => return Err(fail("stale_lease", true)),
|
||
Err(metadata::PreviewFetchError::Status(_)) => return Err(fail("temporarily_failed", true)),
|
||
Err(metadata::PreviewFetchError::DisallowedUrl) => return Err(fail("invalid_media", false)),
|
||
Err(metadata::PreviewFetchError::TooLarge) => return Err(fail("invalid_media", false)),
|
||
Err(metadata::PreviewFetchError::Network) => return Err(fail("network", true)),
|
||
};
|
||
|
||
// MIME gate first (the stale-lease body is an HTML error page), then the
|
||
// container transform every Deezer clip needs: skip the universal empty
|
||
// ID3v2.4 shell that crashes the symphonia entry (Phase 0.3), then prove
|
||
// the remainder starts on an MPEG sync before it goes anywhere near a
|
||
// decoder.
|
||
let mime_ok = media
|
||
.content_type
|
||
.as_deref()
|
||
.is_some_and(|m| m.starts_with("audio/mpeg"));
|
||
if !mime_ok {
|
||
return Err(fail("invalid_media", false));
|
||
}
|
||
let audio = strip_id3v2(&media.bytes);
|
||
if !starts_with_mpeg_sync(audio) {
|
||
return Err(fail("invalid_media", false));
|
||
}
|
||
|
||
let nonce = state.lease_counter.fetch_add(1, Ordering::Relaxed);
|
||
let path = state.clip_dir.join(format!("pv-{track_id}-{nonce}.mp3"));
|
||
if std::fs::write(&path, audio).is_err() {
|
||
return Err(fail("temporarily_failed", true));
|
||
}
|
||
// Measure loudness off the async worker (a 30 s clip decodes in tens of
|
||
// ms). On a prefetched or prewarmed clip this is fully off the playback
|
||
// path.
|
||
let gain = {
|
||
let app2 = app.clone();
|
||
let path2 = path.clone();
|
||
tauri::async_runtime::spawn_blocking(move || measure_clip_gain(&app2, &path2))
|
||
.await
|
||
.unwrap_or(1.0)
|
||
};
|
||
let byte_length = audio.len();
|
||
state
|
||
.recent_clips
|
||
.lock()
|
||
.expect("preview clip lock")
|
||
.insert(track_id, (path.clone(), gain, Instant::now()));
|
||
|
||
Ok(StoredClip {
|
||
path,
|
||
gain,
|
||
byte_length,
|
||
safe_for_ms: compute_safe_for_ms(parse_exp_ms(&preview_url), media.date_ms, now_epoch_ms()),
|
||
})
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn preview_resolve(
|
||
app: tauri::AppHandle,
|
||
library: State<'_, LibraryState>,
|
||
state: State<'_, PreviewState>,
|
||
track_id: u64,
|
||
) -> Result<PreviewResolveResult, String> {
|
||
{
|
||
// Throttled sweep: a directory walk per resolve was pure waste in a
|
||
// scrolling session; hour-old files don't need minute-fresh reaping.
|
||
let mut last = state.last_sweep.lock().expect("preview sweep lock");
|
||
if last.elapsed() > SWEEP_MIN_INTERVAL {
|
||
*last = Instant::now();
|
||
sweep_old_clips(&state.clip_dir);
|
||
}
|
||
}
|
||
|
||
if let Some(cached) = mint_from_recent_clip(&state, track_id) {
|
||
return Ok(cached);
|
||
}
|
||
|
||
let stored = match fetch_store_clip(&app, &library, &state, track_id).await {
|
||
Ok(s) => s,
|
||
Err(failure) => return Ok(failure),
|
||
};
|
||
|
||
let nonce = state.lease_counter.fetch_add(1, Ordering::Relaxed);
|
||
let lease_id = format!("pv-{track_id}-{nonce}");
|
||
state
|
||
.leases
|
||
.lock()
|
||
.expect("preview lease lock")
|
||
.insert(lease_id.clone(), (stored.path, stored.gain));
|
||
|
||
Ok(PreviewResolveResult {
|
||
ok: true,
|
||
lease: Some(PreviewLeaseDto {
|
||
opaque_lease_id: lease_id,
|
||
safe_for_ms: stored.safe_for_ms,
|
||
byte_length: stored.byte_length,
|
||
mime_type: "audio/mpeg".into(),
|
||
clip_duration_ms: 30_000,
|
||
}),
|
||
failure: None,
|
||
retryable: false,
|
||
})
|
||
}
|
||
|
||
/// Best-effort session prewarm: walk the manifest's Deezer track ids in
|
||
/// session order and pull every clip onto disk ahead of the user, so a step
|
||
/// landing ANYWHERE in the session resolves from `recent_clips` without
|
||
/// touching the network — the audio-side twin of the overlay's artwork
|
||
/// preloader (Kyle's call 2026-08-01: decode once at open, never during
|
||
/// scrolling). Paced gently (PREWARM_SPACING between clips, on top of the
|
||
/// metadata crate's own Deezer throttle), capped at PREWARM_CAP, and halted
|
||
/// by the generation guard the moment the session closes or a newer session
|
||
/// opens. Failures are skipped silently — the live resolve path still owns
|
||
/// error classification for anything the user actually lands on.
|
||
#[tauri::command]
|
||
pub async fn preview_prewarm(
|
||
app: tauri::AppHandle,
|
||
library: State<'_, LibraryState>,
|
||
state: State<'_, PreviewState>,
|
||
track_ids: Vec<u64>,
|
||
) -> Result<(), String> {
|
||
let my_gen = state.prewarm_gen.fetch_add(1, Ordering::Relaxed) + 1;
|
||
let gen_ctr = state.prewarm_gen.clone();
|
||
let fresh = move || gen_ctr.load(Ordering::Relaxed) == my_gen;
|
||
|
||
tokio::time::sleep(PREWARM_INITIAL_DELAY).await;
|
||
for track_id in track_ids.into_iter().take(PREWARM_CAP) {
|
||
if !fresh() {
|
||
return Ok(());
|
||
}
|
||
let cached = {
|
||
let clips = state.recent_clips.lock().expect("preview clip lock");
|
||
clips
|
||
.get(&track_id)
|
||
.is_some_and(|(_, _, at)| at.elapsed() < CLIP_REUSE_TTL)
|
||
};
|
||
if cached {
|
||
continue;
|
||
}
|
||
let _ = fetch_store_clip(&app, &library, &state, track_id).await;
|
||
if !fresh() {
|
||
return Ok(());
|
||
}
|
||
tokio::time::sleep(PREWARM_SPACING).await;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ------------------------------------------------------------ transport ---
|
||
|
||
/// Load a resolved clip into the preview player. `start_paused` uses the
|
||
/// desired-paused latch (Phase 0.4): the decoder comes up parked, so opening
|
||
/// against a still-negotiating focus transfer can never leak audio.
|
||
///
|
||
/// `crossfade_ms > 0` (and not paused): the clip lands on the IDLE voice,
|
||
/// which becomes active and ramps 0→target while the old voice ramps down
|
||
/// from wherever it is and stops — the equal-power crossfade. `0`/paused is
|
||
/// the original hard swap on the active voice.
|
||
///
|
||
/// `start_at_secs`: the session's resume memory — the decoder comes up
|
||
/// already positioned there (no play-then-seek race).
|
||
#[tauri::command]
|
||
pub fn preview_load(
|
||
state: State<'_, PreviewState>,
|
||
lease_id: String,
|
||
start_paused: bool,
|
||
crossfade_ms: Option<u64>,
|
||
start_at_secs: Option<f64>,
|
||
) -> Result<(), String> {
|
||
let (path, clip_gain) = state
|
||
.leases
|
||
.lock()
|
||
.expect("preview lease lock")
|
||
.get(&lease_id)
|
||
.cloned()
|
||
.ok_or("unknown or expired lease")?;
|
||
let gen = state.bump_fades();
|
||
let xfade = crossfade_ms.unwrap_or(0);
|
||
let cur = state.active_idx();
|
||
|
||
if xfade == 0 || start_paused {
|
||
// Hard swap (open, replay, muted, parked): idle voice must be silent
|
||
// — it may be a fade tail a bumped generation just orphaned.
|
||
state.voices[1 - cur].stop();
|
||
state.set_clip_gain(cur, clip_gain);
|
||
state.set_vol(cur, state.effective_target(cur));
|
||
state.voices[cur].play_local_from(&path, Some(30.0), start_at_secs);
|
||
if start_paused {
|
||
// Ack receipt matters less than the latch being set before any
|
||
// decoder can finish; a timeout here still leaves the latch armed.
|
||
let _ = state.voices[cur]
|
||
.pause_acked()
|
||
.recv_timeout(Duration::from_millis(750));
|
||
}
|
||
return Ok(());
|
||
}
|
||
|
||
let next = 1 - cur;
|
||
let out_from = state.shadow(cur);
|
||
state.set_clip_gain(next, clip_gain);
|
||
state.set_vol(next, 0.0);
|
||
// The incoming voice ramps to ITS clip's normalized level (app volume ×
|
||
// this clip's gain) — so a loud clip crossing into a quiet one lands at
|
||
// the right, matched loudness, not the outgoing clip's.
|
||
let in_target = state.effective_target(next);
|
||
// Snapshot the incoming voice's audible mark BEFORE play so the fade
|
||
// thread can tell "sounding now" from any earlier life of this voice.
|
||
let mark = state.audible_marks[next].load(Ordering::Relaxed);
|
||
// Active flips BEFORE play so the new voice's Buffering/Playing events
|
||
// are the ones the UI sees; the old voice goes dark mid-fade by design.
|
||
state.active.store(next, Ordering::Relaxed);
|
||
state.voices[next].play_local_from(&path, Some(30.0), start_at_secs);
|
||
spawn_fade(
|
||
gen,
|
||
state.fade_gen.clone(),
|
||
state.voices.clone(),
|
||
state.shadow_vols.clone(),
|
||
Some((next, in_target)),
|
||
Some((cur, out_from)),
|
||
xfade,
|
||
Some((state.audible_marks.clone(), next, mark)),
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn preview_control(
|
||
state: State<'_, PreviewState>,
|
||
action: String,
|
||
value: Option<f64>,
|
||
) -> Result<(), String> {
|
||
let cur = state.active_idx();
|
||
match action.as_str() {
|
||
"pause" => {
|
||
// Pause silences EVERYTHING: orphan fades, kill any dying tail,
|
||
// and park the active voice back at its normalized level so a
|
||
// resume is at full (matched) loudness.
|
||
state.bump_fades();
|
||
state.voices[1 - cur].stop();
|
||
state.voices[cur].pause();
|
||
state.set_vol(cur, state.effective_target(cur));
|
||
}
|
||
"resume" => state.voices[cur].resume(),
|
||
"stop" => {
|
||
state.bump_fades();
|
||
state.voices[0].stop();
|
||
state.voices[1].stop();
|
||
state.set_vol(cur, state.effective_target(cur));
|
||
}
|
||
"seek" => state.voices[cur].seek(value.unwrap_or(0.0)),
|
||
"volume" => {
|
||
// App-volume mirror. The clip's normalization gain rides UNDER
|
||
// it (effective = app volume × clip gain), so the level tracks
|
||
// the slider while staying loudness-matched.
|
||
let v = value.unwrap_or(DEFAULT_TARGET_VOL as f64) as f32;
|
||
state.target_vol.store(v.to_bits(), Ordering::Relaxed);
|
||
state.set_vol(cur, state.effective_target(cur));
|
||
}
|
||
// The crossfade's out-half when the incoming clip is not ready yet:
|
||
// ramp the active voice to silence and stop it. The voice STAYS
|
||
// active — its tail events carry a stale generation and die at the
|
||
// renderer's reducer; a later crossfade load flips voices itself.
|
||
"fade_out" => {
|
||
let gen = state.bump_fades();
|
||
state.voices[1 - cur].stop();
|
||
spawn_fade(
|
||
gen,
|
||
state.fade_gen.clone(),
|
||
state.voices.clone(),
|
||
state.shadow_vols.clone(),
|
||
None,
|
||
Some((cur, state.shadow(cur))),
|
||
(value.unwrap_or(250.0) as u64).max(1),
|
||
None,
|
||
);
|
||
}
|
||
other => return Err(format!("unknown preview action {other}")),
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Session teardown: silence the transport and drop every clip file. Safe to
|
||
/// call repeatedly (close, navigation, hot reload all funnel here).
|
||
#[tauri::command]
|
||
pub fn preview_dispose(state: State<'_, PreviewState>) -> Result<(), String> {
|
||
// Halt any in-flight session prewarm walker before touching the caches.
|
||
state.prewarm_gen.fetch_add(1, Ordering::Relaxed);
|
||
state.bump_fades();
|
||
state.voices[0].stop();
|
||
state.voices[1].stop();
|
||
// Reset clip gains with the session — the next session measures afresh.
|
||
state.set_clip_gain(0, 1.0);
|
||
state.set_clip_gain(1, 1.0);
|
||
state.set_vol(0, state.target());
|
||
state.set_vol(1, state.target());
|
||
// Drain BOTH maps and delete the files each references — prewarmed clips
|
||
// exist only in recent_clips (no lease was ever minted over them), so
|
||
// clearing the map without deleting orphaned them until the hourly
|
||
// sweep. Shared paths get two remove attempts; the second is a no-op.
|
||
{
|
||
let mut clips = state.recent_clips.lock().expect("preview clip lock");
|
||
for (_, (path, _gain, _at)) in clips.drain() {
|
||
let _ = std::fs::remove_file(path);
|
||
}
|
||
}
|
||
let mut leases = state.leases.lock().expect("preview lease lock");
|
||
for (_, (path, _gain)) in leases.drain() {
|
||
let _ = std::fs::remove_file(path);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ----------------------------------------------------- main-player ack ----
|
||
|
||
/// Generation guard for MAIN-player fade threads (the session open/close
|
||
/// crossfades). A newer fade command orphans any running one, exactly like
|
||
/// the preview voices' `fade_gen`.
|
||
static MAIN_FADE_GEN: AtomicU64 = AtomicU64::new(0);
|
||
|
||
/// Session-open crossfade, main side: ramp the main player's FADE-GAIN (a
|
||
/// channel separate from the user volume) 1→0 over `fade_ms` (equal-power
|
||
/// cos, so the preview's sin ramp-in sums level), then pause with the
|
||
/// desired-paused latch. Returns as soon as the fade thread is RUNNING — the
|
||
/// caller starts the preview's ramp-in over it; the overlap is the point
|
||
/// (deliberate relaxation of the Phase 0.4 "acknowledged silence before any
|
||
/// preview audio" rule, user decision 2026-07-23).
|
||
///
|
||
/// Fade-gain is used INSTEAD of the user volume precisely so a fade can never
|
||
/// strand the volume: fade-gain resets to 1 on the next Play, and the
|
||
/// close-side fade-resume ramps it back — so even if the ack times out or a
|
||
/// track change suppresses the resume, a later clicked track still plays at
|
||
/// full (the "audio broken after a track click" bug this replaces).
|
||
#[tauri::command]
|
||
pub fn player_fade_pause_acked(app: tauri::AppHandle, fade_ms: u64) -> Result<(), String> {
|
||
let gen = MAIN_FADE_GEN.fetch_add(1, Ordering::Relaxed) + 1;
|
||
std::thread::Builder::new()
|
||
.name("main-fade-out".into())
|
||
.spawn(move || {
|
||
let players = app.state::<PlayerState>();
|
||
let dur = fade_ms.max(1);
|
||
let t0 = Instant::now();
|
||
loop {
|
||
if MAIN_FADE_GEN.load(Ordering::Relaxed) != gen {
|
||
return;
|
||
}
|
||
let t = (t0.elapsed().as_millis() as f32 / dur as f32).min(1.0);
|
||
players
|
||
.player
|
||
.set_fade_gain((t * std::f32::consts::FRAC_PI_2).cos());
|
||
if t >= 1.0 {
|
||
break;
|
||
}
|
||
std::thread::sleep(FADE_TICK);
|
||
}
|
||
if MAIN_FADE_GEN.load(Ordering::Relaxed) != gen {
|
||
return;
|
||
}
|
||
// Pause with the latch. Fade-gain stays 0 while paused (silent),
|
||
// but the user volume is untouched, so nothing is stranded.
|
||
let rx = players.player.pause_acked();
|
||
let _ = rx.recv_timeout(Duration::from_millis(1_500));
|
||
})
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Session-close crossfade, main side: resume the paused main and ramp its
|
||
/// FADE-GAIN 0→1 while the preview's fade-out rings down. Resume clears the
|
||
/// desired-paused latch (player-crate contract); the user volume is never
|
||
/// touched, so the track returns at exactly the level it left.
|
||
#[tauri::command]
|
||
pub fn player_fade_resume(app: tauri::AppHandle, fade_ms: u64) -> Result<(), String> {
|
||
let gen = MAIN_FADE_GEN.fetch_add(1, Ordering::Relaxed) + 1;
|
||
std::thread::Builder::new()
|
||
.name("main-fade-in".into())
|
||
.spawn(move || {
|
||
let players = app.state::<PlayerState>();
|
||
players.player.set_fade_gain(0.0);
|
||
players.player.resume();
|
||
let dur = fade_ms.max(1);
|
||
let t0 = Instant::now();
|
||
loop {
|
||
if MAIN_FADE_GEN.load(Ordering::Relaxed) != gen {
|
||
return;
|
||
}
|
||
let t = (t0.elapsed().as_millis() as f32 / dur as f32).min(1.0);
|
||
players
|
||
.player
|
||
.set_fade_gain((t * std::f32::consts::FRAC_PI_2).sin());
|
||
if t >= 1.0 {
|
||
break;
|
||
}
|
||
std::thread::sleep(FADE_TICK);
|
||
}
|
||
// Land exactly at unity so a stale tick can't leave it short.
|
||
if MAIN_FADE_GEN.load(Ordering::Relaxed) == gen {
|
||
players.player.set_fade_gain(1.0);
|
||
}
|
||
})
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(())
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct PauseAckDto {
|
||
pub generation: u64,
|
||
pub sink_was_active: bool,
|
||
pub latched: bool,
|
||
}
|
||
|
||
/// The focus coordinator's silence request against the MAIN player: pause
|
||
/// with a generation-bound acknowledgement (Phase 0.4 primitive). A timeout
|
||
/// is an error — the caller keeps preview parked; it never assumes silence.
|
||
#[tauri::command]
|
||
pub async fn player_pause_acked(players: State<'_, PlayerState>) -> Result<PauseAckDto, String> {
|
||
let rx = players.player.pause_acked();
|
||
tauri::async_runtime::spawn_blocking(move || {
|
||
rx.recv_timeout(Duration::from_millis(1_500))
|
||
.map(|ack| PauseAckDto {
|
||
generation: ack.generation,
|
||
sink_was_active: ack.sink_was_active,
|
||
latched: ack.latched,
|
||
})
|
||
.map_err(|_| "silence acknowledgement timed out".to_string())
|
||
})
|
||
.await
|
||
.map_err(|e| e.to_string())?
|
||
}
|
||
|
||
// ------------------------------------------------------- pure helpers -----
|
||
|
||
fn now_epoch_ms() -> i64 {
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_millis() as i64)
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
/// `exp=<epoch-seconds>` out of an Akamai `hdnea` token (or bare query).
|
||
fn parse_exp_ms(url: &str) -> Option<i64> {
|
||
let idx = url.find("exp=")?;
|
||
let digits: String = url[idx + 4..]
|
||
.chars()
|
||
.take_while(|c| c.is_ascii_digit())
|
||
.collect();
|
||
if !(9..=11).contains(&digits.len()) {
|
||
return None;
|
||
}
|
||
digits.parse::<i64>().ok().map(|s| s * 1000)
|
||
}
|
||
|
||
/// Mirror of `computeSafeForMs` in src/lib/preview/lease.js — see that file
|
||
/// for the reasoning; the two implementations are kept behaviorally
|
||
/// identical and both covered by tests.
|
||
fn compute_safe_for_ms(exp_ms: Option<i64>, date_ms: Option<i64>, local_now_ms: i64) -> i64 {
|
||
let Some(exp) = exp_ms else {
|
||
return FALLBACK_SAFE_MS;
|
||
};
|
||
if exp <= 0 {
|
||
return FALLBACK_SAFE_MS;
|
||
}
|
||
let mut remaining: Option<i64> = None;
|
||
if let Some(date) = date_ms {
|
||
if date > 0 {
|
||
let r = exp - date;
|
||
if r <= 0 || r > 2 * MAX_SAFE_MS {
|
||
return FALLBACK_SAFE_MS;
|
||
}
|
||
remaining = Some(r);
|
||
}
|
||
}
|
||
let local_view = exp - local_now_ms;
|
||
if local_view <= 0 && remaining.is_none() {
|
||
return FALLBACK_SAFE_MS;
|
||
}
|
||
let usable = match remaining {
|
||
None => local_view,
|
||
Some(r) => {
|
||
if local_view > 0 {
|
||
r.min(local_view)
|
||
} else {
|
||
r
|
||
}
|
||
}
|
||
};
|
||
(usable.min(MAX_SAFE_MS) - SAFETY_MARGIN_MS).max(0)
|
||
}
|
||
|
||
/// Skip a leading ID3v2 tag. Every Phase 0 sample carries a 10-byte v2.4
|
||
/// shell with tag size 0, but the parser handles real non-zero tags too —
|
||
/// the syncsafe size field is authoritative.
|
||
fn strip_id3v2(bytes: &[u8]) -> &[u8] {
|
||
if bytes.len() < 10 || &bytes[0..3] != b"ID3" {
|
||
return bytes;
|
||
}
|
||
let size = ((bytes[6] & 0x7f) as usize) << 21
|
||
| ((bytes[7] & 0x7f) as usize) << 14
|
||
| ((bytes[8] & 0x7f) as usize) << 7
|
||
| (bytes[9] & 0x7f) as usize;
|
||
let end = 10usize.saturating_add(size);
|
||
if end >= bytes.len() {
|
||
return bytes; // tag claims to swallow the file: leave it alone
|
||
}
|
||
&bytes[end..]
|
||
}
|
||
|
||
fn starts_with_mpeg_sync(bytes: &[u8]) -> bool {
|
||
bytes.len() >= 2 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0
|
||
}
|
||
|
||
// ------------------------------------------------------------------ tests -
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
const MIN: i64 = 60_000;
|
||
const NOW: i64 = 1_784_760_000_000;
|
||
|
||
#[test]
|
||
fn safe_for_ms_valid_uses_provider_clock() {
|
||
assert_eq!(
|
||
compute_safe_for_ms(Some(NOW + 14 * MIN), Some(NOW), NOW),
|
||
14 * MIN - SAFETY_MARGIN_MS
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn safe_for_ms_local_clock_ahead_shrinks() {
|
||
assert_eq!(
|
||
compute_safe_for_ms(Some(NOW + 14 * MIN), Some(NOW), NOW + 10 * MIN),
|
||
4 * MIN - SAFETY_MARGIN_MS
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn safe_for_ms_local_clock_behind_cannot_extend() {
|
||
assert_eq!(
|
||
compute_safe_for_ms(Some(NOW + 14 * MIN), Some(NOW), NOW - 60 * MIN),
|
||
14 * MIN - SAFETY_MARGIN_MS
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn safe_for_ms_missing_or_contradictory_falls_back() {
|
||
assert_eq!(compute_safe_for_ms(None, Some(NOW), NOW), FALLBACK_SAFE_MS);
|
||
assert_eq!(
|
||
compute_safe_for_ms(Some(NOW - MIN), Some(NOW), NOW),
|
||
FALLBACK_SAFE_MS
|
||
);
|
||
assert_eq!(
|
||
compute_safe_for_ms(Some(NOW + 6 * 60 * MIN), Some(NOW), NOW),
|
||
FALLBACK_SAFE_MS
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn exp_parses_from_hdnea_token() {
|
||
let url = "https://cdnt-preview.dzcdn.net/api/x.mp3?hdnea=exp=1784761537~acl=/x*~hmac=aa";
|
||
assert_eq!(parse_exp_ms(url), Some(1_784_761_537_000));
|
||
assert_eq!(parse_exp_ms("https://h/x"), None);
|
||
assert_eq!(parse_exp_ms("https://h/x?exp=12"), None); // too short
|
||
}
|
||
|
||
#[test]
|
||
fn id3_empty_shell_is_skipped() {
|
||
// The exact Phase 0 shape: v2.4 header, flags 0, size 0, then sync.
|
||
let mut f = b"ID3\x04\x00\x00\x00\x00\x00\x00".to_vec();
|
||
f.extend_from_slice(&[0xFF, 0xFB, 0x92, 0x64, 1, 2, 3]);
|
||
let out = strip_id3v2(&f);
|
||
assert!(starts_with_mpeg_sync(out));
|
||
assert_eq!(out.len(), 7);
|
||
}
|
||
|
||
#[test]
|
||
fn id3_nonzero_tag_is_skipped_by_syncsafe_size() {
|
||
let mut f = b"ID3\x03\x00\x00\x00\x00\x00\x0A".to_vec(); // size 10
|
||
f.extend_from_slice(&[0u8; 10]); // tag body
|
||
f.extend_from_slice(&[0xFF, 0xFB, 0x00, 0x00]);
|
||
assert!(starts_with_mpeg_sync(strip_id3v2(&f)));
|
||
}
|
||
|
||
#[test]
|
||
fn id3_overlong_tag_leaves_bytes_untouched() {
|
||
let f = b"ID3\x04\x00\x00\x7f\x7f\x7f\x7f rest".to_vec();
|
||
assert_eq!(strip_id3v2(&f), &f[..]);
|
||
}
|
||
|
||
#[test]
|
||
fn plain_frames_pass_through() {
|
||
let f = [0xFF, 0xFB, 0x92, 0x64];
|
||
assert_eq!(strip_id3v2(&f), &f[..]);
|
||
assert!(starts_with_mpeg_sync(&f));
|
||
assert!(!starts_with_mpeg_sync(b"<HTML>"));
|
||
}
|
||
}
|