forked from Keelrleek/SoulStream
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
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user