Files
dtourolleandClaude Opus 5 0ff1018bcc
CI / static musl binary (push) Has been skipped
CI / fmt, clippy, test (push) Failing after 2m0s
CI / advisories and licences (push) Successful in 27s
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
2026-07-31 09:52:03 +02:00

399 lines
16 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Jmanifest wire types (§2).
//!
//! **`#[serde(deny_unknown_fields)]` on every struct is the §6 stage 2
//! enforcement mechanism.** "No additional fields anywhere" is a property of
//! these type definitions rather than of validator code that could omit a
//! field, so an unrecognised key at any nesting level fails to parse. That is
//! also what makes the §9 `movie`/`jellyfin_id` strip verifiable: a client that
//! forgets gets a hard `400` naming the field, rather than quietly publishing a
//! contributor's directory layout.
//!
//! Deeper semantic checks — bounds, character classes, path-shaped strings —
//! live in [`crate::validate`]. Parsing rejects *shape*; validation rejects
//! *content*.
use serde::{Deserialize, Serialize};
/// Cut-match tier (§3). Ordered worst-to-best so derived `Ord` ranks them.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MatchTier {
/// No cut information was supplied, so alignment is unknown (§4).
Unknown,
/// Audio 0.60–0.85, or runtimes within ±30s. Caveat in UI.
Loose,
/// Runtimes within ±2s.
Runtime,
/// Audio score ≥ 0.85; the top tier, and content-derived.
Audio,
}
impl MatchTier {
pub fn as_str(self) -> &'static str {
match self {
MatchTier::Unknown => "unknown",
MatchTier::Loose => "loose",
MatchTier::Runtime => "runtime",
MatchTier::Audio => "audio",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum IdentityType {
Movie,
Episode,
}
/// What the work is (§2 terminology: *title identity*).
///
/// Movie and episode coordinates share one struct because `deny_unknown_fields`
/// with `#[serde(untagged)]` alternatives produces unhelpful error messages;
/// the discriminant is checked in [`crate::validate`], which can name the
/// offending field.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Identity {
#[serde(rename = "type")]
pub kind: IdentityType,
// Movie coordinates.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tmdb_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub imdb_id: Option<String>,
// Episode coordinates.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub series_tmdb_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub series_imdb_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub season: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub episode: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub year: Option<i64>,
}
impl Identity {
/// The TMDB id used as the lookup key, whichever coordinate carries it.
pub fn effective_tmdb_id(&self) -> Option<&str> {
match self.kind {
IdentityType::Movie => self.tmdb_id.as_deref(),
IdentityType::Episode => self.series_tmdb_id.as_deref(),
}
}
pub fn effective_imdb_id(&self) -> Option<&str> {
match self.kind {
IdentityType::Movie => self.imdb_id.as_deref(),
IdentityType::Episode => self.series_imdb_id.as_deref(),
}
}
}
/// Which encode/edit the timings apply to (§2 terminology: *cut fingerprint*).
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Cut {
/// **Required** — the decoded duration of the media the timings came from.
/// The primary alignment guard (§2).
pub runtime_sec: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container_duration_sec: Option<f64>,
/// Optional; version-prefixed spectral-peak signature (§3, UR-9).
///
/// Accepted and stored by this build; `audio`-tier matching is enabled once
/// coverage is useful, per §3's recommended sequencing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audio_signature: Option<String>,
}
/// How well the contributor's gallery could discriminate (§2, §7).
///
/// The strongest available quality signal between two otherwise comparable
/// manifests: a `Global` gallery had to distinguish its actors from every other
/// actor in the contributor's library, whereas a `Limited` one only had to
/// distinguish them from this title's own cast.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum GalleryScope {
/// Built from this title's cast alone.
Limited,
/// Built from the whole library. The default upstream.
Global,
}
impl GalleryScope {
pub fn as_str(self) -> &'static str {
match self {
GalleryScope::Limited => "limited",
GalleryScope::Global => "global",
}
}
}
/// Extraction parameters, carried for provenance and ranking.
///
/// Note there is no `anneal_sec`: it was **withdrawn** in the SR-003 schema
/// bump, because presence now follows track extent — a track survives its own
/// gaps, so there is nothing to anneal (`scene-actor-extraction` AR-012/AR-013).
/// `deny_unknown_fields` therefore makes its presence a hard parse error rather
/// than something silently ignored, which is deliberate: a manifest still
/// carrying it was produced by a pipeline whose window semantics differ from
/// what this server now assumes.
/// TRACES: UR-003, UR-011 | SR-004
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Extraction {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sample_fps: Option<f64>,
/// The re-acquisition timeout that shapes window extent. Successor to the
/// withdrawn `anneal_sec`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extinction_sec: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pipeline_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gallery_size: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gallery_scope: Option<GalleryScope>,
}
/// One actor's timeline.
///
/// Note there is no `jellyfin_id` field: `deny_unknown_fields` means its
/// presence is a parse error, which is exactly the §2/§6 requirement that it be
/// *rejected on upload* rather than merely ignored on download.
/// TRACES: UR-010, UR-013 | SR-001, SR-002
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Actor {
/// Sent on upload for matching, but **not persisted** — the server resolves
/// each actor to a TMDB person id and serves names from its own TMDB-derived
/// table (§2, §5a). On download this is server-authoritative.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub imdb_id: Option<String>,
/// The **primary** actor join key (§2, §6 stage 3).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tmdb_id: Option<String>,
/// Presence windows, inclusive and sorted by start.
pub scenes: Vec<Scene>,
}
/// TRACES: UR-017 | SR-003
/// How an actor was identified for a given window (`scene-actor-extraction`
/// AR-017: every presence claim carries its belief and identification route).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Route {
/// Identified while the track was live.
Live,
/// Resolved by the deferred pass, after the final frame was read.
Deferred,
/// Resolved from the per-subject embedding pool.
Pooled,
}
impl Route {
pub fn as_str(self) -> &'static str {
match self {
Route::Live => "live",
Route::Deferred => "deferred",
Route::Pooled => "pooled",
}
}
/// Parses a value read back from storage. Returns `None` for anything
/// unrecognised rather than guessing — a row written by a future schema
/// means something this build does not know, and inventing a route would
/// misreport provenance.
///
/// Deliberately not `FromStr`: that trait is for parsing *input*, and this
/// reads a value the server itself wrote from a closed enum. Keeping them
/// distinct stops a future refactor pointing user input at this path.
pub fn from_stored(s: &str) -> Option<Self> {
match s {
"live" => Some(Route::Live),
"deferred" => Some(Route::Deferred),
"pooled" => Some(Route::Pooled),
_ => None,
}
}
}
/// TRACES: UR-013, UR-017 | SR-002, SR-003
/// One presence window.
///
/// **A window is a claim about scene membership, not a recognition event**
/// (SR-002, UR-013). An actor who turns away or is off-camera during a reverse
/// shot is still present, so the server never merges, splits or trims these —
/// it stores what it was given, quantised but not reshaped.
///
/// `belief` and `route` are **excluded from `content_id`** (§9a). Belief is a
/// producer-side estimate that may legitimately differ between pipeline versions
/// for identical timings, so including it would give two servers different ids
/// for the same content — the exact failure mode §9a quantises centiseconds to
/// avoid. They replicate as attributes, exactly as `audio_signature` does.
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Scene {
pub start: f64,
pub end: f64,
/// Accumulated posterior that justified the claim, in `[0, 1]`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub belief: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<Route>,
}
/// One shareable actor timeline for one cut of one title (§2).
/// TRACES: UR-003, UR-011, UR-014 | SR-003, SR-004
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Jmanifest {
pub jmanifest_version: u32,
pub identity: Identity,
pub cut: Cut,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extraction: Option<Extraction>,
pub actors: Vec<Actor>,
}
/// Series-level coordinates for a bundle envelope (§2).
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SeriesRef {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub series_tmdb_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub series_imdb_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
/// A thin wrapper, not a new format (§2). Bundles are a transfer convenience,
/// never a storage unit — each episode is stored and moderated individually.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SeriesBundle {
pub jmanifest_version: u32,
pub series: SeriesRef,
pub episodes: Vec<Jmanifest>,
/// Present on responses only; ignored on upload.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coverage: Option<Coverage>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Coverage {
pub episodes_available: usize,
pub seasons: Vec<i64>,
}
/// The current `jmanifest_version` this server speaks (§2).
///
/// Bumped to 2 with the SR-003 schema change, in lockstep with the truth file's
/// `schema_version` — breaking changes are batched and ship together across all
/// three repos, so a component moving alone is the defect this coordination
/// exists to prevent.
///
/// **Flag day, not dual-accept** (SR-003, `jRay` JR-003). Version 1 is rejected
/// outright rather than carried alongside: all three components are pre-release,
/// and a v1 read path would be the one nobody exercises, so it is the one that
/// would rot while being dragged through every later change to the reader.
///
/// The two version fields remain *independent by design* — `jmanifest_version`
/// versions the exchange envelope, `schema_version` the truth file — and they
/// coincide at 2 only because this bump touched both.
pub const JMANIFEST_VERSION: u32 = 2;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_field_at_top_level_is_rejected() {
let json = r#"{"jmanifest_version":2,"identity":{"type":"movie","tmdb_id":"1"},
"cut":{"runtime_sec":100.0},"actors":[],"surprise":"x"}"#;
let err = serde_json::from_str::<Jmanifest>(json).unwrap_err().to_string();
assert!(err.contains("surprise"), "error should name the field: {err}");
}
#[test]
fn jellyfin_id_on_an_actor_is_a_parse_error() {
// §2: `actors[].jellyfin_id` must not appear. `deny_unknown_fields`
// makes this structural rather than a validator's responsibility.
let json = r#"{"jmanifest_version":2,"identity":{"type":"movie","tmdb_id":"1"},
"cut":{"runtime_sec":100.0},
"actors":[{"name":"A","tmdb_id":"2","jellyfin_id":"guid","scenes":[]}]}"#;
let err = serde_json::from_str::<Jmanifest>(json).unwrap_err().to_string();
assert!(err.contains("jellyfin_id"), "error should name the field: {err}");
}
#[test]
fn movie_path_field_is_a_parse_error() {
let json = r#"{"jmanifest_version":2,"movie":"/data/movies/x.mkv",
"identity":{"type":"movie","tmdb_id":"1"},
"cut":{"runtime_sec":100.0},"actors":[]}"#;
let err = serde_json::from_str::<Jmanifest>(json).unwrap_err().to_string();
assert!(err.contains("movie"), "error should name the field: {err}");
}
#[test]
fn unknown_field_nested_in_cut_is_rejected() {
let json = r#"{"jmanifest_version":2,"identity":{"type":"movie","tmdb_id":"1"},
"cut":{"runtime_sec":100.0,"payload":"x"},"actors":[]}"#;
assert!(serde_json::from_str::<Jmanifest>(json).is_err());
}
#[test]
fn spec_example_manifest_parses() {
let json = r#"{
"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 },
"extraction": { "sample_fps": 5, "extinction_sec": 12,
"pipeline_version": "scene-actor-extraction 0.4.1",
"gallery_size": 1820, "gallery_scope": "global" },
"actors": [ { "name": "Steve Buscemi", "imdb_id": "nm0000114", "tmdb_id": "884",
"scenes": [
{ "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" },
{ "start": 438.2, "end": 465.6, "belief": 0.81, "route": "deferred" }
] } ]
}"#;
let m: Jmanifest = serde_json::from_str(json).unwrap();
assert_eq!(m.actors.len(), 1);
assert_eq!(m.identity.effective_tmdb_id(), Some("504172"));
}
#[test]
fn tiers_order_audio_above_runtime() {
// §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::Runtime > MatchTier::Loose);
}
#[test]
fn withdrawn_video_hash_is_rejected_as_an_unknown_field() {
// §3: withdrawn, and `deny_unknown_fields` is what enforces it. A
// contributor still sending it gets a `400` naming the field rather
// than having it silently dropped.
let json = r#"{"jmanifest_version":2,"identity":{"type":"movie","tmdb_id":"1"},
"cut":{"runtime_sec":100.0,"video_hash":"opensubtitles:8e245d9679d31e12"},
"actors":[]}"#;
let err = serde_json::from_str::<Jmanifest>(json).unwrap_err().to_string();
assert!(err.contains("video_hash"), "error should name the field: {err}");
}
}