Withdraw the file-hash tier; document the legal posture
Removes `cut.video_hash` and the `exact` match tier on legal grounds. The OpenSubtitles hash was the strongest technical signal available — it identifies a specific file, so it cannot produce a false positive — and that is exactly the problem. Every tier must be a claim about a *cut*, never about a copy. A TMDB id discloses "some copy of this film", which is what a library catalogue discloses. A file hash discloses "this exact release": it made a read endpoint into a release-level oracle, and made an instance's database a mapping from file fingerprints to the instances holding them. That is a far more specific disclosure than PR-005 permits, and a dataset no volunteer operator should be asked to hold. The audio signature is the replacement: derived from content, it identifies the cut rather than the copy, so two encodes of the same edit agree. The field is deleted rather than kept as a vestigial null, on the same reasoning §2 applied to `anneal_sec` — a key naming a signal the format no longer has is actively misleading — so an upload carrying one is now an unknown-field 400, with a test asserting it. **Every content_id changes**, including for manifests that never carried a hash, because the canonical `cut` object lost a key. The golden vector is regenerated and re-verified against an independent Python implementation; the plugin and extraction repos must adopt the new value or federation deduplication silently breaks. Free now, pre-release; not free later. Adds docs/legal-posture.md, the operator-facing half of what §5a asks for: what an instance holds exhaustively, what it structurally cannot do, and how that sits against the intermediary-liability regimes that plausibly apply. 208 tests. Coverage 25/32. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: UR-011 | SR-004, PR-005
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user