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

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

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

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

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

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

TRACES: UR-008, UR-009 | SR-003
2026-07-31 22:43:26 +02:00
24 changed files with 2288 additions and 148 deletions
+12 -6
View File
@@ -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 |
+146 -14
View File
@@ -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).
+25 -7
View File
@@ -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` |
+122 -70
View File
@@ -3,7 +3,7 @@
<!-- GENERATED FILE - do not edit by hand. -->
<!-- Regenerate: scripts/traceability/traceability-gate.sh -->
**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<Vec<Jo…`
- [`src/db/repo.rs:797`](../src/db/repo.rs#L797) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result<Vec<Jo…`
### DR-006
@@ -163,11 +164,13 @@ _None._
### DR-013
**Locations:** 3
**Locations:** 5
- [`src/api/json.rs:100`](../src/api/json.rs#L100) — `fn require_utf8(bytes: &[u8]) -> 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<Vec<Jo…`
- [`src/db/repo.rs:797`](../src/db/repo.rs#L797) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result<Vec<Jo…`
### PR-005
**Locations:** 2
**Locations:** 4
- [`src/api/exists.rs:74`](../src/api/exists.rs#L74) — `pub async fn exists_batch(`
- [`src/api/federation.rs:212`](../src/api/federation.rs#L212) — `pub async fn get_peers(`
- [`src/api/search.rs:113`](../src/api/search.rs#L113) — `pub async fn post_search(`
- [`tests/audio.rs:480`](../tests/audio.rs#L480) — `async fn search_identifies_a_file_with_no_metadata_at_all()`
### PR-006
@@ -214,42 +219,59 @@ _None._
- [`src/api/exists.rs:74`](../src/api/exists.rs#L74) — `pub async fn exists_batch(`
- [`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/matching.rs:54`](../src/matching.rs#L54) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option<CutMatch>`
- [`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<CutMatch>`
- [`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<Self>`
- [`src/model.rs:174`](../src/model.rs#L174) — `Unknown`
- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_stored(s: &str) -> Option<Self>`
- [`src/validate.rs:496`](../src/validate.rs#L496) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<SceneCs>>`
### SR-003
**Locations:** 15
**Locations:** 32
- [`src/api/federation.rs:258`](../src/api/federation.rs#L258) — `pub async fn get_capabilities(State(state): State<AppState>) -> ApiResult<Response>`
- [`src/api/federation.rs:261`](../src/api/federation.rs#L261) — `pub async fn get_capabilities(State(state): State<AppState>) -> ApiResult<Response>`
- [`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<ClientCut, ApiError>`
- [`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<SlideMatch>`
- [`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<Self>`
- [`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<CutMatch>`
- [`src/model.rs:192`](../src/model.rs#L192) — `Unknown`
- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_stored(s: &str) -> Option<Self>`
- [`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<ValidManifest>`
- [`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<IpAddr>, 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<ValidManifest>`
- [`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<CutMatch>`
- [`src/matching.rs:94`](../src/matching.rs#L94) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option<CutMatch>`
- [`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<ValidManifest>`
- [`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<AppState>) -> ApiResult<Response>`
- [`src/api/federation.rs:261`](../src/api/federation.rs#L261) — `pub async fn get_capabilities(State(state): State<AppState>) -> ApiResult<Response>`
- [`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<ClientCut, ApiError>`
- [`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<SlideMatch>`
- [`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<CutMatch>`
- [`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<Self>`
- [`src/model.rs:174`](../src/model.rs#L174) — `Unknown`
- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_stored(s: &str) -> Option<Self>`
- [`src/validate.rs:496`](../src/validate.rs#L496) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<SceneCs>>`
### UR-014
**Locations:** 3
**Locations:** 4
- [`src/api/federation.rs:258`](../src/api/federation.rs#L258) — `pub async fn get_capabilities(State(state): State<AppState>) -> ApiResult<Response>`
- [`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<AppState>) -> ApiResult<Response>`
- [`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<ValidManifest>`
- [`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<Self>`
- [`src/model.rs:192`](../src/model.rs#L192) — `Unknown`
- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_stored(s: &str) -> Option<Self>`
### 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
+3 -5
View File
@@ -112,7 +112,7 @@ async fn lookup_one(state: &AppState, params: &LookupParams) -> ApiResult<Exists
IdentityType::Movie => (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<Exists
return Ok(None);
}
let cuts: Vec<(String, StoredCut)> = 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);
+5 -3
View File
@@ -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<AppState>) -> ApiResult<Respon
server_id: state.config.server_id.clone(),
jmanifest_versions: vec![JMANIFEST_VERSION],
federation: true,
// Deferred by design (§3 sequencing): signatures accumulate first.
audio_search: false,
audio_tier_matching: false,
audio_search: state.config.enable_audio_search,
audio_tier_matching: true,
};
Ok(Json(body).into_response())
}
+7 -3
View File
@@ -86,7 +86,7 @@ async fn fetch_best(
IdentityType::Movie => (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,
+42 -6
View File
@@ -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<i64>,
pub episode: Option<i64>,
pub runtime_sec: Option<f64>,
/// 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<String>,
}
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<ClientCut, ApiError> {
// 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 })
}
}
+257
View File
@@ -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<SearchResult>,
/// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub imdb_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub year: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub season: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub episode: Option<i64>,
}
/// TRACES: UR-009 | SR-003 | PR-005
pub async fn post_search(
State(state): State<AppState>,
peer: crate::state::PeerIp,
headers: HeaderMap,
super::json::Json(req): super::json::Json<SearchRequest>,
) -> ApiResult<Response> {
// §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::<SearchRequest>(
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::<SearchRequest>(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}");
}
}
+4 -1
View File
@@ -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",
+419
View File
@@ -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<Vec<u8>> {
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<u8> {
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<u8> {
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<SlideMatch> {
let mut best: Option<SlideMatch> = 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<u32> {
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<u8> {
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<u8> = 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<SlideMatch> {
let slack = SCORE_SLACK_FRAMES as usize;
let mut best: Option<SlideMatch> = 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));
}
}
+9
View File
@@ -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<String>,
/// §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)),
})
+95
View File
@@ -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<bool> {
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<Vec<ManifestRow>> {
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::<rusqlite::Result<Vec<_>>>()?;
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<i64> {
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<Option<TitleRow>> {
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,
+13
View File
@@ -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);
+22 -3
View File
@@ -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()),
+2
View File
@@ -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;
+254 -24
View File
@@ -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<f64>,
/// 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<Vec<u8>>,
}
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<Vec<u8>>,
}
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<f64>,
}
/// 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<CutMatch> {
// 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<K: Clone>(
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<u8> {
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<u8>) -> StoredCut {
StoredCut { runtime_sec: runtime, audio_bins: Some(bins) }
}
fn client_signed(runtime: f64, bins: Vec<u8>) -> 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<u8> = (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<u8> = 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<u8> = 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));
}
}
+3 -2
View File
@@ -108,8 +108,9 @@ pub struct Cut {
pub container_duration_sec: Option<f64>,
/// 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<String>,
}
+25 -1
View File
@@ -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<Vec<u8>, &'static str> {
pub(crate) fn base64_decode(s: &str) -> Result<Vec<u8>, &'static str> {
fn val(b: u8) -> Result<u8, &'static str> {
match b {
b'A'..=b'Z' => Ok(b - b'A'),
+1
View File
@@ -76,6 +76,7 @@ impl TestServer {
server_id: "test.example".into(),
publish_peer_directory: true,
contact: Some("admin@test.example".into()),
enable_audio_search: true,
request_timeout: std::time::Duration::from_secs(30),
job_batch: 8,
job_poll_interval: std::time::Duration::from_secs(3600),
+621
View File
@@ -0,0 +1,621 @@
//! UR-009 end to end — the audio signature through the whole server.
//!
//! The unit tests own the slide's arithmetic and the validator owns the
//! signature's shape. What only holds if the layers are composed correctly is
//! everything here: that a contributed signature survives storage and comes back
//! byte-identically, that the `audio` tier actually reaches the read endpoints,
//! and that a caller with no title identity at all can still find its film.
//!
//! **The round trip is the load-bearing one.** The signature exists so a client
//! can align a manifest against its own copy (`jRay` JR-047), and the client can
//! only do that if the manifest it fetches carries one. A server that validates
//! a signature and then drops it passes every validation test ever written and
//! delivers nothing.
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use jray_server::db::{repo, Db};
use jray_server::ratelimit::RateLimiter;
use jray_server::state::AppState;
use jray_server::tmdb::TmdbClient;
use jray_server::{app, audio_sig, config::Config, worker};
use serde_json::{json, Value};
use tower::ServiceExt;
// ---------------------------------------------------------------------------
// Signatures under test
// ---------------------------------------------------------------------------
/// A real signature's frame count: a 120 s window at a 1024-sample hop (§3).
const FRAMES: usize = 1288;
/// A peak-bin sequence with the statistics film audio produces — no long runs,
/// no short period — packed into a wire signature.
///
/// Not the golden vector, which pins the *format* and is a tone staircase; an
/// alignment search over it is ambiguous in ways no film is. The offset study
/// that does use real audio is `scene-actor-extraction` VR-014.
fn signature(seed: u64, skip_frames: usize) -> String {
let mut x = seed;
let bytes: Vec<u8> = (0..FRAMES + skip_frames)
.map(|_| {
x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
// A whole byte: 5-bit band and 2-bit class, high bit clear.
(((x >> 33) % 32) as u8) << 2 | ((x >> 29) % 4) as u8
})
.skip(skip_frames)
.collect();
audio_sig::encode(&bytes)
}
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let mut p = std::env::temp_dir();
p.push(format!("jray-audio-{}-{}", tag, unique()));
std::fs::create_dir_all(&p).expect("creating temp dir");
Self(p)
}
fn db_path(&self) -> String {
self.0.join("test.db").to_string_lossy().into_owned()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn unique() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{t}-{}", N.fetch_add(1, Ordering::Relaxed))
}
struct TestServer {
router: axum::Router,
db: Db,
_dir: TempDir,
}
impl TestServer {
fn new(tag: &str) -> Self {
Self::with_search(tag, true)
}
fn with_search(tag: &str, enable_audio_search: bool) -> Self {
let dir = TempDir::new(tag);
let db = Db::open(&dir.db_path()).expect("opening database");
let config = Arc::new(Config {
bind: "127.0.0.1:0".into(),
db_path: dir.db_path(),
// Unconfigured, so uploads stay `pending` and no test reaches the
// network. Anything that must be *served* is seeded as listed.
tmdb_api_key: None,
tmdb_base_url: "http://127.0.0.1:1".into(),
trusted_proxies: Vec::new(),
server_id: "audio.example".into(),
publish_peer_directory: false,
contact: None,
enable_audio_search,
request_timeout: std::time::Duration::from_secs(30),
job_batch: 8,
job_poll_interval: std::time::Duration::from_secs(3600),
});
let state = AppState {
db: db.clone(),
config: config.clone(),
limiter: Arc::new(RateLimiter::new()),
tmdb: Arc::new(TmdbClient::new(config.tmdb_base_url.clone(), None)),
};
Self { router: app::router(state), db, _dir: dir }
}
async fn send(&self, req: Request<Body>) -> (StatusCode, Value) {
let resp = self.router.clone().oneshot(req).await.expect("router call");
let status = resp.status();
let bytes = resp.into_body().collect().await.expect("body").to_bytes();
let body = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes)
.unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into_owned()))
};
(status, body)
}
async fn get(&self, uri: &str) -> (StatusCode, Value) {
self.send(Request::builder().uri(uri).body(Body::empty()).unwrap()).await
}
async fn post(&self, uri: &str, body: &Value) -> (StatusCode, Value) {
self.send(
Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
}
async fn post_auth(&self, uri: &str, token: &str, body: &Value) -> (StatusCode, Value) {
self.send(
Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
}
async fn token(&self) -> String {
let (status, body) = self.post("/api/v1/tokens", &json!({})).await;
assert_eq!(status, StatusCode::OK, "token issue failed: {body}");
body["token"].as_str().expect("token").to_string()
}
/// Seeds a listed manifest, since an upload stays `pending` without TMDB and
/// `pending` is never served (§6, §7).
async fn seed_listed(
&self,
tmdb_id: &str,
title: &str,
runtime_sec: f64,
signature: Option<&str>,
) -> String {
let tmdb_id = tmdb_id.to_string();
let title = title.to_string();
let packed = signature.and_then(audio_sig::decode);
self.db
.write(move |tx| {
let now = worker::now_iso();
let title_id = repo::upsert_title(
tx,
jray_server::model::IdentityType::Movie,
Some(&tmdb_id),
None,
Some(&title),
Some(1952),
&now,
)?;
let id = format!("m-{tmdb_id}");
let coarse = packed.as_deref().map(audio_sig::coarse_key);
repo::insert_manifest(
tx,
&repo::NewManifest {
id: &id,
title_id: &title_id,
season: None,
episode: None,
runtime_sec,
audio_signature: packed.as_deref(),
audio_sig_coarse: coarse.as_deref(),
sample_fps: Some(5.0),
extinction_sec: Some(12.0),
pipeline_version: Some("test 0.1"),
gallery_scope: Some("global"),
contributor_id: None,
status: "listed",
content_id: Some(&format!("cid-{tmdb_id}")),
origin: "audio.example",
ingested_from: None,
created_at: &now,
},
)?;
repo::upsert_person(tx, 884, "Bob Hope", false, &now)?;
repo::insert_actor_scenes(
tx,
&id,
884,
&[jray_server::validate::SceneCs::plain(19160, 20920)],
)?;
Ok(id)
})
.await
.expect("seeding")
}
}
fn movie_manifest(tmdb_id: &str, runtime: f64, signature: Option<&str>) -> Value {
let mut cut = json!({ "runtime_sec": runtime });
if let Some(sig) = signature {
cut["audio_signature"] = json!(sig);
}
json!({
"jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": tmdb_id, "title": "Road to Bali", "year": 1952 },
"cut": cut,
"extraction": { "sample_fps": 5, "pipeline_version": "test 0.1" },
"actors": [
{ "name": "Bob Hope", "tmdb_id": "884", "scenes": [{"start":191.6,"end":209.2}] },
{ "name": "Bing Crosby", "tmdb_id": "11007", "scenes": [{"start":300.0,"end":320.0}] }
]
})
}
// ---------------------------------------------------------------------------
// Storage and the round trip
// ---------------------------------------------------------------------------
/// TRACES: UR-009 | DR-002 | SR-003
#[tokio::test]
async fn a_contributed_signature_survives_storage_byte_for_byte() {
// The gap this closes: a signature that is validated and then dropped leaves
// every fetched manifest without one, and the plugin's local alignment
// (JR-047) with nothing to align against.
let s = TestServer::new("roundtrip");
let token = s.token().await;
let sig = signature(11, 0);
let (status, body) = s
.post_auth("/api/v1/manifests", &token, &movie_manifest("504172", 6420.5, Some(&sig)))
.await;
assert_eq!(status, StatusCode::ACCEPTED, "{body}");
let id = body["manifest_id"].as_str().expect("manifest id").to_string();
// Listed by hand, since the cast check cannot run without TMDB.
let listed = id.clone();
s.db.write(move |tx| {
tx.execute("UPDATE manifests SET status = 'listed' WHERE id = ?1", [&listed])?;
Ok(())
})
.await
.unwrap();
let (status, body) = s.get(&format!("/api/v1/manifests/{id}")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["cut"]["audio_signature"].as_str(),
Some(sig.as_str()),
"the served signature must be the contributed one, re-encoded not echoed"
);
}
/// TRACES: UR-009 | SR-003
#[tokio::test]
async fn a_manifest_without_a_signature_serves_no_signature_field() {
// §3's sequencing: signatures accumulate, so most manifests have none for a
// long time. The field must be absent rather than null or empty.
let s = TestServer::new("nosig");
let id = s.seed_listed("100", "Unsigned", 6420.5, None).await;
let (status, body) = s.get(&format!("/api/v1/manifests/{id}")).await;
assert_eq!(status, StatusCode::OK);
assert!(body["cut"].get("audio_signature").is_none(), "{body}");
}
/// TRACES: UR-008, UR-009 | SR-003
#[tokio::test]
async fn a_held_manifest_adopts_a_signature_it_lacked() {
// §9a: the signature is excluded from `content_id` and replicates as an
// attribute, so the same content arriving with a signature must fill the
// hole. Otherwise a manifest that first arrived without audio could never
// gain one, and its cut would sit permanently outside the `audio` tier.
let s = TestServer::new("adopt");
let token = s.token().await;
let sig = signature(12, 0);
let (status, _) =
s.post_auth("/api/v1/manifests", &token, &movie_manifest("777", 6420.5, None)).await;
assert_eq!(status, StatusCode::ACCEPTED);
// Same content, now carrying a signature. Deduplicated by `content_id` —
// which the signature is deliberately not part of.
let (status, body) =
s.post_auth("/api/v1/manifests", &token, &movie_manifest("777", 6420.5, Some(&sig))).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["status"], "already_present");
let id = body["manifest_id"].as_str().unwrap().to_string();
let listed = id.clone();
s.db.write(move |tx| {
tx.execute("UPDATE manifests SET status = 'listed' WHERE id = ?1", [&listed])?;
Ok(())
})
.await
.unwrap();
let (_, body) = s.get(&format!("/api/v1/manifests/{id}")).await;
assert_eq!(body["cut"]["audio_signature"].as_str(), Some(sig.as_str()));
}
// ---------------------------------------------------------------------------
// The `audio` tier on the read endpoints
// ---------------------------------------------------------------------------
/// TRACES: UR-009 | SR-003
#[tokio::test]
async fn a_matching_signature_reaches_the_audio_tier_on_a_fetch() {
let s = TestServer::new("tier");
let sig = signature(13, 0);
s.seed_listed("504172", "Road to Bali", 6420.5, Some(&sig)).await;
let uri = format!(
"/api/v1/manifests/movie?tmdb_id=504172&runtime_sec=6420.5&audio_signature={}",
urlencode(&sig)
);
let (status, body) = s.get(&uri).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["match"], "audio");
assert_eq!(body["offset_sec"], 0.0);
}
/// TRACES: UR-009 | SR-003
#[tokio::test]
async fn a_shifted_release_matches_and_gets_its_offset() {
// The row §3 calls the valuable one: a release with extra head material
// previously failed the ±2 s runtime tier outright. Now it matches, and the
// client shifts every window by the recovered offset.
let s = TestServer::new("shifted");
let stored_runtime = 6420.5;
let head_sec = 40.0;
let sig = signature(14, 0);
s.seed_listed("504172", "Road to Bali", stored_runtime, Some(&sig)).await;
// The client's copy carries 40 s of extra logos: 40 s longer, and its
// centre window therefore starts 20 s later in the content.
let slide = ((head_sec / 2.0) / audio_sig::HOP_SEC).round() as usize;
let client_sig = signature(14, slide);
let uri = format!(
"/api/v1/manifests/movie?tmdb_id=504172&runtime_sec={}&audio_signature={}",
stored_runtime + head_sec,
urlencode(&client_sig)
);
let (status, body) = s.get(&uri).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["match"], "audio");
// Both terms cancel here — the window moved by exactly the slide — so the
// manifest's timings apply unshifted despite a 40 s runtime difference the
// `runtime` tier would have rejected outright.
let offset = body["offset_sec"].as_f64().unwrap();
assert!(offset.abs() <= audio_sig::HOP_SEC, "offset {offset}");
// And without the signature that same request is not a match at all.
let (status, _) = s
.get(&format!(
"/api/v1/manifests/movie?tmdb_id=504172&runtime_sec={}",
stored_runtime + head_sec
))
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
/// TRACES: UR-009 | SR-003
#[tokio::test]
async fn a_signature_that_disagrees_is_not_served_on_a_runtime_coincidence() {
// §3: `audio` outranks `runtime` because it is content-derived, so its
// refusal outranks a runtime agreement. Two different films of the same
// length must not match.
let s = TestServer::new("disagree");
s.seed_listed("504172", "Road to Bali", 6420.5, Some(&signature(15, 0))).await;
let uri = format!(
"/api/v1/manifests/movie?tmdb_id=504172&runtime_sec=6420.5&audio_signature={}",
urlencode(&signature(16, 0))
);
let (status, _) = s.get(&uri).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
/// TRACES: UR-001, UR-009 | SR-003
#[tokio::test]
async fn exists_reports_the_audio_tier_too() {
// The sweep path. `exists` transfers no payload, so a client cannot align
// locally from it — server-side matching is the only way it can say `audio`.
let s = TestServer::new("exists");
let sig = signature(17, 0);
s.seed_listed("504172", "Road to Bali", 6420.5, Some(&sig)).await;
let (status, body) = s
.post(
"/api/v1/manifests/exists",
&json!({ "items": [
{ "tmdb_id": "504172", "runtime_sec": 6420.5, "audio_signature": sig },
{ "tmdb_id": "504172", "runtime_sec": 6420.5 }
]}),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["results"][0]["match"], "audio");
assert_eq!(body["results"][1]["match"], "runtime");
}
/// TRACES: UR-009 | DR-013 | SR-003
#[tokio::test]
async fn a_signature_without_a_runtime_is_refused_by_name() {
// The offset's window-anchor term is derived from both runtimes (§3). Rather
// than answer at a lower tier — which would look like a match the client's
// own signature had failed to improve — the request is refused.
let s = TestServer::new("noruntime");
s.seed_listed("504172", "Road to Bali", 6420.5, Some(&signature(18, 0))).await;
let uri = format!(
"/api/v1/manifests/movie?tmdb_id=504172&audio_signature={}",
urlencode(&signature(18, 0))
);
let (status, body) = s.get(&uri).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.to_string().contains("runtime_sec"), "{body}");
}
/// TRACES: UR-009, UR-011 | DR-013 | SR-004
#[tokio::test]
async fn a_malformed_signature_is_a_bad_request_not_a_silent_downgrade() {
let s = TestServer::new("malformed");
s.seed_listed("504172", "Road to Bali", 6420.5, Some(&signature(19, 0))).await;
for bad in ["v2:AAAA", "notasignature", "v1:!!!!"] {
let uri = format!(
"/api/v1/manifests/movie?tmdb_id=504172&runtime_sec=6420.5&audio_signature={}",
urlencode(bad)
);
let (status, _) = s.get(&uri).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "accepted {bad}");
}
}
// ---------------------------------------------------------------------------
// Unknown-providence search
// ---------------------------------------------------------------------------
/// TRACES: UR-009 | SR-003 | PR-005
#[tokio::test]
async fn search_identifies_a_file_with_no_metadata_at_all() {
// What the endpoint is for: no TMDB id, no usable name, nothing to look up.
// The answer has to carry title identity, because the caller has none.
let s = TestServer::new("search");
let sig = signature(20, 0);
s.seed_listed("504172", "Road to Bali", 6420.5, Some(&sig)).await;
s.seed_listed("999", "A Different Film", 6420.0, Some(&signature(21, 0))).await;
let (status, body) = s
.post("/api/v1/manifests/search", &json!({ "audio_signature": sig, "runtime_sec": 6420.5 }))
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["results"].as_array().unwrap().len(), 1, "{body}");
assert_eq!(body["results"][0]["identity"]["tmdb_id"], "504172");
assert_eq!(body["results"][0]["identity"]["title"], "Road to Bali");
assert_eq!(body["results"][0]["match"], "audio");
assert_eq!(body["results"][0]["score"], 1.0);
assert_eq!(body["truncated"], false);
// The decoy shares a runtime and so cleared the prefilter — the slide is
// what rejected it, which is the whole point of scoring rather than
// shortlisting.
assert_eq!(body["candidates_scored"], 2);
}
/// TRACES: UR-009 | SR-003
#[tokio::test]
async fn search_recovers_the_offset_for_a_differently_trimmed_release() {
let s = TestServer::new("search-offset");
let stored_runtime = 6420.5;
s.seed_listed("504172", "Road to Bali", stored_runtime, Some(&signature(22, 0))).await;
// 30 s of extra head: the window moves 15 s later in the content.
let head_sec = 30.0;
let slide = ((head_sec / 2.0) / audio_sig::HOP_SEC).round() as usize;
let (status, body) = s
.post(
"/api/v1/manifests/search",
&json!({
"audio_signature": signature(22, slide),
"runtime_sec": stored_runtime + head_sec
}),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["results"][0]["identity"]["tmdb_id"], "504172");
let offset = body["results"][0]["offset_sec"].as_f64().unwrap();
assert!(offset.abs() <= audio_sig::HOP_SEC, "offset {offset}");
}
/// TRACES: UR-009 | SR-003
#[tokio::test]
async fn search_declines_content_it_does_not_hold() {
let s = TestServer::new("search-miss");
s.seed_listed("504172", "Road to Bali", 6420.5, Some(&signature(23, 0))).await;
let (status, body) = s
.post(
"/api/v1/manifests/search",
&json!({ "audio_signature": signature(24, 0), "runtime_sec": 6420.5 }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(body["results"].as_array().unwrap().is_empty(), "{body}");
// An empty result is an answer, not an error: the caller learns the
// community does not have this cut.
assert_eq!(body["candidates_scored"], 1);
}
/// TRACES: UR-009, UR-011 | SR-004
#[tokio::test]
async fn search_applies_the_same_structural_rules_as_an_upload() {
// §6: a read must not be a way to hand the server bytes an upload would
// have refused.
let s = TestServer::new("search-validate");
for (body, why) in [
(json!({ "audio_signature": "v1:AAAA", "runtime_sec": 6420.5 }), "wrong length"),
(json!({ "audio_signature": signature(25, 0), "runtime_sec": 60.0 }), "under the window"),
(json!({ "audio_signature": signature(25, 0), "runtime_sec": -1.0 }), "negative runtime"),
] {
let (status, _) = s.post("/api/v1/manifests/search", &body).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "accepted a signature that is {why}");
}
}
/// TRACES: UR-009, UR-014 | SR-003
#[tokio::test]
async fn search_is_absent_when_the_operator_has_not_enabled_it() {
// §3: optional for a server to implement, and advertised rather than
// assumed, so a client discovers the absence in one cheap request.
let s = TestServer::with_search("search-off", false);
let (status, body) = s.get("/api/v1/federation/capabilities").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["audio_search"], false);
// Matching is not the expensive half and stays on regardless.
assert_eq!(body["audio_tier_matching"], true);
let (status, _) = s
.post(
"/api/v1/manifests/search",
&json!({ "audio_signature": signature(26, 0), "runtime_sec": 6420.5 }),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
/// TRACES: UR-002, UR-009 | SR-005
#[tokio::test]
async fn search_never_returns_a_pending_manifest() {
// §6/§7: `pending` is held unlisted and is not served by any route. A search
// that leaked one would publish an unchecked contribution.
let s = TestServer::new("search-pending");
let token = s.token().await;
let sig = signature(27, 0);
let (status, _) = s
.post_auth("/api/v1/manifests", &token, &movie_manifest("504172", 6420.5, Some(&sig)))
.await;
assert_eq!(status, StatusCode::ACCEPTED);
let (status, body) = s
.post("/api/v1/manifests/search", &json!({ "audio_signature": sig, "runtime_sec": 6420.5 }))
.await;
assert_eq!(status, StatusCode::OK);
assert!(body["results"].as_array().unwrap().is_empty(), "{body}");
assert_eq!(body["candidates_scored"], 0);
}
/// Percent-encodes the base64 characters a query string would otherwise eat.
fn urlencode(s: &str) -> String {
s.chars()
.map(|c| match c {
'+' => "%2B".to_string(),
'/' => "%2F".to_string(),
'=' => "%3D".to_string(),
':' => "%3A".to_string(),
'!' => "%21".to_string(),
c => c.to_string(),
})
.collect()
}
+6 -3
View File
@@ -71,6 +71,7 @@ impl TestServer {
server_id: "local.example".into(),
publish_peer_directory: publish_directory,
contact: Some("admin@local.example".into()),
enable_audio_search: true,
request_timeout: std::time::Duration::from_secs(30),
job_batch: 8,
job_poll_interval: std::time::Duration::from_secs(3600),
@@ -397,7 +398,9 @@ async fn capabilities_advertise_the_envelope_version() {
assert_eq!(status, StatusCode::OK);
assert_eq!(body["jmanifest_versions"], json!([2]));
assert_eq!(body["federation"], true);
// Deferred by design (§3 sequencing), and advertised as absent rather than
// left for a client to discover by failure.
assert_eq!(body["audio_search"], false);
// §3: the audio surfaces are advertised rather than assumed, because search
// is optional for a server to implement. This build implements both, so a
// client that reads this and then sends a signature must not get a 404.
assert_eq!(body["audio_search"], true);
assert_eq!(body["audio_tier_matching"], true);
}
+194
View File
@@ -0,0 +1,194 @@
{
"_": "Golden vector for the JRay v1 audio signature (JRay-public-server SPEC.md \u00a73). Shared verbatim between scene-actor-extraction (C++) and the jRay Jellyfin plugin (C#) so the two implementations can be proven bit-identical. IR-004, IR-005, IR-007, IR-008.",
"version": "v1",
"signature": "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeHx8eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh8fHzk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5V1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dycnJycnJycnJycnJycnJycnJycnJycnJycnJycnMPDgwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKytFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRWNjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2Njfn5+fn5+fn5+fn5+fn5+fn5+fn5+fn5+fn5+fn5/GxoZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGTc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3UlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSU1JsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbAoLCwoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCwsLJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSVDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ15eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eX19eeXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXkXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFzExMTExMTExMTExMTExMTExMTExMTExMTExMTExT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09qampqampqampqampqampqampqampqampqampqamsHBwUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyM+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4/PlhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYd3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3ExMRERERERERERERERERERERERERERERERERERERES8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vSkpKSkpKSkpKSkpKSkpKSkpKSkpKSkpKSkpKSkpLS0plZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZQMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0eHh44ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4OFdXV1ZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWV1dXcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXEPDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDyoqKioqKioqKioqKioqKioqKioqKioqKioqKioqKysqRERERERERERERERERERERERERERERERERERERERjY2NjY2NjY2NjYw==",
"frame_count": 1288,
"media": {
"file": "jray_audio_v1_tone.flac",
"generator": "make_fixture.py",
"container": "FLAC (lossless \u2014 decodes to exactly the PCM make_fixture.py emits)",
"duration_sec": 120.0,
"sample_rate": 11025,
"channels": 1,
"sample_format": "s16",
"sha256": "912ecd426cd426dccb37753e0249694227619c701cb9f533502b37da0fbe8096",
"bytes": 585142
},
"decoded_window": {
"_": "Checksums of the 120 s centre window after downmix to mono and resample to 11025 Hz, i.e. exactly the stream `ffmpeg -ss <mid-60> -t 120 -i <file> -vn -ac 1 -ar 11025 -f f32le -` produces. Check these first: a mismatch here is a decode problem, not a DSP one.",
"samples": 1323000,
"f32le_fnv1a64": "0x1ef7899cd4d12662",
"s16le_fnv1a64": "0xf824fa56f125c0dc"
},
"params": {
"window_sec": 120.0,
"window_centre": "runtime/2, i.e. samples from runtime/2 - 60 s; truncated to exactly 1323000 samples",
"min_duration_sec": 120.0,
"min_duration_rule": "IR-007 \u2014 below this emit NO signature and apply no sync offset",
"sample_rate": 11025,
"channels": 1,
"arithmetic": "IEEE-754 double throughout; float32 is not sufficient",
"sample_scale": "s16 * (1/32768), FFmpeg's native s16->flt",
"frame_size": 4096,
"hop_size": 1024,
"frame_count_rule": "1 + (n_samples - 4096) / 1024, integer division; whole frames only",
"window_fn": "Hann, PERIODIC: w[n] = 0.5 * (1 - cos(2*pi*n/4096))",
"transform": "radix-2 DIT complex FFT over the 4096 real samples (imag=0), no normalisation",
"magnitude": "sqrt(re^2 + im^2), linear",
"band_lo_hz": 300.0,
"band_hi_hz": 3000.0,
"num_bands": 32,
"band_edges": "edge[b] = 300 * (3000/300)^(b/32), b = 0..32",
"band_bins": "band b owns FFT bins [k_lo[b], k_lo[b+1]) with k_lo[b] = ceil(edge[b] * 4096 / 11025); see band_fft_bins",
"band_value": "MEAN of the linear magnitudes in the band (not sum, not max)",
"peak_bin": "argmax over the 32 band values; ties resolve to the LOWEST index",
"energy_metric": "E = mean magnitude over all FFT bins 112..1114, i.e. the whole 300-3000 Hz band",
"energy_reference": "upper median of E over all frames: sorted[n/2], no averaging of the two middle values",
"energy_ratio": "r = log10((E + 1e-12) / (E_ref + 1e-12))",
"energy_class_edges": [
-0.6,
-0.2,
0.2
],
"energy_class": "0 if r < -0.6, 1 if r < -0.2, 2 if r < 0.2, else 3",
"byte_layout": "bit7 = 0 (reserved), bits6..2 = 5-bit band index, bits1..0 = 2-bit energy class; byte = (band << 2) | class",
"base64": "standard alphabet A-Za-z0-9+/ with '=' padding",
"prefix": "v1:"
},
"band_fft_bins": [
[
112,
120
],
[
120,
129
],
[
129,
139
],
[
139,
149
],
[
149,
160
],
[
160,
172
],
[
172,
185
],
[
185,
199
],
[
199,
213
],
[
213,
229
],
[
229,
246
],
[
246,
265
],
[
265,
285
],
[
285,
306
],
[
306,
328
],
[
328,
353
],
[
353,
379
],
[
379,
408
],
[
408,
438
],
[
438,
471
],
[
471,
506
],
[
506,
543
],
[
543,
584
],
[
584,
627
],
[
627,
674
],
[
674,
724
],
[
724,
778
],
[
778,
836
],
[
836,
899
],
[
899,
966
],
[
966,
1038
],
[
1038,
1115
]
],
"notes": [
"The server spec fixes the window, rate, STFT geometry, band and the 5+2 bit packing. Everything under params beyond that (Hann periodicity, band aggregation, the energy-class definition, tie-breaking, base64 alphabet) is pinned HERE for v1 \u2014 the spec does not constrain it, and two implementations that guess differently produce non-matching signatures.",
"Decision margins on this fixture: the two strongest bands are within 1.3% on the closest frame, and the closest frame to an energy-class edge is 3.6e-3 away in log10. Both are many orders of magnitude above double-precision FFT differences, so any two correct double- precision implementations agree; a float32 implementation is not guaranteed to.",
"Coverage: all 32 bands and all 4 energy classes appear in the golden signature.",
"Robustness observed on this fixture: identical peak-bin sequence after a stereo/44100 Hz round trip and after AAC 128 kbit/s re-encoding."
]
}
+1
View File
@@ -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),