Withdraw the file-hash tier; document the legal posture
CI / static musl binary (push) Has been skipped
CI / fmt, clippy, test (push) Failing after 2m0s
CI / advisories and licences (push) Successful in 27s

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:
2026-07-31 09:52:03 +02:00
co-authored by Claude Opus 5
parent 545c7d92a2
commit 0ff1018bcc
17 changed files with 779 additions and 289 deletions
+3 -1
View File
@@ -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
+73 -29
View File
@@ -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",
+554
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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),
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}");
}
}
-27
View File
@@ -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
View File
@@ -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);
}
-2
View File
@@ -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
View File
@@ -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