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:
1 parent
8ede6a8adf
commit
8cb3cd593b
11 files changed
+1536
No files matched your search
@@ -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>"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user