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
This commit is contained in:
@@ -76,6 +76,7 @@ impl TestServer {
|
||||
server_id: "test.example".into(),
|
||||
publish_peer_directory: true,
|
||||
contact: Some("admin@test.example".into()),
|
||||
enable_audio_search: true,
|
||||
request_timeout: std::time::Duration::from_secs(30),
|
||||
job_batch: 8,
|
||||
job_poll_interval: std::time::Duration::from_secs(3600),
|
||||
|
||||
+621
@@ -0,0 +1,621 @@
|
||||
//! 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()
|
||||
}
|
||||
+6
-3
@@ -71,6 +71,7 @@ impl TestServer {
|
||||
server_id: "local.example".into(),
|
||||
publish_peer_directory: publish_directory,
|
||||
contact: Some("admin@local.example".into()),
|
||||
enable_audio_search: true,
|
||||
request_timeout: std::time::Duration::from_secs(30),
|
||||
job_batch: 8,
|
||||
job_poll_interval: std::time::Duration::from_secs(3600),
|
||||
@@ -397,7 +398,9 @@ async fn capabilities_advertise_the_envelope_version() {
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["jmanifest_versions"], json!([2]));
|
||||
assert_eq!(body["federation"], true);
|
||||
// Deferred by design (§3 sequencing), and advertised as absent rather than
|
||||
// left for a client to discover by failure.
|
||||
assert_eq!(body["audio_search"], false);
|
||||
// §3: the audio surfaces are advertised rather than assumed, because search
|
||||
// is optional for a server to implement. This build implements both, so a
|
||||
// client that reads this and then sends a signature must not get a 404.
|
||||
assert_eq!(body["audio_search"], true);
|
||||
assert_eq!(body["audio_tier_matching"], true);
|
||||
}
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
{
|
||||
"_": "Golden vector for the JRay v1 audio signature (JRay-public-server SPEC.md \u00a73). Shared verbatim between scene-actor-extraction (C++) and the jRay Jellyfin plugin (C#) so the two implementations can be proven bit-identical. IR-004, IR-005, IR-007, IR-008.",
|
||||
"version": "v1",
|
||||
"signature": "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeHx8eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh8fHzk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5V1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dycnJycnJycnJycnJycnJycnJycnJycnJycnJycnMPDgwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKytFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRWNjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2Njfn5+fn5+fn5+fn5+fn5+fn5+fn5+fn5+fn5+fn5/GxoZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGTc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3UlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSU1JsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbAoLCwoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCwsLJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSVDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ15eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eX19eeXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXkXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFzExMTExMTExMTExMTExMTExMTExMTExMTExMTExT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09qampqampqampqampqampqampqampqampqampqamsHBwUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyM+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4/PlhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYd3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3ExMRERERERERERERERERERERERERERERERERERERES8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vSkpKSkpKSkpKSkpKSkpKSkpKSkpKSkpKSkpKSkpLS0plZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZQMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0eHh44ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4OFdXV1ZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWV1dXcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXEPDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDyoqKioqKioqKioqKioqKioqKioqKioqKioqKioqKysqRERERERERERERERERERERERERERERERERERERERjY2NjY2NjY2NjYw==",
|
||||
"frame_count": 1288,
|
||||
"media": {
|
||||
"file": "jray_audio_v1_tone.flac",
|
||||
"generator": "make_fixture.py",
|
||||
"container": "FLAC (lossless \u2014 decodes to exactly the PCM make_fixture.py emits)",
|
||||
"duration_sec": 120.0,
|
||||
"sample_rate": 11025,
|
||||
"channels": 1,
|
||||
"sample_format": "s16",
|
||||
"sha256": "912ecd426cd426dccb37753e0249694227619c701cb9f533502b37da0fbe8096",
|
||||
"bytes": 585142
|
||||
},
|
||||
"decoded_window": {
|
||||
"_": "Checksums of the 120 s centre window after downmix to mono and resample to 11025 Hz, i.e. exactly the stream `ffmpeg -ss <mid-60> -t 120 -i <file> -vn -ac 1 -ar 11025 -f f32le -` produces. Check these first: a mismatch here is a decode problem, not a DSP one.",
|
||||
"samples": 1323000,
|
||||
"f32le_fnv1a64": "0x1ef7899cd4d12662",
|
||||
"s16le_fnv1a64": "0xf824fa56f125c0dc"
|
||||
},
|
||||
"params": {
|
||||
"window_sec": 120.0,
|
||||
"window_centre": "runtime/2, i.e. samples from runtime/2 - 60 s; truncated to exactly 1323000 samples",
|
||||
"min_duration_sec": 120.0,
|
||||
"min_duration_rule": "IR-007 \u2014 below this emit NO signature and apply no sync offset",
|
||||
"sample_rate": 11025,
|
||||
"channels": 1,
|
||||
"arithmetic": "IEEE-754 double throughout; float32 is not sufficient",
|
||||
"sample_scale": "s16 * (1/32768), FFmpeg's native s16->flt",
|
||||
"frame_size": 4096,
|
||||
"hop_size": 1024,
|
||||
"frame_count_rule": "1 + (n_samples - 4096) / 1024, integer division; whole frames only",
|
||||
"window_fn": "Hann, PERIODIC: w[n] = 0.5 * (1 - cos(2*pi*n/4096))",
|
||||
"transform": "radix-2 DIT complex FFT over the 4096 real samples (imag=0), no normalisation",
|
||||
"magnitude": "sqrt(re^2 + im^2), linear",
|
||||
"band_lo_hz": 300.0,
|
||||
"band_hi_hz": 3000.0,
|
||||
"num_bands": 32,
|
||||
"band_edges": "edge[b] = 300 * (3000/300)^(b/32), b = 0..32",
|
||||
"band_bins": "band b owns FFT bins [k_lo[b], k_lo[b+1]) with k_lo[b] = ceil(edge[b] * 4096 / 11025); see band_fft_bins",
|
||||
"band_value": "MEAN of the linear magnitudes in the band (not sum, not max)",
|
||||
"peak_bin": "argmax over the 32 band values; ties resolve to the LOWEST index",
|
||||
"energy_metric": "E = mean magnitude over all FFT bins 112..1114, i.e. the whole 300-3000 Hz band",
|
||||
"energy_reference": "upper median of E over all frames: sorted[n/2], no averaging of the two middle values",
|
||||
"energy_ratio": "r = log10((E + 1e-12) / (E_ref + 1e-12))",
|
||||
"energy_class_edges": [
|
||||
-0.6,
|
||||
-0.2,
|
||||
0.2
|
||||
],
|
||||
"energy_class": "0 if r < -0.6, 1 if r < -0.2, 2 if r < 0.2, else 3",
|
||||
"byte_layout": "bit7 = 0 (reserved), bits6..2 = 5-bit band index, bits1..0 = 2-bit energy class; byte = (band << 2) | class",
|
||||
"base64": "standard alphabet A-Za-z0-9+/ with '=' padding",
|
||||
"prefix": "v1:"
|
||||
},
|
||||
"band_fft_bins": [
|
||||
[
|
||||
112,
|
||||
120
|
||||
],
|
||||
[
|
||||
120,
|
||||
129
|
||||
],
|
||||
[
|
||||
129,
|
||||
139
|
||||
],
|
||||
[
|
||||
139,
|
||||
149
|
||||
],
|
||||
[
|
||||
149,
|
||||
160
|
||||
],
|
||||
[
|
||||
160,
|
||||
172
|
||||
],
|
||||
[
|
||||
172,
|
||||
185
|
||||
],
|
||||
[
|
||||
185,
|
||||
199
|
||||
],
|
||||
[
|
||||
199,
|
||||
213
|
||||
],
|
||||
[
|
||||
213,
|
||||
229
|
||||
],
|
||||
[
|
||||
229,
|
||||
246
|
||||
],
|
||||
[
|
||||
246,
|
||||
265
|
||||
],
|
||||
[
|
||||
265,
|
||||
285
|
||||
],
|
||||
[
|
||||
285,
|
||||
306
|
||||
],
|
||||
[
|
||||
306,
|
||||
328
|
||||
],
|
||||
[
|
||||
328,
|
||||
353
|
||||
],
|
||||
[
|
||||
353,
|
||||
379
|
||||
],
|
||||
[
|
||||
379,
|
||||
408
|
||||
],
|
||||
[
|
||||
408,
|
||||
438
|
||||
],
|
||||
[
|
||||
438,
|
||||
471
|
||||
],
|
||||
[
|
||||
471,
|
||||
506
|
||||
],
|
||||
[
|
||||
506,
|
||||
543
|
||||
],
|
||||
[
|
||||
543,
|
||||
584
|
||||
],
|
||||
[
|
||||
584,
|
||||
627
|
||||
],
|
||||
[
|
||||
627,
|
||||
674
|
||||
],
|
||||
[
|
||||
674,
|
||||
724
|
||||
],
|
||||
[
|
||||
724,
|
||||
778
|
||||
],
|
||||
[
|
||||
778,
|
||||
836
|
||||
],
|
||||
[
|
||||
836,
|
||||
899
|
||||
],
|
||||
[
|
||||
899,
|
||||
966
|
||||
],
|
||||
[
|
||||
966,
|
||||
1038
|
||||
],
|
||||
[
|
||||
1038,
|
||||
1115
|
||||
]
|
||||
],
|
||||
"notes": [
|
||||
"The server spec fixes the window, rate, STFT geometry, band and the 5+2 bit packing. Everything under params beyond that (Hann periodicity, band aggregation, the energy-class definition, tie-breaking, base64 alphabet) is pinned HERE for v1 \u2014 the spec does not constrain it, and two implementations that guess differently produce non-matching signatures.",
|
||||
"Decision margins on this fixture: the two strongest bands are within 1.3% on the closest frame, and the closest frame to an energy-class edge is 3.6e-3 away in log10. Both are many orders of magnitude above double-precision FFT differences, so any two correct double- precision implementations agree; a float32 implementation is not guaranteed to.",
|
||||
"Coverage: all 32 bands and all 4 energy classes appear in the golden signature.",
|
||||
"Robustness observed on this fixture: identical peak-bin sequence after a stereo/44100 Hz round trip and after AAC 128 kbit/s re-encoding."
|
||||
]
|
||||
}
|
||||
@@ -99,6 +99,7 @@ impl TestServer {
|
||||
server_id: "test.example".into(),
|
||||
publish_peer_directory: true,
|
||||
contact: Some("admin@test.example".into()),
|
||||
enable_audio_search: true,
|
||||
request_timeout: std::time::Duration::from_secs(30),
|
||||
job_batch: 8,
|
||||
job_poll_interval: std::time::Duration::from_secs(3600),
|
||||
|
||||
Reference in New Issue
Block a user