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

+183
View File
@@ -918,6 +918,49 @@ impl MetadataClient {
self.cached_json(url, false).await
}
/// Fetch one Deezer track FRESH, bypassing the catalog cache entirely.
///
/// This exists for preview-lease minting and must not be used for page
/// data: every `/track/{id}` response mints a new signed preview URL that
/// expires in ~13–15 minutes (Phase 0), so a cached body is a stale lease
/// by construction. Pacing and the compliant UA still apply; two attempts,
/// then the caller classifies the failure. The generic cache is also
/// UPDATED with the fresh body so later page reads can't resurrect an
/// older, already-expired preview URL for this track.
pub async fn deezer_track_uncached(&self, id: u64) -> Option<serde_json::Value> {
let url = format!("https://api.deezer.com/track/{id}");
for attempt in 0..2u32 {
self.deezer_throttle().await;
let resp = self.http.get(&url).send().await;
match resp {
Ok(r) if r.status().is_success() => {
let Ok(body) = r.json::<serde_json::Value>().await else {
return None;
};
// Deezer signals quota inside a 200 body; honor the same
// retry the cached path uses before giving up.
let quota = body
.pointer("/error/code")
.and_then(|c| c.as_u64())
.is_some_and(|c| c == 4);
if quota && attempt == 0 {
continue;
}
let mut cache = self.catalog_cache.lock().await;
cache.insert(url.clone(), (Instant::now(), body.clone()));
return Some(body);
}
_ => {
if attempt == 0 {
continue;
}
return None;
}
}
}
None
}
/// GET a MusicBrainz endpoint through the same cache/single-flight layer.
/// The one-request-per-second gate belongs inside the cache-miss
/// initializer: putting it before the cache lookup made two completely warm
@@ -6235,6 +6278,146 @@ struct CoverCandidate {
url: String,
}
// --------------------------------------------------------------------------
// Preview media fetch — the bounded, allowlisted transfer behind the preview
// resolver (scrollable-track-previews Phase 2). Kept separate from the shared
// catalog client because it needs a CUSTOM REDIRECT POLICY: every hop is
// re-validated against the media allowlist, where the shared client follows
// redirects freely (fine for JSON APIs, not for media bytes we will decode).
// --------------------------------------------------------------------------
/// Hosts preview media may be fetched from. HTTPS-only, exact match — one
/// stable CDN host served 71/71 Phase 0 samples. Mirrors the frontend
/// allowlist in `src/lib/preview/redact.js`; a new provider host is a
/// reviewed change in both places.
pub const PREVIEW_MEDIA_HOSTS: [&str; 1] = ["cdnt-preview.dzcdn.net"];
/// Hard cap on a preview transfer. Real clips are ~480 KB (Phase 0, zero
/// variance); 2 MB tolerates provider drift while still refusing anything
/// that plainly is not a 30-second clip.
pub const PREVIEW_MEDIA_MAX_BYTES: usize = 2_000_000;
pub struct PreviewMediaResponse {
pub bytes: Vec<u8>,
pub content_type: Option<String>,
/// Provider `Date` header as epoch ms, for provider-clock lease math.
pub date_ms: Option<i64>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum PreviewFetchError {
/// Initial URL or a redirect target fell outside the https+allowlist gate.
DisallowedUrl,
/// The CDN answered with a non-success status (403 = stale lease).
Status(u16),
/// Body exceeded [`PREVIEW_MEDIA_MAX_BYTES`].
TooLarge,
/// Transport-level failure (timeout, DNS, connection).
Network,
}
pub fn preview_url_allowed(url: &str) -> bool {
let Ok(u) = reqwest::Url::parse(url) else {
return false;
};
u.scheme() == "https"
&& u.host_str()
.is_some_and(|h| PREVIEW_MEDIA_HOSTS.contains(&h))
}
fn preview_media_client() -> &'static reqwest::Client {
use std::sync::OnceLock;
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(Duration::from_secs(15))
.redirect(reqwest::redirect::Policy::custom(|attempt| {
// Every hop passes the same gate as the initial URL; a
// redirect off the allowlist is a hard stop, never a follow.
let ok = attempt.url().scheme() == "https"
&& attempt
.url()
.host_str()
.is_some_and(|h| PREVIEW_MEDIA_HOSTS.contains(&h));
if !ok {
return attempt.error("redirect target off the preview media allowlist");
}
if attempt.previous().len() > 4 {
return attempt.error("too many redirects");
}
attempt.follow()
}))
.build()
.expect("build preview media client")
})
}
/// Fetch preview bytes with the full host/response policy applied. Callers
/// never see the URL again after this returns — bytes, MIME and provider
/// time are all a resolver needs.
pub async fn fetch_preview_media(url: &str) -> Result<PreviewMediaResponse, PreviewFetchError> {
if !preview_url_allowed(url) {
return Err(PreviewFetchError::DisallowedUrl);
}
let resp = preview_media_client()
.get(url)
.send()
.await
.map_err(|e| {
// A redirect rejected by the policy surfaces as a generic
// reqwest error; classify it as the policy violation it is.
if e.is_redirect() {
PreviewFetchError::DisallowedUrl
} else {
PreviewFetchError::Network
}
})?;
let status = resp.status();
if !status.is_success() {
return Err(PreviewFetchError::Status(status.as_u16()));
}
if let Some(len) = resp.content_length() {
if len as usize > PREVIEW_MEDIA_MAX_BYTES {
return Err(PreviewFetchError::TooLarge);
}
}
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(String::from);
let date_ms = resp
.headers()
.get(reqwest::header::DATE)
.and_then(|v| v.to_str().ok())
.and_then(|s| httpdate::parse_http_date(s).ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64);
// Stream with the cap enforced even when Content-Length lied or was
// absent — the count is on received bytes, not the header's promise.
let mut bytes = Vec::with_capacity(512 * 1024);
let mut stream = resp;
loop {
match stream.chunk().await {
Ok(Some(chunk)) => {
if bytes.len() + chunk.len() > PREVIEW_MEDIA_MAX_BYTES {
return Err(PreviewFetchError::TooLarge);
}
bytes.extend_from_slice(&chunk);
}
Ok(None) => break,
Err(_) => return Err(PreviewFetchError::Network),
}
}
Ok(PreviewMediaResponse {
bytes,
content_type,
date_ms,
})
}
/// Decode candidate bytes and keep only plausible front covers: square-ish
/// (grids/banners/booklet scans are not covers) and at least 300px.
fn decode_cover(