From 0ff1018bcc3fe00909c48897972fcb29ae17e29b Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 31 Jul 2026 09:52:03 +0200 Subject: [PATCH] Withdraw the file-hash tier; document the legal posture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 TRACES: UR-011 | SR-004, PR-005 --- README.md | 4 +- SPEC.md | 102 +++++--- docs/legal-posture.md | 554 ++++++++++++++++++++++++++++++++++++++++++ docs/traceability.md | 102 ++++---- src/api/exists.rs | 7 +- src/api/fetch.rs | 4 +- src/api/mod.rs | 2 - src/content_id.rs | 23 +- src/db/repo.rs | 48 ++-- src/db/schema.sql | 4 +- src/ingest.rs | 18 +- src/matching.rs | 138 ++++------- src/model.rs | 27 +- src/validate.rs | 27 -- tests/api.rs | 4 +- tests/federation.rs | 2 - tests/injection.rs | 2 +- 17 files changed, 779 insertions(+), 289 deletions(-) create mode 100644 docs/legal-posture.md diff --git a/README.md b/README.md index 1c98f9e..80537a0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SPEC.md b/SPEC.md index 258b4b6..2820567 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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", diff --git a/docs/legal-posture.md b/docs/legal-posture.md new file mode 100644 index 0000000..b6a4ff3 --- /dev/null +++ b/docs/legal-posture.md @@ -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) diff --git a/docs/traceability.md b/docs/traceability.md index 0c9d06a..75f11e7 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -3,7 +3,7 @@ -**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, now: &str, limit: usize) -> anyhow::Result 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, now: &str, limit: usize) -> anyhow::Result 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` -- [`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` +- [`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` -- [`src/validate.rs:510`](../src/validate.rs#L510) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult>` +- [`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` +- [`src/validate.rs:496`](../src/validate.rs#L496) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult>` ### 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) -> ApiResult` - [`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` -- [`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` +- [`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` -- [`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, 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` -- [`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` +- [`src/matching.rs:54`](../src/matching.rs#L54) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option` ### 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` - [`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` -- [`src/validate.rs:510`](../src/validate.rs#L510) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult>` +- [`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` +- [`src/validate.rs:496`](../src/validate.rs#L496) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult>` ### UR-014 **Locations:** 3 - [`src/api/federation.rs:258`](../src/api/federation.rs#L258) — `pub async fn get_capabilities(State(state): State) -> ApiResult` -- [`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` ### 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` +- [`src/model.rs:191`](../src/model.rs#L191) — `Unknown` +- [`src/model.rs:232`](../src/model.rs#L232) — `pub fn from_stored(s: &str) -> Option` diff --git a/src/api/exists.rs b/src/api/exists.rs index 12171f2..8c79308 100644 --- a/src/api/exists.rs +++ b/src/api/exists.rs @@ -128,12 +128,7 @@ async fn lookup_one(state: &AppState, params: &LookupParams) -> ApiResult = 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 { diff --git a/src/api/fetch.rs b/src/api/fetch.rs index d6ac32b..fde6447 100644 --- a/src/api/fetch.rs +++ b/src/api/fetch.rs @@ -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), diff --git a/src/api/mod.rs b/src/api/mod.rs index 02ee40b..0d54383 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -21,7 +21,6 @@ pub struct LookupParams { pub season: Option, pub episode: Option, pub runtime_sec: Option, - pub video_hash: Option, } 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(), } } } diff --git a/src/content_id.rs b/src/content_id.rs index 8695da5..7f486bb 100644 --- a/src/content_id.rs +++ b/src/content_id.rs @@ -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, } /// 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 { @@ -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] diff --git a/src/db/repo.rs b/src/db/repo.rs index 8525bec..bbd4488 100644 --- a/src/db/repo.rs +++ b/src/db/repo.rs @@ -20,7 +20,6 @@ pub struct ManifestRow { pub season: Option, pub episode: Option, pub runtime_sec: f64, - pub video_hash: Option, pub audio_signature: Option>, pub sample_fps: Option, pub extinction_sec: Option, @@ -31,7 +30,7 @@ pub struct ManifestRow { pub content_id: Option, } -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 { 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, episode: Option, runtime_sec: f64, - video_hash: Option<&str>, contributor_id: &str, ) -> anyhow::Result> { 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, pub episode: Option, 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, @@ -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, diff --git a/src/db/schema.sql b/src/db/schema.sql index 755b9dd..d49952b 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -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); diff --git a/src/ingest.rs b/src/ingest.rs index fcd00ab..b42e413 100644 --- a/src/ingest.rs +++ b/src/ingest.rs @@ -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 = valid .actor_scenes_cs .iter() diff --git a/src/matching.rs b/src/matching.rs index 47e2e21..168b3f9 100644 --- a/src/matching.rs +++ b/src/matching.rs @@ -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, - pub video_hash: Option, } 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, } /// 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 { - // 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 { 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( 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()); } } diff --git a/src/model.rs b/src/model.rs index e50b956..5bf32a3 100644 --- a/src/model.rs +++ b/src/model.rs @@ -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, - /// Optional but strongly preferred. OpenSubtitles hash (§3). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub video_hash: Option, /// 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::(json).unwrap_err().to_string(); + assert!(err.contains("video_hash"), "error should name the field: {err}"); + } } diff --git a/src/validate.rs b/src/validate.rs index 5515120..fcf4dbd 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -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. diff --git a/tests/api.rs b/tests/api.rs index 352dc12..c2fadcf 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -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); } diff --git a/tests/federation.rs b/tests/federation.rs index fe07042..37fbfbb 100644 --- a/tests/federation.rs +++ b/tests/federation.rs @@ -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, diff --git a/tests/injection.rs b/tests/injection.rs index a852775..70a3fce 100644 --- a/tests/injection.rs +++ b/tests/injection.rs @@ -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