Files
JRay-public-server/tests/audio.rs
T
dtourolle c41253ef5c feat(audio): store, serve and match the v1 audio signature
Completes UR-009. The register recorded the signature as stored; it was
not. `ingest` validated `cut.audio_signature` and wrote NULL, so every
served manifest came back without one — which also meant the plugin's own
alignment (jRay JR-047, Done) had nothing to align against and could never
run. That is the failure mode a status field is least able to catch: every
validation test passed and the feature delivered nothing.

Now stored, coarse-indexed and served back byte-identically, with a held
manifest adopting an incoming signature it lacked (§9a). `audio`-tier
matching runs on every read endpoint via an `audio_signature` parameter,
and `POST /manifests/search` answers the unknown-providence case with a
runtime prefilter, a bounded scan and honest truncation reporting.

Three rules §3 did not previously state, now normative:

- **±1 frame of slack in the score.** scene-actor-extraction VR-014
  measured the exact-frame rule demoting 27 of 40 correctly aligned
  releases to `loose`, because the two windows are cut on their own
  file's frame grid and those grids do not coincide. With ±1 frame all
  40 reach `audio` (worst 0.906) and the strongest false match is
  unmoved at 0.16.
- **The offset has two terms.** Both windows are anchored at their own
  file's runtime/2, so the slide alone is wrong by half the runtime
  difference on every shifted release. A signature without a runtime
  therefore cannot align, and is refused by name rather than answered
  at a lower tier.
- **A signature verdict is final**, including its refusals. Falling back
  to the runtime tier after the audio declined would let a coincidence
  overturn direct evidence, inverting the ordering the tier table exists
  to state.

The slide precomputes each frame's neighbourhood as a 32-bit bin set
rather than re-deriving it across 1201 slides — 3.3 ms to 1.1 ms per
candidate, with a test asserting exact equivalence to the rule written
the obvious way. The 1000-candidate search cap follows from that
measurement as a ~1.1 s ceiling per request, not a round number.

jRay's matcher still implements the pre-slack rule and will label some
alignments `loose` that this server calls `audio`. Nothing misaligns —
JR-047 makes the local answer win — but that register now carries the
follow-up.

TRACES: UR-008, UR-009 | SR-003
2026-07-31 22:43:26 +02:00

622 lines
24 KiB
Rust

//! UR-009 end to end — the audio signature through the whole server.
//!
//! The unit tests own the slide's arithmetic and the validator owns the
//! signature's shape. What only holds if the layers are composed correctly is
//! everything here: that a contributed signature survives storage and comes back
//! byte-identically, that the `audio` tier actually reaches the read endpoints,
//! and that a caller with no title identity at all can still find its film.
//!
//! **The round trip is the load-bearing one.** The signature exists so a client
//! can align a manifest against its own copy (`jRay` JR-047), and the client can
//! only do that if the manifest it fetches carries one. A server that validates
//! a signature and then drops it passes every validation test ever written and
//! delivers nothing.
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use jray_server::db::{repo, Db};
use jray_server::ratelimit::RateLimiter;
use jray_server::state::AppState;
use jray_server::tmdb::TmdbClient;
use jray_server::{app, audio_sig, config::Config, worker};
use serde_json::{json, Value};
use tower::ServiceExt;
// ---------------------------------------------------------------------------
// Signatures under test
// ---------------------------------------------------------------------------
/// A real signature's frame count: a 120 s window at a 1024-sample hop (§3).
const FRAMES: usize = 1288;
/// A peak-bin sequence with the statistics film audio produces — no long runs,
/// no short period — packed into a wire signature.
///
/// Not the golden vector, which pins the *format* and is a tone staircase; an
/// alignment search over it is ambiguous in ways no film is. The offset study
/// that does use real audio is `scene-actor-extraction` VR-014.
fn signature(seed: u64, skip_frames: usize) -> String {
let mut x = seed;
let bytes: Vec<u8> = (0..FRAMES + skip_frames)
.map(|_| {
x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
// A whole byte: 5-bit band and 2-bit class, high bit clear.
(((x >> 33) % 32) as u8) << 2 | ((x >> 29) % 4) as u8
})
.skip(skip_frames)
.collect();
audio_sig::encode(&bytes)
}
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let mut p = std::env::temp_dir();
p.push(format!("jray-audio-{}-{}", tag, unique()));
std::fs::create_dir_all(&p).expect("creating temp dir");
Self(p)
}
fn db_path(&self) -> String {
self.0.join("test.db").to_string_lossy().into_owned()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn unique() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{t}-{}", N.fetch_add(1, Ordering::Relaxed))
}
struct TestServer {
router: axum::Router,
db: Db,
_dir: TempDir,
}
impl TestServer {
fn new(tag: &str) -> Self {
Self::with_search(tag, true)
}
fn with_search(tag: &str, enable_audio_search: bool) -> Self {
let dir = TempDir::new(tag);
let db = Db::open(&dir.db_path()).expect("opening database");
let config = Arc::new(Config {
bind: "127.0.0.1:0".into(),
db_path: dir.db_path(),
// Unconfigured, so uploads stay `pending` and no test reaches the
// network. Anything that must be *served* is seeded as listed.
tmdb_api_key: None,
tmdb_base_url: "http://127.0.0.1:1".into(),
trusted_proxies: Vec::new(),
server_id: "audio.example".into(),
publish_peer_directory: false,
contact: None,
enable_audio_search,
request_timeout: std::time::Duration::from_secs(30),
job_batch: 8,
job_poll_interval: std::time::Duration::from_secs(3600),
});
let state = AppState {
db: db.clone(),
config: config.clone(),
limiter: Arc::new(RateLimiter::new()),
tmdb: Arc::new(TmdbClient::new(config.tmdb_base_url.clone(), None)),
};
Self { router: app::router(state), db, _dir: dir }
}
async fn send(&self, req: Request<Body>) -> (StatusCode, Value) {
let resp = self.router.clone().oneshot(req).await.expect("router call");
let status = resp.status();
let bytes = resp.into_body().collect().await.expect("body").to_bytes();
let body = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes)
.unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into_owned()))
};
(status, body)
}
async fn get(&self, uri: &str) -> (StatusCode, Value) {
self.send(Request::builder().uri(uri).body(Body::empty()).unwrap()).await
}
async fn post(&self, uri: &str, body: &Value) -> (StatusCode, Value) {
self.send(
Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
}
async fn post_auth(&self, uri: &str, token: &str, body: &Value) -> (StatusCode, Value) {
self.send(
Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
}
async fn token(&self) -> String {
let (status, body) = self.post("/api/v1/tokens", &json!({})).await;
assert_eq!(status, StatusCode::OK, "token issue failed: {body}");
body["token"].as_str().expect("token").to_string()
}
/// Seeds a listed manifest, since an upload stays `pending` without TMDB and
/// `pending` is never served (§6, §7).
async fn seed_listed(
&self,
tmdb_id: &str,
title: &str,
runtime_sec: f64,
signature: Option<&str>,
) -> String {
let tmdb_id = tmdb_id.to_string();
let title = title.to_string();
let packed = signature.and_then(audio_sig::decode);
self.db
.write(move |tx| {
let now = worker::now_iso();
let title_id = repo::upsert_title(
tx,
jray_server::model::IdentityType::Movie,
Some(&tmdb_id),
None,
Some(&title),
Some(1952),
&now,
)?;
let id = format!("m-{tmdb_id}");
let coarse = packed.as_deref().map(audio_sig::coarse_key);
repo::insert_manifest(
tx,
&repo::NewManifest {
id: &id,
title_id: &title_id,
season: None,
episode: None,
runtime_sec,
audio_signature: packed.as_deref(),
audio_sig_coarse: coarse.as_deref(),
sample_fps: Some(5.0),
extinction_sec: Some(12.0),
pipeline_version: Some("test 0.1"),
gallery_scope: Some("global"),
contributor_id: None,
status: "listed",
content_id: Some(&format!("cid-{tmdb_id}")),
origin: "audio.example",
ingested_from: None,
created_at: &now,
},
)?;
repo::upsert_person(tx, 884, "Bob Hope", false, &now)?;
repo::insert_actor_scenes(
tx,
&id,
884,
&[jray_server::validate::SceneCs::plain(19160, 20920)],
)?;
Ok(id)
})
.await
.expect("seeding")
}
}
fn movie_manifest(tmdb_id: &str, runtime: f64, signature: Option<&str>) -> Value {
let mut cut = json!({ "runtime_sec": runtime });
if let Some(sig) = signature {
cut["audio_signature"] = json!(sig);
}
json!({
"jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": tmdb_id, "title": "Road to Bali", "year": 1952 },
"cut": cut,
"extraction": { "sample_fps": 5, "pipeline_version": "test 0.1" },
"actors": [
{ "name": "Bob Hope", "tmdb_id": "884", "scenes": [{"start":191.6,"end":209.2}] },
{ "name": "Bing Crosby", "tmdb_id": "11007", "scenes": [{"start":300.0,"end":320.0}] }
]
})
}
// ---------------------------------------------------------------------------
// Storage and the round trip
// ---------------------------------------------------------------------------
/// TRACES: UR-009 | DR-002 | SR-003
#[tokio::test]
async fn a_contributed_signature_survives_storage_byte_for_byte() {
// The gap this closes: a signature that is validated and then dropped leaves
// every fetched manifest without one, and the plugin's local alignment
// (JR-047) with nothing to align against.
let s = TestServer::new("roundtrip");
let token = s.token().await;
let sig = signature(11, 0);
let (status, body) = s
.post_auth("/api/v1/manifests", &token, &movie_manifest("504172", 6420.5, Some(&sig)))
.await;
assert_eq!(status, StatusCode::ACCEPTED, "{body}");
let id = body["manifest_id"].as_str().expect("manifest id").to_string();
// Listed by hand, since the cast check cannot run without TMDB.
let listed = id.clone();
s.db.write(move |tx| {
tx.execute("UPDATE manifests SET status = 'listed' WHERE id = ?1", [&listed])?;
Ok(())
})
.await
.unwrap();
let (status, body) = s.get(&format!("/api/v1/manifests/{id}")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["cut"]["audio_signature"].as_str(),
Some(sig.as_str()),
"the served signature must be the contributed one, re-encoded not echoed"
);
}
/// TRACES: UR-009 | SR-003
#[tokio::test]
async fn a_manifest_without_a_signature_serves_no_signature_field() {
// §3's sequencing: signatures accumulate, so most manifests have none for a
// long time. The field must be absent rather than null or empty.
let s = TestServer::new("nosig");
let id = s.seed_listed("100", "Unsigned", 6420.5, None).await;
let (status, body) = s.get(&format!("/api/v1/manifests/{id}")).await;
assert_eq!(status, StatusCode::OK);
assert!(body["cut"].get("audio_signature").is_none(), "{body}");
}
/// TRACES: UR-008, UR-009 | SR-003
#[tokio::test]
async fn a_held_manifest_adopts_a_signature_it_lacked() {
// §9a: the signature is excluded from `content_id` and replicates as an
// attribute, so the same content arriving with a signature must fill the
// hole. Otherwise a manifest that first arrived without audio could never
// gain one, and its cut would sit permanently outside the `audio` tier.
let s = TestServer::new("adopt");
let token = s.token().await;
let sig = signature(12, 0);
let (status, _) =
s.post_auth("/api/v1/manifests", &token, &movie_manifest("777", 6420.5, None)).await;
assert_eq!(status, StatusCode::ACCEPTED);
// Same content, now carrying a signature. Deduplicated by `content_id` —
// which the signature is deliberately not part of.
let (status, body) =
s.post_auth("/api/v1/manifests", &token, &movie_manifest("777", 6420.5, Some(&sig))).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["status"], "already_present");
let id = body["manifest_id"].as_str().unwrap().to_string();
let listed = id.clone();
s.db.write(move |tx| {
tx.execute("UPDATE manifests SET status = 'listed' WHERE id = ?1", [&listed])?;
Ok(())
})
.await
.unwrap();
let (_, body) = s.get(&format!("/api/v1/manifests/{id}")).await;
assert_eq!(body["cut"]["audio_signature"].as_str(), Some(sig.as_str()));
}
// ---------------------------------------------------------------------------
// The `audio` tier on the read endpoints
// ---------------------------------------------------------------------------
/// TRACES: UR-009 | SR-003
#[tokio::test]
async fn a_matching_signature_reaches_the_audio_tier_on_a_fetch() {
let s = TestServer::new("tier");
let sig = signature(13, 0);
s.seed_listed("504172", "Road to Bali", 6420.5, Some(&sig)).await;
let uri = format!(
"/api/v1/manifests/movie?tmdb_id=504172&runtime_sec=6420.5&audio_signature={}",
urlencode(&sig)
);
let (status, body) = s.get(&uri).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["match"], "audio");
assert_eq!(body["offset_sec"], 0.0);
}
/// TRACES: UR-009 | SR-003
#[tokio::test]
async fn a_shifted_release_matches_and_gets_its_offset() {
// The row §3 calls the valuable one: a release with extra head material
// previously failed the ±2 s runtime tier outright. Now it matches, and the
// client shifts every window by the recovered offset.
let s = TestServer::new("shifted");
let stored_runtime = 6420.5;
let head_sec = 40.0;
let sig = signature(14, 0);
s.seed_listed("504172", "Road to Bali", stored_runtime, Some(&sig)).await;
// The client's copy carries 40 s of extra logos: 40 s longer, and its
// centre window therefore starts 20 s later in the content.
let slide = ((head_sec / 2.0) / audio_sig::HOP_SEC).round() as usize;
let client_sig = signature(14, slide);
let uri = format!(
"/api/v1/manifests/movie?tmdb_id=504172&runtime_sec={}&audio_signature={}",
stored_runtime + head_sec,
urlencode(&client_sig)
);
let (status, body) = s.get(&uri).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["match"], "audio");
// Both terms cancel here — the window moved by exactly the slide — so the
// manifest's timings apply unshifted despite a 40 s runtime difference the
// `runtime` tier would have rejected outright.
let offset = body["offset_sec"].as_f64().unwrap();
assert!(offset.abs() <= audio_sig::HOP_SEC, "offset {offset}");
// And without the signature that same request is not a match at all.
let (status, _) = s
.get(&format!(
"/api/v1/manifests/movie?tmdb_id=504172&runtime_sec={}",
stored_runtime + head_sec
))
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
/// TRACES: UR-009 | SR-003
#[tokio::test]
async fn a_signature_that_disagrees_is_not_served_on_a_runtime_coincidence() {
// §3: `audio` outranks `runtime` because it is content-derived, so its
// refusal outranks a runtime agreement. Two different films of the same
// length must not match.
let s = TestServer::new("disagree");
s.seed_listed("504172", "Road to Bali", 6420.5, Some(&signature(15, 0))).await;
let uri = format!(
"/api/v1/manifests/movie?tmdb_id=504172&runtime_sec=6420.5&audio_signature={}",
urlencode(&signature(16, 0))
);
let (status, _) = s.get(&uri).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
/// TRACES: UR-001, UR-009 | SR-003
#[tokio::test]
async fn exists_reports_the_audio_tier_too() {
// The sweep path. `exists` transfers no payload, so a client cannot align
// locally from it — server-side matching is the only way it can say `audio`.
let s = TestServer::new("exists");
let sig = signature(17, 0);
s.seed_listed("504172", "Road to Bali", 6420.5, Some(&sig)).await;
let (status, body) = s
.post(
"/api/v1/manifests/exists",
&json!({ "items": [
{ "tmdb_id": "504172", "runtime_sec": 6420.5, "audio_signature": sig },
{ "tmdb_id": "504172", "runtime_sec": 6420.5 }
]}),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["results"][0]["match"], "audio");
assert_eq!(body["results"][1]["match"], "runtime");
}
/// TRACES: UR-009 | DR-013 | SR-003
#[tokio::test]
async fn a_signature_without_a_runtime_is_refused_by_name() {
// The offset's window-anchor term is derived from both runtimes (§3). Rather
// than answer at a lower tier — which would look like a match the client's
// own signature had failed to improve — the request is refused.
let s = TestServer::new("noruntime");
s.seed_listed("504172", "Road to Bali", 6420.5, Some(&signature(18, 0))).await;
let uri = format!(
"/api/v1/manifests/movie?tmdb_id=504172&audio_signature={}",
urlencode(&signature(18, 0))
);
let (status, body) = s.get(&uri).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.to_string().contains("runtime_sec"), "{body}");
}
/// TRACES: UR-009, UR-011 | DR-013 | SR-004
#[tokio::test]
async fn a_malformed_signature_is_a_bad_request_not_a_silent_downgrade() {
let s = TestServer::new("malformed");
s.seed_listed("504172", "Road to Bali", 6420.5, Some(&signature(19, 0))).await;
for bad in ["v2:AAAA", "notasignature", "v1:!!!!"] {
let uri = format!(
"/api/v1/manifests/movie?tmdb_id=504172&runtime_sec=6420.5&audio_signature={}",
urlencode(bad)
);
let (status, _) = s.get(&uri).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "accepted {bad}");
}
}
// ---------------------------------------------------------------------------
// Unknown-providence search
// ---------------------------------------------------------------------------
/// TRACES: UR-009 | SR-003 | PR-005
#[tokio::test]
async fn search_identifies_a_file_with_no_metadata_at_all() {
// What the endpoint is for: no TMDB id, no usable name, nothing to look up.
// The answer has to carry title identity, because the caller has none.
let s = TestServer::new("search");
let sig = signature(20, 0);
s.seed_listed("504172", "Road to Bali", 6420.5, Some(&sig)).await;
s.seed_listed("999", "A Different Film", 6420.0, Some(&signature(21, 0))).await;
let (status, body) = s
.post("/api/v1/manifests/search", &json!({ "audio_signature": sig, "runtime_sec": 6420.5 }))
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["results"].as_array().unwrap().len(), 1, "{body}");
assert_eq!(body["results"][0]["identity"]["tmdb_id"], "504172");
assert_eq!(body["results"][0]["identity"]["title"], "Road to Bali");
assert_eq!(body["results"][0]["match"], "audio");
assert_eq!(body["results"][0]["score"], 1.0);
assert_eq!(body["truncated"], false);
// The decoy shares a runtime and so cleared the prefilter — the slide is
// what rejected it, which is the whole point of scoring rather than
// shortlisting.
assert_eq!(body["candidates_scored"], 2);
}
/// TRACES: UR-009 | SR-003
#[tokio::test]
async fn search_recovers_the_offset_for_a_differently_trimmed_release() {
let s = TestServer::new("search-offset");
let stored_runtime = 6420.5;
s.seed_listed("504172", "Road to Bali", stored_runtime, Some(&signature(22, 0))).await;
// 30 s of extra head: the window moves 15 s later in the content.
let head_sec = 30.0;
let slide = ((head_sec / 2.0) / audio_sig::HOP_SEC).round() as usize;
let (status, body) = s
.post(
"/api/v1/manifests/search",
&json!({
"audio_signature": signature(22, slide),
"runtime_sec": stored_runtime + head_sec
}),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["results"][0]["identity"]["tmdb_id"], "504172");
let offset = body["results"][0]["offset_sec"].as_f64().unwrap();
assert!(offset.abs() <= audio_sig::HOP_SEC, "offset {offset}");
}
/// TRACES: UR-009 | SR-003
#[tokio::test]
async fn search_declines_content_it_does_not_hold() {
let s = TestServer::new("search-miss");
s.seed_listed("504172", "Road to Bali", 6420.5, Some(&signature(23, 0))).await;
let (status, body) = s
.post(
"/api/v1/manifests/search",
&json!({ "audio_signature": signature(24, 0), "runtime_sec": 6420.5 }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(body["results"].as_array().unwrap().is_empty(), "{body}");
// An empty result is an answer, not an error: the caller learns the
// community does not have this cut.
assert_eq!(body["candidates_scored"], 1);
}
/// TRACES: UR-009, UR-011 | SR-004
#[tokio::test]
async fn search_applies_the_same_structural_rules_as_an_upload() {
// §6: a read must not be a way to hand the server bytes an upload would
// have refused.
let s = TestServer::new("search-validate");
for (body, why) in [
(json!({ "audio_signature": "v1:AAAA", "runtime_sec": 6420.5 }), "wrong length"),
(json!({ "audio_signature": signature(25, 0), "runtime_sec": 60.0 }), "under the window"),
(json!({ "audio_signature": signature(25, 0), "runtime_sec": -1.0 }), "negative runtime"),
] {
let (status, _) = s.post("/api/v1/manifests/search", &body).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "accepted a signature that is {why}");
}
}
/// TRACES: UR-009, UR-014 | SR-003
#[tokio::test]
async fn search_is_absent_when_the_operator_has_not_enabled_it() {
// §3: optional for a server to implement, and advertised rather than
// assumed, so a client discovers the absence in one cheap request.
let s = TestServer::with_search("search-off", false);
let (status, body) = s.get("/api/v1/federation/capabilities").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["audio_search"], false);
// Matching is not the expensive half and stays on regardless.
assert_eq!(body["audio_tier_matching"], true);
let (status, _) = s
.post(
"/api/v1/manifests/search",
&json!({ "audio_signature": signature(26, 0), "runtime_sec": 6420.5 }),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
/// TRACES: UR-002, UR-009 | SR-005
#[tokio::test]
async fn search_never_returns_a_pending_manifest() {
// §6/§7: `pending` is held unlisted and is not served by any route. A search
// that leaked one would publish an unchecked contribution.
let s = TestServer::new("search-pending");
let token = s.token().await;
let sig = signature(27, 0);
let (status, _) = s
.post_auth("/api/v1/manifests", &token, &movie_manifest("504172", 6420.5, Some(&sig)))
.await;
assert_eq!(status, StatusCode::ACCEPTED);
let (status, body) = s
.post("/api/v1/manifests/search", &json!({ "audio_signature": sig, "runtime_sec": 6420.5 }))
.await;
assert_eq!(status, StatusCode::OK);
assert!(body["results"].as_array().unwrap().is_empty(), "{body}");
assert_eq!(body["candidates_scored"], 0);
}
/// Percent-encodes the base64 characters a query string would otherwise eat.
fn urlencode(s: &str) -> String {
s.chars()
.map(|c| match c {
'+' => "%2B".to_string(),
'/' => "%2F".to_string(),
'=' => "%3D".to_string(),
':' => "%3A".to_string(),
'!' => "%21".to_string(),
c => c.to_string(),
})
.collect()
}