diff --git a/README.md b/README.md index be66ee5..9a0e22f 100644 --- a/README.md +++ b/README.md @@ -94,13 +94,18 @@ Reconciled with the system spec (see `docs/requirements.md` for the detail): caller-varying length is the property SR-004 forbids. Items under 120 s now send no signature at all. -Deferred: +- **Audio signatures are complete (UR-009).** The field is accepted, validated, + stored and **served back**, `content_id` still excludes it, `audio`-tier + matching runs on every read endpoint, and `POST /manifests/search` answers + the unknown-providence case. §3's scoring rule gained ±1 frame of tolerance + on `scene-actor-extraction` VR-014's measurement — the exact-frame rule + demoted correctly aligned releases to `loose` because the two windows' frame + grids do not coincide. + + The signature stays optional throughout: a manifest without one is matched by + the runtime tiers exactly as before. This is an enhancement, and §3 requires + that it never be able to break a fetch. -- §3 audio signatures — the field is **accepted, validated and stored**, and - `content_id` already excludes it, but `audio`-tier matching and - `POST /manifests/search` are not wired up. This follows §3's own recommended - sequencing: ship the plugin-side computation first, let signatures accumulate, - then enable matching once coverage is useful. (Federation landed — see below.) ## Running @@ -120,6 +125,7 @@ Configuration is entirely environment variables: | `JRAY_TRUSTED_PROXIES` | — | Comma-separated proxy IPs whose `X-Forwarded-For` is honoured. **Not default-on**: §5 rate limiting and report attribution key on client IP, so a spoofable header defeats both | | `JRAY_SERVER_ID` | `localhost` | This server's identity, used as manifest `origin` and as the report IP-hash salt | | `JRAY_REQUEST_TIMEOUT_SEC` | `30` | Request timeout so a slow bundle query fails fast | +| `JRAY_AUDIO_SEARCH` | `1` | `POST /manifests/search`. §3 makes it optional for a server to implement because it is the most expensive read surface; set `0` to withdraw it, and `GET /federation/capabilities` stops advertising it. `audio`-tier matching on the ordinary reads is unaffected | | `JRAY_JOB_BATCH` | `8` | Cast-check jobs leased per worker tick | | `JRAY_JOB_POLL_SEC` | `5` | Worker poll interval | | `JRAY_LOG` | `info` | `tracing` filter | diff --git a/SPEC.md b/SPEC.md index 5af4a8b..81a8e9d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -459,9 +459,10 @@ Two signatures are compared by sliding one against the other and taking the best score: ``` -for offset in -600 .. +600 frames: # ±56 s - score(offset) = fraction of overlapping frames whose peak bin matches -best = argmax score +for slide in -600 .. +600 frames: # ±56 s + score(slide) = fraction of overlapping frames whose peak bin matches + the reference within ±1 frame +best = argmax score # smallest |slide| wins a tie ``` | Result | Interpretation | @@ -483,6 +484,72 @@ Speed-differing releases (PAL 4% speed-up) are **not** handled by a constant offset and are correctly rejected by the score threshold; a scale-and-offset search is possible later but is out of scope. +##### Normative v1 matching parameters + +As with the construction, prose alone leaves choices that decide whether two +implementations agree. These are pinned for v1: + +| Parameter | v1 value | +|---|---| +| Slide range | ±600 frames, inclusive | +| Score tolerance | A frame agrees if the reference carries the same peak bin within **±1 frame** | +| Tie-break | Smallest \|slide\| | +| Minimum overlap | **64 frames** — an alignment thinner than this does not score at all | +| Field compared | Peak bin only; the 2-bit energy class is not scored | + +**The ±1 frame tolerance is a correction, not a loosening.** The rule was an +exact frame match until `scene-actor-extraction` VR-014 measured it on real +film audio: over 40 random in-cap offsets every alignment was recovered to the +nearest frame, but **27 of the 40 scored below 0.85 and were demoted to +`loose`** — not because the content disagreed but because the two windows are +cut on their own file's frame grid and those grids do not coincide. The exact +rule therefore scored how nearly two grids happened to line up. With ±1 frame +all 40 reach `audio` (worst 0.906), the strongest false match is unmoved at +0.16, and the offset costs 81 ms of a 500 ms budget. The gap that makes the +thresholds mean anything is untouched; what changed is that a correctly aligned +release now clears them. + +**Minimum overlap is not from the construction.** Without a floor the extreme +slides compare a handful of frames, where a chance agreement scores 1.0 and +beats the true alignment. It never binds on the real case — two full-length +signatures still overlap by 688 frames at ±600 — but two implementations that +chose different floors would disagree at the edges, so it is fixed here. + +##### The offset has two terms + +This is the easiest thing in the feature to get wrong, and the pseudocode above +gives only half of it. Both signatures are cut from **their own file's centre**, +`runtime/2 ± 60 s`, so when the runtimes differ the two windows do not begin at +the same point in the content: + +``` +offset = (local_runtime - manifest_runtime) / 2 - slide × 1024/11025 + └──── window-anchor difference ────┘ └───── recovered ─────┘ +``` + +A release carrying 40 s of extra head material takes 20 s from each term. Using +the slide alone is wrong by half the runtime difference on **every** shifted +release — which is every release this feature exists for. + +It follows that **a signature alone cannot produce an alignment**: the anchor +term needs both runtimes. A caller that supplies a signature without a runtime +is refused by name rather than answered at a lower tier, which would look like a +match its own signature had failed to improve. + +##### A signature verdict is final + +When both sides carry a signature, the audio comparison decides — including +when it declines. There is no fallback to the runtime tiers after a `score < +0.60`, because `audio` outranks `runtime` precisely for being content-derived, +and letting a runtime coincidence overturn direct evidence would invert the +ordering the tier table states. The separation measured in VR-014 — true +matches at 0.906 and above, the strongest false one at 0.16 — is what makes +that safe rather than brave. + +A signature on only one side is a different case entirely and falls through to +the runtime tiers, since coverage accumulates gradually and most stored +manifests carry none. + #### Revised tier table | Tier | Signal | Confidence | @@ -503,25 +570,57 @@ With no TMDB id at all, a client can search by signature alone: ``` POST /manifests/search -{ "audio_signature": "base64…", "runtime_sec": 6420.5 } +{ "audio_signature": "v1:…", "runtime_sec": 6420.5 } ``` +Both fields are required. `runtime_sec` drives the prefilter and supplies the +offset's window-anchor term, so a search without it could rank candidates but +could not align to them. + The server returns candidate matches with scores, offsets and title identity — -letting JRay identify an unidentified file *and* align to it in one step. +letting JRay identify an unidentified file *and* align to it in one step: + +```json +{ + "results": [ + { "manifest_id": "01HZ...", "match": "audio", "score": 0.97, "offset_sec": -20.0, + "identity": { "type": "movie", "tmdb_id": "504172", + "title": "Road to Bali", "year": 1952 } } + ], + "candidates_scored": 412, + "truncated": false +} +``` + +Results are best-first and capped at 10. Title identity is the half of the +answer `GET /manifests/exists` cannot give, since a caller with no id has +nothing to probe it with — and it stays cut-level like every other tier, so it +says which *work* this is and never which copy. **This endpoint is a scaling problem, not a correctness one.** A naive implementation compares against every stored signature. Mitigations: - Prefilter by runtime (±90 s) before scoring, which eliminates almost - everything. -- Index a **coarse hash** of the signature (e.g. the peak-bin sequence of - every 16th frame) for candidate generation, with full sliding comparison - only on candidates. + everything. Necessarily wider than the ±30 s `loose` tier: here a large + runtime difference is the premise rather than a disqualification. +- Index a **coarse hash** of the signature — the peak-bin sequence of every + 16th frame — for candidate generation, with full sliding comparison only on + candidates. Subsampling destroys the alignment for any non-zero slide, so + the coarse key settles the *re-encode* case cheaply and orders the scan; the + runtime prefilter is what carries the shifted releases. +- Bound the scan. A full slide costs ~1.1 ms per candidate, so this server + scores at most 1000 of them — a ~1.1 s ceiling on one request — and reports + `candidates_scored` alongside `truncated`. A truncated scan presented as a + complete one turns "no match" into a claim the server cannot support, and + truncation is the signal that the corpus has outgrown a linear scan. - Rate-limit hard (§5): this is the most expensive read endpoint and the most attractive to abuse. Because it is expensive, `POST /manifests/search` is **optional for a server -to implement**; `GET /federation/capabilities` advertises support. +to implement**; `GET /federation/capabilities` advertises support, alongside +`audio_tier_matching` for the read endpoints, which is not optional — matching +costs one slide against the candidates a title already narrows to, which is +nothing like a corpus-wide search. #### Validation and abuse @@ -559,6 +658,12 @@ plugin-side computation first, let signatures accumulate, then enable `audio`-tier matching and the search endpoint once coverage is useful. Nothing above needs to land at once. +That sequence is now complete: the plugin computes signatures (`jRay` JR-042), +the server stores and serves them, and both `audio`-tier matching and +`POST /manifests/search` are live. The optionality survives it — a manifest +without a signature is matched by the runtime tiers exactly as before, and +**nothing here may be allowed to break a fetch** (UR-009 is an enhancement). + #### Computing the signature in the JRay plugin The plugin is the right place for this, and it is the *only* place that covers @@ -670,7 +775,12 @@ title *and* at what cut-match tier, without transferring the payload. Query parameters are the same identity + cut parameters as the fetch endpoints: `tmdb_id` / `imdb_id` (or `series_tmdb_id` + `season` + `episode`), -plus optional `runtime_sec`. +plus optional `runtime_sec` and `audio_signature`. + +`audio_signature` is what lets the `audio` tier fire here at all. This endpoint +transfers no payload, so a client cannot align locally from its answer — the +server-side comparison is the only way a sweep can learn that a manifest is not +merely present but *aligned*. It requires `runtime_sec` alongside it (§3). ```json { "exists": true, "match": "runtime", "manifest_id": "01HZ...", "actor_count": 34 } @@ -701,24 +811,46 @@ returning results positionally. This exists specifically so the rate limit in because the payload does not fit a query string; it is a read and requires no token. -### `GET /manifests/movie?tmdb_id=&imdb_id=&runtime_sec=` +### `GET /manifests/movie?tmdb_id=&imdb_id=&runtime_sec=&audio_signature=` Returns the best-matching Jmanifest, or `404` if none clears `loose`. ```json -{ "match": "runtime", "manifest": { "...": "..." } } +{ "match": "runtime", "offset_sec": 0.0, "manifest": { "...": "..." } } ``` +`offset_sec` is always present and is non-zero only on the `audio` route — the +runtime tiers know that two cuts are close, never by how much they are +displaced. The served manifest carries `cut.audio_signature` when the server +holds one, which is what a client aligning locally compares against. + +`audio_signature` rides in the query string: ~2.3 KB encoded, comfortably inside +any request-line limit. Unlike the withdrawn `video_hash` it is not a file-level +signal, so accepting it on a read does not make the read an oracle for which +*copy* a caller holds (§3, PR-005). + ### `GET /manifests/series/{series_tmdb_id}?season=` Returns a series bundle (§2). `season` optional; omitted means all seasons. Episode-level cut matching is done client-side against the returned bundle, since a client pulling a whole series already knows its own runtimes. -### `GET /manifests/episode?series_tmdb_id=&season=&episode=&runtime_sec=` +### `GET /manifests/episode?series_tmdb_id=&season=&episode=&runtime_sec=&audio_signature=` Single-episode equivalent of the movie endpoint. +### `POST /manifests/search` — UR-009 + +Identify a file of unknown providence by its audio signature, and align to it. +See §3 "Unknown-providence search" for the body, the response and the cost. + +- `200` — results, possibly empty. An empty list is an answer, not an error +- `400` — the signature is malformed, or fails the same structural rules an + upload would (§6) +- `404` — this server does not offer search; `GET /federation/capabilities` + says so up front +- `429` — rate limited (§5) + ### `POST /manifests` Contribute a manifest. Body is a Jmanifest. Requires an API token (§5). diff --git a/docs/requirements.md b/docs/requirements.md index 964d3a1..8b0bf6e 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -35,7 +35,7 @@ requirement and no fixture-generation step, unlike `scene-actor-extraction`. | UR-006 | Serve and accept a whole series in one operation | PR-006 | High | Done | | UR-007 | Plugin queries an ordered, configurable list of servers | PR-005 | High | In Progress | | UR-008 | Servers replicate manifests between each other | PR-006 | Medium | Done | -| UR-009 | Store an audio spectral-peak signature for content-based identification | SR-003 | Medium | In Progress | +| UR-009 | Store an audio spectral-peak signature for content-based identification | SR-003 | Medium | Done | | UR-010 | Identity crossing the API boundary is TMDB/IMDB ids, never a name alone | SR-001 | High | Done | | UR-011 | Reject any field capable of carrying binary or attacker-chosen content | SR-004 | High | Done | | UR-012 | Never accept, store, or serve gallery data — reference faces or embeddings | SR-005 | High | Done | @@ -68,11 +68,29 @@ a peer — is deliberately not an API: §9a requires that a peering exist only because an operator typed a URL, so it is a database action, and `there_is_no_endpoint_that_creates_a_peering` asserts the absence. -**UR-009 is `In Progress`.** The server accepts, validates and stores -`cut.audio_signature`, and `content_id` correctly excludes it (§9a). What is -absent is `audio`-tier matching and `POST /manifests/search`. This is the -sequencing §3 recommends — accumulate signatures first, enable matching once -coverage is useful — not an oversight. +**UR-009 is `Done`.** The server accepts, validates, stores and serves +`cut.audio_signature`; `content_id` correctly excludes it (§9a), and a held +manifest lacking one adopts an incoming signature rather than discarding it. +`audio`-tier matching runs on every read endpoint, and `POST /manifests/search` +answers the unknown-providence case. + +> **The register previously recorded this row as storing the signature, and it +> did not.** `ingest` validated `cut.audio_signature` and then 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. Recorded here because it is the failure mode a status field is +> least able to catch: every validation test passed, and the feature delivered +> nothing. + +**§3's scoring rule changed with this row.** A frame now agrees within ±1 +frame rather than exactly, on `scene-actor-extraction` VR-014's measurement — +the exact rule demoted 27 of 40 correctly aligned releases to `loose` because +the two windows' frame grids do not coincide. **`jRay`'s `AudioSignatureMatcher` +still implements the pre-change rule**, so the plugin will label some alignments +`loose` that this server calls `audio`. Its local answer supersedes the server's +on the fetch path (JR-047), so nothing is misaligned by the divergence — but the +two are now out of step with §3, and the plugin register should carry the +follow-up. **UR-012 is satisfied structurally, by absence.** There is no field in the Jmanifest capable of carrying an embedding or a crop, and no endpoint that would @@ -144,7 +162,7 @@ topology is the point, so this is a deliberate choice rather than an oversight. | UR-006 | T2 | Bundle accepted per-episode, non-atomically | One bad episode rejected while its neighbours are accepted; envelope errors are whole-request `400` | | UR-007 | **external** | *No server-side test, and cannot have one.* The obligation is the plugin's: `jRay` JR-025, which is `Done` and tagged in that register | Verified there, not here — counted as covered by cross-reference, never by a test in this repo. **See the status note: JR-025 being `Done` does not by itself close UR-007**, because the fetch path (`jRay` JR-031) is still `Planned` | | UR-008 | T1 + T2 | Feed, fetch-by-hash, batch have, peer directory | **Cursor is strictly monotonic** — a ULID would sort out of write order within a millisecond and silently skip entries; a peer retraction flags rather than delists; only the opt-in abuse channel delists; `pending` is never replicated; **no endpoint can create a peering** | -| UR-009 | T1 | Signature structurally validated | Fixed length; reserved high bit; **media < 120 s must send no signature at all** | +| UR-009 | T1 + T2 | Signature validated, stored, served back, and matched on | Fixed length; reserved high bit; **media < 120 s must send no signature at all**; a `v2:` payload is refused rather than parsed; **the served signature is byte-identical to the contributed one** — a signature that is validated and then dropped passes every validation test and delivers nothing; a slide past the ±600-frame cap and unrelated content are both *declined*, never given a best-effort alignment; **the offset carries the window-anchor term**, not the slide alone; a signature disagreement is not overturned by a runtime coincidence; a search never returns a `pending` manifest | | UR-010 | T1 + T2 | Actors persist as TMDB person ids | A name the upload invented does not round-trip | | UR-011 | T2 | Every payload-shaped field rejected | base64, hex, markup, control characters, bidi overrides, compatibility homoglyphs | | UR-012 | T2 | No endpoint accepts embeddings or image data | An `embedding` or `crop` field is an unknown-field `400` | diff --git a/docs/traceability.md b/docs/traceability.md index 390f343..47985e8 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -3,7 +3,7 @@ -**Generated:** 2026-07-31T14:25:51+00:00 +**Generated:** 2026-07-31T20:41:09+00:00 Denominators are read from [`requirements.md`](requirements.md) at run time, never hardcoded. Coverage counts a requirement only when it is tagged in source **and** has a verification tier this repo's CI host can execute (`T1, T2, static`). @@ -11,8 +11,8 @@ Denominators are read from [`requirements.md`](requirements.md) at run time, nev | Metric | Value | |---|---| -| Source files scanned | 29 | -| TRACES tags found | 50 | +| Source files scanned | 32 | +| TRACES tags found | 69 | | EXCEPTION tags found | 0 | | Requirements defined | 33 | | Requirements covered | 31 | @@ -59,27 +59,27 @@ _None._ | ID | Status | Tier | Traces to | Trace state | Tagged in | Requirement | |---|---|---|---|---|---|---| -| UR-001 | Done | T2 | SR-001 | covered | `src/api/exists.rs`, `src/matching.rs` | Cheap existence probe, separate from the fetch, returning availabilit… | -| UR-002 | Done | T2 | PR-006 | covered | `src/api/upload.rs`, `src/ingest.rs` | Accept a contributed manifest for a media item | +| UR-001 | Done | T2 | SR-001 | covered | `src/api/exists.rs`, `src/matching.rs`, `tests/audio.rs` | Cheap existence probe, separate from the fetch, returning availabilit… | +| UR-002 | Done | T2 | PR-006 | covered | `src/api/upload.rs`, `src/ingest.rs`, `tests/audio.rs` | Accept a contributed manifest for a media item | | UR-003 | Done | T1, T2 | SR-004 | covered | `src/api/upload.rs`, `src/castcheck.rs`, `src/model.rs`, `src/validate.rs`, `src/worker.rs` | Content verification: strict schema, size caps, approximate TMDB cast… | | UR-004 | Done | T1, T2 | SR-004 | covered | `src/auth.rs`, `src/ratelimit.rs` | Rate limiting, per token where present and per source IP otherwise | | UR-005 | Done | T1, T2 | SR-004 | covered | `src/api/report.rs`, `src/api/upload.rs`, `src/auth.rs`, `src/castcheck.rs`, `src/worker.rs` | Trust without accounts: not usable as a content store, nor for prank … | | UR-006 | Done | T2 | PR-006 | covered | `src/api/fetch.rs`, `src/api/upload.rs`, `src/validate.rs` | Serve and accept a whole series in one operation | | UR-007 | In Progress | unset | PR-005 | covered | `src/api/exists.rs` | Plugin queries an ordered, configurable list of servers | -| UR-008 | Done | T1, T2 | PR-006 | covered | `src/api/federation.rs`, `src/worker.rs` | Servers replicate manifests between each other | -| UR-009 | In Progress | T1 | SR-003 | covered | `src/validate.rs` | Store an audio spectral-peak signature for content-based identificati… | +| UR-008 | Done | T1, T2 | PR-006 | covered | `src/api/federation.rs`, `src/db/repo.rs`, `src/worker.rs`, `tests/audio.rs` | Servers replicate manifests between each other | +| UR-009 | Done | T1, T2 | SR-003 | covered | `src/api/mod.rs`, `src/api/search.rs`, `src/audio_sig.rs`, `src/db/repo.rs`, `src/matching.rs`, `src/validate.rs`, `tests/audio.rs` | Store an audio spectral-peak signature for content-based identificati… | | UR-010 | Done | T1, T2 | SR-001 | covered | `src/api/fetch.rs`, `src/castcheck.rs`, `src/db/repo.rs`, `src/model.rs` | Identity crossing the API boundary is TMDB/IMDB ids, never a name alo… | -| UR-011 | Done | T2 | SR-004 | covered | `src/model.rs`, `src/validate.rs` | Reject any field capable of carrying binary or attacker-chosen content | +| UR-011 | Done | T2 | SR-004 | covered | `src/model.rs`, `src/validate.rs`, `tests/audio.rs` | Reject any field capable of carrying binary or attacker-chosen content | | UR-012 | Done | T2 | SR-005 | covered | `src/db/repo.rs`, `src/ingest.rs` | Never accept, store, or serve gallery data — reference faces or embed… | | UR-013 | Done | T1 | SR-002 | covered | `src/api/fetch.rs`, `src/model.rs`, `src/validate.rs` | Windows are scene-scoped claims; never reinterpret their boundaries | -| UR-014 | Done | T1 | SR-003 | covered | `src/api/federation.rs`, `src/model.rs`, `src/validate.rs` | Reject an unknown `jmanifest_version` outright, never guess | +| UR-014 | Done | T1 | SR-003 | covered | `src/api/federation.rs`, `src/model.rs`, `src/validate.rs`, `tests/audio.rs` | Reject an unknown `jmanifest_version` outright, never guess | | UR-015 | Done | T2 | SR-003 | covered | `src/validate.rs`, `tests/api.rs` | Accept `extraction.extinction_sec` in place of `anneal_sec` | | UR-016 | Done | T2 | SR-003 | covered | `src/validate.rs`, `tests/api.rs` | Accept and store `extraction.gallery_scope`; rank on it (§7) | | UR-017 | Done | T1, T2 | SR-003 | covered | `src/model.rs` | Accept per-window belief and identification route; `scenes` are objec… | | UR-018 | Done | T1 | SR-003 | covered | `src/ingest.rs` | Exclude belief and route from `content_id`, replicating them as attri… | | UR-019 | Done | T2 | PR-006 | covered | `src/api/upload.rs` | Contributed manifests are CC0 1.0; the grant is delivered with the to… | | DR-001 | Done | T1 | SR-004 | covered | `src/model.rs`, `tests/api.rs` | Strict parse boundary: unknown fields rejected structurally, not by v… | -| DR-002 | Done | T1 | SR-004 | covered | `src/api/fetch.rs`, `src/db/repo.rs` | Fully relational storage — no JSON blob on the write path | +| DR-002 | Done | T1 | SR-004 | covered | `src/api/fetch.rs`, `src/db/repo.rs`, `tests/audio.rs` | Fully relational storage — no JSON blob on the write path | | DR-003 | Done | T1 | PR-004 | covered | `src/db/mod.rs` | Single serialized writer connection, with a read pool alongside | | DR-004 | Done | T1 | PR-004 | covered | `src/db/repo.rs` | All database access behind a repository layer, not scattered through … | | DR-005 | Done | T1 | PR-004 | covered | `src/db/repo.rs` | Background work in-process, with the job queue as a table so it survi… | @@ -90,7 +90,7 @@ _None._ | DR-010 | Done | T1 | SR-003 | covered | `src/api/json.rs` | Request bodies are UTF-8 only, rejected with a diagnosable error othe… | | DR-011 | Done | T1 | SR-003 | covered | `src/content_id.rs`, `src/validate.rs` | `content_id` canonical form is byte-stable and cross-implementation t… | | DR-012 | Done | static | PR-004 | untagged | - | Dependency audit: advisories, licence policy, source policy | -| DR-013 | Done | T1 | SR-003 | covered | `src/api/json.rs`, `src/app.rs`, `src/error.rs` | API errors use the status codes the spec names, not the framework's d… | +| DR-013 | Done | T1 | SR-003 | covered | `src/api/json.rs`, `src/app.rs`, `src/error.rs`, `tests/audio.rs` | API errors use the status codes the spec names, not the framework's d… | | DR-014 | Done | static | PR-004 | covered | `src/db/mod.rs` | Portable SQL — no SQLite-specific form where a standard one exists | ## Detailed mapping @@ -99,16 +99,17 @@ _None._ **Locations:** 2 -- [`src/model.rs:323`](../src/model.rs#L323) — `fn unknown_field_at_top_level_is_rejected()` -- [`tests/api.rs:278`](../tests/api.rs#L278) — `async fn unknown_field_anywhere_is_rejected_with_400()` +- [`src/model.rs:324`](../src/model.rs#L324) — `fn unknown_field_at_top_level_is_rejected()` +- [`tests/api.rs:279`](../tests/api.rs#L279) — `async fn unknown_field_anywhere_is_rejected_with_400()` ### DR-002 -**Locations:** 3 +**Locations:** 4 - [`src/api/fetch.rs:269`](../src/api/fetch.rs#L269) — `pub fn reconstruct(` -- [`src/db/repo.rs:331`](../src/db/repo.rs#L331) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` -- [`src/db/repo.rs:405`](../src/db/repo.rs#L405) — `pub fn actors_for_manifest(` +- [`src/db/repo.rs:426`](../src/db/repo.rs#L426) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` +- [`src/db/repo.rs:500`](../src/db/repo.rs#L500) — `pub fn actors_for_manifest(` +- [`tests/audio.rs:258`](../tests/audio.rs#L258) — `async fn a_contributed_signature_survives_storage_byte_for_byte()` ### DR-003 @@ -120,13 +121,13 @@ _None._ **Locations:** 1 -- [`src/db/repo.rs:331`](../src/db/repo.rs#L331) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` +- [`src/db/repo.rs:426`](../src/db/repo.rs#L426) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` ### DR-005 **Locations:** 1 -- [`src/db/repo.rs:702`](../src/db/repo.rs#L702) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result, now: &str, limit: usize) -> anyhow::Result Result<&str, ApiError>` - [`src/app.rs:26`](../src/app.rs#L26) — `pub fn router(state: AppState) -> Router` - [`src/error.rs:8`](../src/error.rs#L8) — `Unknown` +- [`tests/audio.rs:442`](../tests/audio.rs#L442) — `async fn a_signature_without_a_runtime_is_refused_by_name()` +- [`tests/audio.rs:460`](../tests/audio.rs#L460) — `async fn a_malformed_signature_is_a_bad_request_not_a_silent_downgrade()` ### DR-014 @@ -182,14 +185,16 @@ _None._ - [`src/config.rs:10`](../src/config.rs#L10) — `Unknown` - [`src/db/mod.rs:30`](../src/db/mod.rs#L30) — `pub mod repo;` - [`src/db/mod.rs:36`](../src/db/mod.rs#L36) — `struct ReadPool` -- [`src/db/repo.rs:702`](../src/db/repo.rs#L702) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result, now: &str, limit: usize) -> anyhow::Result CastCheckOutcome` -- [`src/db/repo.rs:405`](../src/db/repo.rs#L405) — `pub fn actors_for_manifest(` -- [`src/matching.rs:54`](../src/matching.rs#L54) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option` -- [`src/model.rs:173`](../src/model.rs#L173) — `Unknown` +- [`src/db/repo.rs:500`](../src/db/repo.rs#L500) — `pub fn actors_for_manifest(` +- [`src/matching.rs:94`](../src/matching.rs#L94) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option` +- [`src/model.rs:174`](../src/model.rs#L174) — `Unknown` ### SR-002 **Locations:** 4 - [`src/api/fetch.rs:269`](../src/api/fetch.rs#L269) — `pub fn reconstruct(` -- [`src/model.rs:173`](../src/model.rs#L173) — `Unknown` -- [`src/model.rs:232`](../src/model.rs#L232) — `pub fn from_stored(s: &str) -> Option` +- [`src/model.rs:174`](../src/model.rs#L174) — `Unknown` +- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_stored(s: &str) -> Option` - [`src/validate.rs:496`](../src/validate.rs#L496) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult>` ### SR-003 -**Locations:** 15 +**Locations:** 32 -- [`src/api/federation.rs:258`](../src/api/federation.rs#L258) — `pub async fn get_capabilities(State(state): State) -> ApiResult` +- [`src/api/federation.rs:261`](../src/api/federation.rs#L261) — `pub async fn get_capabilities(State(state): State) -> ApiResult` - [`src/api/json.rs:100`](../src/api/json.rs#L100) — `fn require_utf8(bytes: &[u8]) -> Result<&str, ApiError>` +- [`src/api/mod.rs:38`](../src/api/mod.rs#L38) — `pub fn client_cut(&self) -> Result` +- [`src/api/search.rs:113`](../src/api/search.rs#L113) — `pub async fn post_search(` +- [`src/audio_sig.rs:113`](../src/audio_sig.rs#L113) — `pub fn compare(reference: &[u8], query: &[u8]) -> Option` - [`src/content_id.rs:57`](../src/content_id.rs#L57) — `pub fn canonical_json(` - [`src/content_id.rs:132`](../src/content_id.rs#L132) — `pub fn content_id(` +- [`src/db/repo.rs:316`](../src/db/repo.rs#L316) — `pub fn adopt_audio_signature(` - [`src/error.rs:8`](../src/error.rs#L8) — `Unknown` -- [`src/ingest.rs:387`](../src/ingest.rs#L387) — `fn content_id_excludes_belief_and_route()` -- [`src/model.rs:191`](../src/model.rs#L191) — `Unknown` -- [`src/model.rs:232`](../src/model.rs#L232) — `pub fn from_stored(s: &str) -> Option` -- [`src/model.rs:258`](../src/model.rs#L258) — `Unknown` +- [`src/ingest.rs:406`](../src/ingest.rs#L406) — `fn content_id_excludes_belief_and_route()` +- [`src/matching.rs:94`](../src/matching.rs#L94) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option` +- [`src/model.rs:192`](../src/model.rs#L192) — `Unknown` +- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_stored(s: &str) -> Option` +- [`src/model.rs:259`](../src/model.rs#L259) — `Unknown` - [`src/validate.rs:102`](../src/validate.rs#L102) — `pub fn to_centiseconds(secs: f64) -> i64` - [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult` - [`src/validate.rs:364`](../src/validate.rs#L364) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` -- [`src/validate.rs:731`](../src/validate.rs#L731) — `fn extinction_sec_replaces_anneal_sec()` -- [`tests/api.rs:323`](../tests/api.rs#L323) — `async fn the_withdrawn_anneal_sec_field_is_rejected()` -- [`tests/api.rs:347`](../tests/api.rs#L347) — `async fn the_schema_bump_fields_round_trip()` +- [`src/validate.rs:755`](../src/validate.rs#L755) — `fn extinction_sec_replaces_anneal_sec()` +- [`tests/api.rs:324`](../tests/api.rs#L324) — `async fn the_withdrawn_anneal_sec_field_is_rejected()` +- [`tests/api.rs:348`](../tests/api.rs#L348) — `async fn the_schema_bump_fields_round_trip()` +- [`tests/audio.rs:258`](../tests/audio.rs#L258) — `async fn a_contributed_signature_survives_storage_byte_for_byte()` +- [`tests/audio.rs:292`](../tests/audio.rs#L292) — `async fn a_manifest_without_a_signature_serves_no_signature_field()` +- [`tests/audio.rs:304`](../tests/audio.rs#L304) — `async fn a_held_manifest_adopts_a_signature_it_lacked()` +- [`tests/audio.rs:343`](../tests/audio.rs#L343) — `async fn a_matching_signature_reaches_the_audio_tier_on_a_fetch()` +- [`tests/audio.rs:360`](../tests/audio.rs#L360) — `async fn a_shifted_release_matches_and_gets_its_offset()` +- [`tests/audio.rs:402`](../tests/audio.rs#L402) — `async fn a_signature_that_disagrees_is_not_served_on_a_runtime_coincidence()` +- [`tests/audio.rs:419`](../tests/audio.rs#L419) — `async fn exists_reports_the_audio_tier_too()` +- [`tests/audio.rs:442`](../tests/audio.rs#L442) — `async fn a_signature_without_a_runtime_is_refused_by_name()` +- [`tests/audio.rs:480`](../tests/audio.rs#L480) — `async fn search_identifies_a_file_with_no_metadata_at_all()` +- [`tests/audio.rs:506`](../tests/audio.rs#L506) — `async fn search_recovers_the_offset_for_a_differently_trimmed_release()` +- [`tests/audio.rs:532`](../tests/audio.rs#L532) — `async fn search_declines_content_it_does_not_hold()` +- [`tests/audio.rs:567`](../tests/audio.rs#L567) — `async fn search_is_absent_when_the_operator_has_not_enabled_it()` ### SR-004 -**Locations:** 18 +**Locations:** 20 - [`src/api/report.rs:56`](../src/api/report.rs#L56) — `pub async fn post_report(` - [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `pub async fn post_manifest(` @@ -259,38 +281,43 @@ _None._ - [`src/auth.rs:76`](../src/auth.rs#L76) — `pub fn client_ip(headers: &HeaderMap, peer: Option, trusted_proxies: &[IpAddr]) -…` - [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome` - [`src/castcheck.rs:215`](../src/castcheck.rs#L215) — `pub fn category_guard_violation(matched: &[MatchedActor], title_is_adult: bool) -> Option…` -- [`src/db/repo.rs:331`](../src/db/repo.rs#L331) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` -- [`src/model.rs:150`](../src/model.rs#L150) — `Unknown` -- [`src/model.rs:258`](../src/model.rs#L258) — `Unknown` -- [`src/model.rs:323`](../src/model.rs#L323) — `fn unknown_field_at_top_level_is_rejected()` +- [`src/db/repo.rs:426`](../src/db/repo.rs#L426) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` +- [`src/model.rs:151`](../src/model.rs#L151) — `Unknown` +- [`src/model.rs:259`](../src/model.rs#L259) — `Unknown` +- [`src/model.rs:324`](../src/model.rs#L324) — `fn unknown_field_at_top_level_is_rejected()` - [`src/ratelimit.rs:93`](../src/ratelimit.rs#L93) — `impl Default for RateLimiter` - [`src/validate.rs:136`](../src/validate.rs#L136) — `fn is_allowed_text_char(c: char) -> bool` - [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult` - [`src/validate.rs:364`](../src/validate.rs#L364) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` - [`src/worker.rs:150`](../src/worker.rs#L150) — `async fn run_cast_check(&self, payload: &str) -> Result<(), JobError>` -- [`tests/api.rs:278`](../tests/api.rs#L278) — `async fn unknown_field_anywhere_is_rejected_with_400()` +- [`tests/api.rs:279`](../tests/api.rs#L279) — `async fn unknown_field_anywhere_is_rejected_with_400()` +- [`tests/audio.rs:460`](../tests/audio.rs#L460) — `async fn a_malformed_signature_is_a_bad_request_not_a_silent_downgrade()` +- [`tests/audio.rs:551`](../tests/audio.rs#L551) — `async fn search_applies_the_same_structural_rules_as_an_upload()` ### SR-005 -**Locations:** 2 +**Locations:** 3 -- [`src/db/repo.rs:331`](../src/db/repo.rs#L331) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` +- [`src/db/repo.rs:426`](../src/db/repo.rs#L426) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` - [`src/ingest.rs:50`](../src/ingest.rs#L50) — `pub fn persist(` +- [`tests/audio.rs:588`](../tests/audio.rs#L588) — `async fn search_never_returns_a_pending_manifest()` ### UR-001 -**Locations:** 3 +**Locations:** 4 - [`src/api/exists.rs:61`](../src/api/exists.rs#L61) — `pub async fn exists(` - [`src/api/exists.rs:74`](../src/api/exists.rs#L74) — `pub async fn exists_batch(` -- [`src/matching.rs:54`](../src/matching.rs#L54) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option` +- [`src/matching.rs:94`](../src/matching.rs#L94) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option` +- [`tests/audio.rs:419`](../tests/audio.rs#L419) — `async fn exists_reports_the_audio_tier_too()` ### UR-002 -**Locations:** 2 +**Locations:** 3 - [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `pub async fn post_manifest(` - [`src/ingest.rs:50`](../src/ingest.rs#L50) — `pub fn persist(` +- [`tests/audio.rs:588`](../tests/audio.rs#L588) — `async fn search_never_returns_a_pending_manifest()` ### UR-003 @@ -298,8 +325,8 @@ _None._ - [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `pub async fn post_manifest(` - [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome` -- [`src/model.rs:150`](../src/model.rs#L150) — `Unknown` -- [`src/model.rs:258`](../src/model.rs#L258) — `Unknown` +- [`src/model.rs:151`](../src/model.rs#L151) — `Unknown` +- [`src/model.rs:259`](../src/model.rs#L259) — `Unknown` - [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult` - [`src/worker.rs:150`](../src/worker.rs#L150) — `async fn run_cast_check(&self, payload: &str) -> Result<(), JobError>` @@ -337,20 +364,42 @@ _None._ ### UR-008 -**Locations:** 6 +**Locations:** 8 - [`src/api/federation.rs:66`](../src/api/federation.rs#L66) — `pub async fn get_changes(` - [`src/api/federation.rs:108`](../src/api/federation.rs#L108) — `pub async fn get_manifest_by_content_id(` - [`src/api/federation.rs:160`](../src/api/federation.rs#L160) — `pub async fn post_have(` - [`src/api/federation.rs:212`](../src/api/federation.rs#L212) — `pub async fn get_peers(` -- [`src/api/federation.rs:258`](../src/api/federation.rs#L258) — `pub async fn get_capabilities(State(state): State) -> ApiResult` +- [`src/api/federation.rs:261`](../src/api/federation.rs#L261) — `pub async fn get_capabilities(State(state): State) -> ApiResult` +- [`src/db/repo.rs:316`](../src/db/repo.rs#L316) — `pub fn adopt_audio_signature(` - [`src/worker.rs:107`](../src/worker.rs#L107) — `async fn run_federation_pull(&self, payload: &str) -> Result<(), JobError>` +- [`tests/audio.rs:304`](../tests/audio.rs#L304) — `async fn a_held_manifest_adopts_a_signature_it_lacked()` ### UR-009 -**Locations:** 1 +**Locations:** 21 +- [`src/api/mod.rs:38`](../src/api/mod.rs#L38) — `pub fn client_cut(&self) -> Result` +- [`src/api/search.rs:113`](../src/api/search.rs#L113) — `pub async fn post_search(` +- [`src/audio_sig.rs:113`](../src/audio_sig.rs#L113) — `pub fn compare(reference: &[u8], query: &[u8]) -> Option` +- [`src/db/repo.rs:316`](../src/db/repo.rs#L316) — `pub fn adopt_audio_signature(` +- [`src/matching.rs:94`](../src/matching.rs#L94) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option` - [`src/validate.rs:364`](../src/validate.rs#L364) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` +- [`tests/audio.rs:258`](../tests/audio.rs#L258) — `async fn a_contributed_signature_survives_storage_byte_for_byte()` +- [`tests/audio.rs:292`](../tests/audio.rs#L292) — `async fn a_manifest_without_a_signature_serves_no_signature_field()` +- [`tests/audio.rs:304`](../tests/audio.rs#L304) — `async fn a_held_manifest_adopts_a_signature_it_lacked()` +- [`tests/audio.rs:343`](../tests/audio.rs#L343) — `async fn a_matching_signature_reaches_the_audio_tier_on_a_fetch()` +- [`tests/audio.rs:360`](../tests/audio.rs#L360) — `async fn a_shifted_release_matches_and_gets_its_offset()` +- [`tests/audio.rs:402`](../tests/audio.rs#L402) — `async fn a_signature_that_disagrees_is_not_served_on_a_runtime_coincidence()` +- [`tests/audio.rs:419`](../tests/audio.rs#L419) — `async fn exists_reports_the_audio_tier_too()` +- [`tests/audio.rs:442`](../tests/audio.rs#L442) — `async fn a_signature_without_a_runtime_is_refused_by_name()` +- [`tests/audio.rs:460`](../tests/audio.rs#L460) — `async fn a_malformed_signature_is_a_bad_request_not_a_silent_downgrade()` +- [`tests/audio.rs:480`](../tests/audio.rs#L480) — `async fn search_identifies_a_file_with_no_metadata_at_all()` +- [`tests/audio.rs:506`](../tests/audio.rs#L506) — `async fn search_recovers_the_offset_for_a_differently_trimmed_release()` +- [`tests/audio.rs:532`](../tests/audio.rs#L532) — `async fn search_declines_content_it_does_not_hold()` +- [`tests/audio.rs:551`](../tests/audio.rs#L551) — `async fn search_applies_the_same_structural_rules_as_an_upload()` +- [`tests/audio.rs:567`](../tests/audio.rs#L567) — `async fn search_is_absent_when_the_operator_has_not_enabled_it()` +- [`tests/audio.rs:588`](../tests/audio.rs#L588) — `async fn search_never_returns_a_pending_manifest()` ### UR-010 @@ -358,23 +407,25 @@ _None._ - [`src/api/fetch.rs:269`](../src/api/fetch.rs#L269) — `pub fn reconstruct(` - [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome` -- [`src/db/repo.rs:405`](../src/db/repo.rs#L405) — `pub fn actors_for_manifest(` -- [`src/model.rs:173`](../src/model.rs#L173) — `Unknown` +- [`src/db/repo.rs:500`](../src/db/repo.rs#L500) — `pub fn actors_for_manifest(` +- [`src/model.rs:174`](../src/model.rs#L174) — `Unknown` ### UR-011 -**Locations:** 4 +**Locations:** 6 -- [`src/model.rs:150`](../src/model.rs#L150) — `Unknown` -- [`src/model.rs:258`](../src/model.rs#L258) — `Unknown` +- [`src/model.rs:151`](../src/model.rs#L151) — `Unknown` +- [`src/model.rs:259`](../src/model.rs#L259) — `Unknown` - [`src/validate.rs:136`](../src/validate.rs#L136) — `fn is_allowed_text_char(c: char) -> bool` - [`src/validate.rs:364`](../src/validate.rs#L364) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` +- [`tests/audio.rs:460`](../tests/audio.rs#L460) — `async fn a_malformed_signature_is_a_bad_request_not_a_silent_downgrade()` +- [`tests/audio.rs:551`](../tests/audio.rs#L551) — `async fn search_applies_the_same_structural_rules_as_an_upload()` ### UR-012 **Locations:** 2 -- [`src/db/repo.rs:331`](../src/db/repo.rs#L331) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` +- [`src/db/repo.rs:426`](../src/db/repo.rs#L426) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` - [`src/ingest.rs:50`](../src/ingest.rs#L50) — `pub fn persist(` ### UR-013 @@ -382,45 +433,46 @@ _None._ **Locations:** 4 - [`src/api/fetch.rs:269`](../src/api/fetch.rs#L269) — `pub fn reconstruct(` -- [`src/model.rs:173`](../src/model.rs#L173) — `Unknown` -- [`src/model.rs:232`](../src/model.rs#L232) — `pub fn from_stored(s: &str) -> Option` +- [`src/model.rs:174`](../src/model.rs#L174) — `Unknown` +- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_stored(s: &str) -> Option` - [`src/validate.rs:496`](../src/validate.rs#L496) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult>` ### UR-014 -**Locations:** 3 +**Locations:** 4 -- [`src/api/federation.rs:258`](../src/api/federation.rs#L258) — `pub async fn get_capabilities(State(state): State) -> ApiResult` -- [`src/model.rs:258`](../src/model.rs#L258) — `Unknown` +- [`src/api/federation.rs:261`](../src/api/federation.rs#L261) — `pub async fn get_capabilities(State(state): State) -> ApiResult` +- [`src/model.rs:259`](../src/model.rs#L259) — `Unknown` - [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult` +- [`tests/audio.rs:567`](../tests/audio.rs#L567) — `async fn search_is_absent_when_the_operator_has_not_enabled_it()` ### UR-015 **Locations:** 3 -- [`src/validate.rs:731`](../src/validate.rs#L731) — `fn extinction_sec_replaces_anneal_sec()` -- [`tests/api.rs:323`](../tests/api.rs#L323) — `async fn the_withdrawn_anneal_sec_field_is_rejected()` -- [`tests/api.rs:347`](../tests/api.rs#L347) — `async fn the_schema_bump_fields_round_trip()` +- [`src/validate.rs:755`](../src/validate.rs#L755) — `fn extinction_sec_replaces_anneal_sec()` +- [`tests/api.rs:324`](../tests/api.rs#L324) — `async fn the_withdrawn_anneal_sec_field_is_rejected()` +- [`tests/api.rs:348`](../tests/api.rs#L348) — `async fn the_schema_bump_fields_round_trip()` ### UR-016 **Locations:** 2 -- [`src/validate.rs:731`](../src/validate.rs#L731) — `fn extinction_sec_replaces_anneal_sec()` -- [`tests/api.rs:347`](../tests/api.rs#L347) — `async fn the_schema_bump_fields_round_trip()` +- [`src/validate.rs:755`](../src/validate.rs#L755) — `fn extinction_sec_replaces_anneal_sec()` +- [`tests/api.rs:348`](../tests/api.rs#L348) — `async fn the_schema_bump_fields_round_trip()` ### UR-017 **Locations:** 2 -- [`src/model.rs:191`](../src/model.rs#L191) — `Unknown` -- [`src/model.rs:232`](../src/model.rs#L232) — `pub fn from_stored(s: &str) -> Option` +- [`src/model.rs:192`](../src/model.rs#L192) — `Unknown` +- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_stored(s: &str) -> Option` ### UR-018 **Locations:** 1 -- [`src/ingest.rs:387`](../src/ingest.rs#L387) — `fn content_id_excludes_belief_and_route()` +- [`src/ingest.rs:406`](../src/ingest.rs#L406) — `fn content_id_excludes_belief_and_route()` ### UR-019 diff --git a/src/api/exists.rs b/src/api/exists.rs index 8c79308..615f5da 100644 --- a/src/api/exists.rs +++ b/src/api/exists.rs @@ -112,7 +112,7 @@ async fn lookup_one(state: &AppState, params: &LookupParams) -> ApiResult (None, None), IdentityType::Episode => (params.season, params.episode), }; - let client_cut = params.client_cut(); + let client_cut = params.client_cut()?; let found = state .db @@ -126,10 +126,8 @@ async fn lookup_one(state: &AppState, params: &LookupParams) -> ApiResult = candidates - .iter() - .map(|m| (m.id.clone(), StoredCut { runtime_sec: m.runtime_sec })) - .collect(); + let cuts: Vec<(String, StoredCut)> = + candidates.iter().map(|m| (m.id.clone(), StoredCut::from_row(m))).collect(); let Some((id, m)) = matching::best_match(&client_cut, &cuts) else { return Ok(None); diff --git a/src/api/federation.rs b/src/api/federation.rs index 5a13689..bbe3d28 100644 --- a/src/api/federation.rs +++ b/src/api/federation.rs @@ -245,6 +245,9 @@ pub struct CapabilitiesResponse { /// §3: `POST /manifests/search` is expensive and therefore optional to /// implement, so it is advertised rather than assumed. pub audio_search: bool, + /// Whether the read endpoints will match on a supplied `audio_signature`. + /// Not configurable: matching costs one slide against the candidates a + /// title already narrows to, which is nothing like a corpus-wide search. pub audio_tier_matching: bool, } @@ -261,9 +264,8 @@ pub async fn get_capabilities(State(state): State) -> ApiResult (params.tmdb_id.clone(), params.imdb_id.clone()), IdentityType::Episode => (params.series_tmdb_id.clone(), params.series_imdb_id.clone()), }; - let client_cut = params.client_cut(); + let client_cut = params.client_cut()?; let found = state .db @@ -99,7 +99,7 @@ async fn fetch_best( let cuts: Vec<(ManifestRow, StoredCut)> = candidates .into_iter() .map(|m| { - let cut = StoredCut { runtime_sec: m.runtime_sec }; + let cut = StoredCut::from_row(&m); (m, cut) }) .collect(); @@ -346,7 +346,11 @@ pub fn reconstruct( cut: Cut { runtime_sec: row.runtime_sec, container_duration_sec: None, - audio_signature: None, + // Re-encoded from the stored bytes, not echoed (§7). Serving it is + // what makes the plugin's own alignment possible at all: JR-047 + // compares this against a signature computed from the local file, + // and the server has never seen that file. + audio_signature: row.audio_signature.as_deref().map(crate::audio_sig::encode), }, extraction: has_extraction.then_some(extraction), actors, diff --git a/src/api/mod.rs b/src/api/mod.rs index 0d54383..dfca280 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -5,10 +5,13 @@ pub mod federation; pub mod fetch; pub mod json; pub mod report; +pub mod search; pub mod upload; use serde::Deserialize; +use crate::audio_sig; +use crate::error::ApiError; use crate::matching::ClientCut; /// Identity + cut query parameters, shared by the read endpoints (§4). @@ -21,14 +24,47 @@ pub struct LookupParams { pub season: Option, pub episode: Option, pub runtime_sec: Option, + /// The client's own `v1:` signature (§3, UR-009), so the `audio` tier can + /// fire on the ordinary read paths and not only on `/manifests/search`. + /// + /// It rides in the query string on the `GET` forms. That is ~2.3 KB encoded + /// — comfortably inside any server's request-line limit — and it is not a + /// file-level signal, so unlike the withdrawn `video_hash` it does not turn + /// a read into an oracle for which *copy* a caller holds (§3, PR-005). + pub audio_signature: Option, } impl LookupParams { - pub fn client_cut(&self) -> ClientCut { - ClientCut { - // A non-finite or non-positive runtime is not a usable signal; treat - // it as absent rather than letting it drive a match. - runtime_sec: self.runtime_sec.filter(|r| r.is_finite() && *r > 0.0), - } + /// TRACES: UR-009 | SR-003 + pub fn client_cut(&self) -> Result { + // A non-finite or non-positive runtime is not a usable signal; treat it + // as absent rather than letting it drive a match. + let runtime_sec = self.runtime_sec.filter(|r| r.is_finite() && *r > 0.0); + + let audio_bins = match self.audio_signature.as_deref() { + None => None, + Some(sig) => { + let packed = audio_sig::decode(sig).ok_or_else(|| { + ApiError::BadRequest( + "audio_signature: not a structurally valid 'v1:' signature".into(), + ) + })?; + // The offset has a window-anchor term that only the two runtimes + // give (§3), so a signature without a runtime cannot produce an + // alignment. Refused by name rather than silently answered at a + // lower tier, which would look like a match the client's own + // signature had failed to improve. + if runtime_sec.is_none() { + return Err(ApiError::BadRequest( + "audio_signature: requires runtime_sec — the offset's window-anchor \ + term is derived from both runtimes" + .into(), + )); + } + Some(audio_sig::peak_bins(&packed)) + } + }; + + Ok(ClientCut { runtime_sec, audio_bins }) } } diff --git a/src/api/search.rs b/src/api/search.rs new file mode 100644 index 0000000..7fd0871 --- /dev/null +++ b/src/api/search.rs @@ -0,0 +1,257 @@ +//! `POST /manifests/search` — §3 unknown-providence search, UR-009. +//! +//! Everything else in §4 starts from a title: the caller says "TMDB 504172" and +//! the server answers. This endpoint is for the case where the caller cannot — +//! a renamed file, no usable metadata, nothing to look up — and asks the only +//! question it can answer from the media itself: *what is this, and how is it +//! aligned?* It returns candidates with scores, offsets and title identity, so a +//! client identifies an unidentified file and aligns to it in one request. +//! +//! **It is a scaling problem, not a correctness one.** A naive implementation +//! slides against every stored signature. Three things keep it affordable, in +//! the order §3 gives them: a runtime prefilter that eliminates almost +//! everything, a coarse key that settles the re-encode case first, and a hard +//! rate limit (§5) because this is the most expensive read surface and the most +//! attractive to abuse. +//! +//! Optional to implement, and advertised rather than assumed: +//! `GET /federation/capabilities` carries `audio_search`. + +use axum::extract::State; +use axum::http::HeaderMap; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::{Deserialize, Serialize}; + +use crate::audio_sig; +use crate::db::repo; +use crate::error::{ApiError, ApiResult}; +use crate::matching::{self, ClientCut, StoredCut}; +use crate::model::IdentityType; +use crate::ratelimit::Surface; +use crate::state::{with_quota_headers, AppState}; + +/// §3: prefilter by runtime before scoring. +/// +/// Wider than the ±30 s `loose` tier on purpose. The runtime tiers ask "is this +/// the same cut?" and a large runtime difference answers no; this asks "is this +/// the same *content*, differently trimmed?", where a large runtime difference +/// is the premise rather than a disqualification. ±90 s comfortably contains the +/// ±56 s the slide can recover, with room for the trim that caused it. +pub const RUNTIME_PREFILTER_SEC: f64 = 90.0; + +/// The most candidates one search will slide against. +/// +/// A ceiling on the work one request can commission, not a tuning parameter. +/// The full slide is 1201 alignments over ~1290 frames, measured at **~1.1 ms +/// per candidate**, so this is a ~1.1 s budget for the worst request — chosen +/// against §8's request timeout rather than picked round. An unbounded scan is +/// an amplification factor that grows with the corpus, which is precisely the +/// property a rate limit cannot bound. +/// +/// When it binds, the response says so and the server logs it. A truncated scan +/// reported as a complete one would turn "no match" into a claim the server +/// cannot support — and it is the signal that the corpus has outgrown a linear +/// scan and needs the real index §3 leaves open. +pub const MAX_CANDIDATES: usize = 1000; + +/// At most this many results come back, best first. +pub const MAX_RESULTS: usize = 10; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SearchRequest { + pub audio_signature: String, + /// Required, not optional. It drives the prefilter, and the offset's + /// window-anchor term is derived from it and the stored runtime (§3) — a + /// search without it could rank candidates but could not align to them. + pub runtime_sec: f64, +} + +#[derive(Debug, Serialize)] +pub struct SearchResponse { + pub results: Vec, + /// How many stored signatures were actually slid against. + pub candidates_scored: usize, + /// True when [`MAX_CANDIDATES`] bound the scan, so "no match" here means + /// "not among the candidates scored", not "not held". + pub truncated: bool, +} + +#[derive(Debug, Serialize)] +pub struct SearchResult { + pub manifest_id: String, + pub r#match: &'static str, + pub score: f64, + pub offset_sec: f64, + pub identity: SearchIdentity, +} + +/// Title identity, which is the half of the answer `exists` cannot give: a +/// caller with no TMDB id has nothing to probe `exists` with. +/// +/// Cut-level, like every other tier (§3) — it says which *work* this is, never +/// which copy, so it is not the release-level oracle `video_hash` was withdrawn +/// for being. +#[derive(Debug, Serialize)] +pub struct SearchIdentity { + pub r#type: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub tmdb_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub imdb_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub year: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub season: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub episode: Option, +} + +/// TRACES: UR-009 | SR-003 | PR-005 +pub async fn post_search( + State(state): State, + peer: crate::state::PeerIp, + headers: HeaderMap, + super::json::Json(req): super::json::Json, +) -> ApiResult { + // §3: optional for a server to implement. An operator who would rather not + // carry the cost turns it off, and `capabilities` stops advertising it, so a + // client discovers the absence in one cheap request rather than as a 404 per + // search. + if !state.config.enable_audio_search { + return Err(ApiError::NotFound); + } + + let ip = state.client_ip(&headers, peer.0); + let quota = state.check_limit(&ip, Surface::Search)?; + + if !req.runtime_sec.is_finite() || req.runtime_sec <= 0.0 { + return Err(ApiError::BadRequest("runtime_sec: must be a positive duration".into())); + } + // The same structural rules the upload path applies (§6), so a search cannot + // be a way to hand the server bytes an upload would have refused. + crate::validate::validate_audio_signature(&req.audio_signature, req.runtime_sec) + .map_err(|e| ApiError::BadRequest(e.to_string()))?; + + let packed = audio_sig::decode(&req.audio_signature).ok_or_else(|| { + ApiError::BadRequest("audio_signature: not a structurally valid 'v1:' signature".into()) + })?; + let coarse = audio_sig::coarse_key(&packed); + let client = ClientCut { + runtime_sec: Some(req.runtime_sec), + audio_bins: Some(audio_sig::peak_bins(&packed)), + }; + let runtime_sec = req.runtime_sec; + + let body = state + .db + .read(move |conn| { + let total = + repo::count_search_candidates(conn, runtime_sec, RUNTIME_PREFILTER_SEC)? as usize; + let candidates = repo::search_candidates( + conn, + runtime_sec, + RUNTIME_PREFILTER_SEC, + &coarse, + MAX_CANDIDATES, + )?; + + let mut results = Vec::new(); + for row in &candidates { + let Some(m) = matching::match_cut(&client, &StoredCut::from_row(row)) else { + continue; + }; + // A candidate that cleared the prefilter but has no signature to + // score cannot answer this question — `match_cut` would have + // fallen back to the runtime tier, which is not what was asked. + let Some(score) = m.score else { continue }; + + let Some(title) = repo::title_by_id(conn, &row.title_id)? else { continue }; + let kind = + if row.episode.is_some() { IdentityType::Episode } else { IdentityType::Movie }; + results.push(( + score, + SearchResult { + manifest_id: row.id.clone(), + r#match: m.tier.as_str(), + score, + offset_sec: m.offset_sec, + identity: identity_of(&title, row, kind), + }, + )); + } + + results.sort_by(|a, b| b.0.total_cmp(&a.0)); + results.truncate(MAX_RESULTS); + + Ok(SearchResponse { + results: results.into_iter().map(|(_, r)| r).collect(), + candidates_scored: candidates.len(), + truncated: total > candidates.len(), + }) + }) + .await + .map_err(ApiError::Internal)?; + + if body.truncated { + tracing::warn!( + scored = body.candidates_scored, + "search truncated at the candidate cap — corpus has outgrown a linear scan" + ); + } + + Ok(with_quota_headers(Json(body).into_response(), quota)) +} + +fn identity_of( + title: &repo::TitleRow, + row: &repo::ManifestRow, + kind: IdentityType, +) -> SearchIdentity { + SearchIdentity { + r#type: match kind { + IdentityType::Movie => "movie", + IdentityType::Episode => "episode", + }, + tmdb_id: title.tmdb_id.clone(), + imdb_id: title.imdb_id.clone(), + title: title.name.clone(), + year: title.year, + season: row.season, + episode: row.episode, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_request_rejects_unknown_fields() { + // §6 stage 2 applies here too: this is a read, but it is still a body + // the parse boundary owns (DR-001). + let r = serde_json::from_str::( + r#"{"audio_signature":"v1:AAAA","runtime_sec":6420.5,"video_hash":"x"}"#, + ); + assert!(r.is_err()); + } + + #[test] + fn runtime_is_required_not_optional() { + // Without it there is no prefilter and no window anchor, so a search + // that omitted it would be a full scan that could not align. + let r = serde_json::from_str::(r#"{"audio_signature":"v1:AAAA"}"#); + assert!(r.is_err()); + } + + #[test] + fn the_prefilter_is_wider_than_the_slide_can_recover() { + // The band has to contain the trim differences the slide handles, or the + // prefilter would discard the candidates the endpoint exists to find. + let recoverable = f64::from(audio_sig::SEARCH_CAP_FRAMES) * audio_sig::HOP_SEC; + assert!(RUNTIME_PREFILTER_SEC > recoverable, "{RUNTIME_PREFILTER_SEC} vs {recoverable}"); + } +} diff --git a/src/app.rs b/src/app.rs index 4b859cd..3b41c37 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,7 +14,7 @@ use axum::Router; use tower_http::timeout::TimeoutLayer; use tower_http::trace::TraceLayer; -use crate::api::{exists, federation, fetch, report, upload}; +use crate::api::{exists, federation, fetch, report, search, upload}; use crate::state::AppState; use crate::validate::limits; @@ -37,6 +37,9 @@ pub fn router(state: AppState) -> Router { .route("/manifests/{id}", get(fetch::get_by_id)) .route("/manifests/{id}/status", get(fetch::get_status)) .route("/manifests/{id}/report", post(report::post_report)) + // UR-9 — unknown-providence search. A `POST` because a signature does + // not fit a query string, not because it writes anything. + .route("/manifests/search", post(search::post_search)) // UR-2 — contribution. .route( "/manifests", diff --git a/src/audio_sig.rs b/src/audio_sig.rs new file mode 100644 index 0000000..7db84f0 --- /dev/null +++ b/src/audio_sig.rs @@ -0,0 +1,419 @@ +//! §3 audio signature — parsing, comparison and offset recovery. +//! +//! The server never *computes* a signature: it has no audio decoder and needs +//! none. Construction belongs to the two producers (the JRay plugin and the +//! extraction pipeline), pinned by the golden fixture §3 names. What lives here +//! is the consumer half — read a `v1:` signature, slide two of them against each +//! other, and report the score and the alignment. +//! +//! Only the **peak band** is scored. The 2-bit energy class is the coarser and +//! less re-encoding-stable of the two fields, and §3's rule names the peak bin +//! alone; it is carried in the byte so a future version can use it, not because +//! this comparison does. + +/// §3: the STFT hop, needed to turn a frame slide into seconds. +pub const HOP_SIZE: usize = 1024; +/// §3: the signature's own sample rate. +pub const SAMPLE_RATE: usize = 11025; +/// Seconds per frame — ~92.88 ms. +pub const HOP_SEC: f64 = HOP_SIZE as f64 / SAMPLE_RATE as f64; + +/// §3: the slide is capped at ±600 frames (~55.7 s), which covers realistic +/// trim differences. An offset outside it is declined rather than guessed at — +/// `scene-actor-extraction` VR-014 measured a 75 s displacement scoring 0.16, +/// well below `loose`, which is the decline working. +pub const SEARCH_CAP_FRAMES: i32 = 600; + +/// §3: `score ≥ 0.85` is the `audio` tier. +pub const AUDIO_THRESHOLD: f64 = 0.85; +/// §3: `0.60 ≤ score < 0.85` is `loose`. +pub const LOOSE_THRESHOLD: f64 = 0.60; + +/// §3: a frame counts as agreeing if the reference matches within ±1 frame. +/// +/// Not a fudge factor — the remedy VR-014 measured. Both windows are cut on +/// their own file's frame grid, and those grids do not coincide, so a correctly +/// aligned release scores by how nearly the two grids happen to line up rather +/// than by whether the content matches. Over 40 random offsets on real film +/// audio the exact rule demoted 27 correct alignments to `loose`; ±1 frame +/// restores every one of them (worst 0.906) while the strongest false match +/// stays at 0.16, so the threshold keeps the discrimination it is there for. +pub const SCORE_SLACK_FRAMES: i32 = 1; + +/// The minimum overlap an alignment must have before its score counts. +/// +/// Without a floor the extreme offsets compare a handful of frames, where a +/// chance agreement scores 1.0 and beats the true alignment. It never binds on +/// the real case — two full-length signatures still overlap by 688 frames at +/// the widest offset — and the value is the one the JRay plugin already ships, +/// so the two matchers cannot disagree at the edges. +pub const MIN_OVERLAP_FRAMES: usize = 64; + +/// §3: the coarse candidate-generation key is every 16th frame's peak bin. +pub const COARSE_STRIDE: usize = 16; + +/// The only signature version this build understands (IR-008). +pub const VERSION_PREFIX: &str = "v1:"; + +/// A signature read off the wire: the packed bytes, exactly as submitted. +/// +/// Stored verbatim so a fetch reconstructs the field byte-identically — §7 +/// requires the served document to be rebuilt from rows, and a signature that +/// came back re-derived rather than re-encoded would not be the same claim. +pub fn decode(sig: &str) -> Option> { + let payload = sig.strip_prefix(VERSION_PREFIX)?; + let bytes = crate::validate::base64_decode(payload).ok()?; + // A `v2:` payload that happens to decode is refused above by the prefix; + // this rejects a v1 payload that is not structurally a signature. + if bytes.iter().any(|b| b & 0x80 != 0) { + return None; + } + Some(bytes) +} + +/// The wire form of stored bytes — `v1:` plus standard padded base64. +pub fn encode(packed: &[u8]) -> String { + format!("{VERSION_PREFIX}{}", crate::validate::base64_encode(packed)) +} + +/// The per-frame peak band index, which is what the slide compares. +pub fn peak_bins(packed: &[u8]) -> Vec { + packed.iter().map(|b| b >> 2).collect() +} + +/// The coarse key §3 names for candidate generation. +/// +/// It matches only at **zero slide** — subsampling destroys alignment for any +/// other offset — so it is a fast path for the common case (the same release, +/// re-encoded), not the search itself. The runtime prefilter carries the +/// shifted releases, and the full slide decides all of them. +pub fn coarse_key(packed: &[u8]) -> Vec { + packed.iter().step_by(COARSE_STRIDE).map(|b| b >> 2).collect() +} + +/// The best alignment found between two signatures. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SlideMatch { + /// Fraction of overlapping frames whose peak bin agrees, within the slack. + pub score: f64, + /// Frames by which the query's window begins later than the reference's: + /// `query[i]` lines up with `reference[i + slide]`. + /// + /// This is **not** the offset a client applies. It is one of the two terms; + /// see [`crate::matching::audio_offset_sec`], which adds the window-anchor + /// difference the two runtimes imply. + pub slide: i32, +} + +/// Slides `query` against `reference` over ±[`SEARCH_CAP_FRAMES`], returning the +/// best-scoring alignment (§3 "Matching and offset recovery"). +/// +/// `None` when no alignment in the cap overlaps by [`MIN_OVERLAP_FRAMES`] — two +/// signatures too short to compare, rather than two that disagree. +/// TRACES: UR-009 | SR-003 +pub fn compare(reference: &[u8], query: &[u8]) -> Option { + let mut best: Option = None; + + let slack = SCORE_SLACK_FRAMES as usize; + let smeared = smear(reference, slack); + + for slide in -SEARCH_CAP_FRAMES..=SEARCH_CAP_FRAMES { + let (a, b) = overlap(reference, query, slide); + let n = a.len().min(b.len()); + if n < MIN_OVERLAP_FRAMES { + continue; + } + let (a, b) = (&a[..n], &b[..n]); + + // A frame agrees if the reference carries the same peak bin anywhere + // within ±`slack` frames of it — which is a membership test over a + // fixed neighbourhood, so it is precomputed once per reference as a + // 32-bit set of bins and read back with a shift. Every slide otherwise + // re-derives the same neighbourhoods, and there are 1201 of them. + // + // The interior is the precomputed case: `a[i]` is `reference[base + i]`, + // and for `1 <= i <= n-2` its neighbourhood lies wholly inside the + // overlap, so the whole-reference set is the right one. The two edge + // frames have their window clipped by the overlap rather than by the + // signature, so they are counted directly. + let base = slide.max(0) as usize; + let mut agreed = 0usize; + for i in [0, n - 1] { + let lo = i.saturating_sub(slack); + let hi = (i + slack).min(n - 1); + if a[lo..=hi].contains(&b[i]) { + agreed += 1; + } + } + for i in 1..n - 1 { + agreed += ((smeared[base + i] >> b[i]) & 1) as usize; + } + let score = agreed as f64 / n as f64; + + // Slack flattens the score's peak, so neighbouring slides can tie. The + // smallest shift wins one, which keeps the reported alignment closest + // to the unshifted reading and — more importantly — makes the answer + // independent of the direction the search happens to sweep. + let better = best + .is_none_or(|m| score > m.score || (score == m.score && slide.abs() < m.slide.abs())); + if better { + best = Some(SlideMatch { score, slide }); + } + } + best +} + +/// For each frame, the set of peak bins occurring within ±`slack` of it, as a +/// 32-bit mask — one bit per band, which is exactly what 5 bits of band index +/// allows. +fn smear(reference: &[u8], slack: usize) -> Vec { + let last = reference.len().saturating_sub(1); + (0..reference.len()) + .map(|j| { + let lo = j.saturating_sub(slack); + let hi = (j + slack).min(last); + reference[lo..=hi].iter().fold(0u32, |m, &b| m | 1 << b) + }) + .collect() +} + +/// The overlapping regions of the two signatures at `slide`. +/// +/// A slide that runs one of them off the end entirely yields empty slices, which +/// the caller then skips on the overlap floor like any other thin alignment — +/// no separate error case, because "no overlap" is not a failure. +fn overlap<'a>(reference: &'a [u8], query: &'a [u8], slide: i32) -> (&'a [u8], &'a [u8]) { + let s = slide.unsigned_abs() as usize; + if s >= reference.len() || s >= query.len() { + return (&[], &[]); + } + if slide >= 0 { + (&reference[s..], &query[..query.len() - s]) + } else { + (&reference[..reference.len() - s], &query[s..]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The shared golden vector — the fixture the two producers are held to. + /// + /// Used here for the **wire format** only: length, round-trip, version + /// prefix. It is the wrong input for the slide, and deliberately so. It is a + /// tone sweep, and its peak-bin sequence is a staircase of ~32-frame runs + /// stepping through the bands with a period of ~1024 frames — its own notes + /// call it pathologically easy to align, which cuts both ways: long runs and + /// a short period make some alignments ambiguous that no film audio would + /// be. `scene-actor-extraction` VR-014 exists to measure the slide on real + /// film audio and has done so; what the tests below check is the algorithm's + /// own properties, on a sequence with film-like statistics. + fn golden() -> String { + let raw = include_str!("../tests/fixtures/audio/jray_audio_v1_golden.json"); + let v: serde_json::Value = serde_json::from_str(raw).unwrap(); + v["signature"].as_str().unwrap().to_string() + } + + /// The frame count of a real signature (§3): a 120 s window at a 1024-sample + /// hop and 11025 Hz. + const FRAMES: usize = 1288; + + /// A deterministic peak-bin sequence with the statistics dialogue and score + /// produce — no long runs, no short period — from a plain LCG so the test + /// input is reproducible without a fixture or an audio decoder. + fn bins_from(seed: u64) -> Vec { + let mut x = seed; + (0..FRAMES) + .map(|_| { + x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + ((x >> 33) % 32) as u8 + }) + .collect() + } + + #[test] + fn decodes_the_golden_vector_to_its_recorded_frame_count() { + let raw = include_str!("../tests/fixtures/audio/jray_audio_v1_golden.json"); + let v: serde_json::Value = serde_json::from_str(raw).unwrap(); + let packed = decode(&golden()).unwrap(); + assert_eq!(packed.len() as u64, v["frame_count"].as_u64().unwrap()); + } + + #[test] + fn encode_round_trips_the_golden_vector_byte_for_byte() { + // §7: the served document is rebuilt from rows. A signature that did not + // re-encode exactly would come back as a different claim than the one + // that was contributed. + let sig = golden(); + assert_eq!(encode(&decode(&sig).unwrap()), sig); + } + + #[test] + fn a_v2_signature_is_refused_rather_than_parsed() { + // IR-008: a future producer's DSP change must be detectable, not + // silently scored as if it were understood. + let payload = golden().strip_prefix(VERSION_PREFIX).unwrap().to_string(); + assert!(decode(&format!("v2:{payload}")).is_none()); + assert!(decode(&payload).is_none()); + } + + #[test] + fn a_reserved_high_bit_is_not_a_signature() { + assert!(decode("v1:gAAA").is_none()); + assert!(decode("v1:AAAA").is_some()); + } + + #[test] + fn identical_signatures_score_one_at_zero_slide() { + let bins = bins_from(1); + let m = compare(&bins, &bins).unwrap(); + assert_eq!(m.slide, 0); + assert!((m.score - 1.0).abs() < 1e-12, "score {}", m.score); + } + + #[test] + fn a_known_slide_is_recovered_to_the_nearest_frame() { + // The query is the reference's own tail, so the true alignment is known + // exactly: dropping `k` frames from the front makes the query's window + // begin `k` frames later. + // + // "Nearest frame" rather than "exactly" is the price of the slack, and + // VR-014 measured it: the score's peak flattens, so the argmax can pick + // an adjacent frame. One frame is 93 ms against the 500 ms budget the + // offset is spent on — shifting scene windows that are seconds long. + let bins = bins_from(2); + for k in [1usize, 37, 200, 600] { + let m = compare(&bins, &bins[k..]).unwrap(); + assert!((m.slide - k as i32).abs() <= 1, "slide of {k} frames gave {}", m.slide); + assert!(m.score >= AUDIO_THRESHOLD, "slide {k} scored {}", m.score); + } + } + + #[test] + fn a_negative_slide_is_recovered_too() { + // A client whose window begins *earlier* than the manifest's, which is + // the other half of every trim difference. + let bins = bins_from(3); + let m = compare(&bins[50..], &bins).unwrap(); + assert!((m.slide + 50).abs() <= 1, "slide {}", m.slide); + assert!(m.score >= AUDIO_THRESHOLD); + } + + #[test] + fn an_offset_past_the_cap_is_declined_not_guessed() { + // §3: the cap is the spec's, not a convenience, and beyond it the answer + // must be "no match" rather than a best-effort alignment. VR-014 saw + // 0.16 on a real 75 s displacement. + let bins = bins_from(4); + let beyond = SEARCH_CAP_FRAMES as usize + 100; + let m = compare(&bins, &bins[beyond..]).unwrap(); + assert!(m.score < LOOSE_THRESHOLD, "invented an alignment at {}", m.score); + } + + #[test] + fn unrelated_content_does_not_reach_the_loose_tier() { + let a = bins_from(5); + let b = bins_from(6); + let m = compare(&a, &b).unwrap(); + assert!(m.score < LOOSE_THRESHOLD, "unrelated content scored {}", m.score); + } + + #[test] + fn one_frame_of_slack_absorbs_a_shifted_frame_grid() { + // The failure VR-014 found, and the reason for SCORE_SLACK_FRAMES: the + // content agrees, but the two windows are cut on frame grids that do not + // coincide, so peaks land a frame early or late. Simulated by inserting + // one frame, which shifts everything after it by one. + // + // Both halves are asserted, because slack is only defensible if it + // rescues this case *without* rescuing the one below. + let bins = bins_from(7); + let mut jittered = bins.clone(); + jittered.insert(bins.len() / 2, bins[bins.len() / 2]); + jittered.truncate(bins.len()); + + let m = compare(&bins, &jittered).unwrap(); + assert!( + m.score >= AUDIO_THRESHOLD, + "a one-frame grid shift should stay in the `audio` tier, scored {}", + m.score + ); + } + + #[test] + fn slack_does_not_rescue_content_that_disagrees() { + // The other half of the VR-014 measurement: slack must not narrow the + // gap that makes the threshold mean anything. Reversed content shares + // every frame value and no ordering, which is the hardest version of + // "different content over the same alphabet". + let bins = bins_from(8); + let reversed: Vec = bins.iter().rev().copied().collect(); + let m = compare(&bins, &reversed).unwrap(); + assert!(m.score < LOOSE_THRESHOLD, "reversed content scored {}", m.score); + } + + #[test] + fn too_little_overlap_is_no_comparison_at_all() { + // Without the floor, an extreme slide compares a handful of frames where + // a chance agreement scores 1.0 and beats the true alignment. + let bins = bins_from(9); + assert!(compare(&bins, &bins[..MIN_OVERLAP_FRAMES - 1]).is_none()); + } + + /// The rule as §3 states it, written the obvious way. + /// + /// `compare` precomputes the neighbourhood as a bitmask because 1201 slides + /// otherwise re-derive the same sets, and that optimisation is only worth + /// having if it is exactly equivalent — the edge frames in particular have + /// their window clipped by the overlap rather than by the signature. + fn naive(reference: &[u8], query: &[u8]) -> Option { + let slack = SCORE_SLACK_FRAMES as usize; + let mut best: Option = None; + for slide in -SEARCH_CAP_FRAMES..=SEARCH_CAP_FRAMES { + let (a, b) = overlap(reference, query, slide); + let n = a.len().min(b.len()); + if n < MIN_OVERLAP_FRAMES { + continue; + } + let (a, b) = (&a[..n], &b[..n]); + let agreed = (0..n) + .filter(|&i| a[i.saturating_sub(slack)..=(i + slack).min(n - 1)].contains(&b[i])) + .count(); + let score = agreed as f64 / n as f64; + if best.is_none_or(|m: SlideMatch| { + score > m.score || (score == m.score && slide.abs() < m.slide.abs()) + }) { + best = Some(SlideMatch { score, slide }); + } + } + best + } + + #[test] + fn the_fast_path_is_exactly_the_rule_as_written() { + let a = bins_from(30); + for (r, q) in [ + (a.clone(), a.clone()), + (a.clone(), a[7..].to_vec()), + (a[400..].to_vec(), a.clone()), + (a.clone(), bins_from(31)), + (a.clone(), a.iter().rev().copied().collect()), + (a[..600].to_vec(), a[..600].to_vec()), + ] { + assert_eq!(compare(&r, &q), naive(&r, &q)); + } + } + + #[test] + fn the_coarse_key_matches_only_at_zero_slide() { + // Why the coarse key is a fast path and not the search: subsampling + // every 16th frame destroys the alignment for any other offset, so a + // shifted release must reach the full slide. + let packed = decode(&golden()).unwrap(); + let bins = bins_from(10); + assert_eq!(coarse_key(&packed), coarse_key(&packed)); + assert_ne!(coarse_key(&bins), coarse_key(&bins[1..])); + assert_eq!(coarse_key(&packed).len(), packed.len().div_ceil(COARSE_STRIDE)); + } +} diff --git a/src/config.rs b/src/config.rs index f65e6e5..2b2a050 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,6 +26,12 @@ pub struct Config { pub publish_peer_directory: bool, /// Human contact for operators arranging a peering out of band. pub contact: Option, + /// §3: `POST /manifests/search` is the most expensive read surface, and the + /// spec makes it optional for a server to implement rather than assumed. + /// Default on, since this build does implement it; an operator carrying a + /// large corpus on small hardware turns it off and + /// `GET /federation/capabilities` stops advertising it. + pub enable_audio_search: bool, pub job_batch: usize, pub job_poll_interval: Duration, } @@ -57,6 +63,9 @@ impl Config { .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false), contact: std::env::var("JRAY_CONTACT").ok().filter(|s| !s.is_empty()), + enable_audio_search: std::env::var("JRAY_AUDIO_SEARCH") + .map(|v| !(v == "0" || v.eq_ignore_ascii_case("false"))) + .unwrap_or(true), job_batch: env_num("JRAY_JOB_BATCH", 8) as usize, job_poll_interval: Duration::from_secs(env_num("JRAY_JOB_POLL_SEC", 5)), }) diff --git a/src/db/repo.rs b/src/db/repo.rs index bbd4488..5ff2a79 100644 --- a/src/db/repo.rs +++ b/src/db/repo.rs @@ -307,6 +307,101 @@ pub fn manifest_by_content_id( Ok(stmt.query_row(params![content_id], |r| r.get::<_, String>(0)).optional()?) } +/// §9a: a manifest already held but lacking a signature adopts an incoming one. +/// +/// Only ever fills a hole — `WHERE audio_signature IS NULL` — so a later upload +/// cannot overwrite a signature the server already has. The signature is not +/// part of the content address, so an overwrite would be an unauditable change +/// to stored content that the `content_id` could not detect. +/// TRACES: UR-008, UR-009 | SR-003 +pub fn adopt_audio_signature( + tx: &Transaction<'_>, + manifest_id: &str, + signature: &[u8], + coarse: &[u8], +) -> anyhow::Result { + let n = tx.execute( + "UPDATE manifests SET audio_signature = ?2, audio_sig_coarse = ?3 + WHERE id = ?1 AND audio_signature IS NULL", + params![manifest_id, signature, coarse], + )?; + Ok(n > 0) +} + +/// §3 "Unknown-providence search" stage 1: manifests whose runtime is close +/// enough to be worth the sliding comparison. +/// +/// The prefilter is the cheap half and eliminates almost everything. ±90 s is +/// the spec's band, and it is necessarily wider than the ±30 s `loose` tier: +/// the whole point of the audio route is to match releases whose runtimes +/// disagree, so a band as tight as the runtime tiers would exclude exactly the +/// candidates the search exists to find. +/// +/// Ordered so that the coarse key — equal only at zero slide, i.e. the same +/// release re-encoded — is scored first. That way the `limit` truncates the +/// least likely candidates rather than an arbitrary set. +pub fn search_candidates( + conn: &Connection, + runtime_sec: f64, + tolerance_sec: f64, + coarse: &[u8], + limit: usize, +) -> anyhow::Result> { + let sql = format!( + "SELECT {MANIFEST_COLUMNS} FROM manifests + WHERE status IN {SERVED_STATUSES} + AND audio_signature IS NOT NULL + AND ABS(runtime_sec - ?1) <= ?2 + ORDER BY CASE WHEN audio_sig_coarse = ?3 THEN 0 ELSE 1 END ASC, + ABS(runtime_sec - ?1) ASC, id ASC + LIMIT ?4" + ); + let mut stmt = conn.prepare_cached(&sql)?; + let rows = stmt + .query_map(params![runtime_sec, tolerance_sec, coarse, limit as i64], map_manifest)? + .collect::>>()?; + Ok(rows) +} + +/// How many manifests the prefilter would have returned, so a truncated search +/// can say so rather than presenting a partial scan as a complete one. +pub fn count_search_candidates( + conn: &Connection, + runtime_sec: f64, + tolerance_sec: f64, +) -> anyhow::Result { + let sql = format!( + "SELECT COUNT(*) FROM manifests + WHERE status IN {SERVED_STATUSES} + AND audio_signature IS NOT NULL + AND ABS(runtime_sec - ?1) <= ?2" + ); + let mut stmt = conn.prepare_cached(&sql)?; + Ok(stmt.query_row(params![runtime_sec, tolerance_sec], |r| r.get(0))?) +} + +/// The title a manifest hangs off, for reporting search results. +pub fn title_by_id(conn: &Connection, title_id: &str) -> anyhow::Result> { + let mut stmt = conn.prepare_cached( + "SELECT id, kind, tmdb_id, imdb_id, name, year, adult, certification + FROM titles WHERE id = ?1", + )?; + Ok(stmt + .query_row(params![title_id], |r| { + Ok(TitleRow { + id: r.get(0)?, + kind: r.get(1)?, + tmdb_id: r.get(2)?, + imdb_id: r.get(3)?, + name: r.get(4)?, + year: r.get(5)?, + adult: r.get::<_, i64>(6)? != 0, + certification: r.get(7)?, + }) + }) + .optional()?) +} + /// Everything needed to insert one manifest. pub struct NewManifest<'a> { pub id: &'a str, diff --git a/src/db/schema.sql b/src/db/schema.sql index d49952b..8cb5c03 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -170,4 +170,17 @@ CREATE INDEX IF NOT EXISTS idx_manifests_served ON manifests(title_id, season, episode) WHERE status IN ('listed', 'flagged'); +-- §3 unknown-providence search. The runtime prefilter is what makes the +-- endpoint affordable — a naive implementation slides against every stored +-- signature — so it gets its own index rather than riding on the title-keyed +-- one above, since the search has no title to key on. +CREATE INDEX IF NOT EXISTS idx_manifests_search_runtime + ON manifests(runtime_sec) + WHERE status IN ('listed', 'flagged') AND audio_signature IS NOT NULL; + +-- The coarse key matches only at zero slide, i.e. the same release re-encoded. +-- That is the common case and the cheapest to settle, so it is indexed and +-- scored first; shifted releases fall through to the full slide. +CREATE INDEX IF NOT EXISTS idx_manifests_coarse ON manifests(audio_sig_coarse); + CREATE INDEX IF NOT EXISTS idx_jobs_ready ON jobs(run_after); diff --git a/src/ingest.rs b/src/ingest.rs index 601de4e..c9ee8c6 100644 --- a/src/ingest.rs +++ b/src/ingest.rs @@ -81,7 +81,23 @@ pub fn persist( // cast check drops unmatched actors, since dropping changes the content. let cid = compute_content_id(valid); + // Validation already proved this parses and is structurally a signature + // (§6 stage 2), so a failure here is not reachable from a validated + // manifest; treating it as absent rather than panicking keeps a future + // divergence between the two from taking an upload down. + let signature = m.cut.audio_signature.as_deref().and_then(crate::audio_sig::decode); + let coarse = signature.as_deref().map(crate::audio_sig::coarse_key); + if let Some(existing) = repo::manifest_by_content_id(tx, &cid)? { + // §9a: the signature is excluded from `content_id` and replicates as an + // *attribute*, so "already held" and "already held with a signature" are + // different states. A peer that holds the manifest but lacks its + // signature adopts the incoming one — otherwise a manifest that first + // arrived from a producer without audio could never gain a signature, + // and the whole cut would sit permanently outside the `audio` tier. + if let Some(sig) = &signature { + repo::adopt_audio_signature(tx, &existing, sig, coarse.as_deref().unwrap_or(&[]))?; + } return Ok(IngestOutcome::DuplicateContent { manifest_id: existing }); } @@ -104,9 +120,12 @@ pub fn persist( season, episode, runtime_sec: m.cut.runtime_sec, - // Stored as an attribute, not part of identity (§9a). - audio_signature: None, - audio_sig_coarse: None, + // Stored as an attribute, not part of identity (§9a) — decoding + // audio is version-sensitive, so hashing the signature into the + // `content_id` would give two servers different ids for identical + // content and silently break federation deduplication. + audio_signature: signature.as_deref(), + audio_sig_coarse: coarse.as_deref(), sample_fps: extraction.and_then(|e| e.sample_fps), extinction_sec: extraction.and_then(|e| e.extinction_sec), pipeline_version: extraction.and_then(|e| e.pipeline_version.as_deref()), diff --git a/src/lib.rs b/src/lib.rs index dcddde8..8abeaf8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ //! | [`model`] | §2 Jmanifest format, and §6 stage 2's `deny_unknown_fields` | //! | [`validate`] | §6 stage 2 semantics, §5a character class | //! | [`matching`] | §3 cut matching tiers | +//! | [`audio_sig`] | §3 audio signature: parsing, comparison, offset recovery | //! | [`content_id`] | §9a canonical form and content addressing | //! | [`ingest`] | the shared upload path behind §4's `POST` endpoints | //! | [`castcheck`] | §6 stage 3 scoring, §5a Threat 2 guards | @@ -22,6 +23,7 @@ pub mod api; pub mod app; +pub mod audio_sig; pub mod auth; pub mod castcheck; pub mod config; diff --git a/src/matching.rs b/src/matching.rs index 168b3f9..88bce9f 100644 --- a/src/matching.rs +++ b/src/matching.rs @@ -11,6 +11,7 @@ //! transfer between cuts, so a file-level signal never bought accuracy the //! cut-level ones lack. +use crate::audio_sig::{self, AUDIO_THRESHOLD, HOP_SEC, LOOSE_THRESHOLD}; use crate::model::MatchTier; /// §3: runtimes within ±2s. @@ -22,55 +23,113 @@ pub const LOOSE_TOLERANCE_SEC: f64 = 30.0; #[derive(Debug, Clone, Default)] pub struct ClientCut { pub runtime_sec: Option, + /// The client's own signature, as peak bins (§3). Carried alongside + /// `runtime_sec` rather than instead of it: the offset has a window-anchor + /// term that only the two runtimes give, so a signature without a runtime + /// cannot produce an alignment — see [`audio_offset_sec`]. + pub audio_bins: Option>, } impl ClientCut { /// True when the client supplied nothing to match on, in which case §4 /// specifies a `"match": "unknown"` answer rather than a guess. pub fn is_empty(&self) -> bool { - self.runtime_sec.is_none() + self.runtime_sec.is_none() && self.audio_bins.is_none() } } /// What the server holds. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct StoredCut { pub runtime_sec: f64, + pub audio_bins: Option>, +} + +impl StoredCut { + pub fn from_row(row: &crate::db::repo::ManifestRow) -> Self { + Self { + runtime_sec: row.runtime_sec, + audio_bins: row.audio_signature.as_deref().map(audio_sig::peak_bins), + } + } } /// The outcome of comparing a client's cut against a stored one. #[derive(Debug, Clone, Copy, PartialEq)] pub struct CutMatch { pub tier: MatchTier, - /// Scene offset in seconds the client must add (§3 `audio` tier). Always - /// zero for the tiers implemented here; the field exists because the plugin - /// contract is "the server returns the offset, the client applies it", and - /// enabling `audio` must not change the response shape. + /// Scene offset in seconds the client adds to every window (§3). Non-zero + /// only on the `audio` route, which is the one that can recover it: the + /// runtime tiers know that two cuts are close, never by how much they are + /// displaced. pub offset_sec: f64, + /// The audio score, when the audio route decided this match. Present so a + /// caller ranking several candidates can order within a tier, and so + /// `/manifests/search` can report it. + pub score: Option, +} + +/// The scene offset a client applies, in seconds, from a recovered slide. +/// +/// **The offset has two terms, and using the slide alone is wrong by half the +/// runtime difference on every shifted release.** Both signatures are cut from +/// their own file's centre — `runtime/2 ± 60 s` — so when the runtimes differ +/// the two windows do not start at the same point in the content: +/// +/// ```text +/// offset = (client_runtime - stored_runtime) / 2 - slide × 1024/11025 +/// └──── window-anchor difference ────┘ └──── recovered ────┘ +/// ``` +/// +/// A release carrying 40 s of extra head material takes 20 s from each term. +/// The sign follows from `slide` being how many frames *later* the client's +/// window begins: the further into the content the client's window starts, the +/// earlier the manifest's timings fall in the client's timebase. +pub fn audio_offset_sec(client_runtime: f64, stored_runtime: f64, slide: i32) -> f64 { + (client_runtime - stored_runtime) / 2.0 - f64::from(slide) * HOP_SEC } /// Compares a client's cut against a stored one, returning the best tier that /// fires, or `None` for "beyond that: no match; do not serve" (§3). -/// TRACES: UR-001 | SR-001 +/// TRACES: UR-001, UR-009 | SR-001, SR-003 pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option { - // The `audio` tier slots in here, above `runtime`, once signature coverage - // is useful (§3 recommended sequencing). It is the top tier: with - // `video_hash` withdrawn there is nothing above it. + // §3: `audio` is the top tier because it is content-derived, where equal + // runtimes are only circumstantial. When both sides carry a signature its + // verdict is therefore final — including its refusals. Falling back to the + // runtime tier after the audio said "different content" would let a runtime + // coincidence overturn direct evidence, which inverts the ordering the tier + // table exists to state. The separation makes that safe rather than brave: + // VR-014 measured true matches at 0.906 and above and the strongest false + // one at 0.16. + if let (Some(c_bins), Some(s_bins), Some(c_rt)) = + (&client.audio_bins, &stored.audio_bins, client.runtime_sec) + { + let m = audio_sig::compare(s_bins, c_bins)?; + let offset_sec = audio_offset_sec(c_rt, stored.runtime_sec, m.slide); + let tier = if m.score >= AUDIO_THRESHOLD { + MatchTier::Audio + } else if m.score >= LOOSE_THRESHOLD { + MatchTier::Loose + } else { + return None; + }; + return Some(CutMatch { tier, offset_sec, score: Some(m.score) }); + } if let Some(c_rt) = client.runtime_sec { let delta = (c_rt - stored.runtime_sec).abs(); if delta <= RUNTIME_TOLERANCE_SEC { - return Some(CutMatch { tier: MatchTier::Runtime, offset_sec: 0.0 }); + return Some(CutMatch { tier: MatchTier::Runtime, offset_sec: 0.0, score: None }); } if delta <= LOOSE_TOLERANCE_SEC { - return Some(CutMatch { tier: MatchTier::Loose, offset_sec: 0.0 }); + return Some(CutMatch { tier: MatchTier::Loose, offset_sec: 0.0, score: None }); } // A runtime was supplied and cleared nothing — that is a definite // no-match, not an unknown. return None; } - Some(CutMatch { tier: MatchTier::Unknown, offset_sec: 0.0 }) + Some(CutMatch { tier: MatchTier::Unknown, offset_sec: 0.0, score: None }) } /// Picks the best-matching stored cut, if any clears `loose` (§4). @@ -84,7 +143,13 @@ pub fn best_match( candidates .iter() .filter_map(|(k, cut)| match_cut(client, cut).map(|m| (k.clone(), m))) - .max_by(|a, b| a.1.tier.cmp(&b.1.tier)) + // Within a tier the audio score breaks the tie, so two `audio` matches + // do not resolve on candidate order. + .max_by(|a, b| { + a.1.tier + .cmp(&b.1.tier) + .then(a.1.score.unwrap_or(0.0).total_cmp(&b.1.score.unwrap_or(0.0))) + }) } #[cfg(test)] @@ -92,33 +157,37 @@ mod tests { use super::*; fn stored(runtime: f64) -> StoredCut { - StoredCut { runtime_sec: runtime } + StoredCut { runtime_sec: runtime, audio_bins: None } + } + + fn client(runtime: f64) -> ClientCut { + ClientCut { runtime_sec: Some(runtime), audio_bins: None } } #[test] fn runtime_within_two_seconds_is_runtime_tier() { - let c = ClientCut { runtime_sec: Some(6422.0) }; + let c = client(6422.0); assert_eq!(match_cut(&c, &stored(6420.5)).unwrap().tier, MatchTier::Runtime); } #[test] fn runtime_within_thirty_seconds_is_loose() { - let c = ClientCut { runtime_sec: Some(6450.0) }; + let c = client(6450.0); assert_eq!(match_cut(&c, &stored(6420.5)).unwrap().tier, MatchTier::Loose); } #[test] fn beyond_thirty_seconds_does_not_match() { // §3: "beyond that — no match; do not serve". - let c = ClientCut { runtime_sec: Some(6500.0) }; + let c = client(6500.0); assert!(match_cut(&c, &stored(6420.5)).is_none()); } #[test] fn tier_boundaries_are_inclusive() { - let c = ClientCut { runtime_sec: Some(6422.5) }; + let c = client(6422.5); assert_eq!(match_cut(&c, &stored(6420.5)).unwrap().tier, MatchTier::Runtime); - let c = ClientCut { runtime_sec: Some(6450.5) }; + let c = client(6450.5); assert_eq!(match_cut(&c, &stored(6420.5)).unwrap().tier, MatchTier::Loose); } @@ -137,8 +206,8 @@ mod tests { // different encodes of the same cut get the same tier as a client // holding the very file a manifest was extracted from — the server // cannot tell them apart, and does not need to. - let same_file = ClientCut { runtime_sec: Some(6420.5) }; - let re_encode = ClientCut { runtime_sec: Some(6420.5) }; + let same_file = client(6420.5); + let re_encode = client(6420.5); assert_eq!( match_cut(&same_file, &stored(6420.5)).unwrap().tier, match_cut(&re_encode, &stored(6420.5)).unwrap().tier, @@ -147,7 +216,7 @@ mod tests { #[test] fn best_match_prefers_the_highest_tier() { - let c = ClientCut { runtime_sec: Some(6420.5) }; + let c = client(6420.5); let candidates = vec![("loose", stored(6445.0)), ("runtime", stored(6420.0)), ("none", stored(9999.0))]; let (winner, m) = best_match(&c, &candidates).unwrap(); @@ -157,8 +226,169 @@ mod tests { #[test] fn best_match_returns_none_when_nothing_clears_loose() { - let c = ClientCut { runtime_sec: Some(100.0) }; + let c = client(100.0); let candidates = vec![("a", stored(6420.5)), ("b", stored(3000.0))]; assert!(best_match(&c, &candidates).is_none()); } + + // ── The `audio` tier ──────────────────────────────────────────────────── + + /// A peak-bin sequence with film-like statistics, of the length a real + /// signature has. Not the golden vector: that fixture pins the wire format, + /// and its tone staircase is the wrong input for an alignment search — see + /// the note in [`crate::audio_sig`]'s tests. + fn bins() -> Vec { + let mut x = 20250731u64; + (0..1288) + .map(|_| { + x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + ((x >> 33) % 32) as u8 + }) + .collect() + } + + fn stored_signed(runtime: f64, bins: Vec) -> StoredCut { + StoredCut { runtime_sec: runtime, audio_bins: Some(bins) } + } + + fn client_signed(runtime: f64, bins: Vec) -> ClientCut { + ClientCut { runtime_sec: Some(runtime), audio_bins: Some(bins) } + } + + #[test] + fn matching_signatures_reach_the_audio_tier() { + let bins = bins(); + let m = + match_cut(&client_signed(6420.5, bins.clone()), &stored_signed(6420.5, bins)).unwrap(); + assert_eq!(m.tier, MatchTier::Audio); + assert_eq!(m.offset_sec, 0.0); + assert_eq!(m.score, Some(1.0)); + } + + #[test] + fn audio_outranks_a_runtime_agreement() { + // §3: `audio` is above `runtime` because it is content-derived. The + // runtimes here agree exactly, so only the tier distinguishes them. + let bins = bins(); + let signed = + match_cut(&client_signed(6420.5, bins.clone()), &stored_signed(6420.5, bins)).unwrap(); + let unsigned = match_cut(&client(6420.5), &stored(6420.5)).unwrap(); + assert!(signed.tier > unsigned.tier); + } + + #[test] + fn the_offset_carries_both_terms() { + // The failure this exists to prevent: a release with 40 s of extra head + // material takes 20 s from the window anchor and 20 s from the slide, + // and the slide alone would be wrong by half the runtime difference. + // + // 40 s of head is 40/2 = 20 s of window movement, which at ~92.88 ms a + // frame is ~215 frames. Constructed here by taking the client's + // signature from 215 frames into the stored one. + let stored_bins = bins(); + let head_sec = 40.0; + let slide = ((head_sec / 2.0) / HOP_SEC).round() as usize; + let client_bins = stored_bins[slide..].to_vec(); + + let m = match_cut( + &client_signed(6420.5 + head_sec, client_bins), + &stored_signed(6420.5, stored_bins), + ) + .unwrap(); + + assert_eq!(m.tier, MatchTier::Audio); + // Anchor +20 s, slide -20 s: the two terms cancel, because a longer file + // whose window moved later by exactly the slide is the *same* content + // starting at the same place. + assert!(m.offset_sec.abs() < HOP_SEC, "offset {} should be ~0", m.offset_sec); + + // Whereas the head material really is at the front: the same slide with + // the runtimes equal means the client's window sits later in content it + // shares, so the manifest's timings fall earlier. + let m = match_cut( + &client_signed(6420.5, bins()[slide..].to_vec()), + &stored_signed(6420.5, bins()), + ) + .unwrap(); + assert!( + (m.offset_sec + head_sec / 2.0).abs() < HOP_SEC, + "offset {} should be ~{}", + m.offset_sec, + -head_sec / 2.0 + ); + } + + #[test] + fn a_signature_disagreement_is_final_even_when_the_runtimes_agree() { + // §3: `audio` outranks `runtime` because it is content-derived, so its + // refusal outranks a runtime agreement too. Two different films cut to + // the same length must not match on the coincidence. + let bins = bins(); + let other: Vec = (0..bins.len()).map(|i| ((i * 7 + 3) % 32) as u8).collect(); + assert!(match_cut(&client_signed(6420.5, other), &stored_signed(6420.5, bins)).is_none()); + } + + #[test] + fn a_signature_on_one_side_only_falls_back_to_runtime() { + // Coverage accumulates gradually (§3 sequencing), so most stored + // manifests have no signature for a long time. That must degrade to the + // existing tiers, never to a refusal. + let bins = bins(); + assert_eq!( + match_cut(&client_signed(6420.5, bins.clone()), &stored(6420.5)).unwrap().tier, + MatchTier::Runtime + ); + assert_eq!( + match_cut(&client(6420.5), &stored_signed(6420.5, bins)).unwrap().tier, + MatchTier::Runtime + ); + } + + #[test] + fn a_signature_without_a_runtime_cannot_align() { + // The anchor term needs both runtimes. Rather than return an offset + // wrong by half the runtime difference, the audio route does not fire; + // the read endpoints reject the combination outright so a client learns + // of it rather than silently getting a weaker answer. + let bins = bins(); + let c = ClientCut { runtime_sec: None, audio_bins: Some(bins.clone()) }; + assert!(!c.is_empty()); + assert_eq!(match_cut(&c, &stored_signed(6420.5, bins)).unwrap().tier, MatchTier::Unknown); + } + + #[test] + fn a_degraded_signature_lands_in_loose_with_its_offset() { + // §3's middle row: 0.60–0.85 is "possibly the same cut, degraded + // audio", and it still carries the alignment — a caveat in the UI, not + // a discarded answer. + let bins = bins(); + // Corrupt one frame in four: enough to fall out of `audio`, not enough + // to look like different content. + let degraded: Vec = bins + .iter() + .enumerate() + .map(|(i, b)| if i % 4 == 0 { (b + 9) % 32 } else { *b }) + .collect(); + + let m = match_cut(&client_signed(6420.5, degraded), &stored_signed(6420.5, bins)).unwrap(); + assert_eq!(m.tier, MatchTier::Loose, "score {:?}", m.score); + assert!(m.score.unwrap() >= crate::audio_sig::LOOSE_THRESHOLD); + } + + #[test] + fn best_match_ranks_audio_candidates_by_score() { + let bins = bins(); + let degraded: Vec = bins + .iter() + .enumerate() + .map(|(i, b)| if i % 8 == 0 { (b + 9) % 32 } else { *b }) + .collect(); + let candidates = vec![ + ("degraded", stored_signed(6420.5, degraded)), + ("exact", stored_signed(6420.5, bins.clone())), + ]; + let (winner, m) = best_match(&client_signed(6420.5, bins), &candidates).unwrap(); + assert_eq!(winner, "exact"); + assert_eq!(m.score, Some(1.0)); + } } diff --git a/src/model.rs b/src/model.rs index 09b2066..0ef34fd 100644 --- a/src/model.rs +++ b/src/model.rs @@ -108,8 +108,9 @@ pub struct Cut { pub container_duration_sec: Option, /// Optional; version-prefixed spectral-peak signature (§3, UR-9). /// - /// Accepted and stored by this build; `audio`-tier matching is enabled once - /// coverage is useful, per §3's recommended sequencing. + /// Accepted, stored, served back, and matched on. Optional it stays: a + /// manifest without one is matched by the runtime tiers exactly as before, + /// since §3 requires that this enhancement never be able to break a fetch. #[serde(default, skip_serializing_if = "Option::is_none")] pub audio_signature: Option, } diff --git a/src/validate.rs b/src/validate.rs index 15a7f31..02d833c 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -607,12 +607,36 @@ pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()> { // base64 (standard alphabet, padded) // --------------------------------------------------------------------------- +const B64_ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/// Minimal standard-alphabet base64 encoder, padded — the alphabet §3's +/// normative v1 parameters pin. +/// +/// The inverse of [`base64_decode`], and it exists for the same reason §7 gives: +/// the signature is stored as bytes and the served manifest is rebuilt from +/// rows, so the field has to be re-encoded rather than echoed. +pub(crate) fn base64_encode(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)]; + let acc = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32; + for k in 0..4 { + if k <= chunk.len() { + out.push(B64_ALPHABET[(acc >> (18 - 6 * k)) as usize & 0x3f] as char); + } else { + out.push('='); + } + } + } + out +} + /// Minimal standard-alphabet base64 decoder. /// /// Vendored rather than pulled in as a dependency: the only base64 in this /// service is the fixed-format audio signature, and §3 argues for keeping the /// dependency surface small on the same grounds as the plugin's FFT. -fn base64_decode(s: &str) -> Result, &'static str> { +pub(crate) fn base64_decode(s: &str) -> Result, &'static str> { fn val(b: u8) -> Result { match b { b'A'..=b'Z' => Ok(b - b'A'), diff --git a/tests/api.rs b/tests/api.rs index 088ccfd..d807949 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -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), diff --git a/tests/audio.rs b/tests/audio.rs new file mode 100644 index 0000000..8a2b08e --- /dev/null +++ b/tests/audio.rs @@ -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 = (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) -> (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() +} diff --git a/tests/federation.rs b/tests/federation.rs index 37fbfbb..26e8986 100644 --- a/tests/federation.rs +++ b/tests/federation.rs @@ -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); } diff --git a/tests/fixtures/audio/jray_audio_v1_golden.json b/tests/fixtures/audio/jray_audio_v1_golden.json new file mode 100644 index 0000000..9e88847 --- /dev/null +++ b/tests/fixtures/audio/jray_audio_v1_golden.json @@ -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 -t 120 -i -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." + ] +} diff --git a/tests/injection.rs b/tests/injection.rs index 70a3fce..6070b60 100644 --- a/tests/injection.rs +++ b/tests/injection.rs @@ -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),