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