//! 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub imdb_id: Option, // Episode coordinates. #[serde(default, skip_serializing_if = "Option::is_none")] pub series_tmdb_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub series_imdb_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub season: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub episode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub year: Option, } 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, /// 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, } /// 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, /// 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub pipeline_version: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub gallery_size: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub gallery_scope: Option, } /// 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub imdb_id: Option, /// The **primary** actor join key (§2, §6 stage 3). #[serde(default, skip_serializing_if = "Option::is_none")] pub tmdb_id: Option, /// Presence windows, inclusive and sorted by start. pub scenes: Vec, } /// 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 { 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub route: Option, } /// 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, pub actors: Vec, } /// 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub series_imdb_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, } /// 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, /// Present on responses only; ignored on upload. #[serde(default, skip_serializing_if = "Option::is_none")] pub coverage: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct Coverage { pub episodes_available: usize, pub seasons: Vec, } /// 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::(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::(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::(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::(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::(json).unwrap_err().to_string(); assert!(err.contains("video_hash"), "error should name the field: {err}"); } }