//! 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. The player crate's own default; the /// renderer never sets a preview volume today, so fades ramp to this unless /// a `volume` control changes the target. 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 on each resolve — a /// preview session never legitimately needs an hour-old clip. const CLIP_MAX_AGE: Duration = Duration::from_secs(60 * 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; 2], active: Arc, /// 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, /// 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]>, /// opaque lease id → clip file. Entries die with the session (dispose) /// or the sweep; the map is small by construction (active + prefetch). leases: Mutex>, /// track id → (clip file, fetched at): the CLIP_REUSE_TTL byte cache. /// Several leases may point at one file; dispose clears both maps. recent_clips: Mutex>, clip_dir: PathBuf, lease_counter: AtomicU64, } 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 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 } } /// 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, voices: [Arc; 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; 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(), leases: Mutex::new(HashMap::new()), recent_clips: Mutex::new(HashMap::new()), clip_dir, lease_counter: AtomicU64::new(1), }); // 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 }, 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, #[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 { let (path, byte_length) = { let clips = state.recent_clips.lock().expect("preview clip lock"); let (path, 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(), 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); 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, }) } #[tauri::command] pub async fn preview_resolve( library: State<'_, LibraryState>, state: State<'_, PreviewState>, track_id: u64, ) -> Result { sweep_old_clips(&state.clip_dir); if let Some(cached) = mint_from_recent_clip(&state, track_id) { return Ok(cached); } let Some(body) = library.metadata.deezer_track_uncached(track_id).await else { return Ok(fail("network", true)); }; if let Some(err) = body.get("error") { let class = err.get("type").and_then(|t| t.as_str()).unwrap_or(""); return Ok(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 Ok(fail("not_provided", false)); } let media = match metadata::fetch_preview_media(&preview_url).await { Ok(m) => m, Err(metadata::PreviewFetchError::Status(403)) => return Ok(fail("stale_lease", true)), Err(metadata::PreviewFetchError::Status(_)) => return Ok(fail("temporarily_failed", true)), Err(metadata::PreviewFetchError::DisallowedUrl) => return Ok(fail("invalid_media", false)), Err(metadata::PreviewFetchError::TooLarge) => return Ok(fail("invalid_media", false)), Err(metadata::PreviewFetchError::Network) => return Ok(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 Ok(fail("invalid_media", false)); } let audio = strip_id3v2(&media.bytes); if !starts_with_mpeg_sync(audio) { return Ok(fail("invalid_media", false)); } let nonce = state.lease_counter.fetch_add(1, Ordering::Relaxed); let lease_id = format!("pv-{track_id}-{nonce}"); let path = state.clip_dir.join(format!("{lease_id}.mp3")); if std::fs::write(&path, audio).is_err() { return Ok(fail("temporarily_failed", true)); } state .leases .lock() .expect("preview lease lock") .insert(lease_id.clone(), path.clone()); state .recent_clips .lock() .expect("preview clip lock") .insert(track_id, (path, Instant::now())); let safe_for_ms = compute_safe_for_ms(parse_exp_ms(&preview_url), media.date_ms, now_epoch_ms()); Ok(PreviewResolveResult { ok: true, lease: Some(PreviewLeaseDto { opaque_lease_id: lease_id, safe_for_ms, byte_length: audio.len(), mime_type: "audio/mpeg".into(), clip_duration_ms: 30_000, }), failure: None, retryable: false, }) } // ------------------------------------------------------------ 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, start_at_secs: Option, ) -> Result<(), String> { let path = 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(); let target = state.target(); 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_vol(cur, target); 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_vol(next, 0.0); // 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, 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, ) -> 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 target so resume is full. state.bump_fades(); state.voices[1 - cur].stop(); state.voices[cur].pause(); state.set_vol(cur, state.target()); } "resume" => state.voices[cur].resume(), "stop" => { state.bump_fades(); state.voices[0].stop(); state.voices[1].stop(); state.set_vol(cur, state.target()); } "seek" => state.voices[cur].seek(value.unwrap_or(0.0)), "volume" => { 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, v); } // 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> { state.bump_fades(); state.voices[0].stop(); state.voices[1].stop(); state.set_vol(0, state.target()); state.set_vol(1, state.target()); state .recent_clips .lock() .expect("preview clip lock") .clear(); let mut leases = state.leases.lock().expect("preview lease lock"); for (_, path) 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 to silence over /// `fade_ms` (equal-power cos, so the preview's sin ramp-in sums level), /// then pause it WITH the desired-paused latch, then restore `volume` on the /// now-silent sink so the later resume has its level back. 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). If the ack never lands, the volume stays at 0 — silent is /// the safe failure — and the close-side fade-resume self-heals it. #[tauri::command] pub fn player_fade_pause_acked( app: tauri::AppHandle, fade_ms: u64, volume: f64, ) -> 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::(); let v = volume as f32; 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_volume(v * (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; } let rx = players.player.pause_acked(); match rx.recv_timeout(Duration::from_millis(1_500)) { Ok(_) => players.player.set_volume(v), Err(_) => eprintln!("preview: main fade-pause ack timed out; volume left silent"), } }) .map_err(|e| e.to_string())?; Ok(()) } /// Session-close crossfade, main side: come back from the paused snapshot at /// volume 0 and ramp to `volume` while the preview's fade-out rings down. /// Resume also clears the desired-paused latch (player-crate contract). #[tauri::command] pub fn player_fade_resume(app: tauri::AppHandle, fade_ms: u64, volume: f64) -> 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::(); let v = volume as f32; players.player.set_volume(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_volume(v * (t * std::f32::consts::FRAC_PI_2).sin()); if t >= 1.0 { break; } std::thread::sleep(FADE_TICK); } }) .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 { 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=` out of an Akamai `hdnea` token (or bare query). fn parse_exp_ms(url: &str) -> Option { 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::().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, date_ms: Option, 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 = 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"")); } }