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
+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",