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
399 lines
16 KiB
Rust
399 lines
16 KiB
Rust
//! 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}");
|
||
}
|
||
}
|