Phase 2A+2B: native preview backend and renderer glue

Rust (2A):
- metadata: deezer_track_uncached (fresh mint, cache-updating, paced) and
  fetch_preview_media — dedicated client whose custom redirect policy
  re-validates every hop against the media allowlist; bounded body read
  (cap enforced on received bytes, not Content-Length's promise); Date
  header captured for provider-clock lease math. httpdate dep added.
- src-tauri/preview.rs: second Player instance + soulstream:preview event
  channel; preview_resolve (fresh mint → host gate → MIME gate → ID3v2
  shell strip → MPEG sync check → clip file + opaque lease id → safe_for_ms
  mirroring lease.js); preview_load with start_paused via the
  desired-paused latch; preview_control/dispose; player_pause_acked
  exposing the Phase 0.4 ack on the MAIN player. 9 unit tests.

JS (2B):
- api.js: preview command surface (opaque lease ids only).
- nativeTransport.js: stamps {sessionId, generation, sessionKey} onto
  backend events; cleared context makes stragglers dispatch nothing.
- productionResolver.js: DTO → MediaLease, failure classes preserved.
- focusBridge.js: production coordinator binding with the documented
  renderer-side generation invariant (buffering counter + gateway).
- service.js: the orchestrator — settle window, resolve→load→play under
  focus, single capped retry, one forward lease prefetch with safe-window
  expiry, stale re-checks before every transport command, close/handoff.
  The service constructs its transport around its own dispatch — the
  factory signature exists because a pre-built transport silently loses
  the event wiring (caught by test).

70 JS tests passing (npm test); metadata 46 + app preview 9 in cargo.
This commit is contained in:
KeelrLeek committed 2026-07-22 18:45:29 -05:00
1 parent 8ede6a8adf
commit 8cb3cd593b
11 files changed
+1536

No files matched your search

+510
View File
@@ -0,0 +1,510 @@
//! 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::{AtomicU64, Ordering};
use std::sync::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);
/// 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);
pub struct PreviewState {
pub player: Player,
/// opaque lease id → clip file. Entries die with the session (dispose)
/// or the sweep; the map is small by construction (active + prefetch).
leases: Mutex<HashMap<String, PathBuf>>,
clip_dir: PathBuf,
lease_counter: AtomicU64,
}
// ------------------------------------------------------------------ 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 (player, events) = 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);
app.manage(PreviewState {
player,
leases: Mutex::new(HashMap::new()),
clip_dir,
lease_counter: AtomicU64::new(1),
});
let handle = app.clone();
std::thread::Builder::new()
.name("preview-events".into())
.spawn(move || {
let mut last_position = Instant::now() - PREVIEW_POSITION_INTERVAL;
for ev in events {
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.
#[tauri::command]
pub async fn preview_resolve(
library: State<'_, LibraryState>,
state: State<'_, PreviewState>,
track_id: u64,
) -> Result<PreviewResolveResult, String> {
sweep_old_clips(&state.clip_dir);
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);
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.
#[tauri::command]
pub fn preview_load(
state: State<'_, PreviewState>,
lease_id: String,
start_paused: bool,
) -> Result<(), String> {
let path = state
.leases
.lock()
.expect("preview lease lock")
.get(&lease_id)
.cloned()
.ok_or("unknown or expired lease")?;
state.player.play_local(&path, Some(30.0));
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
.player
.pause_acked()
.recv_timeout(Duration::from_millis(750));
}
Ok(())
}
#[tauri::command]
pub fn preview_control(
state: State<'_, PreviewState>,
action: String,
value: Option<f64>,
) -> Result<(), String> {
match action.as_str() {
"pause" => state.player.pause(),
"resume" => state.player.resume(),
"stop" => state.player.stop(),
"seek" => state.player.seek(value.unwrap_or(0.0)),
"volume" => state.player.set_volume(value.unwrap_or(0.7) as f32),
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.player.stop();
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 ----
#[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>"));
}
}