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
622 lines
24 KiB
Rust
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()
|
|
}
|