Withdraw the file-hash tier; document the legal posture
Removes `cut.video_hash` and the `exact` match tier on legal grounds. The OpenSubtitles hash was the strongest technical signal available — it identifies a specific file, so it cannot produce a false positive — and that is exactly the problem. Every tier must be a claim about a *cut*, never about a copy. A TMDB id discloses "some copy of this film", which is what a library catalogue discloses. A file hash discloses "this exact release": it made a read endpoint into a release-level oracle, and made an instance's database a mapping from file fingerprints to the instances holding them. That is a far more specific disclosure than PR-005 permits, and a dataset no volunteer operator should be asked to hold. The audio signature is the replacement: derived from content, it identifies the cut rather than the copy, so two encodes of the same edit agree. The field is deleted rather than kept as a vestigial null, on the same reasoning §2 applied to `anneal_sec` — a key naming a signal the format no longer has is actively misleading — so an upload carrying one is now an unknown-field 400, with a test asserting it. **Every content_id changes**, including for manifests that never carried a hash, because the canonical `cut` object lost a key. The golden vector is regenerated and re-verified against an independent Python implementation; the plugin and extraction repos must adopt the new value or federation deduplication silently breaks. Free now, pre-release; not free later. Adds docs/legal-posture.md, the operator-facing half of what §5a asks for: what an instance holds exhaustively, what it structurally cannot do, and how that sits against the intermediary-liability regimes that plausibly apply. 208 tests. Coverage 25/32. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: UR-011 | SR-004, PR-005
This commit is contained in:
@@ -6,7 +6,9 @@ running the CV pipeline locally, and optionally contribute the manifests they
|
||||
generate back.
|
||||
|
||||
See [SPEC.md](SPEC.md) for the design. Section references throughout the code
|
||||
point at it.
|
||||
point at it. If you are considering **running an instance**, read
|
||||
[`docs/legal-posture.md`](docs/legal-posture.md) first: it states what an
|
||||
instance holds, what it structurally cannot do, and the operator checklist.
|
||||
|
||||
The community instance is **`https://jray.tourolle.paris`**. The JRay plugin
|
||||
ships with it pre-configured but **disabled** — §9 requires that no traffic leave
|
||||
|
||||
@@ -118,7 +118,6 @@ and a cut fingerprint; the actor timeline payload is unchanged.
|
||||
"cut": {
|
||||
"runtime_sec": 6420.5,
|
||||
"container_duration_sec": 6420.5,
|
||||
"video_hash": "opensubtitles:8e245d9679d31e12",
|
||||
"audio_signature": "v1:v7fA3k…"
|
||||
},
|
||||
"extraction": {
|
||||
@@ -165,7 +164,10 @@ Field notes:
|
||||
lookup keys.
|
||||
- `cut.runtime_sec` — **required**, the decoded duration of the media the
|
||||
timings came from. This is the primary alignment guard.
|
||||
- `cut.video_hash` — optional but strongly preferred. See §3.
|
||||
- `cut.video_hash` — **withdrawn.** A file hash rather than a cut fingerprint;
|
||||
see §3 "Why there is no file-level signal". Rejected as an unknown field like
|
||||
any other (§6 stage 2), so a client still sending it gets a `400` naming it
|
||||
rather than having it silently dropped.
|
||||
- `cut.audio_signature` — optional; a spectral-peak signature from the media
|
||||
centre, version-prefixed (`v1:`). Enables content-based matching and offset
|
||||
recovery for files of unknown providence. See §3.
|
||||
@@ -323,19 +325,45 @@ server reports which tier matched so the client can decide whether to trust it.
|
||||
|
||||
| Tier | Signal | Confidence |
|
||||
|---|---|---|
|
||||
| `exact` | `video_hash` equal | Same file, timings are exact |
|
||||
| `runtime` | runtimes within ±2s | Very likely the same cut |
|
||||
| `loose` | runtimes within ±30s | Probably same cut, different trims |
|
||||
| — | beyond that | No match; do not serve |
|
||||
|
||||
`video_hash` uses the OpenSubtitles hash (first+last 64KiB plus file size) —
|
||||
cheap to compute, no full read, and already well-known in the media-server
|
||||
ecosystem. It identifies a *file*, so it only ever matches an identical
|
||||
release; it can never produce a false positive, which is why it is tier one.
|
||||
The client sends its own runtime when requesting; the server does the matching
|
||||
and returns the best available tier. A `loose` match should surface as a caveat
|
||||
in the JRay UI rather than being applied silently.
|
||||
|
||||
The client sends its own runtime and hash when requesting; the server does the
|
||||
matching and returns the best available tier. A `loose` match should surface
|
||||
as a caveat in the JRay UI rather than being applied silently.
|
||||
### Why there is no file-level signal
|
||||
|
||||
**Every tier is a claim about a *cut*, never about a copy.** No field in the
|
||||
Jmanifest distinguishes two files of the same cut, and none may be added.
|
||||
|
||||
An earlier draft made `cut.video_hash` — the OpenSubtitles hash of first+last
|
||||
64 KiB plus file size — the top `exact` tier, on the reasoning that an equal
|
||||
hash identifies the same file and so can never produce a false positive. It has
|
||||
been **withdrawn**, for two independent reasons:
|
||||
|
||||
- **It was the one field that individuated a copy rather than a work.** A cut
|
||||
fingerprint is shared by everyone who holds that edit, however they came by
|
||||
it, and is therefore a statement about the film. A file hash is a statement
|
||||
about one person's particular encode: anyone holding a given release can
|
||||
compute its hash and ask `GET /manifests/exists` whether the community has a
|
||||
manifest for exactly that file. That made a read endpoint into a release-level
|
||||
oracle, and made a contributor's uploads a published inventory of their own
|
||||
files. Nothing else in the design has that property, and PR-005 is the reason
|
||||
it should not.
|
||||
- **It bought no accuracy the cut-level tiers lack.** Timings transfer between
|
||||
*cuts*. Two files of the same cut yield the same timings whether or not their
|
||||
bytes agree, so `exact` never told a client anything `audio` does not — it
|
||||
only told the *server* something it had no need to know.
|
||||
|
||||
The consequence is accepted rather than mitigated: the server cannot tell a
|
||||
client holding the very file a manifest was extracted from apart from one
|
||||
holding a different encode of the same cut. That is the intended property.
|
||||
|
||||
`audio` (below) is the top tier in its place, and is the better signal on the
|
||||
merits: it confirms the audio actually matches, survives re-encoding, and
|
||||
recovers a trim offset, none of which a byte-level hash can do.
|
||||
|
||||
### Audio signature — UR-009
|
||||
|
||||
@@ -459,13 +487,15 @@ search is possible later but is out of scope.
|
||||
|
||||
| Tier | Signal | Confidence |
|
||||
|---|---|---|
|
||||
| `exact` | `video_hash` equal | Same file |
|
||||
| `audio` | audio score ≥ 0.85 | Same cut; `offset` returned, may be non-zero |
|
||||
| `runtime` | runtimes within ±2s | Very likely the same cut |
|
||||
| `loose` | audio 0.60–0.85, or runtimes within ±30s | Caveat in UI |
|
||||
|
||||
`audio` ranks above `runtime` because it is content-derived: it confirms the
|
||||
audio actually matches, where equal runtimes are only circumstantial.
|
||||
audio actually matches, where equal runtimes are only circumstantial. With
|
||||
`video_hash` withdrawn it is also the **top** tier — there is nothing above it,
|
||||
and nothing above it that could be added without reintroducing a file-level
|
||||
signal.
|
||||
|
||||
#### Unknown-providence search
|
||||
|
||||
@@ -604,9 +634,9 @@ working; UR-009 is an enhancement and must never be able to break a fetch.
|
||||
**Items under 120 s emit no signature at all, and no sync offset is applied to
|
||||
them.** The window is `runtime/2 ± 60 s`, so below 120 s it underflows: there is
|
||||
no shortened window to compute, because the construction has no definition
|
||||
there. Such items fall back to the `exact` and `runtime` tiers, which is
|
||||
adequate — a sub-two-minute item is rarely the ambiguous-providence case UR-009
|
||||
exists to solve.
|
||||
there. Such items fall back to the `runtime` tier, which is adequate — a
|
||||
sub-two-minute item is rarely the ambiguous-providence case UR-009 exists to
|
||||
solve.
|
||||
|
||||
The signature is therefore **fixed-length by construction**, not merely bounded.
|
||||
That is what keeps it inside SR-004: a caller cannot choose the length, so the
|
||||
@@ -640,7 +670,7 @@ 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` and `video_hash`.
|
||||
plus optional `runtime_sec`.
|
||||
|
||||
```json
|
||||
{ "exists": true, "match": "runtime", "manifest_id": "01HZ...", "actor_count": 34 }
|
||||
@@ -650,7 +680,7 @@ plus optional `runtime_sec` and `video_hash`.
|
||||
to this question, and using `404` would conflate "no manifest" with "bad
|
||||
route" for the client.
|
||||
|
||||
If `runtime_sec` and `video_hash` are both omitted, the response reports
|
||||
If `runtime_sec` is omitted, the response reports
|
||||
whether *any* manifest exists for the title with `"match": "unknown"`; the
|
||||
client must still fetch to find out whether a cut actually aligns. This is
|
||||
the mode a library-wide sweep uses.
|
||||
@@ -671,7 +701,7 @@ 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=&video_hash=`
|
||||
### `GET /manifests/movie?tmdb_id=&imdb_id=&runtime_sec=`
|
||||
|
||||
Returns the best-matching Jmanifest, or `404` if none clears `loose`.
|
||||
|
||||
@@ -685,7 +715,7 @@ 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=&video_hash=`
|
||||
### `GET /manifests/episode?series_tmdb_id=&season=&episode=&runtime_sec=`
|
||||
|
||||
Single-episode equivalent of the movie endpoint.
|
||||
|
||||
@@ -801,7 +831,7 @@ stage 2, an accepted document contains only:
|
||||
|---|---|
|
||||
| `identity.tmdb_id` / `imdb_id` | Regex-constrained to digits / `tt\d{7,8}` |
|
||||
| `identity.season`/`episode`/`year` | Bounded integers |
|
||||
| `cut.*` | Numbers, plus a fixed-format hash |
|
||||
| `cut.*` | Numbers, plus the fixed-length audio signature (§3) |
|
||||
| `extraction.*` | Numbers and a version string from an allow-list |
|
||||
| `actors[].tmdb_id` / `imdb_id` | Regex-constrained |
|
||||
| `actors[].scenes` | Pairs of floats |
|
||||
@@ -865,7 +895,7 @@ Additional layers, in order of cost:
|
||||
names.
|
||||
2. **Age-appropriateness guard.** If the target title's TMDB certification is
|
||||
a children's rating, apply the strictest cast-match threshold and require
|
||||
an `exact` or `runtime` cut match. Mismatched content on children's titles
|
||||
an `audio` or `runtime` cut match. Mismatched content on children's titles
|
||||
is the highest-harm case and deserves the tightest gate.
|
||||
3. **Divergence detection.** When two manifests exist for the same
|
||||
`(title, cut)` from different sources and their actor sets disagree beyond
|
||||
@@ -911,10 +941,15 @@ is instant and reversible; deletion is a separate, logged action.
|
||||
|
||||
**Legal posture.** Because the server stores only integers and references to
|
||||
TMDB entities, it holds no user-generated content in the sense that
|
||||
intermediary-liability regimes contemplate. This should be stated plainly in
|
||||
the operator documentation, alongside a contact address for takedown requests.
|
||||
It is a materially better position than "we store user-submitted JSON and
|
||||
moderate it".
|
||||
intermediary-liability regimes contemplate. It is a materially better position
|
||||
than "we store user-submitted JSON and moderate it".
|
||||
|
||||
Stated in full, against EU and international copyright law, in
|
||||
[`docs/legal-posture.md`](docs/legal-posture.md) — which an operator should
|
||||
publish alongside a contact address for notices. That document is downstream of
|
||||
this spec, not alongside it: every claim in it is a consequence of a design
|
||||
property recorded here, so **a change that weakens `SR-004` or `SR-005` silently
|
||||
invalidates it.** Its §7 is the list of changes that would.
|
||||
|
||||
### Client-side hardening
|
||||
|
||||
@@ -1170,7 +1205,7 @@ titles (id PK, kind, -- movie | series
|
||||
adult bool, certification, updated_at)
|
||||
|
||||
manifests (id PK, title_id FK, season, episode,
|
||||
runtime_sec, video_hash,
|
||||
runtime_sec, -- no video_hash: withdrawn, §3
|
||||
audio_signature blob NULL, -- §3, ~1290 bytes
|
||||
audio_sig_coarse blob NULL, -- candidate-generation index key
|
||||
sample_fps, extinction_sec, pipeline_version,
|
||||
@@ -1205,7 +1240,7 @@ same quantisation used for `content_id`, so stored values and hashed values
|
||||
cannot diverge.
|
||||
|
||||
Indexes on `titles(tmdb_id)`, `manifests(title_id, runtime_sec)`,
|
||||
`manifests(video_hash)`, `manifests(title_id, season, episode)`, and
|
||||
`manifests(title_id, season, episode)`, and
|
||||
`scenes(manifest_id, tmdb_person_id)`. All read queries filter
|
||||
`status IN ('listed','flagged')`, so a partial index on that predicate keeps
|
||||
the hot path small.
|
||||
@@ -1459,7 +1494,7 @@ addresses.
|
||||
and must be opt-in)
|
||||
- **Servers** — an *ordered list*, not a single URL. See below.
|
||||
- **Contribute manifests** (separate opt-in from downloading; off by default)
|
||||
- **Minimum accepted match tier** (`exact` / `audio` / `runtime` / `loose`)
|
||||
- **Minimum accepted match tier** (`audio` / `runtime` / `loose`)
|
||||
- **Compute audio signatures** (default off) — enables `audio`-tier matching
|
||||
and unknown-providence search (§3). Uses the FFmpeg binary Jellyfin already
|
||||
ships, via `IMediaEncoder.EncoderPath`; no extra dependency.
|
||||
@@ -1630,6 +1665,15 @@ silently break federation deduplication. The signature is replicated as an
|
||||
attribute of the manifest, not as part of its identity. A peer that already
|
||||
holds a manifest but lacks its signature may adopt the incoming one.
|
||||
|
||||
**`cut` is therefore a single key — `runtime_cs`.** `video_hash` was in the
|
||||
canonical form until it was withdrawn (§3), and it was removed from the form
|
||||
rather than retained as a vestigial `null`, on the same reasoning §2 applied to
|
||||
`anneal_sec`: a key naming a signal the format no longer has is actively
|
||||
misleading. **Every `content_id` changed at that point, including for manifests
|
||||
that never carried a hash.** Peers holding pre-withdrawal ids must re-derive
|
||||
them; there is no migration, because a content address is not a value that can
|
||||
be migrated — it is recomputed or it is wrong.
|
||||
|
||||
Quantising to integers rather than formatting floats is deliberate. Pipeline
|
||||
timings are *derived* by accumulating `1/fps`, not measured, so they carry
|
||||
accumulated float error — real corpus values look like `8045.066666660665`.
|
||||
@@ -1669,7 +1713,7 @@ A monotonic, append-only change feed of locally-*listed* manifests.
|
||||
"content_id": "sha256:9f2a…",
|
||||
"op": "add",
|
||||
"identity": { "type": "movie", "tmdb_id": "504172" },
|
||||
"cut": { "runtime_sec": 6420.5, "video_hash": "opensubtitles:8e24…" },
|
||||
"cut": { "runtime_sec": 6420.5 },
|
||||
"actor_count": 17,
|
||||
"cast_match_ratio": 0.82,
|
||||
"origin": "jray.example.org",
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
# Legal posture
|
||||
|
||||
What a JRay public server instance holds, what it structurally cannot do, and
|
||||
how that sits against EU and international copyright law.
|
||||
|
||||
This document exists because [`SPEC.md`](../SPEC.md) §5a calls for it: *"Because
|
||||
the server stores only integers and references to TMDB entities, it holds no
|
||||
user-generated content in the sense that intermediary-liability regimes
|
||||
contemplate. This should be stated plainly in the operator documentation,
|
||||
alongside a contact address for takedown requests."* It is the operator-facing
|
||||
half of that.
|
||||
|
||||
It documents `SR-004` (no binary content), `SR-005` (gallery data never leaves
|
||||
the instance) and `PR-005` (leak nothing about what a user owns). Those are
|
||||
requirements, not aspirations: the posture below is a *consequence* of them, and
|
||||
it survives exactly as long as they do. §8 is the list of changes that would end
|
||||
it.
|
||||
|
||||
The position is **deliberately not original**: §5 adopts it from the services
|
||||
that have already operated it for decades, and records what was taken from whom.
|
||||
|
||||
> **Not legal advice.** This is an engineering description of what the software
|
||||
> does, mapped onto the legal frameworks that plausibly apply, written to be
|
||||
> useful to an operator, a hosting provider's abuse desk, and anyone sending a
|
||||
> complaint. An operator should confirm the analysis for their own jurisdiction.
|
||||
> Where the law is unsettled or untested this document says so rather than
|
||||
> asserting a conclusion.
|
||||
|
||||
---
|
||||
|
||||
## 1. What an instance holds
|
||||
|
||||
Exhaustively. The submitted JSON is parsed, validated, resolved to TMDB person
|
||||
ids, written as rows, and **discarded**; the document served to a client is
|
||||
*reconstructed* from those rows, never echoed (SPEC §7 "Storage").
|
||||
|
||||
| Stored | Form |
|
||||
|---|---|
|
||||
| Title identity | TMDB / IMDB id, regex-constrained. Title name and year |
|
||||
| Cut identity | A runtime in seconds |
|
||||
| Audio signature | 1288 bytes: one byte per ~93 ms frame, each a 5-bit band index + 2-bit energy class |
|
||||
| Actor identity | TMDB person id — an integer |
|
||||
| Presence | Pairs of integer centisecond offsets |
|
||||
| Provenance | An anonymous token hash, a status, a cast-match ratio, timestamps |
|
||||
|
||||
That is the whole of it. There is no column for anything else, and this is a
|
||||
deliberate control rather than tidiness: a relational schema **can only represent
|
||||
what it models**, so whatever a validator might have missed has physically
|
||||
nowhere to live.
|
||||
|
||||
### What it does not hold
|
||||
|
||||
- **No audio, video, images, or crops.** No field can carry them (`SR-004`).
|
||||
- **No embeddings and no gallery data** (`SR-005`, `UR-012`). A 512-float array
|
||||
is a 2 KB opaque binary field, which is precisely the extension point the
|
||||
design lacks.
|
||||
- **No subtitles or dialogue.** Nothing derived from the script, and nothing
|
||||
that reproduces a line of it. This is the distinction that matters most in
|
||||
practice — see §5.
|
||||
- **No URLs, no links, no source information, no torrent identifiers, no
|
||||
filenames, no file paths.** Nothing that could help anyone locate, obtain, or
|
||||
identify a copy of anything.
|
||||
- **No file-level fingerprint.** `cut.video_hash` was withdrawn (SPEC §3 "Why
|
||||
there is no file-level signal"). Every remaining signal describes a **cut** — the
|
||||
edit — and is therefore shared by everyone who holds that edit, however they
|
||||
came by it. Nothing in the format individuates one person's particular copy.
|
||||
- **No accounts, no email addresses, no personal data by design.** Contribution
|
||||
uses an anonymous bearer token, stored only as a hash; the server cannot
|
||||
enumerate who holds tokens.
|
||||
- **No free-form text.** Actor names submitted on upload are used for matching
|
||||
and then dropped; display names are served from the instance's own
|
||||
TMDB-derived table. The only attacker-controlled values reaching the database
|
||||
are integers.
|
||||
|
||||
---
|
||||
|
||||
## 2. What an instance structurally cannot do
|
||||
|
||||
Stated as capabilities the software lacks, because that is the form a hosting
|
||||
provider and a complainant both need.
|
||||
|
||||
1. **It cannot store or transmit any part of a copyrighted work** in perceptible
|
||||
form. There is no field of any kind that accepts binary or attacker-chosen
|
||||
content.
|
||||
2. **It cannot help anyone obtain a copy of anything.** No links, no sources, no
|
||||
swarm identifiers, no search-by-release. A Jmanifest is inert with respect to
|
||||
acquisition: knowing one tells you when an actor is on screen and nothing
|
||||
whatsoever about where a file might be found.
|
||||
3. **It cannot identify which copy a user holds.** Post-withdrawal of
|
||||
`video_hash`, the server cannot distinguish a client holding the very file a
|
||||
manifest was extracted from and one holding a different encode of the same
|
||||
cut. This is the intended property, not a limitation.
|
||||
4. **It cannot reconstruct audio from a signature.** See §4.
|
||||
5. **It cannot be used as a general content host.** The abuse defence is
|
||||
structural: there is nowhere to put a payload (`SR-004`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Is a Jmanifest a reproduction of the work?
|
||||
|
||||
The core question. The answer rests on the idea/expression dichotomy, which is
|
||||
one of the few genuinely universal principles in this area.
|
||||
|
||||
**International.** TRIPS Art. 9(2) and the WIPO Copyright Treaty Art. 2 both
|
||||
provide that copyright protection "extend[s] to expressions and not to ideas,
|
||||
procedures, methods of operation or mathematical concepts as such." A Jmanifest
|
||||
records: *this TMDB person is present in this cut from 191.60 s to 209.20 s.*
|
||||
That is a proposition about a fact, expressed in integers. It contains no
|
||||
dialogue, no image, no music, no plot, and no authorial choice belonging to the
|
||||
film's makers.
|
||||
|
||||
**EU.** Under the InfoSoc Directive (2001/29/EC) Art. 2, the reproduction right
|
||||
covers reproduction of the author's own intellectual creation. *Infopaq*
|
||||
(C-5/08) established that even an 11-word extract can qualify — but on the
|
||||
express condition that what is taken *expresses the author's own intellectual
|
||||
creation*. The relevant test is therefore qualitative, not quantitative, and it
|
||||
is the test a Jmanifest passes comfortably: an actor's presence interval
|
||||
expresses nothing of the film's creative content. *Football Dataco* (C-604/10)
|
||||
points the same way from the other direction — where the content of a dataset is
|
||||
dictated by technical rules leaving no room for creative freedom, it does not
|
||||
attract copyright.
|
||||
|
||||
**Elsewhere.** In the US, *Feist v. Rural Telephone* (1991) holds that facts are
|
||||
uncopyrightable regardless of the effort spent gathering them, and there is no
|
||||
US database right. The analysis is if anything simpler outside the EU.
|
||||
|
||||
**Honest caveat.** The set of presence windows across a whole title is derived
|
||||
*from* the work by watching it, and no court has ruled on precisely this
|
||||
artefact. The argument above is strong and rests on settled principles, but it
|
||||
is an argument, not an adjudicated holding.
|
||||
|
||||
---
|
||||
|
||||
## 4. The audio signature — the one content-derived artefact
|
||||
|
||||
This is the field a careful lawyer would examine, so it is addressed directly
|
||||
rather than buried.
|
||||
|
||||
**What it is.** A 120-second window at the midpoint of the media is decoded,
|
||||
downmixed to mono and resampled to 11025 Hz. Per ~93 ms frame, the log-magnitude
|
||||
spectrum over 300–3000 Hz is divided into 32 log-spaced bins and **only the index
|
||||
of the loudest bin** is kept, plus a 2-bit coarse energy class. One byte per
|
||||
frame; 1288 bytes total (SPEC §3 "Construction").
|
||||
|
||||
**Why it is not a reproduction.** The construction discards essentially
|
||||
everything:
|
||||
|
||||
- All phase information is gone.
|
||||
- All magnitude information is gone except one 2-bit class per frame.
|
||||
- 31 of 32 bands per frame are discarded; only *which* band was loudest survives.
|
||||
- Everything outside 300–3000 Hz is discarded.
|
||||
- Everything outside the central 120 seconds is discarded.
|
||||
|
||||
The transform is one-way by construction, not merely difficult to invert. **No
|
||||
audio can be recovered from it, and nothing about it is perceptible to a human.**
|
||||
It is roughly 1.3 KB standing in for two minutes of audio — a compression ratio
|
||||
against the source PCM of about four thousand to one, achieved by throwing away
|
||||
the signal rather than by coding it efficiently.
|
||||
|
||||
**Supporting EU reasoning.** *Pelham* (C-476/17) held that where a sound sample
|
||||
is included in another work "in a modified form unrecognisable to the ear", there
|
||||
is no reproduction of the phonogram. That case concerned artistic sampling rather
|
||||
than fingerprinting, so it is reasoning by analogy rather than a holding on
|
||||
point — but the principle it turns on, that unrecognisability defeats the
|
||||
reproduction claim, applies with far greater force here: a Pelham sample is at
|
||||
least still audio.
|
||||
|
||||
**The industry's own premise.** Content-recognition fingerprinting — YouTube
|
||||
Content ID, Audible Magic, and the compliance ecosystem that grew up around
|
||||
Art. 17 CDSM — depends on the proposition that a fingerprint of a work is not a
|
||||
copy of it. Rights holders operate and rely on such systems at scale. A claim
|
||||
that a fingerprint is itself an infringing reproduction would be in tension with
|
||||
that practice.
|
||||
|
||||
**Where it is computed.** On the user's own machine, from their own file, by the
|
||||
FFmpeg binary Jellyfin already ships. The transient decode is an ordinary
|
||||
technological process of the kind InfoSoc Art. 5(1) exempts, and it happens on
|
||||
the client — the server never decodes anything.
|
||||
|
||||
---
|
||||
|
||||
## 5. Alignment with comparable services
|
||||
|
||||
**This posture is deliberately not novel.** The closest analogues have decades of
|
||||
operating history between them, and a bespoke argument — however sound — is worth
|
||||
less than one an ecosystem has already run on. So the position below is adopted
|
||||
from theirs, and this section records what was taken from whom, and what was
|
||||
deliberately left.
|
||||
|
||||
| Service | Indexes | Litigated? |
|
||||
|---|---|---|
|
||||
| CDDB / freedb | Disc IDs computed from track offsets | No copyright litigation. Its controversy was Gracenote's commercial closure of the database, a licensing dispute |
|
||||
| AcoustID / Chromaprint | Compact per-frame spectral features of recordings | None known. Operated openly alongside MusicBrainz for years |
|
||||
| MusicBrainz / AcousticBrainz | Recording metadata; crowdsourced acoustic features | None on the data itself |
|
||||
| OpenSubtitles | **Subtitle files**, plus a file-hash index | **Yes — over the subtitles** |
|
||||
|
||||
### Adopted from MusicBrainz, AcoustID and AcousticBrainz
|
||||
|
||||
These are the strongest models, and the elements worth copying are structural
|
||||
rather than rhetorical:
|
||||
|
||||
- **An explicit, permissive licence on the data.** MusicBrainz core data and all
|
||||
of AcousticBrainz are CC0; AcoustID's data is CC BY-SA 3.0. Every one of them
|
||||
states a licence rather than leaving contributed data in an unstated condition.
|
||||
JRay had no such statement at all until §6 below, which was the largest single
|
||||
gap in this posture.
|
||||
- **An open-source implementation.** The algorithm and the server are both
|
||||
inspectable, so no part of the argument above rests on a claim a reader has to
|
||||
take on trust. `JRay-public-server` is `GPL-3.0-or-later`; the audio signature
|
||||
is specified in the open, down to a golden fixture an independent
|
||||
implementation can be written from (§3).
|
||||
- **The fingerprint framed as identification, never as content.** Chromaprint is
|
||||
presented as a compact mathematical representation for matching near-identical
|
||||
audio — not as a compressed recording. §4 makes the same claim about the JRay
|
||||
signature, and makes it more strongly, since the JRay construction discards
|
||||
strictly more.
|
||||
|
||||
### Adopted from OpenSubtitles
|
||||
|
||||
Their operational practice is sound even where their drafting is thin:
|
||||
|
||||
- **A named copyright contact**, with stated requirements for what a valid notice
|
||||
must contain and in what form. Theirs is `copyright@opensubtitles.org`.
|
||||
- **A plain statement of what is and is not hosted.** Theirs: subtitles
|
||||
translated by users, *not* video, movie or audio files. JRay's equivalent is
|
||||
§1, and it is a far shorter list.
|
||||
- **Prompt removal on doubt, without adjudicating the merits.** They remove on
|
||||
request rather than litigating whether the request is well-founded; §10 below
|
||||
does the same, and SPEC §5a makes delisting a single `UPDATE` precisely so it
|
||||
costs the operator nothing to be cooperative.
|
||||
- **An honest statement of what a takedown can reach.** They say plainly that
|
||||
they can remove a file from their own site and cannot remove it from anywhere
|
||||
else. The federated equivalent is stated in §10 below.
|
||||
|
||||
### Deliberately not adopted
|
||||
|
||||
Three things in the ecosystem should **not** be copied, and are named here
|
||||
because they are the parts most likely to be copied by reflex:
|
||||
|
||||
- **OpenSubtitles' "if you are affiliated with any government or ANTI-Piracy
|
||||
group … you CANNOT enter this web site" clause.** It has no legal effect
|
||||
whatsoever, and its real cost is what it signals: a service that needs to
|
||||
exclude scrutiny is asserting something about its own view of itself. This
|
||||
posture depends on the opposite claim — an instance holds nothing it would
|
||||
mind anyone reading, and §1 is published in full precisely so that a
|
||||
complainant can check it. Nothing here should suggest otherwise.
|
||||
- **Their silence on contributor licensing.** OpenSubtitles' published legal
|
||||
pages say nothing about the rights users grant on upload. That is a gap in
|
||||
their model rather than a feature of it, and §6 exists because federation
|
||||
makes it one JRay cannot afford.
|
||||
- **"Files we believe are free to redistribute."** A belief-based standard about
|
||||
the provenance of contributions. JRay needs no such standard, because the
|
||||
structural argument in §§1–4 does not depend on any belief about where a
|
||||
contributor's media came from — and the design deliberately cannot know.
|
||||
|
||||
### Their published legal reasoning is not a model — only their practice is
|
||||
|
||||
Worth stating explicitly, because "align with OpenSubtitles" is an obvious
|
||||
instruction to give and a bad one to follow literally.
|
||||
|
||||
The OpenSubtitles community does publish legal material — a legal-information
|
||||
page, a DMCA page, a blog post on the legality of subtitle sites, and recurring
|
||||
forum threads. But what it publishes is **advocacy rather than analysis**, and it
|
||||
rests on three arguments none of which JRay should borrow:
|
||||
|
||||
- **"Fair use."** The service and its user base are substantially European, and
|
||||
**there is no fair use in EU law.** InfoSoc Art. 5 provides a *closed list* of
|
||||
permitted exceptions, not an open-ended balancing test. Invoking a US doctrine
|
||||
against an EU claim is not a weak argument, it is an argument addressed to the
|
||||
wrong system — and the Amsterdam court in 2017 was not applying it.
|
||||
- **Cultural and educational benefit.** True, and irrelevant to whether the
|
||||
reproduction right is engaged. It is a case for reforming the law, not a
|
||||
defence under it.
|
||||
- **"The legality is debated / uncertain."** For the specific question of
|
||||
unauthorised fan subtitles in the Netherlands it is not uncertain: it was
|
||||
decided against them in April 2017. Sweden's seizure of Undertexter.se points
|
||||
the same way. Describing a settled adverse holding as an open question is the
|
||||
single thing in that material most worth not imitating.
|
||||
|
||||
**The asymmetry that matters:** OpenSubtitles must argue that copying protected
|
||||
expression is *excused*. JRay argues that it copies **no protected expression at
|
||||
all** (§§3–4). Those are different kinds of position, and the second does not
|
||||
need the first's arguments — borrowing them would import a weakness this design
|
||||
does not have, and would implicitly concede the point they are conceding.
|
||||
|
||||
So: take their **operational practice**, which is sound and battle-tested, and
|
||||
take MusicBrainz's and AcoustID's **structural posture**, which is what a service
|
||||
that genuinely holds no protected expression should look like. Take neither
|
||||
community's excuses.
|
||||
|
||||
### The OpenSubtitles answer specifically
|
||||
|
||||
The user-facing question — *"OpenSubtitles has been doing this for twenty years,
|
||||
what happened to them?"* — has a clean answer, and it is favourable.
|
||||
|
||||
**The legal exposure was always the subtitles, never the hash index.** In April
|
||||
2017 the Amsterdam District Court ruled, in proceedings brought by the Free
|
||||
Subtitles Foundation (Stichting Laat Ondertitels Vrij) against the Dutch
|
||||
anti-piracy body BREIN, that fan-made subtitles may be created and distributed
|
||||
only with the rights holder's permission, and that doing so otherwise is
|
||||
infringement. The reasoning is unremarkable once stated: **a subtitle file is a
|
||||
transcription or translation of the film's dialogue.** Dialogue is the
|
||||
screenwriter's expression; a translation of it is a derivative work. That is
|
||||
squarely within the reproduction and adaptation rights, and the fact that the
|
||||
subtitles were made from scratch and given away free did not save them.
|
||||
|
||||
**JRay carries none of that exposure**, because it carries no dialogue. This is
|
||||
the single sharpest distinction available:
|
||||
|
||||
> OpenSubtitles distributes the *words characters say*. JRay distributes *when a
|
||||
> credited actor is on screen*. The first is authored expression. The second is
|
||||
> a fact about the performance.
|
||||
|
||||
The OpenSubtitles **hash index** — the same first+last-64 KiB-plus-size
|
||||
construction JRay used to use — has to my knowledge never been the subject of a
|
||||
copyright claim in its own right, in any jurisdiction, in about two decades of
|
||||
operation. That is an absence of litigation rather than an adjudicated
|
||||
clearance, and it should be described that way. JRay no longer carries such a
|
||||
hash in any case (§3), so the question is now moot for this design.
|
||||
|
||||
### The distinguishing case on the other side
|
||||
|
||||
*Ziggo* (C-610/15, "The Pirate Bay") held that indexing and categorising torrent
|
||||
files **is** a communication to the public, so an index can certainly infringe.
|
||||
The distinction is precise and JRay sits clearly on the safe side of it: TPB
|
||||
indexed *the works*, via identifiers that let a user obtain them, and the Court's
|
||||
reasoning turned on the platform playing "an essential role" in making the works
|
||||
available. A Jmanifest plays no role in availability whatsoever — it contains
|
||||
nothing that can retrieve a byte of anything. *GS Media* (C-160/15) is likewise
|
||||
inapplicable for the simple reason that there are no links.
|
||||
|
||||
---
|
||||
|
||||
## 6. The licence Jmanifests are published under
|
||||
|
||||
**Recommended: CC0 1.0 Universal.** This matches MusicBrainz core data and
|
||||
AcousticBrainz exactly, and it is the one choice consistent with the rest of this
|
||||
document.
|
||||
|
||||
**Why CC0 and not a share-alike licence.** AcoustID uses CC BY-SA 3.0, and the
|
||||
temptation is to follow it — but a share-alike licence works by *asserting* a
|
||||
right in the data and then conditioning its use. §3 argues at length that
|
||||
presence timings are facts rather than protectable expression. Asserting
|
||||
copyright in them in order to license them would contradict that argument in the
|
||||
same repository, and a contradiction of that kind is worth more to an opponent
|
||||
than the licence is worth to the project. CC0 asserts nothing, which is the
|
||||
position §3 actually takes.
|
||||
|
||||
**Three things it buys, beyond consistency:**
|
||||
|
||||
- **It waives the sui generis database right explicitly.** CC0 1.0 covers
|
||||
database rights by name, not merely copyright. That closes, from the
|
||||
contributor's side, precisely the EU-specific residual exposure §7 identifies —
|
||||
and it closes it in the one jurisdiction where a database right exists to be
|
||||
waived.
|
||||
- **It makes federation lawful without a negotiation.** SPEC §9a has independent
|
||||
operators replicating each other's catalogues wholesale. Replication is a
|
||||
reproduction and a making-available of whatever right subsists in the
|
||||
compilation; without a grant flowing from the contributor through every peer,
|
||||
each hop is an unlicensed one. A viral or non-commercial licence would make
|
||||
each peering an agreement to be checked. CC0 makes it a non-event.
|
||||
- **It removes the incentive to fork the network.** Nobody needs to negotiate
|
||||
with an instance operator to mirror a catalogue, so no instance can become a
|
||||
chokepoint. That is the same property that let freedb be forked when CDDB was
|
||||
closed commercially — which is the failure mode this whole ecosystem learned
|
||||
from.
|
||||
|
||||
**The grant must be taken at upload.** A licence the server declares is worth
|
||||
nothing if contributors never granted it. The contribution terms must state that
|
||||
submitting a manifest places its content under CC0, and the JRay plugin's
|
||||
contribution opt-in (SPEC §9) is where a user encounters that — it is already an
|
||||
explicit, off-by-default choice, so this adds a sentence to a decision the user
|
||||
is already making rather than a new one.
|
||||
|
||||
**Scope, stated precisely.** The licence covers *the manifest* — the timings,
|
||||
identifiers and signature contributed. It does not and cannot purport to license
|
||||
the underlying film, which is not the contributor's to license and which the
|
||||
server does not hold. This distinction is the whole of §§1–4 restated in a
|
||||
sentence, and it belongs in the terms in exactly that form.
|
||||
|
||||
> **Status: recommended, not yet adopted.** No repository currently carries a
|
||||
> `LICENSE` file, and nothing outside this document states a data licence.
|
||||
> Adopting this means a recorded decision, a `LICENSE` file, a line in the
|
||||
> contribution terms, and a sentence in the plugin's contribution opt-in.
|
||||
|
||||
---
|
||||
|
||||
## 7. The other frameworks
|
||||
|
||||
### Database right (EU-specific — Directive 96/9/EC)
|
||||
|
||||
The one people forget, because it has no counterpart in most of the world.
|
||||
|
||||
**Does an instance infringe anyone else's?** The sui generis right protects
|
||||
substantial investment in *obtaining, verifying or presenting* the contents of a
|
||||
database. *British Horseracing Board v William Hill* (C-203/02) drew the decisive
|
||||
line: the right protects investment in **obtaining** existing data, not in
|
||||
**creating** it. JRay's timings are *created* — by running a CV pipeline over a
|
||||
file — not extracted from anyone's database. The TMDB identifiers are a different
|
||||
matter, and the governing instrument there is **TMDB's own API terms, not
|
||||
copyright law**. An operator must hold a valid TMDB API key and comply with its
|
||||
terms, including attribution. Treat that as an operational obligation, because it
|
||||
is the most likely source of a real complaint.
|
||||
|
||||
**Does an instance acquire one?** Possibly, in the operator's favour, over the
|
||||
catalogue as compiled. Not relied upon here.
|
||||
|
||||
### Intermediary liability — DSA (Regulation (EU) 2022/2065)
|
||||
|
||||
Art. 6 carries forward the hosting safe harbour formerly in e-Commerce Directive
|
||||
Art. 14; Art. 8 preserves the prohibition on general monitoring obligations.
|
||||
|
||||
**The safe harbour is a fallback here, not the primary defence** — an instance
|
||||
holds no user-generated content of the kind the regime contemplates. But the
|
||||
obligations that establish it are cheap, and an operator should meet them anyway:
|
||||
a published point of contact (Arts. 11–12) and a notice-and-action mechanism
|
||||
(Art. 16). Note that Art. 19 exempts micro and small enterprises from the heavier
|
||||
Section 3 obligations, and a volunteer-run non-commercial instance sits well
|
||||
outside the tiers aimed at large platforms.
|
||||
|
||||
In France specifically, this is the *hébergeur* status under the LCEN (loi
|
||||
n° 2004-575 du 21 juin 2004, art. 6), and a published contact address is what
|
||||
establishes it.
|
||||
|
||||
### Art. 17 CDSM (Directive (EU) 2019/790) — does not apply
|
||||
|
||||
Someone will raise it, so it is worth disposing of. Art. 17 binds an "online
|
||||
content-sharing service provider", defined in Art. 2(6) as one that stores and
|
||||
gives the public access to **a large amount of copyright-protected works uploaded
|
||||
by its users**. A manifest server stores no works at all, so it is not an OCSSP
|
||||
and Art. 17 does not engage. Art. 2(6) additionally carves out not-for-profit
|
||||
services.
|
||||
|
||||
### Technological protection measures
|
||||
|
||||
InfoSoc Art. 6, and in France the DADVSI. The server never circumvents anything,
|
||||
ships no circumvention tool, and holds nothing that could not equally have been
|
||||
derived from a lawfully obtained copy. Whatever a user does locally to read their
|
||||
own media is independent of the server, which has no way to know it and — since
|
||||
`video_hash` was withdrawn — no way to record which encode a contributor held
|
||||
even incidentally.
|
||||
|
||||
### Data protection (GDPR)
|
||||
|
||||
Personal data held is minimal by design: no accounts, no email, token hashes
|
||||
only, and rate-limit counters that live in process memory and reset on restart.
|
||||
Two items deserve an operator's attention:
|
||||
|
||||
- **`reports.source_ip_hash` is salted but not secret.** It is
|
||||
`SHA-256(server_id ‖ 0x00 ‖ ip)`, and `server_id` is a public value — the
|
||||
instance's hostname. That salt does what its code comment claims, which is to
|
||||
make hashes non-comparable *across instances*; it does **not** make them
|
||||
irreversible, because anyone knowing the hostname can enumerate the whole IPv4
|
||||
space in minutes. Treat these as pseudonymous personal data, set a retention
|
||||
period, and switch to an HMAC under a secret key if the value is worth
|
||||
protecting.
|
||||
- **Reverse-proxy access logs are the real linkage risk**, not the schema. A
|
||||
default nginx or Caddy configuration correlates bearer token to IP address for
|
||||
free, which reconstructs exactly the contributor identity the token design goes
|
||||
to some trouble to avoid. Disable or truncate them.
|
||||
|
||||
Actor names are personal data, but concern public figures acting in a
|
||||
professional capacity, are sourced from TMDB rather than from uploads, and are
|
||||
served rather than stored from user submissions.
|
||||
|
||||
---
|
||||
|
||||
## 8. What would destroy this posture
|
||||
|
||||
Every statement above is a consequence of a design property. These changes would
|
||||
each remove one, and should be treated as proposals to delete the posture rather
|
||||
than as incremental features:
|
||||
|
||||
| Change | What it destroys |
|
||||
|---|---|
|
||||
| Shipping images, face crops, or embeddings | §1, §2.1. Ends `SR-004` and `SR-005` at a stroke; introduces a moderation obligation and a rights exposure over the crops themselves |
|
||||
| Adding any free-form string field | §1 "no free-form text". Reopens the payload channel `SR-004` closes |
|
||||
| Adding a URL field of any kind | §2.2 and the *Ziggo* distinction in §5 — the one that actually matters |
|
||||
| Storing the uploaded JSON verbatim | §1. A blob is an opaque container; whatever the validator missed gets persisted and served back |
|
||||
| Reintroducing a file-level fingerprint | §1, §2.3. Restores the release-level oracle and makes a contributor's uploads an inventory of their own files |
|
||||
| Carrying subtitles or dialogue | §5. Moves the design from JRay's position to OpenSubtitles' — the one that *has* been litigated, and lost |
|
||||
|
||||
---
|
||||
|
||||
## 9. Operator checklist
|
||||
|
||||
1. **Publish a contact address for notices**, and publish this document. This is
|
||||
what makes a complaint resolvable by email rather than by escalation, and
|
||||
what establishes hosting-provider status under the LCEN / DSA.
|
||||
2. **Hold a valid TMDB API key and comply with its terms**, attribution
|
||||
included. This is the most likely source of a genuine complaint.
|
||||
3. **Disable or truncate reverse-proxy access logs.** See §6.
|
||||
4. **Set a retention period on `reports.source_ip_hash`**, and use an HMAC under
|
||||
a secret key rather than the public `server_id` salt if the linkage matters.
|
||||
5. **Do not contribute manifests from the instance you operate.** Aggregate
|
||||
deniability — that a manifest may have been replicated from any peer, and
|
||||
says nothing about what its host holds — is real and load-bearing, and it
|
||||
does not survive a traceable linkage from your instance to your own uploads.
|
||||
Contribute through the ordinary token path, from elsewhere, if at all.
|
||||
6. **Keep the kill switch usable.** SPEC §5a makes delisting a single `UPDATE`:
|
||||
one manifest, every manifest from a token, or every manifest for a title.
|
||||
Delisting is instant and reversible; deletion is a separate, logged action.
|
||||
7. **Adopt a data licence and add a `LICENSE` file.** §6 recommends CC0 1.0.
|
||||
No repository currently carries one, and the server's `GPL-3.0-or-later`
|
||||
covers the *code* only — it says nothing about the manifests, which are the
|
||||
thing that actually replicates between instances. This is the largest
|
||||
outstanding gap in the posture.
|
||||
8. **State the licence in the contribution terms**, so contributors grant it
|
||||
rather than the server merely declaring it (§6).
|
||||
|
||||
---
|
||||
|
||||
## 10. Responding to a notice
|
||||
|
||||
A valid notice should identify the title and, if it concerns a specific
|
||||
manifest, its `manifest_id`. The operator can then:
|
||||
|
||||
- **Delist that manifest**, or every manifest for that title, immediately
|
||||
(SPEC §5a "Residual risk and the operator's lever").
|
||||
- **Confirm what was held**, using §1 — which for any manifest is a list of
|
||||
integers, and can be stated in full.
|
||||
|
||||
What the operator cannot do is remove a copy of the work, because there is not
|
||||
one. A notice demanding takedown of infringing *content* is answerable in one
|
||||
sentence: the service holds no content of the complainant's, and §1 is the
|
||||
complete list of what it does hold. Federation retractions (SPEC §9a) can propagate a
|
||||
withdrawal to peers, and `TrustAbuseRetractions` exists precisely so that a
|
||||
legally-motivated withdrawal can be honoured across instances that opt into it.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
**The 2017 Amsterdam ruling (§5):**
|
||||
|
||||
- [Unauthorized Subtitles For Movies & TV Shows Are Illegal, Court Rules — TorrentFreak](https://torrentfreak.com/unauthorized-subtitles-for-movies-tv-shows-are-illegal-court-rules-170421/)
|
||||
- [Dutch Court Rules That Freely Given Fan-Subtitles Are Copyright Infringement — Techdirt](https://www.techdirt.com/2017/04/25/dutch-court-rules-that-freely-given-fan-subtitles-are-copyright-infringement/)
|
||||
- [Fansubbing: Dutch Court Rules Fan-Made Subtitles Are Illegal And Copyright Infringement — IBTimes](https://www.ibtimes.com/fansubbing-dutch-court-rules-fan-made-subtitles-are-illegal-copyright-infringement-2529162)
|
||||
|
||||
**OpenSubtitles' own published material (§5) —** cited as the position being
|
||||
declined, not adopted:
|
||||
|
||||
- [Legal Information — OpenSubtitles Help Center](https://opensubtitles.tawk.help/article/legal-information)
|
||||
- [Terms of Service — OpenSubtitles Help Center](https://opensubtitles.tawk.help/article/terms-of-service)
|
||||
- [DMCA — OpenSubtitles Help Center](https://opensubtitles.tawk.help/article/dmca)
|
||||
- [Legality of Subtitle Websites — Open Subtitles Blog](https://blog.opensubtitles.com/opensubtitles/legality-of-subtitle-websites-how-open-subtitles-enhances-global-media-accessibility)
|
||||
|
||||
**Fingerprinting and data licensing (§§4, 6):**
|
||||
|
||||
- [Chromaprint — AcoustID](https://acoustid.org/chromaprint)
|
||||
- [Database — AcoustID](https://acoustid.org/database)
|
||||
- [About / Data License — MusicBrainz](https://musicbrainz.org/doc/About/Data_License)
|
||||
- [AcousticBrainz — MusicBrainz](https://musicbrainz.org/doc/AcousticBrainz)
|
||||
- [AcoustID — Wikipedia](https://en.wikipedia.org/wiki/AcoustID)
|
||||
+51
-51
@@ -3,7 +3,7 @@
|
||||
<!-- GENERATED FILE - do not edit by hand. -->
|
||||
<!-- Regenerate: scripts/traceability/traceability-gate.sh -->
|
||||
|
||||
**Generated:** 2026-07-31T07:28:04+00:00
|
||||
**Generated:** 2026-07-31T07:49:04+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`).
|
||||
|
||||
@@ -66,7 +66,7 @@ _None._
|
||||
| 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 | Planned | unset | PR-006 | covered | `src/api/federation.rs`, `src/worker.rs` | Servers replicate manifests between each other |
|
||||
| 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-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 |
|
||||
@@ -98,9 +98,9 @@ _None._
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`src/api/fetch.rs:270`](../src/api/fetch.rs#L270) — `pub fn reconstruct(`
|
||||
- [`src/db/repo.rs:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>`
|
||||
- [`src/db/repo.rs:412`](../src/db/repo.rs#L412) — `pub fn actors_for_manifest(`
|
||||
- [`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(`
|
||||
|
||||
### DR-003
|
||||
|
||||
@@ -112,13 +112,13 @@ _None._
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`src/db/repo.rs:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>`
|
||||
- [`src/db/repo.rs:331`](../src/db/repo.rs#L331) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>`
|
||||
|
||||
### DR-005
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`src/db/repo.rs:709`](../src/db/repo.rs#L709) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result<Vec<Jo…`
|
||||
- [`src/db/repo.rs:702`](../src/db/repo.rs#L702) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result<Vec<Jo…`
|
||||
|
||||
### DR-006
|
||||
|
||||
@@ -149,8 +149,8 @@ _None._
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`src/content_id.rs:52`](../src/content_id.rs#L52) — `pub fn canonical_json(`
|
||||
- [`src/content_id.rs:129`](../src/content_id.rs#L129) — `pub fn content_id(`
|
||||
- [`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/validate.rs:102`](../src/validate.rs#L102) — `pub fn to_centiseconds(secs: f64) -> i64`
|
||||
|
||||
### DR-013
|
||||
@@ -167,7 +167,7 @@ _None._
|
||||
|
||||
- [`src/config.rs:10`](../src/config.rs#L10) — `Unknown`
|
||||
- [`src/db/mod.rs:30`](../src/db/mod.rs#L30) — `struct ReadPool`
|
||||
- [`src/db/repo.rs:709`](../src/db/repo.rs#L709) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result<Vec<Jo…`
|
||||
- [`src/db/repo.rs:702`](../src/db/repo.rs#L702) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result<Vec<Jo…`
|
||||
|
||||
### PR-005
|
||||
|
||||
@@ -183,11 +183,11 @@ _None._
|
||||
- [`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/fetch.rs:128`](../src/api/fetch.rs#L128) — `pub async fn get_series(`
|
||||
- [`src/api/fetch.rs:127`](../src/api/fetch.rs#L127) — `pub async fn get_series(`
|
||||
- [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `pub async fn post_manifest(`
|
||||
- [`src/api/upload.rs:101`](../src/api/upload.rs#L101) — `pub async fn post_bundle(`
|
||||
- [`src/ingest.rs:50`](../src/ingest.rs#L50) — `pub fn persist(`
|
||||
- [`src/validate.rs:586`](../src/validate.rs#L586) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>`
|
||||
- [`src/validate.rs:572`](../src/validate.rs#L572) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>`
|
||||
- [`src/worker.rs:107`](../src/worker.rs#L107) — `async fn run_federation_pull(&self, payload: &str) -> Result<(), JobError>`
|
||||
|
||||
### SR-001
|
||||
@@ -196,20 +196,20 @@ _None._
|
||||
|
||||
- [`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/api/fetch.rs:270`](../src/api/fetch.rs#L270) — `pub fn reconstruct(`
|
||||
- [`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:412`](../src/db/repo.rs#L412) — `pub fn actors_for_manifest(`
|
||||
- [`src/matching.rs:52`](../src/matching.rs#L52) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option<CutMatch>`
|
||||
- [`src/model.rs:179`](../src/model.rs#L179) — `Unknown`
|
||||
- [`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`
|
||||
|
||||
### SR-002
|
||||
|
||||
**Locations:** 4
|
||||
|
||||
- [`src/api/fetch.rs:270`](../src/api/fetch.rs#L270) — `pub fn reconstruct(`
|
||||
- [`src/model.rs:179`](../src/model.rs#L179) — `Unknown`
|
||||
- [`src/model.rs:238`](../src/model.rs#L238) — `pub fn from_stored(s: &str) -> Option<Self>`
|
||||
- [`src/validate.rs:510`](../src/validate.rs#L510) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<SceneCs>>`
|
||||
- [`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/validate.rs:496`](../src/validate.rs#L496) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<SceneCs>>`
|
||||
|
||||
### SR-003
|
||||
|
||||
@@ -217,15 +217,15 @@ _None._
|
||||
|
||||
- [`src/api/federation.rs:258`](../src/api/federation.rs#L258) — `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/content_id.rs:52`](../src/content_id.rs#L52) — `pub fn canonical_json(`
|
||||
- [`src/content_id.rs:129`](../src/content_id.rs#L129) — `pub fn content_id(`
|
||||
- [`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/error.rs:8`](../src/error.rs#L8) — `Unknown`
|
||||
- [`src/model.rs:197`](../src/model.rs#L197) — `Unknown`
|
||||
- [`src/model.rs:238`](../src/model.rs#L238) — `pub fn from_stored(s: &str) -> Option<Self>`
|
||||
- [`src/model.rs:264`](../src/model.rs#L264) — `Unknown`
|
||||
- [`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/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:378`](../src/validate.rs#L378) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
|
||||
- [`src/validate.rs:364`](../src/validate.rs#L364) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
|
||||
|
||||
### SR-004
|
||||
|
||||
@@ -239,20 +239,20 @@ _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:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>`
|
||||
- [`src/model.rs:156`](../src/model.rs#L156) — `Unknown`
|
||||
- [`src/model.rs:264`](../src/model.rs#L264) — `Unknown`
|
||||
- [`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/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:378`](../src/validate.rs#L378) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
|
||||
- [`src/validate.rs:364`](../src/validate.rs#L364) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
|
||||
- [`src/worker.rs:150`](../src/worker.rs#L150) — `async fn run_cast_check(&self, payload: &str) -> Result<(), JobError>`
|
||||
|
||||
### SR-005
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`src/db/repo.rs:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>`
|
||||
- [`src/db/repo.rs:331`](../src/db/repo.rs#L331) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>`
|
||||
- [`src/ingest.rs:50`](../src/ingest.rs#L50) — `pub fn persist(`
|
||||
|
||||
### UR-001
|
||||
@@ -261,7 +261,7 @@ _None._
|
||||
|
||||
- [`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:52`](../src/matching.rs#L52) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option<CutMatch>`
|
||||
- [`src/matching.rs:54`](../src/matching.rs#L54) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option<CutMatch>`
|
||||
|
||||
### UR-002
|
||||
|
||||
@@ -276,8 +276,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:156`](../src/model.rs#L156) — `Unknown`
|
||||
- [`src/model.rs:264`](../src/model.rs#L264) — `Unknown`
|
||||
- [`src/model.rs:150`](../src/model.rs#L150) — `Unknown`
|
||||
- [`src/model.rs:258`](../src/model.rs#L258) — `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>`
|
||||
|
||||
@@ -303,9 +303,9 @@ _None._
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`src/api/fetch.rs:128`](../src/api/fetch.rs#L128) — `pub async fn get_series(`
|
||||
- [`src/api/fetch.rs:127`](../src/api/fetch.rs#L127) — `pub async fn get_series(`
|
||||
- [`src/api/upload.rs:101`](../src/api/upload.rs#L101) — `pub async fn post_bundle(`
|
||||
- [`src/validate.rs:586`](../src/validate.rs#L586) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>`
|
||||
- [`src/validate.rs:572`](../src/validate.rs#L572) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>`
|
||||
|
||||
### UR-007
|
||||
|
||||
@@ -328,54 +328,54 @@ _None._
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`src/validate.rs:378`](../src/validate.rs#L378) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
|
||||
- [`src/validate.rs:364`](../src/validate.rs#L364) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
|
||||
|
||||
### UR-010
|
||||
|
||||
**Locations:** 4
|
||||
|
||||
- [`src/api/fetch.rs:270`](../src/api/fetch.rs#L270) — `pub fn reconstruct(`
|
||||
- [`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:412`](../src/db/repo.rs#L412) — `pub fn actors_for_manifest(`
|
||||
- [`src/model.rs:179`](../src/model.rs#L179) — `Unknown`
|
||||
- [`src/db/repo.rs:405`](../src/db/repo.rs#L405) — `pub fn actors_for_manifest(`
|
||||
- [`src/model.rs:173`](../src/model.rs#L173) — `Unknown`
|
||||
|
||||
### UR-011
|
||||
|
||||
**Locations:** 4
|
||||
|
||||
- [`src/model.rs:156`](../src/model.rs#L156) — `Unknown`
|
||||
- [`src/model.rs:264`](../src/model.rs#L264) — `Unknown`
|
||||
- [`src/model.rs:150`](../src/model.rs#L150) — `Unknown`
|
||||
- [`src/model.rs:258`](../src/model.rs#L258) — `Unknown`
|
||||
- [`src/validate.rs:136`](../src/validate.rs#L136) — `fn is_allowed_text_char(c: char) -> bool`
|
||||
- [`src/validate.rs:378`](../src/validate.rs#L378) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
|
||||
- [`src/validate.rs:364`](../src/validate.rs#L364) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
|
||||
|
||||
### UR-012
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`src/db/repo.rs:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>`
|
||||
- [`src/db/repo.rs:331`](../src/db/repo.rs#L331) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>`
|
||||
- [`src/ingest.rs:50`](../src/ingest.rs#L50) — `pub fn persist(`
|
||||
|
||||
### UR-013
|
||||
|
||||
**Locations:** 4
|
||||
|
||||
- [`src/api/fetch.rs:270`](../src/api/fetch.rs#L270) — `pub fn reconstruct(`
|
||||
- [`src/model.rs:179`](../src/model.rs#L179) — `Unknown`
|
||||
- [`src/model.rs:238`](../src/model.rs#L238) — `pub fn from_stored(s: &str) -> Option<Self>`
|
||||
- [`src/validate.rs:510`](../src/validate.rs#L510) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<SceneCs>>`
|
||||
- [`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/validate.rs:496`](../src/validate.rs#L496) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<SceneCs>>`
|
||||
|
||||
### UR-014
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`src/api/federation.rs:258`](../src/api/federation.rs#L258) — `pub async fn get_capabilities(State(state): State<AppState>) -> ApiResult<Response>`
|
||||
- [`src/model.rs:264`](../src/model.rs#L264) — `Unknown`
|
||||
- [`src/model.rs:258`](../src/model.rs#L258) — `Unknown`
|
||||
- [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest>`
|
||||
|
||||
### UR-017
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`src/model.rs:197`](../src/model.rs#L197) — `Unknown`
|
||||
- [`src/model.rs:238`](../src/model.rs#L238) — `pub fn from_stored(s: &str) -> Option<Self>`
|
||||
- [`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>`
|
||||
|
||||
|
||||
+1
-6
@@ -128,12 +128,7 @@ async fn lookup_one(state: &AppState, params: &LookupParams) -> ApiResult<Exists
|
||||
|
||||
let cuts: Vec<(String, StoredCut)> = candidates
|
||||
.iter()
|
||||
.map(|m| {
|
||||
(
|
||||
m.id.clone(),
|
||||
StoredCut { runtime_sec: m.runtime_sec, video_hash: m.video_hash.clone() },
|
||||
)
|
||||
})
|
||||
.map(|m| (m.id.clone(), StoredCut { runtime_sec: m.runtime_sec }))
|
||||
.collect();
|
||||
|
||||
let Some((id, m)) = matching::best_match(&client_cut, &cuts) else {
|
||||
|
||||
+1
-3
@@ -99,8 +99,7 @@ async fn fetch_best(
|
||||
let cuts: Vec<(ManifestRow, StoredCut)> = candidates
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let cut =
|
||||
StoredCut { runtime_sec: m.runtime_sec, video_hash: m.video_hash.clone() };
|
||||
let cut = StoredCut { runtime_sec: m.runtime_sec };
|
||||
(m, cut)
|
||||
})
|
||||
.collect();
|
||||
@@ -347,7 +346,6 @@ pub fn reconstruct(
|
||||
cut: Cut {
|
||||
runtime_sec: row.runtime_sec,
|
||||
container_duration_sec: None,
|
||||
video_hash: row.video_hash.clone(),
|
||||
audio_signature: None,
|
||||
},
|
||||
extraction: has_extraction.then_some(extraction),
|
||||
|
||||
@@ -21,7 +21,6 @@ pub struct LookupParams {
|
||||
pub season: Option<i64>,
|
||||
pub episode: Option<i64>,
|
||||
pub runtime_sec: Option<f64>,
|
||||
pub video_hash: Option<String>,
|
||||
}
|
||||
|
||||
impl LookupParams {
|
||||
@@ -30,7 +29,6 @@ impl LookupParams {
|
||||
// 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),
|
||||
video_hash: self.video_hash.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-11
@@ -37,11 +37,16 @@ pub struct CanonicalIdentity {
|
||||
/// decoding audio, so two servers running different FFmpeg or resampler versions
|
||||
/// could compute marginally different signatures for identical content, and
|
||||
/// including it would silently break federation deduplication.
|
||||
///
|
||||
/// `video_hash` was withdrawn from the canonical form along with the field
|
||||
/// itself (§3). It was removed rather than retained as a vestigial `null`, on
|
||||
/// the same reasoning §2 applied to `anneal_sec` — a key naming a signal the
|
||||
/// format no longer has is actively misleading. Every `content_id` therefore
|
||||
/// changed at that point, including for manifests that never carried a hash.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CanonicalCut {
|
||||
/// Quantised to centiseconds for the same reason scene times are.
|
||||
pub runtime_cs: i64,
|
||||
pub video_hash: Option<String>,
|
||||
}
|
||||
|
||||
/// Builds the canonical JSON form: keys sorted, no whitespace, actors sorted by
|
||||
@@ -84,8 +89,6 @@ pub fn canonical_json(
|
||||
s.push_str("],\"cut\":{");
|
||||
s.push_str("\"runtime_cs\":");
|
||||
s.push_str(&cut.runtime_cs.to_string());
|
||||
s.push_str(",\"video_hash\":");
|
||||
push_opt_str(&mut s, cut.video_hash.as_deref());
|
||||
s.push_str("},\"identity\":{");
|
||||
s.push_str("\"episode\":");
|
||||
push_opt_num(&mut s, cut_opt(identity.episode));
|
||||
@@ -147,11 +150,12 @@ pub fn content_id(
|
||||
/// Cross-implementation fixture (§8): the JRay plugin and any reimplementation
|
||||
/// must reproduce these exactly, or federation deduplication silently breaks.
|
||||
pub const GOLDEN_VECTORS: &[(&str, &str)] = &[(
|
||||
// Movie, one actor, two windows, with a video hash.
|
||||
r#"{"actors":[{"scenes":[[19160,20920],[43820,46560]],"tmdb_person_id":884}],"cut":{"runtime_cs":642050,"video_hash":"opensubtitles:8e245d9679d31e12"},"identity":{"episode":null,"imdb_id":"tt4686844","season":null,"tmdb_id":"504172","type":"movie"}}"#,
|
||||
// Movie, one actor, two windows. The `cut` object is now a single key: the
|
||||
// withdrawal of `video_hash` (§3) reduced it to the runtime alone.
|
||||
r#"{"actors":[{"scenes":[[19160,20920],[43820,46560]],"tmdb_person_id":884}],"cut":{"runtime_cs":642050},"identity":{"episode":null,"imdb_id":"tt4686844","season":null,"tmdb_id":"504172","type":"movie"}}"#,
|
||||
// Verified against an independent Python implementation:
|
||||
// sha256(canonical.encode()).hexdigest()
|
||||
"sha256:367f8b05c54a992a3a30fa016edaaac0b9b36b148fc76574f5b1ef326b56760f",
|
||||
"sha256:f3f6c7e19a1f47c6e7b648bfc966011b4530433e6adf57469326bef88742600a",
|
||||
)];
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -169,10 +173,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn movie_cut() -> CanonicalCut {
|
||||
CanonicalCut {
|
||||
runtime_cs: 642050,
|
||||
video_hash: Some("opensubtitles:8e245d9679d31e12".into()),
|
||||
}
|
||||
CanonicalCut { runtime_cs: 642050 }
|
||||
}
|
||||
|
||||
fn actors() -> Vec<CanonicalActor> {
|
||||
@@ -203,7 +204,7 @@ mod tests {
|
||||
let id_keys: Vec<&String> = v["identity"].as_object().unwrap().keys().collect();
|
||||
assert_eq!(id_keys, vec!["episode", "imdb_id", "season", "tmdb_id", "type"]);
|
||||
let cut_keys: Vec<&String> = v["cut"].as_object().unwrap().keys().collect();
|
||||
assert_eq!(cut_keys, vec!["runtime_cs", "video_hash"]);
|
||||
assert_eq!(cut_keys, vec!["runtime_cs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+17
-31
@@ -20,7 +20,6 @@ pub struct ManifestRow {
|
||||
pub season: Option<i64>,
|
||||
pub episode: Option<i64>,
|
||||
pub runtime_sec: f64,
|
||||
pub video_hash: Option<String>,
|
||||
pub audio_signature: Option<Vec<u8>>,
|
||||
pub sample_fps: Option<f64>,
|
||||
pub extinction_sec: Option<f64>,
|
||||
@@ -31,7 +30,7 @@ pub struct ManifestRow {
|
||||
pub content_id: Option<String>,
|
||||
}
|
||||
|
||||
const MANIFEST_COLUMNS: &str = "id, title_id, season, episode, runtime_sec, video_hash, \
|
||||
const MANIFEST_COLUMNS: &str = "id, title_id, season, episode, runtime_sec, \
|
||||
audio_signature, sample_fps, extinction_sec, pipeline_version, gallery_scope, \
|
||||
status, cast_match_ratio, \
|
||||
content_id";
|
||||
@@ -43,15 +42,14 @@ fn map_manifest(row: &rusqlite::Row<'_>) -> rusqlite::Result<ManifestRow> {
|
||||
season: row.get(2)?,
|
||||
episode: row.get(3)?,
|
||||
runtime_sec: row.get(4)?,
|
||||
video_hash: row.get(5)?,
|
||||
audio_signature: row.get(6)?,
|
||||
sample_fps: row.get(7)?,
|
||||
extinction_sec: row.get(8)?,
|
||||
pipeline_version: row.get(9)?,
|
||||
gallery_scope: row.get(10)?,
|
||||
status: row.get(11)?,
|
||||
cast_match_ratio: row.get(12)?,
|
||||
content_id: row.get(13)?,
|
||||
audio_signature: row.get(5)?,
|
||||
sample_fps: row.get(6)?,
|
||||
extinction_sec: row.get(7)?,
|
||||
pipeline_version: row.get(8)?,
|
||||
gallery_scope: row.get(9)?,
|
||||
status: row.get(10)?,
|
||||
cast_match_ratio: row.get(11)?,
|
||||
content_id: row.get(12)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -283,24 +281,21 @@ pub fn duplicate_from_contributor(
|
||||
season: Option<i64>,
|
||||
episode: Option<i64>,
|
||||
runtime_sec: f64,
|
||||
video_hash: Option<&str>,
|
||||
contributor_id: &str,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"SELECT id FROM manifests
|
||||
WHERE title_id = ?1 AND contributor_id = ?6
|
||||
WHERE title_id = ?1 AND contributor_id = ?5
|
||||
AND status <> 'rejected'
|
||||
AND ( (?2 IS NULL AND season IS NULL) OR season = ?2 )
|
||||
AND ( (?3 IS NULL AND episode IS NULL) OR episode = ?3 )
|
||||
AND ABS(runtime_sec - ?4) < 0.001
|
||||
AND ( (?5 IS NULL AND video_hash IS NULL) OR video_hash = ?5 )
|
||||
LIMIT 1",
|
||||
)?;
|
||||
Ok(stmt
|
||||
.query_row(
|
||||
params![title_id, season, episode, runtime_sec, video_hash, contributor_id],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.query_row(params![title_id, season, episode, runtime_sec, contributor_id], |r| {
|
||||
r.get::<_, String>(0)
|
||||
})
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
@@ -319,7 +314,6 @@ pub struct NewManifest<'a> {
|
||||
pub season: Option<i64>,
|
||||
pub episode: Option<i64>,
|
||||
pub runtime_sec: f64,
|
||||
pub video_hash: Option<&'a str>,
|
||||
pub audio_signature: Option<&'a [u8]>,
|
||||
pub audio_sig_coarse: Option<&'a [u8]>,
|
||||
pub sample_fps: Option<f64>,
|
||||
@@ -338,18 +332,17 @@ pub struct NewManifest<'a> {
|
||||
pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()> {
|
||||
tx.execute(
|
||||
"INSERT INTO manifests
|
||||
(id, title_id, season, episode, runtime_sec, video_hash,
|
||||
(id, title_id, season, episode, runtime_sec,
|
||||
audio_signature, audio_sig_coarse, sample_fps, extinction_sec, pipeline_version,
|
||||
gallery_scope, contributor_id, status, content_id, origin, ingested_from,
|
||||
created_at)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18)",
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17)",
|
||||
params![
|
||||
m.id,
|
||||
m.title_id,
|
||||
m.season,
|
||||
m.episode,
|
||||
m.runtime_sec,
|
||||
m.video_hash,
|
||||
m.audio_signature,
|
||||
m.audio_sig_coarse,
|
||||
m.sample_fps,
|
||||
@@ -840,7 +833,6 @@ mod tests {
|
||||
season: None,
|
||||
episode: None,
|
||||
runtime_sec: 6420.5,
|
||||
video_hash: Some("opensubtitles:8e245d9679d31e12"),
|
||||
audio_signature: None,
|
||||
audio_sig_coarse: None,
|
||||
sample_fps: Some(5.0),
|
||||
@@ -903,7 +895,6 @@ mod tests {
|
||||
season: None,
|
||||
episode: None,
|
||||
runtime_sec: 100.0,
|
||||
video_hash: None,
|
||||
audio_signature: None,
|
||||
audio_sig_coarse: None,
|
||||
sample_fps: None,
|
||||
@@ -949,7 +940,6 @@ mod tests {
|
||||
season: None,
|
||||
episode: None,
|
||||
runtime_sec: 6420.5,
|
||||
video_hash: None,
|
||||
audio_signature: None,
|
||||
audio_sig_coarse: None,
|
||||
sample_fps: None,
|
||||
@@ -964,11 +954,10 @@ mod tests {
|
||||
created_at: NOW,
|
||||
},
|
||||
)?;
|
||||
let same = duplicate_from_contributor(tx, &title_id, None, None, 6420.5, None, &a)?;
|
||||
let same = duplicate_from_contributor(tx, &title_id, None, None, 6420.5, &a)?;
|
||||
// §7: multiple manifests for the same cut from *different*
|
||||
// contributors are allowed, and ranked.
|
||||
let other =
|
||||
duplicate_from_contributor(tx, &title_id, None, None, 6420.5, None, &b)?;
|
||||
let other = duplicate_from_contributor(tx, &title_id, None, None, 6420.5, &b)?;
|
||||
Ok((same.is_some(), other.is_some()))
|
||||
})
|
||||
.await
|
||||
@@ -1002,7 +991,6 @@ mod tests {
|
||||
season: None,
|
||||
episode: None,
|
||||
runtime_sec: 100.0,
|
||||
video_hash: None,
|
||||
audio_signature: None,
|
||||
audio_sig_coarse: None,
|
||||
sample_fps: None,
|
||||
@@ -1108,7 +1096,6 @@ mod tests {
|
||||
season: None,
|
||||
episode: None,
|
||||
runtime_sec: 100.0,
|
||||
video_hash: None,
|
||||
audio_signature: None,
|
||||
audio_sig_coarse: None,
|
||||
sample_fps: None,
|
||||
@@ -1402,7 +1389,6 @@ mod federation_tests {
|
||||
season: None,
|
||||
episode: None,
|
||||
runtime_sec: 100.0,
|
||||
video_hash: None,
|
||||
audio_signature: None,
|
||||
audio_sig_coarse: None,
|
||||
sample_fps: None,
|
||||
|
||||
+2
-2
@@ -41,7 +41,8 @@ CREATE TABLE IF NOT EXISTS manifests (
|
||||
season INTEGER,
|
||||
episode INTEGER,
|
||||
runtime_sec REAL NOT NULL,
|
||||
video_hash TEXT,
|
||||
-- No video_hash: withdrawn (§3). It fingerprinted an individual file rather
|
||||
-- than a cut, which is the one thing this schema deliberately cannot record.
|
||||
audio_signature BLOB, -- §3, ~1290 bytes
|
||||
audio_sig_coarse BLOB, -- candidate-generation index key
|
||||
sample_fps REAL,
|
||||
@@ -160,7 +161,6 @@ CREATE INDEX IF NOT EXISTS idx_federation_log_content ON federation_log(content_
|
||||
CREATE INDEX IF NOT EXISTS idx_titles_tmdb ON titles(tmdb_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_titles_imdb ON titles(imdb_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manifests_title_runtime ON manifests(title_id, runtime_sec);
|
||||
CREATE INDEX IF NOT EXISTS idx_manifests_video_hash ON manifests(video_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_manifests_episode ON manifests(title_id, season, episode);
|
||||
CREATE INDEX IF NOT EXISTS idx_scenes_manifest_person ON scenes(manifest_id, tmdb_person_id);
|
||||
|
||||
|
||||
+4
-14
@@ -86,15 +86,9 @@ pub fn persist(
|
||||
}
|
||||
|
||||
if let Some(c) = contributor_id {
|
||||
if let Some(existing) = repo::duplicate_from_contributor(
|
||||
tx,
|
||||
&title_id,
|
||||
season,
|
||||
episode,
|
||||
m.cut.runtime_sec,
|
||||
m.cut.video_hash.as_deref(),
|
||||
c,
|
||||
)? {
|
||||
if let Some(existing) =
|
||||
repo::duplicate_from_contributor(tx, &title_id, season, episode, m.cut.runtime_sec, c)?
|
||||
{
|
||||
return Ok(IngestOutcome::DuplicateFromContributor { manifest_id: existing });
|
||||
}
|
||||
}
|
||||
@@ -110,7 +104,6 @@ pub fn persist(
|
||||
season,
|
||||
episode,
|
||||
runtime_sec: m.cut.runtime_sec,
|
||||
video_hash: m.cut.video_hash.as_deref(),
|
||||
// Stored as an attribute, not part of identity (§9a).
|
||||
audio_signature: None,
|
||||
audio_sig_coarse: None,
|
||||
@@ -156,10 +149,7 @@ pub fn compute_content_id(valid: &ValidManifest) -> String {
|
||||
season: m.identity.season,
|
||||
episode: m.identity.episode,
|
||||
};
|
||||
let cut = CanonicalCut {
|
||||
runtime_cs: crate::validate::to_centiseconds(m.cut.runtime_sec),
|
||||
video_hash: m.cut.video_hash.clone(),
|
||||
};
|
||||
let cut = CanonicalCut { runtime_cs: crate::validate::to_centiseconds(m.cut.runtime_sec) };
|
||||
let actors: Vec<CanonicalActor> = valid
|
||||
.actor_scenes_cs
|
||||
.iter()
|
||||
|
||||
+42
-96
@@ -4,8 +4,12 @@
|
||||
//! server reports *which* tier matched — a `loose` match is meant to surface as
|
||||
//! a caveat in the JRay UI rather than being applied silently.
|
||||
//!
|
||||
//! `video_hash` identifies a *file*, so it only ever matches an identical
|
||||
//! release and can never produce a false positive; that is why it is tier one.
|
||||
//! Every tier here is a claim about a *cut*, never about a copy. The withdrawn
|
||||
//! `exact` tier keyed on a file hash, which identified the individual encode a
|
||||
//! contributor held; nothing in this module can now distinguish two files of the
|
||||
//! same cut, which is the intended property and not a lost capability — timings
|
||||
//! transfer between cuts, so a file-level signal never bought accuracy the
|
||||
//! cut-level ones lack.
|
||||
|
||||
use crate::model::MatchTier;
|
||||
|
||||
@@ -14,18 +18,17 @@ pub const RUNTIME_TOLERANCE_SEC: f64 = 2.0;
|
||||
/// §3: runtimes within ±30s.
|
||||
pub const LOOSE_TOLERANCE_SEC: f64 = 30.0;
|
||||
|
||||
/// What the client tells us about its own copy.
|
||||
/// What the client tells us about its own cut.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ClientCut {
|
||||
pub runtime_sec: Option<f64>,
|
||||
pub video_hash: Option<String>,
|
||||
}
|
||||
|
||||
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.video_hash.is_none()
|
||||
self.runtime_sec.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +36,6 @@ impl ClientCut {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredCut {
|
||||
pub runtime_sec: f64,
|
||||
pub video_hash: Option<String>,
|
||||
}
|
||||
|
||||
/// The outcome of comparing a client's cut against a stored one.
|
||||
@@ -51,16 +53,9 @@ pub struct CutMatch {
|
||||
/// fires, or `None` for "beyond that: no match; do not serve" (§3).
|
||||
/// TRACES: UR-001 | SR-001
|
||||
pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option<CutMatch> {
|
||||
// Tier 1 — same file. Checked first and unconditionally: an equal hash is
|
||||
// decisive regardless of what the runtimes say.
|
||||
if let (Some(c), Some(s)) = (&client.video_hash, &stored.video_hash) {
|
||||
if c.eq_ignore_ascii_case(s) {
|
||||
return Some(CutMatch { tier: MatchTier::Exact, offset_sec: 0.0 });
|
||||
}
|
||||
}
|
||||
|
||||
// `audio` tier would slot in here, above `runtime`, once signature coverage
|
||||
// is useful (§3 recommended sequencing).
|
||||
// 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.
|
||||
|
||||
if let Some(c_rt) = client.runtime_sec {
|
||||
let delta = (c_rt - stored.runtime_sec).abs();
|
||||
@@ -75,12 +70,6 @@ pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option<CutMatch> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// A hash that did not match, with no runtime to fall back on, tells us
|
||||
// nothing about alignment either way.
|
||||
if client.video_hash.is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(CutMatch { tier: MatchTier::Unknown, offset_sec: 0.0 })
|
||||
}
|
||||
|
||||
@@ -102,67 +91,35 @@ pub fn best_match<K: Clone>(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn stored(runtime: f64, hash: Option<&str>) -> StoredCut {
|
||||
StoredCut { runtime_sec: runtime, video_hash: hash.map(str::to_string) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_video_hash_is_exact() {
|
||||
let c = ClientCut {
|
||||
runtime_sec: Some(6420.5),
|
||||
video_hash: Some("opensubtitles:8e245d9679d31e12".into()),
|
||||
};
|
||||
let m = match_cut(&c, &stored(6420.5, Some("opensubtitles:8e245d9679d31e12"))).unwrap();
|
||||
assert_eq!(m.tier, MatchTier::Exact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_hash_wins_even_when_runtimes_disagree() {
|
||||
// The hash identifies the file; a differing stored runtime means our
|
||||
// own metadata is off, not that the file is different.
|
||||
let c = ClientCut {
|
||||
runtime_sec: Some(6000.0),
|
||||
video_hash: Some("opensubtitles:8e245d9679d31e12".into()),
|
||||
};
|
||||
let m = match_cut(&c, &stored(6420.5, Some("opensubtitles:8e245d9679d31e12"))).unwrap();
|
||||
assert_eq!(m.tier, MatchTier::Exact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_comparison_is_case_insensitive() {
|
||||
let c = ClientCut {
|
||||
runtime_sec: None,
|
||||
video_hash: Some("opensubtitles:8E245D9679D31E12".into()),
|
||||
};
|
||||
let m = match_cut(&c, &stored(6420.5, Some("opensubtitles:8e245d9679d31e12"))).unwrap();
|
||||
assert_eq!(m.tier, MatchTier::Exact);
|
||||
fn stored(runtime: f64) -> StoredCut {
|
||||
StoredCut { runtime_sec: runtime }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_within_two_seconds_is_runtime_tier() {
|
||||
let c = ClientCut { runtime_sec: Some(6422.0), video_hash: None };
|
||||
assert_eq!(match_cut(&c, &stored(6420.5, None)).unwrap().tier, MatchTier::Runtime);
|
||||
let c = ClientCut { runtime_sec: Some(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), video_hash: None };
|
||||
assert_eq!(match_cut(&c, &stored(6420.5, None)).unwrap().tier, MatchTier::Loose);
|
||||
let c = ClientCut { runtime_sec: Some(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), video_hash: None };
|
||||
assert!(match_cut(&c, &stored(6420.5, None)).is_none());
|
||||
let c = ClientCut { runtime_sec: Some(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), video_hash: None };
|
||||
assert_eq!(match_cut(&c, &stored(6420.5, None)).unwrap().tier, MatchTier::Runtime);
|
||||
let c = ClientCut { runtime_sec: Some(6450.5), video_hash: None };
|
||||
assert_eq!(match_cut(&c, &stored(6420.5, None)).unwrap().tier, MatchTier::Loose);
|
||||
let c = ClientCut { runtime_sec: Some(6422.5) };
|
||||
assert_eq!(match_cut(&c, &stored(6420.5)).unwrap().tier, MatchTier::Runtime);
|
||||
let c = ClientCut { runtime_sec: Some(6450.5) };
|
||||
assert_eq!(match_cut(&c, &stored(6420.5)).unwrap().tier, MatchTier::Loose);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -171,48 +128,37 @@ mod tests {
|
||||
// this title at all", with alignment still to be determined.
|
||||
let c = ClientCut::default();
|
||||
assert!(c.is_empty());
|
||||
assert_eq!(match_cut(&c, &stored(6420.5, None)).unwrap().tier, MatchTier::Unknown);
|
||||
assert_eq!(match_cut(&c, &stored(6420.5)).unwrap().tier, MatchTier::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_matching_hash_alone_is_not_a_match() {
|
||||
let c = ClientCut {
|
||||
runtime_sec: None,
|
||||
video_hash: Some("opensubtitles:ffffffffffffffff".into()),
|
||||
};
|
||||
assert!(match_cut(&c, &stored(6420.5, Some("opensubtitles:8e245d9679d31e12"))).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_matching_hash_falls_back_to_runtime() {
|
||||
let c = ClientCut {
|
||||
runtime_sec: Some(6421.0),
|
||||
video_hash: Some("opensubtitles:ffffffffffffffff".into()),
|
||||
};
|
||||
let m = match_cut(&c, &stored(6420.5, Some("opensubtitles:8e245d9679d31e12"))).unwrap();
|
||||
assert_eq!(m.tier, MatchTier::Runtime);
|
||||
fn identical_cuts_are_indistinguishable_from_identical_files() {
|
||||
// §3: the property the `video_hash` withdrawal buys. Two clients holding
|
||||
// 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) };
|
||||
assert_eq!(
|
||||
match_cut(&same_file, &stored(6420.5)).unwrap().tier,
|
||||
match_cut(&re_encode, &stored(6420.5)).unwrap().tier,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_match_prefers_the_highest_tier() {
|
||||
let c = ClientCut {
|
||||
runtime_sec: Some(6420.5),
|
||||
video_hash: Some("opensubtitles:8e245d9679d31e12".into()),
|
||||
};
|
||||
let candidates = vec![
|
||||
("loose", stored(6445.0, None)),
|
||||
("exact", stored(9999.0, Some("opensubtitles:8e245d9679d31e12"))),
|
||||
("runtime", stored(6420.0, None)),
|
||||
];
|
||||
let c = ClientCut { runtime_sec: Some(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();
|
||||
assert_eq!(winner, "exact");
|
||||
assert_eq!(m.tier, MatchTier::Exact);
|
||||
assert_eq!(winner, "runtime");
|
||||
assert_eq!(m.tier, MatchTier::Runtime);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_match_returns_none_when_nothing_clears_loose() {
|
||||
let c = ClientCut { runtime_sec: Some(100.0), video_hash: None };
|
||||
let candidates = vec![("a", stored(6420.5, None)), ("b", stored(3000.0, None))];
|
||||
let c = ClientCut { runtime_sec: Some(100.0) };
|
||||
let candidates = vec![("a", stored(6420.5)), ("b", stored(3000.0))];
|
||||
assert!(best_match(&c, &candidates).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+16
-11
@@ -24,10 +24,8 @@ pub enum MatchTier {
|
||||
Loose,
|
||||
/// Runtimes within ±2s.
|
||||
Runtime,
|
||||
/// Audio score ≥ 0.85; ranks above `runtime` because it is content-derived.
|
||||
/// Audio score ≥ 0.85; the top tier, and content-derived.
|
||||
Audio,
|
||||
/// `video_hash` equal — same file.
|
||||
Exact,
|
||||
}
|
||||
|
||||
impl MatchTier {
|
||||
@@ -37,7 +35,6 @@ impl MatchTier {
|
||||
MatchTier::Loose => "loose",
|
||||
MatchTier::Runtime => "runtime",
|
||||
MatchTier::Audio => "audio",
|
||||
MatchTier::Exact => "exact",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,9 +106,6 @@ pub struct Cut {
|
||||
pub runtime_sec: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub container_duration_sec: Option<f64>,
|
||||
/// Optional but strongly preferred. OpenSubtitles hash (§3).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub video_hash: Option<String>,
|
||||
/// Optional; version-prefixed spectral-peak signature (§3, UR-9).
|
||||
///
|
||||
/// Accepted and stored by this build; `audio`-tier matching is enabled once
|
||||
@@ -367,8 +361,7 @@ mod tests {
|
||||
"jmanifest_version": 2,
|
||||
"identity": { "type": "movie", "tmdb_id": "504172", "imdb_id": "tt4686844",
|
||||
"title": "The Death of Stalin", "year": 2017 },
|
||||
"cut": { "runtime_sec": 6420.5, "container_duration_sec": 6420.5,
|
||||
"video_hash": "opensubtitles:8e245d9679d31e12" },
|
||||
"cut": { "runtime_sec": 6420.5, "container_duration_sec": 6420.5 },
|
||||
"extraction": { "sample_fps": 5, "extinction_sec": 12,
|
||||
"pipeline_version": "scene-actor-extraction 0.4.1",
|
||||
"gallery_size": 1820, "gallery_scope": "global" },
|
||||
@@ -385,9 +378,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tiers_order_audio_above_runtime() {
|
||||
// §3: `audio` ranks above `runtime` because it is content-derived.
|
||||
// §3: `audio` is the top tier, and ranks above `runtime` because it is
|
||||
// content-derived where equal runtimes are only circumstantial.
|
||||
assert!(MatchTier::Audio > MatchTier::Runtime);
|
||||
assert!(MatchTier::Exact > MatchTier::Audio);
|
||||
assert!(MatchTier::Runtime > MatchTier::Loose);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn withdrawn_video_hash_is_rejected_as_an_unknown_field() {
|
||||
// §3: withdrawn, and `deny_unknown_fields` is what enforces it. A
|
||||
// contributor still sending it gets a `400` naming the field rather
|
||||
// than having it silently dropped.
|
||||
let json = r#"{"jmanifest_version":2,"identity":{"type":"movie","tmdb_id":"1"},
|
||||
"cut":{"runtime_sec":100.0,"video_hash":"opensubtitles:8e245d9679d31e12"},
|
||||
"actors":[]}"#;
|
||||
let err = serde_json::from_str::<Jmanifest>(json).unwrap_err().to_string();
|
||||
assert!(err.contains("video_hash"), "error should name the field: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,26 +347,12 @@ fn validate_cut(m: &Jmanifest) -> VResult<()> {
|
||||
return Err(err("cut.container_duration_sec", "must be a finite duration"));
|
||||
}
|
||||
}
|
||||
if let Some(h) = &m.cut.video_hash {
|
||||
validate_video_hash(h)?;
|
||||
}
|
||||
if let Some(sig) = &m.cut.audio_signature {
|
||||
validate_audio_signature(sig, rt)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// §3: the OpenSubtitles hash, in the fixed `opensubtitles:<16 hex>` form.
|
||||
fn validate_video_hash(h: &str) -> VResult<()> {
|
||||
let Some(hex) = h.strip_prefix("opensubtitles:") else {
|
||||
return Err(err("cut.video_hash", "must be prefixed 'opensubtitles:'"));
|
||||
};
|
||||
if hex.len() != 16 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return Err(err("cut.video_hash", "expected 16 hex digits after the prefix"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// §3 "Validation and abuse": fixed length, base64, and each byte structurally
|
||||
/// constrained (5-bit bin index + 2-bit energy class).
|
||||
///
|
||||
@@ -973,19 +959,6 @@ mod tests {
|
||||
assert_eq!(e.field, "actors");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_hash_format_is_enforced() {
|
||||
let mut m = base_manifest();
|
||||
m.cut.video_hash = Some("opensubtitles:8e245d9679d31e12".into());
|
||||
assert!(validate_manifest(m).is_ok());
|
||||
|
||||
for bad in ["8e245d9679d31e12", "opensubtitles:xyz", "opensubtitles:8e245d9679d31e1"] {
|
||||
let mut m = base_manifest();
|
||||
m.cut.video_hash = Some(bad.into());
|
||||
assert!(validate_manifest(m).is_err(), "{bad} should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centisecond_quantisation_is_stable_for_accumulated_float_error() {
|
||||
// §9a: real corpus values look like 8045.066666660665.
|
||||
|
||||
+2
-2
@@ -154,7 +154,7 @@ fn movie_manifest(tmdb_id: &str, runtime: f64) -> Value {
|
||||
"jmanifest_version": 2,
|
||||
"identity": { "type": "movie", "tmdb_id": tmdb_id, "title": "The Death of Stalin",
|
||||
"year": 2017 },
|
||||
"cut": { "runtime_sec": runtime, "video_hash": "opensubtitles:8e245d9679d31e12" },
|
||||
"cut": { "runtime_sec": runtime },
|
||||
"extraction": { "sample_fps": 5, "extinction_sec": 12,
|
||||
"pipeline_version": "scene-actor-extraction 0.4.1",
|
||||
"gallery_scope": "global" },
|
||||
@@ -405,7 +405,7 @@ async fn missing_runtime_is_rejected() {
|
||||
let s = TestServer::new("no-runtime");
|
||||
let token = s.token().await;
|
||||
let mut m = movie_manifest("504172", 6420.5);
|
||||
m["cut"] = json!({ "video_hash": "opensubtitles:8e245d9679d31e12" });
|
||||
m["cut"] = json!({ "container_duration_sec": 6420.5 });
|
||||
let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
@@ -138,7 +138,6 @@ impl TestServer {
|
||||
season: None,
|
||||
episode: None,
|
||||
runtime_sec: 6420.5,
|
||||
video_hash: None,
|
||||
audio_signature: None,
|
||||
audio_sig_coarse: None,
|
||||
sample_fps: Some(5.0),
|
||||
@@ -256,7 +255,6 @@ async fn a_pending_manifest_is_not_replicated() {
|
||||
season: None,
|
||||
episode: None,
|
||||
runtime_sec: 100.0,
|
||||
video_hash: None,
|
||||
audio_signature: None,
|
||||
audio_sig_coarse: None,
|
||||
sample_fps: None,
|
||||
|
||||
+1
-1
@@ -210,7 +210,7 @@ async fn sql_payloads_in_query_parameters_are_inert() {
|
||||
format!("/api/v1/manifests/movie?imdb_id={enc}"),
|
||||
format!("/api/v1/manifests/episode?series_tmdb_id={enc}&season=1&episode=1"),
|
||||
format!("/api/v1/manifests/series/{enc}"),
|
||||
format!("/api/v1/manifests/exists?tmdb_id=1&video_hash={enc}"),
|
||||
format!("/api/v1/manifests/exists?tmdb_id=1&runtime_sec={enc}"),
|
||||
] {
|
||||
let (status, body) = s.get(&uri).await;
|
||||
// The payload is bound as data, so it matches nothing. What must never
|
||||
|
||||
Reference in New Issue
Block a user